Context
Extension は今 nsx にログインしていないとページ登録に失敗する(401)。原因は popup の
保存/重複チェックリクエストに Authorization ヘッダもトークンも無いこと(server/auth.ts
isAuthorized が 401)。解決策 = Option D(スコープ付き PAT): オーナーが Web 設定画面で
トークンを1回発行 → Extension に貼付 → 保存時のみ Authorization: Bearer nsx_pat_<token>
を送る。Cookie ログインの仕組みには一切触らない。PR1(テスト配線・マイグレーション)
マージ後に着手。
Current State (verified 2026-05-28)
browser-extension/src/entrypoints/popup/App.tsx の保存/重複チェックが認証ヘッダ無し
server/routes/stock.ts:105 POST /push_stock・:176 GET /stock/exists が isAuthorized ゲート
server/lib/authSession.ts:48-50 hashRefreshToken = createHash('sha256').update(token).digest('hex')(PAT が再利用すべき方式)
prisma/schema.prisma:84-96 RefreshToken モデル(PAT モデルの雛形)
- E3 の実バグ(検証済み):
App.tsx:62,139 は import.meta.env.VITE_API_ENDPOINT を読むが、
.env.development/.env.production が定義するのは別キー VITE_API_URL。よって
VITE_API_ENDPOINT は常に undefined → 常に DEFAULT_API_ENDPOINT(http://localhost:4000/api/,
constants.ts:2)にフォールバック。本番ビルドの Extension が localhost を叩く。
加えて env 値が .../api/push_stock フルパス。buildPushStockApiUrl は base を期待し
自前で /api/push_stock を付与(重複ガード有り)するため、フルパスを渡すと二重付与で 404。
Proposed Change
Server
- E7/E12
PersonalAccessToken モデル + 逆リレーション(下記 Implementation Details の Prisma ブロック)
- mint / list / revoke エンドポイント(Web cookie セッション =
isAuthorized のまま。下記 API 仕様)
- E1 専用ミドルウェア
authenticateStockRequest: cookie 認証 → 失敗で PAT 認証、
PAT 失敗時は絶対に clearAuthCookies を呼ばない(confused-deputy 防止)
- E4 PAT lookup で
SESSION_USER_SELECT を使い req.authenticatedUser を全行ハイドレート(部分 {id} 不可)
- E14 原子的検証:
findFirst({ where: { tokenHash, revokedAt: null, OR: [{expiresAt: null}, {expiresAt: {gt: now}}] } }) 単一クエリ
- E6 不正/欠落
Authorization → 401(500 でない)
- E8
push_stock のミドルウェアを isAuthorized→authenticateStockRequest に差替(validateBody は2番目のまま)
- E9 PAT を
/push_stock + /stock/exists のみで受理(stocklist/delete は cookie のまま)
- E5 生トークンを絶対にログしない(
App.tsx:180 のサニタイズ + Logger.ts:12 の redaction キー拡張)
Web (src/pages/Dashboard/Setting/)
- E13 「Extension Token」セクション(hand-rolled Button/Input + Tailwind4 + lucide、shadcn/radix 不使用)。
Generate → show-once 生トークンパネル + copy-to-clipboard(SnackBar 不使用: 大文字化・5s 自動消滅・閉じるボタン未配線)、
一覧は masked nsx_pat_…<last4>、各行に revoke ボタン
Extension (browser-extension/)
- E3 env キー不一致を修正(下記 Implementation Details)、NSX-80 を unskip
- トークン貼付 UI(
chrome.storage.local)、connected 状態、401 → reconnect プロンプト
- E11 Playwright global setup で server:start 前にテスト DB マイグレーション
- E15(P3)
host_permissions を <all_urls> から NSX API origin + active-tab に縮小
- E16(P3)実機ビルドで PAT 保存が 201/401 に到達(CORS エラーでない)を確認
Implementation Details (concrete — leaves zero design decisions)
PAT Prisma model (prisma/schema.prisma, mirrors RefreshToken:84-96)
model PersonalAccessToken {
id Int @id @default(autoincrement())
tokenHash String @unique @db.Char(64) // sha256 hex of "nsx_pat_<raw>"
tokenSuffix String @db.Char(4) // last 4 chars of raw token, for masked list (hash can't reveal it)
name String @db.VarChar(255) // human label, e.g. "Chrome extension"
userId Int
lastUsedAt DateTime?
expiresAt DateTime? // null = non-expiring (default for this single-scope PAT)
revokedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("personal_access_tokens")
}
そして User に personalAccessTokens PersonalAccessToken[] を refreshTokens の隣へ追加(E12, Prisma 検証に必須)。
API (cookie-session gated via existing isAuthorized; mounted under /api)
| Method |
Path |
Req body |
Res |
| POST |
/api/personal_access_token (mint) |
{ "name": string } |
201 { id, name, token: "nsx_pat_<64hex>", tokenSuffix, createdAt } — token はこの応答1回のみ |
| GET |
/api/personal_access_token/list |
— |
200 { tokens: [{ id, name, tokenSuffix, createdAt, lastUsedAt, revokedAt }] } — token/tokenHash を絶対に含めない |
| DELETE |
/api/personal_access_token/:id (revoke) |
— |
200 { id, revokedAt }(revokedAt をセット) |
- トークン生成:
nsx_pat_ + randomBytes(32).toString('hex')(64 hex, 256-bit)。保存は hashToken(raw) の sha256 hex のみ。tokenSuffix = raw 末尾4文字。
- mint はレート制限(既存ミドルウェアがあれば再利用、無ければ簡易に 10 req / hour / user)。
hashRefreshToken(authSession.ts:48-50)を汎用 hashToken にリネームし mint/検証で共用(DRY、2つ目のハッシュ関数を作らない)。
E3 env fix (verified root cause above)
- キー名を揃える: env の
VITE_API_URL を VITE_API_ENDPOINT にリネーム(または App.tsx 側を VITE_API_URL 読み取りに変更)。src/env.d.ts:4 の型も同期。
- 値は origin/base のみにする(
buildPushStockApiUrl が /api/push_stock を付与する):
.env.development: VITE_API_ENDPOINT=http://localhost:4000
.env.production: VITE_API_ENDPOINT=https://nsx.malloc.tokyo
- WXT が本番ビルドで
.env.production を読むこと(build mode 配線)を確認し、NSX-80 を unskip。
E11 Playwright test-DB migration
browser-extension/playwright.config.ts の globalSetup で、pnpm server:start の前に
テスト用 DATABASE_URL に対して pnpm prisma migrate deploy を実行し、personal_access_tokens
テーブルを作る。
Acceptance Criteria
- Web 設定で生成したトークンを Extension に貼付 → ページ保存が 201
- 無効/revoke 済 PAT → 401、かつ
Set-Cookie クリアを発行しない(U5 回帰ガード)
Authorization ヘッダ無しのリクエストは従来通り cookie 認証で動作(U7 回帰ガード)
- 生トークンは mint 応答1回のみ表示、list/ログ/DOM に生値が出ない(list は
tokenSuffix のみ)
/stock/exists も PAT を受理(popup の on-open 重複チェックが connected に到達)
- mint の生成・list の非開示・revoke の 401 化が単体テストで緑(U1–U8)
prisma validate 通過、tsc 通過、pnpm lint warning 0
- 既存 cookie ログイン web アプリに回帰なし(既存テスト全緑)
- E3: 本番ビルドの Extension が
https://nsx.malloc.tokyo/api/push_stock を叩く(localhost でない)、NSX-80 緑
- E15(P3): host_permissions 縮小後も popup が active-tab メタデータを読め、保存が 201
- E16(P3): ビルド済み(dev でない)Extension の保存が 201/401 を返す(CORS ネットワークエラーでない)。CORS エラーなら ext origin を server CORS 許可リストに追加
Testing Plan
| Layer |
What |
Count |
| Unit(node) |
mint/list/revoke, PAT accept/reject, U5/U6/U7/U8, lastUsedAt |
+8 |
| Component(web) |
show-once, masked list, 5xx で panel 維持 |
+3 |
| Component(ext) |
paste UI, connected, 401→reconnect, Bearer 添付 |
+4 |
| E2E |
貼付→保存成功、revoke→reconnect |
+2 |
詳細: eng-review-test-plan-20260528-104950.md(U1–U8/W1–W3/X1–X4/E2E1–E2E2、DAMP・AAA・behavior 命名)
Rollback Plan
PR revert で PAT コード除去。マイグレーションは additive(新テーブルのみ)なので
既存データ影響なし。personal_access_tokens テーブルは drop 可能。
Effort Estimate
schema+migration ~2h + server auth/routes ~4h + web UI ~3h + ext ~3h + tests ~4h = 約1.5〜2日
Files Reference
| File |
Change |
prisma/schema.prisma:10-26 |
User.personalAccessTokens[] 追加(E12) |
prisma/schema.prisma:84-96 付近 |
PersonalAccessToken モデル追加(E7) |
server/lib/authSession.ts:48-50 |
hashRefreshToken→汎用 hashToken 改名・再利用 |
server/lib/authSession.ts (新規分岐) |
authenticateStockRequest(E1/E4/E6/E14) |
server/routes/ (新規 personalAccessToken.ts) |
mint/list/revoke。server/api.ts で router.use |
server/routes/stock.ts:105,176 |
ミドルウェア差替(E8)、PAT 受理(E9) |
server/lib/Logger.ts:12 |
redaction キー拡張(E5: rawToken/pat/personalAccessToken/uppercase Authorization) |
src/pages/Dashboard/Setting/ |
Extension Token UI(E13) |
browser-extension/.env.development, .env.production |
VITE_API_URL→VITE_API_ENDPOINT、値を origin に(E3) |
browser-extension/src/env.d.ts:4 |
型キー同期(E3) |
browser-extension/src/entrypoints/popup/App.tsx:62,139,180 |
Bearer 添付・貼付 UI・raw token 非ログ |
browser-extension/wxt.config.ts |
host_permissions 縮小(E15, P3) |
browser-extension/playwright.config.ts |
テスト DB マイグレーション(E11) |
Out of Scope
- in-extension password ログイン、
/api/login 変更、SameSite 緩和、chrome.cookies 権限
- OAuth/SSO、マルチユーザー、PAT 自動ローテーション/複数スコープ
Related
Context
Extension は今 nsx にログインしていないとページ登録に失敗する(401)。原因は popup の
保存/重複チェックリクエストに
Authorizationヘッダもトークンも無いこと(server/auth.tsisAuthorized が 401)。解決策 = Option D(スコープ付き PAT): オーナーが Web 設定画面で
トークンを1回発行 → Extension に貼付 → 保存時のみ
Authorization: Bearer nsx_pat_<token>を送る。Cookie ログインの仕組みには一切触らない。PR1(テスト配線・マイグレーション)
マージ後に着手。
Current State (verified 2026-05-28)
browser-extension/src/entrypoints/popup/App.tsxの保存/重複チェックが認証ヘッダ無しserver/routes/stock.ts:105POST /push_stock・:176GET /stock/existsがisAuthorizedゲートserver/lib/authSession.ts:48-50hashRefreshToken=createHash('sha256').update(token).digest('hex')(PAT が再利用すべき方式)prisma/schema.prisma:84-96RefreshTokenモデル(PAT モデルの雛形)App.tsx:62,139はimport.meta.env.VITE_API_ENDPOINTを読むが、.env.development/.env.productionが定義するのは別キーVITE_API_URL。よってVITE_API_ENDPOINTは常にundefined→ 常にDEFAULT_API_ENDPOINT(http://localhost:4000/api/,constants.ts:2)にフォールバック。本番ビルドの Extension が localhost を叩く。加えて env 値が
.../api/push_stockフルパス。buildPushStockApiUrlは base を期待し自前で
/api/push_stockを付与(重複ガード有り)するため、フルパスを渡すと二重付与で 404。Proposed Change
Server
PersonalAccessTokenモデル + 逆リレーション(下記 Implementation Details の Prisma ブロック)isAuthorizedのまま。下記 API 仕様)authenticateStockRequest: cookie 認証 → 失敗で PAT 認証、PAT 失敗時は絶対に
clearAuthCookiesを呼ばない(confused-deputy 防止)SESSION_USER_SELECTを使いreq.authenticatedUserを全行ハイドレート(部分{id}不可)findFirst({ where: { tokenHash, revokedAt: null, OR: [{expiresAt: null}, {expiresAt: {gt: now}}] } })単一クエリAuthorization→ 401(500 でない)push_stockのミドルウェアをisAuthorized→authenticateStockRequestに差替(validateBodyは2番目のまま)/push_stock+/stock/existsのみで受理(stocklist/deleteは cookie のまま)App.tsx:180のサニタイズ +Logger.ts:12の redaction キー拡張)Web (
src/pages/Dashboard/Setting/)Generate → show-once 生トークンパネル + copy-to-clipboard(SnackBar 不使用: 大文字化・5s 自動消滅・閉じるボタン未配線)、
一覧は masked
nsx_pat_…<last4>、各行に revoke ボタンExtension (
browser-extension/)chrome.storage.local)、connected 状態、401 → reconnect プロンプトhost_permissionsを<all_urls>から NSX API origin + active-tab に縮小Implementation Details (concrete — leaves zero design decisions)
PAT Prisma model (
prisma/schema.prisma, mirrors RefreshToken:84-96)そして
UserにpersonalAccessTokens PersonalAccessToken[]をrefreshTokensの隣へ追加(E12, Prisma 検証に必須)。API (cookie-session gated via existing
isAuthorized; mounted under/api)/api/personal_access_token(mint){ "name": string }{ id, name, token: "nsx_pat_<64hex>", tokenSuffix, createdAt }—tokenはこの応答1回のみ/api/personal_access_token/list{ tokens: [{ id, name, tokenSuffix, createdAt, lastUsedAt, revokedAt }] }—token/tokenHashを絶対に含めない/api/personal_access_token/:id(revoke){ id, revokedAt }(revokedAtをセット)nsx_pat_+randomBytes(32).toString('hex')(64 hex, 256-bit)。保存はhashToken(raw)の sha256 hex のみ。tokenSuffix= raw 末尾4文字。hashRefreshToken(authSession.ts:48-50)を汎用hashTokenにリネームし mint/検証で共用(DRY、2つ目のハッシュ関数を作らない)。E3 env fix (verified root cause above)
VITE_API_URLをVITE_API_ENDPOINTにリネーム(またはApp.tsx側をVITE_API_URL読み取りに変更)。src/env.d.ts:4の型も同期。buildPushStockApiUrlが/api/push_stockを付与する):.env.development:VITE_API_ENDPOINT=http://localhost:4000.env.production:VITE_API_ENDPOINT=https://nsx.malloc.tokyo.env.productionを読むこと(build mode 配線)を確認し、NSX-80を unskip。E11 Playwright test-DB migration
browser-extension/playwright.config.tsのglobalSetupで、pnpm server:startの前にテスト用
DATABASE_URLに対してpnpm prisma migrate deployを実行し、personal_access_tokensテーブルを作る。
Acceptance Criteria
Set-Cookieクリアを発行しない(U5 回帰ガード)Authorizationヘッダ無しのリクエストは従来通り cookie 認証で動作(U7 回帰ガード)tokenSuffixのみ)/stock/existsも PAT を受理(popup の on-open 重複チェックが connected に到達)prisma validate通過、tsc通過、pnpm lintwarning 0https://nsx.malloc.tokyo/api/push_stockを叩く(localhost でない)、NSX-80 緑Testing Plan
詳細:
eng-review-test-plan-20260528-104950.md(U1–U8/W1–W3/X1–X4/E2E1–E2E2、DAMP・AAA・behavior 命名)Rollback Plan
PR revert で PAT コード除去。マイグレーションは additive(新テーブルのみ)なので
既存データ影響なし。
personal_access_tokensテーブルは drop 可能。Effort Estimate
schema+migration ~2h + server auth/routes ~4h + web UI ~3h + ext ~3h + tests ~4h = 約1.5〜2日
Files Reference
prisma/schema.prisma:10-26User.personalAccessTokens[]追加(E12)prisma/schema.prisma:84-96付近PersonalAccessTokenモデル追加(E7)server/lib/authSession.ts:48-50hashRefreshToken→汎用hashToken改名・再利用server/lib/authSession.ts(新規分岐)authenticateStockRequest(E1/E4/E6/E14)server/routes/(新規personalAccessToken.ts)server/api.tsでrouter.useserver/routes/stock.ts:105,176server/lib/Logger.ts:12src/pages/Dashboard/Setting/browser-extension/.env.development,.env.productionVITE_API_URL→VITE_API_ENDPOINT、値を origin に(E3)browser-extension/src/env.d.ts:4browser-extension/src/entrypoints/popup/App.tsx:62,139,180browser-extension/wxt.config.tsbrowser-extension/playwright.config.tsOut of Scope
/api/login変更、SameSite緩和、chrome.cookies権限Related
~/.gstack/projects/laststance-nsx/extension-login-flow-plan.mdtasks-eng-review-20260528-104950.jsonl