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>
38 KiB
アプリポータル allowlist管理画面 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: apps/app-portalに/admin配下の管理画面を追加し、Master Keyでログインしてallowlist(許可メールアドレス一覧)をWebブラウザから即時追加・削除できるようにする。
Architecture: ファイルベースの永続化(data/allowlist.json、ボリュームマウント)に切り替え、既存のcreateAllowlistMiddlewareを起動時固定文字列からファイル都度読み込み方式へ変更する。/admin配下はTraefikの別ルーター(認証ミドルウェアなし、priority高め)でKeycloak/oauth2-proxy認証をバイパスし、Master Keyによるステートレスなセッションcookie認証のみで保護する。
Tech Stack: Node.js 22 / Express 4(既存踏襲)、追加npm依存なし(Cookie解析・HMAC署名はNode標準cryptoと自前実装)。
Global Constraints
- Node.js 22(既存
apps/app-portalのDockerfileを踏襲) - 追加npm依存を増やさない。Cookie解析・セッション署名は自前実装、フォームPOSTの解析はexpress標準の
express.urlencodedのみ使用 PORTAL_MASTER_KEYはAUTH_SECRETS.md同様の機密情報として扱い、Dokploy Environment画面でのみ設定する。チャット等に出力しない- セッション署名鍵は
PORTAL_MASTER_KEYをそのまま流用し、追加のシークレット管理をしない - allowlistデータは
apps/app-portal/data/allowlist.jsonにファイル永続化し、Dockerボリュームでコンテナ再作成をまたいで保持する /admin配下はKeycloak/LINE WORKS SSO・oauth2-proxy認証をバイパスし、Master Keyのみで認証する(Traefikのpriorityラベルで専用ルーターを優先させる)- 環境変数
PORTAL_ALLOWED_EMAILSは「data/allowlist.jsonが存在しない場合の初期値」としてのみ使う後方互換 - ログイン試行のレート制限・複数管理者権限分離は今回のスコープ外(YAGNI)
ファイル構成
apps/app-portal/
src/
allowlistStore.js — 新規。data/allowlist.jsonの読み書き・追加・削除・重複/形式チェック
allowlist.js — 変更。createAllowlistMiddleware(csv) → createAllowlistMiddleware(filePath)
adminAuth.js — 新規。Master Key検証・セッションcookie発行検証・Cookie解析
adminView.js — 新規。ログインページ・admin一覧ページのHTML生成
index.js — 変更。/admin系ルーティング追加
test/
allowlistStore.test.js
allowlist.test.js — 変更。ファイルパスベースのテストに書き換え
adminAuth.test.js
adminView.test.js
data/ — 新規(gitignore対象、ローカル検証時に生成される)
.env.example — 変更。PORTAL_MASTER_KEY追記
Dockerfile — 変更。/app/dataディレクトリ作成・node所有化
docker-compose.yml — 変更。volumes追加、admin用Traefikルーター追加
docker-compose.local.yml — 変更。dataディレクトリのbind mount追加
Task 1: allowlistStore.js(データ読み書き・追加・削除)
Files:
- Create:
apps/app-portal/src/allowlistStore.js - Test:
apps/app-portal/test/allowlistStore.test.js
Interfaces:
-
Produces:
getFilePath(): string、ensureFile(filePath: string, initialEmailsCsv: string|undefined): void、readEmails(filePath: string): string[]、addEmail(filePath: string, email: string): string[](不正な形式はError('invalid email format')をthrow)、removeEmail(filePath: string, email: string): string[](Task 2・Task 5が利用) -
Step 1: 失敗するテストを書く
apps/app-portal/test/allowlistStore.test.js:
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { ensureFile, readEmails, addEmail, removeEmail } = require('../src/allowlistStore');
function tmpFilePath() {
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'allowlist-test-')), 'allowlist.json');
}
test('ensureFile creates a file seeded from the CSV env var when none exists', () => {
const filePath = tmpFilePath();
ensureFile(filePath, 'a@example.com, B@Example.com');
assert.deepStrictEqual(readEmails(filePath), ['a@example.com', 'b@example.com']);
});
test('ensureFile does nothing when the file already exists', () => {
const filePath = tmpFilePath();
fs.writeFileSync(filePath, JSON.stringify({ emails: ['existing@example.com'] }));
ensureFile(filePath, 'ignored@example.com');
assert.deepStrictEqual(readEmails(filePath), ['existing@example.com']);
});
test('readEmails returns [] when the file does not exist', () => {
const filePath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'allowlist-test-')), 'missing.json');
assert.deepStrictEqual(readEmails(filePath), []);
});
test('addEmail appends a normalized email and persists it', () => {
const filePath = tmpFilePath();
ensureFile(filePath, '');
const result = addEmail(filePath, ' New@Example.com ');
assert.deepStrictEqual(result, ['new@example.com']);
assert.deepStrictEqual(readEmails(filePath), ['new@example.com']);
});
test('addEmail ignores duplicates', () => {
const filePath = tmpFilePath();
ensureFile(filePath, 'a@example.com');
const result = addEmail(filePath, 'A@Example.com');
assert.deepStrictEqual(result, ['a@example.com']);
});
test('addEmail throws on invalid email format', () => {
const filePath = tmpFilePath();
ensureFile(filePath, '');
assert.throws(() => addEmail(filePath, 'not-an-email'), /invalid email format/);
});
test('removeEmail removes a matching email (case-insensitive)', () => {
const filePath = tmpFilePath();
ensureFile(filePath, 'a@example.com,b@example.com');
const result = removeEmail(filePath, 'A@Example.com');
assert.deepStrictEqual(result, ['b@example.com']);
assert.deepStrictEqual(readEmails(filePath), ['b@example.com']);
});
- Step 2: テストが失敗することを確認
Run: cd apps/app-portal && npm test
Expected: FAIL — Cannot find module '../src/allowlistStore'
- Step 3: 実装を書く
apps/app-portal/src/allowlistStore.js:
const fs = require('fs');
const path = require('path');
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function getFilePath() {
return process.env.ALLOWLIST_FILE_PATH || path.join(__dirname, '..', 'data', 'allowlist.json');
}
function parseCsv(csv) {
return (csv || '')
.split(',')
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
}
function ensureFile(filePath, initialEmailsCsv) {
if (fs.existsSync(filePath)) return;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ emails: parseCsv(initialEmailsCsv) }, null, 2));
}
function readEmails(filePath) {
if (!fs.existsSync(filePath)) return [];
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
return Array.isArray(data.emails) ? data.emails : [];
}
function writeEmails(filePath, emails) {
fs.writeFileSync(filePath, JSON.stringify({ emails }, null, 2));
}
function addEmail(filePath, email) {
const normalized = String(email).trim().toLowerCase();
if (!EMAIL_PATTERN.test(normalized)) {
throw new Error('invalid email format');
}
const emails = readEmails(filePath);
if (!emails.includes(normalized)) {
emails.push(normalized);
writeEmails(filePath, emails);
}
return emails;
}
function removeEmail(filePath, email) {
const normalized = String(email).trim().toLowerCase();
const emails = readEmails(filePath).filter((e) => e !== normalized);
writeEmails(filePath, emails);
return emails;
}
module.exports = { getFilePath, ensureFile, readEmails, addEmail, removeEmail };
- Step 4: テストが通ることを確認
Run: cd apps/app-portal && npm test
Expected: PASS(7 tests)
- Step 5: Commit
git add apps/app-portal/src/allowlistStore.js apps/app-portal/test/allowlistStore.test.js
git commit -m "feat(app-portal): allowlistのファイル永続化ストアを追加"
Task 2: allowlist.jsをファイルベース方式に変更
Files:
- Modify:
apps/app-portal/src/allowlist.js - Modify:
apps/app-portal/test/allowlist.test.js(全面書き換え)
Interfaces:
-
Consumes:
readEmails(filePath)(Task 1) -
Produces:
createAllowlistMiddleware(filePath: string): (req, res, next) => void(Task 5のindex.jsが利用。シグネチャ変更: 第一引数がCSV文字列からファイルパスに変わる) -
Step 1: 既存テストをファイルベース版に書き換える(失敗する状態にする)
apps/app-portal/test/allowlist.test.jsを以下で置き換える:
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
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;
},
};
}
function tmpAllowlistFile(emails) {
const filePath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'allowlist-mw-test-')), 'allowlist.json');
fs.writeFileSync(filePath, JSON.stringify({ emails }));
return filePath;
}
test('allows a request whose email is in the allowlist file (case-insensitive)', () => {
const filePath = tmpAllowlistFile(['a@example.com', 'b@example.com']);
const middleware = createAllowlistMiddleware(filePath);
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 filePath = tmpAllowlistFile(['a@example.com']);
const middleware = createAllowlistMiddleware(filePath);
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 file', () => {
const filePath = tmpAllowlistFile(['a@example.com']);
const middleware = createAllowlistMiddleware(filePath);
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);
});
test('re-reads the file on every request, reflecting changes without restart', () => {
const filePath = tmpAllowlistFile(['a@example.com']);
const middleware = createAllowlistMiddleware(filePath);
const req = { headers: { 'x-auth-request-email': 'new@example.com' } };
const res1 = mockRes();
middleware(req, res1, () => { throw new Error('should not reach next yet'); });
assert.strictEqual(res1.statusCode, 403);
fs.writeFileSync(filePath, JSON.stringify({ emails: ['a@example.com', 'new@example.com'] }));
const res2 = mockRes();
let nextCalled = false;
middleware(req, res2, () => { nextCalled = true; });
assert.strictEqual(nextCalled, true);
});
- Step 2: テストが失敗することを確認
Run: cd apps/app-portal && npm test
Expected: FAIL(既存のcreateAllowlistMiddlewareはCSV文字列を受け取る実装のため、ファイルパスを渡しても正しく動かず全テストFAIL)
- Step 3: 実装を書き換える
apps/app-portal/src/allowlist.jsを以下で置き換える:
const { readEmails } = require('./allowlistStore');
function createAllowlistMiddleware(filePath) {
return function allowlistMiddleware(req, res, next) {
const email = (req.headers['x-auth-request-email'] || '').toLowerCase();
const allowed = readEmails(filePath);
if (!email || !allowed.includes(email)) {
res.status(403).send('Forbidden');
return;
}
next();
};
}
module.exports = { createAllowlistMiddleware };
- Step 4: テストが通ることを確認
Run: cd apps/app-portal && npm test
Expected: PASS(全11 tests — Task1の7件 + このタスクの4件)
- Step 5: Commit
git add apps/app-portal/src/allowlist.js apps/app-portal/test/allowlist.test.js
git commit -m "feat(app-portal): allowlistミドルウェアをファイル都度読み込み方式に変更"
Task 3: adminAuth.js(Master Key検証・セッションcookie・Cookie解析)
Files:
- Create:
apps/app-portal/src/adminAuth.js - Test:
apps/app-portal/test/adminAuth.test.js
Interfaces:
-
Produces:
SESSION_COOKIE_NAME: string、verifyMasterKey(input: string, masterKey: string): boolean、createSessionToken(secret: string, now?: number): string、verifySessionToken(token: string, secret: string, now?: number): boolean、parseCookies(cookieHeader: string|undefined): Record<string,string>、buildSessionCookieHeader(token: string): string、buildLogoutCookieHeader(): string(Task 5のindex.jsが利用) -
Step 1: 失敗するテストを書く
apps/app-portal/test/adminAuth.test.js:
const { test } = require('node:test');
const assert = require('node:assert');
const {
SESSION_COOKIE_NAME,
verifyMasterKey,
createSessionToken,
verifySessionToken,
parseCookies,
buildSessionCookieHeader,
buildLogoutCookieHeader,
} = require('../src/adminAuth');
test('verifyMasterKey returns true only for an exact match', () => {
assert.strictEqual(verifyMasterKey('secret123', 'secret123'), true);
assert.strictEqual(verifyMasterKey('wrong', 'secret123'), false);
assert.strictEqual(verifyMasterKey('', 'secret123'), false);
assert.strictEqual(verifyMasterKey('secret123', ''), false);
assert.strictEqual(verifyMasterKey('secret123', undefined), false);
});
test('createSessionToken/verifySessionToken round-trip successfully within TTL', () => {
const secret = 'test-secret';
const now = 1700000000000;
const token = createSessionToken(secret, now);
assert.strictEqual(verifySessionToken(token, secret, now + 1000), true);
});
test('verifySessionToken rejects a token signed with a different secret', () => {
const now = 1700000000000;
const token = createSessionToken('secret-a', now);
assert.strictEqual(verifySessionToken(token, 'secret-b', now + 1000), false);
});
test('verifySessionToken rejects an expired token', () => {
const secret = 'test-secret';
const now = 1700000000000;
const token = createSessionToken(secret, now);
const oneDayAndOneMs = 24 * 60 * 60 * 1000 + 1;
assert.strictEqual(verifySessionToken(token, secret, now + oneDayAndOneMs), false);
});
test('verifySessionToken rejects malformed tokens', () => {
assert.strictEqual(verifySessionToken('', 'secret'), false);
assert.strictEqual(verifySessionToken(undefined, 'secret'), false);
assert.strictEqual(verifySessionToken('no-dot-here', 'secret'), false);
assert.strictEqual(verifySessionToken('123.abc.extra', 'secret'), false);
});
test('parseCookies parses a Cookie header into a key-value map', () => {
const result = parseCookies('foo=bar; portal_admin_session=abc123');
assert.deepStrictEqual(result, { foo: 'bar', portal_admin_session: 'abc123' });
});
test('parseCookies returns {} for an empty or missing header', () => {
assert.deepStrictEqual(parseCookies(undefined), {});
assert.deepStrictEqual(parseCookies(''), {});
});
test('buildSessionCookieHeader includes the cookie name, HttpOnly and Path=/admin', () => {
const header = buildSessionCookieHeader('sometoken');
assert.ok(header.startsWith(`${SESSION_COOKIE_NAME}=sometoken`));
assert.ok(header.includes('HttpOnly'));
assert.ok(header.includes('Path=/admin'));
});
test('buildLogoutCookieHeader clears the cookie with Max-Age=0', () => {
const header = buildLogoutCookieHeader();
assert.ok(header.startsWith(`${SESSION_COOKIE_NAME}=`));
assert.ok(header.includes('Max-Age=0'));
});
- Step 2: テストが失敗することを確認
Run: cd apps/app-portal && npm test
Expected: FAIL — Cannot find module '../src/adminAuth'
- Step 3: 実装を書く
apps/app-portal/src/adminAuth.js:
const crypto = require('crypto');
const SESSION_COOKIE_NAME = 'portal_admin_session';
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
function timingSafeEqualStrings(a, b) {
const bufA = Buffer.from(String(a));
const bufB = Buffer.from(String(b));
if (bufA.length !== bufB.length) {
crypto.timingSafeEqual(bufA, bufA);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
function verifyMasterKey(input, masterKey) {
if (!masterKey || !input) return false;
return timingSafeEqualStrings(input, masterKey);
}
function sign(payload, secret) {
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
}
function createSessionToken(secret, now = Date.now()) {
const payload = String(now);
return `${payload}.${sign(payload, secret)}`;
}
function verifySessionToken(token, secret, now = Date.now()) {
if (!token || typeof token !== 'string') return false;
const parts = token.split('.');
if (parts.length !== 2) return false;
const [payload, signature] = parts;
if (!timingSafeEqualStrings(signature, sign(payload, secret))) return false;
const issuedAt = Number(payload);
if (!Number.isFinite(issuedAt)) return false;
return now - issuedAt < SESSION_TTL_MS;
}
function parseCookies(cookieHeader) {
const result = {};
if (!cookieHeader) return result;
cookieHeader.split(';').forEach((pair) => {
const idx = pair.indexOf('=');
if (idx === -1) return;
const key = pair.slice(0, idx).trim();
const value = pair.slice(idx + 1).trim();
if (key) result[key] = decodeURIComponent(value);
});
return result;
}
function buildSessionCookieHeader(token) {
return [
`${SESSION_COOKIE_NAME}=${encodeURIComponent(token)}`,
'HttpOnly',
'Secure',
'Path=/admin',
'SameSite=Strict',
`Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`,
].join('; ');
}
function buildLogoutCookieHeader() {
return [`${SESSION_COOKIE_NAME}=`, 'HttpOnly', 'Secure', 'Path=/admin', 'SameSite=Strict', 'Max-Age=0'].join('; ');
}
module.exports = {
SESSION_COOKIE_NAME,
SESSION_TTL_MS,
verifyMasterKey,
createSessionToken,
verifySessionToken,
parseCookies,
buildSessionCookieHeader,
buildLogoutCookieHeader,
};
- Step 4: テストが通ることを確認
Run: cd apps/app-portal && npm test
Expected: PASS(全20 tests)
- Step 5: Commit
git add apps/app-portal/src/adminAuth.js apps/app-portal/test/adminAuth.test.js
git commit -m "feat(app-portal): Master Key検証・セッションcookie発行検証を追加"
Task 4: adminView.js(ログインページ・admin一覧ページのHTML生成)
Files:
- Create:
apps/app-portal/src/adminView.js - Test:
apps/app-portal/test/adminView.test.js
Interfaces:
-
Produces:
renderLoginPage(errorMessage?: string): string、renderAdminPage(emails: string[], errorMessage?: string): string(Task 5のindex.jsが利用) -
Step 1: 失敗するテストを書く
apps/app-portal/test/adminView.test.js:
const { test } = require('node:test');
const assert = require('node:assert');
const { renderLoginPage, renderAdminPage } = require('../src/adminView');
test('renderLoginPage without error omits the error message', () => {
const html = renderLoginPage();
assert.ok(!html.includes('class="error"'));
assert.ok(html.includes('action="/admin/login"'));
});
test('renderLoginPage with error escapes and shows the message', () => {
const html = renderLoginPage('<script>bad</script>');
assert.ok(!html.includes('<script>bad</script>'));
assert.ok(html.includes('<script>'));
});
test('renderAdminPage lists emails with a remove form for each, escaping HTML', () => {
const html = renderAdminPage(['a@example.com', '<script>x</script>@example.com']);
assert.ok(html.includes('a@example.com'));
assert.ok(html.includes('value="a@example.com"'));
assert.ok(!html.includes('<script>x</script>@example.com'));
assert.ok(html.includes('action="/admin/allowlist/remove"'));
assert.ok(html.includes('action="/admin/allowlist/add"'));
});
test('renderAdminPage shows an error message when provided', () => {
const html = renderAdminPage(['a@example.com'], 'invalid email format');
assert.ok(html.includes('invalid email format'));
});
- Step 2: テストが失敗することを確認
Run: cd apps/app-portal && npm test
Expected: FAIL — Cannot find module '../src/adminView'
- Step 3: 実装を書く
apps/app-portal/src/adminView.js:
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function renderLoginPage(errorMessage) {
const errorHtml = errorMessage ? `<p class="error">${escapeHtml(errorMessage)}</p>` : '';
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>App Portal Admin Login</title>
</head>
<body>
<h1>Admin Login</h1>
${errorHtml}
<form method="POST" action="/admin/login">
<input type="password" name="masterKey" placeholder="Master Key" required>
<button type="submit">Login</button>
</form>
</body>
</html>`;
}
function renderAdminPage(emails, errorMessage) {
const errorHtml = errorMessage ? `<p class="error">${escapeHtml(errorMessage)}</p>` : '';
const rows = emails
.map(
(email) => `<li>
${escapeHtml(email)}
<form method="POST" action="/admin/allowlist/remove" style="display:inline">
<input type="hidden" name="email" value="${escapeHtml(email)}">
<button type="submit">削除</button>
</form>
</li>`
)
.join('\n');
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Allowlist Admin</title>
</head>
<body>
<h1>Allowlist Admin</h1>
${errorHtml}
<ul>${rows}</ul>
<form method="POST" action="/admin/allowlist/add">
<input type="email" name="email" placeholder="user@example.com" required>
<button type="submit">追加</button>
</form>
<form method="POST" action="/admin/logout">
<button type="submit">ログアウト</button>
</form>
</body>
</html>`;
}
module.exports = { renderLoginPage, renderAdminPage };
- Step 4: テストが通ることを確認
Run: cd apps/app-portal && npm test
Expected: PASS(全24 tests)
- Step 5: Commit
git add apps/app-portal/src/adminView.js apps/app-portal/test/adminView.test.js
git commit -m "feat(app-portal): admin管理画面のHTML生成を追加"
Task 5: index.jsに/adminルート統合
Files:
- Modify:
apps/app-portal/src/index.js
Interfaces:
- Consumes:
createAllowlistMiddleware(Task 2)、verifyMasterKey,createSessionToken,verifySessionToken,parseCookies,buildSessionCookieHeader,buildLogoutCookieHeader,SESSION_COOKIE_NAME(Task 3)、renderLoginPage,renderAdminPage(Task 4)、getFilePath,ensureFile,readEmails,addEmail,removeEmail(Task 1) - Produces: HTTPエンドポイント一式(ユニットテストなし、Task 6のローカルDocker確認・Task 7の実機E2Eで検証。既存のExpress統合部分と同じパターンを踏襲)
重要な実装上の注意: /admin系のルート定義は、app.use(createAllowlistMiddleware(...))より前に書くこと。Expressはミドルウェア・ルートを登録順に評価するため、/admin配下のルートを先に定義しておけば、Keycloak/allowlist認証を経由せずMaster Key認証のみで完結する。
- Step 1: index.jsを書き換える
apps/app-portal/src/index.jsを以下で置き換える(既存の/, /api/compose/*ルートは変更なし、/admin系ルートと関連requireを追加):
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 { getFilePath: getAllowlistFilePath, ensureFile, readEmails, addEmail, removeEmail } = require('./allowlistStore');
const {
verifyMasterKey,
createSessionToken,
verifySessionToken,
parseCookies,
buildSessionCookieHeader,
buildLogoutCookieHeader,
SESSION_COOKIE_NAME,
} = require('./adminAuth');
const { renderLoginPage, renderAdminPage } = require('./adminView');
const app = express();
const PORT = process.env.PORT || 3000;
const ENVIRONMENT_ID = process.env.DOKPLOY_ENVIRONMENT_ID;
const PORTAL_SECRET = process.env.PORTAL_SECRET;
const MASTER_KEY = process.env.PORTAL_MASTER_KEY;
const ALLOWLIST_FILE = getAllowlistFilePath();
ensureFile(ALLOWLIST_FILE, process.env.PORTAL_ALLOWED_EMAILS);
app.use(express.static(path.join(__dirname, '..', 'public')));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy' });
});
function requireAdminSession(req, res, next) {
const cookies = parseCookies(req.headers.cookie);
if (!verifySessionToken(cookies[SESSION_COOKIE_NAME], MASTER_KEY)) {
res.redirect('/admin/login');
return;
}
next();
}
app.get('/admin/login', (req, res) => {
res.set('Content-Type', 'text/html; charset=utf-8').send(renderLoginPage());
});
app.post('/admin/login', (req, res) => {
if (!verifyMasterKey(req.body.masterKey, MASTER_KEY)) {
res.set('Content-Type', 'text/html; charset=utf-8').send(renderLoginPage('Master Keyが正しくありません'));
return;
}
res.set('Set-Cookie', buildSessionCookieHeader(createSessionToken(MASTER_KEY)));
res.redirect('/admin');
});
app.post('/admin/logout', (req, res) => {
res.set('Set-Cookie', buildLogoutCookieHeader());
res.redirect('/admin/login');
});
app.get('/admin', requireAdminSession, (req, res) => {
res.set('Content-Type', 'text/html; charset=utf-8').send(renderAdminPage(readEmails(ALLOWLIST_FILE)));
});
app.post('/admin/allowlist/add', requireAdminSession, (req, res) => {
try {
addEmail(ALLOWLIST_FILE, req.body.email);
} catch (err) {
res.set('Content-Type', 'text/html; charset=utf-8').send(renderAdminPage(readEmails(ALLOWLIST_FILE), err.message));
return;
}
res.redirect('/admin');
});
app.post('/admin/allowlist/remove', requireAdminSession, (req, res) => {
removeEmail(ALLOWLIST_FILE, req.body.email);
res.redirect('/admin');
});
app.use(createAllowlistMiddleware(ALLOWLIST_FILE));
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: 全ユニットテストが引き続き通ることを確認
Run: cd apps/app-portal && npm test
Expected: PASS(全24 tests、index.js自体はテスト対象外)
- Step 3: Commit
git add apps/app-portal/src/index.js
git commit -m "feat(app-portal): /admin配下のallowlist管理ルーティングを追加"
Task 6: Docker/Compose設定変更・ローカル動作確認
Files:
- Modify:
apps/app-portal/Dockerfile - Modify:
apps/app-portal/.env.example - Modify:
apps/app-portal/docker-compose.yml - Modify:
apps/app-portal/docker-compose.local.yml
Interfaces: なし(インフラ・設定ファイルのみ)
- Step 1: Dockerfileに/app/dataディレクトリ作成・所有権変更を追加
apps/app-portal/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 . .
RUN mkdir -p /app/data && chown -R node:node /app/data
USER node
EXPOSE 3000
CMD ["node", "src/index.js"]
- Step 2: .env.exampleにPORTAL_MASTER_KEYを追記
apps/app-portal/.env.exampleの末尾に追記:
PORTAL_MASTER_KEY=
- Step 3: docker-compose.ymlにボリュームとadmin用Traefikルーターを追加
apps/app-portal/docker-compose.ymlを以下で置き換える:
services:
app-portal:
build: .
expose:
- 3000
env_file:
- .env
volumes:
- app_portal_data:/app/data
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.routers.app-portal-admin-websecure.rule=Host(`portal.apps.next-hd.net`) && PathPrefix(`/admin`)
- traefik.http.routers.app-portal-admin-websecure.entrypoints=websecure
- traefik.http.routers.app-portal-admin-websecure.tls.certresolver=letsencrypt
- traefik.http.routers.app-portal-admin-websecure.priority=100
- traefik.http.routers.app-portal-admin-websecure.service=app-portal-svc
- traefik.http.services.app-portal-svc.loadbalancer.server.port=3000
restart: unless-stopped
networks:
dokploy-network:
external: true
volumes:
app_portal_data:
- Step 4: docker-compose.local.ymlにdataディレクトリのbind mountを追加
apps/app-portal/docker-compose.local.ymlを以下で置き換える:
services:
app-portal:
build: .
ports:
- "3000:3000"
env_file:
- .env
volumes:
- ./data:/app/data
- Step 5: ローカルで動作確認
Run: cd apps/app-portal && cp .env.example .env(.envにPORTAL_MASTER_KEY=local-test-keyを追記、他はダミー値でよい)
Run: node src/index.js(Dockerが使えない環境の場合。Docker Desktopが使える環境ではdocker compose -f docker-compose.local.yml up --buildを優先する)
Run: curl -s http://localhost:3000/admin/login
Expected: ログインフォームのHTMLが返る
Run: curl -s -X POST http://localhost:3000/admin/login -d "masterKey=wrong" -c /tmp/cookies.txt
Expected: 「Master Keyが正しくありません」を含むHTML
Run: curl -s -i -X POST http://localhost:3000/admin/login -d "masterKey=local-test-key" -c /tmp/cookies.txt
Expected: 302かつLocation: /admin
Run: curl -s -b /tmp/cookies.txt http://localhost:3000/admin
Expected: allowlist一覧ページのHTML(初期状態は空)
Run: curl -s -b /tmp/cookies.txt -X POST http://localhost:3000/admin/allowlist/add -d "email=test@example.com" -L
Expected: test@example.comを含むallowlist一覧ページ
Run: プロセスを停止し(taskkillまたは該当PIDへのkill)、.envとdata/ディレクトリを削除
- Step 6: Commit
git add apps/app-portal/Dockerfile apps/app-portal/.env.example apps/app-portal/docker-compose.yml apps/app-portal/docker-compose.local.yml
git commit -m "feat(app-portal): allowlistデータ永続化用ボリュームとadmin用Traefikルーターを追加"
Task 7: Dokployへのデプロイ・実機E2E確認
Files: なし(インフラ操作のみ)
Interfaces: なし
- Step 1: Giteaへpush
Run: git push gitea main
- Step 2: PORTAL_MASTER_KEYを新規発行しDokploy Environment画面で設定
新しいランダム値を生成し、既存のapp-portal環境変数(DOKPLOY_API_KEY等)に追記する形でPORTAL_MASTER_KEY=<新規値>を設定する。値はチャットに出力しない。
- Step 3: app-portalを再デプロイ
dokploy compose deploy --composeId "QKWBZ3V_rBJVkuST5hvWt" --title "allowlist管理画面を追加" --json
Run: SSH経由でコンテナ起動確認(ssh -i Keys/LightsailDefaultKey-ap-northeast-1.pem ubuntu@dokploy45.next-hd.net "sudo docker ps --filter name=app-portal")
注意: 過去のセッションでDokployのDeployがconfig-hash差分検知の不具合でコンテナ再作成をスキップすることがあった。コンテナのStartedAtが更新されていない場合は、SSH経由でdocker compose -p app-portal-s0gea0 -f /etc/dokploy/compose/app-portal-s0gea0/code/apps/app-portal/docker-compose.yml up -d --force-recreateを実行する。
- Step 4: /admin/loginへの疎通確認
Run: curl -I https://portal.apps.next-hd.net/admin/login
Expected: 200(oauth2-proxy認証を経由せず直接200が返ること — Traefikルーティング分離の検証)
- Step 5: 手動E2E確認
- ブラウザで
https://portal.apps.next-hd.net/admin/loginにアクセスし、ログインフォームが表示されること(Keycloakへのリダイレクトが発生しないこと)を確認 - 誤ったMaster Keyでログインを試み、エラーメッセージが表示されることを確認
- 正しいMaster Keyでログインし、allowlist一覧ページが表示されることを確認
- 新しいメールアドレスを追加し、一覧に反映されることを確認
dokploy compose deployまたはdocker compose ... up -d --force-recreateでコンテナを再作成し、追加したメールアドレスが消えていない(ボリューム永続化)ことを確認- 追加したメールアドレスで実際にKeycloak/LINE WORKS SSOログインを行い、
https://portal.apps.next-hd.net/のダッシュボードにアクセスできることを確認 - そのメールアドレスを管理画面から削除し、再度ダッシュボードへアクセスすると拒否される(403、無限リダイレクトにならない)ことを確認
https://portal.apps.next-hd.net/への未ログインアクセスは引き続きKeycloakログイン画面へ誘導されることを確認(Traefikルーティング分離により/adminだけがバイパスされ、/は従来通り認証必須であること)
- Step 6: HANDOFFドキュメントは作らず、完了確認のみ
このタスクはインフラ操作のみのためcommitなし。E2E確認結果を確認者へ報告する。
Self-Review メモ
- 設計書(
docs/superpowers/specs/2026-07-26-allowlist-admin-design.md)の全セクション(アーキテクチャ、Traefikルーティング分離、Master Keyログイン・セッション管理、allowlist管理UI・データ操作、認証・権限、エラーハンドリング、テスト方針)はTask1〜7でカバーしている /admin系ルートをcreateAllowlistMiddlewareのapp.useより前に配置する点をTask5に明記し、Keycloak/allowlist認証のバイパスが実装レベルで確実になるようにした- セッションcookieの署名鍵に
PORTAL_MASTER_KEYを流用し、追加のシークレット管理を発生させない設計を徹底(Global Constraints通り) - 既存の
apps/app-portal/test/allowlist.test.jsはTask2で全面的に書き換わる(CSV文字列ベース→ファイルパスベース)ため、旧テストとの後方互換は意図的に断ち切っている