Skip to content

Phase 1 收缩 + Phase 2 后端可用(重放到 main) - #112

Merged
kunish merged 4 commits into
mainfrom
feat/phase1-2-replay
Jul 8, 2026
Merged

Phase 1 收缩 + Phase 2 后端可用(重放到 main)#112
kunish merged 4 commits into
mainfrom
feat/phase1-2-replay

Conversation

@kunish

@kunish kunish commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

概要

Phase 1 产品收缩 + Phase 2 后端可用 重放到 main。两阶段均经 subagent 逐任务实现 + 独立审查,pnpm check 全绿,并在本地 workerd(flue dev --target cloudflare)上跑通完整 web 核心流程冒烟。

Spec:docs/superpowers/specs/2026-07-07-product-refocus-design.md(R1–R9)

Phase 1 — 收缩(净 −44k 行)

  • 后端:删爬虫域(crawl-jobs/guides/quality-reports/training-datasets/crawler-fetch)+ 社区壳(comments/likes/favorites/collections/notifications/push-tokens/qa/travel-notes)+ translations + 501 stub;schema 48→24 张表;重生成干净 D1 baseline
  • Dashboard:删爬虫代理链、孤儿页面(jobs/guides/datasets/pois)、坏的 PDF/忘记密码按钮
  • iOS:删发现/攻略/百科死代码树、社区壳、保险、死协作/翻译模块、手机验证码/微信登录;清聚合器文案

Phase 2 — 后端可用(靶向:后端自洽 + Web 端到端)

  • POST /api/auth/refresh(session 重签,refreshToken 用 jose 签名 JWT 而非可枚举 session id — 修复了账号接管漏洞)
  • AI 面(agent/chat)鉴权收口 + 去 agent 双挂载;agent plan 状态从模块级 Map → D1 持久化(唯一 upsert,防竞态)
  • currency /rates 接 frankfurter.app 写入方(修「永远返回 null」)
  • Dashboard:/api/:path* catch-all rewrite;客户端 (dashboard)/layout.tsx 集中鉴权守卫;登录收敛到 /
  • R4 验证闸:dev 自动建表(predev)+ 冒烟脚本 + 端点 contract 测试(12 例,已验证能抓路由回归)

