# アプリポータル Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** `webapps` project配下の全アプリを一覧表示し、起動/停止/再起動/バッチ手動実行/ログ確認を1画面で行える集中管理ポータル(`apps/app-portal`)を作る。 **Architecture:** Express製のSSRアプリ。サーバー側でDokploy tRPC APIを自前クライアント経由で呼び出し、Compose一覧・各アプリの`env`からポータル用メタ情報(`PORTAL_APP_TYPE`等)を取得してカード表示する。操作(start/stop/restart)はDokploy tRPC APIへの中継、バッチの「今すぐ実行」は対象アプリの`PORTAL_TRIGGER_PATH`への直接POST。認証はTraefik層のoauth2-proxyに加え、アプリ側でも`X-Auth-Request-Email`をallowlistと照合する二段構え。 **Tech Stack:** Node.js 22 / Express 4 / 追加依存なし(Node標準`fetch`でDokploy tRPC APIを直接叩く)。 ## Global Constraints - Node.js 22(既存`apps/_template`のDockerfileを踏襲、`FROM node:22-alpine`) - 追加npm依存を増やさない。HTTP呼び出しはNode標準の`fetch`を使い、`axios`等は入れない - Dokploy APIキー・`PORTAL_SECRET`はDokploy Environment画面でのみ設定する。リポジトリ・`.env`・ログに一切書かない/出力しない - `compose.one`のレスポンスには`gitea.clientSecret`/`accessToken`/`refreshToken`等の機密情報が含まれる(2026-07-25実地検証で確認済み事故の教訓)。Dokployクライアントモジュールはこれらのフィールドを**絶対に**呼び出し元へ返さない。呼び出し元が受け取れるのは`env`文字列と`appName`のみ - 公開ドメインは`apps.next-hd.net`サブゾーン命名規則を踏襲(例: `portal.apps.next-hd.net`) - ポータル自体はTraefik層でoauth2-proxy認証必須(`oauth-errors@file,oauth-auth@file`)。加えてアプリ層でも`PORTAL_ALLOWED_EMAILS`によるallowlist照合を行う(多層防御) - `PORTAL_APP_TYPE`は`web`または`batch`のみ許可。未設定のComposeはポータル管理対象外として自動的に一覧から除外する(既存の`authgw-poc`/`auth-redirect`/`gitea`等、まだこの規約に対応していないアプリは表示されなくてよい) - `PORTAL_APP_URL`は全アプリ共通で必須とする(設計書`docs/superpowers/specs/2026-07-25-app-portal-design.md`からの変更点)。web型では「開く」リンク先、batch型では`dokploy-network`内で到達可能な内部ベースURL(例: `http://portal-sample-batch:3000`)として使う。理由: batch型のトリガー先ホスト名をポータル側で他に導出する手段がないため --- ## ファイル構成 ``` apps/app-portal/ package.json Dockerfile .dockerignore .env.example docker-compose.yml docker-compose.local.yml src/ index.js — Expressルーティング dokployClient.js — Dokploy tRPC APIクライアント(GET/POSTラップ含む) portalMeta.js — env文字列パース・PORTAL_*メタ情報抽出 allowlist.js — X-Auth-Request-Email allowlistミドルウェア dashboard.js — カード一覧構築・HTML生成 public/ portal.js — ダッシュボードのボタン操作(fetch呼び出し) test/ dokployClient.test.js portalMeta.test.js allowlist.test.js dashboard.test.js README.md apps/portal-sample-batch/ — ポータルのbatch型E2E検証専用の軽量アプリ (apps/_templateと同構成 + PORTAL_TRIGGER_PATHエンドポイント) apps/_template/ README.md — batch型セクション追記 .env.example — PORTAL_*コメント追記 README.md — 「アプリポータル」章追加 .claude/skills/dokploy-webapp/SKILL.md — PORTAL_*規約の追記 ``` --- ### Task 1: Dokployクライアント基盤(tRPC GET/POSTラッパー) **Files:** - Create: `apps/app-portal/package.json` - Create: `apps/app-portal/src/dokployClient.js` - Test: `apps/app-portal/test/dokployClient.test.js` **Interfaces:** - Produces: `trpcGet(endpoint: string, params: object): Promise`、`trpcPost(endpoint: string, data: object): Promise` (`dokployClient.js`内部関数、後続タスクの全Dokploy呼び出しが利用) - Consumes: 環境変数 `DOKPLOY_URL`、`DOKPLOY_API_KEY`(呼び出し時に都度`process.env`から読む。モジュールロード時にキャッシュしない — テストで動的に差し替えるため) - [ ] **Step 1: package.jsonを作成** ```json { "name": "app-portal", "version": "0.1.0", "private": true, "type": "commonjs", "scripts": { "dev": "node --watch src/index.js", "start": "node src/index.js", "test": "node --test" }, "dependencies": { "express": "^4.21.0" } } ``` - [ ] **Step 2: 失敗するテストを書く** `apps/app-portal/test/dokployClient.test.js`: ```js const { test } = require('node:test'); const assert = require('node:assert'); function withMockFetch(mockFetch, fn) { const original = global.fetch; global.fetch = mockFetch; return fn().finally(() => { global.fetch = original; }); } test('trpcGet wraps params in {json:} and unwraps result.data.json', async () => { process.env.DOKPLOY_URL = 'https://dokploy.test'; process.env.DOKPLOY_API_KEY = 'test-key'; const { trpcGet } = require('../src/dokployClient'); await withMockFetch(async (url, options) => { const parsed = new URL(url); assert.strictEqual(parsed.pathname, '/api/trpc/environment.one'); const input = JSON.parse(parsed.searchParams.get('input')); assert.deepStrictEqual(input, { json: { environmentId: 'env-1' } }); assert.strictEqual(options.headers['x-api-key'], 'test-key'); return { ok: true, json: async () => ({ result: { data: { json: { hello: 'world' } } } }), }; }, async () => { const result = await trpcGet('environment.one', { environmentId: 'env-1' }); assert.deepStrictEqual(result, { hello: 'world' }); }); }); test('trpcPost wraps data in {json:} in the request body', async () => { process.env.DOKPLOY_URL = 'https://dokploy.test'; process.env.DOKPLOY_API_KEY = 'test-key'; const { trpcPost } = require('../src/dokployClient'); await withMockFetch(async (url, options) => { const parsed = new URL(url); assert.strictEqual(parsed.pathname, '/api/trpc/compose.start'); assert.deepStrictEqual(JSON.parse(options.body), { json: { composeId: 'c1' } }); assert.strictEqual(options.method, 'POST'); return { ok: true, json: async () => ({ result: { data: { json: { success: true } } } }), }; }, async () => { const result = await trpcPost('compose.start', { composeId: 'c1' }); assert.deepStrictEqual(result, { success: true }); }); }); test('trpcGet throws when the response is not ok', async () => { process.env.DOKPLOY_URL = 'https://dokploy.test'; process.env.DOKPLOY_API_KEY = 'test-key'; const { trpcGet } = require('../src/dokployClient'); await withMockFetch(async () => ({ ok: false, status: 400 }), async () => { await assert.rejects(() => trpcGet('compose.one', { composeId: 'c1' }), /400/); }); }); ``` - [ ] **Step 2b: テストが失敗することを確認** Run: `cd apps/app-portal && npm install && npm test` Expected: FAIL — `Cannot find module '../src/dokployClient'` - [ ] **Step 3: 最小実装を書く** `apps/app-portal/src/dokployClient.js`: ```js function getConfig() { const url = process.env.DOKPLOY_URL; const apiKey = process.env.DOKPLOY_API_KEY; if (!url || !apiKey) { throw new Error('DOKPLOY_URL / DOKPLOY_API_KEY が設定されていません'); } return { url, apiKey }; } async function trpcGet(endpoint, params) { const { url, apiKey } = getConfig(); const input = encodeURIComponent(JSON.stringify({ json: params })); const res = await fetch(`${url}/api/trpc/${endpoint}?input=${input}`, { headers: { 'x-api-key': apiKey }, }); if (!res.ok) { throw new Error(`Dokploy API error: ${res.status} ${endpoint}`); } const body = await res.json(); return body.result.data.json; } async function trpcPost(endpoint, data) { const { url, apiKey } = getConfig(); const res = await fetch(`${url}/api/trpc/${endpoint}`, { method: 'POST', headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' }, body: JSON.stringify({ json: data }), }); if (!res.ok) { throw new Error(`Dokploy API error: ${res.status} ${endpoint}`); } const body = await res.json(); return body.result?.data?.json; } module.exports = { trpcGet, trpcPost }; ``` - [ ] **Step 4: テストが通ることを確認** Run: `cd apps/app-portal && npm test` Expected: PASS(3 tests) - [ ] **Step 5: Commit** ```bash git add apps/app-portal/package.json apps/app-portal/src/dokployClient.js apps/app-portal/test/dokployClient.test.js git commit -m "feat(app-portal): Dokploy tRPC APIクライアント基盤を追加" ``` --- ### Task 2: Compose一覧取得・env取得(機密フィールド除外) **Files:** - Modify: `apps/app-portal/src/dokployClient.js` - Modify: `apps/app-portal/test/dokployClient.test.js` **Interfaces:** - Consumes: `trpcGet`(Task 1で定義) - Produces: `listComposes(environmentId: string): Promise>`、`getComposeEnv(composeId: string): Promise<{env: string|null, appName: string}>`(Task 7/8が利用) - [ ] **Step 1: 失敗するテストを追記** `apps/app-portal/test/dokployClient.test.js`に追記: ```js test('listComposes returns the compose array from environment.one', async () => { process.env.DOKPLOY_URL = 'https://dokploy.test'; process.env.DOKPLOY_API_KEY = 'test-key'; const { listComposes } = require('../src/dokployClient'); await withMockFetch(async () => ({ ok: true, json: async () => ({ result: { data: { json: { compose: [{ composeId: 'c1', name: 'auth-redirect', composeStatus: 'done' }] } } }, }), }), async () => { const composes = await listComposes('env-1'); assert.deepStrictEqual(composes, [{ composeId: 'c1', name: 'auth-redirect', composeStatus: 'done' }]); }); }); test('getComposeEnv only returns env and appName, never leaks gitea/github credentials', async () => { process.env.DOKPLOY_URL = 'https://dokploy.test'; process.env.DOKPLOY_API_KEY = 'test-key'; const { getComposeEnv } = require('../src/dokployClient'); await withMockFetch(async () => ({ ok: true, json: async () => ({ result: { data: { json: { composeId: 'c1', appName: 'auth-redirect-uzt5hx', env: 'PORTAL_APP_TYPE=web\nPORTAL_APP_URL=https://x.apps.next-hd.net', gitea: { clientSecret: 'SHOULD_NOT_LEAK', accessToken: 'SHOULD_NOT_LEAK_EITHER' }, deployments: [{ title: 'irrelevant' }], }, }, }, }), }), async () => { const result = await getComposeEnv('c1'); assert.deepStrictEqual(Object.keys(result).sort(), ['appName', 'env']); assert.strictEqual(result.appName, 'auth-redirect-uzt5hx'); assert.ok(result.env.includes('PORTAL_APP_TYPE=web')); assert.ok(!JSON.stringify(result).includes('SHOULD_NOT_LEAK')); }); }); ``` - [ ] **Step 2: テストが失敗することを確認** Run: `cd apps/app-portal && npm test` Expected: FAIL — `listComposes is not a function` - [ ] **Step 3: 実装を追記** `apps/app-portal/src/dokployClient.js`の`module.exports`の直前に追記: ```js async function listComposes(environmentId) { const env = await trpcGet('environment.one', { environmentId }); return env.compose; } async function getComposeEnv(composeId) { const full = await trpcGet('compose.one', { composeId }); return { env: full.env ?? null, appName: full.appName }; } ``` `module.exports`を更新: ```js module.exports = { trpcGet, trpcPost, listComposes, getComposeEnv }; ``` - [ ] **Step 4: テストが通ることを確認** Run: `cd apps/app-portal && npm test` Expected: PASS(5 tests) - [ ] **Step 5: Commit** ```bash git add apps/app-portal/src/dokployClient.js apps/app-portal/test/dokployClient.test.js git commit -m "feat(app-portal): Compose一覧・env取得を追加(機密フィールド除外を担保するテスト付き)" ``` --- ### Task 3: コンテナ取得・ログ取得・start/stop/restart **Files:** - Modify: `apps/app-portal/src/dokployClient.js` - Modify: `apps/app-portal/test/dokployClient.test.js` **Interfaces:** - Consumes: `trpcGet`、`trpcPost`(Task 1) - Produces: `getContainers(appName: string): Promise>`、`readLogs(composeId, containerId, tail): Promise`、`startCompose(composeId): Promise`、`stopCompose(composeId): Promise`、`restartContainer(containerId): Promise`(Task 8が利用) - [ ] **Step 1: 失敗するテストを追記** `apps/app-portal/test/dokployClient.test.js`に追記: ```js test('getContainers calls docker.getContainersByAppNameMatch with appType docker-compose', async () => { process.env.DOKPLOY_URL = 'https://dokploy.test'; process.env.DOKPLOY_API_KEY = 'test-key'; const { getContainers } = require('../src/dokployClient'); await withMockFetch(async (url) => { const parsed = new URL(url); assert.strictEqual(parsed.pathname, '/api/trpc/docker.getContainersByAppNameMatch'); const input = JSON.parse(parsed.searchParams.get('input')); assert.deepStrictEqual(input, { json: { appName: 'auth-redirect-uzt5hx', appType: 'docker-compose' } }); return { ok: true, json: async () => ({ result: { data: { json: [{ containerId: 'abc123', state: 'running' }] } } }), }; }, async () => { const containers = await getContainers('auth-redirect-uzt5hx'); assert.deepStrictEqual(containers, [{ containerId: 'abc123', state: 'running' }]); }); }); test('readLogs passes composeId, containerId and tail through', async () => { process.env.DOKPLOY_URL = 'https://dokploy.test'; process.env.DOKPLOY_API_KEY = 'test-key'; const { readLogs } = require('../src/dokployClient'); await withMockFetch(async (url) => { const parsed = new URL(url); const input = JSON.parse(parsed.searchParams.get('input')); assert.deepStrictEqual(input, { json: { composeId: 'c1', containerId: 'abc123', tail: 200 } }); return { ok: true, json: async () => ({ result: { data: { json: 'log line\n' } } }) }; }, async () => { const logs = await readLogs('c1', 'abc123', 200); assert.strictEqual(logs, 'log line\n'); }); }); test('startCompose/stopCompose/restartContainer POST to the right endpoints', async () => { process.env.DOKPLOY_URL = 'https://dokploy.test'; process.env.DOKPLOY_API_KEY = 'test-key'; const { startCompose, stopCompose, restartContainer } = require('../src/dokployClient'); const calledEndpoints = []; await withMockFetch(async (url) => { calledEndpoints.push(new URL(url).pathname); return { ok: true, json: async () => ({ result: { data: { json: { success: true } } } }) }; }, async () => { await startCompose('c1'); await stopCompose('c1'); await restartContainer('abc123'); }); assert.deepStrictEqual(calledEndpoints, [ '/api/trpc/compose.start', '/api/trpc/compose.stop', '/api/trpc/docker.restartContainer', ]); }); ``` - [ ] **Step 2: テストが失敗することを確認** Run: `cd apps/app-portal && npm test` Expected: FAIL — `getContainers is not a function` - [ ] **Step 3: 実装を追記** `apps/app-portal/src/dokployClient.js`の`module.exports`の直前に追記: ```js async function getContainers(appName) { return trpcGet('docker.getContainersByAppNameMatch', { appName, appType: 'docker-compose' }); } async function readLogs(composeId, containerId, tail) { return trpcGet('compose.readLogs', { composeId, containerId, tail }); } async function startCompose(composeId) { return trpcPost('compose.start', { composeId }); } async function stopCompose(composeId) { return trpcPost('compose.stop', { composeId }); } async function restartContainer(containerId) { return trpcPost('docker.restartContainer', { containerId }); } ``` `module.exports`を更新: ```js module.exports = { trpcGet, trpcPost, listComposes, getComposeEnv, getContainers, readLogs, startCompose, stopCompose, restartContainer, }; ``` - [ ] **Step 4: テストが通ることを確認** Run: `cd apps/app-portal && npm test` Expected: PASS(8 tests) - [ ] **Step 5: Commit** ```bash git add apps/app-portal/src/dokployClient.js apps/app-portal/test/dokployClient.test.js git commit -m "feat(app-portal): コンテナ取得・ログ取得・start/stop/restartを追加" ``` --- ### Task 4: `.env`メタ情報パース(`portalMeta.js`) **Files:** - Create: `apps/app-portal/src/portalMeta.js` - Test: `apps/app-portal/test/portalMeta.test.js` **Interfaces:** - Produces: `parseEnvString(envString: string|null): Record`、`extractPortalMeta(envString: string|null, fallbackLabel: string): {appType: 'web'|'batch', label: string, url: string|null, triggerPath: string|null} | null`(Task 7/8が利用) - [ ] **Step 1: 失敗するテストを書く** `apps/app-portal/test/portalMeta.test.js`: ```js const { test } = require('node:test'); const assert = require('node:assert'); const { parseEnvString, extractPortalMeta } = require('../src/portalMeta'); test('parseEnvString parses KEY=VALUE lines and skips comments/blank lines', () => { const result = parseEnvString('PORT=3000\n# comment\n\nNAME="quoted value"\n'); assert.deepStrictEqual(result, { PORT: '3000', NAME: 'quoted value' }); }); test('parseEnvString returns {} for null or empty input', () => { assert.deepStrictEqual(parseEnvString(null), {}); assert.deepStrictEqual(parseEnvString(''), {}); }); test('extractPortalMeta returns null when PORTAL_APP_TYPE is missing or invalid', () => { assert.strictEqual(extractPortalMeta('PORT=3000', 'fallback'), null); assert.strictEqual(extractPortalMeta('PORTAL_APP_TYPE=invalid', 'fallback'), null); assert.strictEqual(extractPortalMeta(null, 'fallback'), null); }); test('extractPortalMeta parses a web-type app, falling back to label when PORTAL_APP_LABEL is absent', () => { const meta = extractPortalMeta('PORTAL_APP_TYPE=web\nPORTAL_APP_URL=https://x.apps.next-hd.net', 'x'); assert.deepStrictEqual(meta, { appType: 'web', label: 'x', url: 'https://x.apps.next-hd.net', triggerPath: null }); }); test('extractPortalMeta parses a batch-type app with PORTAL_TRIGGER_PATH', () => { const meta = extractPortalMeta( 'PORTAL_APP_TYPE=batch\nPORTAL_APP_LABEL=同期バッチ\nPORTAL_APP_URL=http://sample:3000\nPORTAL_TRIGGER_PATH=/trigger', 'fallback' ); assert.deepStrictEqual(meta, { appType: 'batch', label: '同期バッチ', url: 'http://sample:3000', triggerPath: '/trigger' }); }); ``` - [ ] **Step 2: テストが失敗することを確認** Run: `cd apps/app-portal && npm test` Expected: FAIL — `Cannot find module '../src/portalMeta'` - [ ] **Step 3: 実装を書く** `apps/app-portal/src/portalMeta.js`: ```js function parseEnvString(envString) { const result = {}; if (!envString) return result; for (const line of envString.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eqIndex = trimmed.indexOf('='); if (eqIndex === -1) continue; const key = trimmed.slice(0, eqIndex).trim(); const value = trimmed .slice(eqIndex + 1) .trim() .replace(/^["']|["']$/g, ''); result[key] = value; } return result; } function extractPortalMeta(envString, fallbackLabel) { const vars = parseEnvString(envString); const appType = vars.PORTAL_APP_TYPE; if (appType !== 'web' && appType !== 'batch') { return null; } return { appType, label: vars.PORTAL_APP_LABEL || fallbackLabel, url: vars.PORTAL_APP_URL || null, triggerPath: vars.PORTAL_TRIGGER_PATH || null, }; } module.exports = { parseEnvString, extractPortalMeta }; ``` - [ ] **Step 4: テストが通ることを確認** Run: `cd apps/app-portal && npm test` Expected: PASS(全13 tests) - [ ] **Step 5: Commit** ```bash git add apps/app-portal/src/portalMeta.js apps/app-portal/test/portalMeta.test.js git commit -m "feat(app-portal): PORTAL_*メタ情報のenv文字列パースを追加" ``` --- ### Task 5: allowlist認証ミドルウェア **Files:** - Create: `apps/app-portal/src/allowlist.js` - Test: `apps/app-portal/test/allowlist.test.js` **Interfaces:** - Produces: `createAllowlistMiddleware(allowedEmailsCsv: string): (req, res, next) => void`(Task 8が利用) - [ ] **Step 1: 失敗するテストを書く** `apps/app-portal/test/allowlist.test.js`: ```js const { test } = require('node:test'); const assert = require('node:assert'); const { createAllowlistMiddleware } = require('../src/allowlist'); function mockRes() { return { statusCode: null, body: null, status(code) { this.statusCode = code; return this; }, send(body) { this.body = body; return this; }, }; } test('allows a request whose email is in the allowlist (case-insensitive)', () => { const middleware = createAllowlistMiddleware('a@example.com, B@Example.com'); const req = { headers: { 'x-auth-request-email': 'b@example.com' } }; const res = mockRes(); let nextCalled = false; middleware(req, res, () => { nextCalled = true; }); assert.strictEqual(nextCalled, true); assert.strictEqual(res.statusCode, null); }); test('rejects with 403 when email is missing', () => { const middleware = createAllowlistMiddleware('a@example.com'); const req = { headers: {} }; const res = mockRes(); middleware(req, res, () => { throw new Error('next should not be called'); }); assert.strictEqual(res.statusCode, 403); }); test('rejects with 403 when email is not in the allowlist', () => { const middleware = createAllowlistMiddleware('a@example.com'); const req = { headers: { 'x-auth-request-email': 'stranger@example.com' } }; const res = mockRes(); middleware(req, res, () => { throw new Error('next should not be called'); }); assert.strictEqual(res.statusCode, 403); }); ``` - [ ] **Step 2: テストが失敗することを確認** Run: `cd apps/app-portal && npm test` Expected: FAIL — `Cannot find module '../src/allowlist'` - [ ] **Step 3: 実装を書く** `apps/app-portal/src/allowlist.js`: ```js function createAllowlistMiddleware(allowedEmailsCsv) { const allowed = new Set( (allowedEmailsCsv || '') .split(',') .map((e) => e.trim().toLowerCase()) .filter(Boolean) ); return function allowlistMiddleware(req, res, next) { const email = (req.headers['x-auth-request-email'] || '').toLowerCase(); if (!email || !allowed.has(email)) { res.status(403).send('Forbidden'); return; } next(); }; } module.exports = { createAllowlistMiddleware }; ``` - [ ] **Step 4: テストが通ることを確認** Run: `cd apps/app-portal && npm test` Expected: PASS(全16 tests) - [ ] **Step 5: Commit** ```bash git add apps/app-portal/src/allowlist.js apps/app-portal/test/allowlist.test.js git commit -m "feat(app-portal): X-Auth-Request-Email allowlistミドルウェアを追加" ``` --- ### Task 6: app-portalアプリの骨格(Docker/Compose/env) **Files:** - Create: `apps/app-portal/Dockerfile` - Create: `apps/app-portal/.dockerignore` - Create: `apps/app-portal/.env.example` - Create: `apps/app-portal/docker-compose.yml` - Create: `apps/app-portal/docker-compose.local.yml` **Interfaces:** - Consumes: なし(既存`apps/_template`の同名ファイルをベースにする) - Produces: なし(次タスクのローカル起動・Dokployデプロイの前提) - [ ] **Step 1: Dockerfileを作成**(`apps/_template/Dockerfile`と同一) `apps/app-portal/Dockerfile`: ```dockerfile FROM node:22-alpine AS deps WORKDIR /app COPY package.json package-lock.json* ./ RUN npm ci --omit=dev FROM node:22-alpine WORKDIR /app ENV NODE_ENV=production COPY --from=deps /app/node_modules ./node_modules COPY . . USER node EXPOSE 3000 CMD ["node", "src/index.js"] ``` - [ ] **Step 2: .dockerignoreを作成**(`apps/_template/.dockerignore`と同一内容を想定) `apps/app-portal/.dockerignore`: ``` node_modules npm-debug.log .env .git ``` - [ ] **Step 3: .env.exampleを作成** `apps/app-portal/.env.example`: ``` PORT=3000 NODE_ENV=development DOKPLOY_URL=https://dokploy45.next-hd.net DOKPLOY_API_KEY= DOKPLOY_ENVIRONMENT_ID=Cm0HjMIFyl11UdIcIGRy8 PORTAL_ALLOWED_EMAILS=kenichiro.nogi@next-hd.co.jp PORTAL_SECRET= ``` - [ ] **Step 4: docker-compose.local.ymlを作成**(ローカル検証専用、`apps/_template`と同構成) `apps/app-portal/docker-compose.local.yml`: ```yaml services: app-portal: build: . ports: - "3000:3000" env_file: - .env ``` - [ ] **Step 5: docker-compose.ymlを作成**(Dokploy用、認証ゲートウェイ必須) `apps/app-portal/docker-compose.yml`: ```yaml services: app-portal: build: . expose: - 3000 env_file: - .env networks: - dokploy-network labels: - traefik.enable=true - traefik.http.routers.app-portal-web.rule=Host(`portal.apps.next-hd.net`) - traefik.http.routers.app-portal-web.entrypoints=web - traefik.http.routers.app-portal-web.middlewares=redirect-to-https - traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https - traefik.http.routers.app-portal-web.service=app-portal-svc - traefik.http.routers.app-portal-websecure.rule=Host(`portal.apps.next-hd.net`) - traefik.http.routers.app-portal-websecure.entrypoints=websecure - traefik.http.routers.app-portal-websecure.tls.certresolver=letsencrypt - traefik.http.routers.app-portal-websecure.middlewares=oauth-errors@file,oauth-auth@file - traefik.http.routers.app-portal-websecure.service=app-portal-svc - traefik.http.services.app-portal-svc.loadbalancer.server.port=3000 restart: unless-stopped networks: dokploy-network: external: true ``` - [ ] **Step 6: Commit** ```bash git add apps/app-portal/Dockerfile apps/app-portal/.dockerignore apps/app-portal/.env.example apps/app-portal/docker-compose.yml apps/app-portal/docker-compose.local.yml git commit -m "feat(app-portal): Docker/Compose/env雛形を追加" ``` --- ### Task 7: ダッシュボード構築ロジック(`dashboard.js`) **Files:** - Create: `apps/app-portal/src/dashboard.js` - Test: `apps/app-portal/test/dashboard.test.js` **Interfaces:** - Consumes: `listComposes`, `getComposeEnv`, `getContainers`(Task 1-3)、`extractPortalMeta`(Task 4) - Produces: `buildCards(environmentId: string): Promise>`、`renderCard(card): string`、`renderDashboard(environmentId: string): Promise`(Task 8のルーティングが利用) `CardData`の形: `{composeId, appName, composeStatus, containerState, meta: {appType, label, url, triggerPath}}` `ErrorCardData`の形: `{error: true, composeId, name}` - [ ] **Step 1: 失敗するテストを書く** `apps/app-portal/test/dashboard.test.js`: ```js const { test } = require('node:test'); const assert = require('node:assert'); const Module = require('module'); function withMocked(modulePath, mockExports, fn) { const resolved = require.resolve(modulePath); const original = require.cache[resolved]; require.cache[resolved] = new Module(resolved); require.cache[resolved].exports = mockExports; return Promise.resolve(fn()).finally(() => { if (original) { require.cache[resolved] = original; } else { delete require.cache[resolved]; } }); } test('buildCards skips composes without a valid PORTAL_APP_TYPE and reports fetch errors separately', async () => { delete require.cache[require.resolve('../src/dashboard')]; await withMocked('../src/dokployClient', { listComposes: async () => [ { composeId: 'c1', name: 'auth-redirect', composeStatus: 'done' }, { composeId: 'c2', name: 'gitea', composeStatus: 'done' }, { composeId: 'c3', name: 'broken-app', composeStatus: 'error' }, ], getComposeEnv: async (composeId) => { if (composeId === 'c1') return { env: 'PORTAL_APP_TYPE=web\nPORTAL_APP_URL=https://x', appName: 'auth-redirect-uzt5hx' }; if (composeId === 'c2') return { env: null, appName: 'gitea-xyz' }; throw new Error('boom'); }, getContainers: async () => [{ containerId: 'abc', state: 'running' }], }, async () => { const { buildCards } = require('../src/dashboard'); const cards = await buildCards('env-1'); assert.strictEqual(cards.length, 2); assert.strictEqual(cards[0].composeId, 'c1'); assert.strictEqual(cards[0].meta.appType, 'web'); assert.strictEqual(cards[0].containerState, 'running'); assert.deepStrictEqual(cards[1], { error: true, composeId: 'c3', name: 'broken-app' }); }); }); test('renderCard escapes HTML in labels and shows a "trigger" button for batch apps', () => { delete require.cache[require.resolve('../src/dashboard')]; const { renderCard } = require('../src/dashboard'); const html = renderCard({ composeId: 'c1', appName: 'sample', composeStatus: 'done', containerState: 'running', meta: { appType: 'batch', label: '', url: 'http://sample:3000', triggerPath: '/trigger' }, }); assert.ok(!html.includes('')); assert.ok(html.includes('<script>')); assert.ok(html.includes('data-action="trigger"')); assert.ok(!html.includes('data-action="start"')); }); test('renderCard shows an "状態取得失敗" message for error cards', () => { const { renderCard } = require('../src/dashboard'); const html = renderCard({ error: true, composeId: 'c3', name: 'broken-app' }); assert.ok(html.includes('状態取得失敗')); assert.ok(html.includes('broken-app')); }); ``` - [ ] **Step 2: テストが失敗することを確認** Run: `cd apps/app-portal && npm test` Expected: FAIL — `Cannot find module '../src/dashboard'` - [ ] **Step 3: 実装を書く** `apps/app-portal/src/dashboard.js`: ```js const { listComposes, getComposeEnv, getContainers } = require('./dokployClient'); const { extractPortalMeta } = require('./portalMeta'); function escapeHtml(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } async function buildCards(environmentId) { const composes = await listComposes(environmentId); const settled = await Promise.allSettled( composes.map(async (c) => { const { env, appName } = await getComposeEnv(c.composeId); const meta = extractPortalMeta(env, c.name); if (!meta) return null; let containerState = 'unknown'; try { const containers = await getContainers(appName); containerState = containers[0]?.state || 'stopped'; } catch { containerState = 'unknown'; } return { composeId: c.composeId, appName, composeStatus: c.composeStatus, containerState, meta }; }) ); const cards = []; settled.forEach((result, i) => { if (result.status === 'fulfilled') { if (result.value) cards.push(result.value); } else { cards.push({ error: true, composeId: composes[i].composeId, name: composes[i].name }); } }); return cards; } function renderCard(card) { if (card.error) { return `

