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

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

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_KEYAUTH_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(): stringensureFile(filePath: string, initialEmailsCsv: string|undefined): voidreadEmails(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: stringverifyMasterKey(input: string, masterKey: string): booleancreateSessionToken(secret: string, now?: number): stringverifySessionToken(token: string, secret: string, now?: number): booleanparseCookies(cookieHeader: string|undefined): Record<string,string>buildSessionCookieHeader(token: string): stringbuildLogoutCookieHeader(): 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): stringrenderAdminPage(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('&lt;script&gt;'));
});

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

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(.envPORTAL_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)、.envdata/ディレクトリを削除

  • 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確認
  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系ルートをcreateAllowlistMiddlewareapp.useより前に配置する点をTask5に明記し、Keycloak/allowlist認証のバイパスが実装レベルで確実になるようにした
  • セッションcookieの署名鍵にPORTAL_MASTER_KEYを流用し、追加のシークレット管理を発生させない設計を徹底(Global Constraints通り)
  • 既存のapps/app-portal/test/allowlist.test.jsはTask2で全面的に書き換わる(CSV文字列ベース→ファイルパスベース)ため、旧テストとの後方互換は意図的に断ち切っている