ken_nogi/NodeSrv/docs/superpowers/plans/2026-07-25-app-portal.md
Kenichiro NOGI ce58cb4be4 初回コミット: dev配下(NodeSrv/Pleasanter等)をGitea管理下に統合
GitHub(nextgroup2706/ken_nogi)は今後使わず自社Gitea運用に切替え。
NodeSrvは旧リポジトリの履歴を破棄しファイルのみ統合(Dokploy用サービスアカウントは
別途mygit-admin/NodeSrv.gitに履歴あり)。notepmエクスポート(12GB)とPleasanter
インストーラzip(208MB)はサイズが大きいため.gitignoreで除外。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 15:37:06 +09:00

1488 lines
52 KiB
Markdown

# アプリポータル 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<any>`、`trpcPost(endpoint: string, data: object): Promise<any>` (`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<Array<{composeId, name, composeStatus}>>`、`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<Array<{containerId, state}>>`、`readLogs(composeId, containerId, tail): Promise<string>`、`startCompose(composeId): Promise<any>`、`stopCompose(composeId): Promise<any>`、`restartContainer(containerId): Promise<any>`(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<string,string>`、`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<Array<CardData|ErrorCardData>>`、`renderCard(card): string`、`renderDashboard(environmentId: string): Promise<string>`(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: '<script>bad</script>', url: 'http://sample:3000', triggerPath: '/trigger' },
});
assert.ok(!html.includes('<script>bad</script>'));
assert.ok(html.includes('&lt;script&gt;'));
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 `<div class="card card-error" data-compose-id="${escapeHtml(card.composeId)}">
<h3>${escapeHtml(card.name || card.composeId)}</h3>
<p>状態取得失敗</p>
</div>`;
}
const { composeId, composeStatus, containerState, meta } = card;
const openLink =
meta.appType === 'web' && meta.url
? `<a href="${escapeHtml(meta.url)}" target="_blank" rel="noopener">開く</a>`
: '';
const actionButtons =
meta.appType === 'web'
? `<button data-action="start" data-compose-id="${escapeHtml(composeId)}">Start</button>
<button data-action="stop" data-compose-id="${escapeHtml(composeId)}">Stop</button>
<button data-action="restart" data-compose-id="${escapeHtml(composeId)}">Restart</button>`
: `<button data-action="trigger" data-compose-id="${escapeHtml(composeId)}">今すぐ実行</button>`;
return `<div class="card" data-compose-id="${escapeHtml(composeId)}">
<h3>${escapeHtml(meta.label)}</h3>
<p>compose: ${escapeHtml(composeStatus)} / container: ${escapeHtml(containerState)}</p>
${openLink}
${actionButtons}
<button data-action="logs" data-compose-id="${escapeHtml(composeId)}">ログ表示</button>
</div>`;
}
async function renderDashboard(environmentId) {
const cards = await buildCards(environmentId);
const cardsHtml = cards.map(renderCard).join('\n');
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>App Portal</title>
</head>
<body>
<h1>App Portal</h1>
<div id="cards">${cardsHtml}</div>
<script src="/portal.js"></script>
</body>
</html>`;
}
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://<compose内サービス名>: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 "<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 "<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 "<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 "<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`等)のスコープを侵さない設計にした