# アプリポータル 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`: ```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`: ```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** ```bash 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`を以下で置き換える: ```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`を以下で置き換える: ```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** ```bash 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`、`buildSessionCookieHeader(token: string): string`、`buildLogoutCookieHeader(): string`(Task 5のindex.jsが利用) - [ ] **Step 1: 失敗するテストを書く** `apps/app-portal/test/adminAuth.test.js`: ```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`: ```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** ```bash 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`: ```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(''); assert.ok(!html.includes('')); assert.ok(html.includes('<script>')); }); test('renderAdminPage lists emails with a remove form for each, escaping HTML', () => { const html = renderAdminPage(['a@example.com', '@example.com']); assert.ok(html.includes('a@example.com')); assert.ok(html.includes('value="a@example.com"')); assert.ok(!html.includes('@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`: ```js function escapeHtml(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function renderLoginPage(errorMessage) { const errorHtml = errorMessage ? `

${escapeHtml(errorMessage)}

` : ''; return ` App Portal Admin Login

Admin Login

${errorHtml}
`; } function renderAdminPage(emails, errorMessage) { const errorHtml = errorMessage ? `

${escapeHtml(errorMessage)}

` : ''; const rows = emails .map( (email) => `
  • ${escapeHtml(email)}
  • ` ) .join('\n'); return ` Allowlist Admin

    Allowlist Admin

    ${errorHtml}
    `; } module.exports = { renderLoginPage, renderAdminPage }; ``` - [ ] **Step 4: テストが通ることを確認** Run: `cd apps/app-portal && npm test` Expected: PASS(全24 tests) - [ ] **Step 5: Commit** ```bash 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を追加): ```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 { 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** ```bash 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`を以下で置き換える: ```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`を以下で置き換える: ```yaml 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`を以下で置き換える: ```yaml 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** ```bash 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を再デプロイ** ```bash 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確認** 1. ブラウザで`https://portal.apps.next-hd.net/admin/login`にアクセスし、ログインフォームが表示されること(Keycloakへのリダイレクトが発生しないこと)を確認 2. 誤ったMaster Keyでログインを試み、エラーメッセージが表示されることを確認 3. 正しいMaster Keyでログインし、allowlist一覧ページが表示されることを確認 4. 新しいメールアドレスを追加し、一覧に反映されることを確認 5. `dokploy compose deploy`または`docker compose ... up -d --force-recreate`でコンテナを再作成し、追加したメールアドレスが消えていない(ボリューム永続化)ことを確認 6. 追加したメールアドレスで実際にKeycloak/LINE WORKS SSOログインを行い、`https://portal.apps.next-hd.net/`のダッシュボードにアクセスできることを確認 7. そのメールアドレスを管理画面から削除し、再度ダッシュボードへアクセスすると拒否される(403、無限リダイレクトにならない)ことを確認 8. `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文字列ベース→ファイルパスベース)ため、旧テストとの後方互換は意図的に断ち切っている