iOS 旧契约({success,data} 信封、嵌套 budget 路径、/v1/*)按 R9 归入 Phase 3。

与并行 iOS-chat 工作线的关系

本分支基于 main(含 CF Workers 迁移)重放。并行线的 iOS chat 契约对齐(/sessions/:id/messages 等)与本分支无功能冲突(Phase 1 保留 chat;Phase 2 只给 chat/query 加鉴权)。合并冲突(若有)在本 PR 内解决——请两边 review。

验证

  • pnpm check 全绿(typecheck + lint + api/dashboard/D1 集成/contract 测试)
  • 本地 flue dev 启动 + pnpm smoke 端到端绿:注册→登录→建行程→加天→加 POI→列表→分账

View with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is enabled.

kunish added 3 commits July 8, 2026 19:21
Phase 1: remove crawler/community/dead-feature surface. Phase 2: auth refresh, AI-surface auth, agent plan D1 persistence, currency writer, dashboard catch-all rewrite + layout guard + login consolidation, R4 contract/smoke.

main's redone CF migration is byte-identical on all Phase-2 touched files, so features applied cleanly. Conflicts: logger (kept main's console fix), wrangler (kept real database_id + version note), lockfile (regen), main iOS-chat docs (prettier fix).

pnpm check green.
@kunish

kunish commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

@greptile-apps

@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown

Greptile Summary

This PR cherry-picks Phase 1 (product contraction, −44k lines: removes crawler/social/community domains, shrinks schema from 48→24 tables) and Phase 2 (targeted backend hardening: signed refresh tokens, agent plan D1 persistence, currency rate fetching, dashboard auth guard and catch-all proxy rewrite) onto main.

  • Phase 1: Deletes crawl-jobs, guides, comments, likes, favorites, collections, notifications, push-tokens, qa, travel-notes routes/schemas/iOS code trees; regenerates a clean D1 baseline migration.
  • Phase 2: Adds POST /api/auth/refresh with signed JWT refresh tokens (fixes enumerable-session-ID account takeover); moves AI plan state from a module-level Map to D1 with per-user ownership guards and upsert; fixes the currency /rates endpoint (always returned null) by fetching from frankfurter.app on stale cache; centralizes dashboard auth guard in the layout and replaces five specific rewrites with a single /api/:path* catch-all.
  • R4 gate: Adds 12 contract tests that assert key routes are mounted (401/400/403 ≠ 404) to catch future accidental deletions.

Confidence Score: 3/5

The core auth hardening (non-enumerable refresh tokens) is correct, but refresh token rotation does not actually revoke old tokens, leaving a window where a captured refresh token stays usable for up to 30 days after the legitimate holder has already rotated it.

Most of the changes — route contraction, agent D1 persistence with user-scoped ownership, currency rate fetching, contract tests, dashboard auth centralization — are well-implemented. The auth service refresh flow fixes the enumerable-session-ID vulnerability as described, but the rotation mechanism it introduces does not follow through: old refresh tokens remain valid because no JTI tracking or session replacement occurs on use. The advertised security property is partially undelivered, warranting attention before merging into main on an active product.

packages/api/src/services/auth.service.ts (refreshSession rotation gap), packages/api/src/routes/auth.ts (POST /refresh endpoint depends on it)

Security Review

  • Refresh token rotation without revocation (packages/api/src/services/auth.service.ts): refreshSession rotates to a new signed JWT but does not invalidate the old token. Both tokens share the same sid; since isSessionValid only checks session existence rather than tracking individual token JTIs, a captured refresh token can be reused indefinitely until it expires naturally (30 days). The enumerable-session-ID account-takeover vector is correctly fixed, but the rotation guarantee is not enforced.
  • No other new injection, credential-exposure, or authorization bypass issues were found in the changed files.

Important Files Changed

Filename Overview
packages/api/src/services/auth.service.ts Adds refreshSession + generateRefreshToken (signed JWT instead of enumerable session ID — correct fix). However token rotation does not revoke old tokens, leaving captured refresh tokens reusable until JWT expiry.
packages/api/src/routes/auth.ts Adds POST /refresh endpoint; signin/social responses now include refreshToken. Zod-validated, proper 401 mapping. No independent issues beyond the rotation gap in auth.service.ts.
packages/api/src/routes/agent.ts Replaces module-level Map with D1-backed upsert (aiPlanDrafts), adds authRequired() for all routes, adds userId ownership scoping on every plan read/write — well-executed.
packages/api/src/routes/currency.ts Fixes the 'always returns null' bug by fetching from frankfurter.app on stale cache; adds Zod query validation, manual serializeRate to avoid key-mangling by convertKeysToSnakeCase, and graceful fallback.
packages/api/src/routes/contract.test.ts New R4 gate: 12 contract tests verify routes are mounted by checking pre-handler middleware statuses (401/400/403 ≠ 404). Solid regression guard for route removals.
apps/dashboard/src/app/(dashboard)/layout.tsx Converted to client component to centralize auth guard; uses useAuth + useEffect redirect. Children are still server-rendered as RSC via the children prop.
packages/database/src/schema/ai-plans.ts New ai_plan_drafts table with (session_id, user_id) unique index enabling single-query upsert in savePlan. Migration SQL matches schema definition.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant Auth as POST /api/auth/refresh
    participant AS as auth.service
    participant DB as D1 (authSessions)

    Client->>Auth: "{ refreshToken: signedJWT }"
    Auth->>AS: refreshSession(db, refreshToken, secret)
    AS->>AS: verifyToken(refreshToken) → payload
    AS->>AS: "check payload.typ === 'refresh'"
    AS->>DB: isSessionValid(payload.sid)
    DB-->>AS: true
    AS->>DB: SELECT user by payload.sub
    DB-->>AS: user row
    AS->>AS: generateToken(userId, email, secret, sid)
    AS->>AS: generateRefreshToken(userId, email, secret, sid)
    note over AS,DB: old refreshToken NOT revoked (same sid still valid)
    AS-->>Auth: "{ token, refreshToken rotated }"
    Auth-->>Client: "200 { token, refreshToken, userId, email }"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client
    participant Auth as POST /api/auth/refresh
    participant AS as auth.service
    participant DB as D1 (authSessions)

    Client->>Auth: "{ refreshToken: signedJWT }"
    Auth->>AS: refreshSession(db, refreshToken, secret)
    AS->>AS: verifyToken(refreshToken) → payload
    AS->>AS: "check payload.typ === 'refresh'"
    AS->>DB: isSessionValid(payload.sid)
    DB-->>AS: true
    AS->>DB: SELECT user by payload.sub
    DB-->>AS: user row
    AS->>AS: generateToken(userId, email, secret, sid)
    AS->>AS: generateRefreshToken(userId, email, secret, sid)
    note over AS,DB: old refreshToken NOT revoked (same sid still valid)
    AS-->>Auth: "{ token, refreshToken rotated }"
    Auth-->>Client: "200 { token, refreshToken, userId, email }"
Loading

Reviews (1): Last reviewed commit: "chore: replay Phase 1 contraction + Phas..." | Re-trigger Greptile

Comment on lines +195 to +200
const userId = String(user.id);
const email = user.email ?? '';
const token = await generateToken(userId, email, secret, payload.sid);
const rotatedRefreshToken = await generateRefreshToken(userId, email, secret, payload.sid);

return { token, refreshToken: rotatedRefreshToken, userId, email };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Refresh token rotation doesn't revoke the old token

refreshSession mints a rotated refresh token but does not invalidate the previous one. Both the old and the new token share the same sid, and isSessionValid only checks whether that session row exists — it never records or expires the individual JWT. So a captured refresh token can be called against /refresh indefinitely (until the 30-day JWT expiry) even after the legitimate holder has already rotated it. Token rotation's security property — making a previously captured token immediately unusable — is therefore not delivered here.

To fix properly, either (a) store a jti per issued refresh token and reject reused JTIs, or (b) delete the old authSessions row and create a new one on every /refresh call, so any JWT carrying the old sid fails isSessionValid.

refreshSession re-minted tokens bound to the same sid, and isSessionValid
only checks session existence, so a captured refresh token stayed replayable
for its full 30-day JWT lifetime even after the legitimate holder rotated it.

Rotate the server-side session on refresh: mint a fresh sid via createSession,
then delete the old one. Because both the old refresh token and the access
token minted alongside it carry the now-deleted sid, they are rejected by
isSessionValid on any subsequent use.
@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown

Too many files changed for review. (289 files found, 100 file limit)

Bypass the limit by tagging @greptile-apps to review.

@kunish
kunish merged commit bef1ff4 into main Jul 8, 2026
3 checks passed
@kunish
kunish deleted the feat/phase1-2-replay branch July 8, 2026 15:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant