moments 管理画面(admin.shuntaka.dev)のアーキテクチャ決定と実装計画
起票日: 2026-07-12
ステータス: フェーズ 0〜3 実装・dev / prd デプロイ・通し確認まで完了(2026-07-13)。フェーズ 4(公開側 moments タブ)も実装済み(ブランチ
feat/moments-public、未コミット)。残りは tagpr リリース。詳細は末尾の「経緯(実装ログ)」参照
決定事項
moments(旧称 logs)の投稿用管理画面。Cloudflare Workers + R2 案は 廃案(Workers custom domain に DNS ゾーン移管が必要なため)、オール AWS の CloudFront 構成に決定。
論点 |
決定 |
|---|---|
名称 |
moments(旧称 logs から一新)。logs はシステムログ(tidb-proxy のログ分析基盤の Glue テーブル名も |
ドメイン |
管理画面 prd: |
配信 |
CloudFront 1 ディストリビューション(エイリアス: admin + images の 2 ドメイン)。default → 管理画面 SPA(S3, OAC)/ |
管理画面 FE |
React 19 + Vite + TanStack Router(file-based)/ Query / Form + zod + Tailwind CSS 4 + shadcn/ui + FSD 構成 |
API |
Hono( |
TiDB 接続 |
既存 tidb-proxy VPC の private subnet に Lambda を配置し、 |
ORM |
Kysely(MySQL dialect + mysql2)。DDL は既存の |
認証 |
Cognito User Pool + USER_SRP_AUTH( |
OpenAPI |
|
画像 |
クライアント側で圧縮(canvas → WebP。orig 長辺 1440px + 一覧用 thumb 長辺 640px の 2 サイズ)→ |
draft / preview |
|
MFA |
Cognito MFA (TOTP) は初期は入れない(必要になったら後付け) |
構成図
Browser (admin SPA)
│ ① ログイン: Cognito USER_SRP_AUTH(public client)→ トークンを POST /api/auth/login へ
│ ② API: 暗号化 HttpOnly セッション Cookie(__Host-session, SameSite=Lax)を自動送信
│ ③ 画像: presigned PUT で S3 直アップロード(クライアントで WebP 圧縮、orig / thumb の 2 サイズを 2 本 PUT)
▼
CloudFront ×1(エイリアス: admin.<fqdn> / images.<fqdn>、ACM us-east-1 は SAN で両ドメイン対応)
├─ default → S3: admin SPA(OAC)
├─ /api/* → API Gateway HTTP API → Lambda
│ └ Hono + Kysely(VPC: tidb-proxy private subnet)
│ ├ tidb-proxy.internal:13306 →(Tailnet)→ TiDB blog_{stage}
│ └ squid :3128(外部 HTTPS が必要になった場合の forward proxy)
└─ /images/* → S3: images バケット(OAC, 長め TTL)
▲ presigned PUT(クライアント直アップロード, CORS で admin オリジン許可)
viewer-request の CloudFront Function(default と /api/* にアタッチ):
Host が admin.<fqdn> 以外 → 403 / `/api/` 以下は素通し / それ以外は SPA fallback
画像の公開配信は images.<fqdn>(prd: images.shuntaka.dev / dev: images.shuntaka.tech)で行う。admin と同一 CloudFront のエイリアスだが、Host チェックにより images ホストで応答するのは /images/* のみ。
公開側(shuntaka.dev の moments タブ)は従来どおり blog-api(Rust, api.shuntaka.dev)から読む。blog-api は DB の image_key と env IMAGES_BASE_URL(CDK が stage ごとに注入。prd: https://images.shuntaka.dev / dev: https://images.shuntaka.tech)から画像 URL を組み立てて返す。apps/web は next/image で表示するため next.config.ts の images.remotePatterns に images ドメインを追加する(optimizer がサーバサイドで fetch するので CORS は不要。CORS が必要なのはアップロードの PUT のみ)。admin SPA の一覧プレビューも同じ images URL を参照する。
リポジトリ構成(追加分)
apps/
├── admin-web/ # 管理画面 SPA (Vite + React 19, FSD)
│ └── src/{app,pages,widgets,features,entities,shared}/
├── admin-api/ # Hono + Kysely (esbuild → Lambda, dev は @hono/node-server)
│ └── src/{routes,db,auth,schemas}/ + dev.ts(Scalar /doc はここだけ)
iac/aws/lib/
├── dns/virginia-certificate-stack.ts # us-east-1 ACM(CloudFront 用。新設)
└── admin/admin-stack.ts # Cognito + Lambda + S3×2 + CloudFront + Route53
パッケージ管理は本リポジトリの流儀(Bun workspaces + Turbo、Vite+ (oxlint/oxfmt)、cspell)に合わせる
apps/admin-webはapps/admin-apiをworkspace:*で type-only import し、hc<AppType>で end-to-end 型共有Vite dev proxy:
/api→http://localhost:${ADMIN_API_PORT}(本番は CloudFront が同じパス構造を再現).config/wt.tomlにADMIN_API_PORT/ADMIN_WEB_PORT(worktree ごとの hash_port)と post-remove の kill hook を追加済み。root のbun run dev(turbo dev)で admin-api も起動する
API 設計(初期エンドポイント)
Hono は basePath('/api') で組む(CloudFront 側で prefix strip をしない。CF Function を減らすため)。
Method / Path |
内容 |
|---|---|
|
SRP で得たトークンを検証してセッションを保存し、暗号化 HttpOnly Cookie を発行(認証不要の唯一のルート) |
|
セッション削除 + Cognito |
|
セッション検証の疎通確認(FE の auth guard 用) |
|
一覧(draft 含む全 status、 |
|
作成。 |
|
更新(draft → published の公開操作を含む。公開時に |
|
削除 |
|
presigned PUT URL を orig / thumb の 2 本発行。 |
バリデーションエラーは
OpenAPIHonoのdefaultHookで 400 に統一認証ミドルウェア: セッション Cookie を unseal(sid)→
admin_sessionsからトークンを取得し、access token をjoseで検証(issuer /token_use === 'access'/client_id)。失効間近ならサーバ側で refresh してレコードを更新。加えて Origin allowlist +X-Requested-Withの簡易 CSRF チェック
環境変数(admin-api)
env |
用途 |
|---|---|
|
TiDB 接続文字列(本番: |
|
access token 検証(issuer / client_id)と refresh / RevokeToken |
|
Cookie 暗号鍵。本番は CDK が deploy 時に Secrets Manager から取り出して注入(VPC 内 Lambda から Secrets Manager へ届かないため)。 |
|
本番 Lambda の外部 HTTPS(Cognito API / JWKS)を squid 経由にする。AWS SDK は明示ハンドラ、fetch は |
|
presign 対象の images バケット |
|
配信 URL の組み立て(例: |
|
CSRF チェックの Origin 許可リスト(カンマ区切り) |
|
|
|
dev サーバーのポート( |
値の実体は bare clone 直下の .envrc(direnv 経由で継承)。ポート系だけ worktree の .env.local(wt.toml が生成)で上書き。
環境変数(admin-web)
env |
用途 |
|---|---|
|
SRP ログイン( |
|
プレビューで開く画像公開 URL の組み立て |
|
apps/web の |
|
Vite dev サーバーのポート(wt.toml が採番。既定 43002。shell env から参照) |
値の実体は bare clone 直下の .envrc。ADMIN_WEB_PORT だけ worktree の .env.local から shell env に載る。
DB スキーマ(dsl-tidb/schema/ に追加)
CREATE TABLE IF NOT EXISTS `${SCHEMA}`.`moments` (
`moment_id` CHAR(26) NOT NULL, -- ULID (固定 26 文字)
`user_id` CHAR(36) NOT NULL, -- users.user_id (CHAR(36)) に合わせる
`text` VARCHAR(180) NOT NULL,
`image_key` VARCHAR(255) NOT NULL, -- orig の key。thumb は _thumb サフィックスで導出
`fastener` ENUM('clip','tape') NOT NULL DEFAULT 'clip',
`fastener_color` ENUM('pink','blue','yellow','green') NULL,
`status` ENUM('published','draft') NOT NULL DEFAULT 'published',
`published_at` DATETIME(6) NULL, -- draft は NULL。公開時に設定
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`moment_id`),
KEY `idx_moments_feed` (`user_id`, `status`, `published_at`, `moment_id`)
);
セッション実体(Cognito トークン一式)は Cookie ではなく DB に置く(Cookie 4KB 上限対策 + 失効管理のため)。
CREATE TABLE IF NOT EXISTS `${SCHEMA}`.`admin_sessions` (
`sid` VARCHAR(64) NOT NULL,
`user_id` CHAR(36) NOT NULL, -- ログイン時に users.name から解決した users.user_id
`access_token` TEXT NOT NULL,
`id_token` TEXT NOT NULL,
`refresh_token` TEXT NOT NULL,
`expires_at` DATETIME(6) NOT NULL,
`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`sid`)
);
期限切れレコードはログイン時に掃除する(単一ユーザー運用のため cron は持たない)。
Kysely の型はスキーマから手書き(apps/admin-api/src/db/types.ts)。件数・変更頻度的に codegen は入れない。
画像アップロードフロー
管理画面(モバイル想定)で写真選択
クライアントで圧縮:
createImageBitmap+ canvas で orig(長辺 1440px / WebP q0.8 目安)と一覧用 thumb(長辺 640px)の 2 サイズを生成(正方形トリミングは閲覧側object-coverに任せ、原比率のまま保存)POST /api/images/presign→{ imageKey: 'images/moments/<ulid>.webp', origUrl, thumbUrl }(thumb の key はimages/moments/<ulid>_thumb.webpに固定導出)S3 へ 2 本直接 PUT(バケット CORS で admin オリジンの PUT を許可、presign 時に contentLength 上限チェック。両方成功してから次へ)
POST /api/momentsにimageKeyとstatusを渡して確定配信は
https://images.<fqdn>/images/moments/<ulid>.webp(CloudFront/images/*→ S3, OAC)。Cache-Control: public, max-age=31536000, immutable(key が ULID なので immutable でよい)。一覧は thumb(<ulid>_thumb.webp)、原寸表示は orig を参照。shuntaka.dev / admin のどちらから表示しても<img>タグ読み込み(apps/web は next/image optimizer のサーバサイド fetch)なので CORS 不要
インフラ詳細(CDK)
VirginiaCertificateStack(us-east-1, 新設):
admin.<fqdn>+images.<fqdn>を SAN に持つ ACM 証明書 + SSM。CloudFront から参照(cross-region は SSM 経由 +AwsCustomResource読み出しかcrossRegionReferences: true)AdminStack(ap-northeast-1, stage 単位
{d,p}-st-admin)Cognito User Pool: self sign-up 無効・管理者 1 ユーザー手動作成・app client は public(secret なし)+
ALLOW_USER_SRP_AUTH+ALLOW_REFRESH_TOKEN_AUTH。MFA (TOTP) は初期は入れない(必要になったら後付け)admin-api Lambda:
NodejsFunction(esbuild, Node 22, ARM64)。VPC 配置は blog-api-construct と同じ SSM import(/tidb-proxy/vpc/*,/tidb-proxy/proxy/sg-id)+ Lambda SG(egress 13306/3128)。env:DATABASE_URL=mysql://root@tidb-proxy.internal:13306/blog_{stage}、Cognito の pool/client ID、COOKIE_SECRET(Secrets Manager で 48 文字を自動生成し、VPC 内から実行時に届かないため deploy 時にAwsCustomResourceで取り出して注入)、IMAGES_BUCKET_NAME/IMAGES_BASE_URL/ORIGIN_ALLOWLIST、squid 経由のHTTPS_PROXY/NO_PROXY/NODE_USE_ENV_PROXY=1(一覧は「環境変数(admin-api)」を参照)API Gateway HTTP API(apigwv2)+
HttpLambdaIntegration({proxy+}に ANY)。CloudFront/api/*behavior のオリジンに設定(キャッシュ無効 +AllViewerExceptHostHeader)S3 ×2: SPA バケット(
BucketDeploymentでapps/admin-web/distを投入)/ images バケット(CORS: admin オリジンの PUT)CloudFront: エイリアス
admin.<fqdn>/images.<fqdn>の 2 ドメイン + 上記 3 behavior。viewer-request の CloudFront Function(default と/api/*にアタッチ)で Host チェック(admin 以外 403。/api/以下は素通し、それ以外は SPA fallback)。/api/*はキャッシュ無効 +AllViewerExceptHostHeaderRoute53:
admin.<fqdn>/images.<fqdn>の A エイリアス → 同一 CloudFront
既存 main stack の blog-api Lambda に env
IMAGES_BASE_URL=https://images.<fqdn>を追加(フェーズ 4 で moments API がimage_keyから画像 URL を組み立てるため)GitHub Actions デプロイは既存 deploy-role(OIDC)に載せる
補足: Lambda Function URL + OAC 案の不採用
当初 /api/* は Lambda Function URL + OAC を検討したが、OAC がボディを署名しないため POST / PUT にクライアント側で x-amz-content-sha256 ヘッダ(ボディの SHA-256)を付ける必要がある制約を嫌い不採用。API Gateway HTTP API を挟む(コスト微増だが制約なし)。認証はセッション Cookie(admin ドメインにのみ送信される)のため、API Gateway の execute-api URL を直叩きしても認証済みリクエストは成立しない。気になる場合は CloudFront からのカスタムヘッダ(x-origin-verify)検証を後付けできる。
構築手順(チェックリスト)
進捗はこのチェックリストに同期する。 1 ステップ完了ごとに - [x] に更新して commit に含める。手順や設計が変わった場合はチェックリストと本文の両方を直す。実行したコマンドは省略せず全量を各フェーズの「実行したコマンド」に記録する。
フェーズ 0: DB スキーマ
[x]
tools/dsql-cli/dsl-tidb/schema/07_moments.sql/08_admin_sessions.sqlを追加(本ドキュメントの DDL)[x] 新規 2 ファイルを
blog_devへ適用し、SHOW CREATE TABLEで確認。既存 DB では04_articles.sqlの ALTER TABLE が非冪等でload.sh全実行が途中で失敗するため、新規ファイルのみを同じ流儀で個別適用した(ゼロから構築する場合は従来どおりload.sh)[x]
docs/.tbls.yamlにmoments.user_id → usersの仮想リレーションを追加し、docs/でbun run doc-gen(05_db/moments.md/05_db/admin_sessions.md生成)。生成物は prettier で整形してからコミットする(ルートの lint がprettier --check '**/*.{json,yaml}'でschema.jsonを検査するため必須。md の整形は必須ではないが既存スタイルに合わせる)
実行したコマンド(リポジトリルートから。Tailscale ログイン済みが前提):
# 接続確認
TAILNET=$(tailscale status --json | jq -r '.MagicDNSSuffix')
mysql -h "tidb.$TAILNET" -P 4000 -u root --connect-timeout=5 -N -B \
-e "SELECT VERSION(); SHOW TABLES FROM blog_dev;"
# DDL 適用(新規 2 ファイルのみ。${SCHEMA} 置換は load.sh と同じ流儀)
cd tools/dsql-cli/dsl-tidb
for f in schema/07_moments.sql schema/08_admin_sessions.sql; do
sed 's|${SCHEMA}|blog_dev|g' "$f" | mysql -h "tidb.$TAILNET" -P 4000 -u root --default-character-set=utf8mb4
done
cd ../../..
# 適用結果の確認
mysql -h "tidb.$TAILNET" -P 4000 -u root \
-e "SHOW CREATE TABLE blog_dev.moments\G SHOW CREATE TABLE blog_dev.admin_sessions\G"
# DB ドキュメント再生成(docs/.tbls.yaml へのリレーション追加後)
cd docs && bun run doc-gen && cd ..
# 生成物の整形(schema.json は lint 必須、md はスタイル合わせ)
bunx prettier --write "docs/source/01_開発ドキュメント/05_db/schema.json" \
"docs/source/01_開発ドキュメント/05_db/"*.md
# チェック
bunx cspell --no-progress tools/dsql-cli/dsl-tidb/schema/07_moments.sql \
tools/dsql-cli/dsl-tidb/schema/08_admin_sessions.sql \
"docs/source/01_開発ドキュメント/05_db/moments.md" \
"docs/source/01_開発ドキュメント/05_db/admin_sessions.md" \
docs/.tbls.yaml
bunx prettier --check docs/.tbls.yaml "docs/source/01_開発ドキュメント/05_db/schema.json"
フェーズ 1: apps/admin-api(Hono API)
[x] 雛形作成(package.json / tsconfig / turbo タスク配線:
devbuildtype-checktest)[x] 依存導入:
hono@hono/zod-openapizodkyselymysql2joseiron-webcrypto(Cookie の seal/unseal)ulid@aws-sdk/client-s3@aws-sdk/s3-request-presigner@aws-sdk/client-cognito-identity-provider(refresh / RevokeToken)@aws-sdk/client-secrets-manager(Cookie 暗号鍵)、dev:@hono/node-server@scalar/hono-api-referenceesbuild[x]
src/db/: Kysely セットアップ(DATABASE_URL、mysql2 pool、timezone: 'Z')+types.ts(moments / admin_sessions テーブルの手書き型)[x]
src/auth/: セッション Cookie(seal した sid のみ。本番__Host-session、dev は env フラグで非 Secure に切替)の発行・復号 +admin_sessionsストア + jose による access token 検証ミドルウェア(issuer /token_use/client_id、失効間近のサーバ側 refresh + レコード更新)[x]
src/auth/: Origin allowlist +X-Requested-Withの簡易 CSRF チェック[x]
src/schemas/: zod スキーマ(text≤ 180、fastener / fastenerColor / status の enum、cursor、presign)[x]
src/routes/:auth(login / logout)/me/moments(GET 一覧 draft 込み cursor・POST・PATCH(公開操作含む)・DELETE)/images(presign。orig / thumb の 2 本発行)[x]
src/app.ts:basePath('/api')+defaultHook(400 統一)+export type AppType。エラーはHTTPException→onErrorで JSON に統一[x]
src/index.ts:hono/aws-lambdaのhandle+ esbuild バンドル(build.mjs→dist/index.mjs、約 2.2MB)[x]
src/dev.ts:@hono/node-server(ADMIN_API_PORT、既定 43001)+/api/openapi.json+ Scalar/api/doc(dev 限定。basePath 配下に生えるため実パスは/api付き。素の/は/api/docへリダイレクト)[x] unit テスト(バリデーション / cursor encode・decode)を
bun testで[x] ローカル疎通:
blog_dev直結 +DEV_AUTH_BYPASS=1(Cognito はフェーズ 3 で構築のため)で起動し、Scalar 表示 + curl で draft 作成 → 公開 → 一覧 → presign → 削除 → 404 / 400 系まで一巡
実行したコマンド(リポジトリルートから):
# 依存導入
cd apps/admin-api
bun add hono @hono/zod-openapi zod kysely mysql2 jose iron-webcrypto ulid \
@aws-sdk/client-s3 @aws-sdk/s3-request-presigner \
@aws-sdk/client-cognito-identity-provider @aws-sdk/client-secrets-manager
bun add -d @hono/node-server @scalar/hono-api-reference esbuild
# チェック
bun run type-check && bun test src/ && bun run build
cd ../..
bunx vp fmt apps/admin-api && bunx vp lint apps/admin-api --deny-warnings
bun run spell-check
# ローカル疎通。Tailscale ログイン済み + AWS 認証を通したシェルで実行する
# (presign の署名に AWS 資格情報を使う)
TAILNET=$(tailscale status --json | jq -r '.MagicDNSSuffix')
export DEV_AUTH_BYPASS=1
export DEV_INSECURE_COOKIES=1
export DATABASE_URL="mysql://root@tidb.$TAILNET:4000/blog_dev"
export DEV_AUTH_BYPASS_USER=shuntaka
export IMAGES_BASE_URL=https://images.shuntaka.tech
# バケットはフェーズ 3 で作成するため、この時点では presign URL の生成のみ確認
export IMAGES_BUCKET_NAME=dummy-images-bucket
export ADMIN_API_PORT=43001
# 起動はリポジトリルートから turbo 経由で行う(bun dev なら全 dev タスクごと起動する)
bunx turbo dev --filter=@shuntaka-dev/admin-api &
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:43001/api/doc # 200
MOMENT_ID=$(curl -s -X POST http://localhost:43001/api/moments -H 'content-type: application/json' \
-d '{"text":"疎通テスト","imageKey":"images/moments/01JZX3F4G5H6J7K8M9N0P1Q2R3.webp","fastener":"tape","fastenerColor":"pink","status":"draft"}' \
| jq -r '.momentId')
echo "$MOMENT_ID"
curl -s -X PATCH "http://localhost:43001/api/moments/$MOMENT_ID" \
-H 'content-type: application/json' -d '{"status":"published"}' # publishedAt が現在時刻に
curl -s 'http://localhost:43001/api/moments?limit=1'
curl -s -X POST http://localhost:43001/api/images/presign -H 'content-type: application/json' \
-d '{"contentType":"image/webp","origLength":1000000,"thumbLength":100000}' # orig / thumb の 2 URL
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE "http://localhost:43001/api/moments/$MOMENT_ID" # 204
kill %1
フェーズ 2: apps/admin-web(管理画面 SPA)
[x] 雛形作成(Vite + React 19 +
@tanstack/router-plugin+ Tailwind CSS 4)、FSD ディレクトリ + steiger。[x] shadcn/ui(
base-novaスタイル = Base UI 版)をshared/ui/に配置。components.json を設置し、コンポーネントは参照プロジェクトから必要分(button / button-link / input / textarea / label / badge / card / skeleton)を移植[x]
shared/api/:hc<AppType>(workspace type import。admin-api 側にexports: "./src/app.ts"を追加)。fetch ラッパは作らずhcの init に集約(Cookie 同送 +X-Requested-With常時付与)。401 対応はAuthGuardが担う[x]
features/auth/: SRP ログインフォーム(amazon-cognito-identity-js。インメモリ storage を渡して localStorage にトークンを残さない)→POST /api/auth/loginで Cookie セッション確立、AuthGuard(/api/me。beforeLoad ではなくコンポーネントラップ方式)、ログアウト[x]
entities/moment/: RPC レスポンスから型導出(InferResponseType)+ TanStack Query(一覧はinfiniteQueryOptionsの cursor ページング)[x] pages:
/login//moments(draft 含む一覧 + 公開 / 削除)//moments/new(TanStack Form + zod、180 字カウンタ、fastener トグル / テープ 4 色スウォッチ、draft / published 切替、プレビュー = apps/web の/moments/previewを新規タブで開く)[x] 画像圧縮(
createImageBitmap(EXIF 回転を反映)+ canvas → WebP。orig 長辺 1440px + thumb 長辺 640px の 2 サイズ)→ presign → S3 PUT ×2。プレビューと投稿で同一ファイルのアップロードは 1 回に抑制[x]
vite.config.tsの dev proxy(/api→http://localhost:${ADMIN_API_PORT}。prefix は剥がさない)+ dev ポートADMIN_WEB_PORT(既定 43002)[ ] ローカル E2E(root の
bun devで admin-web + admin-api を起動。Cognito 実接続はフェーズ 3 の dev pool 構築後で、それまでは backend 側DEV_AUTH_BYPASS=1で確認)[x] lint / spell / type-check / steiger グリーン(root の
bun run lint一式通過)
実行したコマンド(リポジトリルートから):
# 依存導入
cd apps/admin-web
bun add react react-dom @tanstack/react-router @tanstack/react-query @tanstack/react-form \
zod hono amazon-cognito-identity-js @base-ui/react class-variance-authority clsx \
tailwind-merge tw-animate-css shadcn @fontsource-variable/geist
bun add -d vite @vitejs/plugin-react @tanstack/router-plugin @tailwindcss/vite tailwindcss \
steiger @feature-sliced/steiger-plugin @types/react @types/react-dom
# routeTree.gen.ts の生成込みでビルド → 型 → FSD 検査
bunx vite build
bun run type-check
bunx steiger src
cd ../..
# 整形と全体チェック
bunx vp fmt apps/admin-web apps/admin-api
bun run lint
フェーズ 3: iac/aws + デプロイ
[x]
lib/dns/virginia-certificate-stack.ts(us-east-1、admin.<fqdn>+images.<fqdn>の SAN 証明書 + SSM。hosted zone ID はlib/cross-region-ssm.tsのAwsCustomResourceで東京の SSM から読む)[x]
lib/config.tsにdomain.admin/domain.imagesと SSM パス(virginia cert / cognito 出力)を追加[x]
lib/admin/admin-stack.ts: Cognito User Pool(self sign-up 無効、強パスワードポリシー、prd は deletionProtection + RETAIN)+ SRP 用 public client[x] 同: admin-api Lambda(
NodejsFunctionesbuild/ESM、VPC = tidb-proxy の SSM import、SG egress 13306/3128)+ Cookie 暗号鍵の Secrets Manager シークレット(自動生成。値は deploy 時に取り出してCOOKIE_SECRET注入)。外部 HTTPS(Cognito / JWKS)は squid 経由(AWS SDK は proxy ハンドラ、fetch はNODE_USE_ENV_PROXY=1)[x] 同: API Gateway HTTP API +
HttpLambdaIntegration({proxy+}ANY)[x] 同: S3 ×2(SPA / images + CORS。images は prd RETAIN、dev はローカル PUT 用に
http://localhost:*も許可)、CloudFront(エイリアス admin / images の 2 ドメイン + 3 behavior + Host チェック付き SPA fallback CF Function + OAC、PRICE_CLASS_200)、Route53 A エイリアス ×2。SPA 資材はBucketDeployment(apps/admin-web/dist不在時はこの stack のデプロイだけをブロック)[x]
bin/cdk.ts配線({d,p}-st-virginia-cert/{d,p}-st-admin)+ cdk-nag suppressions +test/admin.test.ts(NodejsFunction をモックした snapshot + nag 検証)。deploy-role にcognito-idp:*/cloudfront:*/secretsmanager:*を追加[x] dev デプロイ(admin.shuntaka.tech)→
admin-create-userで管理ユーザー作成[x] CloudFront 経由で SRP ログイン → 画像付き投稿 → TiDB 反映 →
images.shuntaka.techでの画像配信(+ images ホストで default //api/*が 403 になること)まで通し確認(2026-07-13。途中で踏んだ障害 2 件は「トラブルシュート」参照)[x] GitHub Actions(既存 deploy-role / OIDC)に admin-web ビルド + デプロイを組み込み(
reusable-deploy.yamlの admin ステップ: virginia-cert → SSM から Cognito 出力を読んで admin-web ビルド → admin。初回は Cognito 出力が無いまま SPA が焼かれるため、スタック適用後にもう一度 admin をデプロイする)[ ]
blog_prdへ DDL 適用 → prd デプロイ(admin.shuntaka.dev)で通し確認
デプロイ手順
AWS 認証を通したシェル(aws-vault 等)で実行する。CI(reusable-deploy.yaml の admin ステップ)も同じ順序。
cdk コマンドは iac/aws で実行する(リポジトリルートだと --app is required で失敗する)。cdk deploy d-st-admin は依存スタック(d-st-global-dns / d-st-virginia-cert)を自動で含めるため、手順 1 を省いて 3 だけ実行しても依存分が先に適用される(初回 dev 適用はこの形で実施。所要 約20分、大半は CloudFront distribution の作成待ち)。
初回のみ: us-east-1 の CDK bootstrap
CloudFront 用証明書スタックが us-east-1 に立つため、リージョン未 bootstrap なら先に実行する。
cd iac/aws
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
bunx cdk bootstrap "aws://${ACCOUNT_ID}/us-east-1"
dev デプロイ
cd iac/aws
# 1. us-east-1 の証明書スタック(cross-region で東京の hosted zone ID を参照する)
bunx cdk deploy d-st-virginia-cert -c stageName=dev --require-approval never
# 2. admin-web をビルド(初回は Cognito 出力が無いので空のままでよい)
POOL_ID=$(aws ssm get-parameter --name /dev/shuntaka/admin/user-pool-id --query 'Parameter.Value' --output text 2>/dev/null || echo '')
CLIENT_ID=$(aws ssm get-parameter --name /dev/shuntaka/admin/user-pool-client-id --query 'Parameter.Value' --output text 2>/dev/null || echo '')
(
cd ../../apps/admin-web
VITE_COGNITO_USER_POOL_ID="$POOL_ID" \
VITE_COGNITO_CLIENT_ID="$CLIENT_ID" \
VITE_IMAGES_BASE_URL=https://images.shuntaka.tech \
VITE_PREVIEW_BASE_URL=https://shuntaka.tech \
bun run build
)
# 3. admin スタック(Cognito / Lambda / API GW / S3×2 / CloudFront / Route53 / SPA 投入)
bunx cdk deploy d-st-admin -c stageName=dev --require-approval never
# 4. 初回のみ: Cognito 出力が SSM に出たので、実 ID を焼き込んで SPA を再デプロイ
# (手順 2 → 3 をもう一度実行する)
管理ユーザー作成(初回のみ)
username は users.name と一致させる(ログイン時に users.name → user_id を解決するため)。
仮パスワードのままだと初回変更チャレンジが返りログインフォームは弾くので、--permanent で本パスワードを設定する。
POOL_ID=$(aws ssm get-parameter --name /dev/shuntaka/admin/user-pool-id --query 'Parameter.Value' --output text)
aws cognito-idp admin-create-user \
--user-pool-id "$POOL_ID" \
--username shuntaka \
--message-action SUPPRESS
# パスワード(12 文字以上・大小英数記号)は対話入力で環境変数へ取り込む
# (コマンドラインに直書きするとシェル履歴に残るため。bash / zsh 共通)
printf 'Password: '
read -rs ADMIN_PASSWORD
echo
aws cognito-idp admin-set-user-password \
--user-pool-id "$POOL_ID" \
--username shuntaka \
--password "$ADMIN_PASSWORD" \
--permanent
unset ADMIN_PASSWORD
通し確認
curl -s -o /dev/null -w '%{http_code}\n' https://admin.shuntaka.tech/ # 200 (SPA)
curl -s -o /dev/null -w '%{http_code}\n' https://admin.shuntaka.tech/api/me # 401 (未ログイン)
curl -s -o /dev/null -w '%{http_code}\n' https://images.shuntaka.tech/ # 403 (Host ガード)
ブラウザで https://admin.shuntaka.tech → SRP ログイン → 画像付き投稿 → 一覧表示 → blog_dev.moments への反映と https://images.shuntaka.tech/images/moments/... の配信を確認する。
prd デプロイ
# DDL を blog_prd へ適用(Tailscale ログイン済みシェル)→ 2026-07-13 適用済み
TAILNET=$(tailscale status --json | jq -r '.MagicDNSSuffix')
for f in tools/dsql-cli/dsl-tidb/schema/07_moments.sql tools/dsql-cli/dsl-tidb/schema/08_admin_sessions.sql; do
sed 's|${SCHEMA}|blog_prd|g' "$f" | mysql -h "tidb.$TAILNET" -P 4000 -u root --default-character-set=utf8mb4
done
# デプロイは dev と同じ流れで stage / prefix / ドメインを差し替える
# d-st-virginia-cert → p-st-virginia-cert / d-st-admin → p-st-admin
# -c stageName=prd / SSM パスは /prd/... / VITE_* は images.shuntaka.dev, shuntaka.dev
# 管理ユーザー作成・通し確認も同様 (admin.shuntaka.dev)
GitHub Actions(手動実行)でやる場合。admin ステップ入りの workflow 定義が main に必要なので、本 PR のマージ後に実行する。
# main から prd へ admin スタックをデプロイ
gh workflow run Deploy --ref main -f stageName=prd -f stack=admin
# 完走まで監視
sleep 5
gh run watch "$(gh run list --workflow Deploy --branch main --limit 1 --json databaseId -q '.[0].databaseId')"
prd も初回は Cognito 出力が SSM に無い状態で SPA が焼かれるため、完走後にもう一度同じ dispatch を実行して実 ID を焼き込む(dev の手順 4. と同じ理屈)。その後 /prd/shuntaka/admin/user-pool-id を使って管理ユーザーを作成し、admin.shuntaka.dev で通し確認する。
CI から実行する場合
GitHub Actions の Deploy workflow を workflow_dispatch で stack=admin を選んで実行する(main push の all にも admin は含まれる)。初回だけ上記 4. の再デプロイが必要なのは同じ。
workflow_dispatch は --ref で指定したブランチ上の workflow 定義とコードで動くため、main へのマージ不要で作業ブランチからそのまま dev へデプロイできる。VITE_* は CI が組み立てる(Cognito 2 つは SSM、URL 2 つは Environment variable SITE_FQDN)ので手元でのビルドも不要。
# push 済みの現在のブランチから dev へ admin スタックをデプロイ
BRANCH=$(git branch --show-current)
gh workflow run Deploy --ref "$BRANCH" -f stageName=dev -f stack=admin
# 完走まで監視
sleep 5
gh run watch "$(gh run list --workflow Deploy --branch "$BRANCH" --limit 1 --json databaseId -q '.[0].databaseId')"
実績: 2026-07-13 に chore/adjustment-document から stageName=dev / stack=admin で実行し 54 秒で完走(他スタックはスキップされることを確認)。
web(Vercel)のデプロイ経路
公開 web は AWS ではなく Vercel 配信のため、Deploy workflow の対象外。
dev(shuntaka.tech): main への push で
deploy-preview.yamlが Vercel CLI デプロイ(tagpr の production 版と同じ手順の preview 版。既存のVERCEL_*secrets を流用)を実行し、デプロイ URL へshuntaka.techを直接エイリアスする。手動再実行は workflow_dispatchprd(shuntaka.dev): tagpr のタグリリース(
tagpr.yamlの deploy-vercel ジョブ、vercel deploy --prod)でのみ更新。vercel.jsonで main の git デプロイは無効化してあり、main push が本番に直行しない注意: shuntaka.tech は dev 確認用のためアクセス制限を有効にしている(閲覧は所有者のみ)
補足: 旧方式(
sync-preview.yamlでpreviewブランチへ ref をミラーし Vercel の git 連携でビルド)は、API の ref 更新が commits 空の push イベントになり Vercel がビルドをスキップするため 1 日で廃止。origin のpreviewブランチと Vercel 側のドメインのブランチ割り当ては不要になったので手動で片付ける
GitHub 側の登録手順
deploy workflow は environment: <stageName> で GitHub Environments(dev / prd)の secrets / variables を参照する。既存デプロイ(main スタック等)で Environments と以下は登録済みのため、admin 用に新規で必要なのは Environment variable SITE_FQDN のみ。
種別 |
名前 |
状態 |
|---|---|---|
secret |
|
登録済み(main 用) |
variable |
|
登録済み(main 用) |
variable |
|
新規。公開サイトの apex ドメイン |
SITE_FQDN は admin-web ビルドの VITE_IMAGES_BASE_URL(https://images.<SITE_FQDN>)と VITE_PREVIEW_BASE_URL(https://<SITE_FQDN>)の組み立てに使う。
UI から: リポジトリの Settings → Environments →
dev(/prd)→ Environment variables → New variableCLI から:
# 登録
gh variable set SITE_FQDN --env dev --body shuntaka.tech
gh variable set SITE_FQDN --env prd --body shuntaka.dev
# 確認
gh variable list --env dev
gh variable list --env prd
Environments 自体が無い場合(新リポジトリ等)は先に作成する。
gh api -X PUT "repos/{owner}/{repo}/environments/dev"
gh api -X PUT "repos/{owner}/{repo}/environments/prd"
実行したコマンド(リポジトリルートから。デプロイ以外の実装時分):
# admin_sessions に user_id 列を追加(セッションは揮発データで dev は空のため再作成)
TAILNET=$(tailscale status --json | jq -r '.MagicDNSSuffix')
mysql -h "tidb.$TAILNET" -P 4000 -u root -N -B -e "SELECT COUNT(*) FROM blog_dev.admin_sessions;" # 0 を確認
mysql -h "tidb.$TAILNET" -P 4000 -u root -e "DROP TABLE blog_dev.admin_sessions;"
sed 's|${SCHEMA}|blog_dev|g' tools/dsql-cli/dsl-tidb/schema/08_admin_sessions.sql \
| mysql -h "tidb.$TAILNET" -P 4000 -u root --default-character-set=utf8mb4
mysql -h "tidb.$TAILNET" -P 4000 -u root -e "SHOW CREATE TABLE blog_dev.admin_sessions\G"
cd docs && bun run doc-gen && cd ..
bunx prettier --write "docs/source/01_開発ドキュメント/05_db/schema.json" "docs/source/01_開発ドキュメント/05_db/"*.md
# admin-api の proxy 対応依存
cd apps/admin-api && bun add https-proxy-agent @smithy/node-http-handler && cd ../..
# iac/aws(NodejsFunction のローカルバンドル用 esbuild + テスト)
cd iac/aws
bun add -d esbuild
bun run type-check
bunx vitest run # snapshot + cdk-nag 検証
bun run test:update # deploy-role への action 追加による snapshot 更新
cd ../..
# 整形と全体チェック
bunx prettier --write .github/workflows/deploy.yaml .github/workflows/reusable-deploy.yaml
bunx vp fmt iac/aws apps/admin-api
bun run lint
フェーズ 4: 公開側(shuntaka.dev の moments タブ)
[x] apps/web: UI モックの
LogCard/LogFeed(+ Story)をMomentCard/MomentFeedにリネーム(Story タイトル / CSS クラスmoment-*/ CSS 変数 / keyframes まで一新)[x] blog-api(Rust):
GET /users/{name}/moments(cursor、published のみ。image_key+ envIMAGES_BASE_URLからimage_url/thumb_urlを組み立てて返す)+ 単体テスト。iac 側で blog-api Lambda に env を追加(domain.imagesから組み立て、snapshot 更新済み)。cursor は admin と同じ moment_id(ULID)単独・moment_id DESC順で、値は素の momentId(base64 化しない)。limit は 1〜50(default 20)、limit+1件取得方式[x] apps/web:
MomentSummary型をlib/api.tsへ移設しgetMoments追加(thumbUrlを含む)[x] apps/web:
/momentsルート追加 +BaseLayoutのcurrentTabunion に'moments'を追加(タブ並びは posts / moments / about)[x]
MomentFeedを実 API(cursor)に接続(server で 1 ページ目 →MomentsInfiniteFeed(client)が cursor で継ぎ足し。追加読み込み失敗時はフィードを打ち切り)。一覧画像はthumb_urlを使用(next.config.tsのimages.remotePatternsにimages.shuntaka.dev/images.shuntaka.techを追加)[x] apps/web:
/moments/previewルート(query パラメータ img / text / fastener / color / date からMomentCardを 1 枚レンダリング。img は https + images ドメイン +/images/moments/パスのみ許可、noindex)[x]
DESIGN.mdに moments の意図的例外(揺れアニメーション / 留め具の実物描写)を明記(3 セクション構成・タブ小文字表記も更新)[x] 追加要望(2026-07-13): admin に編集と下書きに戻すを実装。admin-api に
GET /moments/:id(編集フォームの初期値用)を追加し、admin-web は一覧の published 行に「下書きに戻す」(PATCH status: draft。published_at は backend が NULL に揃える)、全行に「編集」導線、/moments/:id/editの編集ページ(MomentFormを編集モード拡張。画像差し替えは任意で、未選択なら既存 imageKey を維持。clip へ変更時は fastenerColor: null を明示送信)[ ] tagpr リリース
フェーズ 4 の動作確認(2026-07-13): ローカルで blog-api を dev DB に向けて起動し、GET /users/shuntaka/moments が published のみ返すこと(draft は除外)、レスポンス形式(camelCase、orig/thumb URL、RFC3339)、不正 cursor / limit=0 が 400 になることを確認。cargo clippy -D warnings / cargo test / root bun run lint(10 タスク)/ next build すべてグリーン。
料金見積り
追加リソースの固定費は Secrets Manager($0.40/月 × dev/prd の 2 シークレット)のみで、合計 月 $1 未満の見込み。
CloudFront / CloudFront Function: 無料枠(転送 1TB + 1,000 万リクエスト/月、Function 200 万実行/月)内
API Gateway HTTP API / Lambda (ARM64): 従量課金のみ。個人の管理操作 + ブログ閲覧規模では実質 $0
S3 ×2: orig + thumb で 1 投稿 ~0.3MB のため 1,000 投稿でも ~0.3GB ≒ $0.01/月
Cognito: 1 ユーザー(無料枠 10,000 MAU)で $0。ACM 証明書も無料
Route53: 既存 hosted zone に A レコード追加のみ(AWS リソースへのエイリアスクエリは無料)
VPC まわり: 既存 tidb-proxy(Fargate + squid)を流用するため増分なし。NAT Gateway は新設しない(Lambda からの AWS API 呼び出しも squid 経由)
固定費をゼロにしたければ Cookie 暗号鍵を Secrets Manager から SSM Parameter Store の SecureString(standard パラメータは無料)へ置き換え可能。初期は CDK の自動生成 + rotation 余地を優先して Secrets Manager を採用
未決事項
アーキテクチャ上の未決はなし。当初の論点はすべて決定済み(名称は moments へ一新 / 画像は orig + thumb の 2 サイズ / MFA は初期なし / 配信は images.<fqdn> を初期採用 / draft 運用あり + /moments/preview でプレビュー)。
実装時に決める細目:
投稿者→ 決定: env 固定は廃止。ログイン時に access token のuser_idの決め方usernameclaim をusers.name(UNIQUE)へ突き合わせてuser_idを解決しadmin_sessions.user_idに保存、以降の API はセッションのuser_idでコンテンツをスコープする。Cognito ユーザーはusers.nameと同じ username でadmin-create-userするDELETE /api/moments/:idで S3 の orig / thumb も削除するか。presign 後に投稿確定せず離脱した孤児画像の扱い(実害がほぼないため初期は放置。気になれば S3 ライフサイクルで対応)SPA デプロイ時のキャッシュ戦略(
index.htmlを no-cache にするか、BucketDeploymentの distribution 連携で invalidation を打つか)published_atのタイムゾーン運用(UTC 保存 + 表示時 JST 変換。既存 articles の慣例に合わせる)
なお EXIF(GPS 位置情報含む)は canvas 再エンコードで自動的に除去されるため、モバイル写真の位置情報漏れは設計上ケアされている。
経緯(実装ログ)
新しいセッションで作業を再開するためのコンテキスト。作業ブランチは chore/adjustment-document(PR #613、ベース main)。
コミット済み
8329527フェーズ 0: moments / admin_sessions の DDL 追加とblog_dev適用、tbls ドキュメント生成43ebdd1フェーズ 0 の実行コマンド全量を記録f8e6338フェーズ 1: apps/admin-api(Hono + Kysely)実装とローカル疎通
未コミット(作業ツリーに存在)
フェーズ 2 一式: apps/admin-web(Vite + React 19 + TanStack Router + FSD + shadcn base-nova)。実装の流儀は
~/repos/github.com/shuntaka9576/test-pj/apps/frontendを踏襲。lint / type / steiger グリーンフェーズ 3 一式: iac/aws(VirginiaCertificateStack / AdminStack / cross-region-ssm / deploy-role 追記 / nag suppressions / test/admin.test.ts)、CI ワークフローの admin ステップ、admin-api の proxy 対応(https-proxy-agent + @smithy/node-http-handler)
認証のユーザー解決の設計変更(下記)に伴う admin-api 修正、
08_admin_sessions.sqlのuser_id列追加(blog_dev へは適用済み)、tbls 再生成分
実装中に確定した設計変更(本文へ反映済み)
ワークスペース名を
apps/admin-backend→apps/admin-apiに統一(2026-07-13。Lambda 物理名{d,p}-st-admin-apiと揃えるため。本ドキュメント内の旧表記も一括置換済み)一覧 cursor は
created_at + moment_id→moment_id(ULID)単独へ変更ADMIN_USER_IDの固定 env は廃止。ログイン時に access token のusername→users.nameでuser_idを解決しadmin_sessions.user_idに保存、API はセッションのuser_idでスコープCookie 暗号鍵は Secrets Manager 自動生成のまま、deploy 時に値を取り出して
COOKIE_SECRET注入(VPC 内 Lambda から実行時に Secrets Manager へ届かないため)本番 Lambda の外部 HTTPS(Cognito API / JWKS)は squid 経由(SDK は proxy ハンドラ、fetch は
NODE_USE_ENV_PROXY=1。squid は destination 制限なしのため設定変更不要)dev サーバーのポートは wt.toml の hash_port(
ADMIN_API_PORT/ADMIN_WEB_PORT)+ フォールバック 43001 / 43002。起動は root のbun dev(turbo)前提
ローカル開発のハマりどころ(既知)
turbo devは起動時のパッケージグラフで固定されるため、ワークスペース追加後は dev セッションの再起動が必要ワークスペース構成が異なるブランチ間(例: ディレクトリ改名の前後)を行き来したら
bun installで node_modules のリンクを張り直す。張り直さないと lint / type-check が依存を解決できず error 型扱いのエラーが大量に出るroot
.env.localは Vercel CLI に上書きされ wt.toml のポート定義が消えることがある(TODO 管理中の未解決課題)amazon-cognito-identity-js は
global参照するため index.html にwindow.global = windowのシムを入れてある画像アップロードの S3 PUT は presign URL 発行までは確認済み。dev スタック適用でバケットは作成済みのため、残る検証は通し確認(次のアクション 5.)で行う
トラブルシュート: dev ログインが失敗する(2026-07-13 解消)
ログイン失敗の裏に独立した障害が 2 つ重なっていた。
1. blog_dev.admin_sessions テーブル欠落で 500
事象: admin.shuntaka.tech で SRP ログイン後の
POST /api/auth/loginが 500{"error":"internal_error"}(画面上は未ログインの 401 に見える)原因:
blog_dev.admin_sessionsが存在しなかった。user_id 列追加時の「DROP → 再作成」のうち再作成が実際には反映されておらず、ログイン時のdeleteExpiredSessions()がER_NO_SUCH_TABLEで落ちていた調査:
aws logs tail /aws/lambda/d-st-admin-api --since 10m --format shortでTable 'blog_dev.admin_sessions' doesn't existの ERROR を確認解消: DDL を再適用
TAILNET=$(tailscale status --json | jq -r '.MagicDNSSuffix')
sed 's|${SCHEMA}|blog_dev|g' tools/dsql-cli/dsl-tidb/schema/08_admin_sessions.sql \
| mysql -h "tidb.$TAILNET" -P 4000 -u root --default-character-set=utf8mb4
補足:
/apiを curl 等で直接叩いて検証する場合、変更系リクエストはX-Requested-Withヘッダーが必須で、Originは allowlist 内か不送信であること(満たさないと CSRF ガードが 403{"error":"forbidden"}を返す)
2. SPA からのみログインが 401 invalid token(hc の init.headers が Content-Type を消す)
事象: curl / スクリプトからの
POST /api/auth/loginは 204 で成功するのに、SPA からは常に 401{"error":"invalid token"}。Lambda にエラーログは一切出ない(401 は Lambda にとって正常応答で、catch も例外を握りつぶしていた)原因:
apps/admin-web/src/shared/api/index.tsがhc()にinit: { headers: {...} }を渡していた。hono/client はfetch(url, { body, method, headers, ...opt.init })と init を最後に spread するため、init.headersが自動付与のContent-Type: application/jsonを丸ごと上書きして消す。Content-Type の無い body は zod-openapi のバリデータ対象外となりc.req.valid('json')が空 →verifyAccessToken(undefined)→ jose のERR_JWS_INVALID(Compact JWS must be a string or Uint8Array)→ 401修正:
x-requested-withをinit.headersからhc()のheadersオプションへ移動(Content-Type 付与前にマージされる正しい位置)routes/auth.tsの catch にconsole.errorを追加。以降この系統の障害は Lambda ログのlogin: access token verification failed ...で一発診断できる
次のアクション
未コミット分のコミット(フェーズ 2 + 3。ユーザー指示があってから)
dev デプロイ→ 初回適用済み(2026-07-13)。iac/awsからbunx cdk deploy d-st-admin -c stageName=dev --require-approval neverの 1 コマンドで実施(依存の d-st-global-dns / d-st-virginia-cert も自動適用)。疎通確認済み:https://admin.shuntaka.tech/200 //api/me401 /https://images.shuntaka.tech/403初回のみの再デプロイ(デプロイ手順の 4.)→ 実施済み(配信中の SPA JS に実 Pool ID / Client ID の焼き込みを確認)→ 実施済み(UserStatus CONFIRMED。username はadmin-create-userusers.nameと同じshuntaka)通し確認→ 完了(2026-07-13)。SRP ログイン → 画像付き下書き投稿 →blog_dev.moments反映 →images.shuntaka.techの thumb 配信 200 / images ホスト 403 まで確認。テスト投稿(下書き 1 件)は不要になったら管理画面から削除ログイン障害の修正 2 ファイル(admin-web の hc headers / admin-api の catch ログ)のコミット。管理ユーザーのパスワードはトラブルシュート中にターミナルへ表示されたため
admin-set-user-password --permanentで更新を推奨→ 完了(2026-07-13。admin.shuntaka.dev で確認済み。初回は Cognito 焼き込みのため 2 回 dispatch した)blog_prdへ DDL 適用 → prd デプロイフェーズ 4(公開側 moments タブ)→ 実装済み(2026-07-13、ブランチfeat/moments-public。チェックリストはフェーズ 4 の節を参照)。残りはコミット → PR → main マージ(= dev への blog-api / web デプロイ)→ tagpr リリースで本番反映