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>
1488 lines
52 KiB
Markdown
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('<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, '>')
|
|
.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 `<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`等)のスコープを侵さない設計にした
|