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

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

52 KiB

アプリポータル Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: webapps project配下の全アプリを一覧表示し、起動/停止/再起動/バッチ手動実行/ログ確認を1画面で行える集中管理ポータル(apps/app-portal)を作る。

Architecture: Express製のSSRアプリ。サーバー側でDokploy tRPC APIを自前クライアント経由で呼び出し、Compose一覧・各アプリのenvからポータル用メタ情報(PORTAL_APP_TYPE等)を取得してカード表示する。操作(start/stop/restart)はDokploy tRPC APIへの中継、バッチの「今すぐ実行」は対象アプリのPORTAL_TRIGGER_PATHへの直接POST。認証はTraefik層のoauth2-proxyに加え、アプリ側でもX-Auth-Request-Emailをallowlistと照合する二段構え。

Tech Stack: Node.js 22 / Express 4 / 追加依存なし(Node標準fetchでDokploy tRPC APIを直接叩く)。

Global Constraints

  • Node.js 22(既存apps/_templateのDockerfileを踏襲、FROM node:22-alpine)
  • 追加npm依存を増やさない。HTTP呼び出しはNode標準のfetchを使い、axios等は入れない
  • Dokploy APIキー・PORTAL_SECRETはDokploy Environment画面でのみ設定する。リポジトリ・.env・ログに一切書かない/出力しない
  • compose.oneのレスポンスにはgitea.clientSecret/accessToken/refreshToken等の機密情報が含まれる(2026-07-25実地検証で確認済み事故の教訓)。Dokployクライアントモジュールはこれらのフィールドを絶対に呼び出し元へ返さない。呼び出し元が受け取れるのはenv文字列とappNameのみ
  • 公開ドメインはapps.next-hd.netサブゾーン命名規則を踏襲(例: portal.apps.next-hd.net)
  • ポータル自体はTraefik層でoauth2-proxy認証必須(oauth-errors@file,oauth-auth@file)。加えてアプリ層でもPORTAL_ALLOWED_EMAILSによるallowlist照合を行う(多層防御)
  • PORTAL_APP_TYPEwebまたはbatchのみ許可。未設定のComposeはポータル管理対象外として自動的に一覧から除外する(既存のauthgw-poc/auth-redirect/gitea等、まだこの規約に対応していないアプリは表示されなくてよい)
  • PORTAL_APP_URLは全アプリ共通で必須とする(設計書docs/superpowers/specs/2026-07-25-app-portal-design.mdからの変更点)。web型では「開く」リンク先、batch型ではdokploy-network内で到達可能な内部ベースURL(例: http://portal-sample-batch:3000)として使う。理由: batch型のトリガー先ホスト名をポータル側で他に導出する手段がないため

ファイル構成

apps/app-portal/
  package.json
  Dockerfile
  .dockerignore
  .env.example
  docker-compose.yml
  docker-compose.local.yml
  src/
    index.js          — Expressルーティング
    dokployClient.js   — Dokploy tRPC APIクライアント(GET/POSTラップ含む)
    portalMeta.js       — env文字列パース・PORTAL_*メタ情報抽出
    allowlist.js         — X-Auth-Request-Email allowlistミドルウェア
    dashboard.js          — カード一覧構築・HTML生成
  public/
    portal.js           — ダッシュボードのボタン操作(fetch呼び出し)
  test/
    dokployClient.test.js
    portalMeta.test.js
    allowlist.test.js
    dashboard.test.js
  README.md

apps/portal-sample-batch/   — ポータルのbatch型E2E検証専用の軽量アプリ
  (apps/_templateと同構成 + PORTAL_TRIGGER_PATHエンドポイント)

apps/_template/
  README.md      — batch型セクション追記
  .env.example    — PORTAL_*コメント追記

README.md                                     — 「アプリポータル」章追加
.claude/skills/dokploy-webapp/SKILL.md         — PORTAL_*規約の追記

Task 1: Dokployクライアント基盤(tRPC GET/POSTラッパー)

Files:

  • Create: apps/app-portal/package.json
  • Create: apps/app-portal/src/dokployClient.js
  • Test: apps/app-portal/test/dokployClient.test.js

Interfaces:

  • Produces: trpcGet(endpoint: string, params: object): Promise<any>trpcPost(endpoint: string, data: object): Promise<any> (dokployClient.js内部関数、後続タスクの全Dokploy呼び出しが利用)

  • Consumes: 環境変数 DOKPLOY_URLDOKPLOY_API_KEY(呼び出し時に都度process.envから読む。モジュールロード時にキャッシュしない — テストで動的に差し替えるため)

  • Step 1: package.jsonを作成

{
  "name": "app-portal",
  "version": "0.1.0",
  "private": true,
  "type": "commonjs",
  "scripts": {
    "dev": "node --watch src/index.js",
    "start": "node src/index.js",
    "test": "node --test"
  },
  "dependencies": {
    "express": "^4.21.0"
  }
}
  • Step 2: 失敗するテストを書く

apps/app-portal/test/dokployClient.test.js:

const { test } = require('node:test');
const assert = require('node:assert');

function withMockFetch(mockFetch, fn) {
  const original = global.fetch;
  global.fetch = mockFetch;
  return fn().finally(() => {
    global.fetch = original;
  });
}

test('trpcGet wraps params in {json:} and unwraps result.data.json', async () => {
  process.env.DOKPLOY_URL = 'https://dokploy.test';
  process.env.DOKPLOY_API_KEY = 'test-key';
  const { trpcGet } = require('../src/dokployClient');

  await withMockFetch(async (url, options) => {
    const parsed = new URL(url);
    assert.strictEqual(parsed.pathname, '/api/trpc/environment.one');
    const input = JSON.parse(parsed.searchParams.get('input'));
    assert.deepStrictEqual(input, { json: { environmentId: 'env-1' } });
    assert.strictEqual(options.headers['x-api-key'], 'test-key');
    return {
      ok: true,
      json: async () => ({ result: { data: { json: { hello: 'world' } } } }),
    };
  }, async () => {
    const result = await trpcGet('environment.one', { environmentId: 'env-1' });
    assert.deepStrictEqual(result, { hello: 'world' });
  });
});

test('trpcPost wraps data in {json:} in the request body', async () => {
  process.env.DOKPLOY_URL = 'https://dokploy.test';
  process.env.DOKPLOY_API_KEY = 'test-key';
  const { trpcPost } = require('../src/dokployClient');

  await withMockFetch(async (url, options) => {
    const parsed = new URL(url);
    assert.strictEqual(parsed.pathname, '/api/trpc/compose.start');
    assert.deepStrictEqual(JSON.parse(options.body), { json: { composeId: 'c1' } });
    assert.strictEqual(options.method, 'POST');
    return {
      ok: true,
      json: async () => ({ result: { data: { json: { success: true } } } }),
    };
  }, async () => {
    const result = await trpcPost('compose.start', { composeId: 'c1' });
    assert.deepStrictEqual(result, { success: true });
  });
});

test('trpcGet throws when the response is not ok', async () => {
  process.env.DOKPLOY_URL = 'https://dokploy.test';
  process.env.DOKPLOY_API_KEY = 'test-key';
  const { trpcGet } = require('../src/dokployClient');

  await withMockFetch(async () => ({ ok: false, status: 400 }), async () => {
    await assert.rejects(() => trpcGet('compose.one', { composeId: 'c1' }), /400/);
  });
});
  • Step 2b: テストが失敗することを確認

Run: cd apps/app-portal && npm install && npm test Expected: FAIL — Cannot find module '../src/dokployClient'

  • Step 3: 最小実装を書く

apps/app-portal/src/dokployClient.js:

function getConfig() {
  const url = process.env.DOKPLOY_URL;
  const apiKey = process.env.DOKPLOY_API_KEY;
  if (!url || !apiKey) {
    throw new Error('DOKPLOY_URL / DOKPLOY_API_KEY が設定されていません');
  }
  return { url, apiKey };
}

async function trpcGet(endpoint, params) {
  const { url, apiKey } = getConfig();
  const input = encodeURIComponent(JSON.stringify({ json: params }));
  const res = await fetch(`${url}/api/trpc/${endpoint}?input=${input}`, {
    headers: { 'x-api-key': apiKey },
  });
  if (!res.ok) {
    throw new Error(`Dokploy API error: ${res.status} ${endpoint}`);
  }
  const body = await res.json();
  return body.result.data.json;
}

async function trpcPost(endpoint, data) {
  const { url, apiKey } = getConfig();
  const res = await fetch(`${url}/api/trpc/${endpoint}`, {
    method: 'POST',
    headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
    body: JSON.stringify({ json: data }),
  });
  if (!res.ok) {
    throw new Error(`Dokploy API error: ${res.status} ${endpoint}`);
  }
  const body = await res.json();
  return body.result?.data?.json;
}

module.exports = { trpcGet, trpcPost };
  • Step 4: テストが通ることを確認

Run: cd apps/app-portal && npm test Expected: PASS(3 tests)

  • Step 5: Commit
git add apps/app-portal/package.json apps/app-portal/src/dokployClient.js apps/app-portal/test/dokployClient.test.js
git commit -m "feat(app-portal): Dokploy tRPC APIクライアント基盤を追加"

Task 2: Compose一覧取得・env取得(機密フィールド除外)

Files:

  • Modify: apps/app-portal/src/dokployClient.js
  • Modify: apps/app-portal/test/dokployClient.test.js

Interfaces:

  • Consumes: trpcGet(Task 1で定義)

  • Produces: listComposes(environmentId: string): Promise<Array<{composeId, name, composeStatus}>>getComposeEnv(composeId: string): Promise<{env: string|null, appName: string}>(Task 7/8が利用)

  • Step 1: 失敗するテストを追記

apps/app-portal/test/dokployClient.test.jsに追記:

test('listComposes returns the compose array from environment.one', async () => {
  process.env.DOKPLOY_URL = 'https://dokploy.test';
  process.env.DOKPLOY_API_KEY = 'test-key';
  const { listComposes } = require('../src/dokployClient');

  await withMockFetch(async () => ({
    ok: true,
    json: async () => ({
      result: { data: { json: { compose: [{ composeId: 'c1', name: 'auth-redirect', composeStatus: 'done' }] } } },
    }),
  }), async () => {
    const composes = await listComposes('env-1');
    assert.deepStrictEqual(composes, [{ composeId: 'c1', name: 'auth-redirect', composeStatus: 'done' }]);
  });
});

test('getComposeEnv only returns env and appName, never leaks gitea/github credentials', async () => {
  process.env.DOKPLOY_URL = 'https://dokploy.test';
  process.env.DOKPLOY_API_KEY = 'test-key';
  const { getComposeEnv } = require('../src/dokployClient');

  await withMockFetch(async () => ({
    ok: true,
    json: async () => ({
      result: {
        data: {
          json: {
            composeId: 'c1',
            appName: 'auth-redirect-uzt5hx',
            env: 'PORTAL_APP_TYPE=web\nPORTAL_APP_URL=https://x.apps.next-hd.net',
            gitea: { clientSecret: 'SHOULD_NOT_LEAK', accessToken: 'SHOULD_NOT_LEAK_EITHER' },
            deployments: [{ title: 'irrelevant' }],
          },
        },
      },
    }),
  }), async () => {
    const result = await getComposeEnv('c1');
    assert.deepStrictEqual(Object.keys(result).sort(), ['appName', 'env']);
    assert.strictEqual(result.appName, 'auth-redirect-uzt5hx');
    assert.ok(result.env.includes('PORTAL_APP_TYPE=web'));
    assert.ok(!JSON.stringify(result).includes('SHOULD_NOT_LEAK'));
  });
});
  • Step 2: テストが失敗することを確認

Run: cd apps/app-portal && npm test Expected: FAIL — listComposes is not a function

  • Step 3: 実装を追記

apps/app-portal/src/dokployClient.jsmodule.exportsの直前に追記:

async function listComposes(environmentId) {
  const env = await trpcGet('environment.one', { environmentId });
  return env.compose;
}

async function getComposeEnv(composeId) {
  const full = await trpcGet('compose.one', { composeId });
  return { env: full.env ?? null, appName: full.appName };
}

module.exportsを更新:

module.exports = { trpcGet, trpcPost, listComposes, getComposeEnv };
  • Step 4: テストが通ることを確認

Run: cd apps/app-portal && npm test Expected: PASS(5 tests)

  • Step 5: Commit
git add apps/app-portal/src/dokployClient.js apps/app-portal/test/dokployClient.test.js
git commit -m "feat(app-portal): Compose一覧・env取得を追加(機密フィールド除外を担保するテスト付き)"

Task 3: コンテナ取得・ログ取得・start/stop/restart

Files:

  • Modify: apps/app-portal/src/dokployClient.js
  • Modify: apps/app-portal/test/dokployClient.test.js

Interfaces:

  • Consumes: trpcGettrpcPost(Task 1)

  • Produces: getContainers(appName: string): Promise<Array<{containerId, state}>>readLogs(composeId, containerId, tail): Promise<string>startCompose(composeId): Promise<any>stopCompose(composeId): Promise<any>restartContainer(containerId): Promise<any>(Task 8が利用)

  • Step 1: 失敗するテストを追記

apps/app-portal/test/dokployClient.test.jsに追記:

test('getContainers calls docker.getContainersByAppNameMatch with appType docker-compose', async () => {
  process.env.DOKPLOY_URL = 'https://dokploy.test';
  process.env.DOKPLOY_API_KEY = 'test-key';
  const { getContainers } = require('../src/dokployClient');

  await withMockFetch(async (url) => {
    const parsed = new URL(url);
    assert.strictEqual(parsed.pathname, '/api/trpc/docker.getContainersByAppNameMatch');
    const input = JSON.parse(parsed.searchParams.get('input'));
    assert.deepStrictEqual(input, { json: { appName: 'auth-redirect-uzt5hx', appType: 'docker-compose' } });
    return {
      ok: true,
      json: async () => ({ result: { data: { json: [{ containerId: 'abc123', state: 'running' }] } } }),
    };
  }, async () => {
    const containers = await getContainers('auth-redirect-uzt5hx');
    assert.deepStrictEqual(containers, [{ containerId: 'abc123', state: 'running' }]);
  });
});

test('readLogs passes composeId, containerId and tail through', async () => {
  process.env.DOKPLOY_URL = 'https://dokploy.test';
  process.env.DOKPLOY_API_KEY = 'test-key';
  const { readLogs } = require('../src/dokployClient');

  await withMockFetch(async (url) => {
    const parsed = new URL(url);
    const input = JSON.parse(parsed.searchParams.get('input'));
    assert.deepStrictEqual(input, { json: { composeId: 'c1', containerId: 'abc123', tail: 200 } });
    return { ok: true, json: async () => ({ result: { data: { json: 'log line\n' } } }) };
  }, async () => {
    const logs = await readLogs('c1', 'abc123', 200);
    assert.strictEqual(logs, 'log line\n');
  });
});

test('startCompose/stopCompose/restartContainer POST to the right endpoints', async () => {
  process.env.DOKPLOY_URL = 'https://dokploy.test';
  process.env.DOKPLOY_API_KEY = 'test-key';
  const { startCompose, stopCompose, restartContainer } = require('../src/dokployClient');
  const calledEndpoints = [];

  await withMockFetch(async (url) => {
    calledEndpoints.push(new URL(url).pathname);
    return { ok: true, json: async () => ({ result: { data: { json: { success: true } } } }) };
  }, async () => {
    await startCompose('c1');
    await stopCompose('c1');
    await restartContainer('abc123');
  });

  assert.deepStrictEqual(calledEndpoints, [
    '/api/trpc/compose.start',
    '/api/trpc/compose.stop',
    '/api/trpc/docker.restartContainer',
  ]);
});
  • Step 2: テストが失敗することを確認

Run: cd apps/app-portal && npm test Expected: FAIL — getContainers is not a function

  • Step 3: 実装を追記

apps/app-portal/src/dokployClient.jsmodule.exportsの直前に追記:

async function getContainers(appName) {
  return trpcGet('docker.getContainersByAppNameMatch', { appName, appType: 'docker-compose' });
}

async function readLogs(composeId, containerId, tail) {
  return trpcGet('compose.readLogs', { composeId, containerId, tail });
}

async function startCompose(composeId) {
  return trpcPost('compose.start', { composeId });
}

async function stopCompose(composeId) {
  return trpcPost('compose.stop', { composeId });
}

async function restartContainer(containerId) {
  return trpcPost('docker.restartContainer', { containerId });
}

module.exportsを更新:

module.exports = {
  trpcGet,
  trpcPost,
  listComposes,
  getComposeEnv,
  getContainers,
  readLogs,
  startCompose,
  stopCompose,
  restartContainer,
};
  • Step 4: テストが通ることを確認

Run: cd apps/app-portal && npm test Expected: PASS(8 tests)

  • Step 5: Commit
git add apps/app-portal/src/dokployClient.js apps/app-portal/test/dokployClient.test.js
git commit -m "feat(app-portal): コンテナ取得・ログ取得・start/stop/restartを追加"

Task 4: .envメタ情報パース(portalMeta.js)

Files:

  • Create: apps/app-portal/src/portalMeta.js
  • Test: apps/app-portal/test/portalMeta.test.js

Interfaces:

  • Produces: parseEnvString(envString: string|null): Record<string,string>extractPortalMeta(envString: string|null, fallbackLabel: string): {appType: 'web'|'batch', label: string, url: string|null, triggerPath: string|null} | null(Task 7/8が利用)

  • Step 1: 失敗するテストを書く

apps/app-portal/test/portalMeta.test.js:

const { test } = require('node:test');
const assert = require('node:assert');
const { parseEnvString, extractPortalMeta } = require('../src/portalMeta');

test('parseEnvString parses KEY=VALUE lines and skips comments/blank lines', () => {
  const result = parseEnvString('PORT=3000\n# comment\n\nNAME="quoted value"\n');
  assert.deepStrictEqual(result, { PORT: '3000', NAME: 'quoted value' });
});

test('parseEnvString returns {} for null or empty input', () => {
  assert.deepStrictEqual(parseEnvString(null), {});
  assert.deepStrictEqual(parseEnvString(''), {});
});

test('extractPortalMeta returns null when PORTAL_APP_TYPE is missing or invalid', () => {
  assert.strictEqual(extractPortalMeta('PORT=3000', 'fallback'), null);
  assert.strictEqual(extractPortalMeta('PORTAL_APP_TYPE=invalid', 'fallback'), null);
  assert.strictEqual(extractPortalMeta(null, 'fallback'), null);
});

test('extractPortalMeta parses a web-type app, falling back to label when PORTAL_APP_LABEL is absent', () => {
  const meta = extractPortalMeta('PORTAL_APP_TYPE=web\nPORTAL_APP_URL=https://x.apps.next-hd.net', 'x');
  assert.deepStrictEqual(meta, { appType: 'web', label: 'x', url: 'https://x.apps.next-hd.net', triggerPath: null });
});

test('extractPortalMeta parses a batch-type app with PORTAL_TRIGGER_PATH', () => {
  const meta = extractPortalMeta(
    'PORTAL_APP_TYPE=batch\nPORTAL_APP_LABEL=同期バッチ\nPORTAL_APP_URL=http://sample:3000\nPORTAL_TRIGGER_PATH=/trigger',
    'fallback'
  );
  assert.deepStrictEqual(meta, { appType: 'batch', label: '同期バッチ', url: 'http://sample:3000', triggerPath: '/trigger' });
});
  • Step 2: テストが失敗することを確認

Run: cd apps/app-portal && npm test Expected: FAIL — Cannot find module '../src/portalMeta'

  • Step 3: 実装を書く

apps/app-portal/src/portalMeta.js:

function parseEnvString(envString) {
  const result = {};
  if (!envString) return result;
  for (const line of envString.split('\n')) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith('#')) continue;
    const eqIndex = trimmed.indexOf('=');
    if (eqIndex === -1) continue;
    const key = trimmed.slice(0, eqIndex).trim();
    const value = trimmed
      .slice(eqIndex + 1)
      .trim()
      .replace(/^["']|["']$/g, '');
    result[key] = value;
  }
  return result;
}

function extractPortalMeta(envString, fallbackLabel) {
  const vars = parseEnvString(envString);
  const appType = vars.PORTAL_APP_TYPE;
  if (appType !== 'web' && appType !== 'batch') {
    return null;
  }
  return {
    appType,
    label: vars.PORTAL_APP_LABEL || fallbackLabel,
    url: vars.PORTAL_APP_URL || null,
    triggerPath: vars.PORTAL_TRIGGER_PATH || null,
  };
}

module.exports = { parseEnvString, extractPortalMeta };
  • Step 4: テストが通ることを確認

Run: cd apps/app-portal && npm test Expected: PASS(全13 tests)

  • Step 5: Commit
git add apps/app-portal/src/portalMeta.js apps/app-portal/test/portalMeta.test.js
git commit -m "feat(app-portal): PORTAL_*メタ情報のenv文字列パースを追加"

Task 5: allowlist認証ミドルウェア

Files:

  • Create: apps/app-portal/src/allowlist.js
  • Test: apps/app-portal/test/allowlist.test.js

Interfaces:

  • Produces: createAllowlistMiddleware(allowedEmailsCsv: string): (req, res, next) => void(Task 8が利用)

  • Step 1: 失敗するテストを書く

apps/app-portal/test/allowlist.test.js:

const { test } = require('node:test');
const assert = require('node:assert');
const { createAllowlistMiddleware } = require('../src/allowlist');

function mockRes() {
  return {
    statusCode: null,
    body: null,
    status(code) {
      this.statusCode = code;
      return this;
    },
    send(body) {
      this.body = body;
      return this;
    },
  };
}

test('allows a request whose email is in the allowlist (case-insensitive)', () => {
  const middleware = createAllowlistMiddleware('a@example.com, B@Example.com');
  const req = { headers: { 'x-auth-request-email': 'b@example.com' } };
  const res = mockRes();
  let nextCalled = false;
  middleware(req, res, () => { nextCalled = true; });
  assert.strictEqual(nextCalled, true);
  assert.strictEqual(res.statusCode, null);
});

test('rejects with 403 when email is missing', () => {
  const middleware = createAllowlistMiddleware('a@example.com');
  const req = { headers: {} };
  const res = mockRes();
  middleware(req, res, () => { throw new Error('next should not be called'); });
  assert.strictEqual(res.statusCode, 403);
});

test('rejects with 403 when email is not in the allowlist', () => {
  const middleware = createAllowlistMiddleware('a@example.com');
  const req = { headers: { 'x-auth-request-email': 'stranger@example.com' } };
  const res = mockRes();
  middleware(req, res, () => { throw new Error('next should not be called'); });
  assert.strictEqual(res.statusCode, 403);
});
  • Step 2: テストが失敗することを確認

Run: cd apps/app-portal && npm test Expected: FAIL — Cannot find module '../src/allowlist'

  • Step 3: 実装を書く

apps/app-portal/src/allowlist.js:

function createAllowlistMiddleware(allowedEmailsCsv) {
  const allowed = new Set(
    (allowedEmailsCsv || '')
      .split(',')
      .map((e) => e.trim().toLowerCase())
      .filter(Boolean)
  );
  return function allowlistMiddleware(req, res, next) {
    const email = (req.headers['x-auth-request-email'] || '').toLowerCase();
    if (!email || !allowed.has(email)) {
      res.status(403).send('Forbidden');
      return;
    }
    next();
  };
}

module.exports = { createAllowlistMiddleware };
  • Step 4: テストが通ることを確認

Run: cd apps/app-portal && npm test Expected: PASS(全16 tests)

  • Step 5: Commit
git add apps/app-portal/src/allowlist.js apps/app-portal/test/allowlist.test.js
git commit -m "feat(app-portal): X-Auth-Request-Email allowlistミドルウェアを追加"

Task 6: app-portalアプリの骨格(Docker/Compose/env)

Files:

  • Create: apps/app-portal/Dockerfile
  • Create: apps/app-portal/.dockerignore
  • Create: apps/app-portal/.env.example
  • Create: apps/app-portal/docker-compose.yml
  • Create: apps/app-portal/docker-compose.local.yml

Interfaces:

  • Consumes: なし(既存apps/_templateの同名ファイルをベースにする)

  • Produces: なし(次タスクのローカル起動・Dokployデプロイの前提)

  • Step 1: Dockerfileを作成(apps/_template/Dockerfileと同一)

apps/app-portal/Dockerfile:

FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev

FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
CMD ["node", "src/index.js"]
  • Step 2: .dockerignoreを作成(apps/_template/.dockerignoreと同一内容を想定)

apps/app-portal/.dockerignore:

node_modules
npm-debug.log
.env
.git
  • Step 3: .env.exampleを作成

apps/app-portal/.env.example:

PORT=3000
NODE_ENV=development
DOKPLOY_URL=https://dokploy45.next-hd.net
DOKPLOY_API_KEY=
DOKPLOY_ENVIRONMENT_ID=Cm0HjMIFyl11UdIcIGRy8
PORTAL_ALLOWED_EMAILS=kenichiro.nogi@next-hd.co.jp
PORTAL_SECRET=
  • Step 4: docker-compose.local.ymlを作成(ローカル検証専用、apps/_templateと同構成)

apps/app-portal/docker-compose.local.yml:

services:
  app-portal:
    build: .
    ports:
      - "3000:3000"
    env_file:
      - .env
  • Step 5: docker-compose.ymlを作成(Dokploy用、認証ゲートウェイ必須)

apps/app-portal/docker-compose.yml:

services:
  app-portal:
    build: .
    expose:
      - 3000
    env_file:
      - .env
    networks:
      - dokploy-network
    labels:
      - traefik.enable=true
      - traefik.http.routers.app-portal-web.rule=Host(`portal.apps.next-hd.net`)
      - traefik.http.routers.app-portal-web.entrypoints=web
      - traefik.http.routers.app-portal-web.middlewares=redirect-to-https
      - traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https
      - traefik.http.routers.app-portal-web.service=app-portal-svc
      - traefik.http.routers.app-portal-websecure.rule=Host(`portal.apps.next-hd.net`)
      - traefik.http.routers.app-portal-websecure.entrypoints=websecure
      - traefik.http.routers.app-portal-websecure.tls.certresolver=letsencrypt
      - traefik.http.routers.app-portal-websecure.middlewares=oauth-errors@file,oauth-auth@file
      - traefik.http.routers.app-portal-websecure.service=app-portal-svc
      - traefik.http.services.app-portal-svc.loadbalancer.server.port=3000
    restart: unless-stopped

networks:
  dokploy-network:
    external: true
  • Step 6: Commit
git add apps/app-portal/Dockerfile apps/app-portal/.dockerignore apps/app-portal/.env.example apps/app-portal/docker-compose.yml apps/app-portal/docker-compose.local.yml
git commit -m "feat(app-portal): Docker/Compose/env雛形を追加"

Task 7: ダッシュボード構築ロジック(dashboard.js)

Files:

  • Create: apps/app-portal/src/dashboard.js
  • Test: apps/app-portal/test/dashboard.test.js

Interfaces:

  • Consumes: listComposes, getComposeEnv, getContainers(Task 1-3)、extractPortalMeta(Task 4)
  • Produces: buildCards(environmentId: string): Promise<Array<CardData|ErrorCardData>>renderCard(card): stringrenderDashboard(environmentId: string): Promise<string>(Task 8のルーティングが利用)

CardDataの形: {composeId, appName, composeStatus, containerState, meta: {appType, label, url, triggerPath}} ErrorCardDataの形: {error: true, composeId, name}

  • Step 1: 失敗するテストを書く

apps/app-portal/test/dashboard.test.js:

const { test } = require('node:test');
const assert = require('node:assert');
const Module = require('module');

function withMocked(modulePath, mockExports, fn) {
  const resolved = require.resolve(modulePath);
  const original = require.cache[resolved];
  require.cache[resolved] = new Module(resolved);
  require.cache[resolved].exports = mockExports;
  return Promise.resolve(fn()).finally(() => {
    if (original) {
      require.cache[resolved] = original;
    } else {
      delete require.cache[resolved];
    }
  });
}

test('buildCards skips composes without a valid PORTAL_APP_TYPE and reports fetch errors separately', async () => {
  delete require.cache[require.resolve('../src/dashboard')];
  await withMocked('../src/dokployClient', {
    listComposes: async () => [
      { composeId: 'c1', name: 'auth-redirect', composeStatus: 'done' },
      { composeId: 'c2', name: 'gitea', composeStatus: 'done' },
      { composeId: 'c3', name: 'broken-app', composeStatus: 'error' },
    ],
    getComposeEnv: async (composeId) => {
      if (composeId === 'c1') return { env: 'PORTAL_APP_TYPE=web\nPORTAL_APP_URL=https://x', appName: 'auth-redirect-uzt5hx' };
      if (composeId === 'c2') return { env: null, appName: 'gitea-xyz' };
      throw new Error('boom');
    },
    getContainers: async () => [{ containerId: 'abc', state: 'running' }],
  }, async () => {
    const { buildCards } = require('../src/dashboard');
    const cards = await buildCards('env-1');
    assert.strictEqual(cards.length, 2);
    assert.strictEqual(cards[0].composeId, 'c1');
    assert.strictEqual(cards[0].meta.appType, 'web');
    assert.strictEqual(cards[0].containerState, 'running');
    assert.deepStrictEqual(cards[1], { error: true, composeId: 'c3', name: 'broken-app' });
  });
});

test('renderCard escapes HTML in labels and shows a "trigger" button for batch apps', () => {
  delete require.cache[require.resolve('../src/dashboard')];
  const { renderCard } = require('../src/dashboard');
  const html = renderCard({
    composeId: 'c1',
    appName: 'sample',
    composeStatus: 'done',
    containerState: 'running',
    meta: { appType: 'batch', label: '<script>bad</script>', url: 'http://sample:3000', triggerPath: '/trigger' },
  });
  assert.ok(!html.includes('<script>bad</script>'));
  assert.ok(html.includes('&lt;script&gt;'));
  assert.ok(html.includes('data-action="trigger"'));
  assert.ok(!html.includes('data-action="start"'));
});

test('renderCard shows an "状態取得失敗" message for error cards', () => {
  const { renderCard } = require('../src/dashboard');
  const html = renderCard({ error: true, composeId: 'c3', name: 'broken-app' });
  assert.ok(html.includes('状態取得失敗'));
  assert.ok(html.includes('broken-app'));
});
  • Step 2: テストが失敗することを確認

Run: cd apps/app-portal && npm test Expected: FAIL — Cannot find module '../src/dashboard'

  • Step 3: 実装を書く

apps/app-portal/src/dashboard.js:

const { listComposes, getComposeEnv, getContainers } = require('./dokployClient');
const { extractPortalMeta } = require('./portalMeta');

function escapeHtml(value) {
  return String(value)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

async function buildCards(environmentId) {
  const composes = await listComposes(environmentId);
  const settled = await Promise.allSettled(
    composes.map(async (c) => {
      const { env, appName } = await getComposeEnv(c.composeId);
      const meta = extractPortalMeta(env, c.name);
      if (!meta) return null;
      let containerState = 'unknown';
      try {
        const containers = await getContainers(appName);
        containerState = containers[0]?.state || 'stopped';
      } catch {
        containerState = 'unknown';
      }
      return { composeId: c.composeId, appName, composeStatus: c.composeStatus, containerState, meta };
    })
  );
  const cards = [];
  settled.forEach((result, i) => {
    if (result.status === 'fulfilled') {
      if (result.value) cards.push(result.value);
    } else {
      cards.push({ error: true, composeId: composes[i].composeId, name: composes[i].name });
    }
  });
  return cards;
}

function renderCard(card) {
  if (card.error) {
    return `<div class="card card-error" data-compose-id="${escapeHtml(card.composeId)}">
      <h3>${escapeHtml(card.name || card.composeId)}</h3>
      <p>状態取得失敗</p>
    </div>`;
  }
  const { composeId, composeStatus, containerState, meta } = card;
  const openLink =
    meta.appType === 'web' && meta.url
      ? `<a href="${escapeHtml(meta.url)}" target="_blank" rel="noopener">開く</a>`
      : '';
  const actionButtons =
    meta.appType === 'web'
      ? `<button data-action="start" data-compose-id="${escapeHtml(composeId)}">Start</button>
         <button data-action="stop" data-compose-id="${escapeHtml(composeId)}">Stop</button>
         <button data-action="restart" data-compose-id="${escapeHtml(composeId)}">Restart</button>`
      : `<button data-action="trigger" data-compose-id="${escapeHtml(composeId)}">今すぐ実行</button>`;
  return `<div class="card" data-compose-id="${escapeHtml(composeId)}">
    <h3>${escapeHtml(meta.label)}</h3>
    <p>compose: ${escapeHtml(composeStatus)} / container: ${escapeHtml(containerState)}</p>
    ${openLink}
    ${actionButtons}
    <button data-action="logs" data-compose-id="${escapeHtml(composeId)}">ログ表示</button>
  </div>`;
}

async function renderDashboard(environmentId) {
  const cards = await buildCards(environmentId);
  const cardsHtml = cards.map(renderCard).join('\n');
  return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>App Portal</title>
</head>
<body>
<h1>App Portal</h1>
<div id="cards">${cardsHtml}</div>
<script src="/portal.js"></script>
</body>
</html>`;
}

module.exports = { buildCards, renderCard, renderDashboard };
  • Step 4: テストが通ることを確認

Run: cd apps/app-portal && npm test Expected: PASS(全19 tests)

  • Step 5: Commit
git add apps/app-portal/src/dashboard.js apps/app-portal/test/dashboard.test.js
git commit -m "feat(app-portal): ダッシュボードのカード構築・HTML生成ロジックを追加"

Task 8: ルーティング統合(index.js)とフロントJS

Files:

  • Create: apps/app-portal/src/index.js
  • Create: apps/app-portal/public/portal.js

Interfaces:

  • Consumes: createAllowlistMiddleware(Task 5)、renderDashboard(Task 7)、getComposeEnv, getContainers, readLogs, startCompose, stopCompose, restartContainer(Task 1-3)

  • Produces: HTTPエンドポイント一式(以降のタスクではE2E手動確認のみ、ユニットテストなし — Express統合部分はapps/auth-redirect等の既存アプリでもユニットテストしておらず、本リポジトリの既存パターンを踏襲)

  • Step 1: index.jsを書く

apps/app-portal/src/index.js:

const express = require('express');
const path = require('path');
const { createAllowlistMiddleware } = require('./allowlist');
const { renderDashboard } = require('./dashboard');
const { getComposeEnv, getContainers, readLogs, startCompose, stopCompose, restartContainer } = require('./dokployClient');
const { extractPortalMeta } = require('./portalMeta');

const app = express();
const PORT = process.env.PORT || 3000;
const ENVIRONMENT_ID = process.env.DOKPLOY_ENVIRONMENT_ID;
const PORTAL_SECRET = process.env.PORTAL_SECRET;

app.use(express.static(path.join(__dirname, '..', 'public')));
app.use(express.json());

app.get('/health', (req, res) => {
  res.status(200).json({ status: 'healthy' });
});

app.use(createAllowlistMiddleware(process.env.PORTAL_ALLOWED_EMAILS));

app.get('/', async (req, res) => {
  try {
    const html = await renderDashboard(ENVIRONMENT_ID);
    res.set('Content-Type', 'text/html; charset=utf-8').send(html);
  } catch (err) {
    console.error('dashboard render failed', err.message);
    res.status(500).send('ダッシュボード取得に失敗しました');
  }
});

app.post('/api/compose/:composeId/start', async (req, res) => {
  try {
    await startCompose(req.params.composeId);
    res.sendStatus(202);
  } catch (err) {
    console.error('start failed', err.message);
    res.sendStatus(502);
  }
});

app.post('/api/compose/:composeId/stop', async (req, res) => {
  try {
    await stopCompose(req.params.composeId);
    res.sendStatus(202);
  } catch (err) {
    console.error('stop failed', err.message);
    res.sendStatus(502);
  }
});

app.post('/api/compose/:composeId/restart', async (req, res) => {
  try {
    const { appName } = await getComposeEnv(req.params.composeId);
    const containers = await getContainers(appName);
    const containerId = containers[0]?.containerId;
    if (!containerId) {
      res.sendStatus(404);
      return;
    }
    await restartContainer(containerId);
    res.sendStatus(202);
  } catch (err) {
    console.error('restart failed', err.message);
    res.sendStatus(502);
  }
});

app.get('/api/compose/:composeId/logs', async (req, res) => {
  try {
    const { appName } = await getComposeEnv(req.params.composeId);
    const containers = await getContainers(appName);
    const containerId = containers[0]?.containerId;
    if (!containerId) {
      res.sendStatus(404);
      return;
    }
    const logs = await readLogs(req.params.composeId, containerId, 200);
    res.json({ logs });
  } catch (err) {
    console.error('logs failed', err.message);
    res.sendStatus(502);
  }
});

app.post('/api/compose/:composeId/trigger', async (req, res) => {
  try {
    const { env } = await getComposeEnv(req.params.composeId);
    const meta = extractPortalMeta(env);
    if (!meta || meta.appType !== 'batch' || !meta.url || !meta.triggerPath) {
      res.sendStatus(400);
      return;
    }
    const triggerRes = await fetch(`${meta.url}${meta.triggerPath}`, {
      method: 'POST',
      headers: { 'X-Portal-Secret': PORTAL_SECRET },
    });
    res.sendStatus(triggerRes.status === 202 ? 202 : 502);
  } catch (err) {
    console.error('trigger failed', err.message);
    res.status(502).json({ error: '応答なし' });
  }
});

app.listen(PORT, () => {
  console.log(`app-portal listening on port ${PORT}`);
});
  • Step 2: フロントJSを書く

apps/app-portal/public/portal.js:

document.getElementById('cards').addEventListener('click', async (event) => {
  const button = event.target.closest('button[data-action]');
  if (!button) return;
  const { action, composeId } = button.dataset;

  if (action === 'logs') {
    const res = await fetch(`/api/compose/${composeId}/logs`);
    if (!res.ok) {
      alert(`ログ取得に失敗しました (status: ${res.status})`);
      return;
    }
    const data = await res.json();
    alert(data.logs || 'ログなし');
    return;
  }

  const res = await fetch(`/api/compose/${composeId}/${action}`, { method: 'POST' });
  if (res.ok) {
    alert(`${action} を実行しました`);
  } else {
    alert(`${action} に失敗しました (status: ${res.status})`);
  }
});
  • Step 3: ローカルでDocker起動確認

Run: cd apps/app-portal && cp .env.example .env(DOKPLOY_API_KEYはダミー値でよい、この時点では/healthのみ確認)

Run: docker compose -f docker-compose.local.yml up --build

Expected: エラーなく起動し、curl http://localhost:3000/health{"status":"healthy"}を返す

  • Step 4: Commit
git add apps/app-portal/src/index.js apps/app-portal/public/portal.js
git commit -m "feat(app-portal): ダッシュボード・操作APIのルーティングを追加"

Task 9: batch型E2E検証用の軽量アプリportal-sample-batch

Files:

  • Create: apps/portal-sample-batch/package.json
  • Create: apps/portal-sample-batch/src/triggerAuth.js
  • Test: apps/portal-sample-batch/test/triggerAuth.test.js
  • Create: apps/portal-sample-batch/src/index.js
  • Create: apps/portal-sample-batch/Dockerfile
  • Create: apps/portal-sample-batch/.dockerignore
  • Create: apps/portal-sample-batch/.env.example
  • Create: apps/portal-sample-batch/docker-compose.yml
  • Create: apps/portal-sample-batch/docker-compose.local.yml

Interfaces:

  • Produces: isTriggerAuthorized(headerValue: string|undefined, expectedSecret: string|undefined): boolean(index.jsが利用)、POST /triggerエンドポイント(X-Portal-Secretヘッダー検証、202を返す) — Task 12の手動E2Eでapp-portalから呼ばれる

  • Step 1: package.jsonを作成(apps/_template/package.jsonのname違いのみ)

apps/portal-sample-batch/package.json:

{
  "name": "portal-sample-batch",
  "version": "0.1.0",
  "private": true,
  "type": "commonjs",
  "scripts": {
    "dev": "node --watch src/index.js",
    "start": "node src/index.js",
    "test": "node --test"
  },
  "dependencies": {
    "express": "^4.21.0"
  }
}
  • Step 2: 失敗するテストを書く

apps/portal-sample-batch/test/triggerAuth.test.js:

const { test } = require('node:test');
const assert = require('node:assert');
const { isTriggerAuthorized } = require('../src/triggerAuth');

test('isTriggerAuthorized returns true only when the header matches a non-empty expected secret', () => {
  assert.strictEqual(isTriggerAuthorized('correct', 'correct'), true);
  assert.strictEqual(isTriggerAuthorized('wrong', 'correct'), false);
  assert.strictEqual(isTriggerAuthorized(undefined, 'correct'), false);
  assert.strictEqual(isTriggerAuthorized('anything', undefined), false);
  assert.strictEqual(isTriggerAuthorized('anything', ''), false);
});
  • Step 2b: テストが失敗することを確認

Run: cd apps/portal-sample-batch && npm install && npm test Expected: FAIL — Cannot find module '../src/triggerAuth'

  • Step 3: triggerAuth.jsを実装

apps/portal-sample-batch/src/triggerAuth.js:

function isTriggerAuthorized(headerValue, expectedSecret) {
  return Boolean(expectedSecret) && headerValue === expectedSecret;
}

module.exports = { isTriggerAuthorized };
  • Step 4: テストが通ることを確認

Run: cd apps/portal-sample-batch && npm test Expected: PASS(1 test)

  • Step 5: src/index.jsを実装(triggerAuthを使う)

apps/portal-sample-batch/src/index.js:

const express = require('express');
const { isTriggerAuthorized } = require('./triggerAuth');

const app = express();
const PORT = process.env.PORT || 3000;
const PORTAL_SECRET = process.env.PORTAL_SECRET;

app.get('/', (req, res) => {
  res.json({ app: 'portal-sample-batch', status: 'ok' });
});

app.get('/health', (req, res) => {
  res.status(200).json({ status: 'healthy' });
});

app.post('/trigger', (req, res) => {
  if (!isTriggerAuthorized(req.headers['x-portal-secret'], PORTAL_SECRET)) {
    res.sendStatus(403);
    return;
  }
  console.log('portal-sample-batch: triggered at', new Date().toISOString());
  res.sendStatus(202);
});

app.listen(PORT, () => {
  console.log(`portal-sample-batch listening on port ${PORT}`);
});
  • Step 6: Dockerfile / .dockerignore / .env.example / docker-compose.local.ymlを作成(apps/_templateのものをコピーし、.env.exampleのみ差し替え)

apps/portal-sample-batch/Dockerfile(apps/_template/Dockerfileと同一)、.dockerignore(同一)、docker-compose.local.yml(同一パターン、サービス名をportal-sample-batchに):

services:
  portal-sample-batch:
    build: .
    ports:
      - "3001:3000"
    env_file:
      - .env

apps/portal-sample-batch/.env.example:

PORT=3000
NODE_ENV=development
PORTAL_SECRET=
PORTAL_APP_TYPE=batch
PORTAL_APP_LABEL=ポータル検証用バッチ
PORTAL_APP_URL=http://portal-sample-batch:3000
PORTAL_TRIGGER_PATH=/trigger
  • Step 7: Dokploy用docker-compose.ymlを作成(ドメイン非公開、dokploy-network内部限定)

apps/portal-sample-batch/docker-compose.yml:

services:
  portal-sample-batch:
    build: .
    expose:
      - 3000
    env_file:
      - .env
    networks:
      - dokploy-network
    restart: unless-stopped

networks:
  dokploy-network:
    external: true
  • Step 8: Commit
git add apps/portal-sample-batch/
git commit -m "feat: ポータルbatch型E2E検証用のportal-sample-batchを追加"

Task 10: _templateにbatch型パターンを追記

Files:

  • Modify: apps/_template/README.md
  • Modify: apps/_template/.env.example

Interfaces: なし(ドキュメントのみ)

  • *Step 1: .env.exampleにPORTAL_のコメント例を追記

apps/_template/.env.exampleの末尾に追記:

# アプリポータル(apps/app-portal)に登録する場合のみ設定する。
# PORTAL_APP_TYPE=web              # web(常駐サービス) または batch(手動実行ジョブ)
# PORTAL_APP_LABEL=表示名           # 省略時はDokploy Compose名を使う
# PORTAL_APP_URL=https://x.apps.next-hd.net   # web型は「開く」リンク先、batch型はdokploy-network内の到達先(例: http://<compose内サービス名>:3000)
# PORTAL_TRIGGER_PATH=/trigger      # batch型のみ。手動実行トリガーのパス
# PORTAL_SECRET=                    # batch型のみ。app-portalと共有するシークレット(値はDokploy Environment画面でのみ設定)
  • Step 2: README.mdにbatch型セクションを追記

apps/_template/README.mdの末尾に追記:


## アプリポータル(apps/app-portal)への登録

`webapps` project配下のアプリは、`.env.example`のPORTAL_*変数をDokploy Environment画面で設定することで、`apps/app-portal`のダッシュボードに一覧表示・操作対象として登録できる(任意)。

- 常駐WEBサービス(`PORTAL_APP_TYPE=web`): ダッシュボードからStart/Stop/Restart、「開く」で`PORTAL_APP_URL`へ遷移
- バッチジョブ(`PORTAL_APP_TYPE=batch`): ダッシュボードの「今すぐ実行」から`PORTAL_APP_URL${PORTAL_TRIGGER_PATH}`へPOSTされる。アプリ側は以下のパターンでトリガーエンドポイントを実装する:

```js
app.post(process.env.PORTAL_TRIGGER_PATH, (req, res) => {
  if (req.headers['x-portal-secret'] !== process.env.PORTAL_SECRET) {
    return res.sendStatus(403);
  }
  // 実処理を非同期でキューイングし即座に202を返す
  res.sendStatus(202);
});

PORTAL_SECRETはポータルとアプリ間で共有する値。AUTH_SECRETS.md同様、機密情報として扱いDokploy Environment画面でのみ設定する。実装例はapps/portal-sample-batch/src/index.js参照。


- [ ] **Step 3: Commit**

```bash
git add apps/_template/README.md apps/_template/.env.example
git commit -m "docs(_template): アプリポータル登録用のPORTAL_*規約を追記"

Task 11: リポジトリドキュメント更新

Files:

  • Modify: README.md
  • Modify: .claude/skills/dokploy-webapp/SKILL.md

Interfaces: なし(ドキュメントのみ)

  • Step 1: README.mdに「アプリポータル」章を追加

README.mdの「認証ゲートウェイ」章の後に追記(既存の章構成に合わせて配置):


## アプリポータル(apps/app-portal)

`webapps` project配下の複数アプリを横断して起動/停止/再起動/バッチ手動実行/ログ確認を行う集中管理ポータル。設計の経緯・アーキテクチャは `docs/superpowers/specs/2026-07-25-app-portal-design.md`、実装計画は `docs/superpowers/plans/2026-07-25-app-portal.md` 参照。

- 公開ドメイン: `https://portal.apps.next-hd.net`(認証ゲートウェイ必須 + アプリ層allowlist二段防御)
- 各アプリをポータルに登録するには、`.env.example`のPORTAL_*変数(`apps/_template/README.md`参照)をDokploy Environment画面で設定する
- Dokploy tRPC APIを自前クライアント(`apps/app-portal/src/dokployClient.js`)で直接呼び出している。CLIのGET系コマンドが実装バグで全滅する問題(上記「Dokploy CLI」章参照)の回避策の実例でもある
  • *Step 2: SKILL.mdにPORTAL_規約を追記

.claude/skills/dokploy-webapp/SKILL.mdの「参考」セクション手前に追記:


## アプリポータルへの登録(任意)

新規アプリを`apps/app-portal`のダッシュボードに登録したい場合、`.env.example`にPORTAL_*変数を追加する(詳細は`apps/_template/README.md`「アプリポータルへの登録」章、実装は`apps/app-portal/src/dokployClient.js`参照)。
  • Step 3: Commit
git add README.md .claude/skills/dokploy-webapp/SKILL.md
git commit -m "docs: アプリポータルの章を追加"

Task 12: Dokployへのデプロイ・Traefik認証適用・実機E2E確認

Files: なし(インフラ操作のみ)

Interfaces: なし

  • Step 1: portal-sample-batchをGiteaへpush

Run: git push gitea main

  • Step 2: portal-sample-batchのDokploy Compose作成(CLI手順は.claude/skills/dokploy-webapp/SKILL.md「新規Compose作成」参照)
dokploy compose create --name "portal-sample-batch" --environmentId "Cm0HjMIFyl11UdIcIGRy8" --composeType "docker-compose" --appName "portal-sample-batch" --json
# → composeIdを控える
dokploy compose update --composeId "<composeId>" --sourceType "gitea" --giteaId "O5-CqLQwVdlzXw3KfmN-8" --giteaOwner "mygit-admin" --giteaRepository "NodeSrv" --giteaBranch "main" --composePath "apps/portal-sample-batch/docker-compose.yml" --json
  • Step 3: portal-sample-batchの環境変数をDokploy Environment画面で設定

.env.exampleの内容(PORTAL_SECRETはランダムな値を新規発行)を設定。

  • Step 4: portal-sample-batchをデプロイし/healthを確認
dokploy compose deploy --composeId "<composeId>" --title "初回デプロイ" --json

Run: SSH経由でコンテナ起動確認(ssh -i Keys/LightsailDefaultKey-ap-northeast-1.pem ubuntu@dokploy45.next-hd.net "sudo docker ps --filter name=portal-sample-batch")

  • Step 5: app-portalのDokploy Compose作成(同様の2段階)
dokploy compose create --name "app-portal" --environmentId "Cm0HjMIFyl11UdIcIGRy8" --composeType "docker-compose" --appName "app-portal" --json
dokploy compose update --composeId "<composeId>" --sourceType "gitea" --giteaId "O5-CqLQwVdlzXw3KfmN-8" --giteaOwner "mygit-admin" --giteaRepository "NodeSrv" --giteaBranch "main" --composePath "apps/app-portal/docker-compose.yml" --json
  • Step 6: app-portalの環境変数をDokploy Environment画面で設定

DOKPLOY_API_KEYは新規にDokploy管理画面でAPIキーを発行して設定(既存のCLI用トークンとは別に発行し、万一漏れた場合の影響範囲を分離する)。PORTAL_SECRETはStep 3で発行した値と同一にする。

  • Step 7: app-portalをデプロイし/healthを確認
dokploy compose deploy --composeId "<composeId>" --title "初回デプロイ" --json

Run: curl -I https://portal.apps.next-hd.net/health Expected: 200

  • Step 8: 手動E2E確認
  1. ブラウザでhttps://portal.apps.next-hd.net/にアクセス → 未ログインならKeycloakログイン画面に遷移すること(認証ゲートウェイが機能していること)を確認
  2. ログイン後、ダッシュボードにauth-redirect等PORTAL_APP_TYPE設定済みアプリがカード表示されること(この時点で登録済みなのはportal-sample-batchのみの想定。他アプリは任意でPORTAL_*を追加してから確認)
  3. portal-sample-batchカードの「今すぐ実行」を押し、202が返ること・SSH経由でコンテナログにportal-sample-batch: triggered at ...が出力されることを確認
  4. 「ログ表示」を押し、ログが表示されることを確認
  5. PORTAL_ALLOWED_EMAILSに含まれないメールアドレスのユーザーでアクセスし、403になることを確認(allowlist外のテストユーザーが用意できない場合は、PORTAL_ALLOWED_EMAILSを一時的に空にしてallowlistミドルウェアが403を返すことのみ確認してもよい)
  • Step 9: HANDOFFドキュメントは作らず、完了確認のみ

このタスクはインフラ操作のみのためcommitなし。E2E確認結果を確認者へ報告する。


Self-Review メモ

  • 設計書(docs/superpowers/specs/2026-07-25-app-portal-design.md)の「未検証事項」(Compose一覧・ログ取得のtRPCエンドポイント)は本計画作成前に実地検証済み(environment.one, compose.one, docker.getContainersByAppNameMatch, compose.readLogs, compose.start/stop, docker.restartContainer)
  • PORTAL_APP_URLをbatch型でも必須にした点は設計書からの変更。理由をGlobal Constraintsに明記済み
  • compose.oneの機密フィールド漏洩防止は、2026-07-25に実際に発生した事故(Gitea OAuth token漏洩)の再発防止として、Task 2に回帰テストとして明示的に組み込んだ
  • batch型検証専用アプリ(portal-sample-batch)を新規に切り出し、本実装未着手の既存アプリ(lineworks-board-sync等)のスコープを侵さない設計にした