sync: update from private repo (dfadcd5f)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-23 06:38:48 +00:00
parent 6a2f2cc736
commit 29ccaf1e92
377 changed files with 31028 additions and 8994 deletions

6
.gitignore vendored
View File

@ -17,6 +17,12 @@ logs/
.worktrees/ .worktrees/
.claude/ .claude/
.playwright-mcp/ .playwright-mcp/
# Playwright E2E artifacts
test-results/
playwright-report/
blob-report/
ui/test-results/
ui/playwright-report/
# Agent skill installs (modern-web-guidance etc. via `skills add`) — local tooling, # Agent skill installs (modern-web-guidance etc. via `skills add`) — local tooling,
# not project source. # not project source.
.agents/ .agents/

177
DESIGN.md Normal file
View File

@ -0,0 +1,177 @@
# DESIGN.md — MAESTRO
> AI エージェントが一貫した日本語 UI を生成するためのデザイン仕様書。
> `AGENTS.md`(作り方)の隣に置く「見た目と雰囲気」の source of truth。
> セクションヘッダーは英語、値の説明は日本語([awesome-design-md-jp](https://github.com/kzhrknt/awesome-design-md-jp) の Stitch 拡張フォーマットに準拠)。
>
> **この文書が視覚判断の正準**`docs/design/README.md` は改名前・ライトモード前提の旧版で、ここで上書きする。
> 値はすべて**ライブのコード**`ui/tailwind.config.js` / `ui/src/index.css`)から採取。色は CSS 変数でライト/ダークを切り替える。
---
## 1. Visual Theme & Atmosphere
- **デザイン方針**: Refero 系の「ミニマル+高密度」。装飾を削り、ハイライン(極細罫)で構造を作る。影に頼らない
- **密度**: 情報密度の高い業務 UI。本文 13px の詰まったレイアウト
- **キーワード**: 静か、無彩色、機能的、罫線主導、操作対象に語りかける(人称を出さない)
- **特徴**: ニアブラック(#18181b)を唯一のブランド差し色にしたほぼ無彩色。ライト/ダーク両対応で、ダークは pitch-black を避けやや持ち上げた zinc 調
- **トーン**: オペレーター向け。感嘆符なし、UI クロムに絵文字を置かない(絵文字はエージェント生成コンテンツ内の限定箇所のみ)
---
## 2. Color Palette & Roles
色は CSS 変数経由。`*-slate-*` クラス1454 箇所)がそのままライト/ダークで反転する。**ブランド色はランタイム branding`/api/branding`)で差し替え可能** — ハードコードしない。
### Brand既定。branding 未設定時のフォールバック)
| Role | Var | 既定値 |
|---|---|---|
| Primary | `--brand-primary` | `#18181b`(ニアブラック) |
| Primary deephover | `--brand-primary-deep` | `#09090b` |
| Primary soft選択/active tint・light | `--brand-primary-soft` | `#f4f4f5` |
| Primary softdark | `--brand-primary-soft` | `#2c2c34` |
| Primary ringfocus | `--brand-primary-ring` | `rgba(24,24,27,0.25)` |
| Primary fg差し色上の文字 | `--brand-primary-fg` | `#ffffff` |
> 旧版の `#2563eb`blue-600はモダン化で**廃止**。新規 UI で青を主役にしない。
**ブランド色の解決順**: `スペース個別の色spaces.brand_color``組織/全体 brandingconfig.yaml / data/branding``既定 #18181b`。スペース(案件)が自分の色を持てる(クライアント別など)。`--brand-primary-fg`(コントラスト文字色)/ `-deep`hover/ `-soft`tint/ `-ring`focusは選択色から導出する。**ハードコードせず必ず `--brand-primary*` 変数を使う**。Space rail のアバター・アクティブ tint も同じ変数で色付く。
### Semantic surfaces
| Role | Var | Light | Dark |
|---|---|---|---|
| Canvas最背面 | `--canvas` | `#ffffff` | `#18181c` |
| Surfaceパネル | `--surface` | `#fafafa` | `#202024` |
| Surface-2入れ子 | `--surface-2` | `#f4f4f5` | `#2a2a30` |
| Hairline罫線 | `--hairline` | `#e4e4e7` | `#3a3a42` |
| Hairline-soft | `--hairline-soft` | `#f4f4f5` | `#2a2a30` |
| Ink本文 | `--ink` | `#0f172a` | `#e7e7ea` |
| Muted補助 | `--muted` | `#64748b` | `#a1a1aa` |
### Neutral ramp`--slate-50…950`
Light = Tailwind 既定 slate`#f8fafc``#020617`。Dark = 反転 zinc 調(`--slate-50 #18181c``--slate-950 #fafafa`)。`50/100` = サーフェス、`700/900` = テキスト、という意味付けで使う。
### Status状態色は Tailwind 既定スケール維持)
`statusTone()``ui/src/lib/utils.ts`が一元定義。running/success=emerald、waiting/retry=amber、subtasks=indigo、failed=red、queued/cancelled=slate。pastel bg deep fg のピル。
---
## 3. Typography Rules
### 3.1 和文フォント
`IBM Plex Sans JP`(主)→ `Hiragino Sans`フォールバック。Google Fonts から読込済み。
### 3.2 欧文・等幅フォント
等幅は `JetBrains Mono`(主)→ `IBM Plex Mono`。識別子・ログ・バージョン・cron 式・SMALL-CAPS マイクロラベルに使う。
### 3.3 font-family 指定(フォールバック込み)
```css
--font-sans: 'IBM Plex Sans JP', 'Hiragino Sans', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
```
### 3.4 文字サイズ・ウェイト階層
- 本文 **13px**`0.8125rem`)— 高密度の基準。タイトル 18px詳細/ 20pxダイアログ、チャット 14px、マイクロラベル 11px`2xs`/ 10px
- ウェイトは重め。700(bold) が主役ボタン・ピル・チップ・微小ラベル、800(extrabold) はタイトルと主 CTA、本文のみ 400/500
- モバイル入力は `font-size: 16px !important`iOS 自動ズーム防止)
### 3.5 行間・字間
- 本文 `line-height: 1.55`、見出し/詰めたい所 `1.3`、汎用 `1.5`
- **CJK 長文は 1.61.8 を推奨**(和文は欧文より行間を広く)
- マイクロ大文字ラベルは `letter-spacing: 0.04em`。和文本文に欧文的なベタ詰めをしない
### 3.6 禁則処理・改行ルール
- 禁則はブラウザ既定CJK 句読点・閉じ括弧の行頭禁則)を尊重
- 長い識別子・URL は `overflow-wrap: anywhere` で折る。和文本文は `word-break: normal`(無理な分割をしない)
- 数字+単位は分離させない: `<b>N</b> 件` / `N分前`(相対時刻は和文 `たった今 / N分前 / N時間前 / N日前`
### 3.7 OpenType 機能
- 和文プロポーショナル詰めが要る見出しに `font-feature-settings: "palt" 1`、カーニング `"kern" 1`
- 等幅JetBrains Mono`font-feature-settings: 'cv11','ss01','ss03'`(コード可読性向けの既存設定)
### 3.8 縦書き
非対応(業務 UI のため横書き固定)。
---
## 4. Component Stylings
実体は `ui/src/components/**`。新規コンポーネントは下記レシピを踏襲する。
- **Card / Panel**: `bg-canvas`(入れ子は `bg-surface`)、`border border-hairline``rounded-md`6px`shadow-sm`。padding `p-3``p-4`
- **Primary button**: ブランド差し色塗り(`bg-accent text-accent-fg`、hover で deep。padding `8px 16px``rounded-md`、700
- **Ghost / secondary**: 透明 → hover `bg-surface-2`。罫線つきは `border-hairline`
- **Input**: `bg-canvas border-hairline rounded-md`。focus は `ring-2` `--brand-primary-ring`
- **TabsTopBar**: 下線タブ。active は下線+ ink、非 active は muted`components/layout/TopBar.tsx`
- **Pill / Chip / StatusBadge**: `rounded-full`、pastel bg deep fg、700。`statusTone()` 準拠
- **List item**: 入れ子ミニカード。active = `--brand-primary-soft` tint 罫線昇格
- **Chat bubble**: user = 差し色塗り `rounded-2xl rounded-br-md`、agent result = emerald 系、ASK = amber 系(`ChatMessage`
- **Dialog**: 中央、`max-width: min(860px, 92vw)``max-height: 88dvh`、内部スクロール、`shadow-md`、overlay は straight alphablur 無し)
### 4.1 Space & Workspace surfaces本スペックの新面
- **Space rail / list**: 左に縦並び。**個人スペースを先頭固定**、案件スペースが続く。各行は list-item レシピ。active = soft tint。可視性private/org/publicを小さなマイクロラベルで示す
- **Space detail**: 4レーン資料 / 議事録 / 予定 / チャット)の俯瞰。タブピル or セクションで切替。土台で実体があるのは「チャット」「ファイル」。未実装レーンは**ダミーを出さず**、機能完成時に追加(個人の永続ワークスペースは土台から表示)
- **File 窓**: ツリー(`FileBrowser.tsx` プレビュー/編集(`FilePreview.tsx`)。`border-hairline` で 2 ペイン分割、ヘッダは `sticky bg-surface`。HTML アプリ実行は `<iframe sandbox>` CSP で隔離DESIGN は枠のみ規定、実行境界はスペック §2.7
- **Task creation永続/一時的)**: `CreateTaskDialog` 内に **segmented control**2値トグル。既定は「永続」。差し色は active セグメントのみ、ghost で非 active。ラベルは和文「永続ワークスペース」/「一時的」)
---
## 5. Layout Principles
- 固定 TopBar`bg-canvas`、下罫線 `border-hairline`)。本体は残り `h-dvh` を埋め、root は `overflow-hidden`(各ペインが自前スクロール)
- 2カラム list+detail を全機能で統一(タスク/スケジュール/ユーザー/スペース)。編集は詳細パネル内インライン、モーダルは新規作成のみ
- Tailwind 4px スケール。パネル間ガター `8px`、カード padding `16px`
- トーストは上中央スライドイン、ダイアログは中央
---
## 6. Depth & Elevation
- **罫線主導の階層**。影は補助。`shadow-sm`resting/ `shadow-md`ダイアログの2段のみ。色付き影・内側影・グロー無し
- ダークは pitch-black を避け、canvas→surface→surface-2 を一段ずつ持ち上げて分離。罫線 `--hairline` で境界を担保(実機で WCAG AA を確認)
- グラスモーフィズム/backdrop-blur は使わない。overlay は `bg-slate-900/50` の straight alpha
---
## 7. Do's and Don'ts
**Do**
- ブランド色は CSS 変数(`--brand-primary*`)経由。ランタイム branding を尊重
- アイコンは Lucide 系の 2px ストローク・round cap・24×24・inline SVG
- 角丸は既定 `rounded-md`(6px)。ピル/アバターは `rounded-full`
- 絵文字は既存の限定箇所のみ(👍/👎 フィードバック、📋 チェックリスト)
**Don't**
- ❌ 青(#2563eb)を主役に戻す / ブランド色をハードコード
- ❌ グラデーション、任意 z-index、ランプ外のカスタム影、glassmorphism
- ❌ Heroicons solid / Material / Phosphor線幅が崩れる
- ❌ 角丸を 12px 以上に戻す(モダン化で 6px 基準に詰めた)
- ❌ 和文本文に欧文的ベタ詰め・狭い行間
---
## 8. Responsive Behavior
- **モバイル**: 単一カラム。入力 `font-size:16px`(ズーム防止)
- **タブレット**: list + chat
- **デスクトップ**: list + chat + detail の3カラム`clamp(240px,22vw,280px)` / flex / `clamp(280px,26vw,440px)`
- Space rail はモバイルでドロワー化、デスクトップで常設
---
## 9. Agent Prompt Guide
エージェントが**日本語 HTML アプリ/成果物**を生成するときは、この DESIGN.md を読み、次を守る:
1. 色は本書の semantic 変数か近似 hex を使い、ニアブラック差し色+無彩色を基調に。青を主役にしない
2. 和文は `IBM Plex Sans JP``Hiragino Sans`、行間 1.61.8、長 URL は `overflow-wrap:anywhere`、句読点の行頭禁則を尊重
3. 角丸 6px、罫線主導、影は最小、グラデ/グラス無し
4. アイコンは Lucide 系 2px ストローク。絵文字を UI クロムに使わない
5. ダーク対応が要るなら canvas/surface/hairline の3段で分離し、pitch-black を避ける
> 迷ったら「静かで、罫線で構造を作る、無彩色の業務 UI」に倒す。

View File

@ -21,7 +21,11 @@ config_version: 2
# ─── LLM ───────────────────────────────────────────────────── # ─── LLM ─────────────────────────────────────────────────────
# ジョブ実行時に LLM 呼び出し先として使う接続群と、retry / timeout / metrics。 # ジョブ実行時に LLM 呼び出し先として使う接続群と、retry / timeout / metrics。
llm: llm:
timeout_minutes: 10 # 1 リクエスト全体の上限 (分)。default 10 timeout_minutes: 10 # アイドルタイムアウト (分)。チャンク受信ごとにリセットされる。default 10
# max_stream_minutes: 20 # 1 LLM 呼び出しの総時間ハードキャップ (分)。timeout_minutes と違い
# チャンクが届いてもリセットしない。停止トークンを出さずにトークンを
# 吐き続ける暴走 (反復生成など) を確実に打ち切る。未指定なら
# timeout_minutes の2倍。0 で無効 (非推奨: 暴走時に中断するまでハングする)
retry: retry:
max_attempts: 3 max_attempts: 3
@ -120,6 +124,12 @@ llm:
# upstream_timeout_sec: 30 # 各 upstream fetch の TTFB 上限 # upstream_timeout_sec: 30 # 各 upstream fetch の TTFB 上限
# shutdown_graceful_sec: 30 # SIGTERM 後、in-flight SSE の drain 上限秒 # shutdown_graceful_sec: 30 # SIGTERM 後、in-flight SSE の drain 上限秒
# #
# # この AAO 自身が自分の gateway を db virtual key で叩く構成のときだけ指定。
# # ここに挙げた team の下流リクエストは「使用量」ダッシュボードの時間粒度集計
# # (source=gateway_downstream) に二重計上しない (自分の分は source=gateway で
# # 既に記録済み)。判定は認証済み team で行うのでクライアントからは偽装不可。
# # internal_teams: [orchestrator]
#
# backends: # backends:
# # role ベース routing: worker はジョブの role (auto/fast/quality/reflection) # # role ベース routing: worker はジョブの role (auto/fast/quality/reflection)
# # を routing key として送り、Gateway はその role を担う最も空いている # # を routing key として送り、Gateway はその role を担う最も空いている
@ -164,7 +174,7 @@ llm:
storage: storage:
worktree_dir: ./data/workspaces # ジョブ実行時の作業ディレクトリのベース worktree_dir: ./data/workspaces # ジョブ実行時の作業ディレクトリのベース
# custom_pieces_dir: ./custom-pieces # リポジトリ内の pieces/ に加えて読みに行く Piece dir (任意) # custom_pieces_dir: ./custom-pieces # リポジトリ内の pieces/ に加えて読みに行く Piece dir (任意)
user_folder_root: ./data/users # {root}/{userId}/ 配下に AGENTS.md/browser-macros/notes 等を保存 user_folder_root: ./data/users # {root}/{userId}/ 配下に AGENTS.md/browser-macros 等を保存
task_upload_max_size_mb: 50 # /api/local/tasks と /comments の body 上限 (MB) task_upload_max_size_mb: 50 # /api/local/tasks と /comments の body 上限 (MB)
# base64 で乗るので実ファイル目安は値 × 0.75。範囲 [1, 1000] # base64 で乗るので実ファイル目安は値 × 0.75。範囲 [1, 1000]
trash_retention_days: 30 # data/users/{userId}/trash/ の自動 sweep (起動時 + 24h 周期) trash_retention_days: 30 # data/users/{userId}/trash/ の自動 sweep (起動時 + 24h 周期)
@ -203,6 +213,10 @@ subtasks:
# max_iterations: 200 # 1 movement 内の最大イテレーション # max_iterations: 200 # 1 movement 内の最大イテレーション
# max_revisits: 3 # 同一 movement の最大再訪問 # max_revisits: 3 # 同一 movement の最大再訪問
# max_tool_loop_repeats: 5 # 全く同じツール呼び出し(名前+引数)を連続で繰り返した回数がこの値に達したらループとみなし強制中断 (2以上) # max_tool_loop_repeats: 5 # 全く同じツール呼び出し(名前+引数)を連続で繰り返した回数がこの値に達したらループとみなし強制中断 (2以上)
# max_job_minutes: 60 # 1 ジョブの実行時間ハードデッドライン (分)。これを超えたジョブは
# 強制 abort し、占有していた同時実行スロットを解放する (プロセス
# 再起動不要)。サブタスク待機に入るジョブは即座にスロットを返すため
# これに達するのは真のハングのみ。default 60。0 で無効
# prompt_guard_ratio: 0.8 # コンテキスト上限の何 % まで prompt を許容するか (0.50.95) # prompt_guard_ratio: 0.8 # コンテキスト上限の何 % まで prompt を許容するか (0.50.95)
# history_summarization: # 古い turn を構造化要約に置換して粘る (Opencode 方式) # history_summarization: # 古い turn を構造化要約に置換して粘る (Opencode 方式)
# enabled: true # default true # enabled: true # default true
@ -246,9 +260,9 @@ subtasks:
# channel: chrome # 'chromium' (default) / 'chrome' / 'msedge' # channel: chrome # 'chromium' (default) / 'chrome' / 'msedge'
# executable_path: /usr/bin/google-chrome # channel と排他 # executable_path: /usr/bin/google-chrome # channel と排他
# ─── Tools (Web & Search / Media / External / Legacy) ──────── # ─── Tools (Web & Search / Media / External) ────────────────
# UI 上は 5 カテゴリに分かれて編集可能 (Web & Search / Browser Runtime / # UI 上は 4 カテゴリに分かれて編集可能 (Web & Search / Browser Runtime /
# Media & Documents / External Services / Legacy Knowledge)。YAML は # Media & Documents / External Services)。YAML は
# 互換のため `tools` 1 ブロックで管理。 # 互換のため `tools` 1 ブロックで管理。
tools: tools:
# Web & Search # Web & Search
@ -296,22 +310,6 @@ tools:
# - alice-id # - alice-id
# - bob-id # - bob-id
# Legacy Knowledge (DKS) — 新規 namespace 追加は MCP 経由を推奨
# knowledge_service_url: http://dks-server:8100 # 未設定で knowledge ツール無効
# knowledge_namespaces:
# product-a-support:
# api_key: "sk-product-a-xxx"
# contract-review:
# api_key: "sk-contract-yyy"
# ── Shared Knowledge Notes ───────────────────────────────────
# data/users/{userId}/notes/ のノートをシステムプロンプトに自動注入する設定。
# notes:
# inject:
# per_note_max_kb: 8 # 日本語コンテンツは 4 推奨
# total_max_kb: 32
# over_budget_strategy: skip_remaining # truncate_last / skip_remaining (default) / degrade_to_search
# ─── サーバー TLS (オプション) ─────────────────────────────── # ─── サーバー TLS (オプション) ───────────────────────────────
# 【フレッシュインストール】setup.sh が server.tls.enabled: true を自動書き込む。 # 【フレッシュインストール】setup.sh が server.tls.enabled: true を自動書き込む。
# 【アップグレード】server ブロック未記載の場合は false のまま(既存デプロイを壊さない)。 # 【アップグレード】server ブロック未記載の場合は false のまま(既存デプロイを壊さない)。
@ -331,6 +329,10 @@ tools:
# http_redirect_port: 9080 # HTTP→HTTPS リダイレクトの待ち受けポート。単一値か配列で複数指定可 (例: [80, 9876])。 # http_redirect_port: 9080 # HTTP→HTTPS リダイレクトの待ち受けポート。単一値か配列で複数指定可 (例: [80, 9876])。
# # 各ポートは HTTPS ポートと異なる必要があり、1024 未満 (80 等) の bind には権限が必要 # # 各ポートは HTTPS ポートと異なる必要があり、1024 未満 (80 等) の bind には権限が必要
# redirect_host: null # リダイレクト先のホスト; null かつワイルドカード bind の場合はリクエストの Host ヘッダから導出 # redirect_host: null # リダイレクト先のホスト; null かつワイルドカード bind の場合はリクエストの Host ヘッダから導出
# hsts: false # 既定 false。HSTS (Strict-Transport-Security) を送るかどうか。
# # true にするとブラウザを HTTPS に固定し、後からオフにしても解除できなくなる
# # (自己署名証明書では害だけ大きい)。正式な証明書がある場合のみ true。
# # false の間は TLS 応答で max-age=0 を送り、過去に固定されたブラウザを解除する
# # リバースプロキシ構成: このブロックを省略するか enabled: false のままにすること。 # # リバースプロキシ構成: このブロックを省略するか enabled: false のままにすること。
# # プロキシが TLS を終端している場合に native TLS を有効にすると二重終端になり壊れる。 # # プロキシが TLS を終端している場合に native TLS を有効にすると二重終端になり壊れる。
# # noVNC / SSH コンソールを wss で使う場合は「信頼済み証明書」が必要 # # noVNC / SSH コンソールを wss で使う場合は「信頼済み証明書」が必要

View File

@ -0,0 +1,5 @@
{
"title": "ファイル・ノート",
"description": "output/ のファイルを一覧・閲覧し、メモを output/note.md に保存するサンプルアプリ。",
"entry": "index.html"
}

View File

@ -0,0 +1,172 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ファイル・ノート</title>
<style>
/* 自己完結: 外部 CSS/フォント禁止CSP で遮断される)。インラインのみ。 */
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 16px;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.5; color: #1e293b; background: #f8fafc;
}
h1 { font-size: 16px; margin: 0 0 12px; }
.row { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
.panel { flex: 1 1 280px; min-width: 240px; }
.card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; }
ul { list-style: none; margin: 0; padding: 0; max-height: 220px; overflow: auto; }
li { margin: 0; }
.file-btn {
width: 100%; text-align: left; border: 0; background: transparent;
padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 13px; color: #334155;
}
.file-btn:hover { background: #f1f5f9; }
.file-btn[aria-current="true"] { background: #e0e7ff; font-weight: 600; }
pre {
margin: 8px 0 0; padding: 8px; background: #f1f5f9; border-radius: 6px;
font-size: 12px; max-height: 200px; overflow: auto; white-space: pre-wrap; word-break: break-word;
}
textarea {
width: 100%; min-height: 120px; padding: 8px; border: 1px solid #cbd5e1;
border-radius: 6px; font-family: inherit; font-size: 13px; resize: vertical;
}
button.primary {
margin-top: 8px; border: 0; background: #4f46e5; color: #fff; font-weight: 700;
padding: 8px 14px; border-radius: 6px; cursor: pointer; font-size: 13px;
}
button.primary:disabled { opacity: .5; cursor: default; }
.muted { color: #64748b; font-size: 12px; }
.status { margin-top: 8px; font-size: 12px; min-height: 1.2em; }
.status.ok { color: #047857; }
.status.err { color: #dc2626; }
</style>
</head>
<body>
<h1>ファイル・ノート</h1>
<p class="muted">このワークスペースの <code>output/</code> を一覧・閲覧し、メモを <code>output/note.md</code> に保存します。</p>
<div class="row">
<div class="panel">
<div class="card">
<strong style="font-size:13px;">output/ のファイル</strong>
<ul id="files"><li class="muted" style="padding:6px 8px;">読み込み中…</li></ul>
</div>
</div>
<div class="panel">
<div class="card">
<strong style="font-size:13px;">プレビュー</strong>
<pre id="preview" class="muted">左の一覧からファイルを選んでください。</pre>
</div>
<div class="card" style="margin-top:12px;">
<strong style="font-size:13px;">メモoutput/note.md に保存)</strong>
<textarea id="note" placeholder="ここにメモを書いて「保存」を押すと output/note.md に保存されます。"></textarea>
<button id="save" class="primary">保存</button>
<div id="status" class="status"></div>
</div>
</div>
</div>
<script>
// ── postMessage ブリッジ(自己完結 / 外部ネットワーク無し)─────────────────
// 親 (AppRunner) が、ユーザーのセッションでファイル I/O を代理する。
// 詳細仕様: docs/workspace-apps-bridge.md
let seq = 0;
const pending = new Map();
function call(req) {
return new Promise((resolve, reject) => {
const id = ++seq;
pending.set(id, { resolve, reject });
window.parent.postMessage({ ...req, id }, '*');
});
}
window.addEventListener('message', (e) => {
const r = e.data;
const p = r && pending.get(r.id);
if (!p) return;
pending.delete(r.id);
r.ok ? p.resolve(r.data) : p.reject(new Error(r.error || 'request failed'));
});
const $files = document.getElementById('files');
const $preview = document.getElementById('preview');
const $note = document.getElementById('note');
const $save = document.getElementById('save');
const $status = document.getElementById('status');
function setStatus(msg, kind) {
$status.textContent = msg;
$status.className = 'status' + (kind ? ' ' + kind : '');
}
async function loadFiles() {
try {
const { entries } = await call({ type: 'listFiles', dir: 'output' });
const files = (entries || []).filter((e) => e.kind !== 'directory');
$files.innerHTML = '';
if (files.length === 0) {
$files.innerHTML = '<li class="muted" style="padding:6px 8px;">output/ は空です。</li>';
return;
}
for (const f of files) {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.className = 'file-btn';
btn.textContent = f.name;
btn.onclick = () => selectFile(f, btn);
li.appendChild(btn);
$files.appendChild(li);
}
} catch (err) {
// output/ が無い等。空表示にして致命化しない。
$files.innerHTML = '<li class="muted" style="padding:6px 8px;">output/ がまだありません。</li>';
}
}
async function selectFile(file, btn) {
for (const b of $files.querySelectorAll('.file-btn')) b.removeAttribute('aria-current');
btn.setAttribute('aria-current', 'true');
$preview.className = 'muted';
$preview.textContent = '読み込み中…';
try {
const { content } = await call({ type: 'readFile', path: 'output/' + file.name });
$preview.className = '';
$preview.textContent = content.length > 4000 ? content.slice(0, 4000) + '\n…省略' : content;
} catch (err) {
$preview.className = 'muted';
$preview.textContent = '読み込めませんでした(テキストファイルのみ対応)。';
}
}
// 既存の note.md があれば編集欄に読み込む(無ければ空のまま)。
async function loadExistingNote() {
try {
const { content } = await call({ type: 'readFile', path: 'output/note.md' });
$note.value = content;
} catch {
/* 無ければ何もしない */
}
}
$save.onclick = async () => {
$save.disabled = true;
setStatus('保存中…');
try {
// output/ への書き込みは確認不要(親のサイレント許可ポリシー)。
await call({ type: 'writeFile', path: 'output/note.md', content: $note.value });
setStatus('output/note.md に保存しました。', 'ok');
await loadFiles();
} catch (err) {
setStatus('保存に失敗しました: ' + err.message, 'err');
} finally {
$save.disabled = false;
}
};
loadFiles();
loadExistingNote();
</script>
</body>
</html>

View File

@ -134,6 +134,19 @@ docker compose logs | grep "setup token"
認証を有効にするまでは信頼できないネットワークへ公開しないこと。外部公開時は TLS 認証を有効にするまでは信頼できないネットワークへ公開しないこと。外部公開時は TLS
対応のリバースプロキシも使用する。運用上の注意は [../SECURITY.md](../SECURITY.md) を参照。 対応のリバースプロキシも使用する。運用上の注意は [../SECURITY.md](../SECURITY.md) を参照。
### ワークスペースを共有する(招待リンク)
認証を有効にすると、案件ワークスペースを複数ユーザーで共有できる。メンバー追加のピッカーは
プライバシー保護のため同じ組織のユーザーしか出ないため、組織を持たないユーザー(例: 管理者が
ローカル組織を割り当てていない Google ログインユーザー)はピッカーが空になることがある。
その場合は **招待リンク** を使う。オーナーまたは管理者がワークスペースの **設定 → メンバー**
リンクを発行し、付与する役割(編集者 / 閲覧者)と有効期限(無期限 / 7日 / 30日を選ぶ。リンクを
受け取ったログインユーザーは `/ui/invite/<トークン>` を開いて参加する。リンクで付与できるのは
編集者・閲覧者のみ(オーナー権限は付与不可)。発行・無効化はオーナー / 管理者だけが行える。
ワークスペースごとに有効なリンクは1本で、再生成すると旧リンクは即座に失効する。無効・期限切れの
リンクはワークスペース情報を一切返さない。認証なしモードでは招待リンクは無効。
## 8. Bash サンドボックスを有効にする(任意・マルチユーザー推奨) ## 8. Bash サンドボックスを有効にする(任意・マルチユーザー推奨)
エージェントの Bash 実行をタスク単位で隔離する。本番では: エージェントの Bash 実行をタスク単位で隔離する。本番では:

View File

@ -134,6 +134,21 @@ By default it runs without authentication. To use Google / Gitea OAuth, configur
Do not expose it to an untrusted network until authentication is enabled. When exposing it externally, also use a TLS-enabled Do not expose it to an untrusted network until authentication is enabled. When exposing it externally, also use a TLS-enabled
reverse proxy. For operational caveats, see [../SECURITY.md](../SECURITY.md). reverse proxy. For operational caveats, see [../SECURITY.md](../SECURITY.md).
### Share a workspace (invite links)
With authentication enabled, a case workspace can be shared with several users. The
member picker only lists users in the same organization (to avoid leaking the user
list), so a user with no organization — for example a Google login the admin has not
assigned a local org to — may see an empty picker.
For that case, use an **invite link**. An owner or admin generates the link from the
workspace's **Settings → Members** and picks the granted role (editor / viewer) and an
optional expiry (never / 7 days / 30 days). Any logged-in user who has the link joins by
opening `/ui/invite/<token>`. Links can grant editor or viewer only (never owner), and
only an owner or admin can create or revoke them. Each workspace has at most one active
link; regenerating it immediately invalidates the old one, and an invalid or expired link
returns no workspace information. Invite links are disabled in no-auth mode.
## 8. Enable the Bash sandbox (optional, recommended for multi-user) ## 8. Enable the Bash sandbox (optional, recommended for multi-user)
Isolates the agent's Bash execution per task. In production: Isolates the agent's Bash execution per task. In production:

View File

@ -117,6 +117,22 @@ grep -A 20 'LEGACY_SECTION_REDIRECT' ui/src/components/settings/SettingsSidebar.
--- ---
## 4-C. TopBar のトップレベルタブ (page) を追加・削除した場合
`PageId` は複数ファイルに分散しているので、追加・削除のどちらでも下記をすべて触る。
**対象ファイル (すべて必須):**
- `ui/src/lib/urlState.ts``PAGES` 配列 (PageId の唯一の定義源)
- `ui/src/components/layout/TopBar.tsx``NAV_ITEMS` (タブ表示順 + adminOnly / requiresAuth)
- `ui/src/components/layout/NavDrawer.tsx``NAV_ICONS``Record<PageId, ReactNode>` の網羅型。**PageId を削除したら同名キーも消さないと TS が excess-property で落ちる**
- `ui/src/App.tsx``{page === '...' && <... />}` のレンダー分岐 (+ 不要になった import の削除)
- 外部からそのページへ飛ばす導線 (`url.searchParams.set('page', '...')` 等) の付け替え
- i18n `layout.nav.<id>` (`ui/src/i18n/locales/{en,ja}/layout.json`)
**履歴 (2026-06-19):** 旧「ユーザーフォルダ」(`userfolder`) タブは廃止。中身は個人ワークスペースの **設定** タブ (AGENTS.md / メモリ / Pieces / スキル / MCP / SSH / ブラウザ。`SpaceSettings.tsx`) に集約し、孤立していた **Pets** はグローバル設定の `pets` セクション (`settings/PetsForm.tsx`) へ移設した。`userfolder/` 配下のパネル (AgentsMdPanel / MemoryPanel / McpPanel / SshConnectionsPanel / SkillsPanel / AddBrowserSessionDialog / MonacoFileEditor 等) は SpaceSettings / SpaceBrowserPanel / 設定フォームが再利用するため残置。
---
## 5. SSRF 保護に関わる変更をした場合 ## 5. SSRF 保護に関わる変更をした場合
**対象ファイル:** **対象ファイル:**
@ -138,7 +154,7 @@ grep -A 20 'LEGACY_SECTION_REDIRECT' ui/src/components/settings/SettingsSidebar.
**なぜ必要か:** **なぜ必要か:**
新ツールを追加したとき、`RAW_SAVE_TOOLS` への追加を忘れると `logs/raw/` に出力が保存されない。 新ツールを追加したとき、`RAW_SAVE_TOOLS` への追加を忘れると `logs/raw/` に出力が保存されない。
knowledge ツールのように独自で raw 保存する場合は `RAW_SAVE_TOOLS` に含めない(二重保存回避)。 ツール側で独自に raw 保存する場合は `RAW_SAVE_TOOLS` に含めない(二重保存回避)。
**保存先の整理:** **保存先の整理:**
@ -147,7 +163,6 @@ knowledge ツールのように独自で raw 保存する場合は `RAW_SAVE_TOO
| 生データ | `logs/raw/{tool}-{timestamp}.txt/.json` | ツール実行結果の生出力 | | 生データ | `logs/raw/{tool}-{timestamp}.txt/.json` | ツール実行結果の生出力 |
| WebFetch 履歴 | `logs/webfetch-history.jsonl` | URL, ステータス, サイズ | | WebFetch 履歴 | `logs/webfetch-history.jsonl` | URL, ステータス, サイズ |
| ダウンロード履歴 | `logs/downloads.jsonl` | 保存パス, サイズ | | ダウンロード履歴 | `logs/downloads.jsonl` | 保存パス, サイズ |
| ナレッジ履歴 | `logs/knowledge-history.jsonl` | クエリ, ヒット数, 所要時間 |
| チェックリスト | `logs/checklists/{name}.json` | チェックリスト状態 | | チェックリスト | `logs/checklists/{name}.json` | チェックリスト状態 |
| rawdata インデックス | `logs/rawdata-history.jsonl` | raw 保存のメタデータ | | rawdata インデックス | `logs/rawdata-history.jsonl` | raw 保存のメタデータ |
@ -491,45 +506,13 @@ grep -rn "/continue\b\|continueTaskWithPiece" src/scheduler.ts src/bridge/schedu
--- ---
## 14. Knowledge Notes 追加・変更時 ## 14. Knowledge Notes 機能 (削除済み)
Knowledge Notes は `data/users/{userId}/notes/` 以下のマークダウンファイルで管理される共有ナレッジ機能。 共有 Markdown ノート + 購読 + `SearchNotes` / `ReadNote` / `WriteNote` ツールからなる
FM (frontmatter) の `visibility` / `scope_org_id` で公開範囲を制御し、他ユーザーが検索・購読・inject できる。 Knowledge Notes 機能は 2026-06 に削除された (共有ワークスペースと役割が重複したため)。
`src/notes/``src/bridge/notes-api.ts``src/engine/notes-inject.ts``NotesForm.tsx`
**対象ファイル:** `note_index` / `note_subscriptions` / `pending_reindex` テーブルはすべて撤去済み。
- `src/db/schema.sql``src/db/migrate.ts` — dual path で両方更新 (知識テーブル追加時) 既存 DB に残る孤立テーブルは無害なので DROP していない。再導入の予定はない。
- `src/engine/tools/index.ts` — 新ツールの dynamic import
- `pieces/*.yaml``allowed_tools` に必要なツール名を追加
- `src/engine/tools/docs.ts``TOOL_DOC_ALIASES` — 関連ツールを同じ doc にまップ
- `ui/src/components/userfolder/FileTree.tsx``FILE_SUBDIRS` — サイドバーに新ディレクトリを追加
- `config.yaml.example``notes.inject` セクションのサンプル設定を更新
**セキュリティ・可視性チェックリスト:**
- [ ] `src/db/schema.sql``src/db/migrate.ts` の両方を更新 (dual path)
- [ ] 新ツールを `src/engine/tools/index.ts` の dynamic import に追加
- [ ] `pieces/*.yaml``allowed_tools` に必要なツール名を追加
- [ ] `src/engine/tools/docs.ts``TOOL_DOC_ALIASES` にエイリアスを追加
- [ ] `ui/src/components/userfolder/FileTree.tsx``FILE_SUBDIRS` を更新
- [ ] `config.yaml.example``notes.inject` セクションを更新
- [ ] FM の `visibility=org` チェックは publisher の所属 org で行う (`getUserOrgIds`)
- [ ] inject 注入経路 (`agent-loop.ts` `buildSystemPrompt`) は visibility WHERE を必ず通る
- [ ] cross-user read は `audit_log` に記録される
**なぜ必要か:**
`visibility=org` の org チェックを publisher 側の org で行わないと、異なる org のユーザーが他 org のートを閲覧できる。inject 経路で visibility チェックを省略すると、private なートがシステムプロンプト経由で漏洩する。cross-user read の監査記録が欠けると、後からアクセス追跡ができなくなる。
**確認方法:**
```bash
# visibility WHERE が inject 経路にあるか
grep -n "visibility\|getUserOrgIds" src/engine/agent-loop.ts
# audit_log への記録が行われているか
grep -n "audit_log\|read_note" src/engine/tools/knowledge.ts
# SearchNotes / ListNotes ツールが tools-api.ts に登録されているか
grep -n "knowledge\|notes" src/bridge/tools-api.ts
```
--- ---

View File

@ -47,7 +47,7 @@
| Office ファイル読み込み | ReadPdf / ReadExcel / ReadDocx / ReadPPTX | | Office ファイル読み込み | ReadPdf / ReadExcel / ReadDocx / ReadPPTX |
| 音声書き起こし | TranscribeAudio | | 音声書き起こし | TranscribeAudio |
| データベース | SQLite | | データベース | SQLite |
| 検索 | WebSearch / SearchKnowledge | | 検索 | WebSearch / WebFetch |
## サンドボックス機構 (`safety.bash_sandbox`) ## サンドボックス機構 (`safety.bash_sandbox`)

View File

@ -282,6 +282,16 @@ BrowseWithSession({
ダウンロードを認証付きで行いたい場合は、Browser Sessions 機能で対象サイトのログインセッションを保存し、タスクで bind した状態で BrowseWeb を呼ぶこと。 ダウンロードを認証付きで行いたい場合は、Browser Sessions 機能で対象サイトのログインセッションを保存し、タスクで bind した状態で BrowseWeb を呼ぶこと。
## ソースライブラリへの蓄積(スペースタスク)
スペースのタスクで BrowseWeb がページ本文(スナップショット / セレクタ抽出 / dumpHtml 等の抽出テキスト)を取得すると、その本文が Markdown として `source/` に保存され、出典メタURL・取得日時・サイズ・`tool: "BrowseWeb"`)が `source/index.jsonl` に追記される。WebFetch / DownloadFile と同じソースライブラリに合流するので、「そのスペースのソース(資料)」が自動で溜まり、後から追加調査やグラウンディングに使える。
- 同一 URL は重複保存しない(既に index にあればスキップ)。同じ URL を 2 回閲覧しても 1 件のまま
- スペース文脈でないタスクephemeral / gitea issue 等)では従来どおり蓄積しない(`ctx.spaceId` が無いとき)
- 空・エラー結果、`http(s)` 以外(`file://` 等)の URL はソース化しない
- スクリーンショットはソースではないので `output/` に残り、`source/` には入らない(ソースはあくまでテキスト)
- これは純粋な追記サイドライトで、BrowseWeb の戻り値・スクショ・`logs/` 出力は一切変わらない
## SSRF 保護 ## SSRF 保護
ローカル/プライベート IP127.x.x.x, 10.x.x.x, 172.16-31.x.x, 192.168.x.x, ::1, fc00::/7 等へのアクセスはデフォルトでブロックされる。社内ホストへアクセスする必要がある場合は、Settings UI の「SSRF Allowed Hosts」に追加する。 ローカル/プライベート IP127.x.x.x, 10.x.x.x, 172.16-31.x.x, 192.168.x.x, ::1, fc00::/7 等へのアクセスはデフォルトでブロックされる。社内ホストへアクセスする必要がある場合は、Settings UI の「SSRF Allowed Hosts」に追加する。

45
docs/tools/calendar.md Normal file
View File

@ -0,0 +1,45 @@
# カレンダーツールAddCalendarEvent / ListCalendarEvents
スペース別カレンダーに「予定」を読み書きするツール。予定は受動的なエントリで、
cron の定期タスク(`scheduled_tasks`)とは別概念(予定は実行しない)。
予定は実行中タスクが属するスペース(`ctx.spaceId`)のカレンダーに記録される。
スペースに属していないタスク(旧 local タスク)では使えず、明確なエラーを返す。
## AddCalendarEvent
実行中タスクのスペースのカレンダーに予定を1件追加する。
`created_by` は常に `agent``source_task_id` には実行中タスク ID が記録される。
引数:
- `title`(必須): 予定のタイトル
- `date`(必須): 開始日 `YYYY-MM-DD`(ローカル日付)
- `end_date`(任意): 終了日 `YYYY-MM-DD`。複数日にまたがる予定のとき指定。省略すると単日。`date` より前は不可
- `time`(任意): `HH:MM`。省略すると終日扱い
- `description`(任意): 補足説明
例:
```
AddCalendarEvent({ title: "定例MTG", date: "2026-07-01", time: "10:00" })
AddCalendarEvent({ title: "提出締切", date: "2026-07-10" }) // 終日
AddCalendarEvent({ title: "現地出張", date: "2026-07-10", end_date: "2026-07-12" }) // 複数日
```
使いどころ: ユーザーが「○日に△△を予定に入れて」と依頼したとき、調査の結果として
期限や予定日が判明したとき。日付は必ずユーザーのローカル暦日で指定する。
## ListCalendarEvents
スペースの予定を日付範囲で一覧する(読み取り専用)。
引数:
- `from`(任意): 範囲開始 `YYYY-MM-DD`
- `to`(任意): 範囲終了 `YYYY-MM-DD`
範囲を省略すると全期間。結果は日付→時刻順(終日は各日の先頭)で返る。
## gotcha
- `date` / `from` / `to``YYYY-MM-DD``time``HH:MM` 厳守。形式違反はエラー。
- スペース未所属のタスクからは呼べない(`ctx.spaceId` 必須)。
- 予定はスペースの可視性に従う。イベント単独の可視性は持たない。

View File

@ -16,7 +16,10 @@ DownloadFile({
- `url`: ダウンロード元 URL - `url`: ダウンロード元 URL
- `filename`: 保存先パスsection 配下からの相対パス) - `filename`: 保存先パスsection 配下からの相対パス)
- `section`: `"input"``"output"` (成果物に使う場合は `"output"` - `section`: `"input"` / `"output"` / `"source"`
- `"input"`: 後続処理の入力(既定)
- `"output"`: 成果物
- `"source"`: 調べ物・参照資料。スペースのソースライブラリ(`source/`)に蓄積され、出典付きで Files タブに一覧される。research 系のダウンロードはこれを推奨
## ファイル命名規約 ## ファイル命名規約
@ -30,6 +33,13 @@ DownloadFile({
`logs/downloads.jsonl` に各ダウンロードのメタ情報URL, 保存先, サイズ)が記録される。 `logs/downloads.jsonl` に各ダウンロードのメタ情報URL, 保存先, サイズ)が記録される。
### ソースライブラリsection: "source"
スペースのタスクで `section: "source"` を指定すると、ファイルは `source/` に保存され、
出典メタファイル名・URL・取得日時・サイズ・ツール名`source/index.jsonl` に 1 行追記される。
これらは「そのスペースのソース(資料)」として蓄積され、後から追加調査やグラウンディングに使える。
WebFetch がスペース文脈でページ本文を取得した場合や、BrowseWeb がブラウザでページを閲覧した場合も、同じ `source/index.jsonl` に自動で記録される。
## 成果物への画像埋め込み ## 成果物への画像埋め込み
ダウンロードした画像は Markdown レポートから相対パスで埋め込める: ダウンロードした画像は Markdown レポートから相対パスで埋め込める:
@ -47,4 +57,4 @@ WebFetch と同じく、ローカル/プライベート IP はデフォルトブ
## 注意 ## 注意
- 大きすぎるファイル数百MB以上はタイムアウトしやすい - 大きすぎるファイル数百MB以上はタイムアウトしやすい
- バイナリファイルPDF, 動画等)も保存可能だが、画像以外の用途では IngestDocument / TranscribeAudio 等の専用ツールも検討 - バイナリファイルPDF, 動画等)も保存可能だが、画像以外の用途では ReadPdf / TranscribeAudio 等の専用ツールも検討

View File

@ -1,97 +0,0 @@
# SearchNotes / ReadNote / WriteNote
ユーザーの共有 knowledge notes`data/users/{userId}/notes/{folder}/{file}.md`)を扱う 3 ツール。
ファイルは YAML frontmatter + Markdown 本文で構成され、DB の `note_index` (FTS5 対応) に mirror される。
## SearchNotes
購読中(`note_subscriptions``mode=search` または `mode=inject``enabled=1` の行がある)の note を FTS5 全文検索する。
### 引数
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| `query` | string | 必須 | 検索クエリ。ツール内部でフレーズ検索として扱われる |
| `folder` | string | 省略可 | 特定フォルダーのみに絞り込む |
| `limit` | integer | 省略可 | 最大取得件数(デフォルト 10、上限 100 |
### 戻り値
マッチした note のリスト(`owner_id/folder/file_name: title`)。
続いて `ReadNote` で全文を取得できる。
### FTS5 クエリの注意
クエリはフレーズ検索として自動エスケープされる。`kubernetes pod``"kubernetes pod"` に変換。
AND / OR 演算子を使いたい場合は複数回呼び出して結果を手動合算すること。
---
## ReadNote
特定の note の全文frontmatter + 本文)を取得。可視性チェックあり。
### 引数
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| `owner_id` | string | 必須 | note の所有者 user ID |
| `folder` | string | 必須 | フォルダー名 |
| `file_name` | string | 必須 | ファイル名(例: `foo.md` |
### 可視性ルール
- 自分の noteowner_id が自分): 常に読める
- `visibility: public` の note: 全ユーザーが読める
- `visibility: org` の note: `scope_org_id` が自分の所属 org に含まれる場合のみ読める
- `visibility: private` の他人の note: 読めないisError: true
---
## WriteNote
自分の `notes/{folder}/{file}.md` を作成または更新する。
### 引数
| フィールド | 型 | 必須 | 説明 |
|---|---|---|---|
| `folder` | string | 必須 | フォルダー名(`[a-zA-Z0-9._-]` のみ) |
| `file_name` | string | 必須 | ファイル名(`.md` で終わる) |
| `content` | string | 必須 | YAML frontmatter を含む完全な Markdown 内容 |
### Frontmatter フィールド
```yaml
---
title: "Note のタイトル(省略可)"
visibility: public # private | org | public
scope_org_id: "org-id" # visibility=org の場合に必須(自分の所属 org の ID
mode_hint: search # search | inject省略可
tags:
- kubernetes
- security
---
```
- `visibility` は必須。省略すると `private` として扱われる
- `visibility: org` の場合、`scope_org_id` は自分の所属 org の ID でなければならない
- `mode_hint: inject` にすると、購読者の system prompt に自動注入される
- フォルダーとファイル名は固定の 2 階層(`notes/<folder>/<file>.md`
### 書き込み後の動作
- DB の `note_index` + FTS5 テーブルを即座に更新
- 同フォルダーへの self subscription がなければ自動作成(`mode=search``enabled=1`
- エラー時は `isError: true` を返す(バリデーションエラーや権限エラー)
---
## 使い分け
| 場面 | ツール |
|---|---|
| 「CVE 対象?」「〜の設定は?」など知識検索 | `SearchNotes("CVE")` |
| 検索結果の 1 件の詳細を読む | `ReadNote` |
| スケジュールタスクで取得した情報をチームと共有 | `WriteNote``visibility: org` |
| 自分用のメモ・ログを残す | `WriteNote``visibility: private` |

View File

@ -32,7 +32,3 @@ ReadImage({ file_path: "input/screenshot.png" })
- `input/` または `output/` 配下のローカル画像ファイル - `input/` または `output/` 配下のローカル画像ファイル
- URL 指定は不可DownloadFile で先にローカル保存する) - URL 指定は不可DownloadFile で先にローカル保存する)
- 対応形式: png, jpg, jpeg, gif, webp, bmp - 対応形式: png, jpg, jpeg, gif, webp, bmp
## SearchKnowledge との連携
SearchKnowledge が返したページ画像(`input/knowledge/{ns}/page_xxx.png`)も ReadImage で内容確認できる。

35
docs/tools/requesttool.md Normal file
View File

@ -0,0 +1,35 @@
# RequestTool
この movement で提示されていないツールがどうしても必要なときに、その要求を**記録**するためのメタツール(`allowed_tools` に書かなくても常時利用可能)。
## いつ使うか
- 依頼を達成するのに必要なツールが、現在の movement の `allowed_tools` に無いと気づいたとき。
- まず「本当にそのツールが要るか」を検討すること。多くの作業は既存のツール(`Bash` / `Read` / `WebSearch` 等)で代替できる。
## 引数
| 引数 | 必須 | 説明 |
|------|------|------|
| `name` | はい | 必要なツール名(例: `WebSearch`, `Bash`, `mcp__foo__bar` |
| `reason` | はい | なぜそのツールが必要かを具体的に。これがピース作者への記録に残る |
## 重要: これは「要求の記録」であって「即時付与」ではない
RequestTool を呼んでも、そのツールが**その場で使えるようにはならない**。要求は記録され、タスク詳細とピース集計に表示される。ピース作者が `allowed_tools` / `shared_tools` に追加すれば次回から使える。
要求したあとの進め方:
1. そのツール無しで達成できないか、もう一度考える。
2. どうしても無理なら `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` でユーザーに依頼する。
3. 勝手に諦めて中途半端な結果を返さない。
## 分類(記録される `category`
- **既に利用可能**: そのツールはこの movement で使える → 記録せず「そのまま呼んでください」と返る。
- **`requested`**: カタログに存在するがこの movement では未許可 → 設定漏れ候補として記録。
- **`unknown`**: そんなツールは存在しない(名前の誤り・能力ギャップ)→ 記録するが付与対象外。
## 関連
足りないツールを呼んで弾かれた場合も、同じ記録に「受動捕捉(`blocked`)」として残る。ピース側の `shared_tools`(全 movement 共通ツール)も参照(`pieces/SCHEMA.md`)。

View File

@ -1,103 +0,0 @@
# SearchKnowledge / ListNamespaces / ListDocuments / IngestDocument / IngestStatus
DKSDocument Knowledge Serviceに取り込んだ社内文書をベクトル検索で参照するツール群。
## 利用可能性チェック
```js
ListNamespaces() // 利用可能な namespace 一覧を返す
```
DKS が設定されていなければ "Knowledge service not configured" を返す。
namespace が空なら何も検索できない。
## 文書一覧の確認
```js
ListDocuments({ namespace: "product-a-support" })
```
その namespace に取り込み済みの文書を表示する。
## 検索
```js
SearchKnowledge({ namespace: "product-a-support", query: "返品ポリシーは何日以内?" })
```
レスポンスには:
1. **sections** — マッチしたツリーノード(タイトル + summary + ページ範囲)
2. **page_image_urls** — 関連ページの画像PNG
### 自動ダウンロード
検索結果に含まれるページ画像は **自動的にワークスペース** `input/knowledge/{namespace}/page_001.png` などに保存される。
LLM はそのローカルパスを `ReadImage` でそのまま閲覧できる。
```js
// SearchKnowledge の出力例:
// ## 返品ポリシー (manual.pdf, pages: 3, 4)
// 購入後30日以内であれば...
//
// ### ページ画像ReadImage で閲覧可能)
// - input/knowledge/product-a-support/page_003.png
// - input/knowledge/product-a-support/page_004.png
ReadImage({ file_path: "input/knowledge/product-a-support/page_003.png" })
```
### 生 JSON の保存
DKS の生レスポンス JSON は `logs/raw/searchknowledge-{timestamp}.json` に保存される。doc_id 等の詳細フィールドが必要なときはそちらを Read する。
## 文書の取り込み
```js
// 1. 取り込み開始(非同期)
IngestDocument({ namespace: "product-a-support", file_path: "input/manual.pdf" })
// → "取込を開始しました (job: xxx, 45ページ検出)。完了確認は IngestStatus で可能です。"
// 2. 進捗確認
IngestStatus({ namespace: "product-a-support", job_id: "xxx" })
// → "ジョブ xxx: 処理中: VLM 12/45ページ, ツリー構築: 未完了"
// または "完了 (manual.pdf)" / "失敗: ..."
```
DKS は内部で:
1. PDF → ページ画像化
2. VLM でページごとに記述生成
3. ツリー構造(章・節)構築
4. ベクトル化してインデックス登録
を行う。45 ページで数分かかる規模感。
## ワークフロー例
### 質問応答
```
SearchKnowledge → 関連 sections + ページ画像取得
↓ 必要なら ReadImage で図表確認
回答文に sections の要点を引用、根拠ページを示す
```
### 新文書を取り込んで検索
```
IngestDocument → job_id 取得
↓ 待機(数分後 or 別作業)
IngestStatus → completed まで polling
SearchKnowledge で取り込み済みコンテンツを検索
```
## ログ
`logs/knowledge-history.jsonl` に各ツール呼び出し(クエリ・件数・所要時間・エラー)が記録される。
## 注意
- **検索ヒット件数は DKS 側で制御** されるので、件数上限を心配する必要はない
- DKS サーバーがローカル/プライベート IP でも、API キー認証経由なので SSRF 例外不要
- VLM 処理はバックグラウンドで動くので、IngestDocument 後すぐに SearchKnowledge を呼んでもまだヒットしない可能性ありIngestStatus で完了確認)

38
docs/tools/skills.md Normal file
View File

@ -0,0 +1,38 @@
# SkillsInstallSkill / ReadSkill / ListSkills
Skill は「参照知識」(手順書・ガイド・規約)。実行テンプレートの Piece とは別物。
Piece の取得・編集には GetPiece / CreatePiece / UpdatePiece を使う。
利用可能なスキル一覧はシステムプロンプトの **Skills Index** に出る。本文を読むには
`ReadSkill({ name: "..." })` を呼ぶ。
## ツール
- **InstallSkill** — スキルを保存する。通常は `content` に SKILL.md 全文YAML frontmatter + 本文を渡す。workspace 内に `SKILL.md``scripts/` 等を含むディレクトリを組み立て済みなら `sourcePath`workspace 内の絶対パス)を渡す。`scope``user`(個人 or 共有ワークスペース)か `system`全ユーザー共有・admin のみ)。
- **ReadSkill** — スキル本文を取得する。ディレクトリ型スキルは workspace の `skills/{name}/` に展開され、その相対パスでスクリプトを実行できる。
- **ListSkills** — インストール済みスキルの一覧を返す。
## スコープと可視性(重要)
`scope: 'user'` で保存したスキルの置き場所は、**いま作業しているワークスペースで決まる**。
AGENTS.md・メモリ・Piece と同じ考え方。
| 作業中のワークスペース | 保存先 | 見える人 |
|---|---|---|
| 共有ワークスペース(案件など) | `data/spaces/{spaceId}/skills/` | そのワークスペースのメンバー全員 |
| 個人ワークスペース | `data/users/{userId}/skills/` | 自分だけ |
つまり、共有ワークスペースで作ったスキルはメンバー間で共有され、Skills Index にも
全メンバー分が出る。個人ワークスペースで作ったスキルは自分専用で、他人には見えない。
`scope: 'system'` は作業中のワークスペースに関係なく常に全ユーザー共有admin 専用)。
この挙動はワークスペーススコープの影響を受けない。
### 共有時の注意
共有ワークスペースのスキルは他メンバーのジョブからも実行され得る(共有 Piece・
共有 AGENTS.md と同じ信頼境界)。秘匿情報や自分専用の手順は、共有ワークスペースでは
保存しないこと。個人ワークスペースで作れば自分だけのものになる。
InstallSkill のセキュリティスキャン(高深刻度をブロック、中深刻度を警告)と
sourcePath の workspace 外参照拒否・サイズ/深さ上限は、スコープに関係なく常に適用される。

View File

@ -1,43 +0,0 @@
# UpdateDashboardWidget
ユーザーの個人ダッシュボード (Side Info Panel) の Markdown widget を upsert するツール。
## いつ使う
- ユーザーから「ダッシュボードにメモして」「news タブを更新して」などと頼まれたとき
- 長期的に残したい情報 (ニュース要約、TODO、参照リンク) を残すよう指示されたとき
- 1 タスク内の一時メモには使わない (それは task のコメントに書く)
## 引数
| name | required | 説明 |
|---|---|---|
| `slug` | yes | Widget の安定 ID。kebab-case (`memo`, `news`, `todo`)、32 文字以内 |
| `content` | yes | Markdown 本文。64KB まで |
| `title` | 新規 slug では必須 | 表示タイトル。既存 slug を更新するときは省略可(既存タイトル維持) |
| `mode` | optional | `replace` (default) または `append` |
## 挙動
- 同じユーザーの `slug` が既に存在 → 更新
- 存在しない → 新規作成 (title 必須)
- `mode='append'` → 既存 content の末尾に `\n\n` 区切りで追記
## ワークフロー例
「最新のテック関連ニュースを news タブにまとめておいて」:
1. `WebFetch` などでニュースを収集
2. Markdown でまとめて
3. `UpdateDashboardWidget({ slug: "news", title: "ニュース", content: "<markdown>" })` を呼ぶ
4. ユーザーには「ダッシュボードの news タブに反映しました」と返す
## gotcha
- `slug` は user スコープでユニーク。他ユーザーの slug と衝突は起きない
- 書き込み先は実行中タスクの owner の dashboard。共有タスクでも他人の dashboard には書かない
- 1 度書いた slug の title は更新できない (新しいタイトルにしたい場合は UI から行うか、新 slug を切る)
- 64KB を超える content は失敗する → 古いログを切り詰めるか、append ではなく replace でローテーション
## 関連

View File

@ -8,10 +8,12 @@ URL を HTTP GET してレスポンス本文を取得するツール。静的ペ
WebFetch({ url: "https://example.com/article", timeout: 30 }) WebFetch({ url: "https://example.com/article", timeout: 30 })
``` ```
- HTML はテキスト化されて返る(タグ等は除去 - HTML は **readability 抽出**でクリーンな Markdown に変換されて返る(ナビ・広告・ヘッダー・フッターを除去し、見出し・リスト・テーブル・リンクは保持
- JSON / XML / プレーンテキストもそのまま取得可能 - JSON / XML / プレーンテキストもそのまま取得可能
- リダイレクトは自動で追従 - リダイレクトは自動で追従
返却テキストは context 節約のため約 10,000 文字で切り詰められる。**スペースタスクでは全文(未切り詰め)が `source/` に保存される**ので、切り詰めが起きると末尾に `... (truncated; full version saved to source/<file>)` という案内が付く。全文を読みたいときは保存された `source/` のファイルを `Read` で開けばよい。
## いつ使うか ## いつ使うか
| 状況 | 使うツール | | 状況 | 使うツール |
@ -24,6 +26,27 @@ WebFetch({ url: "https://example.com/article", timeout: 30 })
WebFetch は軽量で速い。BrowseWeb はブラウザ起動コストがかかるので、できる限り WebFetch を優先する。 WebFetch は軽量で速い。BrowseWeb はブラウザ起動コストがかかるので、できる限り WebFetch を優先する。
## クリーン本文抽出readability
取得した生 HTML は、記事本文だけを取り出す readability エンジンで処理してから返す。
内部パイプラインは `HTML → linkedom で DOM 化 → @mozilla/readability で記事抽出 → turndown(+gfm) で Markdown 化`
- **除去されるもの**: ナビゲーション、広告バナー、ヘッダー、フッター、サイドバー等のボイラープレート
- **保持されるもの**: 見出し(`#` 記法、箇条書き、番号付きリスト、テーブルGFM 記法)、リンク、コードブロック
- 相対リンクは取得 URL を基準に絶対 URL へ変換される
- `selector` を指定すると、その CSS セレクタにマッチする要素のサブツリーだけをクリーン抽出する(見つからなければページ全体の抽出にフォールバック)
抽出に失敗するページ(記事構造のない LP、JS で本文を生成する SPA、空ページ等では、
従来の素朴なタグ除去にフォールバックするので WebFetch が失敗扱いになることはない。
本文がほぼ空のときは SPA の可能性が高いので `BrowseWeb` に切り替える。
### 全文は source へ、切り詰め版は agent へ
- agent のコンテキストに返るのは約 10,000 文字に切り詰めた版context 膨張を防ぐため)
- **スペースタスクでは、切り詰める前の全文クリーン Markdown が `source/` に保存される**
- 切り詰めが起きた場合、返却テキスト末尾に `... (truncated; full version saved to source/<file>)` が付く
- 病的に巨大なページ対策として、`source/` 保存も 2MB で打ち切る(その旨ノートを付す)
## レスポンス履歴 ## レスポンス履歴
各 WebFetch 呼び出しは `logs/webfetch-history.jsonl` に記録される。後から「どの URL を取得したか」を振り返れる。 各 WebFetch 呼び出しは `logs/webfetch-history.jsonl` に記録される。後から「どの URL を取得したか」を振り返れる。
@ -75,3 +98,13 @@ WebFetch は取得 body の先頭 8KB を sniff しmagic byte / NUL / 不正
(例: `.xls``text/plain`)していても実バイトで検出する。バイナリを処理したい場合は (例: `.xls``text/plain`)していても実バイトで検出する。バイナリを処理したい場合は
`DownloadFile``input/` に保存し、`ReadExcel` / `ReadPdf` 等を使う。テキスト body は `DownloadFile``input/` に保存し、`ReadExcel` / `ReadPdf` 等を使う。テキスト body は
5MB で打ち切られる(末尾に `[truncated: body exceeded 5MB]`)。 5MB で打ち切られる(末尾に `[truncated: body exceeded 5MB]`)。
## ソースライブラリへの蓄積(スペースタスク)
スペースのタスクで WebFetch がページ本文を取得すると、**クリーン抽出した全文 Markdown**agent への
返却で切り詰める前のもの)が `source/` に保存され、出典メタURL・タイトル・取得日時・サイズ
`source/index.jsonl` に追記される。
これにより「そのスペースのソース(資料)」が自動で溜まり、後から追加調査やグラウンディングに使える。
同一 URL は重複保存しない(既に index にあればスキップ)。スペース文脈でないタスク
ephemeral / gitea issue 等)では従来どおり蓄積しない。`logs/raw/` への生データ保存は従来どおり。
BrowseWebブラウザ閲覧や DownloadFile`section: "source"`)も同じ `source/index.jsonl` に合流する。

View File

@ -0,0 +1,45 @@
# ワークスペース・アプリの作り方(エージェント向け)
ユーザーから「ワークスペース・アプリを作って」「○○用の GUI を作って」などと頼まれたときの
手順です。専用ツールは不要 — `edit: true` の movement で **Write/Edit を使って HTML を書く**
だけで成立します。
## 何を作るか
ワークスペースの `apps/{name}/index.html` に置く、**自己完結した 1 枚の HTML**(インライン
JS/CSS、`data:` 画像のみ)。ユーザーはワークスペースの「アプリ」タブから「開く」で起動します。
アプリはサンドボックス iframeopaque origin、`allow-same-origin` 無し)で動き、ファイル I/O は
親への **postMessage ブリッジ経由のみ**です。
## 守るべき制約(重要)
- **外部ネットワーク禁止**: `default-src 'none'; connect-src 'none'` の CSP が自動注入される。
外部 CDN・外部 API・別ファイルの `<script src>`・Web フォントは読み込めない。すべてインライン化する。
- **資格情報は渡らない**: I/O は親がユーザーのセッションで代理する。ユーザーの権限を超えられない。
- **パスはワークスペース相対**: `output/...``apps/{name}/data/...` への書き込みは確認不要。
それ以外への書き込み・削除はユーザー確認が出る。`..`・絶対パスは拒否される。
## ブリッジ API要点
```js
// 一意 id を付けて postMessage、message イベントで応答を id 突き合わせ。
const { entries } = await call({ type: 'listFiles', dir: 'output' }); // 一覧
const { content } = await call({ type: 'readFile', path: 'output/x.md' }); // 読み取り
await call({ type: 'writeFile', path: 'output/note.md', content: '...' }); // 書き込み
await call({ type: 'deleteFile', path: 'output/old.txt' }); // 削除(常に確認)
```
完全なプロトコル・最小ヘルパー実装・応答形式は **`docs/workspace-apps-bridge.md`** を参照。
## ひな型をコピーする
動くサンプルが `docs/examples/workspace-apps/file-note/` にある(`index.html` `app.json`)。
`output/` を一覧・閲覧し、メモを `output/note.md` に保存する自己完結アプリ。これを土台に、
依頼に合わせて改変して `apps/{name}/index.html` に Write するのが最短。
`apps/{name}/app.json`(任意)で一覧の表示名・説明・エントリを指定できる(無ければフォルダ名)。
## 仕上げ
- 書き込んだら、ユーザーに「アプリ」タブの「開く」で起動できることを一言伝える。
- アプリ名(フォルダ名)は内容が分かる短い英小文字+ハイフンにする(例: `invoice-gen`)。

View File

@ -0,0 +1,141 @@
# ワークスペース・アプリ ブリッジ仕様(アプリ作者=エージェント向け)
ワークスペース・アプリは、ワークスペースの `apps/{name}/index.html` に置く HTML 製の小さな
GUI です。ワークスペースの **「アプリ」タブ**に一覧表示され、「開く」を押すと `AppRunner`
サンドボックス iframe で実行します(ファイルタブの「アプリとして実行」からも起動できます)。
アプリはワークスペースのファイルを **postMessage ブリッジ経由でのみ** 読み書きできます。
このドキュメントは、生成する HTMLアプリが従うべきブリッジ API を定めます。
> **エージェントへ**: ユーザーから「ワークスペース・アプリを作って」と頼まれたら、この仕様に
> 従った**自己完結 HTML** を `apps/{name}/index.html` に Write してください(外部リソース禁止・
> インライン JS/CSS のみ)。動くひな型は `docs/examples/workspace-apps/file-note/``index.html`
> `app.json`)。この仕様自体は `ReadToolDoc({ name: "workspace-apps" })` でいつでも読めます。
## 実行環境(重要な制約)
- アプリは `<iframe sandbox="allow-scripts">` で動きます。**`allow-same-origin` は付きません。**
そのため不透明オリジンopaque originで動作し、親ウィンドウの DOM・Cookie・セッション・
localStorage には一切アクセスできません。
- **資格情報は渡されません。** ファイル I/O は親が代理します。代理は実行中ユーザーのセッションで
行われるため、アプリはそのユーザーの権限を超えられません(メンバーシップ・編集権限・パス
ガードはサーバ側でそのまま効きます)。
- **外部ネットワークは使えません。** `fetch` / `XMLHttpRequest` による外部通信は想定外です
(配信はサンドボックス + CSP で絞られます)。データの入出力は必ずブリッジ経由で行ってください。
- V1 の主対象は **単一の自己完結 `index.html`**JS/CSS をインライン化したもの)。複数ファイル
構成(`apps/{name}/` 配下の相対参照)はベストエフォートで、相対 `src`/`href` は親が raw URL に
書き換えます。確実に動かしたいなら 1 ファイルにまとめてください。
## マニフェスト `app.json`(任意)
アプリフォルダに `apps/{name}/app.json` を置くと、「アプリ」タブの表示情報を指定できます。
**任意**で、無ければフォルダ名と `index.html` がそのまま使われます。形式が壊れていても無視され、
フォールバックされます(アプリ一覧は壊れません)。
```json
{
"title": "ファイル・ノート",
"description": "output/ のファイルを閲覧してメモを保存するアプリ",
"entry": "index.html"
}
```
| キー | 必須 | 説明 |
|---|---|---|
| `title` | 任意 | 一覧に出す表示名(無ければフォルダ名) |
| `description` | 任意 | 一覧に出す短い説明 |
| `entry` | 任意 | アプリフォルダ基準の HTML エントリ(既定 `index.html`)。**必ず `apps/{name}/` 配下**に収めること(`..` や絶対パスは拒否され、`index.html` にフォールバックされる) |
## ブリッジ・プロトコル
要求は `window.parent.postMessage(req, '*')` で送り、応答は `window``message` イベントで
受け取ります。各要求には一意の `id` を付け、応答の `id` と突き合わせてください。
### 要求
```js
// 読み取り
window.parent.postMessage({ id, type: 'readFile', path: 'output/data.json' }, '*');
// 一覧dir 省略でワークスペース直下)
window.parent.postMessage({ id, type: 'listFiles', dir: 'output' }, '*');
// 書き込みcontent は文字列)
window.parent.postMessage({ id, type: 'writeFile', path: 'output/請求書.html', content: '<html>…' }, '*');
// 削除(常に確認ダイアログが出る)
window.parent.postMessage({ id, type: 'deleteFile', path: 'output/old.txt' }, '*');
```
### 応答
```js
window.addEventListener('message', (e) => {
const r = e.data; // { id, ok, data } または { id, ok:false, error }
if (r.id !== myRequestId) return;
if (r.ok) { /* r.data を使う */ } else { /* r.error を表示 */ }
});
```
応答ペイロード:
| type | 成功時 `data` |
|-------------|-------------------------------------------------|
| `readFile` | `{ path, content }`content は UTF-8 テキスト)|
| `listFiles` | `{ dir, entries }`entries はファイル/フォルダ)|
| `writeFile` | `{ path, bytes }` |
| `deleteFile`| `{ deleted, skipped }` |
失敗時は `{ id, ok:false, error }`
## パスの規則
- パスは必ず **ワークスペース files 直下からの相対パス**。先頭 `/``..`・ドライブ文字
(`C:`)・バックスラッシュは拒否されます(クライアントとサーバの二重ガード)。
- アプリは自分のワークスペースの外には出られません。
## 書き込みの確認ポリシー
- **確認なしで書ける場所**: `output/` 配下、および自分のアプリの `apps/{name}/data/` 配下。
- それ以外のパスへの `writeFile` は、親が確認ダイアログ(「アプリ『{name}』が {path} に書き込もう
としています。許可しますか?」)を出し、ユーザーが許可したときだけ書き込みます。
- `deleteFile` は場所に関わらず常に確認ダイアログが出ます。
データを残すアプリは、原則として `output/`(ユーザーに見せる成果物)か
`apps/{name}/data/`(アプリ内部の状態)に書いてください。そうすればユーザーを確認で煩わせません。
## 最小サンプル(自己完結 index.html
```html
<!doctype html><meta charset="utf-8">
<button id="save">保存</button>
<script>
let seq = 0;
const pending = new Map();
function call(req) {
return new Promise((resolve, reject) => {
const id = ++seq;
pending.set(id, { resolve, reject });
window.parent.postMessage({ ...req, id }, '*');
});
}
window.addEventListener('message', (e) => {
const r = e.data;
const p = pending.get(r && r.id);
if (!p) return;
pending.delete(r.id);
r.ok ? p.resolve(r.data) : p.reject(new Error(r.error));
});
document.getElementById('save').onclick = async () => {
await call({ type: 'writeFile', path: 'output/note.txt', content: 'hello' });
alert('保存しました');
};
</script>
```
## ネットワーク制約(重要)
アプリ HTML には `default-src 'none'; connect-src 'none'; ...` の CSP が自動注入され、
**外部ネットワークfetch/XHR/WebSocket/sendBeacon/フォーム外部 POSTはすべて遮断**される。
アプリのファイル I/O は postMessage ブリッジ(親が代理)だけが経路。これは、ユーザーから
渡されたワークスペースのファイル内容を外部へ持ち出されないための exfil 対策。
V1 のアプリは **自己完結 HTML**(インライン JS/CSS、`data:` 画像)で書くこと。
外部 CDN・外部 API・別ファイルの `<script src>` は CSP で読み込めない。

263
package-lock.json generated
View File

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@kenjiuno/msgreader": "^1.28.0", "@kenjiuno/msgreader": "^1.28.0",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0",
"@novnc/novnc": "^1.6.0", "@novnc/novnc": "^1.6.0",
"@types/ssh2": "^1.15.5", "@types/ssh2": "^1.15.5",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
@ -24,6 +25,7 @@
"fast-xml-parser": "^5.4.2", "fast-xml-parser": "^5.4.2",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"http-proxy": "^1.18.1", "http-proxy": "^1.18.1",
"linkedom": "^0.18.12",
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"p-queue": "^9.3.0", "p-queue": "^9.3.0",
"passport": "^0.7.0", "passport": "^0.7.0",
@ -37,6 +39,8 @@
"selfsigned": "^2.4.1", "selfsigned": "^2.4.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"ssh2": "^1.17.0", "ssh2": "^1.17.0",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"undici": "^7.25.0", "undici": "^7.25.0",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"web-push": "^3.6.7", "web-push": "^3.6.7",
@ -44,6 +48,7 @@
"yaml": "^2.4.1" "yaml": "^2.4.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.0",
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
"@types/better-sqlite3": "^7.6.8", "@types/better-sqlite3": "^7.6.8",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
@ -55,6 +60,7 @@
"@types/passport-oauth2": "^1.8.0", "@types/passport-oauth2": "^1.8.0",
"@types/proper-lockfile": "^4.1.4", "@types/proper-lockfile": "^4.1.4",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"@types/turndown": "^5.0.6",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"fast-check": "^3.23.2", "fast-check": "^3.23.2",
@ -708,6 +714,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0", "version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@ -1048,6 +1060,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/@mozilla/readability": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz",
"integrity": "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@napi-rs/canvas": { "node_modules/@napi-rs/canvas": {
"version": "0.1.80", "version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
@ -1311,6 +1332,22 @@
"@noble/hashes": "^1.1.5" "@noble/hashes": "^1.1.5"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
@ -1907,6 +1944,13 @@
"@types/superagent": "^8.1.0" "@types/superagent": "^8.1.0"
} }
}, },
"node_modules/@types/turndown": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/uuid": { "node_modules/@types/uuid": {
"version": "10.0.0", "version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
@ -2402,6 +2446,12 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.15", "version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
@ -2725,6 +2775,40 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/cssom": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
"license": "MIT"
},
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.19", "version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
@ -2819,6 +2903,61 @@
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/duck": { "node_modules/duck": {
"version": "0.1.12", "version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
@ -2914,6 +3053,18 @@
"once": "^1.4.0" "once": "^1.4.0"
} }
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -3625,6 +3776,43 @@
"node": ">=16.9.0" "node": ">=16.9.0"
} }
}, },
"node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
"license": "MIT"
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http_ece": { "node_modules/http_ece": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
@ -4236,6 +4424,30 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/linkedom": {
"version": "0.18.12",
"resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz",
"integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==",
"license": "ISC",
"dependencies": {
"css-select": "^5.1.0",
"cssom": "^0.5.0",
"html-escaper": "^3.0.3",
"htmlparser2": "^10.0.0",
"uhyphen": "^0.2.0"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"canvas": ">= 2"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/listenercount": { "node_modules/listenercount": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz",
@ -4578,6 +4790,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/oauth": { "node_modules/oauth": {
"version": "0.10.2", "version": "0.10.2",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
@ -4872,12 +5096,12 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.59.1", "version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.59.1" "playwright-core": "1.61.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -4890,9 +5114,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.59.1", "version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
@ -6395,6 +6619,25 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/turndown": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
},
"engines": {
"node": ">=18",
"npm": ">=9"
}
},
"node_modules/turndown-plugin-gfm": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz",
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
"license": "MIT"
},
"node_modules/tweetnacl": { "node_modules/tweetnacl": {
"version": "0.14.5", "version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
@ -6428,6 +6671,12 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/uhyphen": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
"integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
"license": "ISC"
},
"node_modules/uid-safe": { "node_modules/uid-safe": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",

View File

@ -21,6 +21,8 @@
"dev": "node --loader ts-node/esm src/main.ts", "dev": "node --loader ts-node/esm src/main.ts",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test --config ui/playwright.config.ts",
"test:e2e:auth": "playwright test --config ui/playwright.auth.config.ts",
"lint:pieces": "node scripts/lint-pieces.mjs", "lint:pieces": "node scripts/lint-pieces.mjs",
"bench": "tsx scripts/bench-run.ts", "bench": "tsx scripts/bench-run.ts",
"bench:fixtures": "tsx scripts/build-bench-fixtures.ts", "bench:fixtures": "tsx scripts/build-bench-fixtures.ts",
@ -29,6 +31,7 @@
"dependencies": { "dependencies": {
"@kenjiuno/msgreader": "^1.28.0", "@kenjiuno/msgreader": "^1.28.0",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0",
"@novnc/novnc": "^1.6.0", "@novnc/novnc": "^1.6.0",
"@types/ssh2": "^1.15.5", "@types/ssh2": "^1.15.5",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
@ -42,6 +45,7 @@
"fast-xml-parser": "^5.4.2", "fast-xml-parser": "^5.4.2",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"http-proxy": "^1.18.1", "http-proxy": "^1.18.1",
"linkedom": "^0.18.12",
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"p-queue": "^9.3.0", "p-queue": "^9.3.0",
"passport": "^0.7.0", "passport": "^0.7.0",
@ -55,6 +59,8 @@
"selfsigned": "^2.4.1", "selfsigned": "^2.4.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"ssh2": "^1.17.0", "ssh2": "^1.17.0",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"undici": "^7.25.0", "undici": "^7.25.0",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"web-push": "^3.6.7", "web-push": "^3.6.7",
@ -62,6 +68,7 @@
"yaml": "^2.4.1" "yaml": "^2.4.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.0",
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
"@types/better-sqlite3": "^7.6.8", "@types/better-sqlite3": "^7.6.8",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
@ -73,6 +80,7 @@
"@types/passport-oauth2": "^1.8.0", "@types/passport-oauth2": "^1.8.0",
"@types/proper-lockfile": "^4.1.4", "@types/proper-lockfile": "^4.1.4",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"@types/turndown": "^5.0.6",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"fast-check": "^3.23.2", "fast-check": "^3.23.2",

View File

@ -17,9 +17,40 @@ camelCase internally (see `Movement` in `src/engine/agent-loop.ts`).
| `initial_movement` | string | yes | must reference a `movements[].name` | | `initial_movement` | string | yes | must reference a `movements[].name` |
| `triggers.keywords` | string[] | no | classifier hint only | | `triggers.keywords` | string[] | no | classifier hint only |
| `required_mcp` | string[] | no | `[a-z0-9_-]{1,64}` server slugs | | `required_mcp` | string[] | no | `[a-z0-9_-]{1,64}` server slugs |
| `shared_tools` | string[] | no | piece-level tools merged into **every** movement's effective `allowed_tools` (union). See below. |
| `model` | string | no | preferred LLM model | | `model` | string | no | preferred LLM model |
| `movements` | Movement[] | yes | non-empty array | | `movements` | Movement[] | yes | non-empty array |
### `shared_tools`
Tools listed here are added to every movement's allowed set, so they don't have
to be repeated in each `movements[].allowed_tools`. The effective set for a
movement is:
```
shared_tools movements[].allowed_tools META_TOOLS
```
`prepareMovementContext` (`src/engine/piece-runner.ts`) unions the first two
via `mergeToolNames` (de-duplicates, preserves order) into
`movement.allowedTools`; the always-on `META_TOOLS` are added later, when
`getToolDefs` (`src/engine/tools/index.ts`) builds the tool list.
The per-movement gates still apply on top of the union:
- A movement with `edit: false` never exposes `Write`/`Edit`, even if listed in
`shared_tools`.
- SSH tools (`SshExec` etc.) still require the movement's own
`allowed_ssh_connections` — a shared SSH tool is rejected at runtime in any
movement that has not declared connections (defense in depth).
- Unknown tool names are silently dropped by `getToolDefs` (`name in allDefs`).
Handled by two complementary layers (mirrors `required_mcp`): file-backed
pieces are cleaned leniently at load time by `normalizeSharedTools`
(`piece-runner.ts`, drops bad entries + warns), while API writes
(`POST`/`PUT /api/pieces`, `CreatePiece`/`UpdatePiece`) are validated strictly
by `validatePiece` (`pieces-api.ts`, rejects a non-array or non-string entry).
## Movement ## Movement
| Field | Type | Required | Notes | | Field | Type | Required | Notes |

View File

@ -63,11 +63,6 @@ movements:
- Grep - Grep
- Write - Write
- Edit - Edit
- SearchKnowledge
- ListNamespaces
- ListDocuments
- SearchNotes
- ReadNote
- 'mcp__*' - 'mcp__*'
rules: rules:
- condition: "output/recommendation.md に推奨方針をまとめた" - condition: "output/recommendation.md に推奨方針をまとめた"

View File

@ -59,6 +59,12 @@ movements:
4. YAML 定義を生成し、CreatePiece または UpdatePiece で保存する 4. YAML 定義を生成し、CreatePiece または UpdatePiece で保存する
5. 作成した Piece の内容をユーザーに説明する 5. 作成した Piece の内容をユーザーに説明する
## ワークスペース・アプリ
ユーザーが「ワークスペース・アプリを作って」「○○用の GUI を作って」と依頼した場合:
作り方の仕様を `ReadToolDoc({ name: "workspace-apps" })` で取得し、自己完結 HTML
(インライン JS/CSS のみ・外部ネットワーク禁止)を Write で `apps/{名前}/index.html` に書く。
ワークスペースの「アプリ」タブから起動できる旨を回答で伝える。
## 完了方法(重要) ## 完了方法(重要)
この piece は単一 movement のため、終了は必ず `complete` ツールで行う。`transition` は使わない。 この piece は単一 movement のため、終了は必ず `complete` ツールで行う。`transition` は使わない。
@ -66,7 +72,7 @@ movements:
- `result` がそのままユーザーに表示される最終出力。途中のメモや作業ログは入れない - `result` がそのままユーザーに表示される最終出力。途中のメモや作業ログは入れない
- **ユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "確認したい内容", why_no_default: "デフォルトで進められない理由"})` - **ユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "確認したい内容", why_no_default: "デフォルトで進められない理由"})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "失敗の理由"})` - **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "失敗の理由"})`
allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadExcel, ReadDocx, ReadPPTX, ReadMsg, SQLite, Bash, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, ListPieces, GetPiece, CreatePiece, UpdatePiece, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, ReadToolDoc, UpdateDashboardWidget, 'mcp__*'] allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadExcel, ReadDocx, ReadPPTX, ReadMsg, SQLite, Bash, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, ListPieces, GetPiece, CreatePiece, UpdatePiece, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, ReadToolDoc, AddCalendarEvent, ListCalendarEvents, 'mcp__*']
# default_next is the engine-internal fallback for context overflow / ASK # default_next is the engine-internal fallback for context overflow / ASK
# limit reached / SpawnSubTask unavailable. It is NOT exposed to the LLM. # limit reached / SpawnSubTask unavailable. It is NOT exposed to the LLM.
default_next: COMPLETE default_next: COMPLETE

View File

@ -62,7 +62,7 @@ movements:
- **次の report へ**: `transition({next_step: "report"})` - **次の report へ**: `transition({next_step: "report"})`
- **処理対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - **処理対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **データが壊れている / 読み取れない / エラー発生で打ち切り**: `complete({status: "aborted", abort_reason: "..."})` - **データが壊れている / 読み取れない / エラー発生で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, SQLite, WebSearch, WebFetch, DownloadFile, ReadExcel, ReadDocx, ReadPdf, ReadPPTX, ReadMsg, SplitExcelSheets, PdfToImages, ReadImage, AnnotateImage, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, ReadToolDoc, 'mcp__*'] allowed_tools: [Read, Write, Bash, Glob, Grep, SQLite, WebSearch, WebFetch, DownloadFile, ReadExcel, ReadDocx, ReadPdf, ReadPPTX, ReadMsg, SplitExcelSheets, PdfToImages, ReadImage, AnnotateImage, TranscribeAudio, ReadToolDoc, 'mcp__*']
default_next: report default_next: report
rules: rules:
- condition: output/ に結果を書き出した - condition: output/ に結果を書き出した

View File

@ -49,7 +49,7 @@ movements:
- 各サブタスクの主要な知見を統合(矛盾・重複は整理) - 各サブタスクの主要な知見を統合(矛盾・重複は整理)
- 全体のまとめと結論を付ける - 全体のまとめと結論を付ける
5. output/report.md を書き終えたら verify へ遷移する 5. output/report.md を書き終えたら verify へ遷移する
allowed_tools: [Read, Glob, Grep, Write, Edit, SearchNotes, ReadNote, WriteNote, 'mcp__*'] allowed_tools: [Read, Glob, Grep, Write, Edit, 'mcp__*']
default_next: verify default_next: verify
rules: rules:
- condition: output/report.md に統合レポートを作成した - condition: output/report.md に統合レポートを作成した
@ -121,7 +121,7 @@ movements:
- **並列分解が効率的 → decompose へ**: `transition({next_step: "decompose"})` - **並列分解が効率的 → decompose へ**: `transition({next_step: "decompose"})`
- **必須情報が不足し確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - **必須情報が不足し確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})` - **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadMsg, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, IngestDocument, IngestStatus, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadMsg, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, AddCalendarEvent, ListCalendarEvents, 'mcp__*']
default_next: verify default_next: verify
rules: rules:
- condition: 2つ以上の独立したテーマがあり、並列分解が効率的と判断した - condition: 2つ以上の独立したテーマがあり、並列分解が効率的と判断した
@ -177,7 +177,7 @@ movements:
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})` - 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "execute", summary: "差し戻し指摘"})` (上記形式で) - 修正必要: `transition({next_step: "execute", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})` - 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep, WebSearch, WebFetch, ReadImage, AnnotateImage, ReadPdf, ReadExcel, ReadDocx, ReadPPTX, ReadMsg, SearchNotes, ReadNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache] allowed_tools: [Read, Glob, Grep, WebSearch, WebFetch, ReadImage, AnnotateImage, ReadPdf, ReadExcel, ReadDocx, ReadPPTX, ReadMsg, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache]
default_next: COMPLETE default_next: COMPLETE
rules: rules:
- condition: 成果物がない、または内容に不足・誤りがある(追加質問への回答に検索根拠が不足している場合も含む) - condition: 成果物がない、または内容に不足・誤りがある(追加質問への回答に検索根拠が不足している場合も含む)

View File

@ -67,7 +67,7 @@ movements:
- **追加情報が必要で同じ process を続行**: `transition({next_step: "process", summary: "..."})` - **追加情報が必要で同じ process を続行**: `transition({next_step: "process", summary: "..."})`
- **対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - **対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **読み取り不能・対応外フォーマット等の技術的失敗**: `complete({status: "aborted", abort_reason: "..."})` - **読み取り不能・対応外フォーマット等の技術的失敗**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, ReadExcel, ReadDocx, ReadPdf, ReadPPTX, ReadMsg, SplitExcelSheets, SplitDocxSections, PdfToImages, ReadImage, WebSearch, WebFetch, DownloadFile, SQLite, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, ReadToolDoc, 'mcp__*'] allowed_tools: [Read, Write, Bash, Glob, Grep, ReadExcel, ReadDocx, ReadPdf, ReadPPTX, ReadMsg, SplitExcelSheets, SplitDocxSections, PdfToImages, ReadImage, WebSearch, WebFetch, DownloadFile, SQLite, TranscribeAudio, ReadToolDoc, 'mcp__*']
default_next: verify default_next: verify
rules: rules:
- condition: output/ に成果物を書き出した(または既存ファイルを編集した) - condition: output/ に成果物を書き出した(または既存ファイルを編集した)

View File

@ -1,13 +1,15 @@
name: piece-builder name: piece-builder
description: | description: |
Piece の設計・作成・編集を行う専用エージェント。 orchestrator の「Pieceエージェント定義の YAML / ワークフロー定義)」そのものを設計・作成・編集する専用エージェント。
ユーザーの要件をヒアリングし、適切な movement 構成・ツール選定・遷移ルールを設計して Piece を作成する。 movement 構成・ツール選定・遷移ルールを設計し、CreatePiece / UpdatePiece で Piece を保存する。
「エージェントを作りたい」「ワークフローを自動化したい」「Piece を作って」などの依頼に対応。 「Piece を作って直して」「○○用のエージェントpieceを定義したい」など、**実行エージェントの定義を作る**依頼に限る。
対象外(混同しないこと): 「ワークスペースアプリGUIツールを作って」= 動く HTML 成果物の作成は workspace-app の担当。
通常のファイル生成・コード生成・文書作成は general / chat の担当。これらは Piece 定義の作成ではない。
max_movements: 999 max_movements: 999
initial_movement: design initial_movement: design
triggers: triggers:
keywords: [piece, エージェント作成, ワークフロー作成, 自動化, piece作成] keywords: [piece, Piece, ピース, piece作成, Piece作成, ピース作成, piece編集, Piece編集, エージェント定義, ワークフローpiece, movement設計]
movements: movements:
- name: design - name: design

View File

@ -45,7 +45,7 @@ movements:
- **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})` - **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})`
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})` - **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
default_next: analyze default_next: analyze
rules: rules:
- condition: output/ に情報を書き出した - condition: output/ に情報を書き出した
@ -72,7 +72,7 @@ movements:
output/images/ に画像がある場合は、必ずレポートの該当箇所に埋め込む: output/images/ に画像がある場合は、必ずレポートの該当箇所に埋め込む:
`![説明](./images/ファイル名.png)` `![説明](./images/ファイル名.png)`
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
default_next: verify default_next: verify
rules: rules:
- condition: output/ にレポートを書き出した - condition: output/ にレポートを書き出した
@ -103,7 +103,7 @@ movements:
- 合格: `complete({status: "success", result: "調査結果のまとめ"})` - 合格: `complete({status: "success", result: "調査結果のまとめ"})`
- 修正必要: `transition({next_step: "analyze", summary: "差し戻し指摘"})` - 修正必要: `transition({next_step: "analyze", summary: "差し戻し指摘"})`
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})` - 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep, WebSearch, WebFetch, ReadImage, AnnotateImage, ReadPdf, ReadExcel, ReadDocx, ReadPPTX, SearchNotes, ReadNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache] allowed_tools: [Read, Glob, Grep, WebSearch, WebFetch, ReadImage, AnnotateImage, ReadPdf, ReadExcel, ReadDocx, ReadPPTX, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache]
default_next: COMPLETE default_next: COMPLETE
rules: rules:
- condition: output/ にファイルがない、または内容に不足がある - condition: output/ にファイルがない、または内容に不足がある

View File

@ -45,7 +45,7 @@ movements:
- 比較・対照が必要なら表形式で整理 - 比較・対照が必要なら表形式で整理
- 全体のまとめと考察を付ける - 全体のまとめと考察を付ける
4. output/report.md を書き終えたら verify へ遷移する 4. output/report.md を書き終えたら verify へ遷移する
allowed_tools: [Read, Glob, Grep, Write, Edit, SearchNotes, ReadNote, WriteNote, 'mcp__*'] allowed_tools: [Read, Glob, Grep, Write, Edit, 'mcp__*']
default_next: verify default_next: verify
rules: rules:
- condition: output/report.md に統合レポートを作成した - condition: output/report.md に統合レポートを作成した
@ -113,7 +113,7 @@ movements:
- **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})` - **追加調査のため同じ dig を続行**: `transition({next_step: "dig"})`
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})` - **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
default_next: analyze default_next: analyze
rules: rules:
- condition: 2つ以上の独立した調査テーマがあり、並列分解が効率的と判断した - condition: 2つ以上の独立した調査テーマがあり、並列分解が効率的と判断した
@ -146,7 +146,7 @@ movements:
画像があるのにテキストだけのレポートにしないこと。 画像があるのにテキストだけのレポートにしないこと。
レポート作成中に追加で必要な図・グラフを見つけた場合も DownloadFile で収集して埋め込む。 レポート作成中に追加で必要な図・グラフを見つけた場合も DownloadFile で収集して埋め込む。
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*'] allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
default_next: verify default_next: verify
rules: rules:
- condition: output/ にレポートを書き出した - condition: output/ にレポートを書き出した
@ -199,7 +199,7 @@ movements:
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})` - 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
- 修正必要: `transition({next_step: "analyze", summary: "差し戻し指摘"})` (上記形式で) - 修正必要: `transition({next_step: "analyze", summary: "差し戻し指摘"})` (上記形式で)
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})` - 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Glob, Grep, WebSearch, WebFetch, ReadImage, AnnotateImage, ReadPdf, ReadExcel, ReadDocx, ReadPPTX, SearchNotes, ReadNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache] allowed_tools: [Read, Glob, Grep, WebSearch, WebFetch, ReadImage, AnnotateImage, ReadPdf, ReadExcel, ReadDocx, ReadPPTX, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache]
default_next: COMPLETE default_next: COMPLETE
rules: rules:
- condition: output/ にファイルがない、または内容に不足がある(追加質問への回答に検索根拠が不足している場合も含む) - condition: output/ にファイルがない、または内容に不足がある(追加質問への回答に検索根拠が不足している場合も含む)

View File

@ -118,9 +118,6 @@ movements:
- ReadDocx - ReadDocx
- ReadExcel - ReadExcel
- ReadPPTX - ReadPPTX
- SearchKnowledge
- ListDocuments
- ListNamespaces
- ReadToolDoc - ReadToolDoc
- 'mcp__*' - 'mcp__*'
default_next: verify default_next: verify

View File

@ -57,7 +57,7 @@ movements:
- **追加収集のため同じ gather を続行**: `transition({next_step: "gather"})` - **追加収集のため同じ gather を続行**: `transition({next_step: "gather"})`
- **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})` - **対象が曖昧で確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})` - **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, WebFetch, WebSearch, Read, Write, Edit, Glob, Grep, DownloadFile, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, 'mcp__*'] allowed_tools: [XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, WebFetch, WebSearch, Read, Write, Edit, Glob, Grep, DownloadFile, 'mcp__*']
default_next: analyze default_next: analyze
rules: rules:
- condition: 追加収集が必要別のSNS、追加クエリ等 - condition: 追加収集が必要別のSNS、追加クエリ等
@ -91,7 +91,7 @@ movements:
情報が不足している場合は gather に戻る(追加の検索クエリを明示すること)。 情報が不足している場合は gather に戻る(追加の検索クエリを明示すること)。
verify からの差し戻しがある場合は、指摘された不足点・期待する修正を優先的に解消すること。 verify からの差し戻しがある場合は、指摘された不足点・期待する修正を優先的に解消すること。
allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, BatchReviewTextWithLLM, MergeReviewedResults, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, 'mcp__*'] allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, BatchReviewTextWithLLM, MergeReviewedResults, 'mcp__*']
default_next: verify default_next: verify
rules: rules:
- condition: output/report.md にレポートを書き出した - condition: output/report.md にレポートを書き出した

70
pieces/workspace-app.yaml Normal file
View File

@ -0,0 +1,70 @@
name: workspace-app
description: |
ワークスペース上で動く「ワークスペース・アプリ」(自己完結 HTML の GUI ツール)を作成する専用エージェント。
ユーザーが「ワークスペースアプリを作って」「○○用の GUI を作って」「○○するツール(アプリ)を作って」と
依頼したときに選ぶ。成果物は `apps/{名前}/index.html`(インライン JS/CSS のみ・外部ネットワーク禁止)で、
ワークスペースの「アプリ」タブから起動でき、postMessage ブリッジでワークスペースのファイルを読み書きできる。
注意: ここで作るのは「アプリ(動く HTML 成果物」。orchestrator の Pieceエージェント定義の YAML
作る依頼は piece-builder の担当なので混同しないこと。
max_movements: 25
initial_movement: build
triggers:
keywords: [ワークスペースアプリ, アプリを作, アプリ作成, GUIを作, GUIツール, ミニアプリ, HTMLアプリ, HTMLツール, アプリタブ, ダッシュボードアプリ]
movements:
- name: build
edit: true
persona: builder
instruction: |
## ワークスペース・アプリ作成フェーズ
ユーザーの依頼に沿って、自己完結した HTML アプリを 1 つ作る。
### 手順
1. **最初に `ReadToolDoc({ name: "workspace-apps" })` で作り方の仕様を取得する**
ファイル配置・postMessage ブリッジの API・制約を必ず確認すること
2. アプリ名を決める(英小文字・数字・ハイフン推奨)。
3. 自己完結 HTML を `apps/{アプリ名}/index.html` に Write で書く:
- JS / CSS はインラインのみ。外部 CDN や外部ネットワークへのアクセスは禁止。
- ワークスペースのファイル I/O が必要なら、doc に従って postMessage ブリッジを使う。
- 入力フォーム・表示領域など、依頼された GUI を一通り備えた動くものにする。
4. 既存アプリの改修依頼なら、先に Read で現状の index.html を読んでから Edit する。
### 終了 / 遷移方法
- **作成・更新できた → verify へ**: `transition({next_step: "verify", summary: "アプリ名と概要"})`
- **依頼が曖昧で作れない**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Edit, Glob, Grep, Bash, ReadToolDoc, ReadImage]
default_next: verify
rules:
- condition: アプリの作成・更新が完了した
next: verify
- name: verify
edit: true
persona: reviewer
instruction: |
## 検証フェーズ
作成した `apps/{名前}/index.html` を読み返し、最低限の品質を確認する。
### 確認観点
- ファイルが `apps/{名前}/index.html` に存在し、単体で開ける HTML になっているか
- `<script src=...>` や外部 CDN・外部ネットワーク参照が混入していないかインラインのみか
- 依頼された GUI 要素・操作が一通り入っているか
- ファイル I/O を使う場合、doc どおりの postMessage ブリッジ手順になっているか
問題があれば Edit で直す。直しきれない設計上の問題なら build に戻す。
### 終了方法
- **合格**: `complete({status: "success", result: ...})` を呼ぶ。result はそのままユーザーに表示される。
作ったアプリ名・できること・「ワークスペースの『アプリ』タブから起動できる」旨を簡潔に伝える
(「作成しました」等のメタ説明ではなく、アプリの内容そのものを書く)。
- **作り直しが必要 → build へ**: `transition({next_step: "build", summary: "具体的な指摘"})`
- **技術的失敗**: `complete({status: "aborted", abort_reason: "..."})`
allowed_tools: [Read, Write, Edit, Glob, Grep, Bash, ReadToolDoc]
default_next: COMPLETE
rules:
- condition: 作り直しが必要
next: build

View File

@ -73,11 +73,6 @@ movements:
- Grep - Grep
- Bash - Bash
- DownloadFile - DownloadFile
- SearchKnowledge
- ListNamespaces
- ListDocuments
- SearchNotes
- ReadNote
- 'mcp__*' - 'mcp__*'
default_next: compose default_next: compose
rules: rules:

View File

@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Stop hook — ヘルプ/更新履歴の更新もれを知らせる(警告のみ・非ブロック)。
#
# フィーチャーブランチに「ユーザー向けのコード変更」が commit 済みなのに、
# ヘルプ (ui/src/content/help/) と更新履歴 (00-changelog.md) が未更新のとき、
# 目に見える警告を 1 つ出すだけ。作業はブロックしない(更新するかは開発者が判断)。
#
# 設計メモ:
# - main/master では何もしない(トランク自体には enforce しない)
# - 判定は base ブランチとの "commit 済み差分" のみ。編集途中(未コミット)では黙る
# - ヒューリスティックなので、内部リファクタ等で誤検知したら無視してよい
# `set -e` は使わない([ ... ] && exit の評価で意図せず抜けるのを避けるため)。
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)"
[ -n "$ROOT" ] || exit 0
cd "$ROOT" 2>/dev/null || exit 0
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"
case "$BRANCH" in
main|master|HEAD|"") exit 0 ;;
esac
# 差分の基準ブランチを決める。
BASE=""
for ref in origin/main main origin/master master; do
if git rev-parse --verify --quiet "$ref" >/dev/null 2>&1; then
BASE="$ref"
break
fi
done
[ -n "$BASE" ] || exit 0
# このブランチが base に対して触ったファイルcommit 済みのみ)。
CHANGED="$(git diff --name-only "$BASE"...HEAD 2>/dev/null)"
[ -n "$CHANGED" ] || exit 0
CHANGELOG="ui/src/content/help/00-changelog.md"
changelog_touched=0
user_facing=0
while IFS= read -r f; do
[ -n "$f" ] || continue
case "$f" in
"$CHANGELOG") changelog_touched=1 ;;
esac
case "$f" in
*.test.ts|*.test.tsx|*.spec.ts) ;; # テストは対象外
ui/src/content/help/*) ;; # ヘルプ本文そのものは対象外
ui/src/i18n/*) ;; # 文言だけの変更は対象外
ui/src/*|src/*|pieces/*) user_facing=1 ;; # ユーザーに影響しうるコード
esac
done <<< "$CHANGED"
# ユーザー向け変更が無い、または更新履歴を既に更新済みなら何も言わない。
[ "$user_facing" -eq 1 ] || exit 0
[ "$changelog_touched" -eq 0 ] || exit 0
MSG="📝 ヘルプ未更新のお知らせ: ブランチ '${BRANCH}' に機能コードの変更がありますが、更新履歴 (${CHANGELOG}) は未更新です。ユーザー向けの変更なら、該当するヘルプ (ui/src/content/help/*.md) と更新履歴に追記してください。内部リファクタ等で不要なら無視して構いません。"
# 可視の警告として出すだけdecision を設定しないので stop はブロックしない)。
# Stop フックは stdout の JSON `systemMessage` のみを画面に出すstderr は出ない)ため、
# 必ず stdout に JSON を書く。
if command -v python3 >/dev/null 2>&1; then
printf '{"systemMessage": %s}\n' "$(MSG="$MSG" python3 -c 'import json,os; print(json.dumps(os.environ["MSG"]))')"
else
# python3 が無い環境向けフォールバック。" と \ を手でエスケープして stdout に出す。
esc="$(printf '%s' "$MSG" | sed 's/\\/\\\\/g; s/"/\\"/g')"
printf '{"systemMessage": "%s"}\n' "$esc"
fi
exit 0

View File

@ -2,7 +2,7 @@
# folder customisations. Tests that: # folder customisations. Tests that:
# - mcp.* and ssh.* pass through untouched # - mcp.* and ssh.* pass through untouched
# - user_folder_root migrates into storage.user_folder_root # - user_folder_root migrates into storage.user_folder_root
# - tools.knowledge_namespaces[*].api_key keeps ${ENV} reference literal # - tools.keepa_api_key keeps ${ENV} reference literal
provider: provider:
model: qwen3:32b model: qwen3:32b
@ -12,12 +12,7 @@ worktree_dir: /var/lib/agent-orchestrator/workspaces
user_folder_root: /opt/aao/data/users user_folder_root: /opt/aao/data/users
tools: tools:
knowledge_service_url: http://dks-server:8100 keepa_api_key: ${KEEPA_API_KEY}
knowledge_namespaces:
eng:
api_key: ${DKS_ENG_KEY}
ops:
api_key: ${DKS_OPS_KEY}
task_upload_max_size_mb: 100 task_upload_max_size_mb: 100
mcp: mcp:

View File

@ -91,7 +91,7 @@ describe('admin-api local user management', () => {
const agent = await adminAgent(); const agent = await adminAgent();
const u = repo.createLocalUser({ email: 'dave@x.com', password: 'davepw12', role: 'user', status: 'active' }); const u = repo.createLocalUser({ email: 'dave@x.com', password: 'davepw12', role: 'user', status: 'active' });
const dir = ensureUserFolder(userFolderRoot, u.id); const dir = ensureUserFolder(userFolderRoot, u.id);
writeFileSync(join(dir, 'notes', 'secret.md'), 'data'); writeFileSync(join(dir, 'memory', 'secret.md'), 'data');
expect(existsSync(dir)).toBe(true); expect(existsSync(dir)).toBe(true);
const res = await agent.delete(`/api/admin/users/${u.id}`); const res = await agent.delete(`/api/admin/users/${u.id}`);

View File

@ -88,6 +88,26 @@ describe('local auth routes', () => {
expect(res.headers['location']).toBe('/'); expect(res.headers['location']).toBe('/');
}); });
it('returns to a saved returnTo after a successful local login', async () => {
repo.createLocalUser({ email: 'rt@x.com', password: 'pw12345678', role: 'user', status: 'active' });
const agent = request.agent(app);
// Visiting the login page with returnTo stashes it on the session; it must
// survive the req.login session regeneration (keepSessionInfo).
await agent.get('/auth/login?returnTo=' + encodeURIComponent('/spaces/abc?x=1'));
const res = await agent.post('/auth/local').type('form').send({ email: 'rt@x.com', password: 'pw12345678' });
expect(res.status).toBe(302);
expect(res.headers['location']).toBe('/spaces/abc?x=1');
});
it('ignores an unsafe returnTo and falls back home', async () => {
repo.createLocalUser({ email: 'rt2@x.com', password: 'pw12345678', role: 'user', status: 'active' });
const agent = request.agent(app);
await agent.get('/auth/login?returnTo=' + encodeURIComponent('//evil.com'));
const res = await agent.post('/auth/local').type('form').send({ email: 'rt2@x.com', password: 'pw12345678' });
expect(res.status).toBe(302);
expect(res.headers['location']).toBe('/');
});
it('POST /auth/local with wrong password redirects to login error', async () => { it('POST /auth/local with wrong password redirects to login error', async () => {
repo.createLocalUser({ email: 'b@x.com', password: 'rightpassword', role: 'user', status: 'active' }); repo.createLocalUser({ email: 'b@x.com', password: 'rightpassword', role: 'user', status: 'active' });
const res = await request(app).post('/auth/local').type('form').send({ email: 'b@x.com', password: 'nope' }); const res = await request(app).post('/auth/local').type('form').send({ email: 'b@x.com', password: 'nope' });

View File

@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { Repository } from '../db/repository.js'; import { Repository } from '../db/repository.js';
import { requireAuth, requireAdmin, fetchGiteaOrgsForUser, isProviderConfigured, isProviderActive } from './auth.js'; import { requireAuth, requireAdmin, fetchGiteaOrgsForUser, isProviderConfigured, isProviderActive, safeReturnTo, isTopLevelNavigation, consumeReturnTo } from './auth.js';
import type { AuthConfig } from '../config.js'; import type { AuthConfig } from '../config.js';
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
@ -49,6 +49,110 @@ describe('requireAuth', () => {
requireAuth(req, res, next); requireAuth(req, res, next);
expect(res.redirect).toHaveBeenCalledWith('/auth/login'); expect(res.redirect).toHaveBeenCalledWith('/auth/login');
}); });
it('redirects a top-level navigation to /auth/login?returnTo=<url> (even under /api/)', () => {
const { req, res, next } = mockReqRes({
method: 'GET',
originalUrl: '/api/local/tasks/1/files/raw?trusted=1&path=report.html',
headers: { 'sec-fetch-mode': 'navigate', accept: 'text/html' },
} as Partial<Request>);
requireAuth(req, res, next);
expect(res.redirect).toHaveBeenCalledWith(
'/auth/login?returnTo=' +
encodeURIComponent('/api/local/tasks/1/files/raw?trusted=1&path=report.html'),
);
expect(next).not.toHaveBeenCalled();
});
it('returns 401 JSON for an unauthenticated SPA fetch (sec-fetch-mode=cors), not a redirect', () => {
const { req, res, next } = mockReqRes({
method: 'GET',
originalUrl: '/api/local/tasks',
headers: { 'sec-fetch-mode': 'cors', accept: 'application/json' },
} as Partial<Request>);
requireAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.redirect).not.toHaveBeenCalled();
});
it('returns 401 JSON for an unauthenticated POST even with an HTML Accept header', () => {
const { req, res, next } = mockReqRes({
method: 'POST',
originalUrl: '/api/local/tasks',
headers: { accept: 'text/html' },
} as Partial<Request>);
requireAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.redirect).not.toHaveBeenCalled();
});
});
describe('safeReturnTo', () => {
it('accepts a same-origin absolute path', () => {
expect(safeReturnTo('/spaces/abc')).toBe('/spaces/abc');
expect(safeReturnTo('/api/local/tasks/1/files/raw?trusted=1&path=a.html')).toBe(
'/api/local/tasks/1/files/raw?trusted=1&path=a.html',
);
expect(safeReturnTo('/')).toBe('/');
});
it('rejects open-redirect and non-path inputs', () => {
expect(safeReturnTo('//evil.com')).toBeNull(); // protocol-relative
expect(safeReturnTo('/\\evil.com')).toBeNull(); // backslash smuggling
expect(safeReturnTo('https://evil.com')).toBeNull(); // absolute URL
expect(safeReturnTo('http://evil.com')).toBeNull();
expect(safeReturnTo('javascript:alert(1)')).toBeNull();
expect(safeReturnTo('spaces/abc')).toBeNull(); // not rooted
expect(safeReturnTo('/foo\nbar')).toBeNull(); // control char
expect(safeReturnTo('')).toBeNull();
expect(safeReturnTo(undefined)).toBeNull();
expect(safeReturnTo(123 as unknown)).toBeNull();
expect(safeReturnTo('/' + 'a'.repeat(3000))).toBeNull(); // too long
});
});
describe('consumeReturnTo', () => {
it('returns the saved value and clears it (single use)', () => {
const session = { returnTo: '/spaces/once' } as { returnTo?: string };
const req = { session } as unknown as Request;
expect(consumeReturnTo(req)).toBe('/spaces/once');
expect(session.returnTo).toBeUndefined();
expect(consumeReturnTo(req)).toBeNull(); // already consumed
});
it('re-validates the stored value and rejects unsafe paths', () => {
const req = { session: { returnTo: '//evil.com' } } as unknown as Request;
expect(consumeReturnTo(req)).toBeNull();
});
it('returns null when there is no session', () => {
expect(consumeReturnTo({} as unknown as Request)).toBeNull();
});
});
describe('isTopLevelNavigation', () => {
const mk = (over: Partial<Request>) =>
({ method: 'GET', headers: {}, ...over } as unknown as Request);
it('treats GET with Sec-Fetch-Mode: navigate as a navigation', () => {
expect(isTopLevelNavigation(mk({ headers: { 'sec-fetch-mode': 'navigate' } }))).toBe(true);
});
it('falls back to Accept: text/html when Sec-Fetch-Mode is absent', () => {
expect(isTopLevelNavigation(mk({ headers: { accept: 'text/html,*/*' } }))).toBe(true);
});
it('does not treat a CORS fetch as a navigation', () => {
expect(
isTopLevelNavigation(mk({ headers: { 'sec-fetch-mode': 'cors', accept: 'text/html' } })),
).toBe(false);
});
it('does not treat non-GET as a navigation', () => {
expect(isTopLevelNavigation(mk({ method: 'POST', headers: { accept: 'text/html' } }))).toBe(
false,
);
});
}); });
describe('requireAdmin', () => { describe('requireAdmin', () => {

View File

@ -15,6 +15,15 @@ import { logger } from '../logger.js';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { LoginRateLimiter, throttleScopeForIp } from './login-rate-limit.js'; import { LoginRateLimiter, throttleScopeForIp } from './login-rate-limit.js';
// Stash the URL a user was trying to reach before being bounced to login, so we
// can send them back after authenticating. Lives on the passport session and
// survives the OAuth round trip.
declare module 'express-session' {
interface SessionData {
returnTo?: string;
}
}
/** /**
* WebSocket upgrade IncomingMessage * WebSocket upgrade IncomingMessage
* Express express-session + passport * Express express-session + passport
@ -209,10 +218,57 @@ declare global {
// ── Middleware ──────────────────────────────────────────────────────────────── // ── Middleware ────────────────────────────────────────────────────────────────
/**
* safeReturnTo: オープンリダイレクト対策
* `/` null
* - (`//host`) (`/\host`)
* - (`http:` / `javascript:` ) `/`
* -
*/
export function safeReturnTo(value: unknown): string | null {
if (typeof value !== 'string') return null;
if (value.length === 0 || value.length > 2048) return null;
if (value[0] !== '/') return null; // must be a rooted, same-origin path
if (value[1] === '/') return null; // protocol-relative //host
if (value.includes('\\')) return null; // backslash smuggling (/\host)
if (/[\u0000-\u001f]/.test(value)) return null; // control chars (CR/LF, tab, etc.)
return value;
}
/**
* isTopLevelNavigation: トップレベルのブラウザ遷移
* iframe SPA fetch/XHR
* - GET false
* - `Sec-Fetch-Mode: navigate`
* - `Accept: text/html`
*/
export function isTopLevelNavigation(req: Request): boolean {
if (req.method !== 'GET') return false;
const headers = (req.headers ?? {}) as Record<string, string | string[] | undefined>;
const secFetchMode = headers['sec-fetch-mode'];
if (typeof secFetchMode === 'string') return secFetchMode === 'navigate';
const accept = headers['accept'];
return typeof accept === 'string' && accept.includes('text/html');
}
/**
* consumeReturnTo: session 退 returnTo
* 使 null
*/
export function consumeReturnTo(req: Request): string | null {
const session = req.session as (typeof req.session & { returnTo?: string }) | undefined;
if (!session) return null;
const raw = session.returnTo;
if (raw !== undefined) delete session.returnTo;
return safeReturnTo(raw);
}
/** /**
* requireAuth: 認証済みかつ status=active * requireAuth: 認証済みかつ status=active
* API /api/ 401 JSON * (returnTo)
* /auth/login * HTMLの直リンク等を救済する
* /api/ 401 JSON
* /auth/loginreturnTo
*/ */
export function requireAuth(req: Request, res: Response, next: NextFunction): void { export function requireAuth(req: Request, res: Response, next: NextFunction): void {
if (req.isAuthenticated() && req.user && (req.user as Express.User).status === 'active') { if (req.isAuthenticated() && req.user && (req.user as Express.User).status === 'active') {
@ -220,6 +276,13 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
return; return;
} }
if (isTopLevelNavigation(req)) {
const returnTo = safeReturnTo(req.originalUrl);
const query = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : '';
res.redirect(`/auth/login${query}`);
return;
}
if (req.originalUrl.startsWith('/api/')) { if (req.originalUrl.startsWith('/api/')) {
res.status(401).json({ error: 'Unauthorized' }); res.status(401).json({ error: 'Unauthorized' });
} else { } else {
@ -617,7 +680,11 @@ function createAuthRouter(
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ログインページ // ログインページ
router.get('/login', (_req: Request, res: Response) => { router.get('/login', (req: Request, res: Response) => {
// 戻り先 (returnTo) が付いていれば session に退避する。OAuth は外部往復で
// session が生き残るため、ここで保存すれば全プロバイダで復帰できる。
const returnTo = safeReturnTo(req.query.returnTo);
if (returnTo && req.session) req.session.returnTo = returnTo;
const branding = getBranding ? getBranding() : DEFAULT_LOGIN_BRANDING; const branding = getBranding ? getBranding() : DEFAULT_LOGIN_BRANDING;
res.type('html').send(renderLoginPage(authConfig, branding)); res.type('html').send(renderLoginPage(authConfig, branding));
}); });
@ -646,11 +713,11 @@ function createAuthRouter(
router.get( router.get(
'/google/callback', '/google/callback',
passport.authenticate('google', { failureRedirect: '/auth/login' }), passport.authenticate('google', { failureRedirect: '/auth/login', keepSessionInfo: true }),
(req, res) => { (req, res) => {
const user = req.user as Express.User | undefined; const user = req.user as Express.User | undefined;
if (user?.status === 'active') { if (user?.status === 'active') {
res.redirect('/'); res.redirect(consumeReturnTo(req) ?? '/');
} else { } else {
res.redirect('/auth/pending'); res.redirect('/auth/pending');
} }
@ -664,11 +731,11 @@ function createAuthRouter(
router.get( router.get(
'/gitea/callback', '/gitea/callback',
passport.authenticate('gitea', { failureRedirect: '/auth/login' }), passport.authenticate('gitea', { failureRedirect: '/auth/login', keepSessionInfo: true }),
(req, res) => { (req, res) => {
const user = req.user as Express.User | undefined; const user = req.user as Express.User | undefined;
if (user?.status === 'active') { if (user?.status === 'active') {
res.redirect('/'); res.redirect(consumeReturnTo(req) ?? '/');
} else { } else {
res.redirect('/auth/pending'); res.redirect('/auth/pending');
} }
@ -741,9 +808,12 @@ function createAuthRouter(
// regenerates the session id itself, so no explicit regenerate is needed // regenerates the session id itself, so no explicit regenerate is needed
// here for session-fixation protection. // here for session-fixation protection.
loginLimiter.reset(ipKey); loginLimiter.reset(ipKey);
req.login(toExpressUser(user), (err) => { // keepSessionInfo: req.login regenerates the session id (fixation
// protection) and would otherwise drop the returnTo we stashed on
// GET /auth/login — keep it so the post-login redirect can use it.
req.login(toExpressUser(user), { session: true, keepSessionInfo: true }, (err) => {
if (err) { next(err); return; } if (err) { next(err); return; }
res.redirect(user.status === 'active' ? '/' : '/auth/pending'); res.redirect(user.status === 'active' ? (consumeReturnTo(req) ?? '/') : '/auth/pending');
}); });
}); });

View File

@ -83,7 +83,9 @@ async function canViewSession(
const taskIdNum = Number(session.taskId); const taskIdNum = Number(session.taskId);
if (!Number.isFinite(taskIdNum)) return false; if (!Number.isFinite(taskIdNum)) return false;
const task = await repo.getLocalTask(taskIdNum); const task = await repo.getLocalTask(taskIdNum);
return task ? canUserSeeTask(user, task) : false; if (!task) return false;
const spaceAccessible = task.spaceId ? repo.userCanViewSpace(task.spaceId, user) : false;
return canUserSeeTask(user, task, spaceAccessible);
} }
// 旧来 (kind 未設定): owner だけ // 旧来 (kind 未設定): owner だけ
return session.userId === user.id; return session.userId === user.id;

View File

@ -180,6 +180,139 @@ describe('browser-session-api', () => {
}); });
}); });
describe('browser-session-api per-space scoping (workstream 2)', () => {
let tempDir = '';
let repository: Repository | null = null;
afterEach(() => {
if (repository) { repository.close(); repository = null; }
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
});
function viewerUser(id: string, role: 'admin' | 'user' = 'user'): Express.User {
return {
id, email: `${id}@x.com`, name: id, avatarUrl: null, role,
status: 'active', orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null,
} as Express.User;
}
// Builds an app whose req.user is whatever `current` points at — lets a single
// app act as different members across requests.
async function setup() {
tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsapi-space-'));
repository = new Repository(join(tempDir, 'orchestrator.db'));
const repo = repository;
const db = repo.getDb();
for (const id of ['alice', 'bob', 'carol']) {
db.prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
VALUES (?, ?, 'active', 'active', datetime('now'), datetime('now'))`).run(id, `${id}@x`);
}
const sessRepo = new BrowserSessionRepo(db);
const masterKeyPath = join(tempDir, 'master.key');
// alice owns a case space; bob is an editor; carol is NOT a member.
const space = await repo.createSpace({ kind: 'case', title: 'Case A', ownerId: 'alice', visibility: 'private' });
await repo.addSpaceMember({ spaceId: space.id, userId: 'bob', role: 'editor' });
const current = { user: viewerUser('alice') };
const app = express();
app.use(express.json());
app.use((req, _res, next) => { (req as { user?: unknown }).user = current.user; next(); });
app.use('/api/browser-sessions', createBrowserSessionApi({
sessRepo,
sessionManager: null,
masterKeyPath,
spaceAccess: {
getSpace: (spaceId, viewer) => repo.getSpace(spaceId, { viewer }),
getSpaceMemberRole: (spaceId, userId) => repo.getSpaceMemberRole(spaceId, userId),
},
}));
return { app, repo, sessRepo, spaceId: space.id, current };
}
it('member can create + list a profile in a space; owner_id stays the creator', async () => {
const { app, sessRepo, spaceId, current } = await setup();
current.user = viewerUser('bob'); // editor
const create = await request(app)
.post('/api/browser-sessions/profiles')
.send({ label: 'Shared', startUrl: 'https://s.com', spaceId });
expect(create.status).toBe(201);
expect(create.body.profile.spaceId).toBe(spaceId);
expect(create.body.profile.ownerId).toBe('bob');
const list = await request(app).get('/api/browser-sessions/profiles').query({ spaceId });
expect(list.status).toBe(200);
expect(list.body.profiles.map((p: { label: string }) => p.label)).toEqual(['Shared']);
// DB confirms owner_id is bob (DEK stays per-user), space_id is the space.
const row = sessRepo.listProfilesBySpace(spaceId)[0]!;
expect(row.ownerId).toBe('bob');
expect(row.spaceId).toBe(spaceId);
});
it('non-member (carol) cannot view or create in the space (404)', async () => {
const { app, spaceId, current } = await setup();
current.user = viewerUser('carol');
const list = await request(app).get('/api/browser-sessions/profiles').query({ spaceId });
expect(list.status).toBe(404);
const create = await request(app)
.post('/api/browser-sessions/profiles')
.send({ label: 'Nope', startUrl: 'https://n.com', spaceId });
expect(create.status).toBe(404);
});
it('decryptableByViewer is true only for the owner; other members see false', async () => {
const { app, spaceId, current } = await setup();
current.user = viewerUser('bob');
await request(app).post('/api/browser-sessions/profiles').send({ label: 'Bobs', startUrl: 'https://b.com', spaceId });
// alice (owner of the space, but not the profile owner) sees it but cannot decrypt.
current.user = viewerUser('alice');
const list = await request(app).get('/api/browser-sessions/profiles').query({ spaceId });
expect(list.status).toBe(200);
const p = list.body.profiles[0];
expect(p.ownerId).toBe('bob');
expect(p.decryptableByViewer).toBe(false);
// bob (the owner) sees decryptableByViewer = true.
current.user = viewerUser('bob');
const bobList = await request(app).get('/api/browser-sessions/profiles').query({ spaceId });
expect(bobList.body.profiles[0].decryptableByViewer).toBe(true);
});
it('canEditInSpace gates delete: editor 200, viewer 403', async () => {
const { app, repo, spaceId, current } = await setup();
// carol joins as a viewer (read-only).
await repo.addSpaceMember({ spaceId, userId: 'carol', role: 'viewer' });
current.user = viewerUser('bob'); // editor
const create = await request(app)
.post('/api/browser-sessions/profiles')
.send({ label: 'Del', startUrl: 'https://d.com', spaceId });
const id = create.body.profile.id as number;
// viewer carol: forbidden
current.user = viewerUser('carol');
const denied = await request(app).delete(`/api/browser-sessions/profiles/${id}`).query({ spaceId });
expect(denied.status).toBe(403);
// editor bob: allowed
current.user = viewerUser('bob');
const ok = await request(app).delete(`/api/browser-sessions/profiles/${id}`).query({ spaceId });
expect(ok.status).toBe(200);
});
it('personal (no spaceId) path is unchanged — owner-scoped create + list', async () => {
const { app, current } = await setup();
current.user = viewerUser('alice');
const create = await request(app).post('/api/browser-sessions/profiles').send({ label: 'Personal', startUrl: 'https://p.com' });
expect(create.status).toBe(201);
expect(create.body.profile.spaceId).toBeNull();
const list = await request(app).get('/api/browser-sessions/profiles');
expect(list.body.profiles.map((p: { label: string }) => p.label)).toEqual(['Personal']);
});
});
describe('login + save flow', () => { describe('login + save flow', () => {
let tempDirsToClean: string[] = []; let tempDirsToClean: string[] = [];
let repositoryToClose: Repository | null = null; let repositoryToClose: Repository | null = null;

View File

@ -3,15 +3,10 @@ import { readFileSync } from 'fs';
import { createRequire } from 'module'; import { createRequire } from 'module';
import type { BrowserSessionRepo, BrowserSessionProfile } from '../db/browser-session-repo.js'; import type { BrowserSessionRepo, BrowserSessionProfile } from '../db/browser-session-repo.js';
import type { SessionManager } from '../engine/browser-session.js'; import type { SessionManager } from '../engine/browser-session.js';
import { import { encryptStateBlob } from '../crypto/sessions.js';
initMasterKey, import { ensureProfileEncryptDek, decryptProfileState } from '../crypto/profile-dek.js';
generateUserDek,
encryptUserDek,
decryptUserDek,
encryptStateBlob,
decryptStateBlob,
} from '../crypto/sessions.js';
import { buildNovncPath } from './novnc-proxy.js'; import { buildNovncPath } from './novnc-proxy.js';
import { canEditInSpace } from './visibility.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
const requireFromHere = createRequire(import.meta.url); const requireFromHere = createRequire(import.meta.url);
@ -29,11 +24,25 @@ function getPlaywrightVersion(): string {
return cachedPlaywrightVersion; return cachedPlaywrightVersion;
} }
/**
* Per-space access helpers. Optional: when omitted, the API behaves exactly as
* before (owner-scoped only) and any `space_id` on a request is ignored. When
* present, a request carrying a `space_id` (query or body) is scoped to that
* space: VIEW gated by space membership, CREATE/DELETE by canEditInSpace.
*/
interface SpaceAccess {
/** The space's root owner (for canEditInSpace) or null when not visible. */
getSpace: (spaceId: string, viewer?: Express.User) => Promise<{ id: string; ownerId: string | null } | null>;
/** The viewer's explicit member role in the space (null = not a member row). */
getSpaceMemberRole: (spaceId: string, userId: string) => 'owner' | 'editor' | 'viewer' | null;
}
interface Deps { interface Deps {
sessRepo: BrowserSessionRepo; sessRepo: BrowserSessionRepo;
sessionManager: SessionManager | null; sessionManager: SessionManager | null;
masterKeyPath: string; masterKeyPath: string;
authActive?: boolean; authActive?: boolean;
spaceAccess?: SpaceAccess;
} }
interface AuthedUser { interface AuthedUser {
@ -45,9 +54,25 @@ function getUser(req: Request): AuthedUser | null {
return (req.user as AuthedUser | undefined) ?? null; return (req.user as AuthedUser | undefined) ?? null;
} }
/** JSON-friendly subset of a profile. NEVER includes the encrypted blob. */ /** Extract a spaceId from the request (query param wins over body). */
function serializeProfile(p: BrowserSessionProfile): { function getSpaceId(req: Request): string | undefined {
const fromQuery = typeof req.query?.['spaceId'] === 'string' ? (req.query['spaceId'] as string) : undefined;
const body = (req.body ?? {}) as Record<string, unknown>;
const fromBody = typeof body['spaceId'] === 'string' ? (body['spaceId'] as string) : undefined;
return fromQuery ?? fromBody ?? undefined;
}
/**
* JSON-friendly subset of a profile. NEVER includes the encrypted blob.
* `viewerId` (the requesting user) is compared against owner_id to surface
* `decryptableByViewer` a space member who is NOT the owner can see the
* profile in the list but cannot decrypt/use it (DEK is owner-bound).
*/
function serializeProfile(p: BrowserSessionProfile, viewerId?: string): {
id: number; id: number;
spaceId: string | null;
ownerId: string;
decryptableByViewer: boolean;
label: string; label: string;
startUrl: string; startUrl: string;
matchPatterns: string[]; matchPatterns: string[];
@ -65,6 +90,9 @@ function serializeProfile(p: BrowserSessionProfile): {
} { } {
return { return {
id: p.id, id: p.id,
spaceId: p.spaceId,
ownerId: p.ownerId,
decryptableByViewer: viewerId === undefined ? true : p.ownerId === viewerId,
label: p.label, label: p.label,
startUrl: p.startUrl, startUrl: p.startUrl,
matchPatterns: p.matchPatterns, matchPatterns: p.matchPatterns,
@ -82,22 +110,6 @@ function serializeProfile(p: BrowserSessionProfile): {
}; };
} }
/**
* Ensure the user has a DEK, creating + persisting one if needed.
* Returns the decrypted DEK (32 bytes) for use with state-blob encryption.
*/
export function ensureUserDek(deps: Deps, userId: string): Buffer {
const master = initMasterKey(deps.masterKeyPath);
let enc = deps.sessRepo.getUserDek(userId);
if (!enc) {
const dek = generateUserDek();
enc = encryptUserDek(master, dek);
deps.sessRepo.setUserDek(userId, enc);
return dek;
}
return decryptUserDek(master, enc);
}
function isStringArray(v: unknown): v is string[] { function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every(s => typeof s === 'string'); return Array.isArray(v) && v.every(s => typeof s === 'string');
} }
@ -120,17 +132,55 @@ export function createBrowserSessionApi(deps: Deps): Router {
next(); next();
}); });
// GET /profiles — list owned profiles // GET /profiles — list profiles.
r.get('/profiles', (req: Request, res: Response) => { // - personal (no spaceId): owner-scoped (back-compat).
// - spaceId + spaceAccess: that space's profiles, gated by membership.
r.get('/profiles', async (req: Request, res: Response) => {
const u = getUser(req)!; const u = getUser(req)!;
const profiles = deps.sessRepo.listProfilesByOwner(u.id).map(serializeProfile); const spaceId = getSpaceId(req);
if (spaceId && deps.spaceAccess) {
const space = await deps.spaceAccess.getSpace(spaceId, req.user);
if (!space) {
res.status(404).json({ error: 'space not found' });
return;
}
// VIEW = any space member (getSpace already ran the membership-aware
// visibility gate and returned null for non-members).
const profiles = deps.sessRepo.listProfilesBySpace(spaceId).map((p) => serializeProfile(p, u.id));
res.json({ profiles });
return;
}
const profiles = deps.sessRepo.listProfilesByOwner(u.id).map((p) => serializeProfile(p, u.id));
res.json({ profiles }); res.json({ profiles });
}); });
// POST /profiles — create a new profile (status=pending, no blob yet) // POST /profiles — create a new profile (status=pending, no blob yet)
r.post('/profiles', (req: Request, res: Response) => { r.post('/profiles', async (req: Request, res: Response) => {
const u = getUser(req)!; const u = getUser(req)!;
const b = (req.body ?? {}) as Record<string, unknown>; const b = (req.body ?? {}) as Record<string, unknown>;
const spaceId = getSpaceId(req);
// Space-context create: gate by canEditInSpace. owner_id stays = the
// creating user (DEK is owner-bound); space_id groups the profile.
if (spaceId && deps.spaceAccess) {
const space = await deps.spaceAccess.getSpace(spaceId, req.user);
if (!space) {
res.status(404).json({ error: 'space not found' });
return;
}
const memberRole = deps.spaceAccess.getSpaceMemberRole(spaceId, u.id);
if (!canEditInSpace(req.user as Express.User, { ownerId: space.ownerId }, memberRole)) {
res.status(403).json({ error: 'forbidden: requires edit access in this space' });
return;
}
} else if (spaceId && !deps.spaceAccess) {
// A space was requested but no resolver is wired — refuse rather than
// silently creating a personal profile.
res.status(400).json({ error: 'space context unavailable' });
return;
}
if (typeof b['label'] !== 'string' || (b['label'] as string).length === 0) { if (typeof b['label'] !== 'string' || (b['label'] as string).length === 0) {
res.status(400).json({ error: 'label is required and must be a non-empty string' }); res.status(400).json({ error: 'label is required and must be a non-empty string' });
@ -159,6 +209,7 @@ export function createBrowserSessionApi(deps: Deps): Router {
const id = deps.sessRepo.createProfile({ const id = deps.sessRepo.createProfile({
ownerId: u.id, ownerId: u.id,
spaceId: spaceId ?? null,
label: b['label'] as string, label: b['label'] as string,
startUrl: b['startUrl'] as string, startUrl: b['startUrl'] as string,
matchPatterns: isStringArray(b['matchPatterns']) ? b['matchPatterns'] : [], matchPatterns: isStringArray(b['matchPatterns']) ? b['matchPatterns'] : [],
@ -176,17 +227,49 @@ export function createBrowserSessionApi(deps: Deps): Router {
}); });
const profile = deps.sessRepo.getProfileById(id, u.id); const profile = deps.sessRepo.getProfileById(id, u.id);
res.status(201).json({ profile: serializeProfile(profile!) }); res.status(201).json({ profile: serializeProfile(profile!, u.id) });
}); });
// DELETE /profiles/:id — owner-only delete // DELETE /profiles/:id
r.delete('/profiles/:id', (req: Request, res: Response) => { // - personal (no spaceId): owner-only delete (back-compat).
// - spaceId + spaceAccess: any space editor/owner may delete a profile
// grouped under that space (canEditInSpace).
r.delete('/profiles/:id', async (req: Request, res: Response) => {
const u = getUser(req)!; const u = getUser(req)!;
const id = Number(req.params['id']); const id = Number(req.params['id']);
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' }); res.status(400).json({ error: 'invalid id' });
return; return;
} }
const spaceId = getSpaceId(req);
if (spaceId && deps.spaceAccess) {
const space = await deps.spaceAccess.getSpace(spaceId, req.user);
if (!space) {
res.status(404).json({ error: 'space not found' });
return;
}
const memberRole = deps.spaceAccess.getSpaceMemberRole(spaceId, u.id);
if (!canEditInSpace(req.user as Express.User, { ownerId: space.ownerId }, memberRole)) {
res.status(403).json({ error: 'forbidden: requires edit access in this space' });
return;
}
const ownerForAudit = deps.sessRepo.getProfileByIdInSpace(id, spaceId)?.ownerId ?? null;
const ok = deps.sessRepo.deleteProfileInSpace(id, spaceId);
if (!ok) {
res.status(404).json({ error: 'not found' });
return;
}
deps.sessRepo.audit({
actorUserId: u.id,
ownerId: ownerForAudit,
profileId: id,
action: 'delete',
result: 'success',
});
res.json({ ok: true });
return;
}
const ok = deps.sessRepo.deleteProfile(id, u.id); const ok = deps.sessRepo.deleteProfile(id, u.id);
if (!ok) { if (!ok) {
@ -292,8 +375,15 @@ export function createBrowserSessionApi(deps: Deps): Router {
throw new Error('login session has no browser context'); throw new Error('login session has no browser context');
} }
const state = await session.context.storageState(); const state = await session.context.storageState();
const dek = ensureUserDek(deps, u.id); // Space-owned profile → encrypt under the space DEK (survives member
const blob = encryptStateBlob(dek, JSON.stringify(state)); // turnover); personal/legacy → owner DEK. owner stays u.id (created_by).
const dek = ensureProfileEncryptDek(deps, u.id, profile.spaceId);
let blob: Buffer;
try {
blob = encryptStateBlob(dek, JSON.stringify(state));
} finally {
dek.fill(0);
}
deps.sessRepo.saveProfileBlob(id, blob, getPlaywrightVersion()); deps.sessRepo.saveProfileBlob(id, blob, getPlaywrightVersion());
deps.sessRepo.audit({ deps.sessRepo.audit({
actorUserId: u.id, actorUserId: u.id,
@ -304,7 +394,7 @@ export function createBrowserSessionApi(deps: Deps): Router {
}); });
await deps.sessionManager.destroySession(sessionId); await deps.sessionManager.destroySession(sessionId);
const fresh = deps.sessRepo.getProfileById(id, u.id)!; const fresh = deps.sessRepo.getProfileById(id, u.id)!;
res.json({ profile: serializeProfile(fresh) }); res.json({ profile: serializeProfile(fresh, u.id) });
} catch (e) { } catch (e) {
const msg = (e as Error).message; const msg = (e as Error).message;
deps.sessRepo.audit({ deps.sessRepo.audit({
@ -382,8 +472,7 @@ export function createBrowserSessionApi(deps: Deps): Router {
let stateJson: string; let stateJson: string;
try { try {
const dek = ensureUserDek(deps, u.id); stateJson = decryptProfileState(deps, profile);
stateJson = decryptStateBlob(dek, profile.encryptedStateBlob);
deps.sessRepo.audit({ deps.sessRepo.audit({
actorUserId: u.id, actorUserId: u.id,
ownerId: u.id, ownerId: u.id,

View File

@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { Repository } from '../db/repository.js';
import { createCrossCalendarApi } from './cross-calendar-api.js';
type TestUser = { id: string; role: 'user' | 'admin'; orgIds: string[] };
function makeApp(repo: Repository, user: TestUser | null) {
const app = express();
app.use((req, _res, next) => {
if (user) (req as any).user = user;
next();
});
app.use('/api/calendar', createCrossCalendarApi({ repo, authActive: !!user }));
return app;
}
describe('cross-calendar API GET /api/calendar/cross', () => {
let repo: Repository;
let dir: string;
const owner: TestUser = { id: 'u1', role: 'user', orgIds: [] };
const stranger: TestUser = { id: 'u2', role: 'user', orgIds: [] };
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'cross-cal-api-'));
repo = new Repository(join(dir, `${randomUUID()}.db`));
});
afterEach(() => {
repo.close();
rmSync(dir, { recursive: true, force: true });
});
it('returns aggregated shape for the viewer (days/events/spaces)', async () => {
const a = await repo.createSpace({ kind: 'case', title: 'Alpha', ownerId: 'u1', visibility: 'private' });
await repo.createCalendarEvent({ spaceId: a.id, date: '2026-06-10', title: 'evt' });
const res = await request(makeApp(repo, owner)).get('/api/calendar/cross?month=2026-06&tz_offset=540');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('days');
expect(res.body).toHaveProperty('events');
expect(res.body).toHaveProperty('spaces');
expect(res.body.spaces.map((s: any) => s.id)).toEqual([a.id]);
expect(res.body.days['2026-06-10'][a.id].eventCount).toBe(1);
expect(res.body.events.map((e: any) => e.title)).toEqual(['evt']);
});
it('only returns the viewer-visible spaces (never leaks others)', async () => {
const mine = await repo.createSpace({ kind: 'case', title: 'Mine', ownerId: 'u1', visibility: 'private' });
const theirs = await repo.createSpace({ kind: 'case', title: 'Theirs', ownerId: 'u1', visibility: 'private' });
await repo.createCalendarEvent({ spaceId: mine.id, date: '2026-06-10', title: 'ok' });
await repo.createCalendarEvent({ spaceId: theirs.id, date: '2026-06-10', title: 'secret' });
const res = await request(makeApp(repo, stranger)).get('/api/calendar/cross?month=2026-06');
expect(res.status).toBe(200);
expect(res.body.spaces).toEqual([]);
expect(res.body.events).toEqual([]);
expect(res.body.days).toEqual({});
// assert the secret never appears anywhere in the serialized payload
expect(JSON.stringify(res.body)).not.toContain('secret');
expect(JSON.stringify(res.body)).not.toContain(theirs.id);
});
it('bad month → 400', async () => {
const res = await request(makeApp(repo, owner)).get('/api/calendar/cross?month=2026-6');
expect(res.status).toBe(400);
});
it('missing month → 400', async () => {
const res = await request(makeApp(repo, owner)).get('/api/calendar/cross');
expect(res.status).toBe(400);
});
it('no-auth (synthetic admin) sees all spaces', async () => {
const a = await repo.createSpace({ kind: 'case', title: 'A', ownerId: null, visibility: 'private' });
await repo.createCalendarEvent({ spaceId: a.id, date: '2026-06-10', title: 'local-evt' });
// no req.user → router falls back to synthetic 'local' admin (authActive=false)
const res = await request(makeApp(repo, null)).get('/api/calendar/cross?month=2026-06');
expect(res.status).toBe(200);
expect(res.body.spaces.map((s: any) => s.id)).toEqual([a.id]);
expect(res.body.events.map((e: any) => e.title)).toEqual(['local-evt']);
});
});

View File

@ -0,0 +1,42 @@
import { Router } from 'express';
import type { Repository } from '../db/repository.js';
import { spaceViewerOf, parseTzOffset, monthBounds } from './space-api.js';
// 横断(全スペース)カレンダー API。
// spec: docs/superpowers/specs/2026-06-19-space-calendar-design.md §V2
//
// GET /api/calendar/cross?month=YYYY-MM&tz_offset=<min>
// → { days: {[date]: {[spaceId]: {taskCount,eventCount}}}, events, spaces }
//
// 可視性は Repository.getCrossSpaceCalendarMonth が listSpacesbuildVisibilityWhere
// 経由で絞るので、viewer が見られないスペースのカウント・イベントは一切返さない。
// 認可ミドルウェアrequireAuth / no-auth フォールバックはマウント側server.ts
// 他のトップレベル API と同じパターンを付与する。
export interface CrossCalendarApiDeps {
repo: Repository;
authActive: boolean;
}
export function createCrossCalendarApi(deps: CrossCalendarApiDeps): Router {
const router = Router();
const { repo } = deps;
router.get('/cross', async (req, res) => {
const viewer = spaceViewerOf(req, deps.authActive);
const month = String(req.query.month ?? '');
if (!/^\d{4}-\d{2}$/.test(month)) {
return res.status(400).json({ error: 'month must be YYYY-MM' });
}
const tzOffsetMin = parseTzOffset(req.query.tz_offset);
const { monthStart, monthEnd } = monthBounds(month);
const result = await repo.getCrossSpaceCalendarMonth(viewer, {
monthStart,
monthEnd,
tzOffsetMin,
});
res.json(result);
});
return router;
}

View File

@ -55,73 +55,6 @@ describe('Dashboard API', () => {
rmSync(tmpDir, { recursive: true, force: true }); rmSync(tmpDir, { recursive: true, force: true });
}); });
it('GET /widgets returns empty when none', async () => {
const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/widgets');
expect(res.status).toBe(200);
expect(res.body.widgets).toEqual([]);
});
it('POST /widgets creates a widget', async () => {
const res = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets')
.send({ slug: 'memo', title: 'Memo', content: 'hi' });
expect(res.status).toBe(201);
expect(res.body.widget.slug).toBe('memo');
expect(res.body.widget.markdownContent).toBe('hi');
});
it('POST /widgets rejects invalid slug', async () => {
const res = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets')
.send({ slug: 'Bad Slug!', title: 't', content: '' });
expect(res.status).toBe(400);
});
it('POST /widgets rejects duplicate slug', async () => {
const app = makeApp('u1', repo);
await request(app).post('/api/local/dashboard/widgets').send({ slug: 'memo', title: 'a', content: '' });
const dup = await request(app).post('/api/local/dashboard/widgets').send({ slug: 'memo', title: 'b', content: '' });
expect(dup.status).toBe(409);
});
it('PATCH /widgets/:id updates content', async () => {
const app = makeApp('u1', repo);
const created = await request(app).post('/api/local/dashboard/widgets').send({ slug: 's', title: 't', content: 'old' });
const id = created.body.widget.id;
const res = await request(app).patch(`/api/local/dashboard/widgets/${id}`).send({ content: 'new' });
expect(res.status).toBe(200);
expect(res.body.widget.markdownContent).toBe('new');
});
it('PATCH /widgets/:id returns 404 for other user', async () => {
const created = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets').send({ slug: 's', title: 't', content: '' });
const res = await request(makeApp('u2', repo))
.patch(`/api/local/dashboard/widgets/${created.body.widget.id}`).send({ content: 'hack' });
expect(res.status).toBe(404);
});
it('DELETE /widgets/:id removes for owner only', async () => {
const created = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets').send({ slug: 's', title: 't', content: '' });
const other = await request(makeApp('u2', repo))
.delete(`/api/local/dashboard/widgets/${created.body.widget.id}`);
expect(other.status).toBe(404);
const owner = await request(makeApp('u1', repo))
.delete(`/api/local/dashboard/widgets/${created.body.widget.id}`);
expect(owner.status).toBe(204);
});
it('PUT /widgets/reorder reorders within user scope', async () => {
const app = makeApp('u1', repo);
const a = (await request(app).post('/api/local/dashboard/widgets').send({ slug: 'a', title: 'A', content: '' })).body.widget.id;
const b = (await request(app).post('/api/local/dashboard/widgets').send({ slug: 'b', title: 'B', content: '' })).body.widget.id;
const res = await request(app).put('/api/local/dashboard/widgets/reorder').send({ ids: [b, a] });
expect(res.status).toBe(200);
const list = await request(app).get('/api/local/dashboard/widgets');
expect(list.body.widgets.map((w: any) => w.slug)).toEqual(['b', 'a']);
});
it('GET /workers returns idle/running per worker', async () => { it('GET /workers returns idle/running per worker', async () => {
const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/workers'); const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/workers');
expect(res.status).toBe(200); expect(res.status).toBe(200);
@ -160,74 +93,10 @@ describe('Dashboard API', () => {
getWorkers: () => [], getWorkers: () => [],
authActive: true, authActive: true,
})); }));
const res = await request(app).get('/api/local/dashboard/widgets'); const res = await request(app).get('/api/local/dashboard/workers');
expect(res.status).toBe(401); expect(res.status).toBe(401);
}); });
it('POST /widgets accepts kind=node-status', async () => {
const res = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets')
.send({ slug: 'nodes', title: 'Nodes', kind: 'node-status' });
expect(res.status).toBe(201);
expect(res.body.widget.kind).toBe('node-status');
});
it('POST /widgets defaults kind to markdown when omitted', async () => {
const res = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets')
.send({ slug: 'memo', title: 'Memo' });
expect(res.status).toBe(201);
expect(res.body.widget.kind).toBe('markdown');
});
it('POST /widgets rejects unknown kind', async () => {
const res = await request(makeApp('u1', repo))
.post('/api/local/dashboard/widgets')
.send({ slug: 'x', title: 'X', kind: 'mystery' });
expect(res.status).toBe(400);
});
it('PATCH /widgets/:id rejects content edits on node-status widgets (400)', async () => {
const app = makeApp('u1', repo);
const created = await request(app)
.post('/api/local/dashboard/widgets')
.send({ slug: 'nodes', title: 'Nodes', kind: 'node-status' });
expect(created.status).toBe(201);
const id = created.body.widget.id;
const res = await request(app)
.patch(`/api/local/dashboard/widgets/${id}`)
.send({ content: 'manual override' });
expect(res.status).toBe(400);
expect(String(res.body.error)).toContain('node-status');
});
it('PATCH /widgets/:id allows title-only edits on node-status widgets (200)', async () => {
const app = makeApp('u1', repo);
const created = await request(app)
.post('/api/local/dashboard/widgets')
.send({ slug: 'nodes', title: 'Nodes', kind: 'node-status' });
const id = created.body.widget.id;
const res = await request(app)
.patch(`/api/local/dashboard/widgets/${id}`)
.send({ title: 'GPU Pool' });
expect(res.status).toBe(200);
expect(res.body.widget.title).toBe('GPU Pool');
expect(res.body.widget.kind).toBe('node-status');
});
it('PATCH /widgets/:id still allows content edits on markdown widgets (regression)', async () => {
const app = makeApp('u1', repo);
const created = await request(app)
.post('/api/local/dashboard/widgets')
.send({ slug: 'memo', title: 'Memo', content: 'old' });
const id = created.body.widget.id;
const res = await request(app)
.patch(`/api/local/dashboard/widgets/${id}`)
.send({ content: 'new content' });
expect(res.status).toBe(200);
expect(res.body.widget.markdownContent).toBe('new content');
});
it('GET /node-status returns 503 when registry is not configured', async () => { it('GET /node-status returns 503 when registry is not configured', async () => {
const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/node-status'); const res = await request(makeApp('u1', repo)).get('/api/local/dashboard/node-status');
expect(res.status).toBe(503); expect(res.status).toBe(503);

View File

@ -1,32 +1,22 @@
/** /**
* dashboard-api.ts REST router for Side Info Panel. * dashboard-api.ts REST router for the Side Info Panel.
* Mounted at /api/local/dashboard. * Mounted at /api/local/dashboard.
* *
* Routes: * Routes:
* GET /widgets list current user's widgets * GET /workers worker idle/running (no job details)
* POST /widgets create * GET /node-status latest BackendStatusRegistry snapshot (GPU/node feed)
* PATCH /widgets/:id update title/content
* DELETE /widgets/:id delete
* PUT /widgets/reorder reorder by id list
* GET /workers worker idle/running (no job details)
* *
* Auth: all routes require req.user (or fall back to 'local' when authActive=false). * Auth: all routes require req.user (or fall back to 'local' when authActive=false).
* Owner: every operation scopes to req.user.id; cross-user access returns 404.
*/ */
import { Router, type Request, type Response } from 'express'; import { Router, type Request, type Response } from 'express';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { isDashboardWidgetKind, type DashboardWidgetKind, type Repository } from '../db/repository.js'; import type { Repository } from '../db/repository.js';
import type { WorkerDef } from '../config.js'; import type { WorkerDef } from '../config.js';
import { collectWorkerStatuses } from './dashboard-workers.js'; import { collectWorkerStatuses } from './dashboard-workers.js';
import type { BackendStatusRegistry } from '../engine/backend-status-registry.js'; import type { BackendStatusRegistry } from '../engine/backend-status-registry.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
const SLUG_PATTERN = /^[a-z0-9-]+$/;
const MAX_SLUG_LEN = 32;
const MAX_TITLE_LEN = 64;
const MAX_CONTENT_BYTES = 64 * 1024;
interface AuthedUser { id: string; role: string; } interface AuthedUser { id: string; role: string; }
function getUser(req: Request): AuthedUser | null { function getUser(req: Request): AuthedUser | null {
@ -62,143 +52,6 @@ export function createDashboardApi(deps: DashboardApiDeps): Router {
next(); next();
}); });
r.get('/widgets', async (req, res) => {
const u = getUser(req)!;
try {
const widgets = await repo.listDashboardWidgets(u.id);
res.json({ widgets });
} catch (err) {
logger.error(`[dashboard-api] GET /widgets failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to list widgets' });
}
});
r.post('/widgets', async (req, res) => {
const u = getUser(req)!;
const { slug, title, content, kind } = (req.body ?? {}) as {
slug?: string;
title?: string;
content?: string;
kind?: string;
};
if (!slug || !SLUG_PATTERN.test(slug) || slug.length > MAX_SLUG_LEN) {
res.status(400).json({ error: 'invalid slug (lowercase a-z, 0-9, hyphen; max 32 chars)' });
return;
}
if (!title || title.length > MAX_TITLE_LEN) {
res.status(400).json({ error: `title required and <= ${MAX_TITLE_LEN} chars` });
return;
}
if (content !== undefined && Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
res.status(400).json({ error: `content exceeds ${MAX_CONTENT_BYTES} bytes` });
return;
}
// kind is optional; defaults to 'markdown' for backward compat.
let resolvedKind: DashboardWidgetKind = 'markdown';
if (kind !== undefined) {
if (!isDashboardWidgetKind(kind)) {
res.status(400).json({ error: 'invalid kind (allowed: markdown, node-status)' });
return;
}
resolvedKind = kind;
}
try {
const widget = await repo.createDashboardWidget({
userId: u.id,
slug,
title,
content: content ?? '',
kind: resolvedKind,
});
res.status(201).json({ widget });
} catch (err: any) {
if (String(err?.message ?? err).includes('UNIQUE')) {
res.status(409).json({ error: 'slug already exists' });
return;
}
logger.error(`[dashboard-api] POST /widgets failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to create widget' });
}
});
r.patch('/widgets/:id', async (req, res) => {
const u = getUser(req)!;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const existing = await repo.getDashboardWidget(id, u.id);
if (!existing) {
res.status(404).json({ error: 'not found' });
return;
}
const { title, content } = (req.body ?? {}) as { title?: string; content?: string };
// Non-markdown widget kinds (currently just 'node-status') render
// data live from a backing source instead of stored markdown — any
// content the caller sends would be dead state at best and a
// confusing surprise on the next render at worst. Title remains
// editable so the user can rename the panel.
if (existing.kind !== 'markdown' && content !== undefined) {
res.status(400).json({
error: `cannot edit content of ${existing.kind} widget (title-only updates allowed)`,
});
return;
}
if (title !== undefined && (title.length === 0 || title.length > MAX_TITLE_LEN)) {
res.status(400).json({ error: `title must be 1..${MAX_TITLE_LEN} chars` });
return;
}
if (content !== undefined && Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
res.status(400).json({ error: `content exceeds ${MAX_CONTENT_BYTES} bytes` });
return;
}
try {
const widget = await repo.updateDashboardWidget(id, u.id, { title, content });
res.json({ widget });
} catch (err) {
logger.error(`[dashboard-api] PATCH /widgets/${id} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to update widget' });
}
});
r.delete('/widgets/:id', async (req, res) => {
const u = getUser(req)!;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const existing = await repo.getDashboardWidget(id, u.id);
if (!existing) {
res.status(404).json({ error: 'not found' });
return;
}
try {
await repo.deleteDashboardWidget(id, u.id);
res.status(204).end();
} catch (err) {
logger.error(`[dashboard-api] DELETE /widgets/${id} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to delete widget' });
}
});
r.put('/widgets/reorder', async (req, res) => {
const u = getUser(req)!;
const { ids } = (req.body ?? {}) as { ids?: number[] };
if (!Array.isArray(ids) || !ids.every(n => Number.isFinite(n))) {
res.status(400).json({ error: 'ids must be array of numbers' });
return;
}
try {
await repo.reorderDashboardWidgets(u.id, ids);
res.json({ ok: true });
} catch (err) {
logger.error(`[dashboard-api] reorder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to reorder' });
}
});
r.get('/workers', async (_req, res) => { r.get('/workers', async (_req, res) => {
try { try {
const workers = await collectWorkerStatuses(repo, getWorkers(), deps.backendStatusRegistry ?? null); const workers = await collectWorkerStatuses(repo, getWorkers(), deps.backendStatusRegistry ?? null);

View File

@ -22,8 +22,10 @@ import {
isGatewayPath, isGatewayPath,
classifyGatewayPath, classifyGatewayPath,
mountGateway, mountGateway,
configsEquivalent,
type GatewayMountHandle, type GatewayMountHandle,
} from './gateway-mount.js'; } from './gateway-mount.js';
import { normalizeGatewayConfig } from '../gateway/config.js';
import type { ConfigManager } from '../config-manager.js'; import type { ConfigManager } from '../config-manager.js';
import type { AppConfig } from '../config.js'; import type { AppConfig } from '../config.js';
import type { import type {
@ -513,3 +515,23 @@ describe('mountGateway lifecycle', () => {
expect(mount.getState()).toBe('disabled'); expect(mount.getState()).toBe('disabled');
}); });
}); });
describe('configsEquivalent — internalTeams', () => {
const base = (internalTeams: string[]) =>
normalizeGatewayConfig({
enabled: true,
backends: [{ id: 'gpu-a', endpoint: 'http://gpu-a/v1', model: 'm', maxSlots: 4 }],
virtualKeys: [],
internalTeams,
});
it('differs when internalTeams membership changes (forces a bounce)', () => {
expect(configsEquivalent(base([]), base(['orchestrator']))).toBe(false);
expect(configsEquivalent(base(['orchestrator']), base(['orchestrator', 'ops']))).toBe(false);
});
it('is order-insensitive for internalTeams (no spurious bounce)', () => {
expect(configsEquivalent(base(['a', 'b']), base(['b', 'a']))).toBe(true);
expect(configsEquivalent(base([]), base([]))).toBe(true);
});
});

View File

@ -476,13 +476,17 @@ function stableStringify(value: unknown): string {
* requires a bounce. Uses stableStringify so YAML round-trip key-order * requires a bounce. Uses stableStringify so YAML round-trip key-order
* changes don't cause false-positive bounces. * changes don't cause false-positive bounces.
*/ */
function configsEquivalent(a: GatewayConfig, b: GatewayConfig): boolean { export function configsEquivalent(a: GatewayConfig, b: GatewayConfig): boolean {
if (a.listenPort !== b.listenPort) return false; if (a.listenPort !== b.listenPort) return false;
if (a.requestTimeoutSec !== b.requestTimeoutSec) return false; if (a.requestTimeoutSec !== b.requestTimeoutSec) return false;
if (a.upstreamTimeoutSec !== b.upstreamTimeoutSec) return false; if (a.upstreamTimeoutSec !== b.upstreamTimeoutSec) return false;
if (a.shutdownGracefulSec !== b.shutdownGracefulSec) return false; if (a.shutdownGracefulSec !== b.shutdownGracefulSec) return false;
if (stableStringify(a.backends) !== stableStringify(b.backends)) return false; if (stableStringify(a.backends) !== stableStringify(b.backends)) return false;
if (stableStringify(a.virtualKeys) !== stableStringify(b.virtualKeys)) return false; if (stableStringify(a.virtualKeys) !== stableStringify(b.virtualKeys)) return false;
// internalTeams drives whether downstream traffic is recorded as
// 'gateway_downstream'; a change must bounce the same-process gateway so the
// recorder's Set is rebuilt. Order-insensitive (it's a membership set).
if (stableStringify([...a.internalTeams].sort()) !== stableStringify([...b.internalTeams].sort())) return false;
return true; return true;
} }

View File

@ -1,6 +1,52 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { Response } from 'express'; import type { Response } from 'express';
import { setUntrustedFileResponseHeaders, ensurePathWithin } from './local-api-helpers.js'; import * as fs from 'fs';
import * as os from 'os';
import { join, posix, win32 } from 'path';
import { setUntrustedFileResponseHeaders, ensurePathWithin, isPathEscapeError, isNotFoundError, safeZipEntryName } from './local-api-helpers.js';
describe('safeZipEntryName', () => {
const root = '/srv/ws';
it('keeps a plain relative path as the entry name', () => {
expect(safeZipEntryName(root, '/srv/ws/a.txt')).toBe('a.txt');
expect(safeZipEntryName(root, '/srv/ws/sub/b.txt')).toBe('sub/b.txt');
});
it('produces names that are never absolute (POSIX or Windows), never contain .. or backslash', () => {
// Each is a single literal filename living directly inside root — the
// dangerous forms an attacker could create on POSIX and weaponize on extract.
const evilNames = [
'a\\..\\evil.txt', // backslash + .. (Windows traversal)
'C:\\evil.txt', // drive-letter absolute
'\\\\server\\share\\x.txt', // UNC
'a/../../evil.txt',
];
for (const name of evilNames) {
const entry = safeZipEntryName(root, `/srv/ws/${name}`);
expect(entry.includes('\\'), `${name} -> ${entry}`).toBe(false);
expect(entry.split('/').includes('..'), `${name} -> ${entry}`).toBe(false);
expect(posix.isAbsolute(entry), `${name} -> ${entry}`).toBe(false);
expect(win32.isAbsolute(entry), `${name} -> ${entry}`).toBe(false);
}
});
it('neutralizes a Windows drive-letter segment (C: -> C_)', () => {
expect(safeZipEntryName(root, '/srv/ws/C:\\evil.txt')).toBe('C_/evil.txt');
});
});
describe('isNotFoundError', () => {
it('matches ENOENT and ENOTDIR (missing file / non-dir in path)', () => {
expect(isNotFoundError(Object.assign(new Error('x'), { code: 'ENOENT' }))).toBe(true);
expect(isNotFoundError(Object.assign(new Error('x'), { code: 'ENOTDIR' }))).toBe(true);
});
it('does not match other errors (EACCES, plain Error, null)', () => {
expect(isNotFoundError(Object.assign(new Error('x'), { code: 'EACCES' }))).toBe(false);
expect(isNotFoundError(new Error('boom'))).toBe(false);
expect(isNotFoundError(null)).toBe(false);
});
});
function fakeRes(): { res: Response; headers: Record<string, string> } { function fakeRes(): { res: Response; headers: Record<string, string> } {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
@ -39,3 +85,42 @@ describe('ensurePathWithin (regression: no escape, no sibling-prefix bypass)', (
expect(() => ensurePathWithin('/tmp/ws', '../ws-evil/x')).toThrow(); expect(() => ensurePathWithin('/tmp/ws', '../ws-evil/x')).toThrow();
}); });
}); });
describe('ensurePathWithin (symlink escape hardening)', () => {
let root: string;
let outside: string;
beforeEach(() => {
const base = fs.mkdtempSync(join(os.tmpdir(), 'maestro-helpers-'));
root = join(base, 'root');
outside = join(base, 'outside');
fs.mkdirSync(root, { recursive: true });
fs.mkdirSync(outside, { recursive: true });
fs.writeFileSync(join(outside, 'secret.txt'), 'secret');
});
afterEach(() => {
try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
try { fs.rmSync(outside, { recursive: true, force: true }); } catch { /* ignore */ }
});
it('rejects a symlink inside root that points outside (isPathEscapeError matches)', () => {
// An agent-created symlink resolves lexically "within" root but escapes via realpath.
fs.symlinkSync(outside, join(root, 'evil'));
let err: unknown;
try { ensurePathWithin(root, 'evil/x'); } catch (e) { err = e; }
expect(err).toBeInstanceOf(Error);
expect(isPathEscapeError(err)).toBe(true);
});
it('still resolves a normal in-root path when root exists', () => {
expect(ensurePathWithin(root, 'sub/file.txt')).toBe(join(root, 'sub', 'file.txt'));
});
it('still rejects ../escape when root exists', () => {
let err: unknown;
try { ensurePathWithin(root, '../escape'); } catch (e) { err = e; }
expect(err).toBeInstanceOf(Error);
expect(isPathEscapeError(err)).toBe(true);
});
});

View File

@ -1,5 +1,26 @@
import { type Request, type Response } from 'express'; import { type Request, type Response } from 'express';
import { join, resolve, sep } from 'path'; import { join, resolve, sep, dirname, relative } from 'path';
import * as fs from 'fs';
/**
* zip `abs`
*
* zip entry 使 zip-slip
* POSIX `a\..\evil.txt` `C:\evil.txt` 1
* `ensurePathWithin` Windows /
* `relative(rootDir, abs)` :
* - `\``/` の両方で分割Windows 区切りを無効化)
* - `.` / `..`
* - `:` `_` / NTFS ADS
* skip POSIX
*/
export function safeZipEntryName(rootDir: string, abs: string): string {
return relative(rootDir, abs)
.split(/[\\/]+/)
.filter(seg => seg && seg !== '.' && seg !== '..')
.map(seg => seg.replace(/:/g, '_'))
.join('/');
}
export function getLocalWorkspacePath(worktreeDir: string | undefined, taskId: number): string { export function getLocalWorkspacePath(worktreeDir: string | undefined, taskId: number): string {
const base = worktreeDir ?? '/tmp/maestro/workspaces'; const base = worktreeDir ?? '/tmp/maestro/workspaces';
@ -9,9 +30,24 @@ export function getLocalWorkspacePath(worktreeDir: string | undefined, taskId: n
export function ensurePathWithin(baseDir: string, requestedPath: string): string { export function ensurePathWithin(baseDir: string, requestedPath: string): string {
const resolvedBase = resolve(baseDir); const resolvedBase = resolve(baseDir);
const resolvedPath = resolve(baseDir, requestedPath); const resolvedPath = resolve(baseDir, requestedPath);
// 1) 字句的な封じ込めチェック(従来動作)
if (!resolvedPath.startsWith(resolvedBase + sep) && resolvedPath !== resolvedBase) { if (!resolvedPath.startsWith(resolvedBase + sep) && resolvedPath !== resolvedBase) {
throw new Error('Path escapes workspace'); throw new Error('Path escapes workspace');
} }
// 2) symlink 脱出チェック。root が実在するときだけ行う
// (未作成 root を渡す既存呼び出し・テストの挙動を保つため)。
if (fs.existsSync(resolvedBase)) {
const realRoot = fs.realpathSync(resolvedBase);
// resolvedPath から上に辿り、実在する最も深い祖先を見つける
let probe = resolvedPath;
while (!fs.existsSync(probe) && dirname(probe) !== probe) {
probe = dirname(probe);
}
const realProbe = fs.realpathSync(probe);
if (realProbe !== realRoot && !realProbe.startsWith(realRoot + sep)) {
throw new Error('Path escapes workspace');
}
}
return resolvedPath; return resolvedPath;
} }
@ -37,6 +73,18 @@ export function isPathEscapeError(err: unknown): boolean {
return err instanceof Error && err.message === 'Path escapes workspace'; return err instanceof Error && err.message === 'Path escapes workspace';
} }
/**
* A filesystem "this path does not exist" error: a missing file (ENOENT) or a
* non-directory in the path (ENOTDIR). Callers map these to 404, not 500 a
* requested file that simply hasn't been created yet (e.g. a Source Library
* index for a space with no sources) is "not found", not a server failure, and
* must not be logged at ERROR.
*/
export function isNotFoundError(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException | null)?.code;
return code === 'ENOENT' || code === 'ENOTDIR';
}
export function serializeLocalFileEntry(relativePath: string, name: string, isDirectory: boolean, size: number, mtime: Date) { export function serializeLocalFileEntry(relativePath: string, name: string, isDirectory: boolean, size: number, mtime: Date) {
return { return {
name, name,
@ -66,11 +114,23 @@ type TaskLike = {
ownerId?: string | null; ownerId?: string | null;
visibility?: 'private' | 'org' | 'public' | null; visibility?: 'private' | 'org' | 'public' | null;
visibilityScopeOrgId?: string | null; visibilityScopeOrgId?: string | null;
spaceId?: string | null;
}; };
// Read-side permission check honoring the full visibility model. // Read-side permission check honoring the full visibility model.
// Writes should continue to use checkTaskOwnership (owner-or-admin only). // Writes should continue to use checkTaskOwnership (owner-or-admin only).
export function canViewTask(req: Request, res: Response, task: TaskLike | null): boolean { //
// スペースタスクtask.spaceId が非 nullの閲覧は owner/org/public だけでなく
// スペースメンバーシップでも許可する。メンバーシップ判定は DB を要するため、
// 呼び出し側が `repo.userCanViewSpace(task.spaceId, user)` で算出して
// `spaceAccessible` に渡す(この関数は DB を触らない。spaceId が null の
// 非スペースタスクは従来通り owner/org/public のみspaceAccessible 無視)。
export function canViewTask(
req: Request,
res: Response,
task: TaskLike | null,
spaceAccessible?: boolean,
): boolean {
if (!task) { res.status(404).json({ error: 'Task not found' }); return false; } if (!task) { res.status(404).json({ error: 'Task not found' }); return false; }
const user = req.user as Express.User | undefined; const user = req.user as Express.User | undefined;
if (!user) return true; if (!user) return true;
@ -78,6 +138,27 @@ export function canViewTask(req: Request, res: Response, task: TaskLike | null):
if (task.ownerId && task.ownerId === user.id) return true; if (task.ownerId && task.ownerId === user.id) return true;
if (task.visibility === 'public') return true; if (task.visibility === 'public') return true;
if (task.visibility === 'org' && task.visibilityScopeOrgId && user.orgIds?.includes(task.visibilityScopeOrgId)) return true; if (task.visibility === 'org' && task.visibilityScopeOrgId && user.orgIds?.includes(task.visibilityScopeOrgId)) return true;
if (task.spaceId && spaceAccessible === true) return true;
res.status(404).json({ error: 'Task not found' }); res.status(404).json({ error: 'Task not found' });
return false; return false;
} }
/** Minimal repo surface needed to resolve space membership (avoids a hard Repository import here). */
export interface SpaceAccessRepo {
userCanViewSpace(spaceId: string, user: { id: string; role: string }): boolean;
}
/**
* Resolve the `spaceAccessible` flag for a task's secondary view gate.
* Returns true only when the task belongs to a space AND the viewer is the
* space root owner or a member (admin handled inside userCanViewSpace).
* Non-space tasks (spaceId null) and unauthenticated requests false.
*/
export function resolveSpaceAccess(
repo: SpaceAccessRepo,
task: { spaceId?: string | null } | null,
user: Express.User | undefined,
): boolean {
if (!task?.spaceId || !user) return false;
return repo.userCanViewSpace(task.spaceId, user);
}

View File

@ -4,6 +4,7 @@ import request from 'supertest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs'; import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import AdmZip from 'adm-zip';
import { mountLocalFilesApi } from './local-files-api.js'; import { mountLocalFilesApi } from './local-files-api.js';
import type { Repository } from '../db/repository.js'; import type { Repository } from '../db/repository.js';
@ -109,6 +110,43 @@ describe('GET /api/local/tasks/:taskId/files (listing)', () => {
expect(res.body.error).toBe('Path escapes workspace'); expect(res.body.error).toBe('Path escapes workspace');
expect(JSON.stringify(res.body)).not.toContain('outside-'); expect(JSON.stringify(res.body)).not.toContain('outside-');
}); });
// 計画5: logs section は logRoot(task) で解決する。
it('reads the logs section from runtime_dir when set', async () => {
const runtimeDir = mkdtempSync(join(tmpdir(), 'local-files-rt-'));
try {
writeFileSync(join(runtimeDir, 'activity.log'), 'from-runtime');
// workspace/logs intentionally contains a different marker so we can
// prove the read went to runtime_dir, not workspace_path/logs.
mkdirSync(join(ws, 'logs'), { recursive: true });
writeFileSync(join(ws, 'logs', 'stale.log'), 'from-workspace');
const repo = makeRepo({
getLocalTask: vi.fn().mockResolvedValue({
id: 1, ownerId: 'user-1', visibility: 'private', workspacePath: ws, runtimeDir,
}),
});
const res = await request(makeApp(repo, makeUser())).get('/api/local/tasks/1/files?section=logs');
expect(res.status).toBe(200);
const names = res.body.entries.map((e: { name: string }) => e.name);
expect(names).toContain('activity.log');
expect(names).not.toContain('stale.log');
} finally {
rmSync(runtimeDir, { recursive: true, force: true });
}
});
it('reads the logs section from workspace_path/logs when runtime_dir is null (regression)', async () => {
mkdirSync(join(ws, 'logs'), { recursive: true });
writeFileSync(join(ws, 'logs', 'activity.log'), 'legacy');
const repo = makeRepo({
getLocalTask: vi.fn().mockResolvedValue({
id: 1, ownerId: 'user-1', visibility: 'private', workspacePath: ws, runtimeDir: null,
}),
});
const res = await request(makeApp(repo, makeUser())).get('/api/local/tasks/1/files?section=logs');
expect(res.status).toBe(200);
expect(res.body.entries.map((e: { name: string }) => e.name)).toContain('activity.log');
});
}); });
describe('GET /api/local/tasks/:taskId/files/content', () => { describe('GET /api/local/tasks/:taskId/files/content', () => {
@ -127,6 +165,12 @@ describe('GET /api/local/tasks/:taskId/files/content', () => {
expect(res.body.error).toBe('path is required'); expect(res.body.error).toBe('path is required');
}); });
it('returns 404 (not 500) for a missing file', async () => {
const res = await request(makeApp(makeRepo(), makeUser()))
.get('/api/local/tasks/1/files/content?section=output&path=does-not-exist.md');
expect(res.status).toBe(404);
});
it('rejects a directory path with 400', async () => { it('rejects a directory path with 400', async () => {
const res = await request(makeApp(makeRepo(), makeUser())) const res = await request(makeApp(makeRepo(), makeUser()))
.get('/api/local/tasks/1/files/content?section=output&path=sub'); .get('/api/local/tasks/1/files/content?section=output&path=sub');
@ -173,14 +217,15 @@ describe('GET /api/local/tasks/:taskId/files/raw', () => {
expect(res.headers['content-type']).toContain('html'); expect(res.headers['content-type']).toContain('html');
}); });
it("keeps trusted raw HTML sandboxed even for an admin on another user's task (user→admin lure)", async () => { it('serves trusted raw HTML to an admin viewing another user\'s task (owner-trust policy)', async () => {
const res = await request(makeApp(makeRepo(), makeUser({ id: 'admin-9', role: 'admin' }))) const res = await request(makeApp(makeRepo(), makeUser({ id: 'admin-9', role: 'admin' })))
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1'); .get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers['content-security-policy']).toBe('sandbox'); expect(res.headers['content-security-policy']).toBeUndefined();
expect(res.headers['content-type']).toContain('html');
}); });
it('keeps trusted raw HTML sandboxed for a NON-owner viewer of a shared task', async () => { it('serves trusted raw HTML to a NON-owner viewer of a shared task (visibility already gated)', async () => {
const repo = makeRepo({ const repo = makeRepo({
getLocalTask: vi.fn().mockResolvedValue({ getLocalTask: vi.fn().mockResolvedValue({
id: 1, id: 1,
@ -192,10 +237,11 @@ describe('GET /api/local/tasks/:taskId/files/raw', () => {
const res = await request(makeApp(repo, makeUser({ id: 'user-2' }))) const res = await request(makeApp(repo, makeUser({ id: 'user-2' })))
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1'); .get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers['content-security-policy']).toBe('sandbox'); expect(res.headers['content-security-policy']).toBeUndefined();
expect(res.headers['content-type']).toContain('html');
}); });
it('keeps trusted raw HTML sandboxed for an ownerless task even when authenticated', async () => { it('serves trusted raw HTML for an ownerless task to an authenticated viewer', async () => {
const repo = makeRepo({ const repo = makeRepo({
getLocalTask: vi.fn().mockResolvedValue({ getLocalTask: vi.fn().mockResolvedValue({
id: 1, id: 1,
@ -207,7 +253,7 @@ describe('GET /api/local/tasks/:taskId/files/raw', () => {
const res = await request(makeApp(repo, makeUser())) const res = await request(makeApp(repo, makeUser()))
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1'); .get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.headers['content-security-policy']).toBe('sandbox'); expect(res.headers['content-security-policy']).toBeUndefined();
}); });
it('keeps trusted raw HTML sandboxed when auth is on but no user is present', async () => { it('keeps trusted raw HTML sandboxed when auth is on but no user is present', async () => {
@ -316,3 +362,274 @@ describe('PUT /api/local/tasks/:taskId/files/content', () => {
expect(existsSync(join(ws, 'evil.txt'))).toBe(false); expect(existsSync(join(ws, 'evil.txt'))).toBe(false);
}); });
}); });
describe('POST /api/local/tasks/:taskId/files/upload', () => {
const upload = (app: express.Application, body: unknown) =>
request(app).post('/api/local/tasks/1/files/upload').send(body as object);
const b64 = (s: string) => Buffer.from(s, 'utf-8').toString('base64');
it('uploads a file into the input section for the owner', async () => {
const res = await upload(makeApp(makeRepo(), makeUser()), {
section: 'input',
path: '',
files: [{ name: 'new.txt', contentBase64: b64('hello') }],
});
expect(res.status).toBe(200);
expect(res.body.uploaded).toEqual([{ name: 'new.txt', path: 'new.txt' }]);
expect(readFileSync(join(ws, 'input', 'new.txt'), 'utf-8')).toBe('hello');
});
it('uploads into a subdirectory via path', async () => {
const res = await upload(makeApp(makeRepo(), makeUser()), {
section: 'output',
path: 'sub',
files: [{ name: 'added.txt', contentBase64: b64('x') }],
});
expect(res.status).toBe(200);
expect(res.body.uploaded[0].path).toBe('sub/added.txt');
expect(existsSync(join(ws, 'output', 'sub', 'added.txt'))).toBe(true);
});
it('renames on collision instead of overwriting (O_EXCL)', async () => {
const res = await upload(makeApp(makeRepo(), makeUser()), {
section: 'input',
path: '',
files: [{ name: 'data.csv', contentBase64: b64('new') }],
});
expect(res.status).toBe(200);
expect(res.body.uploaded[0].name).toBe('data (2).csv');
// original untouched
expect(readFileSync(join(ws, 'input', 'data.csv'), 'utf-8')).toBe('a,b\n1,2\n');
expect(readFileSync(join(ws, 'input', 'data (2).csv'), 'utf-8')).toBe('new');
});
it('sanitizes path separators in the file name', async () => {
const res = await upload(makeApp(makeRepo(), makeUser()), {
section: 'input',
path: '',
files: [{ name: '../escape.txt', contentBase64: b64('x') }],
});
expect(res.status).toBe(200);
// `/` and `\` become `_`, so it stays inside input/.
expect(res.body.uploaded[0].name).toBe('.._escape.txt');
expect(existsSync(join(ws, '..', 'escape.txt'))).toBe(false);
});
it('hides the task from a non-owner with 404', async () => {
const res = await upload(makeApp(makeRepo(), makeUser({ id: 'stranger' })), {
section: 'input', path: '', files: [{ name: 'x.txt', contentBase64: b64('x') }],
});
expect(res.status).toBe(404);
});
it('lets an admin upload to another user\'s task', async () => {
const res = await upload(makeApp(makeRepo(), makeUser({ id: 'admin-1', role: 'admin' })), {
section: 'input', path: '', files: [{ name: 'x.txt', contentBase64: b64('x') }],
});
expect(res.status).toBe(200);
});
it('refuses uploads while a job is running', async () => {
const repo = makeRepo({
getLatestJobForIssue: vi.fn().mockResolvedValue({ status: 'running' }),
} as Partial<Repository>);
const res = await upload(makeApp(repo, makeUser()), {
section: 'input', path: '', files: [{ name: 'x.txt', contentBase64: b64('x') }],
});
expect(res.status).toBe(409);
});
it('rejects the logs section with 400', async () => {
const res = await upload(makeApp(makeRepo(), makeUser()), {
section: 'logs', path: '', files: [{ name: 'x.txt', contentBase64: b64('x') }],
});
expect(res.status).toBe(400);
});
it('rejects the workspace section with 400', async () => {
const res = await upload(makeApp(makeRepo(), makeUser()), {
section: 'workspace', path: '', files: [{ name: 'x.txt', contentBase64: b64('x') }],
});
expect(res.status).toBe(400);
});
it('rejects an empty files array with 400', async () => {
const res = await upload(makeApp(makeRepo(), makeUser()), { section: 'input', path: '', files: [] });
expect(res.status).toBe(400);
});
it('rejects traversal in the path with 400 and writes nothing outside', async () => {
const res = await upload(makeApp(makeRepo(), makeUser()), {
section: 'input', path: '../..', files: [{ name: 'pwn.txt', contentBase64: b64('x') }],
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('Path escapes workspace');
});
});
describe('POST /api/local/tasks/:taskId/files/delete', () => {
const del = (app: express.Application, body: unknown) =>
request(app).post('/api/local/tasks/1/files/delete').send(body as object);
it('deletes input files for the owner', async () => {
const res = await del(makeApp(makeRepo(), makeUser()), { section: 'input', paths: ['data.csv'] });
expect(res.status).toBe(200);
expect(res.body.deleted).toEqual(['data.csv']);
expect(existsSync(join(ws, 'input', 'data.csv'))).toBe(false);
});
it('accepts a single path', async () => {
const res = await del(makeApp(makeRepo(), makeUser()), { section: 'output', path: 'report.md' });
expect(res.status).toBe(200);
expect(res.body.deleted).toEqual(['report.md']);
});
it('skips missing files idempotently', async () => {
const res = await del(makeApp(makeRepo(), makeUser()), { section: 'input', paths: ['nope.txt'] });
expect(res.status).toBe(200);
expect(res.body.deleted).toEqual([]);
expect(res.body.skipped).toEqual(['nope.txt']);
});
it('skips directories', async () => {
const res = await del(makeApp(makeRepo(), makeUser()), { section: 'output', paths: ['sub'] });
expect(res.status).toBe(200);
expect(res.body.skipped).toEqual(['sub']);
expect(existsSync(join(ws, 'output', 'sub'))).toBe(true);
});
it('hides the task from a non-owner with 404', async () => {
const res = await del(makeApp(makeRepo(), makeUser({ id: 'stranger' })), { section: 'input', paths: ['data.csv'] });
expect(res.status).toBe(404);
expect(existsSync(join(ws, 'input', 'data.csv'))).toBe(true);
});
it('refuses deletes while a job is running', async () => {
const repo = makeRepo({
getLatestJobForIssue: vi.fn().mockResolvedValue({ status: 'dispatching' }),
} as Partial<Repository>);
const res = await del(makeApp(repo, makeUser()), { section: 'input', paths: ['data.csv'] });
expect(res.status).toBe(409);
expect(existsSync(join(ws, 'input', 'data.csv'))).toBe(true);
});
it('rejects the logs section with 400', async () => {
const res = await del(makeApp(makeRepo(), makeUser()), { section: 'logs', paths: ['activity.log'] });
expect(res.status).toBe(400);
});
it('rejects an empty paths array with 400', async () => {
const res = await del(makeApp(makeRepo(), makeUser()), { section: 'input', paths: [] });
expect(res.status).toBe(400);
});
it('rejects traversal with 400 and deletes nothing outside', async () => {
const res = await del(makeApp(makeRepo(), makeUser()), {
section: 'input', paths: [`../../outside-${process.pid}.txt`],
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('Path escapes workspace');
expect(existsSync(join(ws, '..', `outside-${process.pid}.txt`))).toBe(true);
});
it('does not partially delete when a later path traverses (validate-all-first)', async () => {
// valid path comes first, traversal second. The two-pass guard must validate
// both before unlinking, so the valid file survives the 400.
const res = await del(makeApp(makeRepo(), makeUser()), {
section: 'input', paths: ['data.csv', `../../outside-${process.pid}.txt`],
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('Path escapes workspace');
expect(existsSync(join(ws, 'input', 'data.csv'))).toBe(true);
});
});
describe('POST /api/local/tasks/:taskId/files/download-zip', () => {
// supertest doesn't buffer binary bodies by default — collect the raw zip.
const zipReq = (app: express.Application, body: unknown) =>
request(app)
.post('/api/local/tasks/1/files/download-zip')
.send(body as object)
.buffer(true)
.parse((res, cb) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () => cb(null, Buffer.concat(chunks)));
});
it('zips selected output files for the owner', async () => {
const res = await zipReq(makeApp(makeRepo(), makeUser()), { section: 'output', paths: ['report.md', 'sub/nested.txt'] });
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('application/zip');
expect(res.headers['content-disposition']).toContain('files.zip');
const names = new AdmZip(res.body as Buffer).getEntries().map((e) => e.entryName).sort();
expect(names).toEqual(['report.md', 'sub/nested.txt']);
});
it('allows download from the logs section too (read, all sections)', async () => {
mkdirSync(join(ws, 'logs'), { recursive: true });
writeFileSync(join(ws, 'logs', 'activity.log'), 'hello-log');
const res = await zipReq(makeApp(makeRepo(), makeUser()), { section: 'logs', paths: ['activity.log'] });
expect(res.status).toBe(200);
const zip = new AdmZip(res.body as Buffer);
expect(zip.getEntry('activity.log')?.getData().toString()).toBe('hello-log');
});
it('downloads for a NON-owner viewer of a public task (read-gated, not edit)', async () => {
const repo = makeRepo({
getLocalTask: vi.fn().mockResolvedValue({ id: 1, ownerId: 'user-1', visibility: 'public', workspacePath: ws }),
} as Partial<Repository>);
const res = await zipReq(makeApp(repo, makeUser({ id: 'user-2' })), { section: 'output', paths: ['report.md'] });
expect(res.status).toBe(200);
});
it('hides a private task from a non-owner with 404', async () => {
const res = await zipReq(makeApp(makeRepo(), makeUser({ id: 'stranger' })), { section: 'output', paths: ['report.md'] });
expect(res.status).toBe(404);
});
it('returns 404 when nothing matches (missing/dir paths skipped)', async () => {
const res = await zipReq(makeApp(makeRepo(), makeUser()), { section: 'output', paths: ['nope.md', 'sub'] });
expect(res.status).toBe(404);
});
it('rejects traversal with 400 and never zips outside files', async () => {
const res = await request(makeApp(makeRepo(), makeUser()))
.post('/api/local/tasks/1/files/download-zip')
.send({ section: 'input', paths: [`../../outside-${process.pid}.txt`] });
expect(res.status).toBe(400);
expect(res.body.error).toBe('Path escapes workspace');
});
it('rejects an empty paths array with 400', async () => {
const res = await request(makeApp(makeRepo(), makeUser()))
.post('/api/local/tasks/1/files/download-zip')
.send({ section: 'output', paths: [] });
expect(res.status).toBe(400);
});
it('canonicalizes zip entry names so a backslash filename cannot zip-slip on Windows', async () => {
// On POSIX `a\evil.txt` is a single literal filename inside output/ (passes
// ensurePathWithin), but as a ZIP entry name `\` is a Windows separator.
// The endpoint must emit a `/`-normalized entry name with no backslashes.
writeFileSync(join(ws, 'output', 'a\\evil.txt'), 'x');
const res = await zipReq(makeApp(makeRepo(), makeUser()), { section: 'output', paths: ['a\\evil.txt'] });
expect(res.status).toBe(200);
const names = new AdmZip(res.body as Buffer).getEntries().map((e) => e.entryName);
expect(names.some((n) => n.includes('\\'))).toBe(false);
expect(names).toContain('a/evil.txt');
});
it('strips .. segments from a backslash filename so the entry cannot escape on extract', async () => {
// `a\..\evil.txt` is one literal POSIX filename (passes ensurePathWithin).
// After `\`→`/` normalization it would become `a/../evil.txt` (escapes the
// extract dir); the `..` segment must be dropped → `a/evil.txt`.
writeFileSync(join(ws, 'output', 'a\\..\\evil.txt'), 'x');
const res = await zipReq(makeApp(makeRepo(), makeUser()), { section: 'output', paths: ['a\\..\\evil.txt'] });
expect(res.status).toBe(200);
const names = new AdmZip(res.body as Buffer).getEntries().map((e) => e.entryName);
expect(names.every((n) => !n.split('/').includes('..'))).toBe(true);
expect(names.some((n) => n.includes('\\'))).toBe(false);
expect(names).toContain('a/evil.txt');
});
});

View File

@ -1,10 +1,26 @@
import express, { type Application, type Request, type Response } from 'express'; import express, { type Application, type Request, type Response } from 'express';
import { mkdirSync, readdirSync, statSync, readFileSync, writeFileSync } from 'fs'; import { mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, openSync, writeSync, closeSync, unlinkSync } from 'fs';
import { join, extname } from 'path'; import { join, extname, basename, dirname } from 'path';
import AdmZip from 'adm-zip';
import { Repository, localTaskRepoName } from '../db/repository.js'; import { Repository, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { parseTaskId } from './validation.js'; import { parseTaskId } from './validation.js';
import { ensurePathWithin, isPathEscapeError, serializeLocalFileEntry, checkTaskOwnership, canViewTask, setUntrustedFileResponseHeaders } from './local-api-helpers.js'; import { ensurePathWithin, isPathEscapeError, isNotFoundError, serializeLocalFileEntry, checkTaskOwnership, canViewTask, resolveSpaceAccess, setUntrustedFileResponseHeaders, safeZipEntryName } from './local-api-helpers.js';
import { logRoot } from '../spaces/runtime-paths.js';
/**
* root 5
* `logs` logRoot(task)runtime_dir `workspace`
* input/output workspace_path/section
* runtime_dir=NULL workspace_path/logs
* `task?.workspacePath` 404 null
*/
function sectionRoot(task: { workspacePath: string | null; runtimeDir?: string | null }, section: string): string {
const workspacePath = task.workspacePath as string;
if (section === 'logs') return logRoot({ workspacePath, runtimeDir: task.runtimeDir });
if (section === 'workspace') return workspacePath;
return join(workspacePath, section);
}
export interface LocalFilesApiOptions { export interface LocalFilesApiOptions {
/** Whether the auth subsystem is wired. When false (no-auth single-user /** Whether the auth subsystem is wired. When false (no-auth single-user
@ -30,7 +46,7 @@ export function mountLocalFilesApi(
} }
const viewer = req.user as Express.User | undefined; const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return; if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
if (!task?.workspacePath) { if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' }); res.status(404).json({ error: 'Workspace not found' });
return; return;
@ -43,7 +59,7 @@ export function mountLocalFilesApi(
} }
const relativeDir = String(req.query.path ?? '').replace(/^\/+/, '').replace(/\/+$/, ''); const relativeDir = String(req.query.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section); const rootDir = sectionRoot(task, section);
mkdirSync(rootDir, { recursive: true }); mkdirSync(rootDir, { recursive: true });
const dirPath = ensurePathWithin(rootDir, relativeDir); const dirPath = ensurePathWithin(rootDir, relativeDir);
const entries = readdirSync(dirPath, { withFileTypes: true }).map((entry) => { const entries = readdirSync(dirPath, { withFileTypes: true }).map((entry) => {
@ -56,6 +72,11 @@ export function mountLocalFilesApi(
res.status(400).json({ error: 'Path escapes workspace' }); res.status(400).json({ error: 'Path escapes workspace' });
return; return;
} }
if (isNotFoundError(err)) {
logger.debug(`Local files list not found: ${err}`);
res.status(404).json({ error: 'not found' });
return;
}
logger.error(`Local files list API error: ${err}`); logger.error(`Local files list API error: ${err}`);
res.status(500).json({ error: 'Failed to list files' }); res.status(500).json({ error: 'Failed to list files' });
} }
@ -70,7 +91,7 @@ export function mountLocalFilesApi(
} }
const viewer = req.user as Express.User | undefined; const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return; if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
if (!task?.workspacePath) { if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' }); res.status(404).json({ error: 'Workspace not found' });
return; return;
@ -86,7 +107,7 @@ export function mountLocalFilesApi(
res.status(400).json({ error: 'path is required' }); res.status(400).json({ error: 'path is required' });
return; return;
} }
const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section); const rootDir = sectionRoot(task, section);
const filePath = ensurePathWithin(rootDir, relativePath); const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath); const stat = statSync(filePath);
if (!stat.isFile()) { if (!stat.isFile()) {
@ -101,6 +122,11 @@ export function mountLocalFilesApi(
res.status(400).json({ error: 'Path escapes workspace' }); res.status(400).json({ error: 'Path escapes workspace' });
return; return;
} }
if (isNotFoundError(err)) {
logger.debug(`Local file content not found: ${err}`);
res.status(404).json({ error: 'not found' });
return;
}
logger.error(`Local file content API error: ${err}`); logger.error(`Local file content API error: ${err}`);
res.status(500).json({ error: 'Failed to read file' }); res.status(500).json({ error: 'Failed to read file' });
} }
@ -115,7 +141,7 @@ export function mountLocalFilesApi(
} }
const viewer = req.user as Express.User | undefined; const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return; if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
if (!task?.workspacePath) { if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' }); res.status(404).json({ error: 'Workspace not found' });
return; return;
@ -131,26 +157,25 @@ export function mountLocalFilesApi(
res.status(400).json({ error: 'path is required' }); res.status(400).json({ error: 'path is required' });
return; return;
} }
const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section); const rootDir = sectionRoot(task, section);
const filePath = ensurePathWithin(rootDir, relativePath); const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath); const stat = statSync(filePath);
if (!stat.isFile()) { if (!stat.isFile()) {
res.status(400).json({ error: 'path must point to a file' }); res.status(400).json({ error: 'path must point to a file' });
return; return;
} }
// trusted=1 drops the CSP sandbox so the owner's own generated HTML can // trusted=1 drops the CSP sandbox so generated HTML can run on the app
// run on the app origin. STRICTLY owner-only — self-XSS at worst: // origin as an app. Trust model: task owners are trusted colleagues
// - org/public visibility lets other users VIEW the task, but serving // (internal Gitea-org tool). Any viewer who already passed the visibility
// someone else's HTML unsandboxed here would be stored XSS against // gate (canViewTask above — private=owner+admin, org, public) gets the
// the viewer; // trusted version. Accepted tradeoff: an owner's unsandboxed HTML runs
// - admins are excluded too: another user's HTML running in an ADMIN // with the viewer's session, so a malicious owner could act as a viewer —
// session would be a user→admin privilege-escalation lure. // owners are trusted, so this is allowed.
// Hard floor: an UNAUTHENTICATED request (no viewer) never gets trusted,
// so a shared trusted link bounces through login (requireAuth) first.
// No-auth single-user mode has no req.user; the sole operator owns every // No-auth single-user mode has no req.user; the sole operator owns every
// task, so they are the owner for this purpose (self-XSS only — there is // task (self-XSS only — there is no second principal to attack).
// no second principal to attack). const trustedAllowed = authActive ? !!viewer : true;
const trustedAllowed = authActive
? !!viewer && task.ownerId != null && viewer.id === task.ownerId
: true;
const trustedHtml = req.query.trusted === '1' && /\.html?$/i.test(filePath) && trustedAllowed; const trustedHtml = req.query.trusted === '1' && /\.html?$/i.test(filePath) && trustedAllowed;
if (!trustedHtml) { if (!trustedHtml) {
setUntrustedFileResponseHeaders(res); setUntrustedFileResponseHeaders(res);
@ -162,6 +187,11 @@ export function mountLocalFilesApi(
res.status(400).json({ error: 'Path escapes workspace' }); res.status(400).json({ error: 'Path escapes workspace' });
return; return;
} }
if (isNotFoundError(err)) {
logger.debug(`Local file raw not found: ${err}`);
res.status(404).json({ error: 'not found' });
return;
}
logger.error(`Local file raw API error: ${err}`); logger.error(`Local file raw API error: ${err}`);
res.status(500).json({ error: 'Failed to read raw file' }); res.status(500).json({ error: 'Failed to read raw file' });
} }
@ -215,4 +245,223 @@ export function mountLocalFilesApi(
res.status(500).json({ error: 'Failed to update file' }); res.status(500).json({ error: 'Failed to update file' });
} }
}); });
// upload / delete が書き込みを許す区分。logs実行ログと workspace作業ディレクトリ
// エージェント管理領域なので人手の書き込み対象から外す(編集 PUT が output 限定なのと同方針)。
const WRITABLE_SECTIONS = ['input', 'output'] as const;
/**
* upload / delete owner/admin
* task null return
*/
async function loadWritableTask(req: Request, res: Response, section: string): Promise<{ workspacePath: string; runtimeDir?: string | null } | null> {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return null;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return null;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return null;
}
if (!WRITABLE_SECTIONS.includes(section as typeof WRITABLE_SECTIONS[number])) {
res.status(400).json({ error: `section must be one of: ${WRITABLE_SECTIONS.join(', ')}` });
return null;
}
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (latestJob && ['running', 'dispatching'].includes(latestJob.status)) {
res.status(409).json({ error: 'Cannot modify files while job is running' });
return null;
}
return { workspacePath: task.workspacePath, runtimeDir: task.runtimeDir };
}
// input/output へファイルをアップロード複数可。space-api の /files/upload と同型:
// O_EXCL で衝突回避リネーム上書き禁止、ensurePathWithin で section root に封じ込め。
app.post('/api/local/tasks/:taskId/files/upload', express.json({ limit: '60mb' }), async (req: Request, res: Response) => {
try {
const section = String(req.body?.section ?? '');
const task = await loadWritableTask(req, res, section);
if (!task) return;
const files = req.body?.files;
if (!Array.isArray(files) || files.length === 0) {
res.status(400).json({ error: 'files must be a non-empty array' });
return;
}
const reqPath = String(req.body?.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
const rootDir = sectionRoot(task, section);
const uploaded: { name: string; path: string }[] = [];
for (const file of files) {
const safeName = String(file?.name ?? '').replace(/[\\/]/g, '_');
if (!safeName) {
res.status(400).json({ error: 'file name is required' });
return;
}
const abs = ensurePathWithin(rootDir, join(reqPath, safeName));
mkdirSync(dirname(abs), { recursive: true });
const buf = Buffer.from(String(file?.contentBase64 ?? ''), 'base64');
// 衝突回避: 既存なら `{stem} (N){ext}` を O_EXCL で予約して書く(上書き禁止)。
const ext = extname(safeName);
const stem = basename(safeName, ext);
let candidateName = safeName;
let candidateAbs = abs;
let fd: number | undefined;
for (let n = 1; ; n++) {
if (n > 1) {
candidateName = `${stem} (${n})${ext}`;
candidateAbs = ensurePathWithin(rootDir, join(reqPath, candidateName));
}
try {
fd = openSync(candidateAbs, 'wx'); // O_EXCL: 既存なら EEXIST
break;
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'EEXIST') continue;
throw e;
}
}
try {
writeSync(fd, buf);
} finally {
closeSync(fd);
}
const writtenRel = reqPath ? `${reqPath}/${candidateName}` : candidateName;
uploaded.push({ name: candidateName, path: writtenRel });
}
res.json({ uploaded });
} catch (err) {
if (isPathEscapeError(err)) {
res.status(400).json({ error: 'Path escapes workspace' });
return;
}
logger.error(`Local file upload API error: ${err}`);
res.status(500).json({ error: 'Failed to upload files' });
}
});
// input/output のファイルを削除。body は { section, paths: string[] }(単一 { path } も許容)。
// ファイルのみ対象(ディレクトリ/不在は skipped、冪等。
app.post('/api/local/tasks/:taskId/files/delete', express.json(), async (req: Request, res: Response) => {
try {
const section = String(req.body?.section ?? '');
const task = await loadWritableTask(req, res, section);
if (!task) return;
const rawPaths: unknown =
Array.isArray(req.body?.paths) ? req.body.paths
: req.body?.path != null ? [req.body.path]
: null;
if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
res.status(400).json({ error: 'paths must be a non-empty array (or a single path)' });
return;
}
const rootDir = sectionRoot(task, section);
const deleted: string[] = [];
const skipped: string[] = [];
// まず全 path を封じ込め検証してから削除する2パス。後続 path が traversal で
// 400 になっても、先行ファイルだけ削除済みになる部分適用を避ける。
const resolved: { rel: string; abs: string }[] = [];
for (const raw of rawPaths) {
const rel = String(raw ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
if (!rel) { skipped.push(String(raw ?? '')); continue; }
const abs = ensurePathWithin(rootDir, rel); // escape は throw → 下で 400 化(削除前)
resolved.push({ rel, abs });
}
for (const { rel, abs } of resolved) {
let stat;
try {
stat = statSync(abs);
} catch {
skipped.push(rel); // 存在しない → 冪等にスキップ
continue;
}
if (!stat.isFile()) { skipped.push(rel); continue; } // ディレクトリ等は対象外
unlinkSync(abs);
deleted.push(rel);
}
res.json({ deleted, skipped });
} catch (err) {
if (isPathEscapeError(err)) {
res.status(400).json({ error: 'Path escapes workspace' });
return;
}
logger.error(`Local file delete API error: ${err}`);
res.status(500).json({ error: 'Failed to delete files' });
}
});
// 複数ファイルを zip にまとめてダウンロード。ダウンロードは閲覧操作なので read ゲート
// canViewTaskで、全 sectionworkspace/input/output/logsから取れる。各 path は
// ensurePathWithin で section root に封じ込めtraversal は 400。ファイルのみ対象、
// ディレクトリ/不在はスキップ。zip エントリ名は相対パス(絶対パスを漏らさない)。
app.post('/api/local/tasks/:taskId/files/download-zip', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) {
res.status(400).json({ error: 'Invalid task ID' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
if (!task?.workspacePath) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
const section = String(req.body?.section ?? 'output');
if (!['workspace', 'input', 'output', 'logs'].includes(section)) {
res.status(400).json({ error: 'section must be workspace, input, output, or logs' });
return;
}
const rawPaths: unknown =
Array.isArray(req.body?.paths) ? req.body.paths
: req.body?.path != null ? [req.body.path]
: null;
if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
res.status(400).json({ error: 'paths must be a non-empty array (or a single path)' });
return;
}
const rootDir = sectionRoot(task, section);
const zip = new AdmZip();
let added = 0;
for (const raw of rawPaths) {
const rel = String(raw ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
if (!rel) continue;
const abs = ensurePathWithin(rootDir, rel); // escape は throw → 下で 400 化
let stat;
try { stat = statSync(abs); } catch { continue; } // 不在はスキップ
if (!stat.isFile()) continue; // ディレクトリ等は対象外
// zip エントリ名は封じ込め済み abs から安全に再生成zip-slip 対策、共有ヘルパ)。
const entryName = safeZipEntryName(rootDir, abs);
if (!entryName) continue;
zip.addFile(entryName, readFileSync(abs));
added++;
}
if (added === 0) {
res.status(404).json({ error: 'no downloadable files' });
return;
}
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', 'attachment; filename="files.zip"');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.send(zip.toBuffer());
} catch (err) {
if (isPathEscapeError(err)) {
res.status(400).json({ error: 'Path escapes workspace' });
return;
}
logger.error(`Local file zip API error: ${err}`);
res.status(500).json({ error: 'Failed to build zip' });
}
});
} }

View File

@ -0,0 +1,180 @@
// Shared-workspace WRITE gate for the comment + cancel routes.
//
// Bug: commenting on a co-member's space task returned 404 because the route
// gated with checkTaskOwnership (owner/admin only). A space editor member must
// be able to comment; a space viewer-only member must get a CLEAR 403 (not a
// 404); a non-member must still get 404; owner/admin still work.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { mountLocalTasksApi } from './local-tasks-api.js';
function user(id: string, role: 'user' | 'admin' = 'user', orgIds: string[] = []): Express.User {
return {
id, email: `${id}@x.com`, name: id, avatarUrl: null,
role, status: 'active', orgIds,
defaultVisibility: 'private', defaultVisibilityOrgId: null,
} as Express.User;
}
describe('comment route write gate (shared workspace)', () => {
let dir = '';
let repo: Repository;
let ownerId = '';
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'lt-comment-gate-'));
repo = new Repository(join(dir, 'db.sqlite'));
ownerId = repo.createUser({ email: 'o@x.com', name: 'o', role: 'user', status: 'active' }).id;
});
afterEach(() => {
repo.close();
rmSync(dir, { recursive: true, force: true });
});
function appAs(u: Express.User): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = u;
next();
});
mountLocalTasksApi(app, { repo, worktreeDir: join(dir, 'workspaces') });
return app;
}
async function caseSpaceTask() {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId, visibility: 'private' });
const task = await repo.createLocalTask({
title: 't', body: 'b', pieceName: 'manual-writer', ownerId, spaceId: space.id, visibility: 'private',
});
return { space, task };
}
it('owner can comment (201)', async () => {
const { task } = await caseSpaceTask();
const res = await request(appAs(user(ownerId)))
.post(`/api/local/tasks/${task.id}/comments`)
.send({ body: 'hello', author: 'o' });
expect(res.status).toBe(201);
});
it('admin can comment (201)', async () => {
const { task } = await caseSpaceTask();
const admin = repo.createUser({ email: 'adm@x.com', name: 'adm', role: 'admin', status: 'active' });
const res = await request(appAs(user(admin.id, 'admin')))
.post(`/api/local/tasks/${task.id}/comments`)
.send({ body: 'hi', author: 'adm' });
expect(res.status).toBe(201);
});
it('editor member can comment (201)', async () => {
const { space, task } = await caseSpaceTask();
const editor = repo.createUser({ email: 'ed@x.com', name: 'ed', role: 'user', status: 'active' });
await repo.addSpaceMember({ spaceId: space.id, userId: editor.id, role: 'editor' });
const res = await request(appAs(user(editor.id)))
.post(`/api/local/tasks/${task.id}/comments`)
.send({ body: 'from editor', author: 'ed' });
expect(res.status).toBe(201);
});
it('viewer-only member is denied with a clear 403 (not 404)', async () => {
const { space, task } = await caseSpaceTask();
const v = repo.createUser({ email: 'v@x.com', name: 'v', role: 'user', status: 'active' });
await repo.addSpaceMember({ spaceId: space.id, userId: v.id, role: 'viewer' });
const res = await request(appAs(user(v.id)))
.post(`/api/local/tasks/${task.id}/comments`)
.send({ body: 'should fail', author: 'v' });
expect(res.status).toBe(403);
expect(res.body.error).toBe('閲覧のみのため投稿できません');
});
it('non-member gets 404', async () => {
const { task } = await caseSpaceTask();
const stranger = repo.createUser({ email: 's@x.com', name: 's', role: 'user', status: 'active' });
const res = await request(appAs(user(stranger.id)))
.post(`/api/local/tasks/${task.id}/comments`)
.send({ body: 'nope', author: 's' });
expect(res.status).toBe(404);
});
it('non-space personal task: non-owner still gets 404 (back-compat)', async () => {
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, visibility: 'private' });
const stranger = repo.createUser({ email: 's2@x.com', name: 's2', role: 'user', status: 'active' });
const res = await request(appAs(user(stranger.id)))
.post(`/api/local/tasks/${task.id}/comments`)
.send({ body: 'nope', author: 's2' });
expect(res.status).toBe(404);
});
});
describe('cancel route write gate (shared workspace)', () => {
let dir = '';
let repo: Repository;
let ownerId = '';
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'lt-cancel-gate-'));
repo = new Repository(join(dir, 'db.sqlite'));
ownerId = repo.createUser({ email: 'o@x.com', name: 'o', role: 'user', status: 'active' }).id;
});
afterEach(() => {
repo.close();
rmSync(dir, { recursive: true, force: true });
});
function appAs(u: Express.User): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = u;
next();
});
mountLocalTasksApi(app, { repo, worktreeDir: join(dir, 'workspaces') });
return app;
}
async function runningSpaceTask() {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId, visibility: 'private' });
const task = await repo.createLocalTask({
title: 't', body: 'b', pieceName: 'manual-writer', ownerId, spaceId: space.id, visibility: 'private',
});
const job = await repo.createJob({
repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go',
pieceName: 'manual-writer', ownerId, spaceId: space.id,
});
await repo.updateJob(job.id, { status: 'running' });
return { space, task };
}
it('editor member can cancel a running job (200)', async () => {
const { space, task } = await runningSpaceTask();
const editor = repo.createUser({ email: 'ed2@x.com', name: 'ed2', role: 'user', status: 'active' });
await repo.addSpaceMember({ spaceId: space.id, userId: editor.id, role: 'editor' });
const res = await request(appAs(user(editor.id))).post(`/api/local/tasks/${task.id}/cancel`);
expect(res.status).toBe(200);
});
it('viewer-only member is denied cancel with 403', async () => {
const { space, task } = await runningSpaceTask();
const v = repo.createUser({ email: 'v2@x.com', name: 'v2', role: 'user', status: 'active' });
await repo.addSpaceMember({ spaceId: space.id, userId: v.id, role: 'viewer' });
const res = await request(appAs(user(v.id))).post(`/api/local/tasks/${task.id}/cancel`);
expect(res.status).toBe(403);
expect(res.body.error).toBe('閲覧のみのため操作できません');
});
it('non-member gets 404 on cancel', async () => {
const { task } = await runningSpaceTask();
const stranger = repo.createUser({ email: 's3@x.com', name: 's3', role: 'user', status: 'active' });
const res = await request(appAs(user(stranger.id))).post(`/api/local/tasks/${task.id}/cancel`);
expect(res.status).toBe(404);
});
});

View File

@ -0,0 +1,100 @@
// Membership-aware READ gate exercised end-to-end through the HTTP API.
//
// Core regression: now that space tasks are forced visibility='private', a
// space member (or root owner) loading ANOTHER member's chat detail / files /
// logs via the secondary per-request gate (canViewTask) must get 200, not 404.
// A non-member must still get 404. This proves both gaps are closed:
// gap-1: buildVisibilityWhere now grants the root owner access, and
// gap-2: canViewTask is membership-aware via repo.userCanViewSpace.
// spec: docs/superpowers/specs/2026-06-19-shared-space-design.md
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Repository } from '../db/repository.js';
import { mountLocalTasksApi } from './local-tasks-api.js';
import { mountLocalFilesApi } from './local-files-api.js';
function user(id: string, role: 'user' | 'admin' = 'user', orgIds: string[] = []): Express.User {
return {
id, email: `${id}@x.com`, name: id, avatarUrl: null,
role, status: 'active', orgIds,
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
}
describe('space membership READ gate (HTTP, real repo)', () => {
let dir = '';
let repo: Repository;
let memberId = '';
let strangerId = '';
let spaceId = '';
let taskId = 0;
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'lt-space-view-'));
repo = new Repository(join(dir, 'db.sqlite'));
const owner = repo.createUser({ email: 'o@x.com', name: 'o', role: 'user', status: 'active' });
const memberB = repo.createUser({ email: 'b@x.com', name: 'b', role: 'user', status: 'active' });
const memberA = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
const stranger = repo.createUser({ email: 's@x.com', name: 's', role: 'user', status: 'active' });
memberId = memberA.id;
strangerId = stranger.id;
const space = await repo.createSpace({ kind: 'case', title: '案件', ownerId: owner.id, visibility: 'private' });
spaceId = space.id;
await repo.addSpaceMember({ spaceId, userId: memberA.id, role: 'viewer' });
await repo.addSpaceMember({ spaceId, userId: memberB.id, role: 'editor' });
// Task owned by memberB, private, inside the space, with a real workspace.
const ws = join(dir, 'ws-task');
mkdirSync(join(ws, 'logs'), { recursive: true });
const task = await repo.createLocalTask({
title: 'b task', body: 'x', ownerId: memberB.id, visibility: 'private',
spaceId, workspacePath: ws,
});
taskId = task.id;
});
afterEach(() => {
repo.close();
rmSync(dir, { recursive: true, force: true });
});
function appAs(u: Express.User): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = u;
next();
});
mountLocalTasksApi(app, { repo, worktreeDir: join(dir, 'workspaces') });
mountLocalFilesApi(app, repo);
return app;
}
it('member A (different from task owner B) GETs the chat detail → 200', async () => {
const res = await request(appAs(user(memberId))).get(`/api/local/tasks/${taskId}`);
expect(res.status).toBe(200);
expect(res.body.task.id).toBe(taskId);
});
it('member A GETs the Files tab (section=logs) → 200', async () => {
const res = await request(appAs(user(memberId))).get(`/api/local/tasks/${taskId}/files?section=logs`);
expect(res.status).toBe(200);
expect(res.body.basePath).toBe('logs');
});
it('stranger (non-member) GET chat detail → 404', async () => {
const res = await request(appAs(user(strangerId))).get(`/api/local/tasks/${taskId}`);
expect(res.status).toBe(404);
});
it('stranger (non-member) GET Files tab → 404', async () => {
const res = await request(appAs(user(strangerId))).get(`/api/local/tasks/${taskId}/files?section=logs`);
expect(res.status).toBe(404);
});
});

View File

@ -0,0 +1,322 @@
// Space-scoped task operations exercised through the HTTP API.
//
// The personal-task auth matrix (owner/admin/non-member) is already covered in
// local-tasks-api.test.ts. Here we prove the SAME routes behave correctly when
// the task lives INSIDE a case space (space_id set): the operation succeeds for
// owner/admin, is denied (404) for a non-member, and — critically — that the
// HTTP /continue route propagates space_id onto the spawned child job (the
// regression that would silently route a space job through the wrong
// MCP/SSH/folder set).
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { mountLocalTasksApi } from './local-tasks-api.js';
function user(id: string, role: 'user' | 'admin' = 'user', orgIds: string[] = []): Express.User {
return {
id, email: `${id}@x.com`, name: id, avatarUrl: null,
role, status: 'active', orgIds,
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
}
describe('space-scoped task operations (HTTP)', () => {
let dir = '';
let repo: Repository;
let ownerId = '';
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'lt-space-'));
repo = new Repository(join(dir, 'db.sqlite'));
ownerId = repo.createUser({ email: 'o@x.com', name: 'o', role: 'user', status: 'active' }).id;
});
afterEach(() => {
repo.close();
rmSync(dir, { recursive: true, force: true });
});
function appAs(u: Express.User, extra: Record<string, unknown> = {}): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = u;
next();
});
mountLocalTasksApi(app, { repo, worktreeDir: join(dir, 'workspaces'), ...extra });
return app;
}
async function spaceTaskWithTerminalJob(spaceId: string, status = 'succeeded') {
const task = await repo.createLocalTask({
title: 't', body: 'b', pieceName: 'manual-writer', ownerId, spaceId,
});
const prev = await repo.createJob({
repo: localTaskRepoName(task.id), issueNumber: task.id, instruction: 'go',
pieceName: 'manual-writer', ownerId, spaceId,
});
await repo.updateJob(prev.id, { status: status as never });
return { task, prev };
}
// ── DELETE ──────────────────────────────────────────────────────────────────
it('owner can DELETE a private space task; it disappears', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' });
const res = await request(appAs(user(ownerId))).delete(`/api/local/tasks/${task.id}`);
expect(res.status).toBe(200);
expect(await repo.getLocalTask(task.id)).toBeNull();
});
it('a non-member (non-owner, non-admin) gets 404 on DELETE of a private space task; task survives', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' });
const res = await request(appAs(user('intruder'))).delete(`/api/local/tasks/${task.id}`);
expect(res.status).toBe(404);
expect(await repo.getLocalTask(task.id)).not.toBeNull();
});
it('admin can DELETE any space task', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' });
const res = await request(appAs(user('root', 'admin'))).delete(`/api/local/tasks/${task.id}`);
expect(res.status).toBe(200);
expect(await repo.getLocalTask(task.id)).toBeNull();
});
// ── CANCEL ──────────────────────────────────────────────────────────────────
it('owner can cancel a running space task; the job goes cancelled and keeps its space_id', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
const { prev } = await spaceTaskWithTerminalJob(space.id, 'running');
const res = await request(appAs(user(ownerId))).post(`/api/local/tasks/${prev.issueNumber}/cancel`);
expect(res.status).toBe(200);
expect(res.body.jobId).toBe(prev.id);
const after = await repo.getJob(prev.id);
expect(after?.status).toBe('cancelled');
expect(after?.spaceId).toBe(space.id);
});
it('non-member gets 404 on cancel of a private space task (job left running)', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
const { task, prev } = await spaceTaskWithTerminalJob(space.id, 'running');
const res = await request(appAs(user('intruder'))).post(`/api/local/tasks/${task.id}/cancel`);
expect(res.status).toBe(404);
expect((await repo.getJob(prev.id))?.status).toBe('running');
});
// ── CONTINUE-WITH-PIECE: space_id propagation through the HTTP route ─────────
it('continue: the HTTP route spawns a child job carrying the parent space_id', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
const { task, prev } = await spaceTaskWithTerminalJob(space.id, 'succeeded');
const app = appAs(user(ownerId), { pieceExists: (n: string) => n === 'research' || n === 'manual-writer' });
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'research', instruction: 'keep going in this space' });
expect(res.status).toBe(201);
const child = await repo.getJob(res.body.jobId);
expect(child?.pieceName).toBe('research');
expect(child?.continuedFromJobId).toBe(prev.id);
// The whole point: a space task's continuation must stay in the same space.
expect(child?.spaceId).toBe(space.id);
});
it('continue: a non-member gets 404 and no child job is created', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
const { task } = await spaceTaskWithTerminalJob(space.id, 'succeeded');
const app = appAs(user('intruder'), { pieceExists: () => true });
const res = await request(app)
.post(`/api/local/tasks/${task.id}/continue`)
.send({ piece: 'research', instruction: 'sneak in' });
expect(res.status).toBe(404);
// Only the original starter job exists (no continuation spawned).
const jobCount = (repo.getDb()
.prepare('SELECT COUNT(*) AS c FROM jobs WHERE repo = ?')
.get(localTaskRepoName(task.id)) as { c: number }).c;
expect(jobCount).toBe(1);
});
// ── CREATE: space tasks are FORCED private (no org/public leak) ──────────────
it('create: a task with spaceId set is stored private even when body asks for public', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
const app = appAs(user(ownerId), { selectPiece: async () => 'chat' });
const res = await request(app)
.post('/api/local/tasks')
.send({ body: 'hello space', spaceId: space.id, visibility: 'public' });
expect(res.status).toBe(201);
const created = await repo.getLocalTask(res.body.task.id);
expect(created?.spaceId).toBe(space.id);
// body said 'public' but a space task is always private — access is membership-only.
expect(created?.visibility).toBe('private');
expect(created?.visibilityScopeOrgId).toBeNull();
});
it('create: spaceId set + body org → still private (org scope ignored)', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
const app = appAs(user(ownerId, 'user', ['acme']), { selectPiece: async () => 'chat' });
const res = await request(app)
.post('/api/local/tasks')
.send({ body: 'hi', spaceId: space.id, visibility: 'org', visibilityScopeOrgId: 'acme' });
expect(res.status).toBe(201);
const created = await repo.getLocalTask(res.body.task.id);
expect(created?.visibility).toBe('private');
expect(created?.visibilityScopeOrgId).toBeNull();
});
it('create: a non-space (personal) task keeps the requested public visibility', async () => {
const app = appAs(user(ownerId), { selectPiece: async () => 'chat' });
const res = await request(app)
.post('/api/local/tasks')
.send({ body: 'personal', visibility: 'public' });
expect(res.status).toBe(201);
const created = await repo.getLocalTask(res.body.task.id);
expect(created?.spaceId).toBeNull();
expect(created?.visibility).toBe('public');
});
it('end-to-end: a forced-private space chat is invisible to non-members, visible to members', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
const memberId = repo.createUser({ email: 'm@x.com', name: 'm', role: 'user', status: 'active' }).id;
const strangerId = repo.createUser({ email: 's@x.com', name: 's', role: 'user', status: 'active' }).id;
await repo.addSpaceMember({ spaceId: space.id, userId: memberId, role: 'editor', invitedBy: ownerId });
// Create through the API as the owner, asking (futilely) for public.
const app = appAs(user(ownerId), { selectPiece: async () => 'chat' });
const cres = await request(app)
.post('/api/local/tasks')
.send({ body: 'space chat', spaceId: space.id, visibility: 'public' });
expect(cres.status).toBe(201);
const taskId = cres.body.task.id;
expect((await repo.getLocalTask(taskId))?.visibility).toBe('private');
// Access control runs through buildVisibilityWhere (the membership OR-branch),
// which is where space membership grants/denies read. Assert it directly at
// the repository layer — the single source of truth for who can see a row.
// Member (non-owner) of the space CAN see the private space chat.
const seenByMember = await repo.getLocalTask(taskId, { viewer: user(memberId) });
expect(seenByMember).not.toBeNull();
expect(seenByMember?.spaceId).toBe(space.id);
// Non-member CANNOT (private + not a member → denied → null).
const seenByStranger = await repo.getLocalTask(taskId, { viewer: user(strangerId) });
expect(seenByStranger).toBeNull();
// And the same denial surfaces end-to-end through the HTTP GET for a stranger.
const strangerRes = await request(appAs(user(strangerId))).get(`/api/local/tasks/${taskId}`);
expect(strangerRes.status).toBe(404);
// The task list (also membership-gated via buildVisibilityWhere) includes it
// for the member and excludes it for the stranger.
const memberList = await repo.listLocalTasks({ viewer: user(memberId) });
expect(memberList.some(t => t.id === taskId)).toBe(true);
const strangerList = await repo.listLocalTasks({ viewer: user(strangerId) });
expect(strangerList.some(t => t.id === taskId)).toBe(false);
});
// ── VISIBILITY ──────────────────────────────────────────────────────────────
it('owner can PATCH visibility of a space task; persists and keeps the space tag', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' });
const res = await request(appAs(user(ownerId)))
.patch(`/api/local/tasks/${task.id}`)
.send({ visibility: 'public' });
expect(res.status).toBe(200);
expect(res.body.task.visibility).toBe('public');
const after = await repo.getLocalTask(task.id);
expect(after?.visibility).toBe('public');
expect(after?.spaceId).toBe(space.id);
});
it('non-member gets 404 on PATCH visibility of a private space task', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' });
const res = await request(appAs(user('intruder')))
.patch(`/api/local/tasks/${task.id}`)
.send({ visibility: 'public' });
expect(res.status).toBe(404);
expect((await repo.getLocalTask(task.id))?.visibility).toBe('private');
});
// ── FEEDBACK ──────────────────────────────────────────────────────────────
it('owner can submit feedback on a space task; it persists', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' });
const res = await request(appAs(user(ownerId)))
.put(`/api/local/tasks/${task.id}/feedback`)
.send({ rating: 'good', tags: ['useful'] });
expect(res.status).toBe(200);
const after = await repo.getLocalTask(task.id);
expect(after?.feedbackRating).toBe('good');
expect(after?.feedbackTags).toEqual(['useful']);
expect(after?.spaceId).toBe(space.id);
});
it('non-member gets 404 on feedback for a private space task', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
const task = await repo.createLocalTask({ title: 't', body: 'b', ownerId, spaceId: space.id, visibility: 'private' });
const res = await request(appAs(user('intruder')))
.put(`/api/local/tasks/${task.id}/feedback`)
.send({ rating: 'bad', tags: [] });
expect(res.status).toBe(404);
expect((await repo.getLocalTask(task.id))?.feedbackRating).toBeNull();
});
// ── TITLE REGENERATE ────────────────────────────────────────────────────────
it('owner can regenerate the title of a space task; updateLocalTask persists agent title', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
const task = await repo.createLocalTask({
title: 'old', titleSource: 'auto', body: 'long body to summarize', ownerId, spaceId: space.id,
});
const app = appAs(user(ownerId), { generateTitle: async () => 'A Crisp New Title' });
const res = await request(app).post(`/api/local/tasks/${task.id}/regenerate-title`);
expect(res.status).toBe(200);
expect(res.body.title).toBe('A Crisp New Title');
const after = await repo.getLocalTask(task.id);
expect(after?.title).toBe('A Crisp New Title');
expect(after?.titleSource).toBe('agent');
expect(after?.spaceId).toBe(space.id);
});
it('non-member gets 404 on title regeneration of a private space task', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId });
const task = await repo.createLocalTask({ title: 'old', body: 'b', ownerId, spaceId: space.id, visibility: 'private' });
const app = appAs(user('intruder'), { generateTitle: async () => 'nope' });
const res = await request(app).post(`/api/local/tasks/${task.id}/regenerate-title`);
expect(res.status).toBe(404);
expect((await repo.getLocalTask(task.id))?.title).toBe('old');
});
});

View File

@ -140,6 +140,192 @@ describe('POST /api/local/tasks in no-auth mode (synthetic local owner)', () =>
}); });
}); });
describe('tool-request decide flow (no-auth)', () => {
let tempDir = '';
let repo: Repository;
let app: express.Application;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-api-tr-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
app = express();
app.use(express.json());
mountLocalTasksApi(app, { repo, worktreeDir: join(tempDir, 'workspaces'), authActive: false });
});
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
async function makeTaskWithPendingRequest() {
const created = await request(app).post('/api/local/tasks').send({ body: 'x', piece: 'chat' });
const taskId = created.body.task.id as number;
const job = repo.getDb()
.prepare(`SELECT id FROM jobs WHERE repo = ? ORDER BY created_at DESC LIMIT 1`)
.get(localTaskRepoName(taskId)) as { id: string };
// Park the spawned job as waiting for tool approval (what the engine does).
repo.getDb().prepare(`UPDATE jobs SET status='waiting_human', wait_reason='tool_request' WHERE id = ?`).run(job.id);
const reqId = repo.recordToolRequest({
taskId: String(taskId), jobId: job.id, pieceName: 'chat', movementName: 'respond',
toolName: 'WebSearch', reason: 'need search', category: 'requested', status: 'pending',
});
return { taskId, jobId: job.id, reqId };
}
it('GET lists the pending tool requests for the task', async () => {
const { taskId } = await makeTaskWithPendingRequest();
const res = await request(app).get(`/api/local/tasks/${taskId}/tool-requests`);
expect(res.status).toBe(200);
expect(res.body.toolRequests).toHaveLength(1);
expect(res.body.toolRequests[0].toolName).toBe('WebSearch');
expect(res.body.toolRequests[0].status).toBe('pending');
});
it('approve grants the tool for the task and re-queues the parked job', async () => {
const { taskId, jobId, reqId } = await makeTaskWithPendingRequest();
const res = await request(app).post(`/api/local/tasks/${taskId}/tool-requests/${reqId}/decide`).send({ decision: 'approve' });
expect(res.status).toBe(200);
expect(res.body.resumed).toBe(true);
expect(repo.getGrantedTools(String(taskId))).toContain('WebSearch');
expect(repo.getToolRequest(reqId)!.status).toBe('approved');
expect((await repo.getJob(jobId))!.status).toBe('queued');
});
it('deny resumes the job without granting', async () => {
const { taskId, jobId, reqId } = await makeTaskWithPendingRequest();
const res = await request(app).post(`/api/local/tasks/${taskId}/tool-requests/${reqId}/decide`).send({ decision: 'deny' });
expect(res.status).toBe(200);
expect(repo.getGrantedTools(String(taskId))).toEqual([]);
expect(repo.getToolRequest(reqId)!.status).toBe('denied');
expect((await repo.getJob(jobId))!.status).toBe('queued');
});
it('deciding an already-decided request → 409', async () => {
const { taskId, reqId } = await makeTaskWithPendingRequest();
await request(app).post(`/api/local/tasks/${taskId}/tool-requests/${reqId}/decide`).send({ decision: 'approve' });
const res = await request(app).post(`/api/local/tasks/${taskId}/tool-requests/${reqId}/decide`).send({ decision: 'deny' });
expect(res.status).toBe(409);
});
it('rejects an invalid decision value', async () => {
const { taskId, reqId } = await makeTaskWithPendingRequest();
const res = await request(app).post(`/api/local/tasks/${taskId}/tool-requests/${reqId}/decide`).send({ decision: 'maybe' });
expect(res.status).toBe(400);
});
it('does NOT resume a job parked without wait_reason=tool_request (worker-park contract guard)', async () => {
// Encodes the failure mode where the worker forgot to persist
// wait_reason='tool_request': the approve still records the grant, but the
// job can never be re-queued → permanently stuck. Resume MUST require the
// correct wait_reason.
const created = await request(app).post('/api/local/tasks').send({ body: 'x', piece: 'chat' });
const taskId = created.body.task.id as number;
const job = repo.getDb()
.prepare(`SELECT id FROM jobs WHERE repo = ? ORDER BY created_at DESC LIMIT 1`)
.get(localTaskRepoName(taskId)) as { id: string };
repo.getDb().prepare(`UPDATE jobs SET status='waiting_human' WHERE id = ?`).run(job.id); // no wait_reason
const reqId = repo.recordToolRequest({
taskId: String(taskId), jobId: job.id, pieceName: 'chat', movementName: 'respond',
toolName: 'WebSearch', category: 'requested', status: 'pending',
});
const res = await request(app).post(`/api/local/tasks/${taskId}/tool-requests/${reqId}/decide`).send({ decision: 'approve' });
expect(res.status).toBe(200);
expect(res.body.resumed).toBe(false);
});
});
describe('POST /api/local/tasks runtime_dir (plan 5, Phase B)', () => {
let tempDir = '';
let worktreeDir = '';
let repo: Repository;
let app: express.Application;
let user: Express.User;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-api-runtime-'));
worktreeDir = join(tempDir, 'workspaces');
repo = new Repository(join(tempDir, 'db.sqlite'));
const real = repo.createUser({ email: 'r@x.com', name: 'r', role: 'user', status: 'active' });
user = { ...real, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountLocalTasksApi(app, { repo, worktreeDir });
});
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
it('persistent task in a case space gets runtime_dir = space/{spaceId}/runs/{taskId} and the dir exists', async () => {
const space = await repo.createSpace({ ownerId: user.id, kind: 'case', title: '案件A' });
const res = await request(app).post('/api/local/tasks').send({
body: 'hello',
piece: 'chat',
spaceId: space.id,
workspaceMode: 'persistent',
});
expect(res.status).toBe(201);
const expected = join(worktreeDir, 'space', space.id, 'runs', String(res.body.task.id));
expect(res.body.task.runtimeDir).toBe(expected);
expect(existsSync(expected)).toBe(true);
});
it('ephemeral task gets a null runtime_dir (fallback path)', async () => {
const space = await repo.createSpace({ ownerId: user.id, kind: 'case', title: '案件B' });
const res = await request(app).post('/api/local/tasks').send({
body: 'hello',
piece: 'chat',
spaceId: space.id,
workspaceMode: 'ephemeral',
});
expect(res.status).toBe(201);
expect(res.body.task.runtimeDir).toBeNull();
});
it('backward-compat: a persistent task with no explicit space (personal) still succeeds and gets a runs dir', async () => {
const res = await request(app).post('/api/local/tasks').send({
body: 'hello personal',
piece: 'chat',
});
expect(res.status).toBe(201);
// Personal space is auto-resolved; runtime_dir points under that space's runs.
expect(res.body.task.runtimeDir).toMatch(/[/\\]runs[/\\]\d+$/);
expect(existsSync(res.body.task.runtimeDir)).toBe(true);
});
// Regression: attachments on the FIRST message (task creation) used to be saved
// to input/ but never recorded on the initial 'request' comment nor mentioned in
// the job instruction — so the chat UI showed no chips and the agent wasn't told
// the files existed. The comment path already did both; new-task now matches it.
it('records attachments on the initial request comment and injects them into the job instruction', async () => {
const res = await request(app).post('/api/local/tasks').send({
body: 'please review',
piece: 'chat',
attachments: [{ name: 'spec.pdf', contentBase64: Buffer.from('pdf').toString('base64') }],
});
expect(res.status).toBe(201);
const taskId = res.body.task.id;
// Recorded on the initial 'request' comment so the chat UI renders a download chip.
const comments = await repo.listLocalTaskComments(taskId);
const requestComment = comments.find(c => c.kind === 'request');
expect(requestComment?.attachments).toEqual(['spec.pdf']);
// File landed in the workspace input/ dir.
const task = await repo.getLocalTask(taskId);
expect(existsSync(join(task!.workspacePath!, 'input', 'spec.pdf'))).toBe(true);
// The agent's instruction tells it which files are in input/.
const job = await repo.getJob(res.body.jobId);
expect(job?.instruction).toContain('添付ファイルinput/ に保存済み): spec.pdf');
});
});
describe('DELETE /api/local/tasks/:id owner-or-admin', () => { describe('DELETE /api/local/tasks/:id owner-or-admin', () => {
let tempDir = ''; let tempDir = '';
let repo: Repository; let repo: Repository;
@ -1254,3 +1440,82 @@ describe('POST /api/local/tasks/evaluate-prompt (prompt coach)', () => {
expect(res.status).toBe(502); expect(res.status).toBe(502);
}); });
}); });
describe('POST /api/local/tasks workspaceMode → workspace_path', () => {
let tempDir = '';
let repo: Repository;
let app: express.Application;
let user: Express.User;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'lt-ws-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
const real = repo.createUser({ email: 'w@x.com', name: 'w', role: 'user', status: 'active' });
user = { ...real, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountLocalTasksApi(app, {
repo,
worktreeDir: join(tempDir, 'workspaces'),
userFolderRoot: join(tempDir, 'users'),
});
});
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
it('ephemeral mode → workspacePath under .../ephemeral/{id}', async () => {
const res = await request(app).post('/api/local/tasks').send({
body: 'hello',
piece: 'auto',
workspaceMode: 'ephemeral',
});
expect(res.status).toBe(201);
expect(res.body.task.workspaceMode).toBe('ephemeral');
expect(res.body.task.workspacePath).toContain(join('ephemeral', String(res.body.task.id)));
expect(existsSync(res.body.task.workspacePath)).toBe(true);
});
it('default (persistent) → workspacePath under the personal space files tree', async () => {
const res = await request(app).post('/api/local/tasks').send({
body: 'hello',
piece: 'auto',
});
expect(res.status).toBe(201);
expect(res.body.task.workspaceMode).toBe('persistent');
expect(res.body.task.workspacePath).toMatch(/[\\/]space[\\/].+[\\/]files$/);
expect(existsSync(res.body.task.workspacePath)).toBe(true);
});
it('a task created with spaceId is tagged to that case space and lands in its files tree', async () => {
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId: user.id });
const res = await request(app).post('/api/local/tasks').send({
body: 'スペース内チャット',
piece: 'auto',
spaceId: space.id,
});
expect(res.status).toBe(201);
// 回帰: 以前は spaceId が API 境界で落ち、常に null個人スペースになっていた
expect(res.body.task.spaceId).toBe(space.id);
expect(res.body.task.workspacePath).toContain(join('space', space.id, 'files'));
// 一覧でもそのスペースに紐づいて取得できる
const loaded = await repo.getLocalTask(res.body.task.id);
expect(loaded?.spaceId).toBe(space.id);
});
it('an unseeable/nonexistent spaceId falls back to the personal space (not tagged)', async () => {
const res = await request(app).post('/api/local/tasks').send({
body: 'x',
piece: 'auto',
spaceId: 'does-not-exist',
});
expect(res.status).toBe(201);
expect(res.body.task.spaceId).toBeNull();
});
});

View File

@ -6,13 +6,21 @@ import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { resolveJobScheduling } from '../scheduling.js'; import { resolveJobScheduling } from '../scheduling.js';
import { parseTaskId, validateCreateTaskBody, validateCommentBody, validateFeedbackBody } from './validation.js'; import { parseTaskId, validateCreateTaskBody, validateCommentBody, validateFeedbackBody } from './validation.js';
import { getLocalWorkspacePath, checkTaskOwnership, canViewTask } from './local-api-helpers.js'; import { getLocalWorkspacePath, checkTaskOwnership, canViewTask, resolveSpaceAccess } from './local-api-helpers.js';
import { canEditInSpace } from './visibility.js';
import { jobEventBus, type JobStreamEvent } from './job-events.js'; import { jobEventBus, type JobStreamEvent } from './job-events.js';
import { buildTitleFallback } from '../title-generation.js'; import { buildTitleFallback } from '../title-generation.js';
import { resolveTaskWorkspaceDir, ensureWorkspaceDirs } from '../spaces/workspace-resolver.js';
import { spaceRunsDir } from '../spaces/runtime-paths.js';
export interface LocalTasksApiOptions { export interface LocalTasksApiOptions {
repo: Repository; repo: Repository;
worktreeDir?: string; worktreeDir?: string;
/**
* Per-user folder root (config.userFolderRoot). Used to resolve the effective
* space for a new task's workspace path. Defaults to './data/users'.
*/
userFolderRoot?: string;
generateTitle?: (body: string, ownerId?: string) => Promise<string>; generateTitle?: (body: string, ownerId?: string) => Promise<string>;
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>; selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
/** /**
@ -62,6 +70,7 @@ export interface LocalTasksApiOptions {
export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions): void { export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions): void {
const { repo, worktreeDir, sessRepo } = opts; const { repo, worktreeDir, sessRepo } = opts;
const userFolderRoot = opts.userFolderRoot ?? './data/users';
// No-auth single-user mode: register new tasks/jobs under the 'local' owner // No-auth single-user mode: register new tasks/jobs under the 'local' owner
// (the same namespace pieces/memory/reflection use) rather than letting the // (the same namespace pieces/memory/reflection use) rather than letting the
// missing req.user fall through to a NULL owner. In auth mode this is // missing req.user fall through to a NULL owner. In auth mode this is
@ -76,6 +85,41 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
const dynamicJson = () => (req: Request, res: Response, next: express.NextFunction) => const dynamicJson = () => (req: Request, res: Response, next: express.NextFunction) =>
express.json({ limit: resolveUploadLimit() })(req, res, next); express.json({ limit: resolveUploadLimit() })(req, res, next);
// 共有ワークスペース対応の書き込みゲート。owner/admin に加え、タスクが属する
// スペースの editor 以上のメンバーにも書き込みを許す。判定は 3 値:
// 'allow' : owner/admin もしくは editor+ メンバー → 書き込み可
// 'view-only' : 閲覧はできるが書き込み権限がないspace の viewer ロール)→ 403
// 'deny' : 閲覧すらできない(非メンバー・他人の private→ 404
// 呼び出し側は view-only と deny を別々のステータスコードに割り当てる。
// owner-only のままにすべき破壊的操作DELETE 等)はこのゲートを使わず
// 従来通り checkTaskOwnership を使う。
type WriteGate = 'allow' | 'view-only' | 'deny';
const resolveTaskWriteGate = async (
viewer: Express.User | undefined,
task: { ownerId?: string | null; spaceId?: string | null } | null,
): Promise<WriteGate> => {
if (!task) return 'deny';
// no-authviewer 不在は従来通り素通りsynthetic local owner
if (!viewer) return 'allow';
if (viewer.role === 'admin') return 'allow';
if (task.ownerId && task.ownerId === viewer.id) return 'allow';
// スペースタスクならメンバーシップで判定。
if (task.spaceId) {
// getSpace は buildSpaceVisibilityWhere 経由。case スペースはメンバー
// editor/viewer どちらも)に可視なので、ここで space が取れる=閲覧可。
const space = await repo.getSpace(task.spaceId, { viewer });
if (space) {
const memberRole = repo.getSpaceMemberRole(task.spaceId, viewer.id);
if (canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return 'allow';
}
// 閲覧はできるが editor 未満viewer ロール / 根オーナーでもない)→ 403。
return 'view-only';
}
}
return 'deny';
};
app.get('/api/local/tasks', async (req: Request, res: Response) => { app.get('/api/local/tasks', async (req: Request, res: Response) => {
try { try {
const viewer = req.user as Express.User | undefined; const viewer = req.user as Express.User | undefined;
@ -175,6 +219,26 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
? rawOptions as Record<string, unknown> ? rawOptions as Record<string, unknown>
: {}; : {};
// spaceId は閲覧できるスペースのみ受理する(見えない/存在しない ID は
// 個人スペース既定に倒す)。これで他スペースのワークスペースへの紛れ込みを防ぐ。
let resolvedSpaceId: string | null = null;
if (body.spaceId) {
const reqSpace = await repo.getSpace(body.spaceId, { viewer: req.user as Express.User | undefined });
if (reqSpace) {
resolvedSpaceId = reqSpace.id;
} else {
logger.warn(`[local-tasks] create: spaceId=${body.spaceId} not visible to user; falling back to personal space`);
}
}
// Tasks created inside a space are ALWAYS private: access is granted
// exclusively by space membership (the shared-space OR-branch in
// buildVisibilityWhere lets every member see all rows of their space).
// Forcing private here prevents an org/public selection from leaking the
// chat to non-members. Per-chat visibility is intentionally not selectable.
const effectiveVisibility = resolvedSpaceId ? 'private' : visibility;
const effectiveScopeOrgId = effectiveVisibility === 'org' ? visibilityScopeOrgId : null;
const task = await repo.createLocalTask({ const task = await repo.createLocalTask({
title: taskTitle, title: taskTitle,
titleSource, titleSource,
@ -184,26 +248,53 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
outputFormat, outputFormat,
askPolicy, askPolicy,
priority, priority,
workspaceMode: body.workspaceMode,
spaceId: resolvedSpaceId,
ownerId: req.user?.id ?? noAuthOwner, ownerId: req.user?.id ?? noAuthOwner,
visibility, visibility: effectiveVisibility,
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null, visibilityScopeOrgId: effectiveScopeOrgId,
browserSessionProfileId, browserSessionProfileId,
options: taskOptions, options: taskOptions,
}); });
const workspacePath = getLocalWorkspacePath(worktreeDir, task.id); // 実効スペースを解決し、mode に応じてワークスペースパスを決める。
mkdirSync(join(workspacePath, 'input'), { recursive: true }); // 既定は persistent個人/案件スペースの共有ツリー。ephemeral は使い捨て。
mkdirSync(join(workspacePath, 'output'), { recursive: true }); // resolveTaskFolderContext は副作用で個人スペース生成も担保する。
mkdirSync(join(workspacePath, 'logs'), { recursive: true }); const mode = body.workspaceMode ?? 'persistent';
await repo.updateLocalTask(task.id, { workspacePath }); await repo.resolveTaskFolderContext(task.id, userFolderRoot);
const space = task.spaceId
? await repo.getSpace(task.spaceId)
: await repo.ensurePersonalSpace(task.ownerId ?? 'local');
const effectiveWorktreeDir = worktreeDir ?? './data/worktrees';
const workspacePath = space
? resolveTaskWorkspaceDir(mode, space, effectiveWorktreeDir, task.id)
: getLocalWorkspacePath(worktreeDir, task.id); // フォールバック(スペース解決不能時のみ)
ensureWorkspaceDirs(workspacePath);
// 計画5 (Phase B): persistent スペースタスクは実行ログを成果物ツリーから分離し、
// タスク単位の {worktreeDir}/space/{id}/runs/{taskId} に書く。ephemeral は
// runtime_dir=null のままで、worker/runPiece が ephemeral/{taskId}/logs に
// フォールバックする(後方互換)。
let runtimeDir: string | undefined;
if (mode === 'persistent' && space) {
runtimeDir = spaceRunsDir(effectiveWorktreeDir, space.id, task.id);
mkdirSync(runtimeDir, { recursive: true });
}
await repo.updateLocalTask(task.id, { workspacePath, ...(runtimeDir ? { runtimeDir } : {}) });
// Save attachments to input/. The resulting (sanitized) filenames are
// stored on the initial request comment so the UI can render download
// links, and reused below to tell the agent which files landed in input/.
// (コメント追加パス POST :id/comments と同じ挙動に揃える)
const savedFileNames = (body.attachments ?? [])
.filter(att => att.name && att.contentBase64)
.map(att => att.name.replace(/[\\/]/g, '_'));
for (const att of body.attachments ?? []) { for (const att of body.attachments ?? []) {
if (!att.name || !att.contentBase64) continue; if (!att.name || !att.contentBase64) continue;
const safeName = att.name.replace(/[\\/]/g, '_'); const safeName = att.name.replace(/[\\/]/g, '_');
writeFileSync(join(workspacePath, 'input', safeName), Buffer.from(att.contentBase64, 'base64')); writeFileSync(join(workspacePath, 'input', safeName), Buffer.from(att.contentBase64, 'base64'));
} }
await repo.addLocalTaskComment(task.id, 'user', body.body.trim(), 'request'); await repo.addLocalTaskComment(task.id, 'user', body.body.trim(), 'request', savedFileNames);
const metadataBlock = [ const metadataBlock = [
'---', '---',
@ -213,7 +304,10 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
`ui_priority: ${priority}`, `ui_priority: ${priority}`,
'---', '---',
].join('\n'); ].join('\n');
const instruction = `${taskTitle}\n\n${body.body.trim()}\n\n${metadataBlock}`.trim(); const attachmentBlock = savedFileNames.length > 0
? `添付ファイルinput/ に保存済み): ${savedFileNames.join(', ')}\n\n`
: '';
const instruction = `${taskTitle}\n\n${body.body.trim()}\n\n${attachmentBlock}${metadataBlock}`.trim();
// Merge task options into job payload so the worker can read them at runtime. // Merge task options into job payload so the worker can read them at runtime.
const hasOptions = Object.keys(taskOptions).length > 0; const hasOptions = Object.keys(taskOptions).length > 0;
const job = await repo.createJob({ const job = await repo.createJob({
@ -226,6 +320,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
visibility: task.visibility, visibility: task.visibility,
visibilityScopeOrgId: task.visibilityScopeOrgId, visibilityScopeOrgId: task.visibilityScopeOrgId,
browserSessionProfileId: task.browserSessionProfileId ?? null, browserSessionProfileId: task.browserSessionProfileId ?? null,
spaceId: task.spaceId ?? null,
payload: hasOptions ? JSON.stringify({ options: taskOptions }) : undefined, payload: hasOptions ? JSON.stringify({ options: taskOptions }) : undefined,
}); });
await repo.addAuditLog(job.id, 'job_queued_local_create', 'local-ui', { taskId: task.id }); await repo.addAuditLog(job.id, 'job_queued_local_create', 'local-ui', { taskId: task.id });
@ -253,7 +348,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
} }
const viewer = req.user as Express.User | undefined; const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return; if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
res.json({ task }); res.json({ task });
} catch (err) { } catch (err) {
logger.error(`Local task detail API error: ${err}`); logger.error(`Local task detail API error: ${err}`);
@ -261,6 +356,65 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
} }
}); });
// Tool-request mechanism: list the tool requests recorded for a task
// (RequestTool declarations + passively-captured blocked calls).
app.get('/api/local/tasks/:taskId/tool-requests', async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
res.json({ toolRequests: repo.listToolRequestsByTask(String(taskId)) });
} catch (err) {
logger.error(`Tool requests list API error: ${err}`);
res.status(500).json({ error: 'Failed to list tool requests' });
}
});
// Tool-request mechanism: approve (grant for this task + resume) or deny a
// pending tool request. Requires task write permission (owner / admin /
// space editor). Approving grants the tool for the rest of THIS task; the
// permanent fix (adding it to the piece) is a separate one-click action in
// the piece editor's aggregation view.
app.post('/api/local/tasks/:taskId/tool-requests/:reqId/decide', express.json(), async (req: Request, res: Response) => {
try {
const taskId = parseTaskId(req.params.taskId);
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const reqId = String(req.params.reqId);
const decision = (req.body as { decision?: unknown })?.decision;
if (decision !== 'approve' && decision !== 'deny') {
res.status(400).json({ error: 'decision must be "approve" or "deny"' });
return;
}
const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
const gate = await resolveTaskWriteGate(viewer, task ? { ownerId: task.ownerId, spaceId: task.spaceId } : null);
if (gate !== 'allow') {
res.status(gate === 'view-only' ? 403 : 404)
.json({ error: gate === 'view-only' ? 'No permission to decide tool requests' : 'Task not found' });
return;
}
const tr = repo.getToolRequest(reqId);
if (!tr || tr.taskId !== String(taskId)) { res.status(404).json({ error: 'Tool request not found' }); return; }
if (tr.status !== 'pending') { res.status(409).json({ error: `Already decided (${tr.status})` }); return; }
const decidedBy = viewer?.id ?? null;
if (decision === 'approve') {
repo.addGrantedTool(String(taskId), tr.toolName);
repo.decideToolRequest(reqId, { status: 'approved', grantScope: 'task', decidedBy });
} else {
repo.decideToolRequest(reqId, { status: 'denied', decidedBy });
}
// Resume the parked job so the agent continues (with or without the tool).
const resumed = tr.jobId ? repo.resumeToolRequestJob(tr.jobId) : 0;
res.json({ ok: true, decision, toolName: tr.toolName, resumed: resumed > 0 });
} catch (err) {
logger.error(`Tool request decide API error: ${err}`);
res.status(500).json({ error: 'Failed to decide tool request' });
}
});
app.put('/api/local/tasks/:taskId/feedback', express.json(), async (req: Request, res: Response) => { app.put('/api/local/tasks/:taskId/feedback', express.json(), async (req: Request, res: Response) => {
try { try {
const taskId = parseTaskId(req.params.taskId); const taskId = parseTaskId(req.params.taskId);
@ -326,7 +480,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
} }
const viewer = req.user as Express.User | undefined; const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return; if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
const comments = await repo.listLocalTaskComments(taskId); const comments = await repo.listLocalTaskComments(taskId);
res.json({ comments }); res.json({ comments });
} catch (err) { } catch (err) {
@ -350,7 +504,18 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
const { body, author, attachments } = commentValidation; const { body, author, attachments } = commentValidation;
const viewer = req.user as Express.User | undefined; const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return; // 共有ワークスペースのタスクには editor 以上のメンバーもコメント可能。
// owner/admin/editor+ → 投稿可、閲覧のみviewer ロール)→ 明確な 403、
// 閲覧すらできない(非メンバー)→ 404。
const writeGate = await resolveTaskWriteGate(viewer, task);
if (writeGate === 'deny') {
res.status(404).json({ error: 'Task not found' });
return;
}
if (writeGate === 'view-only') {
res.status(403).json({ error: '閲覧のみのため投稿できません' });
return;
}
// Save attachments to input/. The resulting (sanitized) filenames are // Save attachments to input/. The resulting (sanitized) filenames are
// stored on the comment so the UI can render download links, and reused // stored on the comment so the UI can render download links, and reused
@ -406,6 +571,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
visibility: task!.visibility, visibility: task!.visibility,
visibilityScopeOrgId: task!.visibilityScopeOrgId, visibilityScopeOrgId: task!.visibilityScopeOrgId,
browserSessionProfileId: task!.browserSessionProfileId ?? null, browserSessionProfileId: task!.browserSessionProfileId ?? null,
spaceId: task!.spaceId ?? null,
}); });
if (created) { if (created) {
await repo.addAuditLog(job.id, 'job_queued_local_comment', author, { taskId }); await repo.addAuditLog(job.id, 'job_queued_local_comment', author, { taskId });
@ -562,7 +728,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
} }
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined }); const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
if (!checkTaskOwnership(req, res, task)) return; if (!checkTaskOwnership(req, res, task)) return;
await repo.deleteLocalTask(taskId); await repo.deleteLocalTask(taskId, worktreeDir);
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
@ -584,7 +750,18 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
} }
const viewer = req.user as Express.User | undefined; const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!checkTaskOwnership(req, res, task)) return; // コメントと同じく、共有ワークスペースの editor 以上のメンバーは
// 暴走ジョブを止められるべき。owner/admin/editor+ → 可、viewer → 403、
// 非メンバー → 404。
const cancelGate = await resolveTaskWriteGate(viewer, task);
if (cancelGate === 'deny') {
res.status(404).json({ error: 'Task not found' });
return;
}
if (cancelGate === 'view-only') {
res.status(403).json({ error: '閲覧のみのため操作できません' });
return;
}
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId); const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (!latestJob || !['running', 'dispatching'].includes(latestJob.status)) { if (!latestJob || !['running', 'dispatching'].includes(latestJob.status)) {
res.status(404).json({ error: 'No running job found' }); res.status(404).json({ error: 'No running job found' });
@ -662,6 +839,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
visibility: task!.visibility, visibility: task!.visibility,
visibilityScopeOrgId: task!.visibilityScopeOrgId, visibilityScopeOrgId: task!.visibilityScopeOrgId,
browserSessionProfileId: task!.browserSessionProfileId ?? null, browserSessionProfileId: task!.browserSessionProfileId ?? null,
spaceId: task!.spaceId ?? null,
}); });
await repo.updateLocalTask(taskId, { pieceName: piece }); await repo.updateLocalTask(taskId, { pieceName: piece });

View File

@ -360,3 +360,201 @@ describe('mcp-api', () => {
expect(del.status).toBe(400); expect(del.status).toBe(400);
}); });
}); });
describe('mcp-api per-space scoping', () => {
afterEach(() => {
while (openDbs.length) {
const db = openDbs.pop();
try { db?.close(); } catch { /* noop */ }
}
});
// Build an admin + user-servers app with a getSpace stub that grants access
// only to known space ids ('space-A', 'space-B').
function makeSpaceApp(opts: { role: 'admin' | 'user'; userId?: string }) {
const validKey = 'a'.repeat(64);
process.env.MCP_ENCRYPTION_KEY = validKey;
const db = new Database(':memory:');
openDbs.push(db);
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`);
runMigrations(db);
db.prepare('INSERT INTO users(id) VALUES(?)').run('u1');
const reg = createRegistry(db);
const tm = createTokenManager(db, { doRefresh: async () => ({ access_token: 'x' }) });
const cache = createToolCache(db, 600);
const userId = opts.userId ?? 'u1';
const known = new Set(['space-A', 'space-B']);
const getSpace = async (spaceId: string) => (known.has(spaceId) ? { id: spaceId } : null);
const pass: express.RequestHandler = (_req, _res, next) => next();
const app = express();
app.use(express.json());
const deps = {
db, registry: reg, tokenManager: tm, toolCache: cache,
requireAdmin: pass, requireAuth: pass,
getUserId: () => userId, getSpace, insecureLocalTestMode: true,
// This harness's user has edit rights in any space it can see (member).
canEditSpace: async (spaceId: string) => known.has(spaceId),
};
app.use('/api/mcp/servers', createAdminRouter(deps));
app.use('/api/mcp/user-servers', createUserServersRouter(deps));
return { app, reg };
}
it('admin POST stores a server scoped to a space (space_id)', async () => {
const { app, reg } = makeSpaceApp({ role: 'admin' });
const r = await request(app).post('/api/mcp/servers').send({
id: 'scoped', name: 'Scoped', url: 'http://127.0.0.1:1/mcp',
authKind: 'api_key', staticToken: 'tok', space_id: 'space-A',
});
expect(r.status).toBe(200);
expect(reg.getDecrypted('scoped')?.spaceId).toBe('space-A');
});
it('rejects a server scoped to an inaccessible/unknown space (404)', async () => {
const { app, reg } = makeSpaceApp({ role: 'admin' });
const r = await request(app).post('/api/mcp/servers').send({
id: 'bad', name: 'Bad', url: 'http://127.0.0.1:1/mcp',
authKind: 'api_key', staticToken: 'tok', space_id: 'space-X',
});
expect(r.status).toBe(404);
expect(reg.getDecrypted('bad')).toBeNull();
});
it('GET ?spaceId returns only that space\'s servers', async () => {
const { app, reg } = makeSpaceApp({ role: 'admin' });
reg.upsert({ id: 'a', name: 'A', url: 'http://127.0.0.1:1/mcp', authKind: 'api_key', ownerId: null, staticToken: 'tok', spaceId: 'space-A' });
reg.upsert({ id: 'b', name: 'B', url: 'http://127.0.0.1:2/mcp', authKind: 'api_key', ownerId: null, staticToken: 'tok', spaceId: 'space-B' });
reg.upsert({ id: 'g', name: 'G', url: 'http://127.0.0.1:3/mcp', authKind: 'api_key', ownerId: null, staticToken: 'tok' });
const res = await request(app).get('/api/mcp/servers?spaceId=space-A');
expect(res.status).toBe(200);
const ids = (res.body.servers as Array<{ id: string }>).map(s => s.id);
expect(ids).toEqual(['a']);
expect(ids).not.toContain('b');
expect(ids).not.toContain('g');
});
it('GET ?spaceId for an inaccessible space returns 404', async () => {
const { app } = makeSpaceApp({ role: 'admin' });
const res = await request(app).get('/api/mcp/servers?spaceId=space-X');
expect(res.status).toBe(404);
});
it('user-servers POST stores spaceId for the requesting user', async () => {
const { app, reg } = makeSpaceApp({ role: 'user', userId: 'u1' });
const r = await request(app).post('/api/mcp/user-servers').send({
id: 'mine', name: 'Mine', url: 'http://127.0.0.1:1/mcp',
authKind: 'api_key', staticToken: 'tok', space_id: 'space-B',
});
expect(r.status).toBe(200);
const s = reg.getDecrypted('mine');
expect(s?.ownerId).toBe('u1');
expect(s?.spaceId).toBe('space-B');
});
});
describe('mcp-api: space-managed api_key servers (Space-as-Principal P2)', () => {
afterEach(() => {
while (openDbs.length) {
const db = openDbs.pop();
try { db?.close(); } catch { /* noop */ }
}
});
// user-servers app where `viewerId` is the requester and `canEdit` decides the
// space edit-rights gate. getSpace grants 'space-A' to everyone (visible).
function makeApp(opts: { viewerId: string; canEdit: boolean }) {
process.env.MCP_ENCRYPTION_KEY = 'a'.repeat(64);
const db = new Database(':memory:');
openDbs.push(db);
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY); CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT); CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`);
runMigrations(db);
for (const u of ['owner1', 'member1']) db.prepare('INSERT OR IGNORE INTO users(id) VALUES(?)').run(u);
const reg = createRegistry(db);
const deps = {
db, registry: reg,
tokenManager: createTokenManager(db, { doRefresh: async () => ({ access_token: 'x' }) }),
toolCache: createToolCache(db, 600),
requireAdmin: ((_q: express.Request, _s: express.Response, n: express.NextFunction) => n()) as express.RequestHandler,
requireAuth: ((_q: express.Request, _s: express.Response, n: express.NextFunction) => n()) as express.RequestHandler,
getUserId: () => opts.viewerId,
getSpace: async (spaceId: string) => (spaceId === 'space-A' ? { id: spaceId } : null),
canEditSpace: async () => opts.canEdit,
insecureLocalTestMode: true,
};
const app = express();
app.use(express.json());
app.use('/api/mcp/user-servers', createUserServersRouter(deps));
return { app, reg };
}
// Seed a space-A api_key server owned by 'owner1'.
function seedSpaceApiKey(reg: ReturnType<typeof createRegistry>, id = 'shared') {
reg.upsert({ id, name: id, url: 'http://127.0.0.1:1/mcp', authKind: 'api_key', ownerId: 'owner1', staticToken: 'tok', spaceId: 'space-A' });
}
it('a space member (non-owner) sees the space api_key server in the listing', async () => {
const { app, reg } = makeApp({ viewerId: 'member1', canEdit: false });
seedSpaceApiKey(reg);
const res = await request(app).get('/api/mcp/user-servers?spaceId=space-A');
expect(res.status).toBe(200);
expect((res.body.servers as Array<{ id: string }>).map(s => s.id)).toEqual(['shared']);
});
it('a member WITH edit rights can DELETE a space api_key server they do not own', async () => {
const { app, reg } = makeApp({ viewerId: 'member1', canEdit: true });
seedSpaceApiKey(reg);
const res = await request(app).delete('/api/mcp/user-servers/shared');
expect(res.status).toBe(200);
expect(reg.getDecrypted('shared')).toBeNull();
});
it('a member WITHOUT edit rights cannot delete (403)', async () => {
const { app, reg } = makeApp({ viewerId: 'member1', canEdit: false });
seedSpaceApiKey(reg);
const res = await request(app).delete('/api/mcp/user-servers/shared');
expect(res.status).toBe(403);
expect(reg.getDecrypted('shared')).not.toBeNull();
});
it('an OAuth server in a space stays owner-only — a member cannot delete it', async () => {
const { app, reg } = makeApp({ viewerId: 'member1', canEdit: true });
reg.upsert({ id: 'oauth-srv', name: 'O', url: 'http://127.0.0.1:1/mcp', authKind: 'oauth', ownerId: 'owner1', oauthClientId: 'c', oauthClientSecret: 's', spaceId: 'space-A' });
const res = await request(app).delete('/api/mcp/user-servers/oauth-srv');
expect(res.status).toBe(403); // OAuth stays per-user even in a space
});
it('a space VIEWER (no edit rights) cannot CREATE a shared api_key server (403)', async () => {
const { app, reg } = makeApp({ viewerId: 'member1', canEdit: false });
const res = await request(app).post('/api/mcp/user-servers').send({
id: 'planted', name: 'Planted', url: 'http://127.0.0.1:1/mcp', authKind: 'api_key', staticToken: 'tok', space_id: 'space-A',
});
expect(res.status).toBe(403);
expect(reg.getDecrypted('planted')).toBeNull();
});
it('a member WITH edit rights CAN create a shared api_key server', async () => {
const { app, reg } = makeApp({ viewerId: 'member1', canEdit: true });
const res = await request(app).post('/api/mcp/user-servers').send({
id: 'added', name: 'Added', url: 'http://127.0.0.1:1/mcp', authKind: 'api_key', staticToken: 'tok', space_id: 'space-A',
});
expect(res.status).toBe(200);
expect(reg.getDecrypted('added')?.spaceId).toBe('space-A');
});
it('a member editing a shared api_key server preserves the original owner', async () => {
const { app, reg } = makeApp({ viewerId: 'member1', canEdit: true });
seedSpaceApiKey(reg);
const res = await request(app).post('/api/mcp/user-servers').send({
id: 'shared', name: 'Renamed', url: 'http://127.0.0.1:9/mcp', authKind: 'api_key', staticToken: 'tok', space_id: 'space-A',
});
expect(res.status).toBe(200);
const s = reg.getDecrypted('shared');
expect(s?.name).toBe('Renamed');
expect(s?.ownerId).toBe('owner1'); // NOT reassigned to member1
});
});

View File

@ -17,14 +17,64 @@ export interface McpApiDeps {
getUserId: (req: Request) => string | null; getUserId: (req: Request) => string | null;
insecureLocalTestMode?: boolean; insecureLocalTestMode?: boolean;
allowPrivateAddresses?: boolean; allowPrivateAddresses?: boolean;
/**
* Spaces foundation. Optional repo accessor used to (a) authorize a
* `space_id` supplied on register/list against the requesting viewer and
* (b) resolve the space's existence. When omitted, space-scoped requests
* are rejected (no way to authorize them), keeping global management intact.
*/
getSpace?: (spaceId: string, viewer?: Express.User) => Promise<{ id: string } | null>;
/**
* Space-as-Principal P2: can this request's user EDIT space-owned resources in
* the given space (editor/owner/admin)? Used to let space members share the
* management of api_key (static-token) MCP servers the credential is
* master-encrypted (not user-tied), so it survives member turnover. OAuth
* servers stay owner-gated (their tokens are per-user). When omitted, only the
* server's owner can manage it.
*/
canEditSpace?: (spaceId: string, req: Request) => Promise<boolean>;
} }
const ID_REGEX = /^[a-z0-9_-]{1,64}$/; const ID_REGEX = /^[a-z0-9_-]{1,64}$/;
/**
* Normalize the space id from a register/list request body or query.
* Accepts both `space_id` (snake, wire convention) and `spaceId` (camel).
* Returns: string (a space), null (explicit global), or undefined (not given).
*/
function readSpaceId(src: Record<string, unknown> | undefined): string | null | undefined {
if (!src) return undefined;
const raw = (src.space_id ?? src.spaceId) as unknown;
if (raw === undefined) return undefined;
if (raw === null || raw === '') return null;
// 文字列のみ受理(数値等の coerce を避ける。非文字列は未指定扱い)。
return typeof raw === 'string' ? raw : undefined;
}
export function createAdminRouter(deps: McpApiDeps): Router { export function createAdminRouter(deps: McpApiDeps): Router {
const router = Router(); const router = Router();
router.get('/', deps.requireAdmin, (_req, res) => { router.get('/', deps.requireAdmin, async (req, res) => {
// Space-scoped listing: ?spaceId=<id> (or ?space_id=) returns only that
// space's servers (visibility-checked). Omitting it returns all (global view).
const spaceId = readSpaceId(req.query as Record<string, unknown>);
if (typeof spaceId === 'string') {
if (!deps.getSpace) {
res.status(400).json({ error: 'space-scoped listing not available' });
return;
}
const space = await deps.getSpace(spaceId, (req as Request & { user?: Express.User }).user);
if (!space) {
res.status(404).json({ error: 'unknown or inaccessible space' });
return;
}
const scoped = deps.registry.listForSpace(spaceId).map((s) => ({
...s,
toolCount: deps.toolCache.getForServer(s.id).length,
}));
res.json({ servers: scoped });
return;
}
const servers = deps.registry.listPublic(); const servers = deps.registry.listPublic();
const enriched = servers.map((s) => ({ const enriched = servers.map((s) => ({
...s, ...s,
@ -45,6 +95,8 @@ export function createAdminRouter(deps: McpApiDeps): Router {
staticToken: string; staticToken: string;
authHeaderName: string; authHeaderName: string;
enabled: boolean; enabled: boolean;
space_id: string | null;
spaceId: string | null;
}>; }>;
if (!body.id || !ID_REGEX.test(body.id)) { if (!body.id || !ID_REGEX.test(body.id)) {
@ -56,6 +108,21 @@ export function createAdminRouter(deps: McpApiDeps): Router {
return; return;
} }
// Optional per-space scoping. When a space is supplied, authorize it
// against the requesting admin/viewer via getSpace before storing.
const spaceId = readSpaceId(body as Record<string, unknown>);
if (typeof spaceId === 'string') {
if (!deps.getSpace) {
res.status(400).json({ error: 'space-scoped servers not available' });
return;
}
const space = await deps.getSpace(spaceId, (req as Request & { user?: Express.User }).user);
if (!space) {
res.status(404).json({ error: 'unknown or inaccessible space' });
return;
}
}
// Editing a global server re-POSTs the same id. Preserve write-only secrets // Editing a global server re-POSTs the same id. Preserve write-only secrets
// when left blank, and keep the original authKind (the form locks it on edit). // when left blank, and keep the original authKind (the form locks it on edit).
const existing = deps.registry.getDecrypted(body.id); const existing = deps.registry.getDecrypted(body.id);
@ -81,12 +148,15 @@ export function createAdminRouter(deps: McpApiDeps): Router {
} }
const adminId = deps.getUserId(req); const adminId = deps.getUserId(req);
// spaceId === undefined on edit → keep existing space; on create → null (global).
const effectiveSpaceId = spaceId === undefined ? (isEdit ? existing!.spaceId ?? null : null) : spaceId;
deps.registry.upsert({ deps.registry.upsert({
id: body.id, id: body.id,
name: body.name, name: body.name,
url: body.url, url: body.url,
authKind, authKind,
ownerId: null, ownerId: null,
spaceId: effectiveSpaceId,
oauthClientId, oauthClientId,
oauthClientSecret, oauthClientSecret,
oauthScopes: body.oauthScopes ?? null, oauthScopes: body.oauthScopes ?? null,
@ -242,12 +312,52 @@ export function createUserRouter(deps: McpApiDeps): Router {
export function createUserServersRouter(deps: McpApiDeps): Router { export function createUserServersRouter(deps: McpApiDeps): Router {
const router = Router(); const router = Router();
router.get('/', deps.requireAuth, (req, res) => { // Can `userId` (this request) manage (edit/delete/refresh) the given server?
// The owner always can. Additionally, a space-owned api_key server is managed
// by any space member with edit rights (Space-as-Principal P2) — its static
// token is master-encrypted, not tied to the creator. OAuth servers stay
// owner-only (per-user tokens).
async function canManageServer(
server: { ownerId: string | null; spaceId: string | null; authKind: string },
userId: string,
req: Request,
): Promise<boolean> {
if (server.ownerId === userId) return true;
if (server.spaceId && server.authKind === 'api_key' && deps.canEditSpace) {
return deps.canEditSpace(server.spaceId, req);
}
return false;
}
router.get('/', deps.requireAuth, async (req, res) => {
const userId = deps.getUserId(req); const userId = deps.getUserId(req);
if (!userId) { if (!userId) {
res.status(401).json({ error: 'unauthenticated' }); res.status(401).json({ error: 'unauthenticated' });
return; return;
} }
// Space-scoped listing: ?spaceId=<id> returns that space's servers owned
// by the requesting user (visibility-checked).
const spaceId = readSpaceId(req.query as Record<string, unknown>);
if (typeof spaceId === 'string') {
if (!deps.getSpace) {
res.status(400).json({ error: 'space-scoped listing not available' });
return;
}
const space = await deps.getSpace(spaceId, (req as Request & { user?: Express.User }).user);
if (!space) {
res.status(404).json({ error: 'unknown or inaccessible space' });
return;
}
// Space-as-Principal P2: api_key servers are space-shared (every member who
// can see the space — getSpace already gated that above — sees them). OAuth
// servers remain private to their owner (per-user tokens).
const scoped = deps.registry
.listForSpace(spaceId)
.filter((s) => s.authKind === 'api_key' || s.ownerId === userId)
.map((s) => ({ ...s, toolCount: deps.toolCache.getForServer(s.id).length }));
res.json({ servers: scoped });
return;
}
const servers = deps.registry.listEnabledForOwner(userId); const servers = deps.registry.listEnabledForOwner(userId);
const enriched = servers.map((s) => ({ const enriched = servers.map((s) => ({
...s, ...s,
@ -274,6 +384,8 @@ export function createUserServersRouter(deps: McpApiDeps): Router {
staticToken: string; staticToken: string;
authHeaderName: string; authHeaderName: string;
enabled: boolean; enabled: boolean;
space_id: string | null;
spaceId: string | null;
}>; }>;
if (!body.id || !ID_REGEX.test(body.id)) { if (!body.id || !ID_REGEX.test(body.id)) {
@ -285,10 +397,25 @@ export function createUserServersRouter(deps: McpApiDeps): Router {
return; return;
} }
// Editing re-POSTs the same id. Collide only with a server you do NOT own // Optional per-space scoping. Authorize the space against the viewer.
// (a global or another user's server); your own server is an update. const spaceId = readSpaceId(body as Record<string, unknown>);
if (typeof spaceId === 'string') {
if (!deps.getSpace) {
res.status(400).json({ error: 'space-scoped servers not available' });
return;
}
const space = await deps.getSpace(spaceId, (req as Request & { user?: Express.User }).user);
if (!space) {
res.status(404).json({ error: 'unknown or inaccessible space' });
return;
}
}
// Editing re-POSTs the same id. Collide only with a server you cannot manage
// (a global or another user's server); your own — or a space api_key server
// you may edit (Space-as-Principal P2) — is an update.
const existing = deps.registry.getDecrypted(body.id); const existing = deps.registry.getDecrypted(body.id);
if (existing && existing.ownerId !== userId) { if (existing && !(await canManageServer(existing, userId, req))) {
res.status(409).json({ error: `server id '${body.id}' already exists` }); res.status(409).json({ error: `server id '${body.id}' already exists` });
return; return;
} }
@ -315,12 +442,29 @@ export function createUserServersRouter(deps: McpApiDeps): Router {
return; return;
} }
// Adding a NEW api_key server into a space makes it a shared space credential,
// so it requires space EDIT rights — mere visibility (getSpace above) is not
// enough (else a read-only/public-space viewer could plant a shared server).
// Edits of an existing server are already gated by canManageServer at the
// collision check above. OAuth servers stay owner-private (no extra gate).
if (!isEdit && typeof spaceId === 'string' && authKind === 'api_key') {
if (!deps.canEditSpace || !(await deps.canEditSpace(spaceId, req))) {
res.status(403).json({ error: 'forbidden: space edit rights required to add a shared server' });
return;
}
}
// spaceId === undefined on edit → keep existing space; on create → null (global).
const effectiveSpaceId = spaceId === undefined ? (isEdit ? existing!.spaceId ?? null : null) : spaceId;
deps.registry.upsert({ deps.registry.upsert({
id: body.id, id: body.id,
name: body.name, name: body.name,
url: body.url, url: body.url,
authKind, authKind,
ownerId: userId, // Preserve the original owner (created_by) on edit so a space member editing
// a shared api_key server doesn't reassign ownership to themselves.
ownerId: isEdit ? (existing!.ownerId ?? userId) : userId,
spaceId: effectiveSpaceId,
oauthClientId, oauthClientId,
oauthClientSecret, oauthClientSecret,
oauthScopes: body.oauthScopes ?? null, oauthScopes: body.oauthScopes ?? null,
@ -367,7 +511,7 @@ export function createUserServersRouter(deps: McpApiDeps): Router {
res.json({ ok: true }); res.json({ ok: true });
}); });
router.delete('/:id', deps.requireAuth, (req, res) => { router.delete('/:id', deps.requireAuth, async (req, res) => {
const userId = deps.getUserId(req); const userId = deps.getUserId(req);
if (!userId) { if (!userId) {
res.status(401).json({ error: 'unauthenticated' }); res.status(401).json({ error: 'unauthenticated' });
@ -378,7 +522,7 @@ export function createUserServersRouter(deps: McpApiDeps): Router {
res.status(404).json({ error: 'unknown server' }); res.status(404).json({ error: 'unknown server' });
return; return;
} }
if (server.ownerId !== userId) { if (!(await canManageServer(server, userId, req))) {
res.status(403).json({ error: 'forbidden: you do not own this server' }); res.status(403).json({ error: 'forbidden: you do not own this server' });
return; return;
} }
@ -397,7 +541,7 @@ export function createUserServersRouter(deps: McpApiDeps): Router {
res.status(404).json({ error: 'unknown server' }); res.status(404).json({ error: 'unknown server' });
return; return;
} }
if (server.ownerId !== userId) { if (!(await canManageServer(server, userId, req))) {
res.status(403).json({ error: 'forbidden: you do not own this server' }); res.status(403).json({ error: 'forbidden: you do not own this server' });
return; return;
} }

View File

@ -243,3 +243,113 @@ describe('Memory API', () => {
}); });
}); });
}); });
// ---------------------------------------------------------------------------
// Spaces foundation: optional spaceId scoping (mirrors pieces-api)
// - no spaceId → per-user memory dir (byte-identical regression)
// - case-space spaceId → data/spaces/{id}/memory
// - unresolvable spaceId → 404, never silently falls back to user folder
// ---------------------------------------------------------------------------
import { dirname } from 'path';
import { listMemoryEntries } from '../user-folder/memory.js';
import { userMemoryDir } from '../user-folder/paths.js';
describe('Memory API — spaceId scoping', () => {
const CASE_SPACE = 'space-case-1';
let tmpRoot: string;
let dataDir: string; // data/users
function makeResolver(visibleIds: Set<string>) {
const spacesRoot = join(dirname(dataDir), 'spaces');
return async (spaceId: string) => {
if (!visibleIds.has(spaceId)) return null;
if (spaceId === 'personal') return { rootDir: dataDir, leafId: USER_A };
return { rootDir: spacesRoot, leafId: spaceId };
};
}
function makeSpaceApp(
resolveSpaceFolder?: (spaceId: string) => Promise<{ rootDir: string; leafId: string } | null>,
): express.Application {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).user = { id: USER_A, role: 'user' };
next();
});
app.use('/api/local/memory', createMemoryApi({ dataDir, resolveSpaceFolder }));
return app;
}
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), 'memory-api-space-'));
dataDir = join(tmpRoot, 'data', 'users');
mkdirSync(dataDir, { recursive: true });
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
// (b) regression — no spaceId hits the per-user memory dir
it('without spaceId writes/reads under the per-user memory dir', async () => {
const app = makeSpaceApp(makeResolver(new Set([CASE_SPACE])));
const put = await request(app)
.put('/api/local/memory/entries/personal-note')
.send({ description: 'a personal note', type: 'user', body: 'body' });
expect(put.status).toBe(200);
// Landed under data/users/user-a/memory
expect(listMemoryEntries(userMemoryDir(dataDir, USER_A)).length).toBe(1);
// Did NOT touch the space folder
const spaceMem = userMemoryDir(join(tmpRoot, 'data', 'spaces'), CASE_SPACE);
expect(existsSync(spaceMem)).toBe(false);
const get = await request(app).get('/api/local/memory/entries');
expect(get.body.entries.length).toBe(1);
});
// (a) case-space spaceId → data/spaces/{id}/memory
it('with a case-space spaceId writes/reads/deletes under data/spaces/{id}', async () => {
const app = makeSpaceApp(makeResolver(new Set([CASE_SPACE])));
const put = await request(app)
.put(`/api/local/memory/entries/shared-note?spaceId=${CASE_SPACE}`)
.send({ description: 'a shared note', type: 'reference', body: 'shared body' });
expect(put.status).toBe(200);
const spaceMem = userMemoryDir(join(tmpRoot, 'data', 'spaces'), CASE_SPACE);
expect(listMemoryEntries(spaceMem).length).toBe(1);
// The per-user memory dir must be empty.
expect(existsSync(userMemoryDir(dataDir, USER_A))).toBe(false);
const get = await request(app).get(`/api/local/memory/entries?spaceId=${CASE_SPACE}`);
expect(get.body.entries.length).toBe(1);
// The per-user listing sees nothing.
const getPersonal = await request(app).get('/api/local/memory/entries');
expect(getPersonal.body.entries.length).toBe(0);
const del = await request(app).delete(`/api/local/memory/entries/shared-note?spaceId=${CASE_SPACE}`);
expect(del.status).toBe(200);
expect(listMemoryEntries(spaceMem).length).toBe(0);
});
// (c) unresolvable / forbidden spaceId → 404, no fallback
it('returns 404 for a spaceId not visible to the viewer (GET/PUT/DELETE)', async () => {
const app = makeSpaceApp(makeResolver(new Set([CASE_SPACE])));
const get = await request(app).get('/api/local/memory/entries?spaceId=nope');
expect(get.status).toBe(404);
const put = await request(app)
.put('/api/local/memory/entries/x?spaceId=nope')
.send({ description: 'x', type: 'user', body: 'b' });
expect(put.status).toBe(404);
expect(existsSync(userMemoryDir(dataDir, USER_A))).toBe(false);
const del = await request(app).delete('/api/local/memory/entries/x?spaceId=nope');
expect(del.status).toBe(404);
});
});

View File

@ -43,6 +43,29 @@ export interface MemoryApiDeps {
dataDir: string; dataDir: string;
/** When false (local-dev mode), inject a synthetic 'local' user if req.user absent. */ /** When false (local-dev mode), inject a synthetic 'local' user if req.user absent. */
authActive?: boolean; authActive?: boolean;
/**
* Spaces foundation: optional resolver mapping a `spaceId` (from the request
* query/body) to the `{ rootDir, leafId }` folder whose memory dir is
* `userMemoryDir(rootDir, leafId)`. Returns `null` when the space is not
* visible to the viewer (treated as 404 never silently falls back to the
* user folder). When omitted (or no spaceId on the request), memory
* resolution uses the legacy per-user folder (`dataDir`, `user.id`)
* byte-identical to today.
*/
resolveSpaceFolder?: (
spaceId: string,
viewer?: Express.User,
) => Promise<{ rootDir: string; leafId: string } | null>;
}
/** Extract a spaceId from the request (query param wins over body). */
function getSpaceId(req: Request): string | undefined {
const fromQuery = typeof req.query?.['spaceId'] === 'string' ? (req.query['spaceId'] as string) : undefined;
const fromBody =
req.body && typeof (req.body as Record<string, unknown>)['spaceId'] === 'string'
? ((req.body as Record<string, unknown>)['spaceId'] as string)
: undefined;
return fromQuery ?? fromBody ?? undefined;
} }
// ── Factory ──────────────────────────────────────────────────────────────────── // ── Factory ────────────────────────────────────────────────────────────────────
@ -53,6 +76,26 @@ export function createMemoryApi(deps: MemoryApiDeps): Router {
const r = Router(); const r = Router();
/**
* Resolve the memory folder for a request, honoring an optional `spaceId`.
* - No spaceId legacy per-user folder (`dataDir`, `user.id`).
* - spaceId + resolver space folder when visible; `{ forbidden: true }`
* when the resolver returns null. Never falls back to the user folder for
* an unresolvable spaceId.
*/
async function resolveMemoryFolder(
req: Request,
u: AuthedUser,
): Promise<{ rootDir: string; leafId: string } | { forbidden: true }> {
const spaceId = getSpaceId(req);
if (spaceId && deps.resolveSpaceFolder) {
const fc = await deps.resolveSpaceFolder(spaceId, req.user);
if (!fc) return { forbidden: true };
return { rootDir: fc.rootDir, leafId: fc.leafId };
}
return { rootDir: dataDir, leafId: u.id };
}
// JSON body parser for this router // JSON body parser for this router
r.use((_req, _res, next) => { r.use((_req, _res, next) => {
// body-parser already applied globally only for certain routes; apply here too // body-parser already applied globally only for certain routes; apply here too
@ -62,7 +105,12 @@ export function createMemoryApi(deps: MemoryApiDeps): Router {
// ── Auth gate ────────────────────────────────────────────────────────────── // ── Auth gate ──────────────────────────────────────────────────────────────
r.use((req: Request, res: Response, next) => { r.use((req: Request, res: Response, next) => {
if (!authActive && !getUser(req)) { if (!authActive && !getUser(req)) {
(req as any).user = { id: 'local', role: 'user' }; // role:'admin' + orgIds:[] mirrors the no-auth convention (space-api /
// deserializeUser). No-auth spaces have owner_id=NULL, so the space
// resolver's buildVisibilityWhere only resolves them under the admin
// (1=1) clause; role:'user' would 404 the user's own space memory, and a
// missing orgIds crashes on `.length`.
(req as any).user = { id: 'local', role: 'admin', orgIds: [] };
} }
if (!getUser(req)) { if (!getUser(req)) {
res.status(401).json({ error: 'Unauthenticated' }); res.status(401).json({ error: 'Unauthenticated' });
@ -72,16 +120,21 @@ export function createMemoryApi(deps: MemoryApiDeps): Router {
}); });
// ── GET /entries ─────────────────────────────────────────────────────────── // ── GET /entries ───────────────────────────────────────────────────────────
r.get('/entries', (req: Request, res: Response) => { r.get('/entries', async (req: Request, res: Response) => {
const u = getUser(req)!; const u = getUser(req)!;
const memDir = userMemoryDir(dataDir, u.id); const folder = await resolveMemoryFolder(req, u);
if ('forbidden' in folder) {
res.status(404).json({ error: 'Space not found' });
return;
}
const memDir = userMemoryDir(folder.rootDir, folder.leafId);
try { try {
const entries = listMemoryEntries(memDir); const entries = listMemoryEntries(memDir);
const index = readMemoryIndexFromDir(memDir); const index = readMemoryIndexFromDir(memDir);
res.json({ entries, index }); res.json({ entries, index });
} catch (err) { } catch (err) {
logger.error(`[memory-api] GET /entries failed user=${u.id} err=${err}`); logger.error(`[memory-api] GET /entries failed leaf=${folder.leafId} err=${err}`);
res.status(500).json({ error: 'Failed to list memory entries' }); res.status(500).json({ error: 'Failed to list memory entries' });
} }
}); });
@ -144,19 +197,25 @@ export function createMemoryApi(deps: MemoryApiDeps): Router {
return; return;
} }
const folder = await resolveMemoryFolder(req, u);
if ('forbidden' in folder) {
res.status(404).json({ error: 'Space not found' });
return;
}
try { try {
const result = await withUserLock(dataDir, u.id, async () => { const result = await withUserLock(folder.rootDir, folder.leafId, async () => {
return upsertMemoryEntry(dataDir, u.id, { return upsertMemoryEntry(folder.rootDir, folder.leafId, {
name, name,
type: type as MemoryType, type: type as MemoryType,
description: description.trim(), description: description.trim(),
body: entryBody, body: entryBody,
}); });
}); });
logger.info(`[memory-api] PUT /entries/${name} user=${u.id} path=${result.path}`); logger.info(`[memory-api] PUT /entries/${name} leaf=${folder.leafId} path=${result.path}`);
res.json({ ok: true, name, path: result.path }); res.json({ ok: true, name, path: result.path });
} catch (err) { } catch (err) {
logger.error(`[memory-api] PUT /entries/${name} failed user=${u.id} err=${err}`); logger.error(`[memory-api] PUT /entries/${name} failed leaf=${folder.leafId} err=${err}`);
res.status(500).json({ error: 'Failed to upsert memory entry' }); res.status(500).json({ error: 'Failed to upsert memory entry' });
} }
}); });
@ -172,9 +231,15 @@ export function createMemoryApi(deps: MemoryApiDeps): Router {
return; return;
} }
const folder = await resolveMemoryFolder(req, u);
if ('forbidden' in folder) {
res.status(404).json({ error: 'Space not found' });
return;
}
try { try {
const removed = await withUserLock(dataDir, u.id, async () => { const removed = await withUserLock(folder.rootDir, folder.leafId, async () => {
return removeMemoryEntry(dataDir, u.id, name); return removeMemoryEntry(folder.rootDir, folder.leafId, name);
}); });
if (!removed) { if (!removed) {
@ -182,10 +247,10 @@ export function createMemoryApi(deps: MemoryApiDeps): Router {
return; return;
} }
logger.info(`[memory-api] DELETE /entries/${name} user=${u.id}`); logger.info(`[memory-api] DELETE /entries/${name} leaf=${folder.leafId}`);
res.json({ ok: true, name }); res.json({ ok: true, name });
} catch (err) { } catch (err) {
logger.error(`[memory-api] DELETE /entries/${name} failed user=${u.id} err=${err}`); logger.error(`[memory-api] DELETE /entries/${name} failed leaf=${folder.leafId} err=${err}`);
res.status(500).json({ error: 'Failed to delete memory entry' }); res.status(500).json({ error: 'Failed to delete memory entry' });
} }
}); });

View File

@ -1,203 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import Database from 'better-sqlite3';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { runMigrations } from '../db/migrate.js';
import { NotesRepository } from '../notes/notes-repository.js';
import { NotesService } from '../notes/notes-service.js';
import { createNotesApi } from './notes-api.js';
describe('notes-api', () => {
let tmpRoot: string;
let db: Database.Database;
let service: NotesService;
let auditCalls: any[];
function makeApp(userId: string, orgIds: string[] = []): express.Application {
const app = express();
app.use((req, _res, next) => {
(req as any).user = { id: userId, role: 'user', orgIds };
next();
});
app.use('/api/notes', createNotesApi({ service }));
return app;
}
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), 'notes-api-test-'));
db = new Database(join(tmpRoot, 'test.db'));
runMigrations(db);
db.prepare(`INSERT INTO users (id, email, name) VALUES ('alice','alice@x.com','Alice'),('bob','bob@x.com','Bob')`).run();
const repo = new NotesRepository(db);
auditCalls = [];
service = new NotesService({
db, repo, userFolderRoot: tmpRoot,
getUserOrgIds: () => ['team1'],
audit: (action, actor, target) => auditCalls.push({ action, actor, target }),
});
});
afterEach(() => {
db.close();
rmSync(tmpRoot, { recursive: true, force: true });
});
describe('GET /discover', () => {
it('returns visible notes for the consumer', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'pub.md', content: '---\nvisibility: public\n---\nbody' });
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'priv.md', content: '---\nvisibility: private\n---\nbody' });
const res = await request(makeApp('bob')).get('/api/notes/discover');
expect(res.status).toBe(200);
expect(res.body.rows.map((r: any) => r.file_name)).toEqual(['pub.md']);
});
it('supports keyword search via q', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'pub.md', content: '---\ntitle: kubernetes\nvisibility: public\n---\nbody' });
const res = await request(makeApp('bob')).get('/api/notes/discover?q=kubernetes');
expect(res.status).toBe(200);
expect(res.body.rows).toHaveLength(1);
});
it('paginates', async () => {
for (let i = 0; i < 12; i++) {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: `f${i}.md`, content: `---\nvisibility: public\n---\nbody ${i}` });
}
const res = await request(makeApp('bob')).get('/api/notes/discover?limit=5&offset=5');
expect(res.status).toBe(200);
expect(res.body.rows).toHaveLength(5);
});
it('returns 401 if unauthenticated (authActive=true, default)', async () => {
const app = express();
app.use('/api/notes', createNotesApi({ service }));
const res = await request(app).get('/api/notes/discover');
expect(res.status).toBe(401);
});
it('synthesizes local user when authActive=false (no-auth deployment)', async () => {
// Regression for issue #347: /api/notes/discover returned 401 in
// no-auth deployments because the route had no synthetic-local
// fallback that user-folder-api / dashboard-api already had,
// breaking the Notes panel under User Folder.
db.prepare(`INSERT INTO users (id, email, name) VALUES ('local','local@x.com','Local')`).run();
service.writeNote({ ownerId: 'local', folder: 'cve', fileName: 'mine.md', content: '---\nvisibility: private\n---\nlocal note' });
const app = express();
app.use('/api/notes', createNotesApi({ service, authActive: false }));
const res = await request(app).get('/api/notes/discover?owner_id=me');
expect(res.status, JSON.stringify(res.body)).toBe(200);
const names = res.body.rows.map((r: any) => r.file_name);
expect(names).toContain('mine.md');
});
it('resolves owner_id=me to the caller user.id', async () => {
// Alice writes notes; the FileTree under the Notes tab queries
// /discover?owner_id=me — must resolve to alice, not the literal string "me".
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'a.md', content: '---\nvisibility: private\n---\nmine' });
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'b.md', content: '---\nvisibility: public\n---\nmine2' });
service.writeNote({ ownerId: 'bob', folder: 'cve', fileName: 'c.md', content: '---\nvisibility: public\n---\nbob' });
const res = await request(makeApp('alice')).get('/api/notes/discover?owner_id=me');
expect(res.status).toBe(200);
const names = res.body.rows.map((r: any) => r.file_name).sort();
expect(names).toEqual(['a.md', 'b.md']);
});
});
describe('GET /file', () => {
it('returns note body for visible note and logs audit', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'pub.md', content: '---\nvisibility: public\n---\nbody content' });
const res = await request(makeApp('bob')).get('/api/notes/file?owner_id=alice&folder=cve&file_name=pub.md');
expect(res.status).toBe(200);
expect(res.body.body).toContain('body content');
expect(auditCalls).toHaveLength(1);
expect(auditCalls[0]).toMatchObject({ action: 'read_note', actor: 'bob' });
});
it('returns 404 for private note', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'priv.md', content: '---\nvisibility: private\n---\nbody' });
const res = await request(makeApp('bob')).get('/api/notes/file?owner_id=alice&folder=cve&file_name=priv.md');
expect(res.status).toBe(404);
expect(auditCalls).toHaveLength(0);
});
it('rejects invalid folder/file_name', async () => {
const res = await request(makeApp('bob')).get('/api/notes/file?owner_id=alice&folder=../etc&file_name=passwd');
expect(res.status).toBe(400);
});
});
describe('reindex and inject-preview', () => {
it('POST /reindex rebuilds the index for the requester', async () => {
const fs = require('fs');
fs.mkdirSync(join(tmpRoot, 'alice', 'notes', 'cve'), { recursive: true });
fs.writeFileSync(join(tmpRoot, 'alice', 'notes', 'cve', 'foo.md'), '---\nvisibility: public\n---\nbody');
const res = await request(makeApp('alice')).post('/api/notes/reindex?owner_id=me');
expect(res.status).toBe(200);
expect(res.body.indexed).toBe(1);
});
it('GET /inject-preview shows what will be injected', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: '---\nvisibility: public\n---\nbody A' });
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'bar.md', content: '---\nvisibility: public\n---\nbody B' });
await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'inject', enabled: true });
const res = await request(makeApp('bob')).get('/api/notes/inject-preview');
expect(res.status).toBe(200);
expect(res.body.items).toHaveLength(2);
expect(res.body.total_kb).toBeGreaterThan(0);
expect(res.body.budget_kb).toBeDefined();
});
});
describe('subscriptions', () => {
it('GET /subscriptions returns the user\'s subscriptions', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: '---\nvisibility: public\n---\nbody' });
// Create a subscription via PUT
await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'search', enabled: true });
const res = await request(makeApp('bob')).get('/api/notes/subscriptions');
expect(res.status).toBe(200);
expect(res.body.rows).toHaveLength(1);
expect(res.body.rows[0]).toMatchObject({ publisher_user_id: 'alice', folder: 'cve', mode: 'search' });
});
it('PUT /subscriptions creates a subscription if folder is visible', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: '---\nvisibility: public\n---\nbody' });
const res = await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'inject', enabled: true });
expect(res.status).toBe(200);
});
it('PUT /subscriptions rejects if folder has no visible notes', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'priv.md', content: '---\nvisibility: private\n---\nbody' });
const res = await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'search', enabled: true });
expect(res.status).toBe(403);
});
it('PUT /subscriptions rejects invalid mode', async () => {
const res = await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'bogus', enabled: true });
expect(res.status).toBe(400);
});
it('DELETE /subscriptions removes a subscription', async () => {
service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'pub.md', content: '---\nvisibility: public\n---\nbody' });
await request(makeApp('bob'))
.put('/api/notes/subscriptions')
.send({ publisher_user_id: 'alice', folder: 'cve', mode: 'search', enabled: true });
const del = await request(makeApp('bob'))
.delete('/api/notes/subscriptions?publisher_user_id=alice&folder=cve');
expect(del.status).toBe(200);
const list = await request(makeApp('bob')).get('/api/notes/subscriptions');
expect(list.body.rows).toHaveLength(0);
});
});
});

View File

@ -1,168 +0,0 @@
import { Router, Request, Response, NextFunction, json as expressJson } from 'express';
import { NotesService } from '../notes/notes-service.js';
export interface NotesApiDeps {
service: NotesService;
/**
* Whether the bridge wired the auth subsystem (Passport / OAuth /
* sessions). When `false`, requests will not have a populated
* `req.user` because the upstream `requireAuth` middleware was not
* mounted; the route fills in a synthetic `local` user so the notes
* UI works in no-auth single-user mode (mirrors user-folder-api and
* dashboard-api). Defaults to `true` for backwards compatibility
* with the previous (auth-only) call sites.
*/
authActive?: boolean;
}
const NAME_RE = /^[a-zA-Z0-9._-]+$/;
export function createNotesApi(deps: NotesApiDeps): Router {
const router = Router();
const authActive = deps.authActive ?? true;
router.use((req: Request, res: Response, next: NextFunction) => {
if (!authActive && !(req as any).user) {
// No-auth single-user deployment: synthesize a `local` user so
// per-user storage (and the notes index) has a stable owner.
// Real auth flows are unaffected because Passport populates
// req.user before we get here.
//
// orgIds defaults to [] explicitly — NotesService reads
// user.orgIds.length when computing org-scoped visibility, and
// crashes on the undefined access otherwise.
(req as any).user = { id: 'local', role: 'user', orgIds: [] };
}
if (!(req as any).user) {
res.status(401).json({ error: 'unauthenticated' });
return;
}
// `/api/notes` is NOT behind the `/api/local` requireAuth prefix, so it
// must enforce account status itself. Otherwise a disabled/pending user
// holding a still-valid session keeps notes access (parity with
// requireAuth in auth.ts). The synthetic no-auth `local` user has no
// status field and is unaffected.
const status = (req as any).user.status;
if (authActive && status !== undefined && status !== 'active') {
res.status(403).json({ error: 'account is not active' });
return;
}
next();
});
// Size-limited JSON parser replaces the hand-rolled unbounded reader below.
router.use(expressJson({ limit: '64kb' }));
router.get('/discover', (req, res) => {
const user = (req as any).user;
const rawOwnerId = req.query.owner_id ? String(req.query.owner_id) : undefined;
// `me` is a UI-convenient alias for the caller's own id (matches /reindex).
// Without this alias the FileTree under the Notes tab silently shows zero
// entries when it queries `?owner_id=me`, because the DB stores rows under
// the real user.id and never matches the literal string "me".
const ownerId = rawOwnerId === 'me' ? user.id : rawOwnerId;
const folder = req.query.folder ? String(req.query.folder) : undefined;
const q = req.query.q ? String(req.query.q) : undefined;
const limit = req.query.limit ? Math.max(1, Math.min(200, parseInt(String(req.query.limit), 10) || 50)) : 50;
const offset = req.query.offset ? Math.max(0, parseInt(String(req.query.offset), 10) || 0) : 0;
try {
const rows = deps.service.discoverNotes({ user, ownerId, folder, q, limit, offset });
res.json({ rows });
} catch (err) {
res.status(400).json({ error: (err as Error).message });
}
});
router.get('/file', (req, res) => {
const user = (req as any).user;
const rawOwnerId = String(req.query.owner_id ?? '');
const ownerId = rawOwnerId === 'me' ? user.id : rawOwnerId;
const folder = String(req.query.folder ?? '');
const fileName = String(req.query.file_name ?? '');
if (!NAME_RE.test(folder) || !NAME_RE.test(fileName)) {
res.status(400).json({ error: 'invalid folder or file_name' });
return;
}
try {
const out = deps.service.getCrossUserNote({ user, ownerId, folder, fileName });
if (!out) {
res.status(404).json({ error: 'not found or no permission' });
return;
}
res.json({ fm: out.fm, body: out.body, content: out.content });
} catch (err) {
res.status(400).json({ error: (err as Error).message });
}
});
router.get('/subscriptions', (req, res) => {
const user = (req as any).user;
const rows = deps.service.listSubscriptions(user.id);
res.json({ rows });
});
router.put('/subscriptions', (req, res) => {
const user = (req as any).user;
const body = (req as any).body ?? {};
const publisherUserId = String(body.publisher_user_id ?? '');
const folder = String(body.folder ?? '');
const mode = String(body.mode ?? '');
const enabled = body.enabled === false ? 0 : 1;
if (!publisherUserId || !NAME_RE.test(folder)) {
res.status(400).json({ error: 'invalid publisher_user_id or folder' });
return;
}
if (mode !== 'search' && mode !== 'inject') {
res.status(400).json({ error: 'invalid mode (must be search or inject)' });
return;
}
try {
deps.service.upsertSubscription({
consumerUser: user,
publisherUserId, folder, mode, enabled,
});
res.json({ ok: true });
} catch (err) {
const msg = (err as Error).message;
if (msg.includes('no visible notes')) {
res.status(403).json({ error: msg });
} else {
res.status(400).json({ error: msg });
}
}
});
router.delete('/subscriptions', (req, res) => {
const user = (req as any).user;
const publisherUserId = String(req.query.publisher_user_id ?? '');
const folder = String(req.query.folder ?? '');
if (!publisherUserId || !NAME_RE.test(folder)) {
res.status(400).json({ error: 'invalid publisher_user_id or folder' });
return;
}
deps.service.deleteSubscription({ consumerUserId: user.id, publisherUserId, folder });
res.json({ ok: true });
});
router.post('/reindex', (req, res) => {
const user = (req as any).user;
const ownerIdParam = req.query.owner_id ? String(req.query.owner_id) : 'me';
const ownerId = ownerIdParam === 'me' ? user.id : ownerIdParam;
if (ownerId !== user.id && user.role !== 'admin') {
res.status(403).json({ error: 'forbidden: only owner or admin can reindex' });
return;
}
try {
const stats = deps.service.reindex(ownerId);
res.json({ ok: true, ...stats });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
router.get('/inject-preview', (req, res) => {
const user = (req as any).user;
const preview = deps.service.injectPreview(user);
res.json(preview);
});
return router;
}

View File

@ -295,6 +295,56 @@ describe('Pieces API (no auth — legacy behavior)', () => {
expect(res.status).toBe(201); expect(res.status).toBe(201);
}); });
it('POST /api/pieces accepts piece-level shared_tools (array of strings)', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'shared-ok',
description: 'x',
max_movements: 1,
initial_movement: 'only',
shared_tools: ['WebSearch', 'ReadToolDoc'],
movements: [
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] },
],
});
expect(res.status).toBe(201);
});
it('POST /api/pieces rejects non-array shared_tools', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'shared-bad-type',
description: 'x',
max_movements: 1,
initial_movement: 'only',
shared_tools: 'WebSearch',
movements: [
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] },
],
});
expect(res.status).toBe(400);
expect(String(res.body.error ?? res.body)).toMatch(/shared_tools must be an array/);
});
it('POST /api/pieces rejects shared_tools with non-string entry', async () => {
const res = await request(app)
.post('/api/pieces')
.send({
name: 'shared-bad-entry',
description: 'x',
max_movements: 1,
initial_movement: 'only',
shared_tools: ['WebSearch', ''],
movements: [
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] },
],
});
expect(res.status).toBe(400);
expect(String(res.body.error ?? res.body)).toMatch(/shared_tools\[1\] must be a non-empty string/);
});
it('DELETE /api/pieces/:name — legacy no-auth pieces land in piecesDir (builtin source) and are non-deletable', async () => { it('DELETE /api/pieces/:name — legacy no-auth pieces land in piecesDir (builtin source) and are non-deletable', async () => {
// In legacy no-auth mode with no customPiecesDir, POST writes to piecesDir. // In legacy no-auth mode with no customPiecesDir, POST writes to piecesDir.
// Those files are resolved as source='builtin' and are non-deletable. // Those files are resolved as source='builtin' and are non-deletable.

View File

@ -47,6 +47,18 @@ function validatePiece(piece: any): string | null {
if (typeof piece.max_movements !== 'number' || !Number.isFinite(piece.max_movements) || piece.max_movements <= 0) { if (typeof piece.max_movements !== 'number' || !Number.isFinite(piece.max_movements) || piece.max_movements <= 0) {
return 'max_movements is required (positive integer)'; return 'max_movements is required (positive integer)';
} }
// Piece-level shared tools (union into every movement). Optional; when
// present must be an array of non-empty strings. Unknown tool names are
// tolerated (filtered at runtime by getToolDefs) — same as allowed_tools.
if (piece.shared_tools !== undefined) {
if (!Array.isArray(piece.shared_tools)) return 'shared_tools must be an array';
for (let i = 0; i < piece.shared_tools.length; i++) {
const t = piece.shared_tools[i];
if (typeof t !== 'string' || t.trim().length === 0) {
return `shared_tools[${i}] must be a non-empty string`;
}
}
}
const names = new Set(piece.movements.map((m: any) => m.name)); const names = new Set(piece.movements.map((m: any) => m.name));
if (!names.has(piece.initial_movement)) return 'initial_movement must reference an existing movement'; if (!names.has(piece.initial_movement)) return 'initial_movement must reference an existing movement';
// Phase 6b: rules[].next only accepts existing movement names + WAIT_SUBTASKS. // Phase 6b: rules[].next only accepts existing movement names + WAIT_SUBTASKS.
@ -133,6 +145,14 @@ export interface PiecesApiOptions {
* When unset, per-user piece support is disabled and non-admin POST returns 503. * When unset, per-user piece support is disabled and non-admin POST returns 503.
*/ */
userPiecesRootDir?: string; userPiecesRootDir?: string;
/**
* Spaces foundation: optional resolver mapping a `spaceId` (from the request
* query/body) to the `{ rootDir, leafId }` folder whose `pieces/` dir holds
* that space's custom pieces. When omitted (or no spaceId on the request),
* piece resolution uses the legacy per-user folder (`userPiecesRootDir`,
* `user.id ?? 'local'`) byte-identical to today.
*/
resolveSpaceFolder?: (spaceId: string, viewer: AuthedUser | undefined) => Promise<{ rootDir: string; leafId: string } | null>;
} }
/** Canonical owner id for no-auth / legacy mode. Mirrors worker-bootstrap and piece-catalog default. */ /** Canonical owner id for no-auth / legacy mode. Mirrors worker-bootstrap and piece-catalog default. */
@ -144,6 +164,37 @@ function getUser(req: Request): AuthedUser | undefined {
return (req as any).user as AuthedUser | undefined; return (req as any).user as AuthedUser | undefined;
} }
/** Extract a spaceId from the request (query param wins over body). */
function getSpaceId(req: Request): string | undefined {
const fromQuery = typeof req.query?.spaceId === 'string' ? req.query.spaceId : undefined;
const fromBody = req.body && typeof req.body.spaceId === 'string' ? req.body.spaceId : undefined;
return fromQuery ?? fromBody ?? undefined;
}
/**
* Resolve the per-caller custom-pieces directory + leaf id, honoring an
* optional `spaceId` on the request. Returns `null` when per-user support is
* disabled (`userPiecesRootDir` unset). When a spaceId is present and a
* resolver is configured, the space folder is used; otherwise the legacy
* per-user folder (`userPiecesRootDir`, `user.id ?? 'local'`).
*/
async function resolveCallerPiecesDir(
opts: PiecesApiOptions,
user: AuthedUser | undefined,
req: Request,
): Promise<{ dir: string; leafId: string } | null> {
if (!opts.userPiecesRootDir) return null;
const spaceId = getSpaceId(req);
if (spaceId && opts.resolveSpaceFolder) {
// viewer を渡し getSpace の可視性チェックを効かせる(見えないスペースは null →
// 自分のフォルダにフォールバックし、他スペースの pieces は読ませない)。
const fc = await opts.resolveSpaceFolder(spaceId, user);
if (fc) return { dir: userPiecesDir(fc.rootDir, fc.leafId), leafId: fc.leafId };
}
const ownerId = user?.id ?? LOCAL_OWNER;
return { dir: userPiecesDir(opts.userPiecesRootDir, ownerId), leafId: ownerId };
}
function isAdminOrLegacy(user: AuthedUser | undefined): boolean { function isAdminOrLegacy(user: AuthedUser | undefined): boolean {
// No req.user → legacy (auth disabled or test/internal). Treat as admin so // No req.user → legacy (auth disabled or test/internal). Treat as admin so
// existing callers without auth middleware continue to work. // existing callers without auth middleware continue to work.
@ -161,10 +212,14 @@ function findPieceForCaller(
opts: PiecesApiOptions, opts: PiecesApiOptions,
user: AuthedUser | undefined, user: AuthedUser | undefined,
name: string, name: string,
/** Optional space-resolved caller dir; when present, overrides the legacy
* per-user dir for the user-custom lookup. */
callerDir?: { dir: string; leafId: string } | null,
): { path: string; source: PieceSource; ownerId?: string } | null { ): { path: string; source: PieceSource; ownerId?: string } | null {
if (opts.userPiecesRootDir) { if (opts.userPiecesRootDir) {
const ownerId = user?.id ?? LOCAL_OWNER; const ownerId = callerDir?.leafId ?? user?.id ?? LOCAL_OWNER;
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${name}.yaml`); const ucDir = callerDir?.dir ?? userPiecesDir(opts.userPiecesRootDir, ownerId);
const ucPath = join(ucDir, `${name}.yaml`);
if (existsSync(ucPath)) return { path: ucPath, source: 'user-custom', ownerId }; if (existsSync(ucPath)) return { path: ucPath, source: 'user-custom', ownerId };
} }
if (opts.customPiecesDir) { if (opts.customPiecesDir) {
@ -193,7 +248,7 @@ export function mountPiecesApi(
? { piecesDir: optsOrPiecesDir, customPiecesDir: legacyCustomPiecesDir } ? { piecesDir: optsOrPiecesDir, customPiecesDir: legacyCustomPiecesDir }
: optsOrPiecesDir; : optsOrPiecesDir;
app.get('/api/pieces', (req: Request, res: Response) => { app.get('/api/pieces', async (req: Request, res: Response) => {
try { try {
const user = getUser(req); const user = getUser(req);
const pieces: PieceSummary[] = []; const pieces: PieceSummary[] = [];
@ -202,11 +257,10 @@ export function mountPiecesApi(
// Within custom umbrella: dedup by name so user-custom wins over global-custom. // Within custom umbrella: dedup by name so user-custom wins over global-custom.
// Built-ins are ALWAYS emitted separately — they are never hidden by a same-named custom. // Built-ins are ALWAYS emitted separately — they are never hidden by a same-named custom.
const customSources: Array<{ dir: string; source: PieceSource; ownerId?: string }> = []; const customSources: Array<{ dir: string; source: PieceSource; ownerId?: string }> = [];
if (opts.userPiecesRootDir) { const callerDir = await resolveCallerPiecesDir(opts, user, req);
// No-auth callers use the 'local' owner id, mirroring worker and piece-catalog defaults. if (callerDir) {
const ownerId = user?.id ?? LOCAL_OWNER; // Space-resolved (or legacy per-user) custom dir.
const ucDir = userPiecesDir(opts.userPiecesRootDir, ownerId); if (existsSync(callerDir.dir)) customSources.push({ dir: callerDir.dir, source: 'user-custom', ownerId: callerDir.leafId });
if (existsSync(ucDir)) customSources.push({ dir: ucDir, source: 'user-custom', ownerId });
} }
if (opts.customPiecesDir && existsSync(opts.customPiecesDir)) { if (opts.customPiecesDir && existsSync(opts.customPiecesDir)) {
customSources.push({ dir: opts.customPiecesDir, source: 'global-custom' }); customSources.push({ dir: opts.customPiecesDir, source: 'global-custom' });
@ -268,13 +322,14 @@ export function mountPiecesApi(
} }
}); });
app.get('/api/pieces/:name', (req: Request, res: Response) => { app.get('/api/pieces/:name', async (req: Request, res: Response) => {
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; } if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
try { try {
const user = getUser(req); const user = getUser(req);
const sourceParsed = parseSourceParam(req.query.source as string | undefined); const sourceParsed = parseSourceParam(req.query.source as string | undefined);
if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; } if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
const requestedSource = sourceParsed.value; const requestedSource = sourceParsed.value;
const callerDir = await resolveCallerPiecesDir(opts, user, req);
let found: { path: string; source: PieceSource; ownerId?: string } | null = null; let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
@ -282,10 +337,9 @@ export function mountPiecesApi(
const biPath = join(opts.piecesDir, `${req.params.name}.yaml`); const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
if (existsSync(biPath)) found = { path: biPath, source: 'builtin' }; if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
} else if (requestedSource === 'user-custom') { } else if (requestedSource === 'user-custom') {
if (opts.userPiecesRootDir) { if (callerDir) {
const ownerId = user?.id ?? LOCAL_OWNER; const ucPath = join(callerDir.dir, `${req.params.name}.yaml`);
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`); if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId: callerDir.leafId };
if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
} }
} else if (requestedSource === 'global-custom') { } else if (requestedSource === 'global-custom') {
if (opts.customPiecesDir) { if (opts.customPiecesDir) {
@ -294,7 +348,7 @@ export function mountPiecesApi(
} }
} else { } else {
// No source param: priority resolution (user-custom > global-custom > builtin). // No source param: priority resolution (user-custom > global-custom > builtin).
found = findPieceForCaller(opts, user, req.params.name); found = findPieceForCaller(opts, user, req.params.name, callerDir);
} }
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; } if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
@ -313,23 +367,23 @@ export function mountPiecesApi(
} }
}); });
app.put('/api/pieces/:name', (req: Request, res: Response) => { app.put('/api/pieces/:name', async (req: Request, res: Response) => {
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; } if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
try { try {
const user = getUser(req); const user = getUser(req);
const sourceParsed = parseSourceParam(req.query.source as string | undefined); const sourceParsed = parseSourceParam(req.query.source as string | undefined);
if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; } if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
const requestedSource = sourceParsed.value; const requestedSource = sourceParsed.value;
const callerDir = await resolveCallerPiecesDir(opts, user, req);
let found: { path: string; source: PieceSource; ownerId?: string } | null = null; let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
if (requestedSource === 'builtin') { if (requestedSource === 'builtin') {
const biPath = join(opts.piecesDir, `${req.params.name}.yaml`); const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
if (existsSync(biPath)) found = { path: biPath, source: 'builtin' }; if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
} else if (requestedSource === 'user-custom') { } else if (requestedSource === 'user-custom') {
if (opts.userPiecesRootDir) { if (callerDir) {
const ownerId = user?.id ?? LOCAL_OWNER; const ucPath = join(callerDir.dir, `${req.params.name}.yaml`);
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`); if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId: callerDir.leafId };
if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
} }
} else if (requestedSource === 'global-custom') { } else if (requestedSource === 'global-custom') {
if (opts.customPiecesDir) { if (opts.customPiecesDir) {
@ -337,7 +391,7 @@ export function mountPiecesApi(
if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' }; if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
} }
} else { } else {
found = findPieceForCaller(opts, user, req.params.name); found = findPieceForCaller(opts, user, req.params.name, callerDir);
} }
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; } if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }
@ -373,12 +427,13 @@ export function mountPiecesApi(
} }
}); });
app.post('/api/pieces', (req: Request, res: Response) => { app.post('/api/pieces', async (req: Request, res: Response) => {
try { try {
const error = validatePiece(req.body); const error = validatePiece(req.body);
if (error) { res.status(400).json({ ok: false, error }); return; } if (error) { res.status(400).json({ ok: false, error }); return; }
const user = getUser(req); const user = getUser(req);
const callerDir = await resolveCallerPiecesDir(opts, user, req);
// POST always creates in user-custom dir. "+" is always Create Custom. // POST always creates in user-custom dir. "+" is always Create Custom.
// Admins edit built-ins via PUT on existing ones, not by POST-creating new built-ins. // Admins edit built-ins via PUT on existing ones, not by POST-creating new built-ins.
@ -387,11 +442,10 @@ export function mountPiecesApi(
// No-auth / legacy WITHOUT userPiecesRootDir: fall back to global customPiecesDir or piecesDir. // No-auth / legacy WITHOUT userPiecesRootDir: fall back to global customPiecesDir or piecesDir.
let destDir: string; let destDir: string;
let createdSource: PieceSource; let createdSource: PieceSource;
if (opts.userPiecesRootDir) { if (callerDir) {
// Both authenticated and no-auth go to user-custom dir when userPiecesRootDir is set. // Both authenticated and no-auth go to the resolved user-custom (or space)
// Authenticated users: 503 guard below is only needed when root is NOT set. // dir when userPiecesRootDir is set.
const ownerId = user?.id ?? LOCAL_OWNER; destDir = callerDir.dir;
destDir = userPiecesDir(opts.userPiecesRootDir, ownerId);
mkdirSync(destDir, { recursive: true }); mkdirSync(destDir, { recursive: true });
createdSource = 'user-custom'; createdSource = 'user-custom';
} else if (user) { } else if (user) {
@ -420,7 +474,7 @@ export function mountPiecesApi(
// Reject if the caller's visible piece with this name already exists // Reject if the caller's visible piece with this name already exists
// (global-custom or caller's own user-custom). // (global-custom or caller's own user-custom).
if (findPieceForCaller(opts, user, req.body.name)) { if (findPieceForCaller(opts, user, req.body.name, callerDir)) {
res.status(409).json({ ok: false, error: 'Piece already exists' }); return; res.status(409).json({ ok: false, error: 'Piece already exists' }); return;
} }
@ -433,23 +487,23 @@ export function mountPiecesApi(
} }
}); });
app.delete('/api/pieces/:name', (req: Request, res: Response) => { app.delete('/api/pieces/:name', async (req: Request, res: Response) => {
if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; } if (!validateName(req.params.name)) { res.status(400).json({ error: 'Invalid piece name' }); return; }
try { try {
const user = getUser(req); const user = getUser(req);
const sourceParsed = parseSourceParam(req.query.source as string | undefined); const sourceParsed = parseSourceParam(req.query.source as string | undefined);
if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; } if (!sourceParsed.valid) { res.status(400).json({ ok: false, error: sourceParsed.error }); return; }
const requestedSource = sourceParsed.value; const requestedSource = sourceParsed.value;
const callerDir = await resolveCallerPiecesDir(opts, user, req);
let found: { path: string; source: PieceSource; ownerId?: string } | null = null; let found: { path: string; source: PieceSource; ownerId?: string } | null = null;
if (requestedSource === 'builtin') { if (requestedSource === 'builtin') {
const biPath = join(opts.piecesDir, `${req.params.name}.yaml`); const biPath = join(opts.piecesDir, `${req.params.name}.yaml`);
if (existsSync(biPath)) found = { path: biPath, source: 'builtin' }; if (existsSync(biPath)) found = { path: biPath, source: 'builtin' };
} else if (requestedSource === 'user-custom') { } else if (requestedSource === 'user-custom') {
if (opts.userPiecesRootDir) { if (callerDir) {
const ownerId = user?.id ?? LOCAL_OWNER; const ucPath = join(callerDir.dir, `${req.params.name}.yaml`);
const ucPath = join(userPiecesDir(opts.userPiecesRootDir, ownerId), `${req.params.name}.yaml`); if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId: callerDir.leafId };
if (existsSync(ucPath)) found = { path: ucPath, source: 'user-custom', ownerId };
} }
} else if (requestedSource === 'global-custom') { } else if (requestedSource === 'global-custom') {
if (opts.customPiecesDir) { if (opts.customPiecesDir) {
@ -457,7 +511,7 @@ export function mountPiecesApi(
if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' }; if (existsSync(gcPath)) found = { path: gcPath, source: 'global-custom' };
} }
} else { } else {
found = findPieceForCaller(opts, user, req.params.name); found = findPieceForCaller(opts, user, req.params.name, callerDir);
} }
if (!found) { res.status(404).json({ error: 'Piece not found' }); return; } if (!found) { res.status(404).json({ error: 'Piece not found' }); return; }

View File

@ -39,7 +39,7 @@ function makeMeta(overrides: Partial<WriteSnapshotMeta> = {}): WriteSnapshotMeta
// Create an Express app wired up with the reflection API, with auth disabled // Create an Express app wired up with the reflection API, with auth disabled
// (auth gate injects synthetic user from X-Test-User-Id header via a middleware). // (auth gate injects synthetic user from X-Test-User-Id header via a middleware).
function buildApp(dataDir: string, repo: Repository, userId: string = OWNER_ID) { function buildApp(dataDir: string, repo: Repository, opts: { authActive?: boolean } = {}) {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
@ -47,14 +47,14 @@ function buildApp(dataDir: string, repo: Repository, userId: string = OWNER_ID)
app.use((req, _res, next) => { app.use((req, _res, next) => {
const testUserId = req.headers['x-test-user-id'] as string | undefined; const testUserId = req.headers['x-test-user-id'] as string | undefined;
if (testUserId) { if (testUserId) {
(req as any).user = { id: testUserId, role: testUserId === 'admin' ? 'admin' : 'user' }; (req as any).user = { id: testUserId, role: testUserId === 'admin' ? 'admin' : 'user', orgIds: [] };
} }
next(); next();
}); });
app.use( app.use(
'/api/local/reflection', '/api/local/reflection',
createReflectionApi({ dataDir, repo, authActive: false }), createReflectionApi({ dataDir, repo, authActive: opts.authActive ?? false }),
); );
return app; return app;
@ -447,4 +447,113 @@ describe('reflection-api', () => {
expect(res.body).toBeNull(); expect(res.body).toBeNull();
}); });
}); });
// ── Space-as-Principal: case-space snapshots live under data/spaces/{id} ─────
describe('case-space reflection storage', () => {
function insertJob(jobId: string, taskId: number, ownerId: string) {
const now = new Date().toISOString();
repo.getDb().prepare(`
INSERT INTO jobs (id, repo, issue_number, status, piece_name, required_profile, task_class,
instruction, attempt, max_attempts, ask_count, subtask_depth, task_kind, created_at, updated_at, owner_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(jobId, `local/task-${taskId}`, 1, 'succeeded', 'chat', 'default', 'auto',
'case task', 1, 1, 0, 0, 'agent', now, now, ownerId);
}
it('latest-for-task resolves a CASE-space task snapshot from data/spaces', async () => {
// dataDir = tmpDir/users → spaces sibling at tmpDir/spaces (both cleaned with tmpDir).
const usersDir = join(tmpDir, 'users');
const spacesDir = join(tmpDir, 'spaces');
const app2 = buildApp(usersDir, repo);
const space = await repo.createSpace({ kind: 'case', title: 'C', ownerId: OWNER_ID, visibility: 'private' });
const task = await repo.createLocalTask({ title: 't', body: '', spaceId: space.id, ownerId: OWNER_ID });
insertJob('j-cs-1', task.id, OWNER_ID);
// Snapshot written under the SPACE store (leaf = space id), as the runner does.
const { snapshotId } = await writeSnapshot(
{ dataDir: spacesDir }, {}, {},
makeMeta({ originalJobId: 'j-cs-1', userId: space.id, memoryChanges: 5 }),
undefined, undefined, new Date('2026-05-11T15:00:00Z'),
);
const res = await request(app2)
.get(`/api/local/reflection/latest-for-task/${task.id}`)
.set('x-test-user-id', OWNER_ID);
expect(res.status).toBe(200);
expect(res.body?.snapshotId).toBe(snapshotId);
expect(res.body?.memoryChanges).toBe(5);
});
it('latest-for-task shows the badge to a case-space MEMBER who did not create the task', async () => {
const usersDir = join(tmpDir, 'users');
const spacesDir = join(tmpDir, 'spaces');
const app2 = buildApp(usersDir, repo);
// Space owned by OWNER_ID; OTHER_ID is an editor member. Task + job created by OWNER_ID.
// addSpaceMember has a FK to users(id) → create real user rows.
const owner = repo.createUser({ email: 'o2@x.com', name: 'o2', role: 'user', status: 'active' });
const member = repo.createUser({ email: 'm2@x.com', name: 'm2', role: 'user', status: 'active' });
const space = await repo.createSpace({ kind: 'case', title: 'C', ownerId: owner.id, visibility: 'private' });
await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'editor', invitedBy: owner.id });
const task = await repo.createLocalTask({ title: 't', body: '', spaceId: space.id, ownerId: owner.id });
insertJob('j-cs-mem', task.id, owner.id);
const { snapshotId } = await writeSnapshot(
{ dataDir: spacesDir }, {}, {},
makeMeta({ originalJobId: 'j-cs-mem', userId: space.id, memoryChanges: 2 }),
undefined, undefined, new Date('2026-05-11T15:00:00Z'),
);
// member is NOT the job owner but IS a space member → badge must show.
const res = await request(app2)
.get(`/api/local/reflection/latest-for-task/${task.id}`)
.set('x-test-user-id', member.id);
expect(res.status).toBe(200);
expect(res.body?.snapshotId).toBe(snapshotId);
// A user with no membership and not the owner gets nothing.
const outsider = await request(app2)
.get(`/api/local/reflection/latest-for-task/${task.id}`)
.set('x-test-user-id', 'u-outsider');
expect(outsider.status).toBe(200);
expect(outsider.body).toBeNull();
});
it('GET /history?spaceId lists a case-space history for a member, 404 for a non-member', async () => {
const usersDir = join(tmpDir, 'users');
const spacesDir = join(tmpDir, 'spaces');
// authActive:true so membership gating actually applies (no-auth = admin-all).
const app2 = buildApp(usersDir, repo, { authActive: true });
const space = await repo.createSpace({ kind: 'case', title: 'C', ownerId: OWNER_ID, visibility: 'private' });
await writeSnapshot(
{ dataDir: spacesDir }, {}, {},
makeMeta({ originalJobId: 'j-sp', userId: space.id }),
undefined, undefined, new Date(),
);
// Owner (member) sees the space history.
const ok = await request(app2)
.get(`/api/local/reflection/history?spaceId=${space.id}`)
.set('x-test-user-id', OWNER_ID);
expect(ok.status).toBe(200);
expect(ok.body.items.length).toBe(1);
// Non-member: the space isn't visible → 404 (no existence leak).
const no = await request(app2)
.get(`/api/local/reflection/history?spaceId=${space.id}`)
.set('x-test-user-id', OTHER_ID);
expect(no.status).toBe(404);
});
it('no-auth: resolves a private case-space history via the synthetic local admin viewer', async () => {
// buildApp sets authActive:false. No x-test-user-id header → the auth gate
// injects {id:'local', role:'user'}. The space is private (would be invisible
// to a plain 'local' user), so resolveStore must treat no-auth as admin.
const usersDir = join(tmpDir, 'users');
const spacesDir = join(tmpDir, 'spaces');
const app2 = buildApp(usersDir, repo);
const space = await repo.createSpace({ kind: 'case', title: 'C', ownerId: OWNER_ID, visibility: 'private' });
await writeSnapshot(
{ dataDir: spacesDir }, {}, {},
makeMeta({ originalJobId: 'j-noauth', userId: space.id }),
undefined, undefined, new Date(),
);
const res = await request(app2).get(`/api/local/reflection/history?spaceId=${space.id}`);
expect(res.status).toBe(200);
expect(res.body.items.length).toBe(1);
});
});
}); });

View File

@ -25,6 +25,8 @@ import {
type SnapshotDetail, type SnapshotDetail,
} from '../engine/reflection/snapshot.js'; } from '../engine/reflection/snapshot.js';
import type { Repository } from '../db/repository.js'; import type { Repository } from '../db/repository.js';
import { resolveSpaceFolder } from '../spaces/folder-resolver.js';
import { canEditInSpace } from './visibility.js';
// ── Types ────────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────────
@ -71,12 +73,47 @@ export function createReflectionApi(deps: ReflectionApiDeps): Router {
next(); next();
}); });
/**
* Resolve which snapshot store a /history request targets (Space-as-Principal).
* Without ?spaceId the viewer's personal store (data/users/{viewerId}).
* With ?spaceId the case space's store (data/spaces/{spaceId}); the snapshot
* leaf becomes the space id. Membership-gated: the space must be visible to the
* viewer (getSpace({viewer}) null otherwise), and for mutations the viewer
* must have edit rights. Returns null caller responds 404 (no existence leak).
*/
async function resolveStore(
req: Request,
forEdit: boolean,
): Promise<{ deps: SnapshotDeps; leaf: string } | null> {
const user = req.user as Express.User;
const spaceId =
typeof req.query.spaceId === 'string' && req.query.spaceId ? req.query.spaceId : null;
if (!spaceId) return { deps: makeDeps(dataDir), leaf: user.id };
// No-auth (single-user local) mode: spaces are created with owner_id=NULL and
// the synthetic user is {id:'local', role:'user'}, which can't see a private
// case space. Resolve as a 'local' admin viewer (full access), matching the
// other space-aware APIs — otherwise space history/revert is unreachable.
const viewer: Express.User = authActive
? user
: ({ ...user, role: 'admin', orgIds: (user as { orgIds?: string[] }).orgIds ?? [] } as Express.User);
const space = await repo.getSpace(spaceId, { viewer });
if (!space) return null; // not visible to this viewer
if (forEdit) {
const role = repo.getSpaceMemberRole(spaceId, user.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, role)) return null;
}
const fc = resolveSpaceFolder(space, dataDir);
return { deps: makeDeps(fc.rootDir), leaf: fc.leafId };
}
// ── GET /history ─────────────────────────────────────────────────────────── // ── GET /history ───────────────────────────────────────────────────────────
// Returns paged list of snapshot index entries (most recent first). // Returns paged list of snapshot index entries (most recent first).
// Query params: // Query params:
// limit — max items to return (default 50, max 200) // limit — max items to return (default 50, max 200)
// before — ISO timestamp cursor (exclusive, for pagination) // before — ISO timestamp cursor (exclusive, for pagination)
r.get('/history', (req: Request, res: Response) => { r.get('/history', async (req: Request, res: Response) => {
const u = getUser(req)!; const u = getUser(req)!;
const rawLimit = parseInt(String(req.query.limit ?? '50'), 10); const rawLimit = parseInt(String(req.query.limit ?? '50'), 10);
@ -84,7 +121,12 @@ export function createReflectionApi(deps: ReflectionApiDeps): Router {
const before = typeof req.query.before === 'string' ? req.query.before : undefined; const before = typeof req.query.before === 'string' ? req.query.before : undefined;
try { try {
const items = listSnapshots(makeDeps(dataDir), u.id, { limit, before }); const store = await resolveStore(req, false);
if (!store) {
res.status(404).json({ error: 'not_found' });
return;
}
const items = listSnapshots(store.deps, store.leaf, { limit, before });
// Compute nextCursor from the last item's ts (if we got a full page) // Compute nextCursor from the last item's ts (if we got a full page)
const nextCursor: string | null = const nextCursor: string | null =
@ -99,21 +141,26 @@ export function createReflectionApi(deps: ReflectionApiDeps): Router {
// ── GET /history/:snapshotId ─────────────────────────────────────────────── // ── GET /history/:snapshotId ───────────────────────────────────────────────
// Returns full snapshot detail for the owner. 404 for non-owner or missing. // Returns full snapshot detail for the owner. 404 for non-owner or missing.
r.get('/history/:snapshotId', (req: Request, res: Response) => { r.get('/history/:snapshotId', async (req: Request, res: Response) => {
const u = getUser(req)!; const u = getUser(req)!;
const { snapshotId } = req.params; const { snapshotId } = req.params;
try { try {
const detail: SnapshotDetail | null = readSnapshot(makeDeps(dataDir), u.id, snapshotId); const store = await resolveStore(req, false);
if (!store) {
res.status(404).json({ error: 'not_found' });
return;
}
const detail: SnapshotDetail | null = readSnapshot(store.deps, store.leaf, snapshotId);
if (!detail) { if (!detail) {
// Either doesn't exist or belongs to another user — always 404 // Either doesn't exist or belongs to another store — always 404
res.status(404).json({ error: 'not_found' }); res.status(404).json({ error: 'not_found' });
return; return;
} }
// Owner check: the meta.json embeds userId // Store check: the meta.json embeds the storage leaf (user id or space id)
if (detail.userId !== u.id) { if (detail.userId !== store.leaf) {
res.status(404).json({ error: 'not_found' }); res.status(404).json({ error: 'not_found' });
return; return;
} }
@ -131,10 +178,25 @@ export function createReflectionApi(deps: ReflectionApiDeps): Router {
const u = getUser(req)!; const u = getUser(req)!;
const { snapshotId } = req.params; const { snapshotId } = req.params;
// Owner check: read meta first (cheaper than a full revert attempt that fails) // Resolve the target store. Reverting mutates memory → require edit rights
// when targeting a space (resolveStore gates membership/role).
let store: { deps: SnapshotDeps; leaf: string } | null;
try { try {
const detail = readSnapshot(makeDeps(dataDir), u.id, snapshotId); store = await resolveStore(req, true);
if (!detail || detail.userId !== u.id) { } catch (err) {
logger.error(`[reflection-api] POST /revert store-resolve failed user=${u.id} snapshotId=${snapshotId} err=${err}`);
res.status(500).json({ error: 'Failed to verify snapshot ownership' });
return;
}
if (!store) {
res.status(404).json({ error: 'not_found' });
return;
}
// Store check: read meta first (cheaper than a full revert attempt that fails)
try {
const detail = readSnapshot(store.deps, store.leaf, snapshotId);
if (!detail || detail.userId !== store.leaf) {
res.status(404).json({ error: 'not_found' }); res.status(404).json({ error: 'not_found' });
return; return;
} }
@ -145,8 +207,8 @@ export function createReflectionApi(deps: ReflectionApiDeps): Router {
} }
try { try {
const result = await revertSnapshotForUser(makeDeps(dataDir), u.id, snapshotId); const result = await revertSnapshotForUser(store.deps, store.leaf, snapshotId);
logger.info(`[reflection-api] POST /revert snapshotId=${snapshotId} user=${u.id} reverted=${result.reverted}`); logger.info(`[reflection-api] POST /revert snapshotId=${snapshotId} user=${u.id} leaf=${store.leaf} reverted=${result.reverted}`);
res.json(result); res.json(result);
} catch (err) { } catch (err) {
logger.error(`[reflection-api] POST /revert failed user=${u.id} snapshotId=${snapshotId} err=${err}`); logger.error(`[reflection-api] POST /revert failed user=${u.id} snapshotId=${snapshotId} err=${err}`);
@ -240,7 +302,7 @@ export function createReflectionApi(deps: ReflectionApiDeps): Router {
// Owner check: the job must belong to the caller (owner_id match), or the // Owner check: the job must belong to the caller (owner_id match), or the
// user must be an admin. Returns null (not 404) when there's no snapshot — // user must be an admin. Returns null (not 404) when there's no snapshot —
// the badge simply stays hidden. // the badge simply stays hidden.
r.get('/latest-for-task/:taskId', (req: Request, res: Response) => { r.get('/latest-for-task/:taskId', async (req: Request, res: Response) => {
const u = getUser(req)!; const u = getUser(req)!;
const rawTaskId = parseInt(req.params.taskId, 10); const rawTaskId = parseInt(req.params.taskId, 10);
@ -260,8 +322,15 @@ export function createReflectionApi(deps: ReflectionApiDeps): Router {
owner_id: string | null; owner_id: string | null;
} }
// A case-space task is visible to every space member (not just its creator),
// and its reflection lives under the space — so any member who can view the
// task should get the 🧠 badge. Grant the unrestricted job scan when the
// viewer can view the task's space (admin/owner/member).
const task = await repo.getLocalTask(rawTaskId);
const canSeeViaSpace = task?.spaceId ? repo.userCanViewSpace(task.spaceId, u) : false;
let rows: JobRow[]; let rows: JobRow[];
if (u.role === 'admin') { if (u.role === 'admin' || canSeeViaSpace) {
rows = db rows = db
.prepare(`SELECT id, owner_id FROM jobs WHERE repo = ? ORDER BY created_at DESC`) .prepare(`SELECT id, owner_id FROM jobs WHERE repo = ? ORDER BY created_at DESC`)
.all(repoName) as JobRow[]; .all(repoName) as JobRow[];
@ -294,8 +363,20 @@ export function createReflectionApi(deps: ReflectionApiDeps): Router {
// Collect all job IDs for this task // Collect all job IDs for this task
const jobIds = new Set(rows.map((r) => r.id)); const jobIds = new Set(rows.map((r) => r.id));
// List all snapshots for this user (no limit — we need to scan for a match) // Resolve WHERE this task's snapshots live (Space-as-Principal): a case-space
const allSnapshots = listSnapshots(makeDeps(dataDir), u.id, { limit: 200 }); // task's reflection is stored under the space (data/spaces/{spaceId}), not the
// triggering user's personal folder. Personal / no-space → data/users/{u.id}.
let store: { deps: SnapshotDeps; leaf: string } = { deps: makeDeps(dataDir), leaf: u.id };
if (task?.spaceId) {
const space = await repo.getSpace(task.spaceId);
if (space) {
const fc = resolveSpaceFolder(space, dataDir);
store = { deps: makeDeps(fc.rootDir), leaf: fc.leafId };
}
}
// List all snapshots in that store (no limit — we need to scan for a match)
const allSnapshots = listSnapshots(store.deps, store.leaf, { limit: 200 });
// Find the most recent snapshot whose originalJobId is in our job set // Find the most recent snapshot whose originalJobId is in our job set
const match = allSnapshots.find((s: SnapshotIndexEntry) => jobIds.has(s.jobId)); const match = allSnapshots.find((s: SnapshotIndexEntry) => jobIds.has(s.jobId));
@ -306,7 +387,7 @@ export function createReflectionApi(deps: ReflectionApiDeps): Router {
} }
// Load the full detail so the badge can show outcome + counts // Load the full detail so the badge can show outcome + counts
const detail = readSnapshot(makeDeps(dataDir), u.id, match.snapshotId); const detail = readSnapshot(store.deps, store.leaf, match.snapshotId);
if (!detail) { if (!detail) {
res.json(null); res.json(null);
return; return;

View File

@ -108,6 +108,168 @@ describe('POST /api/scheduled-tasks with visibility', () => {
}); });
}); });
describe('POST /api/scheduled-tasks with spaceId', () => {
let sTempDir = '';
let sRepo: Repository;
let sApp: express.Application;
let user: Express.User;
let mySpaceId: string;
let otherSpaceId: string;
let publicSpaceId: string;
beforeEach(async () => {
sTempDir = mkdtempSync(join(tmpdir(), 'sched-space-api-'));
sRepo = new Repository(join(sTempDir, 'db.sqlite'));
const real = sRepo.createUser({ email: 'me@x.com', name: 'me', role: 'user', status: 'active' });
user = { ...real, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
// A case space I own (visible to me) + a private case space owned by someone else.
mySpaceId = (await sRepo.createSpace({ kind: 'case', title: 'Mine', ownerId: real.id, visibility: 'private' })).id;
const stranger = sRepo.createUser({ email: 'x@x.com', name: 'x', role: 'user', status: 'active' });
otherSpaceId = (await sRepo.createSpace({ kind: 'case', title: 'Theirs', ownerId: stranger.id, visibility: 'private' })).id;
// A PUBLIC space owned by someone else: visible to `user` but not editable.
publicSpaceId = (await sRepo.createSpace({ kind: 'case', title: 'Public', ownerId: stranger.id, visibility: 'public' })).id;
const sScheduler = new Scheduler(sRepo, join(sTempDir, 'workspaces'));
sApp = express();
sApp.use(express.json());
sApp.use((req, _res, next) => { (req as unknown as { user: Express.User }).user = user; next(); });
mountScheduledTasksApi(sApp, sRepo, sScheduler);
});
afterEach(() => {
sRepo.close();
rmSync(sTempDir, { recursive: true, force: true });
});
it('stores spaceId and forces visibility=private when created in a visible space', async () => {
const res = await request(sApp).post('/api/scheduled-tasks').send({
body: 'space job', scheduleType: 'daily', hour: 9, spaceId: mySpaceId, visibility: 'public',
});
expect(res.status).toBe(201);
expect(res.body.task.spaceId).toBe(mySpaceId);
expect(res.body.task.visibility).toBe('private'); // forced private inside a space
});
it('falls back to NULL space (personal) when spaceId is not visible to the user', async () => {
const res = await request(sApp).post('/api/scheduled-tasks').send({
body: 'sneaky', scheduleType: 'daily', hour: 9, spaceId: otherSpaceId,
});
expect(res.status).toBe(201);
expect(res.body.task.spaceId).toBeNull(); // not visible → dropped, not leaked into their space
});
it('falls back to NULL when the user can SEE but not EDIT the space (read-only viewer)', async () => {
// Regression: a space schedule writes to the shared files tree each run, so a
// read-only viewer of a public space must not be able to bind one (that would
// be an automated write channel bypassing canEditInSpace).
const res = await request(sApp).post('/api/scheduled-tasks').send({
body: 'viewer write attempt', scheduleType: 'daily', hour: 9, spaceId: publicSpaceId,
});
expect(res.status).toBe(201);
expect(res.body.task.spaceId).toBeNull();
});
it('leaves spaceId NULL when none is supplied (global create)', async () => {
const res = await request(sApp).post('/api/scheduled-tasks').send({
body: 'global', scheduleType: 'daily', hour: 9,
});
expect(res.status).toBe(201);
expect(res.body.task.spaceId).toBeNull();
});
it('lets a space editor (not the creator) pause/delete a space schedule — survives creator leaving', async () => {
// Creator (the space owner) makes a space schedule.
const created = (await request(sApp).post('/api/scheduled-tasks').send({
body: 'space job', scheduleType: 'daily', hour: 9, spaceId: mySpaceId,
})).body.task;
// A different user who is an EDITOR member of the space.
const editor = sRepo.createUser({ email: 'ed@x.com', name: 'ed', role: 'user', status: 'active' });
await sRepo.addSpaceMember({ spaceId: mySpaceId, userId: editor.id, role: 'editor' });
const editorUser: Express.User = { ...editor, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
const edApp = express();
edApp.use(express.json());
edApp.use((req, _res, next) => { (req as unknown as { user: Express.User }).user = editorUser; next(); });
mountScheduledTasksApi(edApp, sRepo, new Scheduler(sRepo, join(sTempDir, 'workspaces')));
// The editor can pause it (manage), even though they didn't create it.
const patch = await request(edApp).patch(`/api/scheduled-tasks/${created.id}`).send({ isActive: false });
expect(patch.status).toBe(200);
expect(patch.body.task.isActive).toBe(false);
// ...and delete it.
const del = await request(edApp).delete(`/api/scheduled-tasks/${created.id}`);
expect(del.status).toBe(200);
});
it('a creator who LOST space edit rights can no longer trigger/edit the space schedule', async () => {
// An editor member creates a space schedule.
const editor = sRepo.createUser({ email: 'wased@x.com', name: 'wasEd', role: 'user', status: 'active' });
await sRepo.addSpaceMember({ spaceId: mySpaceId, userId: editor.id, role: 'editor' });
const edUser: Express.User = { ...editor, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
const mkApp = (u: Express.User) => {
const a = express(); a.use(express.json());
a.use((req, _res, next) => { (req as unknown as { user: Express.User }).user = u; next(); });
mountScheduledTasksApi(a, sRepo, new Scheduler(sRepo, join(sTempDir, 'workspaces')));
return a;
};
const created = (await request(mkApp(edUser)).post('/api/scheduled-tasks').send({
body: 'editor job', scheduleType: 'daily', hour: 9, spaceId: mySpaceId,
})).body.task;
expect(created.spaceId).toBe(mySpaceId);
// The editor is demoted to viewer (loses edit rights).
await sRepo.addSpaceMember({ spaceId: mySpaceId, userId: editor.id, role: 'viewer' });
// Even though they are still the schedule's ownerId, they can no longer
// trigger (self-write to the shared files tree) or edit it.
const trig = await request(mkApp(edUser)).post(`/api/scheduled-tasks/${created.id}/trigger`);
expect(trig.status).toBe(404);
const patch = await request(mkApp(edUser)).patch(`/api/scheduled-tasks/${created.id}`).send({ isActive: false });
expect(patch.status).toBe(404);
// The space owner can still manage it (it's a space asset).
const ownerPatch = await request(sApp).patch(`/api/scheduled-tasks/${created.id}`).send({ isActive: false });
expect(ownerPatch.status).toBe(200);
});
it('does NOT let a non-member manage a space schedule', async () => {
const created = (await request(sApp).post('/api/scheduled-tasks').send({
body: 'space job', scheduleType: 'daily', hour: 9, spaceId: mySpaceId,
})).body.task;
const outsider = sRepo.createUser({ email: 'out@x.com', name: 'out', role: 'user', status: 'active' });
const outUser: Express.User = { ...outsider, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null };
const outApp = express();
outApp.use(express.json());
outApp.use((req, _res, next) => { (req as unknown as { user: Express.User }).user = outUser; next(); });
mountScheduledTasksApi(outApp, sRepo, new Scheduler(sRepo, join(sTempDir, 'workspaces')));
const patch = await request(outApp).patch(`/api/scheduled-tasks/${created.id}`).send({ isActive: false });
expect(patch.status).toBe(404); // not visible / not manageable
});
it('PATCH keeps a space-bound schedule private (cannot be opened to public/org)', async () => {
const created = (await request(sApp).post('/api/scheduled-tasks').send({
body: 'space job', scheduleType: 'daily', hour: 9, spaceId: mySpaceId,
})).body.task;
expect(created.spaceId).toBe(mySpaceId);
// Owner tries to widen visibility via edit — must stay private (space is the boundary).
const res = await request(sApp).patch(`/api/scheduled-tasks/${created.id}`).send({ visibility: 'public' });
expect(res.status).toBe(200);
const after = (await request(sApp).get(`/api/scheduled-tasks/${created.id}`)).body.task;
expect(after.visibility).toBe('private');
expect(after.spaceId).toBe(mySpaceId);
});
it('GET /api/scheduled-tasks?spaceId=<id> returns only that space\'s schedules', async () => {
await request(sApp).post('/api/scheduled-tasks').send({ body: 'in-space', scheduleType: 'daily', hour: 9, spaceId: mySpaceId });
await request(sApp).post('/api/scheduled-tasks').send({ body: 'global', scheduleType: 'daily', hour: 9 });
const scoped = await request(sApp).get(`/api/scheduled-tasks?spaceId=${mySpaceId}`);
expect(scoped.status).toBe(200);
expect(scoped.body.tasks).toHaveLength(1);
expect(scoped.body.tasks[0].spaceId).toBe(mySpaceId);
// Unscoped list still returns both.
const all = await request(sApp).get('/api/scheduled-tasks');
expect(all.body.tasks.length).toBe(2);
});
});
describe('POST /api/scheduled-tasks in no-auth mode (synthetic local owner)', () => { describe('POST /api/scheduled-tasks in no-auth mode (synthetic local owner)', () => {
let nTempDir = ''; let nTempDir = '';
let nRepo: Repository; let nRepo: Repository;
@ -566,3 +728,197 @@ describe('POST /api/scheduled-tasks browserSessionProfileId owner check', () =>
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
}); });
// Confused-deputy guard: a SPACE schedule may be managed by any space
// owner/admin/editor, but the scheduler runs the job as the schedule OWNER and
// reuses the stored browser profile. A non-owner manager who changes WHAT the
// schedule runs must not keep the owner's browser session attached, or their
// instructions would execute inside the owner's authenticated session.
describe('scheduled-tasks browserSessionProfileId confused-deputy guard', () => {
let sTempDir = '';
let sRepo: Repository;
let sSessRepo: BrowserSessionRepo;
let alice: { id: string }; // space owner + schedule owner
let bob: { id: string }; // space editor (non-owner manager)
let mySpaceId = '';
let aliceProfileId: number; // owned by alice
beforeEach(async () => {
sTempDir = mkdtempSync(join(tmpdir(), 'sched-bsp-deputy-'));
sRepo = new Repository(join(sTempDir, 'db.sqlite'));
sSessRepo = new BrowserSessionRepo(sRepo.getDb());
alice = sRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
bob = sRepo.createUser({ email: 'b@x.com', name: 'b', role: 'user', status: 'active' });
mySpaceId = (await sRepo.createSpace({ kind: 'case', title: 'Mine', ownerId: alice.id, visibility: 'private' })).id;
await sRepo.addSpaceMember({ spaceId: mySpaceId, userId: bob.id, role: 'editor' });
aliceProfileId = sSessRepo.createProfile({
ownerId: alice.id, spaceId: null, label: 'alice-twitter',
startUrl: 'https://twitter.com/home',
matchPatterns: ['https://twitter.com/**'],
storageOrigins: ['https://twitter.com'],
loggedInSelector: null,
loginUrlPatterns: [],
});
});
afterEach(() => {
sRepo.close();
rmSync(sTempDir, { recursive: true, force: true });
});
function asUser(u: { id: string }, email: string): Express.User {
return {
id: u.id, email, name: 'x', avatarUrl: null,
role: 'user', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
}
function buildAppForUser(user: Express.User): express.Application {
const sScheduler = new Scheduler(sRepo, join(sTempDir, 'workspaces'));
const sApp = express();
sApp.use(express.json());
sApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountScheduledTasksApi(sApp, sRepo, sScheduler, { sessRepo: sSessRepo });
return sApp;
}
async function createSpaceScheduleWithProfile(): Promise<number> {
const res = await request(buildAppForUser(asUser(alice, 'a@x.com')))
.post('/api/scheduled-tasks')
.send({ body: 'orig', scheduleType: 'daily', hour: 9, spaceId: mySpaceId, browserSessionProfileId: aliceProfileId });
expect(res.status).toBe(201);
expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
return res.body.task.id;
}
it('rejects a non-owner editor changing the body (403)', async () => {
const id = await createSpaceScheduleWithProfile();
const res = await request(buildAppForUser(asUser(bob, 'b@x.com')))
.patch(`/api/scheduled-tasks/${id}`)
.send({ body: 'malicious instructions' });
expect(res.status).toBe(403);
// Definition unchanged: body and profile both intact.
const after = await sRepo.getScheduledTask(id);
expect(after?.body).toBe('orig');
expect(after?.browserSessionProfileId).toBe(aliceProfileId);
});
it('rejects a non-owner editor changing only the title (403)', async () => {
// title is included in the runtime instruction, so it is content.
const id = await createSpaceScheduleWithProfile();
const res = await request(buildAppForUser(asUser(bob, 'b@x.com')))
.patch(`/api/scheduled-tasks/${id}`)
.send({ title: 'renamed' });
expect(res.status).toBe(403);
});
it('rejects a non-owner editor changing script fields (403)', async () => {
const id = await createSpaceScheduleWithProfile();
const res = await request(buildAppForUser(asUser(bob, 'b@x.com')))
.patch(`/api/scheduled-tasks/${id}`)
.send({ taskKind: 'script', scriptName: 'evil', scriptParams: '{"x":1}' });
expect(res.status).toBe(403);
});
it('rejects a non-owner editor attaching their own profile (403)', async () => {
const id = await createSpaceScheduleWithProfile();
const bobProfileId = sSessRepo.createProfile({
ownerId: bob.id, spaceId: null, label: 'bob-twitter',
startUrl: 'https://twitter.com/home',
matchPatterns: ['https://twitter.com/**'],
storageOrigins: ['https://twitter.com'],
loggedInSelector: null,
loginUrlPatterns: [],
});
const res = await request(buildAppForUser(asUser(bob, 'b@x.com')))
.patch(`/api/scheduled-tasks/${id}`)
.send({ browserSessionProfileId: bobProfileId });
expect(res.status).toBe(403);
const after = await sRepo.getScheduledTask(id);
expect(after?.browserSessionProfileId).toBe(aliceProfileId); // unchanged
});
it('lets a non-owner manager pause a space schedule (lifecycle, 200)', async () => {
const id = await createSpaceScheduleWithProfile();
const res = await request(buildAppForUser(asUser(bob, 'b@x.com')))
.patch(`/api/scheduled-tasks/${id}`)
.send({ isActive: false });
expect(res.status).toBe(200);
expect(res.body.task.isActive).toBe(false);
expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
});
it('lets a non-owner manager reschedule a space schedule (lifecycle, 200)', async () => {
const id = await createSpaceScheduleWithProfile();
const res = await request(buildAppForUser(asUser(bob, 'b@x.com')))
.patch(`/api/scheduled-tasks/${id}`)
.send({ scheduleType: 'daily', hour: 18 });
expect(res.status).toBe(200);
expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
});
it('lets a non-owner reschedule even when the UI resends the whole unchanged form (200)', async () => {
// The editor PATCHes the full form; unchanged content fields must not 403.
const id = await createSpaceScheduleWithProfile();
const res = await request(buildAppForUser(asUser(bob, 'b@x.com')))
.patch(`/api/scheduled-tasks/${id}`)
.send({
title: null, body: 'orig', piece: 'auto', profile: 'auto', outputFormat: 'markdown',
taskKind: 'agent', scriptName: null, scriptParams: null,
browserSessionProfileId: aliceProfileId, // unchanged
scheduleType: 'daily', hour: 18, // the only real change
});
expect(res.status).toBe(200);
expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
});
it('still rejects a non-owner whose full-form PATCH actually changes the body (403)', async () => {
const id = await createSpaceScheduleWithProfile();
const res = await request(buildAppForUser(asUser(bob, 'b@x.com')))
.patch(`/api/scheduled-tasks/${id}`)
.send({
title: null, body: 'CHANGED', piece: 'auto', profile: 'auto', outputFormat: 'markdown',
taskKind: 'agent', scriptName: null, scriptParams: null,
browserSessionProfileId: aliceProfileId,
scheduleType: 'daily', hour: 18,
});
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/body/);
});
it('lets the OWNER edit their own space schedule content (200)', async () => {
const id = await createSpaceScheduleWithProfile();
const res = await request(buildAppForUser(asUser(alice, 'a@x.com')))
.patch(`/api/scheduled-tasks/${id}`)
.send({ body: 'owner revises' });
expect(res.status).toBe(200);
expect(res.body.task.body).toBe('owner revises');
expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
});
it('lets the no-auth single operator edit a space schedule body (200)', async () => {
// No-auth (authActive:false, no req.user) is the sole principal — no confused
// deputy is possible, so the content guard must not block content edits.
const noAuthApp = (() => {
const sScheduler = new Scheduler(sRepo, join(sTempDir, 'workspaces'));
const a = express();
a.use(express.json());
mountScheduledTasksApi(a, sRepo, sScheduler, { sessRepo: sSessRepo, authActive: false });
return a;
})();
const created = await request(noAuthApp)
.post('/api/scheduled-tasks')
.send({ body: 'orig', scheduleType: 'daily', hour: 9, spaceId: mySpaceId });
expect(created.status).toBe(201);
expect(created.body.task.spaceId).toBe(mySpaceId);
const res = await request(noAuthApp)
.patch(`/api/scheduled-tasks/${created.body.task.id}`)
.send({ body: 'edited by operator' });
expect(res.status).toBe(200);
expect(res.body.task.body).toBe('edited by operator');
});
});

View File

@ -3,6 +3,8 @@ import { type Repository } from '../db/repository.js';
import type { BrowserSessionRepo } from '../db/browser-session-repo.js'; import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { convertToCron, calcNextRun, toSqliteDatetime } from '../scheduler.js'; import { convertToCron, calcNextRun, toSqliteDatetime } from '../scheduler.js';
import { type Scheduler } from '../scheduler.js'; import { type Scheduler } from '../scheduler.js';
import { logger } from '../logger.js';
import { canEditInSpace } from './visibility.js';
export interface ScheduledTasksApiOptions { export interface ScheduledTasksApiOptions {
/** /**
@ -36,6 +38,14 @@ export function mountScheduledTasksApi(
* - { ok: true, value: number | null } when accepted (null = unset / clear). * - { ok: true, value: number | null } when accepted (null = unset / clear).
* - { ok: false, error } when validation fails (caller sends 400). * - { ok: false, error } when validation fails (caller sends 400).
* Pass an undefined raw to skip validation entirely (PATCH "field absent" case). * Pass an undefined raw to skip validation entirely (PATCH "field absent" case).
*
* The profile must be OWNED BY the requesting user. The scheduler spawns the
* job as the schedule's owner and the Worker enforces job.ownerId ===
* profile.ownerId (assertProfileOwner, fail-closed), so a profile owned by
* anyone else would fail at runtime reject it here instead of accepting a
* schedule that can never execute. The separate confused-deputy guard (a
* non-owner editing WHAT a space schedule runs while keeping the owner's
* profile) is enforced in the PATCH handler.
*/ */
function resolveBrowserSessionProfileId( function resolveBrowserSessionProfileId(
raw: unknown, raw: unknown,
@ -59,11 +69,47 @@ export function mountScheduledTasksApi(
return { ok: true, value: n }; return { ok: true, value: n };
} }
// 一覧取得 /**
* Who may MANAGE (edit / pause / delete / trigger) a scheduled task:
* - admin, or the schedule's own owner (legacy behavior), OR
* - for a SPACE-bound schedule: any owner/admin/editor of that space.
* Space schedules are space ASSETS, so they survive the creator leaving and
* the space's members can take them over or stop them (no auto-deactivation).
* This also gives the space a control path to stop a schedule whose creator
* lost edit rights. No-auth (no viewer) = sole operator, allowed.
*/
async function canManageSchedule(
viewer: Express.User | undefined,
sched: { ownerId: string | null; spaceId: string | null },
): Promise<boolean> {
if (!viewer) return true; // no-auth: sole operator
if (viewer.role === 'admin') return true;
if (sched.spaceId) {
// SPACE schedule: governed by CURRENT space edit rights, NOT the original
// creator's identity. A creator who left or was demoted loses all control
// — including manual trigger (which would write to the shared files tree),
// so the create-time canEditInSpace gate can't be bypassed afterward. The
// remaining space owner/admin/editors keep control (no auto-delete; the
// schedule survives the creator leaving and the space manages it).
const space = await repo.getSpace(sched.spaceId);
if (!space) return false;
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
return canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole);
}
// Personal (space-less) schedule: the owner manages it (legacy behavior).
return sched.ownerId !== null && sched.ownerId === viewer.id;
}
// 一覧取得(?spaceId=<id> でそのスペースのスケジュールだけに絞れる。スペースの
// 「スケジュール」タブが使う。可視性フィルタ (viewer) と併用される)。
app.get('/api/scheduled-tasks', async (req: Request, res: Response) => { app.get('/api/scheduled-tasks', async (req: Request, res: Response) => {
try { try {
const viewer = req.user as Express.User | undefined; const viewer = req.user as Express.User | undefined;
const tasks = await repo.listScheduledTasks(viewer ? { viewer } : undefined); const rawSpaceId = req.query.spaceId;
const filter: { viewer?: Express.User; spaceId?: string } = {};
if (viewer) filter.viewer = viewer;
if (typeof rawSpaceId === 'string' && rawSpaceId.length > 0) filter.spaceId = rawSpaceId;
const tasks = await repo.listScheduledTasks(Object.keys(filter).length > 0 ? filter : undefined);
res.json({ tasks }); res.json({ tasks });
} catch (err) { } catch (err) {
res.status(500).json({ error: `Failed to list scheduled tasks: ${err}` }); res.status(500).json({ error: `Failed to list scheduled tasks: ${err}` });
@ -125,13 +171,41 @@ export function mountScheduledTasksApi(
if (taskKind === 'agent' && !body) { res.status(400).json({ error: 'body is required' }); return; } if (taskKind === 'agent' && !body) { res.status(400).json({ error: 'body is required' }); return; }
if (!scheduleType) { res.status(400).json({ error: 'scheduleType is required' }); return; } if (!scheduleType) { res.status(400).json({ error: 'scheduleType is required' }); return; }
// Visibility extraction + validation (mirrors POST /api/local/tasks) // spaceId resolution: accept only a space the requester can both SEE and
// EDIT. A space-bound schedule writes to the space's shared files tree on
// every run, so binding requires write (owner/admin/editor) — not just
// visibility — or a read-only viewer of a public/org space could create an
// automated write channel that bypasses canEditInSpace. An invisible /
// non-editable / nonexistent id falls back to NULL (= personal space), so a
// schedule never slips into a space the user can't write to. No-auth (no
// req.user) = sole operator, allowed. Mirrors POST /api/local/tasks +
// space-api's canEditInSpace write gate.
const viewer = req.user as Express.User | undefined;
let resolvedSpaceId: string | null = null;
if (typeof req.body?.spaceId === 'string' && req.body.spaceId.length > 0) {
const reqSpace = await repo.getSpace(req.body.spaceId, viewer ? { viewer } : undefined);
if (!reqSpace) {
logger.warn(`[scheduled-tasks] create: spaceId=${req.body.spaceId} not visible to user; falling back to personal space`);
} else {
const memberRole = viewer ? repo.getSpaceMemberRole(reqSpace.id, viewer.id) : null;
const canEdit = !viewer || canEditInSpace(viewer, { ownerId: reqSpace.ownerId }, memberRole);
if (canEdit) {
resolvedSpaceId = reqSpace.id;
} else {
logger.warn(`[scheduled-tasks] create: user lacks edit permission in space=${reqSpace.id}; falling back to personal space`);
}
}
}
// Visibility extraction + validation (mirrors POST /api/local/tasks).
// Schedules created inside a space are ALWAYS private (the space itself
// is the sharing boundary), matching local-tasks-api.
const rawVisibility = req.body?.visibility ?? 'private'; const rawVisibility = req.body?.visibility ?? 'private';
if (!['private', 'org', 'public'].includes(rawVisibility)) { if (!['private', 'org', 'public'].includes(rawVisibility)) {
res.status(400).json({ error: 'invalid visibility' }); res.status(400).json({ error: 'invalid visibility' });
return; return;
} }
const visibility = rawVisibility as 'private' | 'org' | 'public'; const visibility = (resolvedSpaceId ? 'private' : rawVisibility) as 'private' | 'org' | 'public';
const rawScopeOrgId = req.body?.visibilityScopeOrgId; const rawScopeOrgId = req.body?.visibilityScopeOrgId;
const visibilityScopeOrgId: string | null = const visibilityScopeOrgId: string | null =
typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null; typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null;
@ -180,6 +254,7 @@ export function mountScheduledTasksApi(
taskKind, taskKind,
scriptName, scriptName,
scriptParams, scriptParams,
spaceId: resolvedSpaceId,
}); });
res.status(201).json({ task }); res.status(201).json({ task });
@ -195,11 +270,66 @@ export function mountScheduledTasksApi(
const viewer = req.user as Express.User | undefined; const viewer = req.user as Express.User | undefined;
const existing = await repo.getScheduledTask(id, { viewer }); const existing = await repo.getScheduledTask(id, { viewer });
if (!existing) { res.status(404).json({ error: 'Not found' }); return; } if (!existing) { res.status(404).json({ error: 'Not found' }); return; }
if (viewer && viewer.role !== 'admin' && existing.ownerId !== viewer.id) { if (!(await canManageSchedule(viewer, existing))) {
res.status(404).json({ error: 'Not found' }); res.status(404).json({ error: 'Not found' });
return; return;
} }
// Confused-deputy guard (codex P1) for SPACE schedules.
// A space schedule is executed AS ITS OWNER (job.ownerId = schedule owner),
// so the run pulls that owner's user-bound assets: browser session profile,
// browser-macros (read by item.ownerId), memory, MCP / SSH credentials.
// canManageSchedule lets any space owner/admin/editor MANAGE the schedule
// for business continuity, but if a non-owner could change WHAT it runs,
// their instructions would execute inside the owner's authenticated context
// (confused deputy). Re-homing the execution principal to the space is the
// P3 redesign; until then, a non-owner may only manage the LIFECYCLE
// (pause/resume, reschedule, change visibility, delete, trigger) — never
// change content or attach credentials. Content edits stay owner-only.
//
// Reject only fields whose VALUE actually changes vs. the stored schedule:
// the editor UI PATCHes the whole form (title/body/piece/… unchanged), so a
// presence-only check would 403 a legitimate non-owner reschedule.
// `!viewer` = no-auth single operator (sole principal, no confused deputy
// possible) → treated as owner, like canManageSchedule.
const isOwner = !viewer || (viewer.id != null && viewer.id === existing.ownerId);
if (existing.spaceId && viewer && !isOwner) {
const norm = (v: unknown): unknown => (v === undefined || v === null || v === '' ? null : v);
const canonParams = (v: unknown): string | null => {
if (v === undefined || v === null || v === '') return null;
try { return JSON.stringify(typeof v === 'string' ? JSON.parse(v) : v); } catch { return String(v); }
};
const changed: string[] = [];
const cmp = (key: string, reqVal: unknown, curVal: unknown) => {
if (reqVal === undefined) return; // field absent from this PATCH
if (norm(reqVal) !== norm(curVal)) changed.push(key);
};
cmp('title', req.body.title, existing.title);
cmp('body', req.body.body, existing.body);
cmp('piece', req.body.piece, existing.pieceName);
cmp('profile', req.body.profile, existing.profile);
cmp('outputFormat', req.body.outputFormat, existing.outputFormat);
cmp('taskKind', req.body.taskKind, existing.taskKind);
cmp('scriptName', req.body.scriptName, existing.scriptName);
if (req.body.scriptParams !== undefined
&& canonParams(req.body.scriptParams) !== canonParams(existing.scriptParams)) {
changed.push('scriptParams');
}
if (req.body.browserSessionProfileId !== undefined) {
const reqProfile = norm(req.body.browserSessionProfileId);
const cur = norm(existing.browserSessionProfileId);
if ((reqProfile == null ? null : Number(reqProfile)) !== (cur == null ? null : Number(cur))) {
changed.push('browserSessionProfileId');
}
}
if (changed.length > 0) {
res.status(403).json({
error: `Only the schedule owner can change what a space schedule runs (${changed.join(', ')}). As a space manager you can pause/resume, reschedule, change visibility, trigger, or delete it.`,
});
return;
}
}
const updates: Record<string, any> = {}; const updates: Record<string, any> = {};
if (req.body.title !== undefined) updates.title = req.body.title; if (req.body.title !== undefined) updates.title = req.body.title;
@ -244,22 +374,34 @@ export function mountScheduledTasksApi(
res.status(400).json({ error: 'invalid visibility' }); res.status(400).json({ error: 'invalid visibility' });
return; return;
} }
const rawScopeOrgId = req.body.visibilityScopeOrgId; // スペース内スケジュールはスペースが共有境界なので常に private。POST と
const visibilityScopeOrgId: string | null = // 同じ不変条件を編集時にも維持し、owner が public/org に変えてメンバー外へ
typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null; // スケジュール定義・以後の実行タスクを公開する経路を塞ぐ。
if (rawVisibility === 'org') { if (existing.spaceId) {
const orgIds = viewer?.orgIds ?? []; updates.visibility = 'private';
if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) { updates.visibilityScopeOrgId = null;
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' }); } else {
return; const rawScopeOrgId = req.body.visibilityScopeOrgId;
const visibilityScopeOrgId: string | null =
typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null;
if (rawVisibility === 'org') {
const orgIds = viewer?.orgIds ?? [];
if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) {
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' });
return;
}
} }
updates.visibility = rawVisibility;
updates.visibilityScopeOrgId = rawVisibility === 'org' ? visibilityScopeOrgId : null;
} }
updates.visibility = rawVisibility;
updates.visibilityScopeOrgId = rawVisibility === 'org' ? visibilityScopeOrgId : null;
} }
// browserSessionProfileId 変更 (owner check) // browserSessionProfileId 変更 (owner check). Skip for a non-owner editing
if (req.body.browserSessionProfileId !== undefined) { // a space schedule: the confused-deputy guard above already handled the
// field (rejecting an actual change, ignoring an unchanged resend). Running
// the owner check here would 400 when the editor's full-form PATCH resends
// the owner's unchanged profile id (which the non-owner does not own).
if (req.body.browserSessionProfileId !== undefined && (isOwner || !existing.spaceId)) {
const binding = resolveBrowserSessionProfileId(req.body.browserSessionProfileId, viewer); const binding = resolveBrowserSessionProfileId(req.body.browserSessionProfileId, viewer);
if (!binding.ok) { if (!binding.ok) {
res.status(400).json({ error: binding.error }); res.status(400).json({ error: binding.error });
@ -322,7 +464,7 @@ export function mountScheduledTasksApi(
const viewer = req.user as Express.User | undefined; const viewer = req.user as Express.User | undefined;
const existing = await repo.getScheduledTask(id, { viewer }); const existing = await repo.getScheduledTask(id, { viewer });
if (!existing) { res.status(404).json({ error: 'Not found' }); return; } if (!existing) { res.status(404).json({ error: 'Not found' }); return; }
if (viewer && viewer.role !== 'admin' && existing.ownerId !== viewer.id) { if (!(await canManageSchedule(viewer, existing))) {
res.status(404).json({ error: 'Not found' }); res.status(404).json({ error: 'Not found' });
return; return;
} }
@ -341,7 +483,7 @@ export function mountScheduledTasksApi(
const viewer = req.user as Express.User | undefined; const viewer = req.user as Express.User | undefined;
const existing = await repo.getScheduledTask(id, { viewer }); const existing = await repo.getScheduledTask(id, { viewer });
if (!existing) { res.status(404).json({ error: 'Not found' }); return; } if (!existing) { res.status(404).json({ error: 'Not found' }); return; }
if (viewer && viewer.role !== 'admin' && existing.ownerId !== viewer.id) { if (!(await canManageSchedule(viewer, existing))) {
res.status(404).json({ error: 'Not found' }); res.status(404).json({ error: 'Not found' });
return; return;
} }

View File

@ -34,16 +34,37 @@ describe('applySecurityHeaders', () => {
expect(headers['X-Frame-Options']).toBe('SAMEORIGIN'); expect(headers['X-Frame-Options']).toBe('SAMEORIGIN');
}); });
it('emits HSTS only when the response is served over TLS', () => { it('never touches HSTS on a plain-HTTP response, regardless of policy', () => {
const plain = fakeRes(); const clear = fakeRes();
applySecurityHeaders({ secure: false } as Request, plain.res); applySecurityHeaders({ secure: false } as Request, clear.res, 'clear');
expect(plain.headers['Strict-Transport-Security']).toBeUndefined(); expect(clear.headers['Strict-Transport-Security']).toBeUndefined();
const enable = fakeRes();
applySecurityHeaders({ secure: false } as Request, enable.res, 'enable');
expect(enable.headers['Strict-Transport-Security']).toBeUndefined();
});
it("emits HSTS max-age (no includeSubDomains) over TLS when policy is 'enable'", () => {
const tls = fakeRes();
applySecurityHeaders({ secure: true } as Request, tls.res, 'enable');
expect(tls.headers['Strict-Transport-Security']).toBe(`max-age=${HSTS_MAX_AGE_SECONDS}`);
// includeSubDomains is intentionally dropped — it is the most damaging part
// of a mistaken HSTS rollout (pins every subdomain too).
expect(tls.headers['Strict-Transport-Security']).not.toContain('includeSubDomains');
});
it("actively clears a stale HSTS pin (max-age=0) over TLS when policy is 'clear'", () => {
// This is the recovery path: a browser pinned by an earlier build loads the
// app over HTTPS, receives max-age=0, and forgets the pin so plain HTTP works.
const tls = fakeRes();
applySecurityHeaders({ secure: true } as Request, tls.res, 'clear');
expect(tls.headers['Strict-Transport-Security']).toBe('max-age=0');
});
it("defaults to the 'clear' policy when none is passed", () => {
const tls = fakeRes(); const tls = fakeRes();
applySecurityHeaders({ secure: true } as Request, tls.res); applySecurityHeaders({ secure: true } as Request, tls.res);
expect(tls.headers['Strict-Transport-Security']).toBe( expect(tls.headers['Strict-Transport-Security']).toBe('max-age=0');
`max-age=${HSTS_MAX_AGE_SECONDS}; includeSubDomains`,
);
}); });
}); });
@ -55,4 +76,19 @@ describe('securityHeadersMiddleware', () => {
expect(headers['X-Frame-Options']).toBe('SAMEORIGIN'); expect(headers['X-Frame-Options']).toBe('SAMEORIGIN');
expect(next).toHaveBeenCalledOnce(); expect(next).toHaveBeenCalledOnce();
}); });
it('reads a thunk policy per request (lazy resolution)', () => {
let policy: 'enable' | 'clear' = 'clear';
const mw = securityHeadersMiddleware(() => policy);
const a = fakeRes();
mw({ secure: true } as Request, a.res, vi.fn());
expect(a.headers['Strict-Transport-Security']).toBe('max-age=0');
// Flip the source after registration — the next request sees the new value.
policy = 'enable';
const b = fakeRes();
mw({ secure: true } as Request, b.res, vi.fn());
expect(b.headers['Strict-Transport-Security']).toBe(`max-age=${HSTS_MAX_AGE_SECONDS}`);
});
}); });

View File

@ -14,32 +14,56 @@ import { type Request, type Response, type NextFunction } from 'express';
* while still allowing the app's own same-origin iframes. * while still allowing the app's own same-origin iframes.
* - `Referrer-Policy: strict-origin-when-cross-origin` never leak the full * - `Referrer-Policy: strict-origin-when-cross-origin` never leak the full
* path (which can carry task ids) to third-party origins. * path (which can carry task ids) to third-party origins.
* - `Strict-Transport-Security` only when the response is served over TLS * - `Strict-Transport-Security` handled per the HSTS policy below, and only
* (native HTTPS, or a trusted proxy reporting `X-Forwarded-Proto: https`). * on responses served over TLS (native HTTPS, or a trusted proxy reporting
* Never emitted on plain-HTTP localhost dev so it can't pin a dev browser. * `X-Forwarded-Proto: https`). Never touched on plain-HTTP localhost dev.
* *
* Note: `req.secure` reflects `X-Forwarded-Proto` only when Express trusts the * Note: `req.secure` reflects `X-Forwarded-Proto` only when Express trusts the
* proxy. The server enables `trust proxy` together with `auth.secure_cookie`, so * proxy. The server enables `trust proxy` together with `auth.secure_cookie`, so
* an operator terminating TLS at a reverse proxy should set `secure_cookie: true` * an operator terminating TLS at a reverse proxy should set `secure_cookie: true`
* to also get HSTS. Native HTTPS sets `req.secure` intrinsically and is unaffected. * for the HSTS policy to take effect. Native HTTPS sets `req.secure` intrinsically.
*
* ## HSTS policy
*
* HSTS is OPT-IN (`server.tls.hsts`, default off). Reasons:
* - The default deployment uses a self-signed cert, where pinning a browser to
* HTTPS for months is pure footgun.
* - HSTS is sticky on the client: once sent, the browser upgrades httphttps
* BEFORE any request reaches the server, so turning HTTPS off in the UI can
* never un-stick it server-side. That is the "redirects forever" bug.
*
* So:
* - `'enable'` (operator opted in, real cert): emit a normal HSTS max-age. We
* intentionally drop `includeSubDomains` this app pins one host, and the
* subdomain pin is the most damaging part of a mistaken HSTS rollout.
* - `'clear'` (default, TLS on but HSTS not opted in): emit `max-age=0` to
* ACTIVELY un-pin any browser that an earlier build/config had pinned. This
* is the recovery path load the app once over HTTPS and the pin is cleared.
*/ */
export const HSTS_MAX_AGE_SECONDS = 15552000; // 180 days export const HSTS_MAX_AGE_SECONDS = 15552000; // 180 days
export function applySecurityHeaders(req: Request, res: Response): void { export type HstsPolicy = 'enable' | 'clear';
export function applySecurityHeaders(req: Request, res: Response, hsts: HstsPolicy = 'clear'): void {
res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN'); res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
if (req.secure) { if (req.secure) {
res.setHeader( res.setHeader(
'Strict-Transport-Security', 'Strict-Transport-Security',
`max-age=${HSTS_MAX_AGE_SECONDS}; includeSubDomains`, hsts === 'enable' ? `max-age=${HSTS_MAX_AGE_SECONDS}` : 'max-age=0',
); );
} }
} }
export function securityHeadersMiddleware() { /**
* @param hsts The HSTS policy, or a thunk returning it. A thunk lets the server
* register this middleware before the resolved TLS config is available and
* read the policy lazily per request (the value is set by listen time).
*/
export function securityHeadersMiddleware(hsts: HstsPolicy | (() => HstsPolicy) = 'clear') {
return (req: Request, res: Response, next: NextFunction): void => { return (req: Request, res: Response, next: NextFunction): void => {
applySecurityHeaders(req, res); applySecurityHeaders(req, res, typeof hsts === 'function' ? hsts() : hsts);
next(); next();
}; };
} }

View File

@ -15,6 +15,7 @@ import {
clearSetupToken, clearSetupToken,
} from './setup-api.js'; } from './setup-api.js';
import { mountPiecesApi } from './pieces-api.js'; import { mountPiecesApi } from './pieces-api.js';
import { resolveSpaceFolder } from '../spaces/folder-resolver.js';
import { mountToolsApi } from './tools-api.js'; import { mountToolsApi } from './tools-api.js';
import { mountSkillsApi } from './skills-api.js'; import { mountSkillsApi } from './skills-api.js';
import { mountNotificationsApi } from './notifications-api.js'; import { mountNotificationsApi } from './notifications-api.js';
@ -31,10 +32,10 @@ import { setUserFolderToolDeps } from '../engine/tools/user-folder.js';
import { setSkillToolDeps } from '../engine/tools/skills.js'; import { setSkillToolDeps } from '../engine/tools/skills.js';
import { setAppDocsDeps } from '../engine/tools/app-docs.js'; import { setAppDocsDeps } from '../engine/tools/app-docs.js';
import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler, resolveOrgIds, DEFAULT_SESSION_SECRET_PATH } from './auth.js'; import { setupAuth, requireAuth, requireAdmin, isProviderConfigured, isLocalEnabled, buildChangePasswordHandler, resolveOrgIds, DEFAULT_SESSION_SECRET_PATH } from './auth.js';
import { canUserSeeTask } from './visibility.js'; import { canUserSeeTask, canEditInSpace } from './visibility.js';
import { mountAdminApi } from './admin-api.js'; import { mountAdminApi } from './admin-api.js';
import { createAdminGatewayApi } from './admin-gateway-api.js'; import { createAdminGatewayApi } from './admin-gateway-api.js';
import { mountUsersApi } from './users-api.js'; import { mountUsersApi, mountUsersPickableApi } from './users-api.js';
import { mountShareApi } from './share-api.js'; import { mountShareApi } from './share-api.js';
import { securityHeadersMiddleware } from './security-headers.js'; import { securityHeadersMiddleware } from './security-headers.js';
import { mountLocalTasksApi } from './local-tasks-api.js'; import { mountLocalTasksApi } from './local-tasks-api.js';
@ -45,6 +46,8 @@ import { createUserFolderApi } from './user-folder-api.js';
import { createMemoryApi } from './memory-api.js'; import { createMemoryApi } from './memory-api.js';
import { createReflectionApi } from './reflection-api.js'; import { createReflectionApi } from './reflection-api.js';
import { createDashboardApi } from './dashboard-api.js'; import { createDashboardApi } from './dashboard-api.js';
import { createSpaceApi } from './space-api.js';
import { createCrossCalendarApi } from './cross-calendar-api.js';
import { registerShutdownHook, installSignalHandlers } from './shutdown.js'; import { registerShutdownHook, installSignalHandlers } from './shutdown.js';
import { createBackendStatusRegistry, type BackendStatusRegistry } from '../engine/backend-status-registry.js'; import { createBackendStatusRegistry, type BackendStatusRegistry } from '../engine/backend-status-registry.js';
import { createWorkerRegistry } from '../metrics/registry.js'; import { createWorkerRegistry } from '../metrics/registry.js';
@ -65,7 +68,7 @@ import { executeMcpCall } from '../mcp/tool-executor.js';
import { refreshAccessToken } from '../mcp/discovery.js'; import { refreshAccessToken } from '../mcp/discovery.js';
import { setMcpAggregator } from '../engine/tools/index.js'; import { setMcpAggregator } from '../engine/tools/index.js';
import { setMcpToolLookup } from '../engine/tools/docs.js'; import { setMcpToolLookup } from '../engine/tools/docs.js';
import { setDashboardRepo } from '../engine/tools/dashboard.js'; import { setCalendarRepo } from '../engine/tools/calendar.js';
import { createMcpOauthRouter } from '../mcp/oauth-routes.js'; import { createMcpOauthRouter } from '../mcp/oauth-routes.js';
import { createAdminRouter as createMcpAdminRouter, createUserRouter as createMcpUserRouter, createUserServersRouter as createMcpUserServersRouter } from './mcp-api.js'; import { createAdminRouter as createMcpAdminRouter, createUserRouter as createMcpUserRouter, createUserServersRouter as createMcpUserServersRouter } from './mcp-api.js';
import { mergeMcpConfig } from '../mcp/config.js'; import { mergeMcpConfig } from '../mcp/config.js';
@ -107,9 +110,6 @@ import {
type SimpleUser, type SimpleUser,
} from './console-ws-api.js'; } from './console-ws-api.js';
import { createConsoleAdminRouter } from './console-admin-api.js'; import { createConsoleAdminRouter } from './console-admin-api.js';
import { NotesRepository } from '../notes/notes-repository.js';
import { NotesService } from '../notes/notes-service.js';
import { createNotesApi } from './notes-api.js';
import { mountGateway, type GatewayMountHandle } from './gateway-mount.js'; import { mountGateway, type GatewayMountHandle } from './gateway-mount.js';
import { readGatewayConfig } from '../gateway/config.js'; import { readGatewayConfig } from '../gateway/config.js';
import { createAdminGatewayStatusRouter } from './admin-gateway-status-api.js'; import { createAdminGatewayStatusRouter } from './admin-gateway-status-api.js';
@ -210,7 +210,14 @@ export function createCoreServer(opts: CoreServerOptions): {
// Referrer-Policy, and HSTS over TLS). File-serving endpoints add // Referrer-Policy, and HSTS over TLS). File-serving endpoints add
// `Content-Security-Policy: sandbox` on top (see setUntrustedFileResponseHeaders) // `Content-Security-Policy: sandbox` on top (see setUntrustedFileResponseHeaders)
// to neutralize stored XSS from agent/user-authored workspace files. // to neutralize stored XSS from agent/user-authored workspace files.
app.use(securityHeadersMiddleware()); //
// HSTS policy is read lazily: serverCfg (with the resolved tls block) is built
// further down, but the middleware only runs the thunk per request — long
// after serverCfg is assigned. Default 'clear' until then. HSTS is opt-in
// (server.tls.hsts); when off we emit `max-age=0` to un-pin browsers that an
// earlier build pinned (see security-headers.ts).
let hstsPolicy: import('./security-headers.js').HstsPolicy = 'clear';
app.use(securityHeadersMiddleware(() => hstsPolicy));
// リバースプロキシ背後で secure cookie / X-Forwarded-Proto を正しく処理 // リバースプロキシ背後で secure cookie / X-Forwarded-Proto を正しく処理
if (opts.authConfig?.secureCookie) { if (opts.authConfig?.secureCookie) {
@ -313,6 +320,12 @@ export function createCoreServer(opts: CoreServerOptions): {
httpsPort: opts.listenPort ?? resolveListenPort(process.env['PORT'], loadConfig().server?.port), httpsPort: opts.listenPort ?? resolveListenPort(process.env['PORT'], loadConfig().server?.port),
}); });
// Now that the TLS block is resolved, set the HSTS policy the lazy
// securityHeadersMiddleware thunk reads. Only 'enable' when HTTPS is on AND
// the operator opted in; otherwise 'clear' (emit max-age=0) so a previously
// pinned browser recovers the next time it loads the app over HTTPS.
hstsPolicy = serverCfg.tls.enabled && serverCfg.tls.hsts ? 'enable' : 'clear';
if (authActive) { if (authActive) {
// Idempotently seed the shared `local` system admin (id='local', the same // Idempotently seed the shared `local` system admin (id='local', the same
// owner the no-auth path uses) so an existing single-user / no-auth // owner the no-auth path uses) so an existing single-user / no-auth
@ -381,6 +394,9 @@ export function createCoreServer(opts: CoreServerOptions): {
// is enforced inside pieces-api.ts handlers. // is enforced inside pieces-api.ts handlers.
app.use('/api/pieces', requireAuth); app.use('/api/pieces', requireAuth);
app.use('/api/usage', requireAuth); app.use('/api/usage', requireAuth);
// 横断カレンダー: 認証時はログイン必須。可視スペースの絞り込みは
// Repository.getCrossSpaceCalendarMonth が viewer 単位で行う。
app.use('/api/calendar', requireAuth);
// Scheduled tasks: any authenticated user can create/list (visibility-filtered). // Scheduled tasks: any authenticated user can create/list (visibility-filtered).
// PATCH/DELETE owner-or-admin enforcement lives in the handlers (Task 14). // PATCH/DELETE owner-or-admin enforcement lives in the handlers (Task 14).
app.use('/api/scheduled-tasks', requireAuth); app.use('/api/scheduled-tasks', requireAuth);
@ -444,6 +460,11 @@ export function createCoreServer(opts: CoreServerOptions): {
// /api/users/me/* routes — current viewer info (gated by requireAuth when auth is active) // /api/users/me/* routes — current viewer info (gated by requireAuth when auth is active)
mountUsersApi(app, repo, authActive); mountUsersApi(app, repo, authActive);
// /api/users/pickable — active-user picker for space-member invites.
// Gated by requireAuth (NOT requireAdmin): any authed user may invite. Returns
// [] in no-auth mode (no other users to invite).
mountUsersPickableApi(app, repo, authActive);
// --- Share API (public + authenticated routes) --- // --- Share API (public + authenticated routes) ---
mountShareApi(app, repo); mountShareApi(app, repo);
@ -544,7 +565,7 @@ export function createCoreServer(opts: CoreServerOptions): {
}); });
setMcpAggregator(mcpAggregator); setMcpAggregator(mcpAggregator);
setMcpToolLookup((serverId, toolName) => mcpToolCache.get(serverId, toolName)); setMcpToolLookup((serverId, toolName) => mcpToolCache.get(serverId, toolName));
setDashboardRepo(repo); setCalendarRepo(repo);
opts.workerManager?.setMcpDeps({ tokenManager: mcpTokenManager }); opts.workerManager?.setMcpDeps({ tokenManager: mcpTokenManager });
// Expose MCP enumeration to /api/tools so the Piece allowed_tools editor // Expose MCP enumeration to /api/tools so the Piece allowed_tools editor
@ -601,6 +622,7 @@ export function createCoreServer(opts: CoreServerOptions): {
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(), requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(), requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null, getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
getSpace: (spaceId, viewer) => repo.getSpace(spaceId, { viewer }),
allowPrivateAddresses: mcpConfig.allowPrivateAddresses, allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
})); }));
@ -622,7 +644,33 @@ export function createCoreServer(opts: CoreServerOptions): {
toolCache: mcpToolCache, toolCache: mcpToolCache,
requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(), requireAdmin: authActive ? requireAdmin : (_req, _res, next) => next(),
requireAuth: authActive ? requireAuth : (_req, _res, next) => next(), requireAuth: authActive ? requireAuth : (_req, _res, next) => next(),
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null, // No-auth single-user mode: passport is not mounted, so req.user is
// never populated. Fall back to the synthetic `local` admin (the same
// owner the SSH user router and space API use) so per-user / per-space
// MCP server management stays usable; otherwise getUserId is null and
// every register/list 401s, making the per-space MCP panel dead in
// no-auth deployments. No-op when auth is active (req.user wins).
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? (authActive ? null : 'local'),
// Mirror the no-auth fallback: when auth is off and passport hasn't
// populated a viewer, authorize the space against the synthetic `local`
// admin so getSpace doesn't reject the (null-owner) local space.
getSpace: (spaceId, viewer) =>
repo.getSpace(spaceId, {
viewer: viewer ?? (authActive ? undefined : ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User)),
}),
// Space-as-Principal P2: edit-rights check so space members can share the
// management of space-owned api_key MCP servers. Mirrors the no-auth
// synthetic local admin used by getUserId/getSpace above.
canEditSpace: async (spaceId, req) => {
const viewer =
(req.user as Express.User | undefined) ??
(authActive ? undefined : ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User));
if (!viewer) return false;
const space = await repo.getSpace(spaceId, { viewer });
if (!space) return false;
const role = repo.getSpaceMemberRole(spaceId, viewer.id);
return canEditInSpace(viewer, { ownerId: space.ownerId }, role);
},
insecureLocalTestMode: false, insecureLocalTestMode: false,
allowPrivateAddresses: mcpConfig.allowPrivateAddresses, allowPrivateAddresses: mcpConfig.allowPrivateAddresses,
})); }));
@ -682,6 +730,7 @@ export function createCoreServer(opts: CoreServerOptions): {
getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null, getUserId: (req) => (req.user as { id?: string } | undefined)?.id ?? null,
isAdmin: (req) => (req.user as { role?: string } | undefined)?.role === 'admin', isAdmin: (req) => (req.user as { role?: string } | undefined)?.role === 'admin',
getOrgIds: (req) => ((req.user as { orgIds?: string[] } | undefined)?.orgIds ?? []), getOrgIds: (req) => ((req.user as { orgIds?: string[] } | undefined)?.orgIds ?? []),
getSpace: (spaceId, viewer) => repo.getSpace(spaceId, { viewer }),
connectionRepo, connectionRepo,
grantsRepo, grantsRepo,
auditRepo, auditRepo,
@ -689,23 +738,23 @@ export function createCoreServer(opts: CoreServerOptions): {
accessResolver, accessResolver,
maintenance: sshMaintenance, maintenance: sshMaintenance,
forceUnlockLimiter, forceUnlockLimiter,
encryptKeyMaterial: (ownerId, pem, passphrase) => { encryptKeyMaterial: (ownerId, pem, passphrase, spaceId) => {
const { blob, keyVersion } = sshEncryptPrivateKey(repo.getDb(), ownerId, pem); const { blob, keyVersion } = sshEncryptPrivateKey(repo.getDb(), ownerId, pem, spaceId);
const passphraseBlob = passphrase const passphraseBlob = passphrase
? sshEncryptPrivateKey(repo.getDb(), ownerId, passphrase).blob ? sshEncryptPrivateKey(repo.getDb(), ownerId, passphrase, spaceId).blob
: null; : null;
const fingerprint = sshComputeKeyFingerprint(pem, passphrase); const fingerprint = sshComputeKeyFingerprint(pem, passphrase);
const publicKey = sshFormatPublicKey(pem, passphrase); const publicKey = sshFormatPublicKey(pem, passphrase);
return { blob, passphraseBlob, keyVersion, fingerprint, publicKey }; return { blob, passphraseBlob, keyVersion, fingerprint, publicKey };
}, },
decryptKeyMaterial: (ownerId, blob) => sshDecryptPrivateKey(repo.getDb(), ownerId, blob), decryptKeyMaterial: (ownerId, blob, spaceId) => sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId),
decryptPassphrase: (ownerId, blob) => decryptPassphrase: (ownerId, blob, spaceId) =>
blob ? sshDecryptPrivateKey(repo.getDb(), ownerId, blob) : null, blob ? sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId) : null,
generateKeypair: (keyType: SshGeneratedKeyType) => sshGenerateKeypair(keyType), generateKeypair: (keyType: SshGeneratedKeyType) => sshGenerateKeypair(keyType),
derivePublicKey: (ownerId, blob, passphraseBlob) => { derivePublicKey: (ownerId, blob, passphraseBlob, spaceId) => {
const pem = sshDecryptPrivateKey(repo.getDb(), ownerId, blob); const pem = sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId);
const pass = passphraseBlob const pass = passphraseBlob
? sshDecryptPrivateKey(repo.getDb(), ownerId, passphraseBlob) ? sshDecryptPrivateKey(repo.getDb(), ownerId, passphraseBlob, spaceId)
: null; : null;
try { try {
return sshFormatPublicKey(pem, pass); return sshFormatPublicKey(pem, pass);
@ -757,10 +806,10 @@ export function createCoreServer(opts: CoreServerOptions): {
auditRepo, auditRepo,
abuseRepo, abuseRepo,
accessResolver, accessResolver,
decryptKeyMaterial: (ownerId, blob) => decryptKeyMaterial: (ownerId, blob, spaceId) =>
sshDecryptPrivateKey(repo.getDb(), ownerId, blob), sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId),
decryptPassphrase: (ownerId, blob) => decryptPassphrase: (ownerId, blob, spaceId) =>
blob ? sshDecryptPrivateKey(repo.getDb(), ownerId, blob) : null, blob ? sshDecryptPrivateKey(repo.getDb(), ownerId, blob, spaceId) : null,
getUserAccess: (userId) => { getUserAccess: (userId) => {
const user = repo.getUserById(userId); const user = repo.getUserById(userId);
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
@ -922,6 +971,7 @@ export function createCoreServer(opts: CoreServerOptions): {
mountLocalTasksApi(app, { mountLocalTasksApi(app, {
repo, repo,
worktreeDir, worktreeDir,
userFolderRoot: loadConfig().userFolderRoot ?? './data/users',
authActive, authActive,
generateTitle: opts.generateTitle, generateTitle: opts.generateTitle,
selectPiece: opts.selectPiece, selectPiece: opts.selectPiece,
@ -950,6 +1000,8 @@ export function createCoreServer(opts: CoreServerOptions): {
// --- Subtask activity API --- // --- Subtask activity API ---
app.use('/api/local/tasks', createSubtaskActivityRouter(repo)); app.use('/api/local/tasks', createSubtaskActivityRouter(repo));
app.use('/api/usage', createUsageRouter(repo, { authActive })); app.use('/api/usage', createUsageRouter(repo, { authActive }));
// 横断(全スペース)カレンダー: GET /api/calendar/cross
app.use('/api/calendar', createCrossCalendarApi({ repo, authActive }));
// --- Subtask files API (listing MUST come before wildcard) --- // --- Subtask files API (listing MUST come before wildcard) ---
mountSubtaskFilesApi(app, repo); mountSubtaskFilesApi(app, repo);
@ -1042,10 +1094,17 @@ export function createCoreServer(opts: CoreServerOptions): {
mountBrandingApi(app, opts.configManager, { brandingDir, adminGuard: brandingGuard }); mountBrandingApi(app, opts.configManager, { brandingDir, adminGuard: brandingGuard });
if (opts.piecesDir) { if (opts.piecesDir) {
const piecesUserFolderRoot = loadConfig().userFolderRoot ?? './data/users';
mountPiecesApi(app, { mountPiecesApi(app, {
piecesDir: opts.piecesDir, piecesDir: opts.piecesDir,
customPiecesDir: opts.customPiecesDir, customPiecesDir: opts.customPiecesDir,
userPiecesRootDir: loadConfig().userFolderRoot ?? './data/users', userPiecesRootDir: piecesUserFolderRoot,
// Spaces foundation: resolve a request's spaceId to its pieces folder.
// No spaceId on the request => legacy per-user resolution (unchanged).
resolveSpaceFolder: async (spaceId: string, viewer) => {
const space = await repo.getSpace(spaceId, { viewer: viewer as Express.User | undefined });
return space ? resolveSpaceFolder(space, piecesUserFolderRoot) : null;
},
}); });
} }
@ -1056,6 +1115,7 @@ export function createCoreServer(opts: CoreServerOptions): {
}); });
if (opts.skillCatalog) { if (opts.skillCatalog) {
const skillsUserFolderRoot = loadConfig().userFolderRoot ?? './data/users';
mountSkillsApi(app, { mountSkillsApi(app, {
skillCatalog: opts.skillCatalog, skillCatalog: opts.skillCatalog,
requireAuth, requireAuth,
@ -1064,6 +1124,13 @@ export function createCoreServer(opts: CoreServerOptions): {
auditLog: async (jobId, action, actor, detail) => { auditLog: async (jobId, action, actor, detail) => {
try { await repo.addAuditLog(jobId, action, actor, detail); } catch {} try { await repo.addAuditLog(jobId, action, actor, detail); } catch {}
}, },
// Spaces foundation: resolve a request's spaceId to its skills folder,
// gated on the viewer's visibility of the space. No spaceId => legacy
// per-user resolution (unchanged).
resolveSpaceFolder: async (spaceId, viewer) => {
const space = await repo.getSpace(spaceId, { viewer });
return space ? resolveSpaceFolder(space, skillsUserFolderRoot) : null;
},
}); });
} }
@ -1105,40 +1172,66 @@ export function createCoreServer(opts: CoreServerOptions): {
app.use( app.use(
'/api/browser-sessions', '/api/browser-sessions',
express.json(), express.json(),
createBrowserSessionApi({ sessRepo, sessionManager: browserSessionManager, masterKeyPath, authActive }), createBrowserSessionApi({
sessRepo,
sessionManager: browserSessionManager,
masterKeyPath,
authActive,
spaceAccess: {
// Mirror the MCP/folder wiring's no-auth handling. Under no-auth the
// browser-session API mints a synthetic 'local' req.user that carries no
// orgIds — passing it straight to buildVisibilityWhere crashes, and even
// normalized it cannot see the null-owner local space (non-admin, no
// membership row). So when auth is OFF, always resolve as a synthetic
// admin local viewer. When auth is ON, pass the real viewer through
// (defaulting orgIds to [] defensively).
getSpace: (spaceId, viewer) =>
repo.getSpace(spaceId, {
viewer: authActive
? (viewer && !(viewer as { orgIds?: string[] }).orgIds
? ({ ...viewer, orgIds: [] } as unknown as Express.User)
: viewer)
: ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User),
}),
getSpaceMemberRole: (spaceId, userId) => repo.getSpaceMemberRole(spaceId, userId),
},
}),
); );
// Per-user folder REST API: list / read / write / delete with trash. // Per-user folder REST API: list / read / write / delete with trash.
const userFolderRoot = loadConfig().userFolderRoot ?? './data/users'; const userFolderRoot = loadConfig().userFolderRoot ?? './data/users';
// Shared NotesService: one instance for the entire app lifetime. // Spaces foundation: resolve a request's spaceId to its folder, gated on the
// getUserOrgIds uses the same repo.listUserGiteaOrgs pattern as SSH / MCP auth. // viewer's visibility of that space. Returns null when the space is not found
const notesRepo = new NotesRepository(repo.getDb()); // or not visible to the viewer (callers then return 404 — no silent fallback).
const notesService = new NotesService({ // No spaceId on a request => callers use the legacy per-user folder (unchanged).
db: repo.getDb(), const resolveSpaceFolderForViewer = async (
repo: notesRepo, spaceId: string,
userFolderRoot, viewer?: Express.User,
getUserOrgIds: (userId) => repo.listUserGiteaOrgs(userId).map((o) => o.orgId), ): Promise<{ rootDir: string; leafId: string } | null> => {
audit: (action, actor, target) => { const space = await repo.getSpace(spaceId, { viewer });
try { return space ? resolveSpaceFolder(space, userFolderRoot) : null;
repo.addAuditLog(null, action, actor, { target }); };
} catch (err) {
logger.warn(`[notes-audit] failed: ${(err as Error).message}`);
}
},
});
app.use('/api/users/me', createUserFolderApi({ userFolderRoot, sessRepo, masterKeyPath, authActive, notesService })); app.use('/api/users/me', createUserFolderApi({ userFolderRoot, sessRepo, masterKeyPath, authActive, resolveSpaceFolder: resolveSpaceFolderForViewer }));
// Notes knowledge-sharing REST API.
app.use('/api/notes', createNotesApi({ service: notesService, authActive }));
// Per-user memory entries REST API (GET/PUT/DELETE). // Per-user memory entries REST API (GET/PUT/DELETE).
app.use('/api/local/memory', createMemoryApi({ dataDir: userFolderRoot, authActive })); app.use('/api/local/memory', createMemoryApi({ dataDir: userFolderRoot, authActive, resolveSpaceFolder: resolveSpaceFolderForViewer }));
// Per-user reflection history REST API (GET/POST). // Per-user reflection history REST API (GET/POST).
app.use('/api/local/reflection', createReflectionApi({ dataDir: userFolderRoot, repo, authActive })); app.use('/api/local/reflection', createReflectionApi({ dataDir: userFolderRoot, repo, authActive }));
// スペース(個人 / 案件CRUD REST API。
// NOTE: ここでグローバルな express.json() は付けない。デフォルトの ~100kb 上限が
// upload ルートの大きい json parser より前に body を消費し、>100kb のアップロードが
// 413 になるため。各ルートが自前の json parser を宣言するspace-api.ts 側)。
app.use('/api/local/spaces', createSpaceApi({
repo,
dataRoot: userFolderRoot,
worktreeDir: loadConfig().worktreeDir ?? './data/worktrees',
authActive,
}));
// BackendStatusRegistry singleton — probes every configured worker // BackendStatusRegistry singleton — probes every configured worker
// (direct llama-server or proxy LiteLLM) at a fixed cadence so the // (direct llama-server or proxy LiteLLM) at a fixed cadence so the
// Side Info Panel's node-status widget can paint without hammering // Side Info Panel's node-status widget can paint without hammering
@ -1152,7 +1245,7 @@ export function createCoreServer(opts: CoreServerOptions): {
}); });
backendStatusRegistry.start(); backendStatusRegistry.start();
// Dashboard widget + worker status REST API. // Worker / node (GPU) status REST API.
app.use('/api/local/dashboard', express.json(), createDashboardApi({ app.use('/api/local/dashboard', express.json(), createDashboardApi({
repo, repo,
getWorkers: () => loadConfig().provider.workers, getWorkers: () => loadConfig().provider.workers,
@ -1270,7 +1363,8 @@ export function createCoreServer(opts: CoreServerOptions): {
if (!Number.isFinite(taskIdNum)) return false; if (!Number.isFinite(taskIdNum)) return false;
const task = await repo.getLocalTask(taskIdNum); const task = await repo.getLocalTask(taskIdNum);
if (!task) return false; if (!task) return false;
return canUserSeeTask(user, task); const spaceAccessible = task.spaceId ? repo.userCanViewSpace(task.spaceId, user) : false;
return canUserSeeTask(user, task, spaceAccessible);
} }
// legacy: kind 未設定 / taskId なし → 旧来の owner-or-admin // legacy: kind 未設定 / taskId なし → 旧来の owner-or-admin
const isOwner = session.userId === user.id; const isOwner = session.userId === user.id;

View File

@ -329,3 +329,110 @@ describe('DELETE /api/skills/:name', () => {
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
}); });
// ---------------------------------------------------------------------------
// Spaces foundation: optional spaceId scoping (mirrors pieces-api)
// - no spaceId → per-user skills dir (byte-identical regression)
// - case-space spaceId → {spacesRoot}/{id}/skills
// - unresolvable spaceId → 404, never silently falls back to user folder
// ---------------------------------------------------------------------------
describe('Skills API — spaceId scoping', () => {
const CASE_SPACE = 'space-case-1';
function spacesRoot(): string {
return join(root, 'spaces');
}
function makeResolver(visibleIds: Set<string>) {
return async (spaceId: string) => {
if (!visibleIds.has(spaceId)) return null;
if (spaceId === 'personal') return { rootDir: userRoot, leafId: 'user-1' };
return { rootDir: spacesRoot(), leafId: spaceId };
};
}
function makeSpaceApp(
catalog: SkillCatalog,
resolveSpaceFolder?: (spaceId: string) => Promise<{ rootDir: string; leafId: string } | null>,
): express.Application {
const app = express();
app.use((req, _res, next) => {
(req as unknown as { user: { id: string; role: string } }).user = { id: 'user-1', role: 'user' };
next();
});
mountSkillsApi(app, { skillCatalog: catalog, authActive: false, resolveSpaceFolder });
return app;
}
function addSpaceSkill(spaceId: string, name: string): void {
const dir = join(spacesRoot(), spaceId, 'skills', name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'SKILL.md'), SKILL_MD(name));
}
// (b) regression — no spaceId hits the per-user folder
it('without spaceId lists/creates under the per-user folder', async () => {
addUserSkill('user-1', 'my-skill');
const app = makeSpaceApp(makeCatalog(), makeResolver(new Set([CASE_SPACE])));
const list = await request(app).get('/api/skills');
const names = list.body.skills.map((s: { name: string }) => s.name).sort();
expect(names).toEqual(['my-skill', 'sys-skill']);
const create = await request(app)
.post('/api/skills')
.send({ name: 'personal-new', scope: 'user', content: SKILL_MD('personal-new') });
expect(create.status).toBe(201);
// Landed in the per-user skills dir, NOT in the space folder.
expect(existsSync(join(userRoot, 'user-1', 'skills', 'personal-new', 'SKILL.md'))).toBe(true);
expect(existsSync(join(spacesRoot(), CASE_SPACE, 'skills', 'personal-new'))).toBe(false);
});
// (a) case-space spaceId → {spacesRoot}/{id}/skills
it('with a case-space spaceId lists/creates/deletes under the space folder', async () => {
addSpaceSkill(CASE_SPACE, 'space-skill');
addUserSkill('user-1', 'personal-only');
const app = makeSpaceApp(makeCatalog(), makeResolver(new Set([CASE_SPACE])));
// List with spaceId sees the space skill + system, NOT the personal one.
const list = await request(app).get(`/api/skills?spaceId=${CASE_SPACE}`);
const names = list.body.skills.map((s: { name: string }) => s.name).sort();
expect(names).toEqual(['space-skill', 'sys-skill']);
expect(names).not.toContain('personal-only');
// Create lands under the space folder.
const create = await request(app)
.post(`/api/skills?spaceId=${CASE_SPACE}`)
.send({ name: 'space-new', scope: 'user', content: SKILL_MD('space-new') });
expect(create.status).toBe(201);
expect(existsSync(join(spacesRoot(), CASE_SPACE, 'skills', 'space-new', 'SKILL.md'))).toBe(true);
// Per-user folder untouched.
expect(existsSync(join(userRoot, 'user-1', 'skills', 'space-new'))).toBe(false);
// Delete the space skill.
const del = await request(app).delete(`/api/skills/space-skill?scope=user&spaceId=${CASE_SPACE}`);
expect(del.status).toBe(200);
expect(existsSync(join(spacesRoot(), CASE_SPACE, 'skills', 'space-skill'))).toBe(false);
});
// (c) unresolvable / forbidden spaceId → 404, no fallback
it('returns 404 for a spaceId not visible to the viewer', async () => {
const app = makeSpaceApp(makeCatalog(), makeResolver(new Set([CASE_SPACE])));
const list = await request(app).get('/api/skills?spaceId=nope');
expect(list.status).toBe(404);
const detail = await request(app).get('/api/skills/sys-skill?spaceId=nope');
expect(detail.status).toBe(404);
const create = await request(app)
.post('/api/skills?spaceId=nope')
.send({ name: 'x', scope: 'user', content: SKILL_MD('x') });
expect(create.status).toBe(404);
expect(existsSync(join(userRoot, 'user-1', 'skills', 'x'))).toBe(false);
const del = await request(app).delete('/api/skills/x?scope=user&spaceId=nope');
expect(del.status).toBe(404);
});
});

View File

@ -17,6 +17,19 @@ export interface MountSkillsApiOptions {
requireAdmin?: RequestHandler; requireAdmin?: RequestHandler;
authActive?: boolean; authActive?: boolean;
auditLog?: (jobId: string | null, action: string, actor: string, detail: object) => Promise<void>; auditLog?: (jobId: string | null, action: string, actor: string, detail: object) => Promise<void>;
/**
* Spaces foundation: optional resolver mapping a `spaceId` (from the request
* query/body) to the `{ rootDir, leafId }` folder whose `skills/` dir holds
* that space's custom skills (`{rootDir}/{leafId}/skills`). Returns `null`
* when the space is not visible to the viewer (treated as 404 never
* silently falls back to the user folder). When omitted (or no spaceId on the
* request), skill resolution uses the legacy per-user folder byte-identical
* to today.
*/
resolveSpaceFolder?: (
spaceId: string,
viewer?: Express.User,
) => Promise<{ rootDir: string; leafId: string } | null>;
} }
type AuthedUser = { id?: string; role?: string }; type AuthedUser = { id?: string; role?: string };
@ -30,6 +43,25 @@ function getUserId(req: Request): string {
return user?.id ?? 'local'; return user?.id ?? 'local';
} }
/** Extract a spaceId from the request (query param wins over body). */
function getSpaceId(req: Request): string | undefined {
const fromQuery = typeof req.query?.['spaceId'] === 'string' ? (req.query['spaceId'] as string) : undefined;
const fromBody =
req.body && typeof (req.body as Record<string, unknown>)['spaceId'] === 'string'
? ((req.body as Record<string, unknown>)['spaceId'] as string)
: undefined;
return fromQuery ?? fromBody ?? undefined;
}
/**
* A resolved custom-skill scope for one request. `forbidden` means a spaceId was
* supplied but is not visible (callers return 404). Otherwise `space` is set
* when a space folder applies, or undefined for the legacy per-user folder.
*/
type SkillScope =
| { forbidden: true }
| { forbidden?: false; space?: { rootDir: string; leafId: string } };
function isAdmin(req: Request): boolean { function isAdmin(req: Request): boolean {
const user = getUser(req); const user = getUser(req);
return user?.role === 'admin'; return user?.role === 'admin';
@ -74,6 +106,56 @@ function listDirFiles(baseDir: string, maxDepth: number = 5): string[] {
export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): void { export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): void {
const { skillCatalog } = opts; const { skillCatalog } = opts;
/**
* Resolve the custom-skill scope for a request, honoring an optional `spaceId`.
* - No spaceId legacy per-user folder (`space` undefined).
* - spaceId + resolver `space` set when visible; `{ forbidden: true }` when
* the resolver returns null. Never falls back to the user folder for an
* unresolvable spaceId.
*/
async function resolveSkillScope(req: Request): Promise<SkillScope> {
const spaceId = getSpaceId(req);
if (spaceId && opts.resolveSpaceFolder) {
const fc = await opts.resolveSpaceFolder(spaceId, getUser(req) as Express.User | undefined);
if (!fc) return { forbidden: true };
return { space: { rootDir: fc.rootDir, leafId: fc.leafId } };
}
return {};
}
/** Merged catalog (system + custom) for the resolved scope. */
function catalogFor(scope: SkillScope, userId: string): SkillEntry[] {
if (!('forbidden' in scope) && scope.space) {
return skillCatalog.getForFolder(scope.space.rootDir, scope.space.leafId);
}
return skillCatalog.getForUser(userId);
}
/** Body-only content for a named skill within the resolved scope. */
function contentFor(scope: SkillScope, name: string, userId: string): { content: string; dirPath: string | null } | null {
if (!('forbidden' in scope) && scope.space) {
return skillCatalog.getSkillContentForFolder(name, scope.space.rootDir, scope.space.leafId);
}
return skillCatalog.getSkillContent(name, userId);
}
/** Custom-skill base dir for the resolved scope (user-scope writes). */
function userSkillDirFor(scope: SkillScope, userId: string): string {
if (!('forbidden' in scope) && scope.space) {
return skillCatalog.getSkillDirForFolder(scope.space.rootDir, scope.space.leafId);
}
return skillCatalog.getUserSkillDir(userId);
}
/** Invalidate the user-scope cache for the resolved scope. */
function invalidateUserScope(scope: SkillScope, userId: string): void {
if (!('forbidden' in scope) && scope.space) {
skillCatalog.invalidateFolder(scope.space.rootDir, scope.space.leafId);
} else {
skillCatalog.invalidate(userId);
}
}
// JSON body parser for skills endpoints // JSON body parser for skills endpoints
app.use('/api/skills', express.json()); app.use('/api/skills', express.json());
@ -83,7 +165,7 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v
} }
// ── GET /api/skills ── list skills ────────────────────────────── // ── GET /api/skills ── list skills ──────────────────────────────
app.get('/api/skills', (req: Request, res: Response) => { app.get('/api/skills', async (req: Request, res: Response) => {
try { try {
const userId = getUserId(req); const userId = getUserId(req);
const scope = (req.query.scope as string) ?? 'all'; const scope = (req.query.scope as string) ?? 'all';
@ -93,7 +175,13 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v
return; return;
} }
const entries = skillCatalog.getForUser(userId); const skillScope = await resolveSkillScope(req);
if ('forbidden' in skillScope && skillScope.forbidden) {
res.status(404).json({ error: 'Space not found' });
return;
}
const entries = catalogFor(skillScope, userId);
const filtered = scope === 'all' const filtered = scope === 'all'
? entries ? entries
: entries.filter(e => e.source === scope); : entries.filter(e => e.source === scope);
@ -120,7 +208,7 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v
})); }));
// ── GET /api/skills/:name ── skill detail ────────────────────── // ── GET /api/skills/:name ── skill detail ──────────────────────
app.get('/api/skills/:name', (req: Request, res: Response) => { app.get('/api/skills/:name', async (req: Request, res: Response) => {
const { name } = req.params; const { name } = req.params;
if (!VALID_SKILL_NAME.test(name)) { if (!VALID_SKILL_NAME.test(name)) {
res.status(400).json({ error: 'Invalid skill name' }); res.status(400).json({ error: 'Invalid skill name' });
@ -131,8 +219,14 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v
const userId = getUserId(req); const userId = getUserId(req);
const scopeHint = req.query.scope as string | undefined; const scopeHint = req.query.scope as string | undefined;
const skillScope = await resolveSkillScope(req);
if ('forbidden' in skillScope && skillScope.forbidden) {
res.status(404).json({ error: 'Space not found' });
return;
}
// Find the entry matching name (and optional scope filter) // Find the entry matching name (and optional scope filter)
const entries = skillCatalog.getForUser(userId); const entries = catalogFor(skillScope, userId);
let entry: SkillEntry | undefined; let entry: SkillEntry | undefined;
if (scopeHint && ['system', 'user'].includes(scopeHint)) { if (scopeHint && ['system', 'user'].includes(scopeHint)) {
entry = entries.find(e => e.name === name && e.source === scopeHint); entry = entries.find(e => e.name === name && e.source === scopeHint);
@ -146,7 +240,7 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v
} }
// Read content via catalog // Read content via catalog
const contentResult = skillCatalog.getSkillContent(name, userId); const contentResult = contentFor(skillScope, name, userId);
const content = contentResult?.content ?? ''; const content = contentResult?.content ?? '';
// Read raw file for frontmatter // Read raw file for frontmatter
@ -225,10 +319,16 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v
const userId = getUserId(req); const userId = getUserId(req);
const skillScope = await resolveSkillScope(req);
if ('forbidden' in skillScope && skillScope.forbidden) {
res.status(404).json({ error: 'Space not found' });
return;
}
// Determine destination directory // Determine destination directory
const destDir = scope === 'system' const destDir = scope === 'system'
? skillCatalog.getSystemDir() ? skillCatalog.getSystemDir()
: skillCatalog.getUserSkillDir(userId); : userSkillDirFor(skillScope, userId);
// Check for existing skill (directory or flat file) // Check for existing skill (directory or flat file)
const destDirPath = join(destDir, name); const destDirPath = join(destDir, name);
@ -252,7 +352,7 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v
if (scope === 'system') { if (scope === 'system') {
skillCatalog.refreshSystem(); skillCatalog.refreshSystem();
} else { } else {
skillCatalog.invalidate(userId); invalidateUserScope(skillScope, userId);
} }
// Audit log // Audit log
@ -305,9 +405,16 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v
} }
const userId = getUserId(req); const userId = getUserId(req);
const skillScope = await resolveSkillScope(req);
if ('forbidden' in skillScope && skillScope.forbidden) {
res.status(404).json({ error: 'Space not found' });
return;
}
const baseDir = scope === 'system' const baseDir = scope === 'system'
? skillCatalog.getSystemDir() ? skillCatalog.getSystemDir()
: skillCatalog.getUserSkillDir(userId); : userSkillDirFor(skillScope, userId);
// Find the skill file: either flat file or directory with SKILL.md // Find the skill file: either flat file or directory with SKILL.md
let targetPath: string | null = null; let targetPath: string | null = null;
@ -363,7 +470,7 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v
if (scope === 'system') { if (scope === 'system') {
skillCatalog.refreshSystem(); skillCatalog.refreshSystem();
} else { } else {
skillCatalog.invalidate(userId); invalidateUserScope(skillScope, userId);
} }
// Audit log // Audit log
@ -405,9 +512,16 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v
try { try {
const userId = getUserId(req); const userId = getUserId(req);
const skillScope = await resolveSkillScope(req);
if ('forbidden' in skillScope && skillScope.forbidden) {
res.status(404).json({ error: 'Space not found' });
return;
}
const baseDir = scope === 'system' const baseDir = scope === 'system'
? skillCatalog.getSystemDir() ? skillCatalog.getSystemDir()
: skillCatalog.getUserSkillDir(userId); : userSkillDirFor(skillScope, userId);
// Find the skill: directory or flat file // Find the skill: directory or flat file
const dirPath = join(baseDir, name); const dirPath = join(baseDir, name);
@ -431,7 +545,7 @@ export function mountSkillsApi(app: Application, opts: MountSkillsApiOptions): v
if (scope === 'system') { if (scope === 'system') {
skillCatalog.refreshSystem(); skillCatalog.refreshSystem();
} else { } else {
skillCatalog.invalidate(userId); invalidateUserScope(skillScope, userId);
} }
// Audit log // Audit log

View File

@ -0,0 +1,236 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { Repository } from '../db/repository.js';
import { createSpaceApi } from './space-api.js';
import { spaceFilesDir } from '../spaces/paths.js';
type TestUser = { id: string; role: 'user' | 'admin'; orgIds: string[] };
function makeApp(repo: Repository, dataRoot: string, worktreeDir: string, user: TestUser) {
const app = express();
app.use((req, _res, next) => {
(req as any).user = user;
next();
});
app.use('/api/local/spaces', createSpaceApi({ repo, dataRoot, worktreeDir, authActive: true }));
return app;
}
describe('space-api calendar', () => {
let repo: Repository;
let dir: string;
let worktreeDir: string;
let dataRoot: string;
let spaceId: string;
const owner: TestUser = { id: 'user-1', role: 'user', orgIds: [] };
const admin: TestUser = { id: 'admin-1', role: 'admin', orgIds: [] };
const stranger: TestUser = { id: 'user-2', role: 'user', orgIds: [] };
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'space-cal-'));
dataRoot = join(dir, 'data', 'users');
worktreeDir = join(dir, 'wt');
repo = new Repository(join(dir, `${randomUUID()}.db`));
const space = await repo.createSpace({
kind: 'case', title: '案件', ownerId: 'user-1', visibility: 'private',
});
spaceId = space.id;
});
afterEach(() => {
repo.close();
rmSync(dir, { recursive: true, force: true });
});
function appAs(user: TestUser) {
return makeApp(repo, dataRoot, worktreeDir, user);
}
describe('auth matrix', () => {
it('owner can create an event (201)', async () => {
const res = await request(appAs(owner))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-07-01', title: 'mtg', time: '10:00' });
expect(res.status).toBe(201);
expect(res.body.title).toBe('mtg');
expect(res.body.createdBy).toBe('user');
expect(res.body.ownerId).toBe('user-1');
});
it('admin can read the month view (200)', async () => {
const res = await request(appAs(admin)).get(`/api/local/spaces/${spaceId}/calendar?month=2026-07&tz_offset=540`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('days');
expect(res.body).toHaveProperty('events');
});
it('non-member gets 404 on month view (private space invisible)', async () => {
const res = await request(appAs(stranger)).get(`/api/local/spaces/${spaceId}/calendar?month=2026-07`);
expect(res.status).toBe(404);
});
it('non-member gets 404 on event create', async () => {
const res = await request(appAs(stranger))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-07-01', title: 'x' });
expect(res.status).toBe(404);
});
});
describe('validation', () => {
it('bad month → 400', async () => {
const res = await request(appAs(owner)).get(`/api/local/spaces/${spaceId}/calendar?month=2026-7`);
expect(res.status).toBe(400);
});
it('missing title → 400', async () => {
const res = await request(appAs(owner))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-07-01' });
expect(res.status).toBe(400);
});
it('bad date → 400', async () => {
const res = await request(appAs(owner))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026/07/01', title: 't' });
expect(res.status).toBe(400);
});
it('bad end_date format → 400', async () => {
const res = await request(appAs(owner))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-07-01', end_date: '2026/07/03', title: 't' });
expect(res.status).toBe(400);
});
it('end_date before date → 400', async () => {
const res = await request(appAs(owner))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-07-05', end_date: '2026-07-01', title: 't' });
expect(res.status).toBe(400);
});
});
describe('multi-day events via HTTP', () => {
it('creates a span and surfaces it on every covered day in the month view', async () => {
const created = await request(appAs(owner))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-07-10', end_date: '2026-07-12', title: 'offsite' });
expect(created.status).toBe(201);
expect(created.body.endDate).toBe('2026-07-12');
const month = await request(appAs(owner)).get(`/api/local/spaces/${spaceId}/calendar?month=2026-07&tz_offset=540`);
expect(month.body.days['2026-07-10']?.eventCount).toBe(1);
expect(month.body.days['2026-07-11']?.eventCount).toBe(1);
expect(month.body.days['2026-07-12']?.eventCount).toBe(1);
expect(month.body.days['2026-07-13']).toBeUndefined();
});
it('PATCH end_date="" collapses a span back to a single day', async () => {
const created = await request(appAs(owner))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-07-10', end_date: '2026-07-12', title: 'x' });
const patched = await request(appAs(owner))
.patch(`/api/local/spaces/${spaceId}/calendar/events/${created.body.id}`)
.send({ end_date: '' });
expect(patched.status).toBe(200);
expect(patched.body.endDate).toBeNull();
});
});
describe('event CRUD via HTTP', () => {
it('create → patch → delete', async () => {
const created = await request(appAs(owner))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-07-01', title: 'orig' });
const id = created.body.id;
const patched = await request(appAs(owner))
.patch(`/api/local/spaces/${spaceId}/calendar/events/${id}`)
.send({ title: 'renamed', time: '09:30' });
expect(patched.status).toBe(200);
expect(patched.body.title).toBe('renamed');
expect(patched.body.time).toBe('09:30');
const del = await request(appAs(owner)).delete(`/api/local/spaces/${spaceId}/calendar/events/${id}`);
expect(del.status).toBe(204);
const month = await request(appAs(owner)).get(`/api/local/spaces/${spaceId}/calendar?month=2026-07`);
expect(month.body.events).toHaveLength(0);
});
});
describe('cross-space event id → 404', () => {
it('PATCH on an event from another space is 404', async () => {
const other = await repo.createSpace({ kind: 'case', title: 'B', ownerId: 'user-1', visibility: 'private' });
const ev = await repo.createCalendarEvent({ spaceId: other.id, date: '2026-07-01', title: 'inB' });
const res = await request(appAs(owner))
.patch(`/api/local/spaces/${spaceId}/calendar/events/${ev.id}`)
.send({ title: 'hijack' });
expect(res.status).toBe(404);
});
it('DELETE on an event from another space is 404', async () => {
const other = await repo.createSpace({ kind: 'case', title: 'B', ownerId: 'user-1', visibility: 'private' });
const ev = await repo.createCalendarEvent({ spaceId: other.id, date: '2026-07-01', title: 'inB' });
const res = await request(appAs(owner)).delete(`/api/local/spaces/${spaceId}/calendar/events/${ev.id}`);
expect(res.status).toBe(404);
});
});
describe('day endpoint', () => {
it('returns tasks + events + files for a local day', async () => {
// task created on local day 2026-07-02 (JST): pick a UTC time inside that day.
const task = await repo.createLocalTask({ title: 'dayTask', body: '', spaceId, ownerId: 'user-1' });
(repo as any).db
.prepare(`UPDATE local_tasks SET created_at = ? WHERE id = ?`)
.run('2026-07-01 23:30:00', task.id); // +540 → 2026-07-02 08:30
await repo.createCalendarEvent({ spaceId, date: '2026-07-02', title: 'evt' });
// place a file with an mtime on the same local day
const filesDir = spaceFilesDir(worktreeDir, spaceId);
mkdirSync(filesDir, { recursive: true });
const fp = join(filesDir, 'report.md');
writeFileSync(fp, 'hello');
const mtime = new Date('2026-07-02T03:00:00.000Z'); // +540 → 12:00 local 2026-07-02
utimesSync(fp, mtime, mtime);
// a file that should be excluded (under runs/)
const runsDir = join(filesDir, 'runs', '99');
mkdirSync(runsDir, { recursive: true });
const lf = join(runsDir, 'log.txt');
writeFileSync(lf, 'log');
utimesSync(lf, mtime, mtime);
// a file on a different day
const other = join(filesDir, 'old.md');
writeFileSync(other, 'x');
const otherMtime = new Date('2026-06-01T03:00:00.000Z');
utimesSync(other, otherMtime, otherMtime);
const res = await request(appAs(owner))
.get(`/api/local/spaces/${spaceId}/calendar/day?date=2026-07-02&tz_offset=540`);
expect(res.status).toBe(200);
expect(res.body.tasks.map((t: any) => t.title)).toEqual(['dayTask']);
expect(res.body.events.map((e: any) => e.title)).toEqual(['evt']);
const fileNames = res.body.files.map((f: any) => f.path);
expect(fileNames).toContain('report.md');
expect(fileNames).not.toContain('runs/99/log.txt');
expect(fileNames).not.toContain('old.md');
});
it('files is empty array when files dir missing', async () => {
const res = await request(appAs(owner))
.get(`/api/local/spaces/${spaceId}/calendar/day?date=2026-07-02&tz_offset=540`);
expect(res.status).toBe(200);
expect(res.body.files).toEqual([]);
});
it('bad date → 400', async () => {
const res = await request(appAs(owner)).get(`/api/local/spaces/${spaceId}/calendar/day?date=bad`);
expect(res.status).toBe(400);
});
});
});

View File

@ -0,0 +1,207 @@
// スペース招待リンク(再利用トークン)の API テスト。
//
// 発行/無効化は canManageSpace、参加は認証済みユーザー。組織非依存の招待経路で、
// Google 認証など組織を持たないユーザーでもリンクで参加できることを担保する。
// spec: docs/superpowers/specs/2026-06-19-space-invite-links-design.md
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Repository } from '../db/repository.js';
import { createSpaceApi, type SpaceApiDeps } from './space-api.js';
function user(id: string, role: 'user' | 'admin' = 'user', orgIds: string[] = []): Express.User {
return {
id,
email: `${id}@x.com`,
name: id,
avatarUrl: null,
role,
status: 'active',
orgIds,
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
} as unknown as Express.User;
}
describe('space invite-link API', () => {
let dir = '';
let repo: Repository;
let ownerId = '';
let editorId = '';
let strangerId = '';
let spaceId = '';
let worktreeDir = '';
function appAs(u: Express.User | null, authActive = true): express.Application {
const app = express();
if (u) {
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = u;
next();
});
}
const deps: SpaceApiDeps = {
repo,
dataRoot: join(dir, 'data', 'users'),
worktreeDir,
authActive,
};
app.use('/api/local/spaces', createSpaceApi(deps));
return app;
}
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'space-invite-'));
worktreeDir = join(dir, 'workspaces');
repo = new Repository(join(dir, 'db.sqlite'));
ownerId = repo.createUser({ email: 'owner@x.com', name: 'owner', role: 'user', status: 'active' }).id;
editorId = repo.createUser({ email: 'editor@x.com', name: 'editor', role: 'user', status: 'active' }).id;
strangerId = repo.createUser({ email: 'stranger@x.com', name: 'stranger', role: 'user', status: 'active' }).id;
const space = await repo.createSpace({ kind: 'case', title: '案件A', ownerId, visibility: 'private' });
spaceId = space.id;
await repo.addSpaceMember({ spaceId, userId: editorId, role: 'editor', invitedBy: ownerId });
});
afterEach(() => {
repo.close();
rmSync(dir, { recursive: true, force: true });
});
// ─── 発行 ───────────────────────────────────────────────
it('owner generates a reusable link (token + url + role)', async () => {
const res = await request(appAs(user(ownerId)))
.post(`/api/local/spaces/${spaceId}/invite`)
.send({ role: 'viewer' });
expect(res.status).toBe(201);
expect(res.body.invite.token).toBeTruthy();
expect(res.body.invite.url).toBe(`/ui/invite/${res.body.invite.token}`);
expect(res.body.invite.role).toBe('viewer');
expect(res.body.invite.valid).toBe(true);
});
it('regenerating replaces the old token (old token becomes invalid)', async () => {
const app = appAs(user(ownerId));
const first = (await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'viewer' })).body.invite.token;
const second = (await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'editor' })).body.invite.token;
expect(second).not.toBe(first);
// old token no longer previews
expect((await request(app).get(`/api/local/spaces/invite/${first}`)).status).toBe(404);
// new token previews with the new role
const prev = await request(app).get(`/api/local/spaces/invite/${second}`);
expect(prev.status).toBe(200);
expect(prev.body.role).toBe('editor');
});
it('GET returns the current invite, or null when none', async () => {
const app = appAs(user(ownerId));
expect((await request(app).get(`/api/local/spaces/${spaceId}/invite`)).body.invite).toBeNull();
await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'viewer' });
expect((await request(app).get(`/api/local/spaces/${spaceId}/invite`)).body.invite.role).toBe('viewer');
});
it('rejects invalid role and the owner role (no privilege escalation)', async () => {
const app = appAs(user(ownerId));
expect((await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'owner' })).status).toBe(400);
expect((await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'bogus' })).status).toBe(400);
});
it('rejects a non-positive expiresInDays', async () => {
const app = appAs(user(ownerId));
expect((await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'viewer', expiresInDays: 0 })).status).toBe(400);
expect((await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'viewer', expiresInDays: -3 })).status).toBe(400);
expect((await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'viewer', expiresInDays: 7 })).status).toBe(201);
});
// ─── 認可 ───────────────────────────────────────────────
it('non-manager member (editor) cannot generate → 403', async () => {
const res = await request(appAs(user(editorId)))
.post(`/api/local/spaces/${spaceId}/invite`)
.send({ role: 'viewer' });
expect(res.status).toBe(403);
});
it('non-member cannot generate → 404 (space not visible)', async () => {
const res = await request(appAs(user(strangerId)))
.post(`/api/local/spaces/${spaceId}/invite`)
.send({ role: 'viewer' });
expect(res.status).toBe(404);
});
it('admin (non-owner) can generate', async () => {
const adminId = repo.createUser({ email: 'a@x.com', name: 'a', role: 'admin', status: 'active' }).id;
const res = await request(appAs(user(adminId, 'admin')))
.post(`/api/local/spaces/${spaceId}/invite`)
.send({ role: 'editor' });
expect(res.status).toBe(201);
});
// ─── 参加 ───────────────────────────────────────────────
it('a stranger with a valid token joins as the link role, idempotently', async () => {
const ownerApp = appAs(user(ownerId));
const token = (await request(ownerApp).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'viewer' })).body.invite.token;
const strangerApp = appAs(user(strangerId));
// preview is visible to a non-member (must be logged in; here injected)
const prev = await request(strangerApp).get(`/api/local/spaces/invite/${token}`);
expect(prev.status).toBe(200);
expect(prev.body).toMatchObject({ spaceId, spaceTitle: '案件A', role: 'viewer' });
const accept = await request(strangerApp).post(`/api/local/spaces/invite/${token}/accept`);
expect(accept.status).toBe(200);
expect(accept.body).toMatchObject({ spaceId, alreadyMember: false });
expect(repo.getSpaceMemberRole(spaceId, strangerId)).toBe('viewer');
// accepting again is idempotent
const again = await request(strangerApp).post(`/api/local/spaces/invite/${token}/accept`);
expect(again.status).toBe(200);
expect(again.body.alreadyMember).toBe(true);
});
it('the owner accepting their own link is a no-op success (no member row)', async () => {
const ownerApp = appAs(user(ownerId));
const token = (await request(ownerApp).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'editor' })).body.invite.token;
const accept = await request(ownerApp).post(`/api/local/spaces/invite/${token}/accept`);
expect(accept.status).toBe(200);
expect(accept.body.alreadyMember).toBe(true);
expect(repo.getSpaceMemberRole(spaceId, ownerId)).toBeNull(); // owner has no member row
});
// ─── 無効化・無効トークン ────────────────────────────────
it('a revoked link no longer previews or accepts (404)', async () => {
const ownerApp = appAs(user(ownerId));
const token = (await request(ownerApp).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'viewer' })).body.invite.token;
expect((await request(ownerApp).delete(`/api/local/spaces/${spaceId}/invite`)).status).toBe(204);
const strangerApp = appAs(user(strangerId));
expect((await request(strangerApp).get(`/api/local/spaces/invite/${token}`)).status).toBe(404);
expect((await request(strangerApp).post(`/api/local/spaces/invite/${token}/accept`)).status).toBe(404);
expect(repo.getSpaceMemberRole(spaceId, strangerId)).toBeNull();
});
it('an unknown token previews/accepts as 404 (leaks no space info)', async () => {
const strangerApp = appAs(user(strangerId));
const prev = await request(strangerApp).get(`/api/local/spaces/invite/does-not-exist`);
expect(prev.status).toBe(404);
expect(prev.body.spaceTitle).toBeUndefined();
expect((await request(strangerApp).post(`/api/local/spaces/invite/does-not-exist/accept`)).status).toBe(404);
});
// ─── no-auth モード ──────────────────────────────────────
it('all invite endpoints are 404 in no-auth mode', async () => {
const app = appAs(null, false); // authActive=false → viewerOf = local admin, but invites disabled
expect((await request(app).get(`/api/local/spaces/${spaceId}/invite`)).status).toBe(404);
expect((await request(app).post(`/api/local/spaces/${spaceId}/invite`).send({ role: 'viewer' })).status).toBe(404);
expect((await request(app).delete(`/api/local/spaces/${spaceId}/invite`)).status).toBe(404);
expect((await request(app).get(`/api/local/spaces/invite/whatever`)).status).toBe(404);
expect((await request(app).post(`/api/local/spaces/invite/whatever/accept`)).status).toBe(404);
});
});

View File

@ -0,0 +1,420 @@
// Space members CRUD + user-picker + member-role write gates, exercised through
// the HTTP API with a real auth matrix.
//
// Roles under test: root owner_id, admin, member-with-owner-role, editor member,
// viewer member, non-member. Each of GET/POST/PATCH/DELETE asserts the right
// 200/201/204/400/403/404. Read-gate (getSpace → 404) is always the first gate;
// member read access is granted by buildVisibilityWhere (stage 1).
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Repository } from '../db/repository.js';
import { createSpaceApi, type SpaceApiDeps } from './space-api.js';
import { mountUsersPickableApi } from './users-api.js';
function user(id: string, role: 'user' | 'admin' = 'user', orgIds: string[] = []): Express.User {
return {
id,
email: `${id}@x.com`,
name: id,
avatarUrl: null,
role,
status: 'active',
orgIds,
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
} as unknown as Express.User;
}
describe('space members API (HTTP auth matrix)', () => {
let dir = '';
let repo: Repository;
// Persisted users (so getUserById resolves and pickable lists them).
let ownerId = '';
let adminId = '';
let memberOwnerId = '';
let editorId = '';
let viewerId = '';
let strangerId = '';
let spaceId = '';
function appAs(u: Express.User | null, authActive = true): express.Application {
const app = express();
if (u) {
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = u;
next();
});
}
const deps: SpaceApiDeps = {
repo,
dataRoot: join(dir, 'data', 'users'),
worktreeDir: join(dir, 'workspaces'),
authActive,
};
app.use('/api/local/spaces', createSpaceApi(deps));
return app;
}
function pickableAppAs(u: Express.User | null, authActive = true): express.Application {
const app = express();
if (u) {
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = u;
// requireAuth (auth.ts) calls req.isAuthenticated(), which passport
// installs at runtime. Stub it here (admin-api.test.ts precedent).
(req as unknown as { isAuthenticated: () => boolean }).isAuthenticated = () => true;
next();
});
}
mountUsersPickableApi(app, repo, authActive);
return app;
}
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'space-members-'));
repo = new Repository(join(dir, 'db.sqlite'));
ownerId = repo.createUser({ email: 'owner@x.com', name: 'owner', role: 'user', status: 'active' }).id;
adminId = repo.createUser({ email: 'admin@x.com', name: 'admin', role: 'admin', status: 'active' }).id;
memberOwnerId = repo.createUser({ email: 'mowner@x.com', name: 'mowner', role: 'user', status: 'active' }).id;
editorId = repo.createUser({ email: 'editor@x.com', name: 'editor', role: 'user', status: 'active' }).id;
viewerId = repo.createUser({ email: 'viewer@x.com', name: 'viewer', role: 'user', status: 'active' }).id;
strangerId = repo.createUser({ email: 'stranger@x.com', name: 'stranger', role: 'user', status: 'active' }).id;
const space = await repo.createSpace({ kind: 'case', title: '案件', ownerId, visibility: 'private' });
spaceId = space.id;
await repo.addSpaceMember({ spaceId, userId: memberOwnerId, role: 'owner', invitedBy: ownerId });
await repo.addSpaceMember({ spaceId, userId: editorId, role: 'editor', invitedBy: ownerId });
await repo.addSpaceMember({ spaceId, userId: viewerId, role: 'viewer', invitedBy: ownerId });
});
afterEach(() => {
repo.close();
rmSync(dir, { recursive: true, force: true });
});
// helper express.User shortcuts
const owner = () => user(ownerId);
const admin = () => user(adminId, 'admin');
const mowner = () => user(memberOwnerId);
const editor = () => user(editorId);
const viewer = () => user(viewerId);
const stranger = () => user(strangerId);
// ── GET /:id/members ─────────────────────────────────────────────────────────
it('GET members: owner sees synthesized owner row + members', async () => {
const res = await request(appAs(owner())).get(`/api/local/spaces/${spaceId}/members`);
expect(res.status).toBe(200);
const byId = Object.fromEntries(res.body.map((m: any) => [m.userId, m]));
expect(byId[ownerId]).toMatchObject({ role: 'owner', isOwner: true, name: 'owner' });
expect(byId[memberOwnerId]).toMatchObject({ role: 'owner', isOwner: false });
expect(byId[editorId]).toMatchObject({ role: 'editor', isOwner: false });
expect(byId[viewerId]).toMatchObject({ role: 'viewer', isOwner: false });
// owner row appears exactly once (de-dupe)
expect(res.body.filter((m: any) => m.userId === ownerId)).toHaveLength(1);
});
it('GET members: admin (non-member) can read', async () => {
const res = await request(appAs(admin())).get(`/api/local/spaces/${spaceId}/members`);
expect(res.status).toBe(200);
expect(res.body.length).toBe(4);
});
it('GET members: editor + viewer members can read', async () => {
for (const u of [editor(), viewer()]) {
const res = await request(appAs(u)).get(`/api/local/spaces/${spaceId}/members`);
expect(res.status).toBe(200);
}
});
it('GET members: non-member → 404 (read-gate)', async () => {
const res = await request(appAs(stranger())).get(`/api/local/spaces/${spaceId}/members`);
expect(res.status).toBe(404);
});
it('GET members: owner de-dupes when owner_id also has a member row (owner wins)', async () => {
await repo.addSpaceMember({ spaceId, userId: ownerId, role: 'editor', invitedBy: ownerId });
const res = await request(appAs(owner())).get(`/api/local/spaces/${spaceId}/members`);
expect(res.status).toBe(200);
const ownerRows = res.body.filter((m: any) => m.userId === ownerId);
expect(ownerRows).toHaveLength(1);
expect(ownerRows[0]).toMatchObject({ role: 'owner', isOwner: true });
});
// ── POST /:id/members ────────────────────────────────────────────────────────
it('POST members: owner can add a new member', async () => {
const res = await request(appAs(owner()))
.post(`/api/local/spaces/${spaceId}/members`)
.send({ userId: strangerId, role: 'editor' });
expect(res.status).toBe(201);
expect(repo.getSpaceMemberRole(spaceId, strangerId)).toBe('editor');
});
it('POST members: admin can add', async () => {
const res = await request(appAs(admin()))
.post(`/api/local/spaces/${spaceId}/members`)
.send({ userId: strangerId, role: 'viewer' });
expect(res.status).toBe(201);
});
it('POST members: member-with-owner-role can add', async () => {
const res = await request(appAs(mowner()))
.post(`/api/local/spaces/${spaceId}/members`)
.send({ userId: strangerId, role: 'viewer' });
expect(res.status).toBe(201);
});
it('POST members: editor member CANNOT add → 403', async () => {
const res = await request(appAs(editor()))
.post(`/api/local/spaces/${spaceId}/members`)
.send({ userId: strangerId, role: 'viewer' });
expect(res.status).toBe(403);
});
it('POST members: viewer member CANNOT add → 403', async () => {
const res = await request(appAs(viewer()))
.post(`/api/local/spaces/${spaceId}/members`)
.send({ userId: strangerId, role: 'viewer' });
expect(res.status).toBe(403);
});
it('POST members: non-member → 404 (read-gate before 403)', async () => {
const res = await request(appAs(stranger()))
.post(`/api/local/spaces/${spaceId}/members`)
.send({ userId: adminId, role: 'viewer' });
expect(res.status).toBe(404);
});
it('POST members: adding owner_id as a member → 400', async () => {
const res = await request(appAs(owner()))
.post(`/api/local/spaces/${spaceId}/members`)
.send({ userId: ownerId, role: 'editor' });
expect(res.status).toBe(400);
});
it('POST members: unknown userId → 400', async () => {
const res = await request(appAs(owner()))
.post(`/api/local/spaces/${spaceId}/members`)
.send({ userId: 'nope-not-a-user', role: 'editor' });
expect(res.status).toBe(400);
});
it('POST members: bad role → 400', async () => {
const res = await request(appAs(owner()))
.post(`/api/local/spaces/${spaceId}/members`)
.send({ userId: strangerId, role: 'superuser' });
expect(res.status).toBe(400);
});
it('POST members: idempotent add upserts the role', async () => {
await request(appAs(owner()))
.post(`/api/local/spaces/${spaceId}/members`)
.send({ userId: strangerId, role: 'viewer' });
const res = await request(appAs(owner()))
.post(`/api/local/spaces/${spaceId}/members`)
.send({ userId: strangerId, role: 'editor' });
expect(res.status).toBe(201);
expect(repo.getSpaceMemberRole(spaceId, strangerId)).toBe('editor');
});
// ── PATCH /:id/members/:userId ─────────────────────────────────────────────────
it('PATCH member role: owner can change', async () => {
const res = await request(appAs(owner()))
.patch(`/api/local/spaces/${spaceId}/members/${editorId}`)
.send({ role: 'viewer' });
expect(res.status).toBe(200);
expect(repo.getSpaceMemberRole(spaceId, editorId)).toBe('viewer');
});
it('PATCH member role: admin + member-owner can change', async () => {
expect((await request(appAs(admin())).patch(`/api/local/spaces/${spaceId}/members/${editorId}`).send({ role: 'viewer' })).status).toBe(200);
expect((await request(appAs(mowner())).patch(`/api/local/spaces/${spaceId}/members/${viewerId}`).send({ role: 'editor' })).status).toBe(200);
});
it('PATCH member role: editor + viewer members → 403', async () => {
expect((await request(appAs(editor())).patch(`/api/local/spaces/${spaceId}/members/${viewerId}`).send({ role: 'editor' })).status).toBe(403);
expect((await request(appAs(viewer())).patch(`/api/local/spaces/${spaceId}/members/${editorId}`).send({ role: 'viewer' })).status).toBe(403);
});
it('PATCH member role: non-member → 404', async () => {
const res = await request(appAs(stranger()))
.patch(`/api/local/spaces/${spaceId}/members/${editorId}`)
.send({ role: 'viewer' });
expect(res.status).toBe(404);
});
it('PATCH member role: not-a-member target → 404', async () => {
const res = await request(appAs(owner()))
.patch(`/api/local/spaces/${spaceId}/members/${strangerId}`)
.send({ role: 'viewer' });
expect(res.status).toBe(404);
});
it('PATCH member role: bad role → 400', async () => {
const res = await request(appAs(owner()))
.patch(`/api/local/spaces/${spaceId}/members/${editorId}`)
.send({ role: 'god' });
expect(res.status).toBe(400);
});
// ── DELETE /:id/members/:userId ────────────────────────────────────────────────
it('DELETE member: owner can remove', async () => {
const res = await request(appAs(owner())).delete(`/api/local/spaces/${spaceId}/members/${editorId}`);
expect(res.status).toBe(204);
expect(repo.getSpaceMemberRole(spaceId, editorId)).toBeNull();
});
it('DELETE member: admin + member-owner can remove', async () => {
expect((await request(appAs(admin())).delete(`/api/local/spaces/${spaceId}/members/${editorId}`)).status).toBe(204);
expect((await request(appAs(mowner())).delete(`/api/local/spaces/${spaceId}/members/${viewerId}`)).status).toBe(204);
});
it('DELETE member: editor cannot remove ANOTHER member → 403', async () => {
const res = await request(appAs(editor())).delete(`/api/local/spaces/${spaceId}/members/${viewerId}`);
expect(res.status).toBe(403);
});
it('DELETE member: a member CAN remove THEMSELVES (self-removal)', async () => {
const res = await request(appAs(editor())).delete(`/api/local/spaces/${spaceId}/members/${editorId}`);
expect(res.status).toBe(204);
expect(repo.getSpaceMemberRole(spaceId, editorId)).toBeNull();
});
it('DELETE member: viewer CAN remove themselves', async () => {
const res = await request(appAs(viewer())).delete(`/api/local/spaces/${spaceId}/members/${viewerId}`);
expect(res.status).toBe(204);
});
it('DELETE member: non-member → 404 (read-gate)', async () => {
const res = await request(appAs(stranger())).delete(`/api/local/spaces/${spaceId}/members/${editorId}`);
expect(res.status).toBe(404);
});
it('DELETE member: not-a-member target (manager) → 404', async () => {
const res = await request(appAs(owner())).delete(`/api/local/spaces/${spaceId}/members/${strangerId}`);
expect(res.status).toBe(404);
});
// ── GET /api/users/pickable ────────────────────────────────────────────────────
it('pickable: authed non-admin → 200 with active SAME-ORG users only', async () => {
// owner + viewer share org "acme"; add a disabled + pending acme user (excluded),
// and an active user in a DIFFERENT org "other" (excluded by org scoping).
repo.replaceUserGiteaOrgs(ownerId, [{ orgId: 'acme', orgName: 'Acme' }]);
repo.replaceUserGiteaOrgs(viewerId, [{ orgId: 'acme', orgName: 'Acme' }]);
const disabled = repo.createUser({ email: 'dis@x.com', name: 'dis', role: 'user', status: 'disabled' }).id;
const pending = repo.createUser({ email: 'pend@x.com', name: 'pend', role: 'user', status: 'pending' }).id;
repo.replaceUserGiteaOrgs(disabled, [{ orgId: 'acme', orgName: 'Acme' }]);
repo.replaceUserGiteaOrgs(pending, [{ orgId: 'acme', orgName: 'Acme' }]);
const otherOrgUser = repo.createUser({ email: 'other@x.com', name: 'other', role: 'user', status: 'active' }).id;
repo.replaceUserGiteaOrgs(otherOrgUser, [{ orgId: 'other', orgName: 'Other' }]);
const res = await request(pickableAppAs(user(viewerId, 'user', ['acme']))).get('/api/users/pickable');
expect(res.status).toBe(200);
const ids = res.body.map((u: any) => u.id);
expect(ids).toContain(ownerId); // same org → present
expect(ids).toContain(viewerId); // requester is in acme → present
expect(ids).not.toContain(otherOrgUser); // different org → absent
expect(ids).not.toContain(disabled); // inactive → absent
expect(ids).not.toContain(pending); // inactive → absent
// minimal shape — ONLY these keys (no role/status/token leak; regression guard)
expect(Object.keys(res.body[0]).sort()).toEqual(['avatarUrl', 'email', 'id', 'name']);
});
it('pickable: local-org membership also counts as a shared org', async () => {
const org = repo.createLocalOrg('Team', ownerId);
repo.addOrgMember(org.id, ownerId, 'member');
repo.addOrgMember(org.id, editorId, 'member');
const res = await request(pickableAppAs(user(ownerId, 'user', [org.id]))).get('/api/users/pickable');
expect(res.status).toBe(200);
const ids = res.body.map((u: any) => u.id);
expect(ids).toContain(editorId);
expect(ids).not.toContain(strangerId); // not in the local org
});
it('pickable: regular requester with NO orgs → [] (privacy: no full directory leak)', async () => {
const res = await request(pickableAppAs(user(strangerId, 'user', []))).get('/api/users/pickable');
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('pickable: admin sees ALL active users regardless of org', async () => {
repo.replaceUserGiteaOrgs(ownerId, [{ orgId: 'acme', orgName: 'Acme' }]);
const res = await request(pickableAppAs(admin())).get('/api/users/pickable');
expect(res.status).toBe(200);
const ids = res.body.map((u: any) => u.id);
// admin has no orgs but still sees everyone (orgless owner included)
expect(ids).toContain(ownerId);
expect(ids).toContain(editorId);
expect(ids).toContain(strangerId);
expect(Object.keys(res.body[0]).sort()).toEqual(['avatarUrl', 'email', 'id', 'name']);
});
it('pickable: no-auth mode → []', async () => {
const res = await request(pickableAppAs(null, false)).get('/api/users/pickable');
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
// ── GET /:id/members email masking (PII) ────────────────────────────────────
// public/org スペースは非メンバーにも一覧が見える。email は資格者(admin/owner/
// メンバー)にだけ返し、それ以外には name+avatar のみ(収集ベクター対策)。
it('GET members: non-member viewer of a PUBLIC space sees names but NOT emails', async () => {
const pub = await repo.createSpace({ kind: 'case', title: '公開', ownerId, visibility: 'public' });
await repo.addSpaceMember({ spaceId: pub.id, userId: editorId, role: 'editor', invitedBy: ownerId });
// stranger (non-member) can READ the public space's member list...
const sres = await request(appAs(stranger())).get(`/api/local/spaces/${pub.id}/members`);
expect(sres.status).toBe(200);
const sByName = Object.fromEntries(sres.body.map((m: any) => [m.name, m]));
expect(sByName['owner']).toBeTruthy();
expect(sByName['editor']).toBeTruthy();
// ...but every email is masked to null for the non-member.
expect(sres.body.every((m: any) => m.email === null)).toBe(true);
// a member (editor) DOES get emails.
const eres = await request(appAs(editor())).get(`/api/local/spaces/${pub.id}/members`);
expect(eres.status).toBe(200);
const eByName = Object.fromEntries(eres.body.map((m: any) => [m.name, m]));
expect(eByName['owner'].email).toBe('owner@x.com');
expect(eByName['editor'].email).toBe('editor@x.com');
});
// ── myRole閲覧者のメンバーロール─ クライアントの canEditFiles 判定に使う ──────────
it('GET /:id includes myRole for each member role', async () => {
const cases: [Express.User, string | null][] = [
[editor(), 'editor'],
[viewer(), 'viewer'],
[mowner(), 'owner'],
];
for (const [u, expected] of cases) {
const res = await request(appAs(u)).get(`/api/local/spaces/${spaceId}`);
expect(res.status).toBe(200);
expect(res.body.myRole).toBe(expected);
}
});
it('GET /:id returns myRole null for the root owner (not a member row)', async () => {
const res = await request(appAs(owner())).get(`/api/local/spaces/${spaceId}`);
expect(res.status).toBe(200);
expect(res.body.myRole).toBeNull();
});
it('GET / (list) tags each space with the viewer myRole', async () => {
const res = await request(appAs(editor())).get('/api/local/spaces');
expect(res.status).toBe(200);
const mine = res.body.find((s: any) => s.id === spaceId);
expect(mine?.myRole).toBe('editor');
});
});

View File

@ -0,0 +1,411 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { Repository } from '../db/repository.js';
import { createSpaceApi } from './space-api.js';
function makeApp(repo: Repository, dataRoot: string, worktreeDir: string) {
const app = express();
app.use(express.json());
// テスト用の固定ユーザーを注入(認証 OFF 環境の synthetic user と同形)
app.use((req, _res, next) => {
(req as any).user = { id: 'user-1', role: 'user', orgIds: [] };
next();
});
app.use('/api/local/spaces', createSpaceApi({ repo, dataRoot, worktreeDir, authActive: false }));
return app;
}
// 本番(修正後の server.tsと同形: グローバル express.json() を付けずに mount する。
// これでルート自前の json parser最大 50mbだけが効くことを検証できる。
// 旧バグでは default 100kb のグローバル json が body を先に消費し >100kb で 413 になった。
function makeAppNoGlobalJson(repo: Repository, dataRoot: string, worktreeDir: string) {
const app = express();
app.use((req, _res, next) => {
(req as any).user = { id: 'user-1', role: 'user', orgIds: [] };
next();
});
app.use('/api/local/spaces', createSpaceApi({ repo, dataRoot, worktreeDir, authActive: false }));
return app;
}
describe('space-api', () => {
let repo: Repository; let dir: string; let app: express.Express;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'space-api-'));
repo = new Repository(join(dir, `${randomUUID()}.db`));
// dataRoot は本番同様 userFolderRootdata/usersを渡す。space-api は
// dirname(dataRoot)=data を scaffold に渡すので実体は data/spaces/{id} になる。
app = makeApp(repo, join(dir, 'data', 'users'), join(dir, 'wt'));
});
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('GET returns the auto-created personal space', async () => {
const res = await request(app).get('/api/local/spaces');
expect(res.status).toBe(200);
expect(res.body.some((s: any) => s.kind === 'personal')).toBe(true);
});
it('POST creates a case space and scaffolds its dirs', async () => {
const res = await request(app).post('/api/local/spaces').send({ title: '案件A' });
expect(res.status).toBe(201);
expect(res.body.kind).toBe('case');
expect(res.body.ownerId).toBe('user-1');
expect(existsSync(join(dir, 'data', 'spaces', res.body.id, 'AGENTS.md'))).toBe(true);
expect(existsSync(join(dir, 'wt', 'space', res.body.id, 'files'))).toBe(true);
});
it('PATCH updates title; POST archive archives', async () => {
const created = (await request(app).post('/api/local/spaces').send({ title: 'old' })).body;
const patched = await request(app).patch(`/api/local/spaces/${created.id}`).send({ title: 'new' });
expect(patched.body.title).toBe('new');
const archived = await request(app).post(`/api/local/spaces/${created.id}/archive`);
expect(archived.status).toBe(200);
const list = (await request(app).get('/api/local/spaces')).body;
expect(list.find((s: any) => s.id === created.id)).toBeUndefined(); // archived hidden by default
});
it('lists + serves files from the space persistent workspace and rejects traversal', async () => {
const created = (await request(app).post('/api/local/spaces').send({ title: '案件F' })).body;
const filesDir = join(dir, 'wt', 'space', created.id, 'files');
writeFileSync(join(filesDir, 'note.md'), '# hello space');
// list
const list = await request(app).get(`/api/local/spaces/${created.id}/files`);
expect(list.status).toBe(200);
expect(list.body.basePath).toBe('space');
expect(list.body.entries.some((e: any) => e.name === 'note.md' && e.kind === 'file')).toBe(true);
// raw content
const raw = await request(app).get(`/api/local/spaces/${created.id}/files/raw?path=note.md`);
expect(raw.status).toBe(200);
expect(raw.text).toContain('# hello space');
// text content endpoint
const content = await request(app).get(`/api/local/spaces/${created.id}/files/content?path=note.md`);
expect(content.status).toBe(200);
expect(content.text).toContain('# hello space');
// path traversal is rejected (confined to spaceFilesDir)
const escape = await request(app).get(`/api/local/spaces/${created.id}/files?path=../../../../etc`);
expect(escape.status).toBe(400);
expect(escape.body.error).toBe('Path escapes workspace');
const escapeRaw = await request(app).get(`/api/local/spaces/${created.id}/files/raw?path=..%2F..%2F..%2Fpasswd`);
expect(escapeRaw.status).toBe(400);
});
it('returns 404 (not 500) for a missing file/dir — e.g. source/index.jsonl before any source exists', async () => {
const created = (await request(app).post('/api/local/spaces').send({ title: '案件Z' })).body;
// content of a file that does not exist (the Source Library index of an
// empty space) must be a quiet 404 the frontend treats as "no content",
// not a 500 that floods the log with ERROR.
const content = await request(app).get(`/api/local/spaces/${created.id}/files/content?path=source/index.jsonl`);
expect(content.status).toBe(404);
// raw of a missing file → 404 too
const raw = await request(app).get(`/api/local/spaces/${created.id}/files/raw?path=output/nope.png`);
expect(raw.status).toBe(404);
// listing a missing subdir → 404 (root is auto-created, subdirs are not)
const list = await request(app).get(`/api/local/spaces/${created.id}/files?path=output/missing`);
expect(list.status).toBe(404);
});
it('serves trusted HTML (no CSP sandbox) to a non-owner viewer of a public space', async () => {
// user-1 owns a PUBLIC space with an HTML app inside it.
const created = (await request(app)
.post('/api/local/spaces')
.send({ title: '案件T', visibility: 'public' })).body;
const filesDir = join(dir, 'wt', 'space', created.id, 'files');
writeFileSync(join(filesDir, 'app.html'), '<h1>hi</h1>');
// A different, authenticated viewer (auth ON) opens it with trusted=1.
const viewerApp = express();
viewerApp.use((req, _res, next) => {
(req as any).user = { id: 'user-2', role: 'user', orgIds: [] };
next();
});
viewerApp.use(
'/api/local/spaces',
createSpaceApi({
repo,
dataRoot: join(dir, 'data', 'users'),
worktreeDir: join(dir, 'wt'),
authActive: true,
}),
);
const res = await request(viewerApp).get(
`/api/local/spaces/${created.id}/files/raw?path=app.html&trusted=1`,
);
expect(res.status).toBe(200);
expect(res.headers['content-security-policy']).toBeUndefined();
expect(res.headers['content-type']).toContain('html');
});
it('still sandboxes trusted HTML for an unauthenticated request when auth is ON', async () => {
const created = (await request(app)
.post('/api/local/spaces')
.send({ title: '案件U', visibility: 'public' })).body;
const filesDir = join(dir, 'wt', 'space', created.id, 'files');
writeFileSync(join(filesDir, 'app.html'), '<h1>hi</h1>');
const anonApp = express();
anonApp.use(
'/api/local/spaces',
createSpaceApi({
repo,
dataRoot: join(dir, 'data', 'users'),
worktreeDir: join(dir, 'wt'),
authActive: true,
}),
);
const res = await request(anonApp).get(
`/api/local/spaces/${created.id}/files/raw?path=app.html&trusted=1`,
);
expect(res.status).toBe(200);
expect(res.headers['content-security-policy']).toBe('sandbox');
});
it('file window excludes logs/, .conflict/ and dotfiles (plan 5, Phase C)', async () => {
const created = (await request(app).post('/api/local/spaces').send({ title: '案件N' })).body;
const filesDir = join(dir, 'wt', 'space', created.id, 'files');
mkdirSync(join(filesDir, 'logs'), { recursive: true });
mkdirSync(join(filesDir, '.conflict'), { recursive: true });
mkdirSync(join(filesDir, 'output'), { recursive: true });
writeFileSync(join(filesDir, '.hidden'), 'secret');
writeFileSync(join(filesDir, 'output', 'x.txt'), 'visible');
const list = await request(app).get(`/api/local/spaces/${created.id}/files`);
expect(list.status).toBe(200);
const names = list.body.entries.map((e: any) => e.name);
expect(names).toContain('output');
expect(names).not.toContain('logs');
expect(names).not.toContain('.conflict');
expect(names).not.toContain('.hidden');
});
it('returns 404 for files of a non-existent / non-visible space', async () => {
const res = await request(app).get('/api/local/spaces/does-not-exist/files');
expect(res.status).toBe(404);
});
const b64 = (s: string) => Buffer.from(s, 'utf-8').toString('base64');
it('uploads a file into a viewable space and writes it to disk', async () => {
const created = (await request(app).post('/api/local/spaces').send({ title: '案件U' })).body;
const res = await request(app)
.post(`/api/local/spaces/${created.id}/files/upload`)
.send({ path: '', files: [{ name: 'a.txt', contentBase64: b64('hi') }] });
expect(res.status).toBe(200);
expect(res.body.uploaded).toEqual([{ name: 'a.txt', path: 'a.txt' }]);
const abs = join(dir, 'wt', 'space', created.id, 'files', 'a.txt');
expect(existsSync(abs)).toBe(true);
expect(readFileSync(abs, 'utf-8')).toBe('hi');
});
it('accepts a >150kb upload body when no global json parser is mounted (P1 413 regression)', async () => {
// server.ts と同形: グローバル json 無し。ルート自前の 50mb parser が効くはず。
const noGlobal = makeAppNoGlobalJson(repo, join(dir, 'data', 'users'), join(dir, 'wt'));
const created = (await request(noGlobal).post('/api/local/spaces').send({ title: '案件L' })).body;
// ~200kb の生データ → base64 で ~270kb の JSON body。旧バグでは 413 になる。
const big = 'a'.repeat(200 * 1024);
const res = await request(noGlobal)
.post(`/api/local/spaces/${created.id}/files/upload`)
.send({ path: '', files: [{ name: 'big.txt', contentBase64: b64(big) }] });
expect(res.status).toBe(200);
expect(res.body.uploaded).toEqual([{ name: 'big.txt', path: 'big.txt' }]);
const abs = join(dir, 'wt', 'space', created.id, 'files', 'big.txt');
expect(existsSync(abs)).toBe(true);
expect(readFileSync(abs, 'utf-8').length).toBe(200 * 1024);
});
it('renames a duplicate upload to "name (2).ext" without overwriting the original', async () => {
const created = (await request(app).post('/api/local/spaces').send({ title: '案件D' })).body;
await request(app)
.post(`/api/local/spaces/${created.id}/files/upload`)
.send({ path: '', files: [{ name: 'dup.txt', contentBase64: b64('first') }] });
const res = await request(app)
.post(`/api/local/spaces/${created.id}/files/upload`)
.send({ path: '', files: [{ name: 'dup.txt', contentBase64: b64('second') }] });
expect(res.status).toBe(200);
expect(res.body.uploaded).toEqual([{ name: 'dup (2).txt', path: 'dup (2).txt' }]);
const filesDir = join(dir, 'wt', 'space', created.id, 'files');
expect(readFileSync(join(filesDir, 'dup.txt'), 'utf-8')).toBe('first');
expect(readFileSync(join(filesDir, 'dup (2).txt'), 'utf-8')).toBe('second');
});
it('rejects an upload whose path escapes the workspace with 400', async () => {
const created = (await request(app).post('/api/local/spaces').send({ title: '案件E' })).body;
const res = await request(app)
.post(`/api/local/spaces/${created.id}/files/upload`)
.send({ path: '../escape', files: [{ name: 'x.txt', contentBase64: b64('x') }] });
expect(res.status).toBe(400);
});
it('returns 404 when uploading to a space not visible to the viewer', async () => {
// user-1 (注入された viewer) には見えない、別 owner の private スペース。
const other = await repo.createSpace({
kind: 'case',
title: 'secret',
description: '',
ownerId: 'user-2',
visibility: 'private',
visibilityScopeOrgId: null,
brandColor: null,
});
const res = await request(app)
.post(`/api/local/spaces/${other.id}/files/upload`)
.send({ path: '', files: [{ name: 'x.txt', contentBase64: b64('x') }] });
expect(res.status).toBe(404);
});
// ─── ファイル削除POST /files/delete────────────────────────────────
it('deletes a file (single path) and the file is gone afterward', async () => {
const created = (await request(app).post('/api/local/spaces').send({ title: '削除案件' })).body;
await request(app)
.post(`/api/local/spaces/${created.id}/files/upload`)
.send({ path: '', files: [{ name: 'gone.txt', contentBase64: b64('bye') }] });
const filesDir = join(dir, 'wt', 'space', created.id, 'files');
expect(existsSync(join(filesDir, 'gone.txt'))).toBe(true);
const res = await request(app)
.post(`/api/local/spaces/${created.id}/files/delete`)
.send({ path: 'gone.txt' });
expect(res.status).toBe(200);
expect(res.body.deleted).toEqual(['gone.txt']);
expect(existsSync(join(filesDir, 'gone.txt'))).toBe(false);
});
it('deletes multiple files in one batch (paths array)', async () => {
const created = (await request(app).post('/api/local/spaces').send({ title: '一括削除' })).body;
await request(app)
.post(`/api/local/spaces/${created.id}/files/upload`)
.send({ path: '', files: [
{ name: 'a.txt', contentBase64: b64('a') },
{ name: 'b.txt', contentBase64: b64('b') },
{ name: 'keep.txt', contentBase64: b64('k') },
] });
const filesDir = join(dir, 'wt', 'space', created.id, 'files');
const res = await request(app)
.post(`/api/local/spaces/${created.id}/files/delete`)
.send({ paths: ['a.txt', 'b.txt'] });
expect(res.status).toBe(200);
expect(res.body.deleted.sort()).toEqual(['a.txt', 'b.txt']);
expect(existsSync(join(filesDir, 'a.txt'))).toBe(false);
expect(existsSync(join(filesDir, 'b.txt'))).toBe(false);
expect(existsSync(join(filesDir, 'keep.txt'))).toBe(true);
});
it('skips (does not error) on a missing path — idempotent', async () => {
const created = (await request(app).post('/api/local/spaces').send({ title: '冪等' })).body;
const res = await request(app)
.post(`/api/local/spaces/${created.id}/files/delete`)
.send({ paths: ['nope.txt'] });
expect(res.status).toBe(200);
expect(res.body.deleted).toEqual([]);
expect(res.body.skipped).toEqual(['nope.txt']);
});
it('rejects a delete whose path escapes the workspace with 400', async () => {
const created = (await request(app).post('/api/local/spaces').send({ title: 'traversal' })).body;
const res = await request(app)
.post(`/api/local/spaces/${created.id}/files/delete`)
.send({ paths: ['../../etc/passwd'] });
expect(res.status).toBe(400);
});
it('400 when neither path nor paths is provided', async () => {
const created = (await request(app).post('/api/local/spaces').send({ title: 'empty' })).body;
const res = await request(app)
.post(`/api/local/spaces/${created.id}/files/delete`)
.send({});
expect(res.status).toBe(400);
});
// メンバーロール起点の権限テスト。space_members.user_id は users(id) に FK するので、
// 招待するメンバーと owner を実ユーザーとして作成し、そのメンバーを viewer として注入する
// 専用 app を組むauthActive=true
function makeAppAs(userId: string) {
const a = express();
a.use((req, _res, next) => { (req as any).user = { id: userId, role: 'user', orgIds: [] }; next(); });
a.use('/api/local/spaces', createSpaceApi({
repo, dataRoot: join(dir, 'data', 'users'), worktreeDir: join(dir, 'wt'), authActive: true,
}));
return a;
}
it('viewer-role member cannot delete (403)', async () => {
const owner = repo.createUser({ email: 'o1@b.com', name: 'Owner', role: 'user', status: 'active' });
const member = repo.createUser({ email: 'v1@b.com', name: 'Viewer', role: 'user', status: 'active' });
const owned = await repo.createSpace({
kind: 'case', title: '閲覧者テスト', description: '', ownerId: owner.id,
visibility: 'public', visibilityScopeOrgId: null, brandColor: null,
});
const filesDir = join(dir, 'wt', 'space', owned.id, 'files');
mkdirSync(filesDir, { recursive: true });
writeFileSync(join(filesDir, 'locked.txt'), 'x');
await repo.addSpaceMember({ spaceId: owned.id, userId: member.id, role: 'viewer', invitedBy: owner.id });
const res = await request(makeAppAs(member.id))
.post(`/api/local/spaces/${owned.id}/files/delete`)
.send({ paths: ['locked.txt'] });
expect(res.status).toBe(403);
expect(existsSync(join(filesDir, 'locked.txt'))).toBe(true);
});
it('editor-role member can delete a file', async () => {
const owner = repo.createUser({ email: 'o2@b.com', name: 'Owner2', role: 'user', status: 'active' });
const member = repo.createUser({ email: 'e1@b.com', name: 'Editor', role: 'user', status: 'active' });
const owned = await repo.createSpace({
kind: 'case', title: '編集者テスト', description: '', ownerId: owner.id,
visibility: 'public', visibilityScopeOrgId: null, brandColor: null,
});
const filesDir = join(dir, 'wt', 'space', owned.id, 'files');
mkdirSync(filesDir, { recursive: true });
writeFileSync(join(filesDir, 'edit.txt'), 'x');
await repo.addSpaceMember({ spaceId: owned.id, userId: member.id, role: 'editor', invitedBy: owner.id });
const res = await request(makeAppAs(member.id))
.post(`/api/local/spaces/${owned.id}/files/delete`)
.send({ paths: ['edit.txt'] });
expect(res.status).toBe(200);
expect(res.body.deleted).toEqual(['edit.txt']);
expect(existsSync(join(filesDir, 'edit.txt'))).toBe(false);
});
it('returns 404 when deleting in a space not visible to the viewer', async () => {
const other = await repo.createSpace({
kind: 'case',
title: 'secret-del',
description: '',
ownerId: 'user-2',
visibility: 'private',
visibilityScopeOrgId: null,
brandColor: null,
});
const res = await request(app)
.post(`/api/local/spaces/${other.id}/files/delete`)
.send({ paths: ['x.txt'] });
expect(res.status).toBe(404);
});
it('does not delete a directory (skips it, leaving it intact)', async () => {
const created = (await request(app).post('/api/local/spaces').send({ title: 'dir-skip' })).body;
const filesDir = join(dir, 'wt', 'space', created.id, 'files');
mkdirSync(join(filesDir, 'sub'), { recursive: true });
writeFileSync(join(filesDir, 'sub', 'f.txt'), 'x');
const res = await request(app)
.post(`/api/local/spaces/${created.id}/files/delete`)
.send({ paths: ['sub'] });
expect(res.status).toBe(200);
expect(res.body.deleted).toEqual([]);
expect(res.body.skipped).toEqual(['sub']);
expect(existsSync(join(filesDir, 'sub', 'f.txt'))).toBe(true);
});
});

898
src/bridge/space-api.ts Normal file
View File

@ -0,0 +1,898 @@
import express, { Router } from 'express';
import { dirname, join, extname, basename, relative } from 'node:path';
import { mkdirSync, readdirSync, statSync, readFileSync, openSync, writeSync, closeSync, unlinkSync, writeFileSync } from 'node:fs';
import AdmZip from 'adm-zip';
import type { Repository } from '../db/repository.js';
import { spaceWorkspaceDir, spaceFilesDir } from '../spaces/paths.js';
import { scaffoldCaseSpace, removeSpaceDirs } from '../spaces/scaffold.js';
import { canManageSpace, canEditInSpace } from './visibility.js';
import type { SpaceMemberRoleValue, SpaceInvite, SpaceInviteRole } from '../db/repository.js';
import {
ensurePathWithin,
isPathEscapeError,
isNotFoundError,
serializeLocalFileEntry,
setUntrustedFileResponseHeaders,
safeZipEntryName,
} from './local-api-helpers.js';
import { logger } from '../logger.js';
export interface SpaceApiDeps {
repo: Repository;
dataRoot: string;
worktreeDir: string;
authActive: boolean;
/** アップロード JSON ボディの上限MB。未指定は 50MBbase64 は ~33% 膨張する点に注意)。 */
uploadLimitMb?: number;
}
/**
* OFF synthetic 'local' no-auth local
* role 'admin' server.ts no-auth
* deserializeUser `{ id: 'local', role: 'admin' }`
* memory-api / reflection-api role:'user' owner_id
* role owner_id=null local
* canEditEntity role:'admin' owner
* 'user' owner_id=null
*/
function viewerOf(req: any, authActive: boolean): Express.User {
if (authActive && req.user) return req.user;
return req.user ?? ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User);
}
const MAX_TZ_OFFSET = 14 * 60; // ±14h は IANA TZ の理論上限
/**
* OFF cross-calendar-api
* viewerOf synthetic 'local' admin
*/
export function spaceViewerOf(req: any, authActive: boolean): Express.User {
return viewerOf(req, authActive);
}
/** tz_offset クエリ(分、-getTimezoneOffset() 由来で JST=+540を安全に整数化する。 */
export function parseTzOffset(raw: unknown): number {
const n = typeof raw === 'string' ? parseInt(raw, 10) : NaN;
if (!Number.isFinite(n)) return 0;
return Math.max(-MAX_TZ_OFFSET, Math.min(MAX_TZ_OFFSET, n));
}
/** 'YYYY-MM' の月初・月末(ローカル暦日)を返す。 */
export function monthBounds(month: string): { monthStart: string; monthEnd: string } {
const [y, m] = month.split('-').map((s) => parseInt(s, 10));
const start = `${month}-01`;
// 当月末日 = 翌月0日Date は UTC ベースだが日付計算のみに使うので TZ 非依存)。
const lastDay = new Date(Date.UTC(y, m, 0)).getUTCDate();
const end = `${month}-${String(lastDay).padStart(2, '0')}`;
return { monthStart: start, monthEnd: end };
}
/** UTC instantmsをローカル TZ オフセット分ずらして暦日 'YYYY-MM-DD' に落とす。 */
function localDayOfMs(ms: number, tzOffsetMin: number): string {
return new Date(ms + tzOffsetMin * 60_000).toISOString().slice(0, 10);
}
/**
* spaceFilesDir mtime date
* runs/.conflict/
*
*/
function scanFilesForLocalDay(
rootDir: string,
date: string,
tzOffsetMin: number,
): Array<{ name: string; path: string; size: number; mtime: string }> {
const out: Array<{ name: string; path: string; size: number; mtime: string }> = [];
const walk = (absDir: string, relDir: string): void => {
let entries: import('node:fs').Dirent[];
try {
entries = readdirSync(absDir, { withFileTypes: true });
} catch {
return; // ディレクトリ非存在・読めない場合は黙ってスキップ
}
for (const entry of entries) {
// runs/(実行ログ)・.conflict・全ドットファイルを除外
if (entry.name === 'runs' || entry.name === '.conflict' || entry.name.startsWith('.')) continue;
const abs = join(absDir, entry.name);
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(abs, rel);
continue;
}
if (!entry.isFile()) continue;
let stat;
try {
stat = statSync(abs);
} catch {
continue;
}
if (localDayOfMs(stat.mtimeMs, tzOffsetMin) === date) {
out.push({ name: entry.name, path: rel, size: stat.size, mtime: stat.mtime.toISOString() });
}
}
};
walk(rootDir, '');
return out;
}
export function createSpaceApi(deps: SpaceApiDeps): Router {
const router = Router();
const { repo, dataRoot, worktreeDir } = deps;
// body を読むルートcreate / patch / upload専用の json parser。
// グローバル json は server.ts から外したので、各ルートが自前で宣言する。
// upload を通すため 50mbまたは config 指定)まで許容する。
const jsonParser = express.json({ limit: deps.uploadLimitMb ? `${deps.uploadLimitMb}mb` : '50mb' });
// 一覧(個人スペースは初回アクセス時に lazy 生成)。各スペースに閲覧者の myRole も付与
// (クライアントの editor 含むファイル編集可否判定に使う。SpaceDetail は一覧から space を引く)。
router.get('/', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
await repo.ensurePersonalSpace(viewer.id, viewer.name ?? undefined);
const spaces = await repo.listSpaces({ viewer });
res.json(spaces.map(s => ({ ...s, myRole: repo.getSpaceMemberRole(s.id, viewer.id) })));
});
// 詳細。閲覧者のメンバーロールmyRoleも載せる。クライアントが editor を含む
// ファイル編集可否canEditInSpace 相当)を判定するために使う。非メンバーは null。
router.get('/:id', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const myRole = repo.getSpaceMemberRole(space.id, viewer.id);
res.json({ ...space, myRole });
});
// 作成案件スペース。DB 行 → 雛形作成。雛形失敗時は行を消してロールバック。
router.post('/', jsonParser, async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const title = (req.body?.title ?? '').toString().trim();
if (!title) return res.status(400).json({ error: 'title is required' });
const ownerId = viewer.id === 'local' ? null : viewer.id;
const space = await repo.createSpace({
kind: 'case',
title,
description: req.body?.description?.toString() ?? '',
ownerId,
visibility: req.body?.visibility ?? 'private',
visibilityScopeOrgId: req.body?.visibilityScopeOrgId ?? null,
brandColor: req.body?.brandColor ?? null,
});
try {
// 案件スペースのフォルダ実体は data/spaces/{id}spec §5.3。dataRoot は
// userFolderRootdata/usersが渡るため、scaffold の spaceDataDir が
// {dataRoot}/spaces/{id} を作る前提に合わせ dataRoot=dirname(userFolderRoot)=data を渡す。
scaffoldCaseSpace({ dataRoot: dirname(dataRoot), worktreeDir, id: space.id, title });
// workspace_dir を確定保存
const ws = spaceWorkspaceDir(worktreeDir, space.id);
await repo.setSpaceWorkspaceDir(space.id, ws);
const fresh = await repo.getSpace(space.id, { viewer });
res.status(201).json(fresh);
} catch (err) {
logger.error(`[space-api] scaffold failed, rolling back space=${space.id} err=${(err as Error).message}`);
removeSpaceDirs({ dataRoot: dirname(dataRoot), worktreeDir, id: space.id });
await repo.hardDeleteSpace(space.id);
res.status(500).json({ error: 'failed to create space' });
}
});
// 更新owner / admin / member.role==='owner'
router.patch('/:id', jsonParser, async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const updated = await repo.updateSpace(req.params.id, {
title: req.body?.title,
description: req.body?.description,
brandColor: req.body?.brandColor,
visibility: req.body?.visibility,
visibilityScopeOrgId: req.body?.visibilityScopeOrgId,
});
res.json(updated);
});
// アーカイブowner / admin / member.role==='owner'、案件のみ)
router.post('/:id/archive', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
if (space.kind === 'personal') return res.status(400).json({ error: 'cannot archive personal space' });
await repo.archiveSpace(req.params.id);
res.json({ ok: true });
});
// ─── ファイル窓(スペースの永続ワークスペース {worktreeDir}/space/{id}/files────────
//
// タスク版local-files-api.tsの section='space' に相当する経路を、スペース id で
// キーした形で提供する。閉じ込めは spaceFilesDir(worktreeDir, id) を root にした
// ensurePathWithinrealpath ではなく resolve ベースの正規化ガード。task の files API と同一)で行う。
// 可視性は getSpace({viewer}) が null を返したら 404owner/org/public の判定を repo に委譲)。
// 一覧
router.get('/:id/files', async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const rootDir = spaceFilesDir(worktreeDir, space.id);
const relativeDir = String(req.query.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
mkdirSync(rootDir, { recursive: true });
const dirPath = ensurePathWithin(rootDir, relativeDir);
const entries = readdirSync(dirPath, { withFileTypes: true })
// 計画5 (Phase C): 成果物だけの綺麗なツリーにするため、実行ログ (logs)・
// 競合台帳 (.conflict)・全ドットファイルをファイル窓から除外する。
.filter((entry) => entry.name !== 'logs' && entry.name !== '.conflict' && !entry.name.startsWith('.'))
.map((entry) => {
const stat = statSync(join(dirPath, entry.name));
return serializeLocalFileEntry(relativeDir, entry.name, entry.isDirectory(), stat.size, stat.mtime);
});
res.json({ basePath: 'space', path: relativeDir, entries });
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
if (isNotFoundError(err)) { logger.debug(`[space-api] files list not found: ${err}`); return res.status(404).json({ error: 'not found' }); }
logger.error(`[space-api] files list error: ${err}`);
res.status(500).json({ error: 'Failed to list files' });
}
});
// テキスト本文(プレビュー用)
router.get('/:id/files/content', async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
const rootDir = spaceFilesDir(worktreeDir, space.id);
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) return res.status(400).json({ error: 'path must point to a file' });
setUntrustedFileResponseHeaders(res);
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(readFileSync(filePath, 'utf-8'));
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
if (isNotFoundError(err)) { logger.debug(`[space-api] file content not found: ${err}`); return res.status(404).json({ error: 'not found' }); }
logger.error(`[space-api] file content error: ${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
// バイナリ配信 / HTML アプリ実行trusted=1
router.get('/:id/files/raw', async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
const rootDir = spaceFilesDir(worktreeDir, space.id);
const filePath = ensurePathWithin(rootDir, relativePath);
const stat = statSync(filePath);
if (!stat.isFile()) return res.status(400).json({ error: 'path must point to a file' });
// trusted=1 で CSP sandbox を外し、生成 HTML をアプリ origin 上でアプリとして実行させる。
// 信頼モデルは「スペースのオーナーは信頼できる同僚」(社内 Gitea-org ツール)。
// 手前の getSpace({viewer}) で可視性private=owner+admin / org / publicを通過した
// 閲覧者には trusted 版を配る。受容するトレードオフ: オーナーの無サンドボックス HTML は
// 閲覧者のセッションで動くため、悪意あるオーナーは閲覧者になりすませる(オーナーは信頼前提)。
// ハードフロア: 未認証(実 req.user なし)には trusted を渡さない=共有リンクはまず
// ログインを経由する。viewerOf は authActive 時に synthetic admin を返しうるので、
// ここは viewer ではなく実認証ユーザー (req.user) の有無で判定する。
// 認証 OFF の単一運用者モードでは唯一の主体が全スペースの ownerself-XSS のみ)。
const trustedAllowed = deps.authActive ? !!(req as { user?: Express.User }).user : true;
const trustedHtml = req.query.trusted === '1' && /\.html?$/i.test(filePath) && trustedAllowed;
if (!trustedHtml) setUntrustedFileResponseHeaders(res);
res.type(extname(filePath) || 'application/octet-stream');
res.send(readFileSync(filePath));
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
if (isNotFoundError(err)) { logger.debug(`[space-api] file raw not found: ${err}`); return res.status(404).json({ error: 'not found' }); }
logger.error(`[space-api] file raw error: ${err}`);
res.status(500).json({ error: 'Failed to read raw file' });
}
});
// アップロードowner / admin / member.role∈{owner,editor})。共有スペースは協働モデルなので
// 行 owner 限定にはしないが、viewer ロールのメンバーは read のみで書き込み不可。
// base64-JSON 方式添付と同形、multipart dep 無し)。同名衝突は `{stem} (N){ext}` に
// O_EXCL で確保してリネーム上書き禁止。path は ensurePathWithin で spaceFilesDir に封じ込め。
router.post('/:id/files/upload', jsonParser, async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const files = req.body?.files;
if (!Array.isArray(files) || files.length === 0) {
return res.status(400).json({ error: 'files must be a non-empty array' });
}
const reqPath = String(req.body?.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
const filesDir = spaceFilesDir(worktreeDir, space.id);
const uploaded: { name: string; path: string }[] = [];
for (const file of files) {
const safeName = String(file?.name ?? '').replace(/[\\/]/g, '_');
if (!safeName) return res.status(400).json({ error: 'file name is required' });
const rel = join(reqPath, safeName);
const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
mkdirSync(dirname(abs), { recursive: true });
const buf = Buffer.from(String(file?.contentBase64 ?? ''), 'base64');
// 衝突回避: 既存なら `{stem} (N){ext}` を O_EXCL で予約して書く(上書き禁止)。
const ext = extname(safeName);
const stem = basename(safeName, ext);
let candidateName = safeName;
let candidateAbs = abs;
let fd: number | undefined;
for (let n = 1; ; n++) {
if (n > 1) {
candidateName = `${stem} (${n})${ext}`;
candidateAbs = ensurePathWithin(filesDir, join(reqPath, candidateName));
}
try {
fd = openSync(candidateAbs, 'wx'); // O_EXCL: 既存なら EEXIST
break;
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'EEXIST') continue;
throw e;
}
}
try {
writeSync(fd, buf);
} finally {
closeSync(fd);
}
const writtenRel = reqPath ? `${reqPath}/${candidateName}` : candidateName;
uploaded.push({ name: candidateName, path: writtenRel });
}
res.json({ uploaded });
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
logger.error(`[space-api] file upload error: ${err}`);
res.status(500).json({ error: 'Failed to upload files' });
}
});
// 削除owner / admin / member.role∈{owner,editor}。viewer ロールは read のみで削除不可。
// body は { paths: string[] }(複数選択)または単一 { path: string }。各パスは
// ensurePathWithin で spaceFilesDir に封じ込めtraversal は throw → 400、ファイルのみ
// 対象ディレクトリはスキップskipped に記録、ファイル窓を壊さない)。存在しないパスは
// 黙ってスキップ(冪等)。削除結果は { deleted, skipped } で返す。
router.post('/:id/files/delete', jsonParser, async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const rawPaths: unknown =
Array.isArray(req.body?.paths) ? req.body.paths
: req.body?.path != null ? [req.body.path]
: null;
if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
return res.status(400).json({ error: 'paths must be a non-empty array (or a single path)' });
}
const filesDir = spaceFilesDir(worktreeDir, space.id);
const deleted: string[] = [];
const skipped: string[] = [];
for (const raw of rawPaths) {
const rel = String(raw ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
if (!rel) { skipped.push(String(raw ?? '')); continue; }
const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
let stat;
try {
stat = statSync(abs);
} catch {
skipped.push(rel); // 存在しない → 冪等にスキップ
continue;
}
if (!stat.isFile()) { skipped.push(rel); continue; } // ディレクトリ等は対象外
unlinkSync(abs);
deleted.push(rel);
}
res.json({ deleted, skipped });
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
logger.error(`[space-api] file delete error: ${err}`);
res.status(500).json({ error: 'Failed to delete files' });
}
});
// 複数ファイルを zip でダウンロード。ダウンロードは閲覧操作なので read ゲート
// getSpace が viewer で読めれば可。canEditInSpace は不要)。各 path は spaceFilesDir に
// 封じ込めtraversal は 400。ファイルのみ、ディレクトリ/不在はスキップ。zip エントリ名は
// 相対パス(絶対パスを漏らさない)。
router.post('/:id/files/download-zip', jsonParser, async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const rawPaths: unknown =
Array.isArray(req.body?.paths) ? req.body.paths
: req.body?.path != null ? [req.body.path]
: null;
if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
return res.status(400).json({ error: 'paths must be a non-empty array (or a single path)' });
}
const filesDir = spaceFilesDir(worktreeDir, space.id);
const zip = new AdmZip();
let added = 0;
for (const raw of rawPaths) {
const rel = String(raw ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
if (!rel) continue;
const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
let stat;
try { stat = statSync(abs); } catch { continue; } // 不在はスキップ
if (!stat.isFile()) continue; // ディレクトリ等は対象外
// zip エントリ名は封じ込め済み abs から安全に再生成zip-slip 対策、共有ヘルパ)。
const entryName = safeZipEntryName(filesDir, abs);
if (!entryName) continue;
zip.addFile(entryName, readFileSync(abs));
added++;
}
if (added === 0) return res.status(404).json({ error: 'no downloadable files' });
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', 'attachment; filename="files.zip"');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.send(zip.toBuffer());
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
logger.error(`[space-api] file zip error: ${err}`);
res.status(500).json({ error: 'Failed to build zip' });
}
});
// プログラム書込1 ファイルを path 指定で上書き。upload が O_EXCL で衝突回避リネーム
// するのに対し、こちらは「同じパスを更新する」用途(ワークスペース・アプリの postMessage
// ブリッジ writeFile 等)のため**上書き許可**。本文は base64任意バイナリまたは content
// UTF-8 テキスト)で受ける。書込ゲートは upload/delete と同じ canEditInSpaceowner /
// admin / member.role∈{owner,editor}。path は ensurePathWithin で spaceFilesDir に封じ込め
// traversal は throw → 400。親ディレクトリは mkdir -p。
router.post('/:id/files/write', jsonParser, async (req, res) => {
try {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const rel = String(req.body?.path ?? '').replace(/^\/+/, '');
if (!rel) return res.status(400).json({ error: 'path is required' });
const hasBase64 = typeof req.body?.contentBase64 === 'string';
const hasText = typeof req.body?.content === 'string';
if (!hasBase64 && !hasText) {
return res.status(400).json({ error: 'content or contentBase64 is required' });
}
const buf = hasBase64
? Buffer.from(String(req.body.contentBase64), 'base64')
: Buffer.from(String(req.body.content), 'utf-8');
// サイズ上限DoS / ディスク枯渇対策)。
const MAX_WRITE_BYTES = 10 * 1024 * 1024;
if (buf.length > MAX_WRITE_BYTES) {
return res.status(400).json({ error: `content exceeds ${MAX_WRITE_BYTES} bytes` });
}
const filesDir = spaceFilesDir(worktreeDir, space.id);
const abs = ensurePathWithin(filesDir, rel); // escape は throw → 下で 400 化
// 書込先は apps/ と output/ サブツリーに限定する。任意 .html や AGENTS.md /
// source/index.jsonl 等を上書きされ、trusted-html 無サンドボックス配信経路と
// 組み合わさって stored XSS になるのを防ぐ(ブリッジ writeFile の正当な用途は
// アプリの出力 = output/ と アプリ自身のデータ = apps/。escape は ensurePathWithin
// が既に弾く。正規化後の相対先頭セグメントで判定する。
const normRel = relative(filesDir, abs).split(/[\\/]/);
if (normRel[0] !== 'apps' && normRel[0] !== 'output') {
return res.status(403).json({ error: 'write target must be under apps/ or output/' });
}
// 既存ディレクトリを潰さないガード(ファイルのみ上書き対象)。
try {
if (statSync(abs).isDirectory()) {
return res.status(400).json({ error: 'path points to a directory' });
}
} catch { /* 非存在は新規作成 */ }
mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, buf);
res.json({ path: rel, bytes: buf.length });
} catch (err) {
if (isPathEscapeError(err)) return res.status(400).json({ error: 'Path escapes workspace' });
logger.error(`[space-api] file write error: ${err}`);
res.status(500).json({ error: 'Failed to write file' });
}
});
// ─── カレンダー(予定 + 日次集計)────────────────────────────────────
//
// 可視性は他のスペース配下ルートと同じく getSpace({viewer}) が null を返したら
// 404。書き込みPOST/PATCH/DELETEは canEditInSpaceowner / admin / member.role∈
// {owner,editor}で更にゲート。viewer ロールのメンバーは閲覧のみで編集不可。
// 他スペースのイベント id への操作は spaceId 不一致で 404buildVisibilityWhere
// 同様のスコープ)。日付バケツ化のローカル TZ オフセットは tz_offsetで受ける
// Usage ダッシュボードと同方式)。
// spec: docs/superpowers/specs/2026-06-19-space-calendar-design.md
// 月ビュー: 日別カウント + 当月の予定一覧
router.get('/:id/calendar', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const month = String(req.query.month ?? '');
if (!/^\d{4}-\d{2}$/.test(month)) {
return res.status(400).json({ error: 'month must be YYYY-MM' });
}
const tzOffsetMin = parseTzOffset(req.query.tz_offset);
const { monthStart, monthEnd } = monthBounds(month);
// Personal spaces also own the owner's space-less (space_id NULL) tasks.
const personalOwnerId = space.kind === 'personal' ? space.ownerId : undefined;
const result = await repo.getSpaceCalendarMonth(space.id, { monthStart, monthEnd, tzOffsetMin, personalOwnerId });
res.json(result);
});
// 日詳細: その日に作成されたタスク / 変更ファイル / 予定
router.get('/:id/calendar/day', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const date = String(req.query.date ?? '');
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return res.status(400).json({ error: 'date must be YYYY-MM-DD' });
}
const tzOffsetMin = parseTzOffset(req.query.tz_offset);
const personalOwnerId = space.kind === 'personal' ? space.ownerId : undefined;
const { tasks, events } = await repo.getSpaceCalendarDay(space.id, date, tzOffsetMin, personalOwnerId);
const files = scanFilesForLocalDay(spaceFilesDir(worktreeDir, space.id), date, tzOffsetMin);
res.json({ tasks, files, events });
});
// 予定の作成(編集権限保有者のみ)
router.post('/:id/calendar/events', jsonParser, async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const date = (req.body?.date ?? '').toString();
const title = (req.body?.title ?? '').toString().trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return res.status(400).json({ error: 'date must be YYYY-MM-DD' });
if (!title) return res.status(400).json({ error: 'title is required' });
const time = req.body?.time != null && req.body.time !== '' ? String(req.body.time) : null;
if (time != null && !/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) {
return res.status(400).json({ error: 'time must be HH:MM' });
}
let endDate: string | null = null;
if (req.body?.end_date != null && req.body.end_date !== '') {
endDate = String(req.body.end_date);
if (!/^\d{4}-\d{2}-\d{2}$/.test(endDate)) return res.status(400).json({ error: 'end_date must be YYYY-MM-DD' });
if (endDate < date) return res.status(400).json({ error: 'end_date must be on or after date' });
}
const ownerId = viewer.id === 'local' ? null : viewer.id;
const ev = await repo.createCalendarEvent({
spaceId: space.id,
ownerId,
date,
endDate,
time,
title,
description: req.body?.description != null ? String(req.body.description) : null,
createdBy: 'user',
});
res.status(201).json(ev);
});
// 予定の更新(編集権限保有者のみ、他スペースの id は 404
router.patch('/:id/calendar/events/:eventId', jsonParser, async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const eventId = Number(req.params.eventId);
const existing = Number.isInteger(eventId) ? await repo.getCalendarEvent(eventId) : null;
if (!existing || existing.spaceId !== space.id) return res.status(404).json({ error: 'event not found' });
const patch: { date?: string; endDate?: string | null; time?: string | null; title?: string; description?: string | null } = {};
if (req.body?.date !== undefined) {
const d = String(req.body.date);
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return res.status(400).json({ error: 'date must be YYYY-MM-DD' });
patch.date = d;
}
// 終了日。null/'' で単日に戻す。開始日(更新後の値)より前は 400。
const effectiveStart = patch.date ?? existing.date;
if (req.body?.end_date !== undefined) {
if (req.body.end_date === null || req.body.end_date === '') {
patch.endDate = null;
} else {
const ed = String(req.body.end_date);
if (!/^\d{4}-\d{2}-\d{2}$/.test(ed)) return res.status(400).json({ error: 'end_date must be YYYY-MM-DD' });
if (ed < effectiveStart) return res.status(400).json({ error: 'end_date must be on or after date' });
patch.endDate = ed > effectiveStart ? ed : null;
}
} else if (patch.date !== undefined && existing.endDate && existing.endDate < patch.date) {
// 開始日だけを既存終了日より後ろにずらした場合は整合のため単日へ。
patch.endDate = null;
}
if (req.body?.time !== undefined) {
const t = req.body.time === null || req.body.time === '' ? null : String(req.body.time);
if (t != null && !/^([01]\d|2[0-3]):[0-5]\d$/.test(t)) return res.status(400).json({ error: 'time must be HH:MM' });
patch.time = t;
}
if (req.body?.title !== undefined) {
const t = String(req.body.title).trim();
if (!t) return res.status(400).json({ error: 'title is required' });
patch.title = t;
}
if (req.body?.description !== undefined) {
patch.description = req.body.description === null ? null : String(req.body.description);
}
const updated = await repo.updateCalendarEvent(eventId, patch);
res.json(updated);
});
// 予定の削除(編集権限保有者のみ、他スペースの id は 404
router.delete('/:id/calendar/events/:eventId', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canEditInSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const eventId = Number(req.params.eventId);
const existing = Number.isInteger(eventId) ? await repo.getCalendarEvent(eventId) : null;
if (!existing || existing.spaceId !== space.id) return res.status(404).json({ error: 'event not found' });
await repo.deleteCalendarEvent(eventId);
res.status(204).end();
});
// ─── メンバー(スペースの協働者)─────────────────────────────────────
//
// 可視性は他のスペース配下ルートと同じく getSpace({viewer}) が null を返したら 404。
// 一覧はスペース可視者なら誰でも可read。追加/変更/削除は canManageSpace
// owner / admin / member.role==='owner')。自分自身を抜けるのは管理権限が無くても可。
// owner_id 本人は常に owner として合成して返し、member 行を持つことはできない。
const VALID_MEMBER_ROLES: readonly SpaceMemberRoleValue[] = ['owner', 'editor', 'viewer'];
const isValidRole = (r: unknown): r is SpaceMemberRoleValue =>
typeof r === 'string' && (VALID_MEMBER_ROLES as readonly string[]).includes(r);
// 一覧スペース可視者なら誰でも。owner_id を owner として合成し、member 行を後続。
router.get('/:id/members', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const members = await repo.listSpaceMembers(space.id);
// email は PII。public/org スペースでは getSpace を通る非メンバー閲覧者にも
// メンバー一覧が見えるため、email は「資格者」(admin / owner / メンバー本人) に
// だけ返す。それ以外には name + avatar のみ(メールアドレス収集ベクター対策)。
const viewerEntitled =
viewer.role === 'admin' ||
space.ownerId === viewer.id ||
(await repo.getSpaceMemberRole(space.id, viewer.id)) !== null;
const maskEmail = (email: string | null) => (viewerEntitled ? email : null);
const out: Array<{
userId: string;
name: string | null;
email: string | null;
avatarUrl: string | null;
role: SpaceMemberRoleValue;
isOwner: boolean;
}> = [];
// owner_id を合成 owner として先頭に置く(行が重複しても owner 行が勝つ)。
if (space.ownerId) {
const ownerUser = repo.getUserById(space.ownerId);
out.push({
userId: space.ownerId,
name: ownerUser?.name ?? null,
email: maskEmail(ownerUser?.email ?? null),
avatarUrl: ownerUser?.avatarUrl ?? null,
role: 'owner',
isOwner: true,
});
}
for (const m of members) {
if (space.ownerId && m.userId === space.ownerId) continue; // owner 行が勝つ → de-dupe
out.push({
userId: m.userId,
name: m.name,
email: maskEmail(m.email),
avatarUrl: m.avatarUrl,
role: m.role,
isOwner: false,
});
}
res.json(out);
});
// 追加 / 招待canManageSpace。owner_id 本人は追加不可。未知ユーザー・不正ロールは 400。
router.post('/:id/members', jsonParser, async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const userId = (req.body?.userId ?? '').toString();
const role = req.body?.role;
if (!userId) return res.status(400).json({ error: 'userId is required' });
if (space.ownerId && userId === space.ownerId) {
return res.status(400).json({ error: 'user is already the space owner' });
}
if (!isValidRole(role)) return res.status(400).json({ error: 'invalid role' });
if (!repo.getUserById(userId)) return res.status(400).json({ error: 'unknown user' });
await repo.addSpaceMember({ spaceId: space.id, userId, role, invitedBy: viewer.id });
res.status(201).json({ userId, role });
});
// ロール変更canManageSpace。メンバーでなければ 404、不正ロールは 400。
router.patch('/:id/members/:userId', jsonParser, async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const targetUserId = req.params.userId;
const role = req.body?.role;
if (!isValidRole(role)) return res.status(400).json({ error: 'invalid role' });
if (repo.getSpaceMemberRole(space.id, targetUserId) === null) {
return res.status(404).json({ error: 'member not found' });
}
await repo.updateSpaceMemberRole(space.id, targetUserId, role);
res.json({ userId: targetUserId, role });
});
// 除去canManageSpace、または自分自身を抜ける場合。メンバーでなければ 404。
router.delete('/:id/members/:userId', async (req, res) => {
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const targetUserId = req.params.userId;
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
const isSelfRemoval = targetUserId === viewer.id;
if (!isSelfRemoval && !canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
if (repo.getSpaceMemberRole(space.id, targetUserId) === null) {
return res.status(404).json({ error: 'member not found' });
}
await repo.removeSpaceMember(space.id, targetUserId);
res.status(204).end();
});
// ─── 招待リンク(再利用トークン)───────────────────────────────────
// 組織非依存の招待経路。発行/無効化は canManageSpace、参加は認証済みユーザー。
// no-auth モードでは他ユーザーの概念が無いので invite 系は一律 404。
// spec: docs/superpowers/specs/2026-06-19-space-invite-links-design.md
const INVITE_ROLES: readonly SpaceInviteRole[] = ['editor', 'viewer'];
const isInviteRole = (r: unknown): r is SpaceInviteRole =>
typeof r === 'string' && (INVITE_ROLES as readonly string[]).includes(r);
// invite を API レスポンス形に整えるurl は相対パス。SPA が origin を前置する)。
const inviteOut = (inv: SpaceInvite) => ({
token: inv.token,
url: `/ui/invite/${inv.token}`,
role: inv.role,
createdAt: inv.createdAt,
expiresAt: inv.expiresAt,
revokedAt: inv.revokedAt,
valid: repo.isSpaceInviteValid(inv),
});
// 現行リンクを取得canManageSpace。無ければ { invite: null }。
router.get('/:id/invite', async (req, res) => {
if (!deps.authActive) return res.status(404).json({ error: 'not found' });
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const inv = repo.getSpaceInvite(space.id);
res.json({ invite: inv ? inviteOut(inv) : null });
});
// リンクを (再)生成canManageSpace。body { role, expiresInDays? }。
router.post('/:id/invite', jsonParser, async (req, res) => {
if (!deps.authActive) return res.status(404).json({ error: 'not found' });
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
const role = req.body?.role ?? 'viewer';
if (!isInviteRole(role)) return res.status(400).json({ error: 'invalid role' });
const rawDays = req.body?.expiresInDays;
let expiresInDays: number | null = null;
if (rawDays != null) {
const n = Number(rawDays);
if (!Number.isInteger(n) || n <= 0) {
return res.status(400).json({ error: 'expiresInDays must be a positive integer' });
}
expiresInDays = n;
}
const inv = repo.createSpaceInvite({ spaceId: space.id, role, createdBy: viewer.id, expiresInDays });
res.status(201).json({ invite: inviteOut(inv) });
});
// リンクを無効化canManageSpace
router.delete('/:id/invite', async (req, res) => {
if (!deps.authActive) return res.status(404).json({ error: 'not found' });
const viewer = viewerOf(req, deps.authActive);
const space = await repo.getSpace(req.params.id, { viewer });
if (!space) return res.status(404).json({ error: 'not found' });
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
return res.status(403).json({ error: 'forbidden' });
}
repo.revokeSpaceInvite(space.id);
res.status(204).end();
});
// プレビュー(認証済み・メンバーでなくてよい)。無効/不明トークンは 404 で、
// スペース情報を一切返さない(列挙・情報漏洩対策)。
// ルーティング: '/invite/:token' は1セグメント目が literal 'invite'。'/:id/invite'
// とは排他(スペース id は UUID で 'invite' と衝突しない)。
router.get('/invite/:token', async (req, res) => {
if (!deps.authActive) return res.status(404).json({ error: 'not found' });
const inv = repo.getSpaceInviteByToken(req.params.token);
if (!repo.isSpaceInviteValid(inv)) return res.status(404).json({ error: 'invalid invite' });
const space = await repo.getSpace(inv.spaceId, { viewer: { id: 'x', role: 'admin', orgIds: [] } as unknown as Express.User });
if (!space) return res.status(404).json({ error: 'invalid invite' });
res.json({ spaceId: space.id, spaceTitle: space.title, role: inv.role });
});
// 参加(認証済み)。検証 → メンバー追加(冪等)。既メンバー/オーナーも 200。
router.post('/invite/:token/accept', async (req, res) => {
if (!deps.authActive) return res.status(404).json({ error: 'not found' });
const viewer = viewerOf(req, deps.authActive);
const inv = repo.getSpaceInviteByToken(req.params.token);
if (!repo.isSpaceInviteValid(inv)) return res.status(404).json({ error: 'invalid invite' });
const space = await repo.getSpace(inv.spaceId, { viewer: { id: 'x', role: 'admin', orgIds: [] } as unknown as Express.User });
if (!space) return res.status(404).json({ error: 'invalid invite' });
// オーナー本人 → 既に最上位権限。メンバー行は作らず成功扱い。
if (space.ownerId && viewer.id === space.ownerId) {
return res.json({ spaceId: space.id, alreadyMember: true });
}
const existing = repo.getSpaceMemberRole(space.id, viewer.id);
if (existing) return res.json({ spaceId: space.id, alreadyMember: true });
await repo.addSpaceMember({ spaceId: space.id, userId: viewer.id, role: inv.role, invitedBy: inv.createdBy });
res.json({ spaceId: space.id, alreadyMember: false });
});
return router;
}

View File

@ -0,0 +1,215 @@
// Programmatic single-file write endpoint (POST /files/write).
//
// Backs the workspace-apps AppRunner postMessage `writeFile` bridge. Unlike
// /files/upload (O_EXCL, collision-rename), this OVERWRITES the target path so
// an app can update its own data file. Same write gate as upload/delete
// (canEditInSpace) and same ensurePathWithin traversal guard.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import AdmZip from 'adm-zip';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Repository } from '../db/repository.js';
import { spaceFilesDir } from '../spaces/paths.js';
import { createSpaceApi, type SpaceApiDeps } from './space-api.js';
function user(id: string, role: 'user' | 'admin' = 'user', orgIds: string[] = []): Express.User {
return {
id,
email: `${id}@x.com`,
name: id,
avatarUrl: null,
role,
status: 'active',
orgIds,
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
} as unknown as Express.User;
}
describe('space /files/write endpoint', () => {
let dir = '';
let repo: Repository;
let ownerId = '';
let editorId = '';
let viewerId = '';
let strangerId = '';
let spaceId = '';
let worktreeDir = '';
function appAs(u: Express.User | null, authActive = true): express.Application {
const app = express();
if (u) {
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = u;
next();
});
}
const deps: SpaceApiDeps = {
repo,
dataRoot: join(dir, 'data', 'users'),
worktreeDir,
authActive,
};
app.use('/api/local/spaces', createSpaceApi(deps));
return app;
}
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'space-write-'));
worktreeDir = join(dir, 'workspaces');
repo = new Repository(join(dir, 'db.sqlite'));
ownerId = repo.createUser({ email: 'owner@x.com', name: 'owner', role: 'user', status: 'active' }).id;
editorId = repo.createUser({ email: 'editor@x.com', name: 'editor', role: 'user', status: 'active' }).id;
viewerId = repo.createUser({ email: 'viewer@x.com', name: 'viewer', role: 'user', status: 'active' }).id;
strangerId = repo.createUser({ email: 'stranger@x.com', name: 'stranger', role: 'user', status: 'active' }).id;
const space = await repo.createSpace({ kind: 'case', title: '案件', ownerId, visibility: 'private' });
spaceId = space.id;
await repo.addSpaceMember({ spaceId, userId: editorId, role: 'editor', invitedBy: ownerId });
await repo.addSpaceMember({ spaceId, userId: viewerId, role: 'viewer', invitedBy: ownerId });
});
afterEach(() => {
repo.close();
rmSync(dir, { recursive: true, force: true });
});
it('editor writes text content under output/, file lands on disk', async () => {
const res = await request(appAs(user(editorId)))
.post(`/api/local/spaces/${spaceId}/files/write`)
.send({ path: 'output/report.txt', content: 'hello world' });
expect(res.status).toBe(200);
const abs = join(spaceFilesDir(worktreeDir, spaceId), 'output', 'report.txt');
expect(readFileSync(abs, 'utf-8')).toBe('hello world');
});
it('OVERWRITES an existing file (not collision-renamed like upload)', async () => {
const app = appAs(user(editorId));
await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ path: 'apps/foo/data/state.json', content: '{"v":1}' });
const res = await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ path: 'apps/foo/data/state.json', content: '{"v":2}' });
expect(res.status).toBe(200);
const abs = join(spaceFilesDir(worktreeDir, spaceId), 'apps', 'foo', 'data', 'state.json');
expect(readFileSync(abs, 'utf-8')).toBe('{"v":2}');
});
it('accepts base64 (binary) content', async () => {
const res = await request(appAs(user(editorId)))
.post(`/api/local/spaces/${spaceId}/files/write`)
.send({ path: 'output/blob.bin', contentBase64: Buffer.from([0, 1, 2, 255]).toString('base64') });
expect(res.status).toBe(200);
const abs = join(spaceFilesDir(worktreeDir, spaceId), 'output', 'blob.bin');
expect(Array.from(readFileSync(abs))).toEqual([0, 1, 2, 255]);
});
it('viewer member → 403 (read-only)', async () => {
const res = await request(appAs(user(viewerId)))
.post(`/api/local/spaces/${spaceId}/files/write`)
.send({ path: 'output/x.txt', content: 'no' });
expect(res.status).toBe(403);
});
it('non-member → 404 (read-gate, never reaches write gate)', async () => {
const res = await request(appAs(user(strangerId)))
.post(`/api/local/spaces/${spaceId}/files/write`)
.send({ path: 'output/x.txt', content: 'leak' });
expect(res.status).toBe(404);
});
it('path traversal → 400', async () => {
const res = await request(appAs(user(editorId)))
.post(`/api/local/spaces/${spaceId}/files/write`)
.send({ path: '../../escape.txt', content: 'pwn' });
expect(res.status).toBe(400);
});
it('missing path → 400; missing body content → 400', async () => {
const app = appAs(user(editorId));
expect((await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ content: 'x' })).status).toBe(400);
expect((await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ path: 'output/x.txt' })).status).toBe(400);
});
it('writing onto an existing directory path → 400 (does not clobber dir)', async () => {
mkdirSync(join(spaceFilesDir(worktreeDir, spaceId), 'output', 'adir'), { recursive: true });
const res = await request(appAs(user(editorId)))
.post(`/api/local/spaces/${spaceId}/files/write`)
.send({ path: 'output/adir', content: 'x' });
expect(res.status).toBe(400);
});
it('write outside apps/ or output/ → 403 (no arbitrary .html / AGENTS.md overwrite)', async () => {
const app = appAs(user(editorId));
// top-level file, AGENTS.md, source index, and a sneaky normalized path all rejected
for (const path of ['evil.html', 'AGENTS.md', 'source/index.jsonl', 'apps/../sneaky.html']) {
const res = await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ path, content: 'x' });
expect([400, 403]).toContain(res.status); // apps/../ escapes-normalizes → not under apps/output → 403 (or 400 if guard trips)
expect(res.status).not.toBe(200);
}
// sanity: a legit apps/ path still works
expect((await request(app).post(`/api/local/spaces/${spaceId}/files/write`).send({ path: 'apps/x/index.html', content: '<html></html>' })).status).toBe(200);
});
it('content over the 10MB cap → 400', async () => {
const big = 'a'.repeat(10 * 1024 * 1024 + 1);
const res = await request(appAs(user(editorId)))
.post(`/api/local/spaces/${spaceId}/files/write`)
.send({ path: 'output/big.txt', content: big });
expect(res.status).toBe(400);
});
// ── /files/download-zipread ゲート: メンバーなら viewer でも可、非メンバーは 404──
describe('download-zip', () => {
const zipReq = (app: express.Application, body: unknown) =>
request(app)
.post(`/api/local/spaces/${spaceId}/files/download-zip`)
.send(body as object)
.buffer(true)
.parse((res, cb) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () => cb(null, Buffer.concat(chunks)));
});
beforeEach(() => {
const filesDir = spaceFilesDir(worktreeDir, spaceId);
mkdirSync(join(filesDir, 'sub'), { recursive: true });
writeFileSync(join(filesDir, 'a.txt'), 'alpha');
writeFileSync(join(filesDir, 'sub', 'b.txt'), 'bravo');
});
it('owner zips selected files with relative entry names', async () => {
const res = await zipReq(appAs(user(ownerId)), { paths: ['a.txt', 'sub/b.txt'] });
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('application/zip');
const zip = new AdmZip(res.body as Buffer);
expect(zip.getEntries().map((e) => e.entryName).sort()).toEqual(['a.txt', 'sub/b.txt']);
expect(zip.getEntry('a.txt')?.getData().toString()).toBe('alpha');
});
it('a read-only viewer member CAN download (read-gated, not edit)', async () => {
const res = await zipReq(appAs(user(viewerId)), { paths: ['a.txt'] });
expect(res.status).toBe(200);
});
it('a non-member stranger gets 404 (no read access)', async () => {
const res = await zipReq(appAs(user(strangerId)), { paths: ['a.txt'] });
expect(res.status).toBe(404);
});
it('rejects traversal with 400', async () => {
const res = await request(appAs(user(ownerId)))
.post(`/api/local/spaces/${spaceId}/files/download-zip`)
.send({ paths: ['../../etc/passwd'] });
expect(res.status).toBe(400);
expect(res.body.error).toBe('Path escapes workspace');
});
it('returns 404 when nothing matches', async () => {
const res = await zipReq(appAs(user(ownerId)), { paths: ['nope.txt'] });
expect(res.status).toBe(404);
});
});
});

View File

@ -0,0 +1,188 @@
// Membership-based write gates on space sub-routes (calendar + settings).
//
// Stage 1 grants members read access via buildVisibilityWhere; stage 2 widens
// the write gates so an editor+ member can manage the shared calendar and an
// owner-role member can edit space settings. A viewer member is read-only; a
// non-member gets 404 from the read-gate (getSpace) and never reaches the write
// gate.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { Repository } from '../db/repository.js';
import { createSpaceApi, type SpaceApiDeps } from './space-api.js';
function user(id: string, role: 'user' | 'admin' = 'user', orgIds: string[] = []): Express.User {
return {
id,
email: `${id}@x.com`,
name: id,
avatarUrl: null,
role,
status: 'active',
orgIds,
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
} as unknown as Express.User;
}
describe('space write gates by member role (calendar + settings)', () => {
let dir = '';
let repo: Repository;
let ownerId = '';
let editorId = '';
let viewerId = '';
let strangerId = '';
let spaceId = '';
function appAs(u: Express.User | null, authActive = true): express.Application {
const app = express();
if (u) {
app.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = u;
next();
});
}
const deps: SpaceApiDeps = {
repo,
dataRoot: join(dir, 'data', 'users'),
worktreeDir: join(dir, 'workspaces'),
authActive,
};
app.use('/api/local/spaces', createSpaceApi(deps));
return app;
}
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'space-wgate-'));
repo = new Repository(join(dir, 'db.sqlite'));
ownerId = repo.createUser({ email: 'owner@x.com', name: 'owner', role: 'user', status: 'active' }).id;
editorId = repo.createUser({ email: 'editor@x.com', name: 'editor', role: 'user', status: 'active' }).id;
viewerId = repo.createUser({ email: 'viewer@x.com', name: 'viewer', role: 'user', status: 'active' }).id;
strangerId = repo.createUser({ email: 'stranger@x.com', name: 'stranger', role: 'user', status: 'active' }).id;
const space = await repo.createSpace({ kind: 'case', title: '案件', ownerId, visibility: 'private' });
spaceId = space.id;
await repo.addSpaceMember({ spaceId, userId: editorId, role: 'editor', invitedBy: ownerId });
await repo.addSpaceMember({ spaceId, userId: viewerId, role: 'viewer', invitedBy: ownerId });
});
afterEach(() => {
repo.close();
rmSync(dir, { recursive: true, force: true });
});
const owner = () => user(ownerId);
const editor = () => user(editorId);
const viewer = () => user(viewerId);
const stranger = () => user(strangerId);
// ── Calendar event creation ────────────────────────────────────────────────
it('editor member CAN create a calendar event', async () => {
const res = await request(appAs(editor()))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-06-10', title: 'editor event' });
expect(res.status).toBe(201);
expect(res.body.title).toBe('editor event');
});
it('viewer member CANNOT create a calendar event → 403', async () => {
const res = await request(appAs(viewer()))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-06-10', title: 'nope' });
expect(res.status).toBe(403);
});
it('non-member CANNOT create a calendar event → 404 (read-gate)', async () => {
const res = await request(appAs(stranger()))
.post(`/api/local/spaces/${spaceId}/calendar/events`)
.send({ date: '2026-06-10', title: 'leak' });
expect(res.status).toBe(404);
});
it('editor member CAN PATCH + DELETE a calendar event', async () => {
const ev = await repo.createCalendarEvent({ spaceId, date: '2026-06-11', title: 'orig' });
const patched = await request(appAs(editor()))
.patch(`/api/local/spaces/${spaceId}/calendar/events/${ev.id}`)
.send({ title: 'updated' });
expect(patched.status).toBe(200);
expect(patched.body.title).toBe('updated');
const del = await request(appAs(editor())).delete(`/api/local/spaces/${spaceId}/calendar/events/${ev.id}`);
expect(del.status).toBe(204);
});
it('viewer member CANNOT PATCH a calendar event → 403', async () => {
const ev = await repo.createCalendarEvent({ spaceId, date: '2026-06-11', title: 'orig' });
const res = await request(appAs(viewer()))
.patch(`/api/local/spaces/${spaceId}/calendar/events/${ev.id}`)
.send({ title: 'hijack' });
expect(res.status).toBe(403);
});
it('viewer + editor + stranger can all GET the calendar (read), non-member 404', async () => {
expect((await request(appAs(viewer())).get(`/api/local/spaces/${spaceId}/calendar?month=2026-06`)).status).toBe(200);
expect((await request(appAs(editor())).get(`/api/local/spaces/${spaceId}/calendar?month=2026-06`)).status).toBe(200);
expect((await request(appAs(stranger())).get(`/api/local/spaces/${spaceId}/calendar?month=2026-06`)).status).toBe(404);
});
// ── Space settings PATCH + archive ─────────────────────────────────────────
it('owner can PATCH brand_color/settings', async () => {
const res = await request(appAs(owner()))
.patch(`/api/local/spaces/${spaceId}`)
.send({ brandColor: '#ff0000', title: 'renamed' });
expect(res.status).toBe(200);
expect(res.body.brandColor).toBe('#ff0000');
});
it('member-with-owner-role CAN PATCH brand_color/settings', async () => {
const moId = repo.createUser({ email: 'mo@x.com', name: 'mo', role: 'user', status: 'active' }).id;
await repo.addSpaceMember({ spaceId, userId: moId, role: 'owner', invitedBy: ownerId });
const res = await request(appAs(user(moId)))
.patch(`/api/local/spaces/${spaceId}`)
.send({ brandColor: '#00ff00' });
expect(res.status).toBe(200);
expect(res.body.brandColor).toBe('#00ff00');
});
it('editor member CANNOT PATCH settings → 403 (manage requires owner-role)', async () => {
const res = await request(appAs(editor()))
.patch(`/api/local/spaces/${spaceId}`)
.send({ brandColor: '#abcdef' });
expect(res.status).toBe(403);
});
it('viewer member CANNOT PATCH settings → 403', async () => {
const res = await request(appAs(viewer()))
.patch(`/api/local/spaces/${spaceId}`)
.send({ title: 'no' });
expect(res.status).toBe(403);
});
it('non-member PATCH settings → 404 (read-gate, never leaks)', async () => {
const res = await request(appAs(stranger()))
.patch(`/api/local/spaces/${spaceId}`)
.send({ title: 'leak' });
expect(res.status).toBe(404);
});
it('editor member CANNOT archive → 403; member-owner CAN', async () => {
expect((await request(appAs(editor())).post(`/api/local/spaces/${spaceId}/archive`)).status).toBe(403);
const moId = repo.createUser({ email: 'mo2@x.com', name: 'mo2', role: 'user', status: 'active' }).id;
await repo.addSpaceMember({ spaceId, userId: moId, role: 'owner', invitedBy: ownerId });
expect((await request(appAs(user(moId))).post(`/api/local/spaces/${spaceId}/archive`)).status).toBe(200);
});
// ── File upload gate ───────────────────────────────────────────────────────
it('editor member CAN upload a file; viewer CANNOT (403); non-member 404', async () => {
const payload = { files: [{ name: 'note.txt', contentBase64: Buffer.from('hi').toString('base64') }] };
expect((await request(appAs(editor())).post(`/api/local/spaces/${spaceId}/files/upload`).send(payload)).status).toBe(200);
expect((await request(appAs(viewer())).post(`/api/local/spaces/${spaceId}/files/upload`).send(payload)).status).toBe(403);
expect((await request(appAs(stranger())).post(`/api/local/spaces/${spaceId}/files/upload`).send(payload)).status).toBe(404);
});
});

View File

@ -69,6 +69,8 @@ function makeHarness(opts: {
* user resolvers read req.user, mirroring how server.ts wires SshApiDeps * user resolvers read req.user, mirroring how server.ts wires SshApiDeps
* when the auth subsystem is disabled. */ * when the auth subsystem is disabled. */
authActive?: boolean; authActive?: boolean;
/** Stub getSpace (spec §11). Defaults to undefined (no space subsystem). */
getSpace?: SshApiDeps['getSpace'];
} = {}): Harness { } = {}): Harness {
const db = makeDb(); const db = makeDb();
const connectionRepo = createConnectionRepo(db); const connectionRepo = createConnectionRepo(db);
@ -94,24 +96,32 @@ function makeHarness(opts: {
next(); next();
}; };
// Stub encryption: store the PEM bytes prefixed with a marker so decrypt // Stub encryption: store the PEM bytes prefixed with a marker that ENCODES the
// can verify roundtrip. Real impl uses src/ssh/crypto.ts. // spaceId (Space-as-Principal P2). Decrypt rebuilds the expected prefix from the
// spaceId it is given and rejects a mismatch — so every create→use flow in this
// suite now also asserts the API threads the SAME spaceId on encrypt and decrypt
// (a wiring regression where the wrong/no spaceId is passed → decrypt throws).
// Real impl uses src/ssh/crypto.ts (space DEK round-trip unit-tested there).
const SAMPLE_PUBKEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTPUBKEY'; const SAMPLE_PUBKEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTPUBKEY';
const encryptKeyMaterial: SshApiDeps['encryptKeyMaterial'] = (_ownerId, pem, passphrase) => ({ const encPrefix = (kind: 'ENC' | 'PEN', spaceId?: string | null) =>
blob: Buffer.concat([Buffer.from('ENC:'), pem]), Buffer.from(`${kind}:${spaceId ?? ''}:`);
passphraseBlob: passphrase ? Buffer.concat([Buffer.from('PEN:'), passphrase]) : null, const encryptKeyMaterial: SshApiDeps['encryptKeyMaterial'] = (_ownerId, pem, passphrase, spaceId) => ({
blob: Buffer.concat([encPrefix('ENC', spaceId), pem]),
passphraseBlob: passphrase ? Buffer.concat([encPrefix('PEN', spaceId), passphrase]) : null,
keyVersion: 1, keyVersion: 1,
fingerprint: SAMPLE_FP, fingerprint: SAMPLE_FP,
publicKey: SAMPLE_PUBKEY, publicKey: SAMPLE_PUBKEY,
}); });
const decryptKeyMaterial: SshApiDeps['decryptKeyMaterial'] = (_ownerId, blob) => { const decryptKeyMaterial: SshApiDeps['decryptKeyMaterial'] = (_ownerId, blob, spaceId) => {
if (!blob.subarray(0, 4).equals(Buffer.from('ENC:'))) throw new Error('decrypt: bad blob'); const p = encPrefix('ENC', spaceId);
return Buffer.from(blob.subarray(4)); if (!blob.subarray(0, p.length).equals(p)) throw new Error('decrypt: bad blob (space mismatch)');
return Buffer.from(blob.subarray(p.length));
}; };
const decryptPassphrase: SshApiDeps['decryptPassphrase'] = (_ownerId, blob) => { const decryptPassphrase: SshApiDeps['decryptPassphrase'] = (_ownerId, blob, spaceId) => {
if (!blob) return null; if (!blob) return null;
if (!blob.subarray(0, 4).equals(Buffer.from('PEN:'))) throw new Error('decrypt: bad pass blob'); const p = encPrefix('PEN', spaceId);
return Buffer.from(blob.subarray(4)); if (!blob.subarray(0, p.length).equals(p)) throw new Error('decrypt: bad pass blob (space mismatch)');
return Buffer.from(blob.subarray(p.length));
}; };
const generateKeypair: SshApiDeps['generateKeypair'] = (keyType) => ({ const generateKeypair: SshApiDeps['generateKeypair'] = (keyType) => ({
privateKeyPem: Buffer.from(`STUB-PEM-${keyType}`, 'utf8'), privateKeyPem: Buffer.from(`STUB-PEM-${keyType}`, 'utf8'),
@ -130,6 +140,7 @@ function makeHarness(opts: {
authActive ? isAdmin : ((req.user as { role?: string } | undefined)?.role === 'admin'), authActive ? isAdmin : ((req.user as { role?: string } | undefined)?.role === 'admin'),
getOrgIds: (req) => getOrgIds: (req) =>
authActive ? (opts.orgIds ?? []) : ((req.user as { orgIds?: string[] } | undefined)?.orgIds ?? []), authActive ? (opts.orgIds ?? []) : ((req.user as { orgIds?: string[] } | undefined)?.orgIds ?? []),
getSpace: opts.getSpace,
connectionRepo, connectionRepo,
grantsRepo, grantsRepo,
auditRepo, auditRepo,
@ -211,7 +222,7 @@ describe('SSH API: auth gating', () => {
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
// No-auth single-user mode: SSH must work without a logged-in user. The // No-auth single-user mode: SSH must work without a logged-in user. The
// router synthesizes a 'local' owner so per-user connection storage has a // router synthesizes a 'local' owner so per-user connection storage has a
// stable key (mirrors notes-api / memory-api / user-folder-api). // stable key (mirrors memory-api / user-folder-api).
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
describe('SSH API: no-auth local fallback (authActive=false)', () => { describe('SSH API: no-auth local fallback (authActive=false)', () => {
@ -468,6 +479,85 @@ describe('SSH API: user CRUD', () => {
}); });
}); });
// ──────────────────────────────────────────────────────────────────────
// Per-space scoping (spec §11)
// ──────────────────────────────────────────────────────────────────────
describe('SSH API: per-space create + list', () => {
it('POST /connections with space_id is visibility-checked and persists the space', async () => {
const seen: string[] = [];
const h = makeHarness({
getSpace: async (spaceId) => {
seen.push(spaceId);
return spaceId === 'space-A' ? { id: 'space-A' } : null;
},
});
const id = await createOwnedConnection(h, { space_id: 'space-A' });
expect(seen).toContain('space-A');
const conn = h.deps.connectionRepo.resolveConnection(id)!;
expect(conn.spaceId).toBe('space-A');
// owner_id is preserved (created_by).
expect(conn.ownerId).toBe('alice');
// Space-as-Principal P2: the stored blob is encrypted under the SPACE DEK
// (space marker present), NOT the owner DEK — proving the API passed the
// connection's spaceId on encrypt. Decrypt with that space succeeds; with a
// different/absent space the (space-aware stub) DEK rejects it.
expect(conn.privateKeyEnc.subarray(0, 'ENC:space-A:'.length).toString()).toBe('ENC:space-A:');
expect(() => h.deps.decryptKeyMaterial(conn.ownerId, conn.privateKeyEnc, 'space-A')).not.toThrow();
expect(() => h.deps.decryptKeyMaterial(conn.ownerId, conn.privateKeyEnc, null)).toThrow();
});
it('POST /connections with an inaccessible space → 404', async () => {
const h = makeHarness({ getSpace: async () => null });
const res = await request(h.app)
.post('/api/ssh/connections')
.send({
label: 'x', host: 'h', port: 22, username: 'u', privateKeyPem: SAMPLE_PEM,
remotePathPrefix: '/home/deploy', space_id: 'space-X',
});
expect(res.status).toBe(404);
expect(res.body.error).toBe('unknown_or_inaccessible_space');
});
it('POST /connections with space_id but no getSpace wired → 400', async () => {
const h = makeHarness(); // getSpace undefined
const res = await request(h.app)
.post('/api/ssh/connections')
.send({
label: 'x', host: 'h', port: 22, username: 'u', privateKeyPem: SAMPLE_PEM,
remotePathPrefix: '/home/deploy', space_id: 'space-A',
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('space_scoped_create_unavailable');
});
it('GET /connections?spaceId returns only that space (visibility-checked)', async () => {
const h = makeHarness({ getSpace: async (id) => ({ id }) });
const a = await createOwnedConnection(h, { space_id: 'space-A', label: 'a' });
await createOwnedConnection(h, { space_id: 'space-B', label: 'b' });
await createOwnedConnection(h, { label: 'legacy' }); // space-less
const res = await request(h.app).get('/api/ssh/connections?spaceId=space-A');
expect(res.status).toBe(200);
const ids = res.body.connections.map((c: { id: string }) => c.id);
expect(ids).toEqual([a]);
});
it('GET /connections?spaceId with an inaccessible space → 404', async () => {
const h = makeHarness({ getSpace: async () => null });
const res = await request(h.app).get('/api/ssh/connections?spaceId=space-X');
expect(res.status).toBe(404);
});
it('GET /connections (no spaceId) keeps the legacy owned+globals list', async () => {
const h = makeHarness({ getSpace: async (id) => ({ id }) });
const legacy = await createOwnedConnection(h, { label: 'legacy' });
const res = await request(h.app).get('/api/ssh/connections');
expect(res.status).toBe(200);
expect(res.body.connections.map((c: { id: string }) => c.id)).toContain(legacy);
});
});
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
// Host key flow // Host key flow
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────

View File

@ -78,11 +78,18 @@ export interface SshApiDeps {
getUserId(req: Request): string | null; getUserId(req: Request): string | null;
isAdmin(req: Request): boolean; isAdmin(req: Request): boolean;
getOrgIds(req: Request): string[]; getOrgIds(req: Request): string[];
/**
* Spaces foundation (spec §11). Authorizes a `space_id` supplied on create /
* list against the requesting viewer and resolves the space's existence.
* When omitted, space-scoped requests are rejected (no way to authorize
* them) while legacy per-user create/list keeps working. Mirrors mcp-api.
*/
getSpace?: (spaceId: string, viewer?: Express.User) => Promise<{ id: string } | null>;
/** /**
* Whether the bridge wired the auth subsystem. When `false` (no-auth * Whether the bridge wired the auth subsystem. When `false` (no-auth
* single-user deployment) requests carry no `req.user`, so the router * single-user deployment) requests carry no `req.user`, so the router
* synthesizes a stable `local` owner otherwise every endpoint 401s on * synthesizes a stable `local` owner otherwise every endpoint 401s on
* the null userId and SSH is unusable. Mirrors notes-api / memory-api / * the null userId and SSH is unusable. Mirrors memory-api /
* user-folder-api. Defaults to `true` for existing (auth-only) callers. * user-folder-api. Defaults to `true` for existing (auth-only) callers.
*/ */
authActive?: boolean; authActive?: boolean;
@ -101,11 +108,16 @@ export interface SshApiDeps {
* stored in ssh_connections. ownerId=null = system DEK (global connection), * stored in ssh_connections. ownerId=null = system DEK (global connection),
* ownerId=<uid> = user DEK. * ownerId=<uid> = user DEK.
*/ */
encryptKeyMaterial(ownerId: string | null, pem: Buffer, passphrase: Buffer | null): SshEncryptResult; encryptKeyMaterial(ownerId: string | null, pem: Buffer, passphrase: Buffer | null, spaceId?: string | null): SshEncryptResult;
/** Decrypt blob using the correct DEK. Buffer must be cleared by caller. */ /**
decryptKeyMaterial(ownerId: string | null, blob: Buffer): Buffer; * Decrypt blob using the correct DEK. Buffer must be cleared by caller.
decryptPassphrase(ownerId: string | null, blob: Buffer | null): Buffer | null; * spaceId (Space-as-Principal P2): when given, the space DEK is tried first
* with a fallback to the owner/system DEK (lets pre-migration owner-encrypted
* blobs of a now-space-owned connection still decrypt).
*/
decryptKeyMaterial(ownerId: string | null, blob: Buffer, spaceId?: string | null): Buffer;
decryptPassphrase(ownerId: string | null, blob: Buffer | null, spaceId?: string | null): Buffer | null;
/** /**
* Generate a fresh keypair (no passphrase). The caller is expected to feed * Generate a fresh keypair (no passphrase). The caller is expected to feed
@ -125,6 +137,7 @@ export interface SshApiDeps {
ownerId: string | null, ownerId: string | null,
blob: Buffer, blob: Buffer,
passphraseBlob: Buffer | null, passphraseBlob: Buffer | null,
spaceId?: string | null,
): string; ): string;
/** SSH dial helper — injected so tests don't need a real SSH server. */ /** SSH dial helper — injected so tests don't need a real SSH server. */
@ -223,6 +236,21 @@ function safeSubjectType(v: unknown): SshGrantSubjectType | null {
return v === 'user' || v === 'org' ? v : null; return v === 'user' || v === 'org' ? v : null;
} }
/**
* Normalize a space id from a request body or query (spec §11). Accepts both
* `space_id` (snake, wire convention) and `spaceId` (camel). Returns: string
* (a space), null (explicit global / space-less), or undefined (not given).
* Mirrors mcp-api.readSpaceId.
*/
function readSpaceId(src: Record<string, unknown> | undefined): string | null | undefined {
if (!src) return undefined;
const raw = (src.space_id ?? src.spaceId) as unknown;
if (raw === undefined) return undefined;
if (raw === null || raw === '') return null;
// 文字列のみ受理(数値等の coerce を避ける)。
return typeof raw === 'string' ? raw : undefined;
}
function presentConnection(conn: SshConnection): Record<string, unknown> { function presentConnection(conn: SshConnection): Record<string, unknown> {
// Strip the encrypted blob fields and the pending verify token (the token // Strip the encrypted blob fields and the pending verify token (the token
// is only surfaced on /test responses; subsequent GETs do not expose it // is only surfaced on /test responses; subsequent GETs do not expose it
@ -315,10 +343,24 @@ export function createSshUserRouter(deps: SshApiDeps): Router {
// - all globals (owner_id IS NULL) — visibility is enforced at tool-call // - all globals (owner_id IS NULL) — visibility is enforced at tool-call
// time via accessResolver, so listing globals here is informational. // time via accessResolver, so listing globals here is informational.
// Admins see all via /api/ssh/admin/connections (separate endpoint). // Admins see all via /api/ssh/admin/connections (separate endpoint).
router.get('/connections', deps.requireAuth, (req, res) => { router.get('/connections', deps.requireAuth, async (req, res) => {
try { try {
const userId = deps.getUserId(req); const userId = deps.getUserId(req);
if (!userId) { jsonError(res, 401, 'unauthorized'); return; } if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
// Space-scoped listing (spec §11): ?spaceId=<id> (or ?space_id=) returns
// ONLY that space's connections, visibility-checked against the viewer.
// Omitting it keeps the legacy per-user list (owned + globals).
const spaceId = readSpaceId(req.query as Record<string, unknown>);
if (typeof spaceId === 'string') {
if (!deps.getSpace) { jsonError(res, 400, 'space_scoped_listing_unavailable'); return; }
const space = await deps.getSpace(spaceId, (req as Request & { user?: Express.User }).user);
if (!space) { jsonError(res, 404, 'unknown_or_inaccessible_space'); return; }
const scoped = deps.connectionRepo.listBySpace(spaceId);
res.json({ connections: scoped.map(presentConnection) });
return;
}
const owned = deps.connectionRepo.listOwned(userId); const owned = deps.connectionRepo.listOwned(userId);
const all = deps.connectionRepo.listAll(); const all = deps.connectionRepo.listAll();
const globals = all.filter((c) => c.ownerId === null); const globals = all.filter((c) => c.ownerId === null);
@ -400,15 +442,38 @@ export function createSshUserRouter(deps: SshApiDeps): Router {
jsonError(res, 400, 'invalid_command_allow_patterns'); return; jsonError(res, 400, 'invalid_command_allow_patterns'); return;
} }
// Per-space scoping (spec §11). When a space is supplied, authorize it
// against the requesting viewer via getSpace BEFORE encrypting/storing.
// owner_id stays = userId (created_by); space_id gates who may USE the
// connection at runtime (space membership) AND — Space-as-Principal P2 —
// selects the SPACE DEK for at-rest encryption so the key survives member
// turnover. Omitting it = legacy space-less (owner-DEK) connection.
const spaceId = readSpaceId(body as Record<string, unknown>);
if (typeof spaceId === 'string') {
if (!deps.getSpace) {
pemBuf.fill(0);
if (passBuf) passBuf.fill(0);
jsonError(res, 400, 'space_scoped_create_unavailable'); return;
}
const space = await deps.getSpace(spaceId, (req as Request & { user?: Express.User }).user);
if (!space) {
pemBuf.fill(0);
if (passBuf) passBuf.fill(0);
jsonError(res, 404, 'unknown_or_inaccessible_space'); return;
}
}
const createSpaceId = typeof spaceId === 'string' ? spaceId : null;
let encrypted: SshEncryptResult; let encrypted: SshEncryptResult;
try { try {
encrypted = deps.encryptKeyMaterial(userId, pemBuf, passBuf); encrypted = deps.encryptKeyMaterial(userId, pemBuf, passBuf, createSpaceId);
} finally { } finally {
pemBuf.fill(0); pemBuf.fill(0);
if (passBuf) passBuf.fill(0); if (passBuf) passBuf.fill(0);
} }
const conn = deps.connectionRepo.create({ const conn = deps.connectionRepo.create({
ownerId: userId, ownerId: userId,
spaceId: typeof spaceId === 'string' ? spaceId : null,
label, host, port, username, label, host, port, username,
privateKeyEnc: encrypted.blob, privateKeyEnc: encrypted.blob,
passphraseEnc: encrypted.passphraseBlob, passphraseEnc: encrypted.passphraseBlob,
@ -453,7 +518,7 @@ export function createSshUserRouter(deps: SshApiDeps): Router {
} }
let publicKey: string | null = null; let publicKey: string | null = null;
try { try {
publicKey = deps.derivePublicKey(conn.ownerId, conn.privateKeyEnc, conn.passphraseEnc); publicKey = deps.derivePublicKey(conn.ownerId, conn.privateKeyEnc, conn.passphraseEnc, conn.spaceId);
} catch (e) { } catch (e) {
// Don't fail the whole response if key derivation fails (e.g. corrupt // Don't fail the whole response if key derivation fails (e.g. corrupt
// blob, missing passphrase) — return the connection without it and log. // blob, missing passphrase) — return the connection without it and log.
@ -522,7 +587,9 @@ export function createSshUserRouter(deps: SshApiDeps): Router {
let pemBuf = Buffer.from(body.privateKeyPem, 'utf8'); let pemBuf = Buffer.from(body.privateKeyPem, 'utf8');
let passBuf = typeof body.passphrase === 'string' ? Buffer.from(body.passphrase, 'utf8') : null; let passBuf = typeof body.passphrase === 'string' ? Buffer.from(body.passphrase, 'utf8') : null;
try { try {
const enc = deps.encryptKeyMaterial(userId, pemBuf, passBuf); // Re-encrypt under the connection's current DEK domain (space DEK when
// space-owned) so a key rotation keeps the space-ownership property.
const enc = deps.encryptKeyMaterial(userId, pemBuf, passBuf, conn.spaceId);
patch.privateKeyEnc = enc.blob; patch.privateKeyEnc = enc.blob;
patch.passphraseEnc = enc.passphraseBlob; patch.passphraseEnc = enc.passphraseBlob;
patch.keyVersion = enc.keyVersion; patch.keyVersion = enc.keyVersion;
@ -613,8 +680,8 @@ export function createSshUserRouter(deps: SshApiDeps): Router {
actingUserId: userId, actingUserId: userId,
detail: { op: 'test' }, detail: { op: 'test' },
}); });
decryptedKey = deps.decryptKeyMaterial(conn.ownerId, conn.privateKeyEnc); decryptedKey = deps.decryptKeyMaterial(conn.ownerId, conn.privateKeyEnc, conn.spaceId);
passphrase = deps.decryptPassphrase(conn.ownerId, conn.passphraseEnc); passphrase = deps.decryptPassphrase(conn.ownerId, conn.passphraseEnc, conn.spaceId);
const result = await deps.sshTester.test({ const result = await deps.sshTester.test({
connection: conn, connection: conn,
decryptedKey, decryptedKey,
@ -846,7 +913,7 @@ export function createSshAdminRouter(deps: SshApiDeps): Router {
if (!conn) { jsonError(res, 404, 'not_found'); return; } if (!conn) { jsonError(res, 404, 'not_found'); return; }
let publicKey: string | null = null; let publicKey: string | null = null;
try { try {
publicKey = deps.derivePublicKey(conn.ownerId, conn.privateKeyEnc, conn.passphraseEnc); publicKey = deps.derivePublicKey(conn.ownerId, conn.privateKeyEnc, conn.passphraseEnc, conn.spaceId);
} catch (e) { } catch (e) {
logger.warn(`[ssh:api] admin derive public key failed id=${conn.id} err=${String(e)}`); logger.warn(`[ssh:api] admin derive public key failed id=${conn.id} err=${String(e)}`);
} }

View File

@ -3,7 +3,7 @@ import { existsSync, readFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { type Repository, type Job, localTaskRepoName } from '../db/repository.js'; import { type Repository, type Job, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { canViewTask } from './local-api-helpers.js'; import { canViewTask, resolveSpaceAccess } from './local-api-helpers.js';
const MAX_ACTIVITY_LOG_CHARS = 4000; const MAX_ACTIVITY_LOG_CHARS = 4000;
@ -30,7 +30,7 @@ export function createSubtaskActivityRouter(repo: Repository): Router {
const taskId = Number(req.params.id); const taskId = Number(req.params.id);
const viewer = req.user as Express.User | undefined; const viewer = req.user as Express.User | undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return; if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId); const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
if (!latestJob) { res.status(404).json({ error: 'No job found' }); return; } if (!latestJob) { res.status(404).json({ error: 'No job found' }); return; }
@ -72,7 +72,7 @@ export function createSubtaskActivityRouter(repo: Repository): Router {
const viewer = (req.user as Express.User | undefined) ?? undefined; const viewer = (req.user as Express.User | undefined) ?? undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return; if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
// jobId で直接取得(孫タスクにも対応) // jobId で直接取得(孫タスクにも対応)
const job = await repo.getJob(jobId, viewer ? { viewer } : undefined); const job = await repo.getJob(jobId, viewer ? { viewer } : undefined);

View File

@ -4,7 +4,7 @@ import { resolve, sep } from 'path';
import { Repository } from '../db/repository.js'; import { Repository } from '../db/repository.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { parseTaskId } from './validation.js'; import { parseTaskId } from './validation.js';
import { canViewTask, setUntrustedFileResponseHeaders } from './local-api-helpers.js'; import { canViewTask, resolveSpaceAccess, setUntrustedFileResponseHeaders } from './local-api-helpers.js';
export function mountSubtaskFilesApi(app: Application, repo: Repository): void { export function mountSubtaskFilesApi(app: Application, repo: Repository): void {
@ -20,7 +20,7 @@ export function mountSubtaskFilesApi(app: Application, repo: Repository): void {
const viewer = (req.user as Express.User | undefined) ?? undefined; const viewer = (req.user as Express.User | undefined) ?? undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return; if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
// jobId で直接取得(孫タスクにも対応) // jobId で直接取得(孫タスクにも対応)
const subJob = await repo.getJob(jobId, viewer ? { viewer } : undefined); const subJob = await repo.getJob(jobId, viewer ? { viewer } : undefined);
@ -64,7 +64,7 @@ export function mountSubtaskFilesApi(app: Application, repo: Repository): void {
const viewer = (req.user as Express.User | undefined) ?? undefined; const viewer = (req.user as Express.User | undefined) ?? undefined;
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
if (!canViewTask(req, res, task)) return; if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
// jobId で直接取得(孫タスクにも対応) // jobId で直接取得(孫タスクにも対応)
const subJob = await repo.getJob(jobId, viewer ? { viewer } : undefined); const subJob = await repo.getJob(jobId, viewer ? { viewer } : undefined);

View File

@ -89,7 +89,6 @@ const MODULE_SPECS: Array<{ category: string; specifier: string }> = [
{ category: 'amazon', specifier: '../engine/tools/amazon.js' }, { category: 'amazon', specifier: '../engine/tools/amazon.js' },
{ category: 'speech', specifier: '../engine/tools/speech.js' }, { category: 'speech', specifier: '../engine/tools/speech.js' },
{ category: 'checklist', specifier: '../engine/tools/checklist.js' }, { category: 'checklist', specifier: '../engine/tools/checklist.js' },
{ category: 'knowledge', specifier: '../engine/tools/knowledge.js' },
{ category: 'ms-learn', specifier: '../engine/tools/ms-learn.js' }, { category: 'ms-learn', specifier: '../engine/tools/ms-learn.js' },
{ category: 'slide', specifier: '../engine/tools/slide.js' }, { category: 'slide', specifier: '../engine/tools/slide.js' },
{ category: 'docs', specifier: '../engine/tools/docs.js' }, { category: 'docs', specifier: '../engine/tools/docs.js' },
@ -99,8 +98,6 @@ const MODULE_SPECS: Array<{ category: string; specifier: string }> = [
{ category: 'app-docs', specifier: '../engine/tools/app-docs.js' }, { category: 'app-docs', specifier: '../engine/tools/app-docs.js' },
{ category: 'ssh', specifier: '../engine/tools/ssh.js' }, { category: 'ssh', specifier: '../engine/tools/ssh.js' },
{ category: 'ssh', specifier: '../engine/tools/ssh-console.js' }, { category: 'ssh', specifier: '../engine/tools/ssh-console.js' },
{ category: 'notes', specifier: '../engine/tools/notes.js' },
{ category: 'dashboard', specifier: '../engine/tools/dashboard.js' },
{ category: 'skills', specifier: '../engine/tools/skills.js' }, { category: 'skills', specifier: '../engine/tools/skills.js' },
]; ];

View File

@ -278,3 +278,51 @@ describe('GET /api/usage/daily — tzOffset local re-bucketing', () => {
expect(onLocalDay.body.totals.direct).toMatchObject({ tokensIn: 7 }); expect(onLocalDay.body.totals.direct).toMatchObject({ tokensIn: 7 });
}); });
}); });
describe('GET /api/usage/daily granularity=hour (time-of-day profile)', () => {
let repo: Repository;
beforeEach(() => {
repo = new Repository(':memory:');
seed(repo, [
{ hour: '2026-06-10T09', userId: 'u1', source: 'gateway', tin: 10, tout: 1 },
{ hour: '2026-06-11T09', userId: 'u1', source: 'gateway', tin: 20, tout: 2 }, // same hour-of-day, next day
{ hour: '2026-06-10T23', userId: 'u1', source: 'direct', tin: 5, tout: 1 },
]);
});
it('folds all days in range into 24 hour-of-day buckets (HH)', async () => {
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-11&granularity=hour');
expect(res.status).toBe(200);
expect(res.body.granularity).toBe('hour');
expect(res.body.series.every((s: { bucket: string }) => /^\d\d$/.test(s.bucket))).toBe(true);
const b09 = res.body.series.find((s: { bucket: string }) => s.bucket === '09');
expect(b09.segments.gateway).toMatchObject({ tokensIn: 30, tokensOut: 3, requests: 2 });
const b23 = res.body.series.find((s: { bucket: string }) => s.bucket === '23');
expect(b23.segments.direct).toMatchObject({ tokensIn: 5, tokensOut: 1 });
});
it('re-buckets by LOCAL hour under tzOffset (JST +540 shifts T23→08, T09→18)', async () => {
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-12&granularity=hour&tzOffset=540');
const b08 = res.body.series.find((s: { bucket: string }) => s.bucket === '08');
expect(b08?.segments.direct).toMatchObject({ tokensIn: 5, tokensOut: 1 }); // UTC 06-10T23 -> JST 06-11 08:00
const b18 = res.body.series.find((s: { bucket: string }) => s.bucket === '18');
expect(b18?.segments.gateway).toMatchObject({ tokensIn: 30, tokensOut: 3 }); // both T09 UTC -> 18:00 JST
});
});
describe('GET /api/usage/daily source=gateway_downstream', () => {
it('keeps gateway_downstream as its own series, not collapsed into direct', async () => {
const repo = new Repository(':memory:');
repo.incrementLlmUsageHourly({ hour: '2026-06-10T12', userId: 'gw:teamA', source: 'gateway_downstream', model: 'm', route: 'b1', tokensIn: 100, tokensOut: 50, requests: 1 });
repo.incrementLlmUsageHourly({ hour: '2026-06-10T12', userId: 'u1', source: 'gateway', model: 'm', route: 'b1', tokensIn: 1, tokensOut: 1, requests: 1 });
const app = makeApp(repo, { authActive: false });
const res = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-10&groupBy=source');
expect(res.status).toBe(200);
expect(res.body.keys).toContain('gateway_downstream');
expect(res.body.totals.gateway_downstream).toMatchObject({ tokensIn: 100, tokensOut: 50, requests: 1 });
expect(res.body.totals.direct).toBeUndefined(); // downstream is NOT mislabeled as direct
expect(res.body.totals.gateway).toMatchObject({ tokensIn: 1, tokensOut: 1 });
});
});

View File

@ -35,7 +35,7 @@ const MAX_RANGE_DAYS = 800; // ~2y guard so a hand-crafted range can't scan unbo
const MAX_TZ_OFFSET = 14 * 60; // clamp to the real-world UTC-14..+14 envelope const MAX_TZ_OFFSET = 14 * 60; // clamp to the real-world UTC-14..+14 envelope
const MAX_SERIES = 12; // cap distinct series; the rest fold into an 'other' bucket const MAX_SERIES = 12; // cap distinct series; the rest fold into an 'other' bucket
type Granularity = 'day' | 'week' | 'month'; type Granularity = 'hour' | 'day' | 'week' | 'month';
type GroupBy = 'source' | 'model' | 'route' | 'user' | 'org'; type GroupBy = 'source' | 'model' | 'route' | 'user' | 'org';
const GROUP_BYS: readonly GroupBy[] = ['source', 'model', 'route', 'user', 'org']; const GROUP_BYS: readonly GroupBy[] = ['source', 'model', 'route', 'user', 'org'];
@ -107,6 +107,16 @@ function localDayOf(utcHour: string, tzOffsetMin: number): string {
return new Date(ms).toISOString().slice(0, 10); return new Date(ms).toISOString().slice(0, 10);
} }
/**
* Local hour-of-day '00'..'23' of a UTC hour for a viewer at tzOffsetMin. Used
* by granularity='hour' to fold every day in the range into a 24-bucket
* time-of-day profile ("what time of day is the backend busy").
*/
function localHourOfDay(utcHour: string, tzOffsetMin: number): string {
const ms = Date.parse(`${utcHour}:00:00.000Z`) + tzOffsetMin * 60_000;
return new Date(ms).toISOString().slice(11, 13);
}
function parseTzOffset(raw: unknown): number { function parseTzOffset(raw: unknown): number {
const n = typeof raw === 'string' ? parseInt(raw, 10) : NaN; const n = typeof raw === 'string' ? parseInt(raw, 10) : NaN;
if (!Number.isFinite(n)) return 0; if (!Number.isFinite(n)) return 0;
@ -141,7 +151,11 @@ function dimensionOf(
return orgMap.get(row.userId) ?? 'no-org'; return orgMap.get(row.userId) ?? 'no-org';
case 'source': case 'source':
default: default:
return row.source === 'gateway' ? 'gateway' : 'direct'; // Known sources pass through verbatim (incl. 'gateway_downstream' for
// external gateway consumers); anything unexpected folds into 'direct'.
return row.source === 'gateway' || row.source === 'gateway_downstream'
? row.source
: 'direct';
} }
} }
@ -163,7 +177,8 @@ export function createUsageRouter(repo: Repository, opts: { authActive: boolean
return; return;
} }
const gq = req.query['granularity']; const gq = req.query['granularity'];
const granularity: Granularity = gq === 'week' || gq === 'month' ? gq : 'day'; const granularity: Granularity =
gq === 'hour' || gq === 'week' || gq === 'month' ? gq : 'day';
const gbq = req.query['groupBy']; const gbq = req.query['groupBy'];
const groupBy: GroupBy = const groupBy: GroupBy =
typeof gbq === 'string' && (GROUP_BYS as readonly string[]).includes(gbq) typeof gbq === 'string' && (GROUP_BYS as readonly string[]).includes(gbq)
@ -193,7 +208,11 @@ export function createUsageRouter(repo: Repository, opts: { authActive: boolean
for (const row of rows) { for (const row of rows) {
const localDay = localDayOf(row.hour, tzOffset); const localDay = localDayOf(row.hour, tzOffset);
if (localDay < from || localDay > to) continue; // precise local-range filter if (localDay < from || localDay > to) continue; // precise local-range filter
const bk = bucketKey(localDay, granularity); // hour granularity folds the whole range into 24 local hour-of-day
// buckets; the other granularities bucket the local calendar day.
const bk = granularity === 'hour'
? localHourOfDay(row.hour, tzOffset)
: bucketKey(localDay, granularity);
const dim = dimensionOf(row, groupBy, orgMap); const dim = dimensionOf(row, groupBy, orgMap);
let seg = buckets.get(bk); let seg = buckets.get(bk);
@ -218,7 +237,7 @@ export function createUsageRouter(repo: Repository, opts: { authActive: boolean
// tail into a visible 'other' bucket so the legend/palette stay sane. // tail into a visible 'other' bucket so the legend/palette stay sane.
let keys: string[]; let keys: string[];
if (groupBy === 'source') { if (groupBy === 'source') {
keys = ['gateway', 'direct'].filter((k) => totals.has(k)); keys = ['gateway', 'direct', 'gateway_downstream'].filter((k) => totals.has(k));
} else { } else {
const ordered = Array.from(totals.keys()).sort( const ordered = Array.from(totals.keys()).sort(
(a, b) => localTotal(totals.get(b)!) - localTotal(totals.get(a)!), (a, b) => localTotal(totals.get(b)!) - localTotal(totals.get(a)!),

View File

@ -1,8 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express'; import express from 'express';
import request from 'supertest'; import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs'; import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
import { join } from 'path'; import { join, dirname } from 'path';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { createUserFolderApi } from './user-folder-api.js'; import { createUserFolderApi } from './user-folder-api.js';
@ -108,3 +108,116 @@ describe('User Folder API — AGENTS.md routes', () => {
expect(res.body.existed).toBe(false); expect(res.body.existed).toBe(false);
}); });
}); });
// ---------------------------------------------------------------------------
// Spaces foundation: optional spaceId scoping (mirrors pieces-api)
// - no spaceId → per-user folder (byte-identical regression)
// - case-space spaceId → data/spaces/{id}/AGENTS.md
// - unresolvable spaceId → 404, never silently falls back to user folder
// ---------------------------------------------------------------------------
describe('User Folder API — AGENTS.md spaceId scoping', () => {
const USER_A = 'user-a';
const CASE_SPACE = 'space-case-1';
let tmpRoot: string;
let userFolderRoot: string;
/**
* Stub resolver mirroring src/spaces/folder-resolver.ts semantics:
* personal { rootDir: userFolderRoot, leafId: ownerId } (same user folder)
* case { rootDir: <userFolderRoot>/../spaces, leafId: spaceId }
* unknown null (not visible / not found)
*/
function makeResolver(visibleIds: Set<string>) {
const spacesRoot = join(dirname(userFolderRoot), 'spaces');
return async (spaceId: string) => {
if (!visibleIds.has(spaceId)) return null;
if (spaceId === 'personal') return { rootDir: userFolderRoot, leafId: USER_A };
return { rootDir: spacesRoot, leafId: spaceId };
};
}
function makeSpaceApp(
resolveSpaceFolder?: (spaceId: string) => Promise<{ rootDir: string; leafId: string } | null>,
): express.Application {
const app = express();
app.use((req, _res, next) => {
(req as any).user = { id: USER_A, role: 'user' };
next();
});
app.use('/api/users/me', createUserFolderApi({ userFolderRoot, resolveSpaceFolder }));
return app;
}
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), 'agents-md-space-'));
userFolderRoot = join(tmpRoot, 'data', 'users');
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
// (b) regression — no spaceId hits the per-user folder
it('without spaceId writes/reads under the per-user folder', async () => {
const app = makeSpaceApp(makeResolver(new Set([CASE_SPACE])));
const text = '# personal\n';
const put = await request(app)
.put('/api/users/me/agents-md')
.set('Content-Type', 'text/plain')
.send(text);
expect(put.status).toBe(200);
expect(existsSync(join(userFolderRoot, USER_A, 'AGENTS.md'))).toBe(true);
expect(existsSync(join(tmpRoot, 'data', 'spaces', CASE_SPACE, 'AGENTS.md'))).toBe(false);
const get = await request(app).get('/api/users/me/agents-md');
expect(get.body).toEqual({ exists: true, content: text });
});
// (a) case-space spaceId → data/spaces/{id}/AGENTS.md
it('with a case-space spaceId writes/reads/deletes under data/spaces/{id}', async () => {
const app = makeSpaceApp(makeResolver(new Set([CASE_SPACE])));
const text = '# shared space instructions\n';
const put = await request(app)
.put(`/api/users/me/agents-md?spaceId=${CASE_SPACE}`)
.set('Content-Type', 'text/plain')
.send(text);
expect(put.status).toBe(200);
const spaceFile = join(tmpRoot, 'data', 'spaces', CASE_SPACE, 'AGENTS.md');
expect(existsSync(spaceFile)).toBe(true);
expect(readFileSync(spaceFile, 'utf-8')).toBe(text);
// The per-user folder must be untouched.
expect(existsSync(join(userFolderRoot, USER_A, 'AGENTS.md'))).toBe(false);
const get = await request(app).get(`/api/users/me/agents-md?spaceId=${CASE_SPACE}`);
expect(get.body).toEqual({ exists: true, content: text });
const del = await request(app).delete(`/api/users/me/agents-md?spaceId=${CASE_SPACE}`);
expect(del.status).toBe(200);
expect(del.body).toEqual({ ok: true, existed: true });
expect(existsSync(spaceFile)).toBe(false);
});
// (c) unresolvable / forbidden spaceId → 404, no fallback
it('returns 404 for a spaceId not visible to the viewer (GET/PUT/DELETE)', async () => {
const app = makeSpaceApp(makeResolver(new Set([CASE_SPACE])));
const get = await request(app).get('/api/users/me/agents-md?spaceId=nope');
expect(get.status).toBe(404);
const put = await request(app)
.put('/api/users/me/agents-md?spaceId=nope')
.set('Content-Type', 'text/plain')
.send('# x\n');
expect(put.status).toBe(404);
expect(existsSync(join(userFolderRoot, USER_A, 'AGENTS.md'))).toBe(false);
const del = await request(app).delete('/api/users/me/agents-md?spaceId=nope');
expect(del.status).toBe(404);
});
});

View File

@ -9,8 +9,6 @@ import { createUserFolderApi } from './user-folder-api.js';
import { recorder } from '../engine/browser-recorder.js'; import { recorder } from '../engine/browser-recorder.js';
import AdmZip from 'adm-zip'; import AdmZip from 'adm-zip';
import { runMigrations } from '../db/migrate.js'; import { runMigrations } from '../db/migrate.js';
import { NotesRepository } from '../notes/notes-repository.js';
import { NotesService } from '../notes/notes-service.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@ -18,17 +16,28 @@ import { NotesService } from '../notes/notes-service.js';
function makeApp(userId: string, userFolderRoot: string): express.Application { function makeApp(userId: string, userFolderRoot: string): express.Application {
const tmpDb = new Database(':memory:'); const tmpDb = new Database(':memory:');
// Seed the users table (normally created by schema.sql before runMigrations).
tmpDb.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT,
avatar_url TEXT,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
runMigrations(tmpDb); runMigrations(tmpDb);
tmpDb.prepare(`INSERT OR IGNORE INTO users (id, email) VALUES (?, ?)`).run(userId, `${userId}@x.com`); tmpDb.prepare(`INSERT OR IGNORE INTO users (id, email) VALUES (?, ?)`).run(userId, `${userId}@x.com`);
const repo = new NotesRepository(tmpDb);
const notesService = new NotesService({ db: tmpDb, repo, userFolderRoot, getUserOrgIds: () => ['team1'] });
const app = express(); const app = express();
// Inject a fake req.user // Inject a fake req.user
app.use((req, _res, next) => { app.use((req, _res, next) => {
(req as any).user = { id: userId, role: 'user', orgIds: ['team1'] }; (req as any).user = { id: userId, role: 'user', orgIds: ['team1'] };
next(); next();
}); });
app.use('/api/users/me', createUserFolderApi({ userFolderRoot, notesService })); app.use('/api/users/me', createUserFolderApi({ userFolderRoot }));
return app; return app;
} }
@ -1085,36 +1094,21 @@ module.exports = async function main({ params }) { return params.username; };
}); });
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// PUT /folder/file with subdir=notes // Notes feature removed (2026-06): the `notes` subdir is no longer writable.
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
describe('PUT /folder/file with subdir=notes', () => { describe('PUT /folder/file with subdir=notes (removed feature)', () => {
it('writes note and creates DB index row via notes-service', async () => { it('rejects PUT to notes subdir (no longer a writable subdir)', async () => {
const noteContent = `---\ntitle: Test\nvisibility: public\n---\nbody content`;
const res = await request(app) const res = await request(app)
.put('/api/users/me/folder/file?subdir=notes&path=cve/foo.md') .put('/api/users/me/folder/file?subdir=notes&path=cve/foo.md')
.set('Content-Type', 'text/plain') .set('Content-Type', 'text/plain')
.send(noteContent);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.indexed).toBe(true);
const filePath = join(tmpRoot, USER_A, 'notes', 'cve', 'foo.md');
expect(existsSync(filePath)).toBe(true);
});
it('rejects PUT with depth-1 path (no folder)', async () => {
const res = await request(app)
.put('/api/users/me/folder/file?subdir=notes&path=foo.md')
.set('Content-Type', 'text/plain')
.send('body'); .send('body');
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
it('rejects PUT with depth-3 path', async () => { it('rejects DELETE to notes subdir (no longer a writable subdir)', async () => {
const res = await request(app) const res = await request(app)
.put('/api/users/me/folder/file?subdir=notes&path=a/b/c.md') .delete('/api/users/me/folder/file?subdir=notes&path=cve/foo.md');
.set('Content-Type', 'text/plain')
.send('body');
expect(res.status).toBe(400); expect(res.status).toBe(400);
}); });
}); });

View File

@ -33,14 +33,24 @@ import {
slugifyPetId, slugifyPetId,
writePetSettings, writePetSettings,
} from '../user-folder/pets.js'; } from '../user-folder/pets.js';
import type { NotesService } from '../notes/notes-service.js';
interface Deps { interface Deps {
userFolderRoot: string; userFolderRoot: string;
sessRepo?: BrowserSessionRepo; sessRepo?: BrowserSessionRepo;
masterKeyPath?: string; masterKeyPath?: string;
authActive?: boolean; // default true; when false, fall back to synthetic 'local' user authActive?: boolean; // default true; when false, fall back to synthetic 'local' user
notesService?: NotesService; /**
* Spaces foundation: optional resolver mapping a `spaceId` (from the request
* query/body) to the `{ rootDir, leafId }` folder whose AGENTS.md lives under
* `userRoot(rootDir, leafId)`. Returns `null` when the space is not visible to
* the viewer (treated as 404 never silently falls back to the user folder).
* When omitted (or no spaceId on the request), AGENTS.md resolution uses the
* legacy per-user folder (`userFolderRoot`, `user.id`) byte-identical to today.
*/
resolveSpaceFolder?: (
spaceId: string,
viewer?: Express.User,
) => Promise<{ rootDir: string; leafId: string } | null>;
} }
interface AuthedUser { id: string; role: string; } interface AuthedUser { id: string; role: string; }
@ -49,6 +59,16 @@ function getUser(req: Request): AuthedUser | null {
return (req.user as AuthedUser | undefined) ?? null; return (req.user as AuthedUser | undefined) ?? null;
} }
/** Extract a spaceId from the request (query param wins over body). */
function getSpaceId(req: Request): string | undefined {
const fromQuery = typeof req.query?.['spaceId'] === 'string' ? (req.query['spaceId'] as string) : undefined;
const fromBody =
req.body && typeof (req.body as Record<string, unknown>)['spaceId'] === 'string'
? ((req.body as Record<string, unknown>)['spaceId'] as string)
: undefined;
return fromQuery ?? fromBody ?? undefined;
}
const MAX_FILE_BYTES = 1024 * 1024; // 1 MB const MAX_FILE_BYTES = 1024 * 1024; // 1 MB
function isUserSubdir(s: string): s is UserSubdir { function isUserSubdir(s: string): s is UserSubdir {
@ -56,9 +76,7 @@ function isUserSubdir(s: string): s is UserSubdir {
} }
// Subdirs that users may write to / delete from. 'trash' is system-managed. // Subdirs that users may write to / delete from. 'trash' is system-managed.
// 'notes' is included here so the PUT/DELETE whitelist accepts it; those handlers const WRITABLE_SUBDIRS = ['browser-macros', 'recordings'] as const;
// then delegate immediately to NotesService rather than the generic file writer.
const WRITABLE_SUBDIRS = ['browser-macros', 'recordings', 'notes'] as const;
type WritableSubdir = typeof WRITABLE_SUBDIRS[number]; type WritableSubdir = typeof WRITABLE_SUBDIRS[number];
function isWritableSubdir(s: string): s is WritableSubdir { function isWritableSubdir(s: string): s is WritableSubdir {
return (WRITABLE_SUBDIRS as readonly string[]).includes(s); return (WRITABLE_SUBDIRS as readonly string[]).includes(s);
@ -100,6 +118,26 @@ export function createUserFolderApi(deps: Deps): Router {
const { userFolderRoot } = deps; const { userFolderRoot } = deps;
const r = Router(); const r = Router();
/**
* Resolve the AGENTS.md folder for a request, honoring an optional `spaceId`.
* - No spaceId legacy per-user folder (`userFolderRoot`, `user.id`).
* - spaceId + resolver space folder when visible; `{ forbidden: true }` when
* the resolver returns null (not-found / not-visible). Never falls back to
* the user folder for an unresolvable spaceId.
*/
async function resolveAgentsFolder(
req: Request,
u: AuthedUser,
): Promise<{ rootDir: string; leafId: string } | { forbidden: true }> {
const spaceId = getSpaceId(req);
if (spaceId && deps.resolveSpaceFolder) {
const fc = await deps.resolveSpaceFolder(spaceId, req.user);
if (!fc) return { forbidden: true };
return { rootDir: fc.rootDir, leafId: fc.leafId };
}
return { rootDir: userFolderRoot, leafId: u.id };
}
// ── Auth gate ──────────────────────────────────────────────────────────── // ── Auth gate ────────────────────────────────────────────────────────────
const authActive = deps.authActive ?? true; const authActive = deps.authActive ?? true;
r.use((req: Request, res: Response, next) => { r.use((req: Request, res: Response, next) => {
@ -107,7 +145,14 @@ export function createUserFolderApi(deps: Deps): Router {
// Local-dev / no-auth mode: inject a synthetic 'local' user so handlers // Local-dev / no-auth mode: inject a synthetic 'local' user so handlers
// can operate against data/users/local/. Real OAuth deployments are // can operate against data/users/local/. Real OAuth deployments are
// unaffected because authActive=true and Passport populates req.user. // unaffected because authActive=true and Passport populates req.user.
(req as any).user = { id: 'local', role: 'user' }; //
// role:'admin' + orgIds:[] mirrors the no-auth convention used by
// space-api / deserializeUser (server.ts): under no-auth, spaces are
// created with owner_id=NULL, so only the admin visibility clause (1=1)
// can resolve them. The space resolver (resolveSpaceFolder → getSpace)
// runs buildVisibilityWhere; with role:'user' it would 404 the user's own
// no-auth spaces, and a missing orgIds crashes on `.length`.
(req as any).user = { id: 'local', role: 'admin', orgIds: [] };
} }
if (!getUser(req)) { if (!getUser(req)) {
res.status(401).json({ error: 'Unauthenticated' }); res.status(401).json({ error: 'Unauthenticated' });
@ -230,8 +275,8 @@ export function createUserFolderApi(deps: Deps): Router {
} }
}); });
// ── GET /folder/list?subdir=scripts ────────────────────────────────────── // ── GET /folder/list?subdir=scripts[&spaceId=...] ─────────────────────────
r.get('/folder/list', (req: Request, res: Response) => { r.get('/folder/list', async (req: Request, res: Response) => {
const u = getUser(req)!; const u = getUser(req)!;
const subdir = req.query['subdir'] as string | undefined; const subdir = req.query['subdir'] as string | undefined;
@ -240,15 +285,24 @@ export function createUserFolderApi(deps: Deps): Router {
return; return;
} }
// Space-aware: spaceId scopes the listing to that space's folder (gated by
// resolveSpaceFolder visibility). No spaceId → personal folder.
const folder = await resolveAgentsFolder(req, u);
if ('forbidden' in folder) {
res.status(404).json({ error: 'space not found' });
return;
}
const { rootDir, leafId } = folder;
try { try {
ensureUserFolder(userFolderRoot, u.id); ensureUserFolder(rootDir, leafId);
} catch (err) { } catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`); logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' }); res.status(500).json({ error: 'Failed to ensure user folder' });
return; return;
} }
const dirPath = join(userRoot(userFolderRoot, u.id), subdir); const dirPath = join(userRoot(rootDir, leafId), subdir);
try { try {
const entries = readdirSync(dirPath, { withFileTypes: true }); const entries = readdirSync(dirPath, { withFileTypes: true });
@ -269,8 +323,8 @@ export function createUserFolderApi(deps: Deps): Router {
} }
}); });
// ── GET /folder/file?subdir=scripts&path=foo.js ────────────────────────── // ── GET /folder/file?subdir=scripts&path=foo.js[&spaceId=...] ─────────────
r.get('/folder/file', (req: Request, res: Response) => { r.get('/folder/file', async (req: Request, res: Response) => {
const u = getUser(req)!; const u = getUser(req)!;
const subdir = req.query['subdir'] as string | undefined; const subdir = req.query['subdir'] as string | undefined;
const relPath = req.query['path'] as string | undefined; const relPath = req.query['path'] as string | undefined;
@ -284,8 +338,15 @@ export function createUserFolderApi(deps: Deps): Router {
return; return;
} }
const folder = await resolveAgentsFolder(req, u);
if ('forbidden' in folder) {
res.status(404).json({ error: 'space not found' });
return;
}
const { rootDir, leafId } = folder;
try { try {
ensureUserFolder(userFolderRoot, u.id); ensureUserFolder(rootDir, leafId);
} catch (err) { } catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`); logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' }); res.status(500).json({ error: 'Failed to ensure user folder' });
@ -294,7 +355,7 @@ export function createUserFolderApi(deps: Deps): Router {
let fullPath: string; let fullPath: string;
try { try {
fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath); fullPath = resolveUserSubdir(rootDir, leafId, subdir, relPath);
} catch { } catch {
res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' }); res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' });
return; return;
@ -334,7 +395,7 @@ export function createUserFolderApi(deps: Deps): Router {
} }
}); });
// ── PUT /folder/file?subdir=scripts&path=foo.js ────────────────────────── // ── PUT /folder/file?subdir=scripts&path=foo.js[&spaceId=...] ─────────────
r.put('/folder/file', express.text({ limit: '1mb', type: '*/*' }), async (req: Request, res: Response) => { r.put('/folder/file', express.text({ limit: '1mb', type: '*/*' }), async (req: Request, res: Response) => {
const u = getUser(req)!; const u = getUser(req)!;
const subdir = req.query['subdir'] as string | undefined; const subdir = req.query['subdir'] as string | undefined;
@ -349,37 +410,17 @@ export function createUserFolderApi(deps: Deps): Router {
return; return;
} }
// ── notes subdir: delegate entirely to NotesService ────────────────── // Space-aware (mirrors AGENTS.md / memory write routes): spaceId targets the
if (subdir === 'notes') { // space folder, gated by the resolver's visibility check.
if (!deps.notesService) { const folder = await resolveAgentsFolder(req, u);
res.status(500).json({ error: 'notesService is not configured; cannot write notes' }); if ('forbidden' in folder) {
return; res.status(404).json({ error: 'space not found' });
}
// Validate path: must be exactly 2 segments (<folder>/<file.md>)
const segments = relPath.split('/').filter(s => s.length > 0);
if (segments.length !== 2) {
res.status(400).json({ error: 'notes path must be exactly <folder>/<file.md>' });
return;
}
const [folder, fileName] = segments as [string, string];
const content = typeof req.body === 'string' ? req.body : '';
try {
deps.notesService.writeNote({ ownerId: u.id, folder, fileName, content });
res.json({ ok: true, indexed: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/scope_org_id|invalid|path|\.md/.test(msg)) {
res.status(400).json({ error: msg });
return;
}
logger.error(`[user-folder-api] notes write failed user=${u.id} path=${relPath} err=${err}`);
res.status(500).json({ error: 'Failed to write note' });
}
return; return;
} }
const { rootDir, leafId } = folder;
try { try {
ensureUserFolder(userFolderRoot, u.id); ensureUserFolder(rootDir, leafId);
} catch (err) { } catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`); logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' }); res.status(500).json({ error: 'Failed to ensure user folder' });
@ -388,7 +429,7 @@ export function createUserFolderApi(deps: Deps): Router {
let fullPath: string; let fullPath: string;
try { try {
fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath); fullPath = resolveUserSubdir(rootDir, leafId, subdir, relPath);
} catch { } catch {
res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' }); res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' });
return; return;
@ -406,7 +447,7 @@ export function createUserFolderApi(deps: Deps): Router {
} }
}); });
// ── DELETE /folder/file?subdir=scripts&path=foo.js ─────────────────────── // ── DELETE /folder/file?subdir=scripts&path=foo.js[&spaceId=...] ──────────
r.delete('/folder/file', async (req: Request, res: Response) => { r.delete('/folder/file', async (req: Request, res: Response) => {
const u = getUser(req)!; const u = getUser(req)!;
const subdir = req.query['subdir'] as string | undefined; const subdir = req.query['subdir'] as string | undefined;
@ -421,35 +462,15 @@ export function createUserFolderApi(deps: Deps): Router {
return; return;
} }
// ── notes subdir: delegate entirely to NotesService ────────────────── const folder = await resolveAgentsFolder(req, u);
if (subdir === 'notes') { if ('forbidden' in folder) {
if (!deps.notesService) { res.status(404).json({ error: 'space not found' });
res.status(500).json({ error: 'notesService is not configured; cannot delete notes' });
return;
}
const segments = relPath.split('/').filter(s => s.length > 0);
if (segments.length !== 2) {
res.status(400).json({ error: 'notes path must be exactly <folder>/<file.md>' });
return;
}
const [folder, fileName] = segments as [string, string];
try {
deps.notesService.deleteNote({ ownerId: u.id, folder, fileName });
res.json({ ok: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/scope_org_id|invalid|path|\.md/.test(msg)) {
res.status(400).json({ error: msg });
return;
}
logger.error(`[user-folder-api] notes delete failed user=${u.id} path=${relPath} err=${err}`);
res.status(500).json({ error: 'Failed to delete note' });
}
return; return;
} }
const { rootDir, leafId } = folder;
try { try {
ensureUserFolder(userFolderRoot, u.id); ensureUserFolder(rootDir, leafId);
} catch (err) { } catch (err) {
logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`); logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to ensure user folder' }); res.status(500).json({ error: 'Failed to ensure user folder' });
@ -458,7 +479,7 @@ export function createUserFolderApi(deps: Deps): Router {
let fullPath: string; let fullPath: string;
try { try {
fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath); fullPath = resolveUserSubdir(rootDir, leafId, subdir, relPath);
} catch { } catch {
res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' }); res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' });
return; return;
@ -474,7 +495,7 @@ export function createUserFolderApi(deps: Deps): Router {
const ts = utcTimestamp(new Date()); const ts = utcTimestamp(new Date());
const suffix = Math.random().toString(16).slice(2, 6); const suffix = Math.random().toString(16).slice(2, 6);
const trashedAs = `${ts}-${suffix}-${originalName}`; const trashedAs = `${ts}-${suffix}-${originalName}`;
const trashDir = join(userRoot(userFolderRoot, u.id), 'trash'); const trashDir = join(userRoot(rootDir, leafId), 'trash');
const trashPath = join(trashDir, trashedAs); const trashPath = join(trashDir, trashedAs);
try { try {
@ -919,31 +940,41 @@ export function createUserFolderApi(deps: Deps): Router {
}); });
// ── GET /agents-md ─────────────────────────────────────────────────────── // ── GET /agents-md ───────────────────────────────────────────────────────
r.get('/agents-md', (req: Request, res: Response) => { r.get('/agents-md', async (req: Request, res: Response) => {
const u = getUser(req)!; const u = getUser(req)!;
const folder = await resolveAgentsFolder(req, u);
if ('forbidden' in folder) {
res.status(404).json({ error: 'Space not found' });
return;
}
try { try {
const content = readUserAgentsMd(userFolderRoot, u.id); const content = readUserAgentsMd(folder.rootDir, folder.leafId);
if (content === null) { if (content === null) {
res.json({ exists: false, content: '' }); res.json({ exists: false, content: '' });
return; return;
} }
res.json({ exists: true, content }); res.json({ exists: true, content });
} catch (err) { } catch (err) {
logger.error(`[user-folder-api] read AGENTS.md failed user=${u.id} err=${err}`); logger.error(`[user-folder-api] read AGENTS.md failed leaf=${folder.leafId} err=${err}`);
res.status(500).json({ error: 'Failed to read AGENTS.md' }); res.status(500).json({ error: 'Failed to read AGENTS.md' });
} }
}); });
// ── PUT /agents-md ─────────────────────────────────────────────────────── // ── PUT /agents-md ───────────────────────────────────────────────────────
r.put('/agents-md', express.text({ type: '*/*', limit: '256kb' }), (req: Request, res: Response) => { r.put('/agents-md', express.text({ type: '*/*', limit: '256kb' }), async (req: Request, res: Response) => {
const u = getUser(req)!; const u = getUser(req)!;
const body = (req.body as unknown) as string; const body = (req.body as unknown) as string;
if (typeof body !== 'string') { if (typeof body !== 'string') {
res.status(400).json({ error: 'body must be text/plain' }); res.status(400).json({ error: 'body must be text/plain' });
return; return;
} }
const folder = await resolveAgentsFolder(req, u);
if ('forbidden' in folder) {
res.status(404).json({ error: 'Space not found' });
return;
}
try { try {
writeUserAgentsMd(userFolderRoot, u.id, body); writeUserAgentsMd(folder.rootDir, folder.leafId, body);
res.json({ ok: true, bytes: Buffer.byteLength(body, 'utf-8') }); res.json({ ok: true, bytes: Buffer.byteLength(body, 'utf-8') });
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
@ -951,19 +982,24 @@ export function createUserFolderApi(deps: Deps): Router {
res.status(413).json({ error: msg }); res.status(413).json({ error: msg });
return; return;
} }
logger.error(`[user-folder-api] write AGENTS.md failed user=${u.id} err=${msg}`); logger.error(`[user-folder-api] write AGENTS.md failed leaf=${folder.leafId} err=${msg}`);
res.status(500).json({ error: 'Failed to write AGENTS.md' }); res.status(500).json({ error: 'Failed to write AGENTS.md' });
} }
}); });
// ── DELETE /agents-md ──────────────────────────────────────────────────── // ── DELETE /agents-md ────────────────────────────────────────────────────
r.delete('/agents-md', (req: Request, res: Response) => { r.delete('/agents-md', async (req: Request, res: Response) => {
const u = getUser(req)!; const u = getUser(req)!;
const folder = await resolveAgentsFolder(req, u);
if ('forbidden' in folder) {
res.status(404).json({ error: 'Space not found' });
return;
}
try { try {
const existed = deleteUserAgentsMd(userFolderRoot, u.id); const existed = deleteUserAgentsMd(folder.rootDir, folder.leafId);
res.json({ ok: true, existed }); res.json({ ok: true, existed });
} catch (err) { } catch (err) {
logger.error(`[user-folder-api] delete AGENTS.md failed user=${u.id} err=${err}`); logger.error(`[user-folder-api] delete AGENTS.md failed leaf=${folder.leafId} err=${err}`);
res.status(500).json({ error: 'Failed to delete AGENTS.md' }); res.status(500).json({ error: 'Failed to delete AGENTS.md' });
} }
}); });

View File

@ -66,3 +66,41 @@ export function mountUsersApi(app: Application, repo: Repository, authActive = t
res.json({ ok: true }); res.json({ ok: true });
}); });
} }
/**
* User picker API for the space-member invite flow.
*
* `GET /api/users/pickable` gated by `requireAuth` (NOT admin-only): any
* authenticated user must be able to pick collaborators to invite. Returns the
* minimal display shape for active users only (pending / disabled are excluded).
*
* Privacy: a regular requester only sees users who share at least one
* organization with them (`req.user.orgIds`, Gitea cache + local orgs). Exposing
* the entire user directory to anyone is a leak. A requester in no org sees an
* empty list. Admins keep full visibility they already manage every user.
*
* In no-auth (single-operator local) mode there is no notion of inviting other
* users, so an empty array is returned mirroring `/api/admin/users` which also
* returns `[]` when auth is disabled.
*/
export function mountUsersPickableApi(app: Application, repo: Repository, authActive = true): void {
const guard = authActive ? requireAuth : passthrough;
app.get('/api/users/pickable', guard, (req: Request, res: Response) => {
if (!authActive) {
res.json([]);
return;
}
const viewer = req.user as Express.User | undefined;
if (!viewer) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const source =
viewer.role === 'admin'
? repo.listUsers().filter((u) => u.status === 'active')
: repo.listActiveUsersInOrgs(viewer.orgIds ?? []);
const users = source.map((u) => ({ id: u.id, name: u.name, email: u.email, avatarUrl: u.avatarUrl }));
res.json(users);
});
}

View File

@ -21,6 +21,8 @@ export interface ValidatedCreateTask {
outputFormat?: typeof VALID_OUTPUT_FORMATS[number]; outputFormat?: typeof VALID_OUTPUT_FORMATS[number];
askPolicy?: typeof VALID_ASK_POLICIES[number]; askPolicy?: typeof VALID_ASK_POLICIES[number];
priority?: typeof VALID_PRIORITIES[number]; priority?: typeof VALID_PRIORITIES[number];
workspaceMode?: 'persistent' | 'ephemeral';
spaceId?: string;
attachments?: Array<{ name: string; contentBase64: string }>; attachments?: Array<{ name: string; contentBase64: string }>;
} }
@ -71,6 +73,20 @@ export function validateCreateTaskBody(raw: unknown): ValidationResult {
} }
} }
// workspaceMode は permissive: 許可値以外は無視して既定persistentに倒す。
const rawMode = obj.workspaceMode ?? obj.workspace_mode;
const workspaceMode =
rawMode === 'persistent' || rawMode === 'ephemeral'
? (rawMode as 'persistent' | 'ephemeral')
: undefined;
// spaceId: 文字列のみ受理。空文字や非文字列は undefined= 個人スペース既定に解決)。
const rawSpaceId = obj.spaceId ?? obj.space_id;
const spaceId =
typeof rawSpaceId === 'string' && rawSpaceId.trim().length > 0
? rawSpaceId.trim()
: undefined;
return { return {
valid: true, valid: true,
data: { data: {
@ -81,6 +97,8 @@ export function validateCreateTaskBody(raw: unknown): ValidationResult {
outputFormat: obj.outputFormat as ValidatedCreateTask['outputFormat'], outputFormat: obj.outputFormat as ValidatedCreateTask['outputFormat'],
askPolicy: obj.askPolicy as ValidatedCreateTask['askPolicy'], askPolicy: obj.askPolicy as ValidatedCreateTask['askPolicy'],
priority: obj.priority as ValidatedCreateTask['priority'], priority: obj.priority as ValidatedCreateTask['priority'],
workspaceMode,
spaceId,
attachments: obj.attachments as ValidatedCreateTask['attachments'], attachments: obj.attachments as ValidatedCreateTask['attachments'],
}, },
}; };

View File

@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildVisibilityWhere, canUserSeeTask } from './visibility.js'; import {
buildVisibilityWhere,
canUserSeeTask,
canManageSpace,
canEditInSpace,
} from './visibility.js';
function makeUser(overrides: Partial<Express.User> = {}): Express.User { function makeUser(overrides: Partial<Express.User> = {}): Express.User {
return { return {
@ -36,6 +41,106 @@ describe('buildVisibilityWhere', () => {
expect(w.clause).toContain('j.owner_id'); expect(w.clause).toContain('j.owner_id');
expect(w.clause).not.toContain('lt.'); expect(w.clause).not.toContain('lt.');
}); });
// ─── 後方互換: opts 省略時はバイト一致で現状動作 ───
it('backward-compat: omitting opts is byte-identical to the legacy 2-arg form (no orgs)', () => {
const noOpts = buildVisibilityWhere(makeUser(), 'lt');
expect(noOpts.clause).not.toContain('space_members');
expect(noOpts.params).toEqual(['user-1']);
// 既存の節構造owner / public / orgのみ
expect(noOpts.clause).toBe(
"( lt.owner_id = ? OR lt.visibility = 'public' OR (lt.visibility = 'org' AND lt.visibility_scope_org_id IN (NULL)) )",
);
});
it('backward-compat: omitting opts byte-identical with orgs', () => {
const noOpts = buildVisibilityWhere(makeUser({ orgIds: ['10', '20'] }), 'lt');
expect(noOpts.clause).not.toContain('space_members');
expect(noOpts.params).toEqual(['user-1', '10', '20']);
});
// ─── 新 opts: spaceColumn のメンバーシップ OR ブランチ(メンバー + 根オーナー) ───
it('opts.spaceColumn (non-admin) appends membership+owner OR-branch with user.id LAST (twice) in params', () => {
const w = buildVisibilityWhere(makeUser({ orgIds: ['10'] }), 'lt', { spaceColumn: 'space_id' });
// メンバーシップspace_membersと根オーナーspaces.owner_idの両方を OR で通す。
expect(w.clause).toContain(
'OR (lt.space_id IN (SELECT space_id FROM space_members WHERE user_id = ?) OR lt.space_id IN (SELECT id FROM spaces WHERE owner_id = ?))',
);
// 既存 params の後ろに membership.user_id → owner.user_id の順で 2 回足される(順序が重要)
expect(w.params).toEqual(['user-1', '10', 'user-1', 'user-1']);
});
it('opts.spaceColumn supports the spaces self-alias (id column), includes owner branch', () => {
const w = buildVisibilityWhere(makeUser(), 's', { spaceColumn: 'id' });
expect(w.clause).toContain(
'OR (s.id IN (SELECT space_id FROM space_members WHERE user_id = ?) OR s.id IN (SELECT id FROM spaces WHERE owner_id = ?))',
);
expect(w.params).toEqual(['user-1', 'user-1', 'user-1']);
});
it('opts.spaceColumn is IGNORED for admin (still 1=1, no membership subquery)', () => {
const w = buildVisibilityWhere(makeUser({ role: 'admin' }), 'lt', { spaceColumn: 'space_id' });
expect(w.clause).toBe('1=1');
expect(w.params).toEqual([]);
expect(w.clause).not.toContain('space_members');
});
it('opts present but spaceColumn unset → no membership branch (identical to omitting opts)', () => {
const w = buildVisibilityWhere(makeUser(), 'lt', {});
expect(w.clause).not.toContain('space_members');
expect(w.params).toEqual(['user-1']);
});
});
describe('canManageSpace', () => {
const admin = makeUser({ id: 'admin-x', role: 'admin' });
const owner = makeUser({ id: 'owner-1' });
const other = makeUser({ id: 'other-1' });
const space = { ownerId: 'owner-1' };
it('admin can always manage', () => {
expect(canManageSpace(admin, space)).toBe(true);
expect(canManageSpace(admin, space, null)).toBe(true);
});
it('root owner (space.ownerId) can manage regardless of member role', () => {
expect(canManageSpace(owner, space)).toBe(true);
expect(canManageSpace(owner, space, null)).toBe(true);
});
it("member with role 'owner' can manage", () => {
expect(canManageSpace(other, space, 'owner')).toBe(true);
});
it("editor / viewer / non-member cannot manage", () => {
expect(canManageSpace(other, space, 'editor')).toBe(false);
expect(canManageSpace(other, space, 'viewer')).toBe(false);
expect(canManageSpace(other, space, null)).toBe(false);
expect(canManageSpace(other, space)).toBe(false);
});
it('null space.ownerId does not match a null-id-ish user', () => {
expect(canManageSpace(other, { ownerId: null })).toBe(false);
});
});
describe('canEditInSpace', () => {
const admin = makeUser({ id: 'admin-x', role: 'admin' });
const owner = makeUser({ id: 'owner-1' });
const other = makeUser({ id: 'other-1' });
const space = { ownerId: 'owner-1' };
it('admin / root owner / member-owner can edit', () => {
expect(canEditInSpace(admin, space)).toBe(true);
expect(canEditInSpace(owner, space)).toBe(true);
expect(canEditInSpace(other, space, 'owner')).toBe(true);
});
it("editor can edit", () => {
expect(canEditInSpace(other, space, 'editor')).toBe(true);
});
it("viewer cannot edit (read-only)", () => {
expect(canEditInSpace(other, space, 'viewer')).toBe(false);
});
it("non-member cannot edit", () => {
expect(canEditInSpace(other, space, null)).toBe(false);
expect(canEditInSpace(other, space)).toBe(false);
});
}); });
describe('canUserSeeTask', () => { describe('canUserSeeTask', () => {
@ -83,4 +188,15 @@ describe('canUserSeeTask', () => {
const t = { ownerId: null, visibility: 'private' as const, visibilityScopeOrgId: null }; const t = { ownerId: null, visibility: 'private' as const, visibilityScopeOrgId: null };
expect(canUserSeeTask(aliceNoOrg, t)).toBe(false); expect(canUserSeeTask(aliceNoOrg, t)).toBe(false);
}); });
it('private space task: spaceAccessible=true grants a non-owner member view', () => {
const t = { ownerId: 'someone-else', visibility: 'private' as const, visibilityScopeOrgId: null };
expect(canUserSeeTask(aliceNoOrg, t, true)).toBe(true);
});
it('private space task: spaceAccessible=false → still 404 for non-member', () => {
const t = { ownerId: 'someone-else', visibility: 'private' as const, visibilityScopeOrgId: null };
expect(canUserSeeTask(aliceNoOrg, t, false)).toBe(false);
expect(canUserSeeTask(aliceNoOrg, t)).toBe(false);
});
}); });

View File

@ -3,27 +3,117 @@ export interface VisibilityWhere {
params: unknown[]; params: unknown[];
} }
export function buildVisibilityWhere(user: Express.User, tableAlias: string): VisibilityWhere { export interface VisibilityOptions {
/**
* space_id `'space_id'`spaces `'id'`
* admin OR
* ****tableAlias
*/
spaceColumn?: string;
}
export function buildVisibilityWhere(
user: Express.User,
tableAlias: string,
opts?: VisibilityOptions,
): VisibilityWhere {
if (user.role === 'admin') { if (user.role === 'admin') {
return { clause: '1=1', params: [] }; return { clause: '1=1', params: [] };
} }
const orgPlaceholders = user.orgIds.length > 0 // Synthetic users (no-auth 'local' user, internal callers) may omit orgIds —
? user.orgIds.map(() => '?').join(',') // treat as no org membership rather than throwing on `.length`.
const orgIds = user.orgIds ?? [];
const orgPlaceholders = orgIds.length > 0
? orgIds.map(() => '?').join(',')
: 'NULL'; : 'NULL';
// 既存の params 順は [user.id, ...user.orgIds]。membership の user.id は必ず末尾に足す(順序が重要)。
const params: unknown[] = [user.id, ...orgIds];
let membershipBranch = '';
if (opts?.spaceColumn) {
// スペース行の閲覧資格 = 明示メンバーspace_membersOR スペースの根オーナー
// spaces.owner_id。根オーナーは通常 space_members 行を持たないため、
// owner サブクエリを足さないと自分のスペースの「他メンバーの行」が見えないgap-1
// user.id を clause 出現順members → spacesに 2 回 push する。
membershipBranch =
` OR (${tableAlias}.${opts.spaceColumn} IN (SELECT space_id FROM space_members WHERE user_id = ?)` +
` OR ${tableAlias}.${opts.spaceColumn} IN (SELECT id FROM spaces WHERE owner_id = ?))`;
params.push(user.id, user.id);
}
return { return {
clause: `( clause: `(
${tableAlias}.owner_id = ? ${tableAlias}.owner_id = ?
OR ${tableAlias}.visibility = 'public' OR ${tableAlias}.visibility = 'public'
OR (${tableAlias}.visibility = 'org' AND ${tableAlias}.visibility_scope_org_id IN (${orgPlaceholders})) OR (${tableAlias}.visibility = 'org' AND ${tableAlias}.visibility_scope_org_id IN (${orgPlaceholders}))${membershipBranch}
)`.replace(/\s+/g, ' ').trim(), )`.replace(/\s+/g, ' ').trim(),
params: [user.id, ...user.orgIds], params,
}; };
} }
/**
* `spaces` WHERE
* **kind='personal'admin **
* admin listSpaces
* UI personal
* members-owner / AGENTS.md / MCP / root cause
*
* - `kind='personal'` : `s.owner_id = ?`viewer.idrole
* - `kind='case'` : 従来の `buildVisibilityWhere`admin=1=1 / owner / org /
* public / space OR
*
* param SQL の出現順に合わせる: 先頭に personal-owner viewer.id
* `buildVisibilityWhere` paramsadmin `tableAlias`
* buildVisibilityWhere
*/
export function buildSpaceVisibilityWhere(
user: Express.User,
tableAlias: string,
): VisibilityWhere {
const base = buildVisibilityWhere(user, tableAlias, { spaceColumn: 'id' });
const clause =
`(
(${tableAlias}.kind = 'personal' AND ${tableAlias}.owner_id = ?)
OR (${tableAlias}.kind != 'personal' AND ${base.clause})
)`.replace(/\s+/g, ' ').trim();
// 出現順: personal-owner の viewer.id → base の params。
return { clause, params: [user.id, ...base.params] };
}
export function canEditEntity(user: Express.User, entity: { ownerId: string | null }): boolean { export function canEditEntity(user: Express.User, entity: { ownerId: string | null }): boolean {
return user.role === 'admin' || entity.ownerId === user.id; return user.role === 'admin' || entity.ownerId === user.id;
} }
export type SpaceMemberRole = 'owner' | 'editor' | 'viewer';
/**
* archive
* admin / space.ownerId/ member.role==='owner' true
* `memberRole` Repository.getSpaceMemberRole DB Repository
*/
export function canManageSpace(
user: Express.User,
space: { ownerId: string | null },
memberRole?: SpaceMemberRole | null,
): boolean {
return (
user.role === 'admin' ||
(space.ownerId !== null && space.ownerId === user.id) ||
memberRole === 'owner'
);
}
/**
* / CRUD upload
* canManageSpace editor
* viewer read buildVisibilityWhere
*/
export function canEditInSpace(
user: Express.User,
space: { ownerId: string | null },
memberRole?: SpaceMemberRole | null,
): boolean {
return canManageSpace(user, space, memberRole) || memberRole === 'editor';
}
/** /**
* user SQL * user SQL
* 使`buildVisibilityWhere` * 使`buildVisibilityWhere`
@ -32,17 +122,23 @@ export function canEditEntity(user: Express.User, entity: { ownerId: string | nu
* - owner: 常に true * - owner: 常に true
* - public: true * - public: true
* - org: user.orgIds visibilityScopeOrgId * - org: user.orgIds visibilityScopeOrgId
* - private: owner false * - /: `spaceAccessible === true` true
* `repo.userCanViewSpace(task.spaceId, user)`
* DB
* - private: false
*/ */
export function canUserSeeTask( export function canUserSeeTask(
user: Express.User, user: Express.User,
task: { ownerId: string | null; visibility: 'private' | 'org' | 'public'; visibilityScopeOrgId: string | null }, task: { ownerId: string | null; visibility: 'private' | 'org' | 'public'; visibilityScopeOrgId: string | null },
spaceAccessible?: boolean,
): boolean { ): boolean {
if (user.role === 'admin') return true; if (user.role === 'admin') return true;
if (task.ownerId !== null && task.ownerId === user.id) return true; if (task.ownerId !== null && task.ownerId === user.id) return true;
if (task.visibility === 'public') return true; if (task.visibility === 'public') return true;
if (task.visibility === 'org' && task.visibilityScopeOrgId !== null) { if (task.visibility === 'org' && task.visibilityScopeOrgId !== null) {
return user.orgIds.includes(task.visibilityScopeOrgId); if (user.orgIds.includes(task.visibilityScopeOrgId)) return true;
} }
// スペースメンバーシップ起点の閲覧許可buildVisibilityWhere の OR ブランチに対応)。
if (spaceAccessible === true) return true;
return false; return false;
} }

View File

@ -81,16 +81,6 @@ export class ConfigManager {
} }
} }
// Mask all API keys in knowledgeNamespaces
const namespaces = (config as any).tools?.knowledgeNamespaces;
if (namespaces && typeof namespaces === 'object') {
for (const ns of Object.values(namespaces)) {
if (ns && typeof ns === 'object' && 'apiKey' in ns) {
(ns as any).apiKey = MASKED;
}
}
}
// Mask api keys in llm.workers[] and gateway.backends[]. Both // Mask api keys in llm.workers[] and gateway.backends[]. Both
// arrays carry per-endpoint bearer tokens (worker apiKey is used for // arrays carry per-endpoint bearer tokens (worker apiKey is used for
// AAO Gateway / LiteLLM bearer auth, backend apiKey is forwarded to // AAO Gateway / LiteLLM bearer auth, backend apiKey is forwarded to
@ -243,17 +233,6 @@ export class ConfigManager {
} }
} }
// Restore masked knowledgeNamespaces API keys
const mergedNs = (merged as any).tools?.knowledgeNamespaces;
const currentNs = current.tools?.knowledgeNamespaces;
if (mergedNs && currentNs) {
for (const [name, val] of Object.entries(mergedNs)) {
if ((val as any)?.apiKey === MASKED && currentNs[name]) {
(val as any).apiKey = currentNs[name].apiKey;
}
}
}
// Restore masked provider.workers[].apiKey by matching on worker id // Restore masked provider.workers[].apiKey by matching on worker id
// (preferred) or array index (fallback). Matching on id survives // (preferred) or array index (fallback). Matching on id survives
// reordering, which is needed because the UI may sort rows before // reordering, which is needed because the UI may sort rows before

View File

@ -456,10 +456,9 @@ describe('normalizeConfig — fixtures', () => {
masterKeyPath: './data/secrets/ssh-master.key', masterKeyPath: './data/secrets/ssh-master.key',
}); });
// ${VAR} inside tools.knowledge_namespaces stays literal // ${VAR} inside tools.keepa_api_key stays literal
const tools = (out as unknown as { tools?: { knowledgeNamespaces?: Record<string, { apiKey: string }> } }).tools; const tools = (out as unknown as { tools?: { keepaApiKey?: string } }).tools;
expect(tools?.knowledgeNamespaces?.eng?.apiKey).toBe('${DKS_ENG_KEY}'); expect(tools?.keepaApiKey).toBe('${KEEPA_API_KEY}');
expect(tools?.knowledgeNamespaces?.ops?.apiKey).toBe('${DKS_OPS_KEY}');
}); });
}); });

Some files were not shown because too many files have changed in this diff Show More