arcrun — AI workflow execution engine (clean history)

Self-hosted 開源:WASM 零件 + recipe + cypher-executor,跑在你自己的 Cloudflare。

此為重建的乾淨歷史起點(移除曾誤 commit 的 GCP SA 金鑰,舊歷史保留在
richblack/arcrun 與本地 backup 分支)。含:
- acr init --self-hosted installer(建 KV/R2 + codeload 拉預編譯 wasm + wrangler deploy + seed recipe)
- recipe push 把關(資料外流提醒 + 打通檢查)
- 19 個正當零件預編譯 wasm(claude_api/km_writer/kbdb_upsert_block 排除:違反 DECISIONS §1)
- CLI / cypher-executor / registry / 完整 SDD

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-06-03 15:52:38 +08:00
commit 922a57fe34
485 changed files with 89356 additions and 0 deletions
+475
View File
@@ -0,0 +1,475 @@
// arcrun OAuth 登入路由
// GET /auth/google/start → redirect to Google OAuth
// GET /auth/github/start → redirect to GitHub OAuth
// GET /auth/callback → exchange code, create session
// POST /auth/logout → clear session cookie
// GET /me → current user info
// PUT /me/api-key/rotate → generate new api key
// DELETE /me/api-key → revoke api key
import { Hono } from 'hono';
import type { Bindings } from '../types';
export const authRouter = new Hono<{ Bindings: Bindings }>();
// ─── Types ────────────────────────────────────────────────────────────────────
type UserRecord = {
email: string;
display_name: string;
avatar_url?: string;
api_key: string;
provider: 'google' | 'github';
provider_id: string;
created_at: string;
revoked?: boolean;
};
type SessionRecord = {
user_key: string; // "user:{provider}:{provider_id}"
api_key: string;
email: string;
expires_at: number; // unix timestamp ms
};
type OAuthStateRecord = {
provider: 'google' | 'github';
redirect_back: string;
created_at: number;
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getLandingOrigin(c: { req: { raw: Request } }): string {
const origin = c.req.raw.headers.get('origin');
// 允許的 landing origins
const allowed = ['https://arcrun.dev', 'https://www.arcrun.dev'];
if (origin && allowed.includes(origin)) return origin;
return 'https://arcrun.dev';
}
/** 產生 API KeyHMAC-SHA256 of email,與 /register 相同邏輯) */
async function generateApiKey(email: string, encryptionKey: string): Promise<string> {
const keyData = new TextEncoder().encode(encryptionKey.slice(0, 32));
const msgData = new TextEncoder().encode(email);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, msgData);
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
return 'ak_' + hex.slice(0, 32);
}
/** AES-GCM 加密,回傳 {encrypted, iv}base64),與 SDK 格式相同 */
async function aesEncrypt(plaintext: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plaintext));
const toB64 = (buf: ArrayBuffer | Uint8Array) => btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf)));
return { encrypted: toB64(enc), iv: toB64(iv) };
}
/** 幂等寫入 auth_recipe 到 RECIPES KV(若已存在相同版本則跳過) */
async function upsertAuthRecipe(recipes: KVNamespace, recipe: Record<string, unknown>): Promise<void> {
const key = `auth_recipe:${recipe.service}`;
const existing = await recipes.get(key);
if (existing) return; // 已存在,不覆蓋(用戶可能已自訂)
await recipes.put(key, JSON.stringify({ ...recipe, created_at: Date.now(), updated_at: Date.now() }));
}
/** 產生隨機 token(用於 session ID 和 state */
function randomToken(bytes = 32): string {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
}
/** 從 Cookie header 取 session ID */
function getSessionId(req: Request): string | null {
const cookie = req.headers.get('cookie') ?? '';
const match = cookie.match(/arcrun_session=([a-f0-9]+)/);
return match ? match[1] : null;
}
/** 從 Request 取 API KeyX-Arcrun-API-Key header 或 Authorization: Bearer */
function getApiKeyFromRequest(req: Request): string | null {
const direct = req.headers.get('x-arcrun-api-key');
if (direct) return direct;
const auth = req.headers.get('authorization') ?? '';
const match = auth.match(/^Bearer\s+(ak_\S+)/i);
return match ? match[1] : null;
}
/** 驗證 session → 回傳 user record,或 null */
async function resolveSession(c: { req: { raw: Request }; env: Bindings }): Promise<UserRecord | null> {
const sessId = getSessionId(c.req.raw);
if (sessId) {
const sess = await c.env.SESSIONS_KV.get<SessionRecord>(`sess:${sessId}`, 'json');
if (sess && sess.expires_at > Date.now()) {
const user = await c.env.USERS_KV.get<UserRecord>(sess.user_key, 'json');
if (user && !user.revoked) return user;
}
}
// Fallback: API Key header
const apiKey = getApiKeyFromRequest(c.req.raw);
if (apiKey) {
// 掃描 USERS_KV by api_key 太慢;改用 reverse index: apikey:{ak_...} → user_key
const userKey = await c.env.USERS_KV.get(`apikey:${apiKey}`);
if (userKey) {
const user = await c.env.USERS_KV.get<UserRecord>(userKey, 'json');
if (user && !user.revoked && user.api_key === apiKey) return user;
}
}
return null;
}
// ─── Google OAuth ─────────────────────────────────────────────────────────────
authRouter.get('/auth/google/start', async (c) => {
const clientId = c.env.GOOGLE_CLIENT_ID;
if (!clientId) return c.json({ error: 'Google OAuth not configured' }, 503);
const state = randomToken(16);
const stateRecord: OAuthStateRecord = {
provider: 'google',
redirect_back: c.req.query('redirect') ?? '/dashboard',
created_at: Date.now(),
};
// state TTL = 10 minutes
await c.env.SESSIONS_KV.put(`state:${state}`, JSON.stringify(stateRecord), { expirationTtl: 600 });
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid profile email',
state,
access_type: 'offline',
prompt: 'consent',
});
return Response.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`, 302);
});
// ─── GitHub OAuth ─────────────────────────────────────────────────────────────
authRouter.get('/auth/github/start', async (c) => {
const clientId = c.env.GITHUB_CLIENT_ID;
if (!clientId) return c.json({ error: 'GitHub OAuth not configured' }, 503);
const state = randomToken(16);
const stateRecord: OAuthStateRecord = {
provider: 'github',
redirect_back: c.req.query('redirect') ?? '/dashboard',
created_at: Date.now(),
};
await c.env.SESSIONS_KV.put(`state:${state}`, JSON.stringify(stateRecord), { expirationTtl: 600 });
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
scope: 'read:user user:email',
state,
});
return Response.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
});
// ─── OAuth Callback ───────────────────────────────────────────────────────────
authRouter.get('/auth/callback', async (c) => {
const code = c.req.query('code');
const state = c.req.query('state');
const error = c.req.query('error');
const landingOrigin = getLandingOrigin(c);
if (error || !code || !state) {
return Response.redirect(`${landingOrigin}/login?error=${encodeURIComponent(error ?? 'cancelled')}`, 302);
}
// Validate state
const stateRecord = await c.env.SESSIONS_KV.get<OAuthStateRecord>(`state:${state}`, 'json');
if (!stateRecord) {
return Response.redirect(`${landingOrigin}/login?error=invalid_state`, 302);
}
await c.env.SESSIONS_KV.delete(`state:${state}`);
const encryptionKey = c.env.ENCRYPTION_KEY;
if (!encryptionKey) {
return Response.redirect(`${landingOrigin}/login?error=server_error`, 302);
}
try {
let email: string;
let displayName: string;
let avatarUrl: string | undefined;
let providerId: string;
const provider = stateRecord.provider;
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
if (provider === 'google') {
// Exchange code for token
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: c.env.GOOGLE_CLIENT_ID ?? '',
client_secret: c.env.GOOGLE_CLIENT_SECRET ?? '',
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}),
});
if (!tokenRes.ok) throw new Error('google token exchange failed');
const tokenData = await tokenRes.json() as { access_token: string; refresh_token?: string };
// Get user info
const userRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});
if (!userRes.ok) throw new Error('google userinfo failed');
const userInfo = await userRes.json() as {
sub: string; email: string; name: string; picture?: string;
};
email = userInfo.email.toLowerCase();
displayName = userInfo.name;
avatarUrl = userInfo.picture;
providerId = userInfo.sub;
// 存 Google refresh_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
// Google 只在首次授權時回傳 refresh_token,後續登入 tokenData.refresh_token 為 undefined
if (tokenData.refresh_token) {
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:google_refresh_token`;
const encrypted = await aesEncrypt(tokenData.refresh_token, encryptionKey);
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
// 種 auth_recipe:google_user(用戶自己的 Google OAuth2
void upsertAuthRecipe(c.env.RECIPES, {
kind: 'auth_recipe',
service: 'google_user',
version: 1,
primitive: 'oauth2',
base_url: 'https://www.googleapis.com',
display_name: 'Google(用戶帳號)',
oauth2: {
token_endpoint: 'https://oauth2.googleapis.com/token',
client_id: c.env.GOOGLE_CLIENT_ID ?? '',
client_secret: c.env.GOOGLE_CLIENT_SECRET ?? '',
scopes: ['https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/spreadsheets'],
},
required_secrets: [{ key: 'google_refresh_token', label: 'Google Refresh Token' }],
inject: { header: { Authorization: 'Bearer {{runtime.access_token}}' } },
});
}
} else {
// GitHub: exchange code for token
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: new URLSearchParams({
code,
client_id: c.env.GITHUB_CLIENT_ID ?? '',
client_secret: c.env.GITHUB_CLIENT_SECRET ?? '',
redirect_uri: redirectUri,
}),
});
if (!tokenRes.ok) throw new Error('github token exchange failed');
const tokenData = await tokenRes.json() as { access_token: string; token_type?: string };
// Get user info
const userRes = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
'User-Agent': 'arcrun',
'Accept': 'application/vnd.github+json',
},
});
if (!userRes.ok) throw new Error('github user fetch failed');
const userInfo = await userRes.json() as {
id: number; login: string; name?: string; avatar_url?: string; email?: string;
};
// GitHub email might be null if private; fetch emails list
let ghEmail = userInfo.email ?? '';
if (!ghEmail) {
const emailsRes = await fetch('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
'User-Agent': 'arcrun',
'Accept': 'application/vnd.github+json',
},
});
if (emailsRes.ok) {
const emails = await emailsRes.json() as { email: string; primary: boolean; verified: boolean }[];
const primary = emails.find(e => e.primary && e.verified);
ghEmail = primary?.email ?? emails[0]?.email ?? '';
}
}
if (!ghEmail) throw new Error('github email not available');
email = ghEmail.toLowerCase();
displayName = userInfo.name ?? userInfo.login;
avatarUrl = userInfo.avatar_url;
providerId = String(userInfo.id);
// 存 GitHub access_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
// GitHub 沒有 refresh_tokenaccess_token 長效(直到 revoke
if (tokenData.access_token) {
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:github_access_token`;
const encrypted = await aesEncrypt(tokenData.access_token, encryptionKey);
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
// GitHub access_token 長效無 refresh 概念,用 static_key primitive
void upsertAuthRecipe(c.env.RECIPES, {
kind: 'auth_recipe',
service: 'github_user',
version: 1,
primitive: 'static_key',
base_url: 'https://api.github.com',
display_name: 'GitHub(用戶帳號)',
required_secrets: [{ key: 'github_access_token', label: 'GitHub Access Token' }],
inject: { header: { Authorization: 'Bearer {{secret.github_access_token}}' } },
});
}
}
// Upsert user record
const userKey = `user:${provider}:${providerId}`;
const existing = await c.env.USERS_KV.get<UserRecord>(userKey, 'json');
let apiKey: string;
if (existing && !existing.revoked) {
// Existing user — keep their api key
apiKey = existing.api_key;
// Update display info
const updated: UserRecord = { ...existing, display_name: displayName, avatar_url: avatarUrl };
await c.env.USERS_KV.put(userKey, JSON.stringify(updated));
} else {
// New user — generate api key (same HMAC logic as /register)
apiKey = await generateApiKey(email, encryptionKey);
const newUser: UserRecord = {
email, display_name: displayName, avatar_url: avatarUrl,
api_key: apiKey, provider, provider_id: providerId,
created_at: new Date().toISOString(),
};
await c.env.USERS_KV.put(userKey, JSON.stringify(newUser));
// Reverse index for API-Key-based auth
await c.env.USERS_KV.put(`apikey:${apiKey}`, userKey);
}
// Create session (TTL 7 days)
const sessionId = randomToken(32);
const session: SessionRecord = {
user_key: userKey,
api_key: apiKey,
email,
expires_at: Date.now() + 7 * 24 * 60 * 60 * 1000,
};
await c.env.SESSIONS_KV.put(`sess:${sessionId}`, JSON.stringify(session), {
expirationTtl: 7 * 24 * 60 * 60,
});
const redirectBack = stateRecord.redirect_back.startsWith('/') ? stateRecord.redirect_back : '/dashboard';
return new Response(null, {
status: 302,
headers: {
Location: `${landingOrigin}${redirectBack}`,
'Set-Cookie': `arcrun_session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=${7 * 24 * 60 * 60}`,
},
});
} catch (err) {
console.error('[auth/callback]', err);
return Response.redirect(`${landingOrigin}/login?error=server_error`, 302);
}
});
// ─── Logout ───────────────────────────────────────────────────────────────────
authRouter.post('/auth/logout', async (c) => {
const sessId = getSessionId(c.req.raw);
if (sessId) {
await c.env.SESSIONS_KV.delete(`sess:${sessId}`);
}
const landingOrigin = getLandingOrigin(c);
return new Response(null, {
status: 302,
headers: {
Location: `${landingOrigin}/`,
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=0',
},
});
});
// ─── /me ──────────────────────────────────────────────────────────────────────
authRouter.get('/me', async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: 'not authenticated' }, 401);
return c.json({
email: user.email,
display_name: user.display_name,
avatar_url: user.avatar_url,
api_key: user.api_key,
provider: user.provider,
created_at: user.created_at,
});
});
// ─── Rotate API Key ───────────────────────────────────────────────────────────
authRouter.put('/me/api-key/rotate', async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: 'not authenticated' }, 401);
// Generate new random key (not HMAC — rotated keys are random)
const newRaw = randomToken(24);
const newKey = 'ak_' + newRaw;
const oldKey = user.api_key;
const userKey = `user:${user.provider}:${user.provider_id}`;
const updated: UserRecord = { ...user, api_key: newKey };
await c.env.USERS_KV.put(userKey, JSON.stringify(updated));
// Update reverse index
await c.env.USERS_KV.delete(`apikey:${oldKey}`);
await c.env.USERS_KV.put(`apikey:${newKey}`, userKey);
return c.json({
success: true,
api_key: newKey,
message: 'API Key rotated. Your existing workflow credentials are still stored under the old key namespace.',
});
});
// ─── Revoke API Key ───────────────────────────────────────────────────────────
authRouter.delete('/me/api-key', async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: 'not authenticated' }, 401);
const userKey = `user:${user.provider}:${user.provider_id}`;
const revoked: UserRecord = { ...user, revoked: true };
await c.env.USERS_KV.put(userKey, JSON.stringify(revoked));
await c.env.USERS_KV.delete(`apikey:${user.api_key}`);
// Clear session cookie
const sessId = getSessionId(c.req.raw);
if (sessId) await c.env.SESSIONS_KV.delete(`sess:${sessId}`);
return new Response(JSON.stringify({ success: true, message: 'API Key revoked.' }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=0',
},
});
});