# OAuth2 模块化功能实施计划 > **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:** 实现基于 GitHub 的 OAuth2 登录模块,支持新用户 OAuth 登录和已注册用户账号绑定,采用配置驱动架构设计。 **Architecture:** 采用策略模式实现 OAuth Provider 接口,核心模块 OAuthManager 统一调度,StateStore 管理 CSRF 令牌防止攻击,集成到现有认证系统。 **Tech Stack:** Nuxt 4 / Nitro API / TypeScript / SQLite / Drizzle ORM --- ## 文件结构 ``` packages/oauth/ ├── src/ │ ├── index.ts # 模块入口 │ ├── core/ │ │ ├── oauth-manager.ts # 核心管理器 │ │ ├── oauth-state-store.ts # State 存储 │ │ └── oauth-error.ts # 错误类 │ ├── providers/ │ │ ├── base.ts # Provider 接口 │ │ ├── github.ts # GitHub Provider │ │ └── index.ts # Provider 导出 │ ├── db/ │ │ ├── schema.ts # oauth_accounts 表定义 │ │ └── queries.ts # 数据库操作 │ └── api/ │ ├── authorize.get.ts # 授权接口 │ ├── callback.get.ts # 回调接口 │ ├── bind.post.ts # 绑定接口 │ ├── unbind.delete.ts # 解绑接口 │ ├── bindings.get.ts # 绑定列表接口 │ └── status.get.ts # 绑定状态接口 ├── package.json └── tsconfig.json ``` --- ## Task 1: 创建 oauth 包基础结构 **Files:** - Create: `packages/oauth/package.json` - Create: `packages/oauth/tsconfig.json` - Create: `packages/oauth/src/index.ts` - [ ] **Step 1: 创建目录结构** ```bash mkdir -p packages/oauth/src/{core,providers,db,api} ``` - [ ] **Step 2: 创建 package.json** ```json { "name": "oauth", "version": "0.1.0", "type": "module", "exports": { ".": "./src/index.ts" }, "dependencies": { "zod": "4.3.6" }, "devDependencies": { "@types/node": "20.0.0" } } ``` - [ ] **Step 3: 创建 tsconfig.json** ```json { "extends": "tsconfig/tsconfig.json" } ``` - [ ] **Step 4: 创建 src/index.ts** ```typescript export { OAuthManager } from './core/oauth-manager'; export { OAuthStateStore } from './core/oauth-state-store'; export { OAuthError, OAuthErrorCode } from './core/oauth-error'; export type { OAuthProvider, OAuthUserInfo } from './providers/base'; export type { OAuthBinding, OAuthCallbackResult } from './core/oauth-manager'; ``` - [ ] **Step 5: 在根目录 package.json 添加 workspace 依赖** 在根目录 `package.json` 的 `scripts.postinstall` 之前添加: ```json "install:oauth": "bun add -w oauth", ``` 然后运行 `bun install` 安装依赖。 - [ ] **Step 6: Commit** ```bash git add packages/oauth/ git commit -m "feat(oauth): 创建 oauth 包基础结构" ``` --- ## Task 2: 实现 OAuth 错误类 **Files:** - Create: `packages/oauth/src/core/oauth-error.ts` - [ ] **Step 1: 创建 oauth-error.ts** ```typescript export const OAuthErrorCodes = { PROVIDER_NOT_FOUND: 'OAUTH_PROVIDER_NOT_FOUND', STATE_INVALID: 'OAUTH_STATE_INVALID', STATE_EXPIRED: 'OAUTH_STATE_EXPIRED', TOKEN_EXCHANGE_FAILED: 'OAUTH_TOKEN_EXCHANGE_FAILED', USER_INFO_FAILED: 'OAUTH_USER_INFO_FAILED', ALREADY_BIND: 'OAUTH_ALREADY_BIND', BINDING_USER_MISMATCH: 'OAUTH_BINDING_USER_MISMATCH', BINDING_TOKEN_INVALID: 'OAUTH_BINDING_TOKEN_INVALID', LAST_BIND: 'OAUTH_LAST_BIND', } as const; export type OAuthErrorCode = typeof OAuthErrorCodes[keyof typeof OAuthErrorCodes]; export class OAuthError extends Error { constructor( public code: OAuthErrorCode, message: string, public statusCode: number = 400 ) { super(message); this.name = 'OAuthError'; } } ``` - [ ] **Step 2: Commit** ```bash git add packages/oauth/src/core/oauth-error.ts git commit -m "feat(oauth): 添加 OAuth 错误类" ``` --- ## Task 3: 实现 State 存储管理 **Files:** - Create: `packages/oauth/src/core/oauth-state-store.ts` - [ ] **Step 1: 创建 oauth-state-store.ts** ```typescript import { randomUUID } from 'crypto'; export type OAuthState = { providerName: string; redirectUri: string; createdAt: number; userId?: number; isBinding: boolean; }; const STATE_TTL_MS = 5 * 60 * 1000; // 5 minutes export class OAuthStateStore { private store = new Map(); // 定期清理过期 state private cleanupInterval: NodeJS.Timeout | null = null; constructor() { this.startCleanup(); } generate( providerName: string, redirectUri: string, userId?: number ): { state: string; oauthState: OAuthState } { const state = randomUUID(); const oauthState: OAuthState = { providerName, redirectUri, createdAt: Date.now(), userId, isBinding: userId !== undefined, }; this.store.set(state, oauthState); return { state, oauthState }; } validate(state: string): OAuthState | null { const oauthState = this.store.get(state); if (!oauthState) { return null; } if (Date.now() - oauthState.createdAt > STATE_TTL_MS) { this.store.delete(state); return null; } return oauthState; } consume(state: string): OAuthState | null { const oauthState = this.validate(state); if (oauthState) { this.store.delete(state); } return oauthState; } private startCleanup() { // 每分钟清理一次过期 state this.cleanupInterval = setInterval(() => { const now = Date.now(); for (const [state, data] of this.store.entries()) { if (now - data.createdAt > STATE_TTL_MS) { this.store.delete(state); } } }, 60_000); } destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); } } } export const oauthStateStore = new OAuthStateStore(); ``` - [ ] **Step 2: Commit** ```bash git add packages/oauth/src/core/oauth-state-store.ts git commit -m "feat(oauth): 实现 State 存储管理" ``` --- ## Task 4: 实现 GitHub Provider **Files:** - Create: `packages/oauth/src/providers/base.ts` - Create: `packages/oauth/src/providers/github.ts` - Create: `packages/oauth/src/providers/index.ts` - [ ] **Step 1: 创建 providers/base.ts** ```typescript export interface OAuthProvider { name: string; icon: string; clientId: string; clientSecret: string; authorizeUrl: string; tokenUrl: string; userInfoUrl: string; scopes: string[]; getAuthorizationUrl(code: string, state: string): string; mapUserInfo(raw: Record): OAuthUserInfo; } export type OAuthUserInfo = { provider: string; providerUserId: string; username?: string; email?: string; avatar?: string; }; ``` - [ ] **Step 2: 创建 providers/github.ts** ```typescript import type { OAuthProvider, OAuthUserInfo } from './base'; export class GitHubProvider implements OAuthProvider { name = 'github'; icon = 'github'; clientId: string; clientSecret: string; authorizeUrl = 'https://github.com/login/oauth/authorize'; tokenUrl = 'https://github.com/login/oauth/access_token'; userInfoUrl = 'https://api.github.com/user'; scopes = ['read:user', 'user:email']; constructor(config: { clientId: string; clientSecret: string }) { this.clientId = config.clientId; this.clientSecret = config.clientSecret; } getAuthorizationUrl(code: string, state: string): string { const params = new URLSearchParams({ client_id: this.clientId, redirect_uri: `${process.env.APP_URL}/api/auth/oauth/github/callback`, scope: this.scopes.join(' '), state, }); return `${this.authorizeUrl}?${params.toString()}`; } mapUserInfo(raw: Record): OAuthUserInfo { return { provider: this.name, providerUserId: String(raw.id), username: String(raw.login ?? ''), email: String(raw.email ?? ''), avatar: String(raw.avatar_url ?? ''), }; } } ``` - [ ] **Step 3: 创建 providers/index.ts** ```typescript import { GitHubProvider } from './github'; export type { OAuthProvider, OAuthUserInfo } from './base'; export const oauthProviders = { github: new GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), }; export function getProvider(name: string): OAuthProvider | undefined { return (oauthProviders as Record)[name]; } ``` - [ ] **Step 4: Commit** ```bash git add packages/oauth/src/providers/ git commit -m "feat(oauth): 实现 GitHub Provider" ``` --- ## Task 5: 实现数据库 Schema 和查询 **Files:** - Create: `packages/oauth/src/db/schema.ts` - Create: `packages/oauth/src/db/queries.ts` - [ ] **Step 1: 创建 db/schema.ts** ```typescript import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; export const oauthAccounts = sqliteTable('oauth_accounts', { id: integer('id').primaryKey({ autoIncrement: true }), userId: integer('user_id').notNull(), provider: text('provider').notNull(), providerUserId: text('provider_user_id').notNull(), username: text('username'), email: text('email'), avatar: text('avatar'), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }); export type OAuthAccount = typeof oauthAccounts.$inferSelect; export type NewOAuthAccount = typeof oauthAccounts.$inferInsert; ``` **注意:** 需要在 drizzle-pkg 中添加 `oauth_accounts` 表的定义,因为 drizzle-pkg 管理数据库 schema。 - [ ] **Step 2: 创建 db/queries.ts** ```typescript import { dbGlobal } from 'drizzle-pkg/lib/db'; import { oauthAccounts } from './schema'; import { eq, and } from 'drizzle-orm'; import type { NewOAuthAccount } from './schema'; import type { OAuthUserInfo } from '../providers/base'; export async function createOAuthAccount(data: { userId: number; provider: string; providerUserId: string; username?: string; email?: string; avatar?: string; }): Promise { const now = new Date(); const [account] = await dbGlobal .insert(oauthAccounts) .values({ userId: data.userId, provider: data.provider, providerUserId: data.providerUserId, username: data.username, email: data.email, avatar: data.avatar, createdAt: now, updatedAt: now, }) .returning(); return account; } export async function findOAuthAccount( provider: string, providerUserId: string ): Promise { const [account] = await dbGlobal .select() .from(oauthAccounts) .where(and( eq(oauthAccounts.provider, provider), eq(oauthAccounts.providerUserId, providerUserId) )); return account ?? null; } export async function findUserOAuthAccounts(userId: number): Promise { return dbGlobal .select() .from(oauthAccounts) .where(eq(oauthAccounts.userId, userId)); } export async function deleteOAuthAccount( userId: number, provider: string ): Promise { const result = await dbGlobal .delete(oauthAccounts) .where(and( eq(oauthAccounts.userId, userId), eq(oauthAccounts.provider, provider) )); return result.rowCount > 0; } ``` - [ ] **Step 3: 在 drizzle-pkg 添加 oauth_accounts 表** 需要修改 `packages/drizzle-pkg/lib/schema/auth.ts` 添加 oauth_accounts 表定义。 在 `sessions` 表定义后添加: ```typescript export const oauthAccounts = sqliteTable('oauth_accounts', { id: integer('id').primaryKey({ autoIncrement: true }), userId: integer('user_id').notNull(), provider: text('provider').notNull(), providerUserId: text('provider_user_id').notNull(), username: text('username'), email: text('email'), avatar: text('avatar'), createdAt: integer('created_at').default(sql`(cast((julianday('now') - 2440587.5)*86400000 as integer))`), updatedAt: integer('updated_at').default(sql`(cast((julianday('now') - 2440587.5)*86400000 as integer))`), }); ``` 然后生成数据库迁移 SQL。 - [ ] **Step 4: Commit** ```bash git add packages/oauth/src/db/ git add packages/drizzle-pkg/lib/schema/auth.ts git commit -m "feat(oauth): 添加 oauth_accounts 表和查询" ``` --- ## Task 6: 实现 OAuthManager 核心类 **Files:** - Create: `packages/oauth/src/core/oauth-manager.ts` - [ ] **Step 1: 创建 core/oauth-manager.ts** ```typescript import type { OAuthProvider, OAuthUserInfo } from '../providers/base'; import { getProvider } from '../providers'; import { OAuthStateStore, type OAuthState } from './oauth-state-store'; import { OAuthError, OAuthErrorCodes } from './oauth-error'; import { createOAuthAccount, findOAuthAccount, findUserOAuthAccounts, deleteOAuthAccount, } from '../db/queries'; import { registerUser } from 'auth'; import { createSession } from 'auth'; import { MinimalUser } from 'auth'; export type OAuthBinding = { id: number; provider: string; username: string | null; email: string | null; avatar: string | null; boundAt: Date; }; export type OAuthCallbackResult = { success: boolean; isNewUser: boolean; user?: MinimalUser; requiresBinding: boolean; bindingToken?: string; sessionId?: string; expiresAt?: Date; }; export class OAuthManager { private stateStore: OAuthStateStore; constructor() { this.stateStore = new OAuthStateStore(); } getAuthorizationUrl(providerName: string, userId?: number): string { const provider = getProvider(providerName); if (!provider) { throw new OAuthError( OAuthErrorCodes.PROVIDER_NOT_FOUND, `Provider ${providerName} not found` ); } const redirectUri = userId ? `/profile?bind_success=1` : `/auth/login?oauth_success=1`; const { state } = this.stateStore.generate(providerName, redirectUri, userId); return provider.getAuthorizationUrl('', state); } async handleCallback( providerName: string, code: string, state: string ): Promise { const provider = getProvider(providerName); if (!provider) { throw new OAuthError( OAuthErrorCodes.PROVIDER_NOT_FOUND, `Provider ${providerName} not found` ); } const oauthState = this.stateStore.consume(state); if (!oauthState) { throw new OAuthError( OAuthErrorCodes.STATE_INVALID, 'Invalid or expired state' ); } if (oauthState.providerName !== providerName) { throw new OAuthError( OAuthErrorCodes.STATE_INVALID, 'State provider mismatch' ); } // 交换 token const tokenResponse = await this.exchangeToken(provider, code); if (!tokenResponse.access_token) { throw new OAuthError( OAuthErrorCodes.TOKEN_EXCHANGE_FAILED, 'Failed to exchange token' ); } // 获取用户信息 const userInfo = await this.getUserInfo(provider, tokenResponse.access_token); if (!userInfo) { throw new OAuthError( OAuthErrorCodes.USER_INFO_FAILED, 'Failed to get user info' ); } // 查找是否已绑定 const existingAccount = await findOAuthAccount(providerName, userInfo.providerUserId); if (oauthState.isBinding) { // 绑定模式:关联到已登录用户 if (existingAccount && existingAccount.userId !== oauthState.userId) { throw new OAuthError( OAuthErrorCodes.ALREADY_BIND, 'This OAuth account is already bound to another user' ); } await createOAuthAccount({ userId: oauthState.userId!, provider: providerName, providerUserId: userInfo.providerUserId, username: userInfo.username, email: userInfo.email, avatar: userInfo.avatar, }); return { success: true, isNewUser: false, requiresBinding: false, }; } // 登录模式 if (existingAccount) { // 已存在用户,创建 session const { sessionId, expiresAt } = await createSession(existingAccount.userId); return { success: true, isNewUser: false, requiresBinding: false, sessionId, expiresAt, }; } // 新用户:自动注册 const newUser = await registerUser({ username: userInfo.username || `oauth_${providerName}_${userInfo.providerUserId}`, password: '', // OAuth 用户不需要密码 } as any); await createOAuthAccount({ userId: newUser.id, provider: providerName, providerUserId: userInfo.providerUserId, username: userInfo.username, email: userInfo.email, avatar: userInfo.avatar, }); const { sessionId, expiresAt } = await createSession(newUser.id); return { success: true, isNewUser: true, user: newUser, requiresBinding: false, sessionId, expiresAt, }; } async bindAccount( userId: number, providerName: string, userInfo: OAuthUserInfo ): Promise { const existingAccount = await findOAuthAccount(providerName, userInfo.providerUserId); if (existingAccount && existingAccount.userId !== userId) { throw new OAuthError( OAuthErrorCodes.ALREADY_BIND, 'This OAuth account is already bound to another user' ); } if (existingAccount) { return; // 已绑定,跳过 } await createOAuthAccount({ userId, provider: providerName, providerUserId: userInfo.providerUserId, username: userInfo.username, email: userInfo.email, avatar: userInfo.avatar, }); } async unbindAccount(userId: number, providerName: string): Promise { const deleted = await deleteOAuthAccount(userId, providerName); if (!deleted) { throw new OAuthError( OAuthErrorCodes.PROVIDER_NOT_FOUND, 'OAuth account not found' ); } } async getUserBindings(userId: number): Promise { const accounts = await findUserOAuthAccounts(userId); return accounts.map((account) => ({ id: account.id, provider: account.provider, username: account.username, email: account.email, avatar: account.avatar, boundAt: account.createdAt, })); } private async exchangeToken( provider: OAuthProvider, code: string ): Promise<{ access_token?: string; token_type?: string; scope?: string }> { const response = await fetch(provider.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ client_id: provider.clientId, client_secret: provider.clientSecret, code, redirect_uri: `${process.env.APP_URL}/api/auth/oauth/${provider.name}/callback`, }), }); if (!response.ok) { throw new OAuthError( OAuthErrorCodes.TOKEN_EXCHANGE_FAILED, 'Failed to exchange token' ); } return response.json() as Promise<{ access_token?: string; token_type?: string; scope?: string; }>; } private async getUserInfo( provider: OAuthProvider, accessToken: string ): Promise { const response = await fetch(provider.userInfoUrl, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }, }); if (!response.ok) { return null; } const raw = await response.json() as Record; return provider.mapUserInfo(raw); } } export const oauthManager = new OAuthManager(); ``` - [ ] **Step 2: Commit** ```bash git add packages/oauth/src/core/oauth-manager.ts git commit -m "feat(oauth): 实现 OAuthManager 核心类" ``` --- ## Task 7: 实现 API 接口 **Files:** - Create: `packages/oauth/src/api/authorize.get.ts` - Create: `packages/oauth/src/api/callback.get.ts` - Create: `packages/oauth/src/api/bind.post.ts` - Create: `packages/oauth/src/api/unbind.delete.ts` - Create: `packages/oauth/src/api/bindings.get.ts` - Create: `packages/oauth/src/api/status.get.ts` - [ ] **Step 1: 创建 authorize.get.ts** ```typescript import { oauthManager } from 'oauth'; import { createAuthContext } from '#server/service/auth/context'; export default defineWrappedResponseHandler(async (event) => { const { getCurrent } = createAuthContext(event); const user = await getCurrent(); const providerName = getRouterParam(event, 'provider'); if (!providerName) { throw createError({ statusCode: 400, statusMessage: 'Provider is required', }); } const userId = user?.id; const authUrl = oauthManager.getAuthorizationUrl(providerName, userId); return sendRedirect(event, authUrl); }); ``` - [ ] **Step 2: 创建 callback.get.ts** ```typescript import { oauthManager } from 'oauth'; import { setSessionCookie } from '#server/service/auth/cookie'; export default defineWrappedResponseHandler(async (event) => { const providerName = getRouterParam(event, 'provider'); const query = getQuery(event); const { code, state } = query as { code?: string; state?: string }; if (!code || !state) { return sendRedirect(event, '/auth/login?oauth_error=missing_params'); } try { const result = await oauthManager.handleCallback(providerName!, code, state); if (result.sessionId) { setSessionCookie(event, result.sessionId); } const redirectUri = new URL( result.isNewUser ? '/auth/login?oauth_success=1' : '/auth/login?oauth_success=1' ); return sendRedirect(event, redirectUri.toString()); } catch (error) { const errorCode = error instanceof OAuthError ? error.code : 'OAUTH_UNKNOWN'; return sendRedirect(event, `/auth/login?oauth_error=${errorCode}`); } }); ``` - [ ] **Step 3: 创建 bindings.get.ts** ```typescript import { oauthManager } from 'oauth'; import { createAuthContext } from '#server/service/auth/context'; export default defineWrappedResponseHandler(async (event) => { const { requireUser } = createAuthContext(event); const user = await requireUser(); const bindings = await oauthManager.getUserBindings(user.id); return R.success({ bindings }); }); ``` - [ ] **Step 4: 创建 status.get.ts** ```typescript import { oauthManager } from 'oauth'; import { createAuthContext } from '#server/service/auth/context'; export default defineWrappedResponseHandler(async (event) => { const { requireUser } = createAuthContext(event); const user = await requireUser(); const providerName = getRouterParam(event, 'provider'); const bindings = await oauthManager.getUserBindings(user.id); const binding = bindings.find((b) => b.provider === providerName); return R.success({ bound: !!binding, binding: binding ? { id: binding.id, username: binding.username, } : undefined, }); }); ``` - [ ] **Step 5: 创建 unbind.delete.ts** ```typescript import { oauthManager } from 'oauth'; import { createAuthContext } from '#server/service/auth/context'; export default defineWrappedResponseHandler(async (event) => { const { requireUser } = createAuthContext(event); const user = await requireUser(); const providerName = getRouterParam(event, 'provider'); await oauthManager.unbindAccount(user.id, providerName!); return R.success({ success: true }); }); ``` - [ ] **Step 6: 创建 bind.post.ts** ```typescript import { oauthManager } from 'oauth'; import { createAuthContext } from '#server/service/auth/context'; export default defineWrappedResponseHandler(async (event) => { const { requireUser } = createAuthContext(event); const user = await requireUser(); const body = await readBody(event); const { bindingToken } = body; // bindingToken 在当前设计中暂不使用,回调时已直接绑定 return R.success({ success: true, binding: { provider: 'github', username: user.username, }, }); }); ``` - [ ] **Step 7: Commit** ```bash git add packages/oauth/src/api/ git commit -m "feat(oauth): 实现 API 接口" ``` --- ## Task 8: 前端集成 **Files:** - Modify: `pages/auth/login.vue` (在现有登录表单中添加 GitHub 登录按钮) - [ ] **Step 1: 添加 GitHub 登录按钮到登录页** 在现有登录表单的分割线区域添加: ```vue
``` ```typescript function loginWithGithub() { window.location.href = '/api/auth/oauth/github/authorize'; } onMounted(() => { const url = new URL(window.location.href); if (url.searchParams.get('oauth_success') === '1') { refreshSession(); router.push('/'); } if (url.searchParams.get('oauth_error')) { const error = url.searchParams.get('oauth_error'); console.error('OAuth error:', error); } }); ``` - [ ] **Step 2: Commit** ```bash git add pages/auth/login.vue git commit -m "feat(oauth): 集成 GitHub 登录按钮到登录页" ``` --- ## Task 9: 配置文档 **Files:** - Create: `docs/superpowers/oauth2/FLOW.md` - Modify: `docs/superpowers/oauth2/README.md` - [ ] **Step 1: 创建 FLOW.md** 补充流程图详解文档,包含: - 新用户 OAuth 登录完整时序图 - 已注册用户绑定流程时序图 - state 生命周期管理说明 - 错误处理流程 - [ ] **Step 2: Commit** ```bash git add docs/superpowers/oauth2/ git commit -m "docs(oauth): 补充流程图文档" ``` --- ## 自检清单 完成实现后,对照设计文档检查: - [ ] 6 个 API 接口全部实现 - [ ] GitHub Provider 配置正确 - [ ] State 存储 + 5min TTL 过期清理 - [ ] oauth_accounts 表创建成功 - [ ] 新用户自动注册 + 创建 session - [ ] 已登录用户绑定第三方账号 - [ ] 解绑、绑定列表、绑定状态接口 - [ ] 前端登录页集成 GitHub 按钮 - [ ] 错误处理覆盖所有错误码 - [ ] 文档齐全(README、API、PROVIDERS、FLOW) --- ## 执行选项 **Plan complete and saved to `docs/superpowers/plans/2026-05-26-oauth2-implementation-plan.md`. Two execution options:** **1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration **2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints **Which approach?**