${escapeHtml(card.name || card.composeId)}

状態取得失敗

`; } const { composeId, composeStatus, containerState, meta } = card; const openLink = meta.appType === 'web' && meta.url ? `開く` : ''; const actionButtons = meta.appType === 'web' ? ` ` : ``; return `

${escapeHtml(meta.label)}

compose: ${escapeHtml(composeStatus)} / container: ${escapeHtml(containerState)}

${openLink} ${actionButtons}
`; } async function renderDashboard(environmentId) { const cards = await buildCards(environmentId); const cardsHtml = cards.map(renderCard).join('\n'); return ` App Portal

App Portal

${cardsHtml}
`; } module.exports = { buildCards, renderCard, renderDashboard }; ``` - [ ] **Step 4: テストが通ることを確認** Run: `cd apps/app-portal && npm test` Expected: PASS(全19 tests) - [ ] **Step 5: Commit** ```bash git add apps/app-portal/src/dashboard.js apps/app-portal/test/dashboard.test.js git commit -m "feat(app-portal): ダッシュボードのカード構築・HTML生成ロジックを追加" ``` --- ### Task 8: ルーティング統合(`index.js`)とフロントJS **Files:** - Create: `apps/app-portal/src/index.js` - Create: `apps/app-portal/public/portal.js` **Interfaces:** - Consumes: `createAllowlistMiddleware`(Task 5)、`renderDashboard`(Task 7)、`getComposeEnv`, `getContainers`, `readLogs`, `startCompose`, `stopCompose`, `restartContainer`(Task 1-3) - Produces: HTTPエンドポイント一式(以降のタスクではE2E手動確認のみ、ユニットテストなし — Express統合部分は`apps/auth-redirect`等の既存アプリでもユニットテストしておらず、本リポジトリの既存パターンを踏襲) - [ ] **Step 1: index.jsを書く** `apps/app-portal/src/index.js`: ```js const express = require('express'); const path = require('path'); const { createAllowlistMiddleware } = require('./allowlist'); const { renderDashboard } = require('./dashboard'); const { getComposeEnv, getContainers, readLogs, startCompose, stopCompose, restartContainer } = require('./dokployClient'); const { extractPortalMeta } = require('./portalMeta'); const app = express(); const PORT = process.env.PORT || 3000; const ENVIRONMENT_ID = process.env.DOKPLOY_ENVIRONMENT_ID; const PORTAL_SECRET = process.env.PORTAL_SECRET; app.use(express.static(path.join(__dirname, '..', 'public'))); app.use(express.json()); app.get('/health', (req, res) => { res.status(200).json({ status: 'healthy' }); }); app.use(createAllowlistMiddleware(process.env.PORTAL_ALLOWED_EMAILS)); app.get('/', async (req, res) => { try { const html = await renderDashboard(ENVIRONMENT_ID); res.set('Content-Type', 'text/html; charset=utf-8').send(html); } catch (err) { console.error('dashboard render failed', err.message); res.status(500).send('ダッシュボード取得に失敗しました'); } }); app.post('/api/compose/:composeId/start', async (req, res) => { try { await startCompose(req.params.composeId); res.sendStatus(202); } catch (err) { console.error('start failed', err.message); res.sendStatus(502); } }); app.post('/api/compose/:composeId/stop', async (req, res) => { try { await stopCompose(req.params.composeId); res.sendStatus(202); } catch (err) { console.error('stop failed', err.message); res.sendStatus(502); } }); app.post('/api/compose/:composeId/restart', async (req, res) => { try { const { appName } = await getComposeEnv(req.params.composeId); const containers = await getContainers(appName); const containerId = containers[0]?.containerId; if (!containerId) { res.sendStatus(404); return; } await restartContainer(containerId); res.sendStatus(202); } catch (err) { console.error('restart failed', err.message); res.sendStatus(502); } }); app.get('/api/compose/:composeId/logs', async (req, res) => { try { const { appName } = await getComposeEnv(req.params.composeId); const containers = await getContainers(appName); const containerId = containers[0]?.containerId; if (!containerId) { res.sendStatus(404); return; } const logs = await readLogs(req.params.composeId, containerId, 200); res.json({ logs }); } catch (err) { console.error('logs failed', err.message); res.sendStatus(502); } }); app.post('/api/compose/:composeId/trigger', async (req, res) => { try { const { env } = await getComposeEnv(req.params.composeId); const meta = extractPortalMeta(env); if (!meta || meta.appType !== 'batch' || !meta.url || !meta.triggerPath) { res.sendStatus(400); return; } const triggerRes = await fetch(`${meta.url}${meta.triggerPath}`, { method: 'POST', headers: { 'X-Portal-Secret': PORTAL_SECRET }, }); res.sendStatus(triggerRes.status === 202 ? 202 : 502); } catch (err) { console.error('trigger failed', err.message); res.status(502).json({ error: '応答なし' }); } }); app.listen(PORT, () => { console.log(`app-portal listening on port ${PORT}`); }); ``` - [ ] **Step 2: フロントJSを書く** `apps/app-portal/public/portal.js`: ```js document.getElementById('cards').addEventListener('click', async (event) => { const button = event.target.closest('button[data-action]'); if (!button) return; const { action, composeId } = button.dataset; if (action === 'logs') { const res = await fetch(`/api/compose/${composeId}/logs`); if (!res.ok) { alert(`ログ取得に失敗しました (status: ${res.status})`); return; } const data = await res.json(); alert(data.logs || 'ログなし'); return; } const res = await fetch(`/api/compose/${composeId}/${action}`, { method: 'POST' }); if (res.ok) { alert(`${action} を実行しました`); } else { alert(`${action} に失敗しました (status: ${res.status})`); } }); ``` - [ ] **Step 3: ローカルでDocker起動確認** Run: `cd apps/app-portal && cp .env.example .env`(`DOKPLOY_API_KEY`はダミー値でよい、この時点では`/health`のみ確認) Run: `docker compose -f docker-compose.local.yml up --build` Expected: エラーなく起動し、`curl http://localhost:3000/health`が`{"status":"healthy"}`を返す - [ ] **Step 4: Commit** ```bash git add apps/app-portal/src/index.js apps/app-portal/public/portal.js git commit -m "feat(app-portal): ダッシュボード・操作APIのルーティングを追加" ``` --- ### Task 9: batch型E2E検証用の軽量アプリ`portal-sample-batch` **Files:** - Create: `apps/portal-sample-batch/package.json` - Create: `apps/portal-sample-batch/src/triggerAuth.js` - Test: `apps/portal-sample-batch/test/triggerAuth.test.js` - Create: `apps/portal-sample-batch/src/index.js` - Create: `apps/portal-sample-batch/Dockerfile` - Create: `apps/portal-sample-batch/.dockerignore` - Create: `apps/portal-sample-batch/.env.example` - Create: `apps/portal-sample-batch/docker-compose.yml` - Create: `apps/portal-sample-batch/docker-compose.local.yml` **Interfaces:** - Produces: `isTriggerAuthorized(headerValue: string|undefined, expectedSecret: string|undefined): boolean`(`index.js`が利用)、`POST /trigger`エンドポイント(`X-Portal-Secret`ヘッダー検証、202を返す) — Task 12の手動E2Eで`app-portal`から呼ばれる - [ ] **Step 1: package.jsonを作成**(`apps/_template/package.json`のname違いのみ) `apps/portal-sample-batch/package.json`: ```json { "name": "portal-sample-batch", "version": "0.1.0", "private": true, "type": "commonjs", "scripts": { "dev": "node --watch src/index.js", "start": "node src/index.js", "test": "node --test" }, "dependencies": { "express": "^4.21.0" } } ``` - [ ] **Step 2: 失敗するテストを書く** `apps/portal-sample-batch/test/triggerAuth.test.js`: ```js const { test } = require('node:test'); const assert = require('node:assert'); const { isTriggerAuthorized } = require('../src/triggerAuth'); test('isTriggerAuthorized returns true only when the header matches a non-empty expected secret', () => { assert.strictEqual(isTriggerAuthorized('correct', 'correct'), true); assert.strictEqual(isTriggerAuthorized('wrong', 'correct'), false); assert.strictEqual(isTriggerAuthorized(undefined, 'correct'), false); assert.strictEqual(isTriggerAuthorized('anything', undefined), false); assert.strictEqual(isTriggerAuthorized('anything', ''), false); }); ``` - [ ] **Step 2b: テストが失敗することを確認** Run: `cd apps/portal-sample-batch && npm install && npm test` Expected: FAIL — `Cannot find module '../src/triggerAuth'` - [ ] **Step 3: triggerAuth.jsを実装** `apps/portal-sample-batch/src/triggerAuth.js`: ```js function isTriggerAuthorized(headerValue, expectedSecret) { return Boolean(expectedSecret) && headerValue === expectedSecret; } module.exports = { isTriggerAuthorized }; ``` - [ ] **Step 4: テストが通ることを確認** Run: `cd apps/portal-sample-batch && npm test` Expected: PASS(1 test) - [ ] **Step 5: src/index.jsを実装(triggerAuthを使う)** `apps/portal-sample-batch/src/index.js`: ```js const express = require('express'); const { isTriggerAuthorized } = require('./triggerAuth'); const app = express(); const PORT = process.env.PORT || 3000; const PORTAL_SECRET = process.env.PORTAL_SECRET; app.get('/', (req, res) => { res.json({ app: 'portal-sample-batch', status: 'ok' }); }); app.get('/health', (req, res) => { res.status(200).json({ status: 'healthy' }); }); app.post('/trigger', (req, res) => { if (!isTriggerAuthorized(req.headers['x-portal-secret'], PORTAL_SECRET)) { res.sendStatus(403); return; } console.log('portal-sample-batch: triggered at', new Date().toISOString()); res.sendStatus(202); }); app.listen(PORT, () => { console.log(`portal-sample-batch listening on port ${PORT}`); }); ``` - [ ] **Step 6: Dockerfile / .dockerignore / .env.example / docker-compose.local.ymlを作成**(`apps/_template`のものをコピーし、`.env.example`のみ差し替え) `apps/portal-sample-batch/Dockerfile`(`apps/_template/Dockerfile`と同一)、`.dockerignore`(同一)、`docker-compose.local.yml`(同一パターン、サービス名を`portal-sample-batch`に): ```yaml services: portal-sample-batch: build: . ports: - "3001:3000" env_file: - .env ``` `apps/portal-sample-batch/.env.example`: ``` PORT=3000 NODE_ENV=development PORTAL_SECRET= PORTAL_APP_TYPE=batch PORTAL_APP_LABEL=ポータル検証用バッチ PORTAL_APP_URL=http://portal-sample-batch:3000 PORTAL_TRIGGER_PATH=/trigger ``` - [ ] **Step 7: Dokploy用docker-compose.ymlを作成**(ドメイン非公開、`dokploy-network`内部限定) `apps/portal-sample-batch/docker-compose.yml`: ```yaml services: portal-sample-batch: build: . expose: - 3000 env_file: - .env networks: - dokploy-network restart: unless-stopped networks: dokploy-network: external: true ``` - [ ] **Step 8: Commit** ```bash git add apps/portal-sample-batch/ git commit -m "feat: ポータルbatch型E2E検証用のportal-sample-batchを追加" ``` --- ### Task 10: `_template`にbatch型パターンを追記 **Files:** - Modify: `apps/_template/README.md` - Modify: `apps/_template/.env.example` **Interfaces:** なし(ドキュメントのみ) - [ ] **Step 1: `.env.example`にPORTAL_*のコメント例を追記** `apps/_template/.env.example`の末尾に追記: ``` # アプリポータル(apps/app-portal)に登録する場合のみ設定する。 # PORTAL_APP_TYPE=web # web(常駐サービス) または batch(手動実行ジョブ) # PORTAL_APP_LABEL=表示名 # 省略時はDokploy Compose名を使う # PORTAL_APP_URL=https://x.apps.next-hd.net # web型は「開く」リンク先、batch型はdokploy-network内の到達先(例: http://:3000) # PORTAL_TRIGGER_PATH=/trigger # batch型のみ。手動実行トリガーのパス # PORTAL_SECRET= # batch型のみ。app-portalと共有するシークレット(値はDokploy Environment画面でのみ設定) ``` - [ ] **Step 2: README.mdにbatch型セクションを追記** `apps/_template/README.md`の末尾に追記: ```markdown ## アプリポータル(apps/app-portal)への登録 `webapps` project配下のアプリは、`.env.example`のPORTAL_*変数をDokploy Environment画面で設定することで、`apps/app-portal`のダッシュボードに一覧表示・操作対象として登録できる(任意)。 - 常駐WEBサービス(`PORTAL_APP_TYPE=web`): ダッシュボードからStart/Stop/Restart、「開く」で`PORTAL_APP_URL`へ遷移 - バッチジョブ(`PORTAL_APP_TYPE=batch`): ダッシュボードの「今すぐ実行」から`PORTAL_APP_URL${PORTAL_TRIGGER_PATH}`へPOSTされる。アプリ側は以下のパターンでトリガーエンドポイントを実装する: ```js app.post(process.env.PORTAL_TRIGGER_PATH, (req, res) => { if (req.headers['x-portal-secret'] !== process.env.PORTAL_SECRET) { return res.sendStatus(403); } // 実処理を非同期でキューイングし即座に202を返す res.sendStatus(202); }); ``` `PORTAL_SECRET`はポータルとアプリ間で共有する値。`AUTH_SECRETS.md`同様、機密情報として扱いDokploy Environment画面でのみ設定する。実装例は`apps/portal-sample-batch/src/index.js`参照。 ``` - [ ] **Step 3: Commit** ```bash git add apps/_template/README.md apps/_template/.env.example git commit -m "docs(_template): アプリポータル登録用のPORTAL_*規約を追記" ``` --- ### Task 11: リポジトリドキュメント更新 **Files:** - Modify: `README.md` - Modify: `.claude/skills/dokploy-webapp/SKILL.md` **Interfaces:** なし(ドキュメントのみ) - [ ] **Step 1: README.mdに「アプリポータル」章を追加** `README.md`の「認証ゲートウェイ」章の後に追記(既存の章構成に合わせて配置): ```markdown ## アプリポータル(apps/app-portal) `webapps` project配下の複数アプリを横断して起動/停止/再起動/バッチ手動実行/ログ確認を行う集中管理ポータル。設計の経緯・アーキテクチャは `docs/superpowers/specs/2026-07-25-app-portal-design.md`、実装計画は `docs/superpowers/plans/2026-07-25-app-portal.md` 参照。 - 公開ドメイン: `https://portal.apps.next-hd.net`(認証ゲートウェイ必須 + アプリ層allowlist二段防御) - 各アプリをポータルに登録するには、`.env.example`のPORTAL_*変数(`apps/_template/README.md`参照)をDokploy Environment画面で設定する - Dokploy tRPC APIを自前クライアント(`apps/app-portal/src/dokployClient.js`)で直接呼び出している。CLIのGET系コマンドが実装バグで全滅する問題(上記「Dokploy CLI」章参照)の回避策の実例でもある ``` - [ ] **Step 2: SKILL.mdにPORTAL_*規約を追記** `.claude/skills/dokploy-webapp/SKILL.md`の「参考」セクション手前に追記: ```markdown ## アプリポータルへの登録(任意) 新規アプリを`apps/app-portal`のダッシュボードに登録したい場合、`.env.example`にPORTAL_*変数を追加する(詳細は`apps/_template/README.md`「アプリポータルへの登録」章、実装は`apps/app-portal/src/dokployClient.js`参照)。 ``` - [ ] **Step 3: Commit** ```bash git add README.md .claude/skills/dokploy-webapp/SKILL.md git commit -m "docs: アプリポータルの章を追加" ``` --- ### Task 12: Dokployへのデプロイ・Traefik認証適用・実機E2E確認 **Files:** なし(インフラ操作のみ) **Interfaces:** なし - [ ] **Step 1: portal-sample-batchをGiteaへpush** Run: `git push gitea main` - [ ] **Step 2: portal-sample-batchのDokploy Compose作成**(CLI手順は`.claude/skills/dokploy-webapp/SKILL.md`「新規Compose作成」参照) ```bash dokploy compose create --name "portal-sample-batch" --environmentId "Cm0HjMIFyl11UdIcIGRy8" --composeType "docker-compose" --appName "portal-sample-batch" --json # → composeIdを控える dokploy compose update --composeId "" --sourceType "gitea" --giteaId "O5-CqLQwVdlzXw3KfmN-8" --giteaOwner "mygit-admin" --giteaRepository "NodeSrv" --giteaBranch "main" --composePath "apps/portal-sample-batch/docker-compose.yml" --json ``` - [ ] **Step 3: portal-sample-batchの環境変数をDokploy Environment画面で設定** `.env.example`の内容(`PORTAL_SECRET`はランダムな値を新規発行)を設定。 - [ ] **Step 4: portal-sample-batchをデプロイし`/health`を確認** ```bash dokploy compose deploy --composeId "" --title "初回デプロイ" --json ``` Run: SSH経由でコンテナ起動確認(`ssh -i Keys/LightsailDefaultKey-ap-northeast-1.pem ubuntu@dokploy45.next-hd.net "sudo docker ps --filter name=portal-sample-batch"`) - [ ] **Step 5: app-portalのDokploy Compose作成**(同様の2段階) ```bash dokploy compose create --name "app-portal" --environmentId "Cm0HjMIFyl11UdIcIGRy8" --composeType "docker-compose" --appName "app-portal" --json dokploy compose update --composeId "" --sourceType "gitea" --giteaId "O5-CqLQwVdlzXw3KfmN-8" --giteaOwner "mygit-admin" --giteaRepository "NodeSrv" --giteaBranch "main" --composePath "apps/app-portal/docker-compose.yml" --json ``` - [ ] **Step 6: app-portalの環境変数をDokploy Environment画面で設定** `DOKPLOY_API_KEY`は新規にDokploy管理画面でAPIキーを発行して設定(既存のCLI用トークンとは別に発行し、万一漏れた場合の影響範囲を分離する)。`PORTAL_SECRET`はStep 3で発行した値と同一にする。 - [ ] **Step 7: app-portalをデプロイし`/health`を確認** ```bash dokploy compose deploy --composeId "" --title "初回デプロイ" --json ``` Run: `curl -I https://portal.apps.next-hd.net/health` Expected: `200` - [ ] **Step 8: 手動E2E確認** 1. ブラウザで`https://portal.apps.next-hd.net/`にアクセス → 未ログインならKeycloakログイン画面に遷移すること(認証ゲートウェイが機能していること)を確認 2. ログイン後、ダッシュボードに`auth-redirect`等PORTAL_APP_TYPE設定済みアプリがカード表示されること(この時点で登録済みなのは`portal-sample-batch`のみの想定。他アプリは任意でPORTAL_*を追加してから確認) 3. `portal-sample-batch`カードの「今すぐ実行」を押し、202が返ること・SSH経由でコンテナログに`portal-sample-batch: triggered at ...`が出力されることを確認 4. 「ログ表示」を押し、ログが表示されることを確認 5. `PORTAL_ALLOWED_EMAILS`に含まれないメールアドレスのユーザーでアクセスし、403になることを確認(allowlist外のテストユーザーが用意できない場合は、`PORTAL_ALLOWED_EMAILS`を一時的に空にしてallowlistミドルウェアが403を返すことのみ確認してもよい) - [ ] **Step 9: HANDOFFドキュメントは作らず、完了確認のみ** このタスクはインフラ操作のみのためcommitなし。E2E確認結果を確認者へ報告する。 --- ## Self-Review メモ - 設計書(`docs/superpowers/specs/2026-07-25-app-portal-design.md`)の「未検証事項」(Compose一覧・ログ取得のtRPCエンドポイント)は本計画作成前に実地検証済み(`environment.one`, `compose.one`, `docker.getContainersByAppNameMatch`, `compose.readLogs`, `compose.start/stop`, `docker.restartContainer`) - `PORTAL_APP_URL`をbatch型でも必須にした点は設計書からの変更。理由をGlobal Constraintsに明記済み - `compose.one`の機密フィールド漏洩防止は、2026-07-25に実際に発生した事故(Gitea OAuth token漏洩)の再発防止として、Task 2に回帰テストとして明示的に組み込んだ - batch型検証専用アプリ(`portal-sample-batch`)を新規に切り出し、本実装未着手の既存アプリ(`lineworks-board-sync`等)のスコープを侵さない設計にした