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:
@@ -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 Key(HMAC-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 Key(X-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_token,access_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',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Credentials API — 多租戶 credential 管理
|
||||
*
|
||||
* POST /credentials
|
||||
* Body: { name: string, encrypted: string, iv: string }
|
||||
* Header: X-Arcrun-API-Key
|
||||
* → 以 {api_key}:cred:{name} 為 KV key 存入 CREDENTIALS_KV
|
||||
*
|
||||
* DELETE /credentials/:name
|
||||
* Header: X-Arcrun-API-Key
|
||||
* → 刪除 {api_key}:cred:{name}
|
||||
*
|
||||
* GET /credentials
|
||||
* Header: X-Arcrun-API-Key
|
||||
* → 列出當前 api_key 下所有 credential 名稱(不含加密值)
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export const credentialsRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// POST /credentials — 上傳加密 credential
|
||||
credentialsRouter.post('/credentials', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null) as {
|
||||
name?: string;
|
||||
encrypted?: string;
|
||||
iv?: string;
|
||||
} | null;
|
||||
|
||||
if (!body?.name || !body.encrypted || !body.iv) {
|
||||
return c.json({ error: '缺少必要欄位:name, encrypted, iv' }, 400);
|
||||
}
|
||||
|
||||
const name = body.name.trim();
|
||||
if (!/^\w+$/.test(name)) {
|
||||
return c.json({ error: 'credential name 只能包含英文字母、數字和底線' }, 400);
|
||||
}
|
||||
|
||||
const kvKey = `${apiKey}:cred:${name}`;
|
||||
const record = JSON.stringify({ encrypted: body.encrypted, iv: body.iv });
|
||||
|
||||
await c.env.CREDENTIALS_KV.put(kvKey, record);
|
||||
|
||||
return c.json({ success: true, name });
|
||||
});
|
||||
|
||||
// DELETE /credentials/:name — 刪除 credential
|
||||
credentialsRouter.delete('/credentials/:name', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
|
||||
const name = c.req.param('name');
|
||||
const kvKey = `${apiKey}:cred:${name}`;
|
||||
await c.env.CREDENTIALS_KV.delete(kvKey);
|
||||
|
||||
return c.json({ success: true, name });
|
||||
});
|
||||
|
||||
// GET /credentials — 列出 credential 名稱(不含值)
|
||||
credentialsRouter.get('/credentials', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
|
||||
const prefix = `${apiKey}:cred:`;
|
||||
const list = await c.env.CREDENTIALS_KV.list({ prefix });
|
||||
const names = list.keys.map(k => k.name.slice(prefix.length));
|
||||
|
||||
return c.json({ credentials: names });
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { handleCypherSearch, handleCypherExecute } from '../actions/cypher-handlers';
|
||||
|
||||
export const cypherRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// POST /cypher/search — 三元組 → 解析節點 → 語意搜尋零件 → 回傳 Cypher JSON (開發友善格式)
|
||||
cypherRouter.post('/cypher/search', async (c) => {
|
||||
const body = await c.req.json() as { triplets?: unknown };
|
||||
const rawTriplets = body?.triplets;
|
||||
|
||||
if (!Array.isArray(rawTriplets) || rawTriplets.length === 0) {
|
||||
return c.json({ error: 'triplets 必須為非空字串陣列' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString();
|
||||
const versionId = `search-v1-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
|
||||
const result = await handleCypherSearch(rawTriplets, c.env);
|
||||
|
||||
const response = {
|
||||
version: versionId,
|
||||
timestamp,
|
||||
triplets: rawTriplets,
|
||||
nodes: result.nodes,
|
||||
cypher: result.cypher,
|
||||
missing: result.missing,
|
||||
};
|
||||
|
||||
return c.json(response);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ error: errMsg }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /cypher/execute — 三元組 → 一步執行(search + execute 合一)
|
||||
cypherRouter.post('/cypher/execute', async (c) => {
|
||||
const body = await c.req.json() as {
|
||||
triplets?: unknown;
|
||||
context?: Record<string, unknown>;
|
||||
config?: Record<string, Record<string, unknown>>; // node_name → {component, ...params}
|
||||
graph_id?: string;
|
||||
graph_name?: string;
|
||||
};
|
||||
|
||||
if (!Array.isArray(body?.triplets) || body.triplets.length === 0) {
|
||||
return c.json({ error: 'triplets 必須為非空字串陣列' }, 400);
|
||||
}
|
||||
|
||||
const graphId = typeof body.graph_id === 'string' ? body.graph_id : `triplet-exec-${Date.now()}`;
|
||||
const graphName = typeof body.graph_name === 'string' ? body.graph_name : 'Triplet Execution';
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString();
|
||||
// 版本號格式:execute-v1-20260327-143022
|
||||
const versionId = `execute-v1-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key') ?? undefined;
|
||||
|
||||
try {
|
||||
const result = await handleCypherExecute(
|
||||
body.triplets as unknown[],
|
||||
body.context,
|
||||
graphId,
|
||||
graphName,
|
||||
body.config,
|
||||
c.env,
|
||||
(p) => c.executionCtx.waitUntil(p),
|
||||
apiKey,
|
||||
);
|
||||
// 包裝成開發友善格式(execute 成功時)
|
||||
const response = {
|
||||
version: versionId,
|
||||
timestamp,
|
||||
...result,
|
||||
};
|
||||
return c.json(response);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
try {
|
||||
const parsed = JSON.parse(errMsg);
|
||||
const response = {
|
||||
version: versionId,
|
||||
timestamp,
|
||||
...parsed,
|
||||
};
|
||||
return c.json(response, 500);
|
||||
} catch {
|
||||
return c.json({ version: versionId, timestamp, success: false, error: errMsg, duration_ms: 0 }, 500);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { OPENAPI_SPEC } from '../lib/openapi';
|
||||
|
||||
export const docsRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// GET /openapi.json
|
||||
docsRouter.get('/openapi.json', (c) => {
|
||||
return c.json(OPENAPI_SPEC);
|
||||
});
|
||||
|
||||
// GET /docs — Swagger UI
|
||||
docsRouter.get('/docs', (c) => {
|
||||
const specStr = JSON.stringify(OPENAPI_SPEC);
|
||||
const htmlStr = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Cypher Executor API Docs</title>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@4/swagger-ui.css">
|
||||
<style>html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; } *, *:before, *:after { box-sizing: inherit; } body { margin:0; padding:0; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@4/swagger-ui-bundle.js"> </script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@4/swagger-ui-standalone-preset.js"> </script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
spec: ${specStr},
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "BaseLayout"
|
||||
})
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
return c.html(htmlStr);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings, ExecutionGraph } from '../types';
|
||||
import { ExecutionError } from '../types';
|
||||
import { GraphExecutor } from '../graph-executor';
|
||||
import { executeSchema } from '../lib/schemas';
|
||||
import { createComponentLoader } from '../lib/component-loader';
|
||||
import { writeExecutionVerdict } from '../actions/execution-logger';
|
||||
|
||||
export const executeRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// POST /execute — 執行一個完整的圖
|
||||
executeRouter.post('/execute', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const parsed = executeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: '圖定義驗證失敗', details: parsed.error.issues }, 400);
|
||||
}
|
||||
|
||||
const { graph, context } = parsed.data;
|
||||
const apiKey = c.req.header('x-arcrun-api-key') ?? undefined;
|
||||
const loader = createComponentLoader(c.env);
|
||||
const executor = new GraphExecutor(loader, undefined, c.env, apiKey);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
// BUILD-006:傳入 KV namespace(若不存在則 fallback 到記憶體 merge)
|
||||
const result = await executor.execute(graph as ExecutionGraph, context, c.env.EXEC_CONTEXT);
|
||||
const duration_ms = Date.now() - start;
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'success', duration_ms, '執行完成')
|
||||
);
|
||||
return c.json({ success: true, data: result.data, trace: result.trace, duration_ms });
|
||||
} catch (err) {
|
||||
const duration_ms = Date.now() - start;
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, graph.id, graph.nodes, 'failed', duration_ms, errMsg.slice(0, 100))
|
||||
);
|
||||
if (err instanceof ExecutionError) {
|
||||
const traceFormatted = err.trace.map(s => ({
|
||||
node: s.nodeId,
|
||||
status: s.error ? 'failed' : 'success',
|
||||
...(s.error ? { error: s.error } : {}),
|
||||
}));
|
||||
return c.json({
|
||||
success: false,
|
||||
error: errMsg,
|
||||
failed_node: err.failed_node,
|
||||
failed_input: err.failed_input,
|
||||
trace: traceFormatted,
|
||||
duration_ms,
|
||||
}, 500);
|
||||
}
|
||||
return c.json({ success: false, error: errMsg, failed_node: null, trace: [], duration_ms }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Executions routes — LI SDD M2.1
|
||||
*
|
||||
* 對應 .agents/specs/llm-interface/ Milestone 2.1。給 AI 看 workflow 執行狀態的端點。
|
||||
*
|
||||
* - GET /executions/paused — 列當前所有 paused 的 workflow(等 callback resume)
|
||||
* - GET /executions/:task_id — 看單一 paused state 細節(含 trace、graph、node id)
|
||||
* - GET /workflows/:name/executions — 列某 workflow 最近 N 次執行 verdict
|
||||
*
|
||||
* 設計:純讀,無 side effect。所有路由要 api_key auth(防偷看他人 workflow state)。
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { listPausedRunsByApiKey } from '../lib/paused-runs';
|
||||
|
||||
export const executionsRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
/**
|
||||
* GET /executions/paused — 列當前 api_key 下所有 paused workflow
|
||||
*
|
||||
* 走 per-user index `paused_idx:{api_key}`(單 KV get,強 consistent,無 KV list 延遲)
|
||||
* 取代舊的 `paused_run:*` prefix scan(CF KV list 30-60 秒 eventual consistent)
|
||||
*/
|
||||
executionsRouter.get('/executions/paused', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error_code: 'auth_missing',
|
||||
human_message: '缺 X-Arcrun-API-Key header',
|
||||
next_actions: ['call /me 取得你的 ak_xxx,加進 header'],
|
||||
}, 401);
|
||||
}
|
||||
|
||||
const limitParam = c.req.query('limit');
|
||||
const limit = Math.min(Math.max(parseInt(limitParam || '20', 10), 1), 100);
|
||||
|
||||
const paused = await listPausedRunsByApiKey(c.env.EXEC_CONTEXT, apiKey, limit);
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
data: { count: paused.length, paused },
|
||||
hints: paused.length > 0
|
||||
? [`${paused.length} 個 workflow 等 callback resume。call get_execution_trace(task_id) 看細節`]
|
||||
: ['沒有任何 paused workflow'],
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /executions/:task_id — 看單一 paused workflow 的 state(trace、graph、context)
|
||||
*
|
||||
* task_id 來源:trigger workflow 時 response 含 paused 結果,task_id 在 error 字串裡,
|
||||
* 或前端 list_paused_executions 回的 task_id。
|
||||
*
|
||||
* 隔離:只能讀自己 api_key 的 state。
|
||||
*/
|
||||
executionsRouter.get('/executions/:task_id', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error_code: 'auth_missing',
|
||||
human_message: '缺 X-Arcrun-API-Key header',
|
||||
next_actions: ['加 X-Arcrun-API-Key header'],
|
||||
}, 401);
|
||||
}
|
||||
|
||||
const taskId = c.req.param('task_id');
|
||||
const raw = await c.env.EXEC_CONTEXT.get(`paused_run:${taskId}`);
|
||||
|
||||
if (!raw) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error_code: 'not_found',
|
||||
human_message: `task_id "${taskId}" 沒對應的 paused state(可能已 resume 完、過 24h TTL 被 GC、或從未存在)`,
|
||||
next_actions: [
|
||||
'call /executions/paused 看當前所有 paused,確認 task_id 正確',
|
||||
'若該 workflow 不是 paused 型,看 /workflows/:name/executions 查歷史 verdict',
|
||||
],
|
||||
}, 404);
|
||||
}
|
||||
|
||||
let state: {
|
||||
run_id: string;
|
||||
graph?: unknown;
|
||||
paused_node_id: string;
|
||||
paused_context?: Record<string, unknown>;
|
||||
paused_pending_result?: Record<string, unknown>;
|
||||
trace_so_far?: unknown;
|
||||
api_key?: string;
|
||||
expires_at?: number;
|
||||
};
|
||||
try {
|
||||
state = JSON.parse(raw);
|
||||
} catch {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error_code: 'internal_error',
|
||||
human_message: 'paused state JSON 損毀',
|
||||
next_actions: ['告訴 leo / 平台維護者'],
|
||||
}, 500);
|
||||
}
|
||||
|
||||
if (state.api_key !== apiKey) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error_code: 'not_found', // 不洩漏存在性
|
||||
human_message: `task_id "${taskId}" 找不到`,
|
||||
next_actions: ['確認 task_id 屬於你 (用 /executions/paused 列出)'],
|
||||
}, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
data: {
|
||||
task_id: taskId,
|
||||
run_id: state.run_id,
|
||||
paused_node_id: state.paused_node_id,
|
||||
paused_context: state.paused_context,
|
||||
paused_pending_result: state.paused_pending_result,
|
||||
trace_so_far: state.trace_so_far,
|
||||
expires_at: state.expires_at,
|
||||
},
|
||||
hints: [
|
||||
'paused 狀態 = workflow 等 daemon callback。等對應 service 回 POST /workflows/resume 即可繼續',
|
||||
'若 daemon 掛了,看 expires_at — 過 24h KV TTL 會 GC 此 state',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /workflows/:name/executions — 看某 workflow 最近 N 次執行 verdict
|
||||
*
|
||||
* 走 ANALYTICS_KV `stats:{workflowId}:*` prefix scan。
|
||||
*
|
||||
* workflowId 等於 webhook name(execution-logger 寫入時用 graph.id ?? name)。
|
||||
*
|
||||
* 限制:ANALYTICS_KV list 沒辦法依 timestamp 排序,只能拿 key 後段 timestamp 排。
|
||||
*/
|
||||
executionsRouter.get('/workflows/:name/executions', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error_code: 'auth_missing',
|
||||
human_message: '缺 X-Arcrun-API-Key header',
|
||||
next_actions: ['加 X-Arcrun-API-Key header'],
|
||||
}, 401);
|
||||
}
|
||||
|
||||
const name = c.req.param('name');
|
||||
const limitParam = c.req.query('limit');
|
||||
const limit = Math.min(Math.max(parseInt(limitParam || '10', 10), 1), 100);
|
||||
|
||||
// 確認 workflow 是該 api_key 的(防偷看他人)
|
||||
const wfRaw = await c.env.WEBHOOKS.get(`${apiKey}:wf:${name}`, 'text');
|
||||
if (!wfRaw) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error_code: 'not_found',
|
||||
human_message: `workflow "${name}" 不存在或不屬於你`,
|
||||
next_actions: ['call /webhooks/named 看你有什麼 workflow'],
|
||||
}, 404);
|
||||
}
|
||||
|
||||
// 撈 stats:{name}:* 全 list(每個 key 含 timestamp 後綴)
|
||||
const list = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
|
||||
|
||||
// 按 timestamp 降序(key suffix 是 unix ms)
|
||||
const sorted = [...list.keys].sort((a, b) => {
|
||||
const ta = parseInt(a.name.split(':').pop() ?? '0', 10);
|
||||
const tb = parseInt(b.name.split(':').pop() ?? '0', 10);
|
||||
return tb - ta;
|
||||
}).slice(0, limit);
|
||||
|
||||
const executions = [];
|
||||
for (const key of sorted) {
|
||||
const raw = await c.env.ANALYTICS_KV.get(key.name);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
executions.push({
|
||||
timestamp: key.name.split(':').pop(),
|
||||
...record,
|
||||
});
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
ok: true,
|
||||
data: {
|
||||
workflow_name: name,
|
||||
count: executions.length,
|
||||
executions,
|
||||
},
|
||||
hints: executions.length === 0
|
||||
? ['尚未有任何執行紀錄(或都過了 90d TTL)。先 call /webhooks/named/:name/trigger 跑一次']
|
||||
: [`最近 ${executions.length} 次。看到 verdict=failed 的,call /executions/:task_id 看 paused state 或繼續 debug`],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export const healthRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
healthRouter.get('/health', (c) =>
|
||||
c.json({ ok: true })
|
||||
);
|
||||
|
||||
healthRouter.get('/', (c) =>
|
||||
c.json({
|
||||
service: 'arcrun-cypher-executor',
|
||||
version: '1.0.0',
|
||||
status: 'ok',
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* /recipes — API recipe CRUD
|
||||
*
|
||||
* recipe 是「http_request + 參數模板」的具名封裝。
|
||||
* 不需要 deploy Worker,執行時由 cypher-executor 直接 fetch。
|
||||
*
|
||||
* KV 結構:
|
||||
* recipe:{canonical_id} → RecipeDefinition JSON
|
||||
* idx:{rec_hash} → canonical_id (反查索引)
|
||||
*
|
||||
* 引用方式(workflow config):
|
||||
* component: "rec_f7e2a1b3" → 永久穩定,不受改名影響
|
||||
* component: "slack" → 向前兼容,直接用 canonical_id 查
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { deriveRecipeHash } from '../lib/hash';
|
||||
import { checkExposureConsent, resolveConsentForRecord } from '../lib/exposure-consent';
|
||||
import type { ExposureConsent } from '../lib/exposure-consent';
|
||||
|
||||
export const recipesRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
export interface RecipeDefinition {
|
||||
canonical_id: string;
|
||||
hash_id: string; // rec_xxxxxxxx
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
endpoint: string;
|
||||
method?: string; // GET | POST | PUT | PATCH | DELETE,預設 POST
|
||||
headers?: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
/**
|
||||
* 此 recipe 要用哪個 auth recipe(auth_recipe:{auth_service})。
|
||||
* 讓多個 recipe 共用同一把 auth(例:kbdb_get / kbdb_create_block 都設 "kbdb")。
|
||||
* 未設時 auth-dispatcher fallback 到把 canonical_id 當 service name(向後相容)。
|
||||
*/
|
||||
auth_service?: string;
|
||||
credentials_required?: Array<{
|
||||
key: string;
|
||||
inject_as: string;
|
||||
}>;
|
||||
// 資料外流警示:recipe 定義一個資料去向(endpoint)。push 需人類明示同意(法律憑證)。
|
||||
// SDD: data-exfil-warning §7(公私一視同仁)
|
||||
exposure_consent?: ExposureConsent;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
// POST /recipes — 新增或更新 recipe
|
||||
recipesRouter.post('/recipes', async (c) => {
|
||||
let body: Partial<RecipeDefinition>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
|
||||
}
|
||||
|
||||
const canonicalId = (body.canonical_id ?? '').trim().toLowerCase();
|
||||
if (!canonicalId) return c.json({ success: false, error: 'canonical_id 必填' }, 400);
|
||||
if (!body.endpoint) return c.json({ success: false, error: 'endpoint 必填' }, 400);
|
||||
|
||||
const hashId = await deriveRecipeHash(canonicalId);
|
||||
const now = Date.now();
|
||||
|
||||
// 讀取現有版本(保留 created_at + 既有同意憑證)
|
||||
const existing = await c.env.RECIPES.get(`recipe:${canonicalId}`, 'json') as RecipeDefinition | null;
|
||||
|
||||
// 資料外流警示:recipe 定義資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
|
||||
const consentError = checkExposureConsent(body.exposure_consent, existing?.exposure_consent);
|
||||
if (consentError !== null) {
|
||||
return c.json({ success: false, error: consentError, requires: 'exposure_consent' }, 403);
|
||||
}
|
||||
|
||||
const recipe: RecipeDefinition = {
|
||||
canonical_id: canonicalId,
|
||||
hash_id: hashId,
|
||||
display_name: body.display_name,
|
||||
description: body.description,
|
||||
endpoint: body.endpoint,
|
||||
method: (body.method ?? 'POST').toUpperCase(),
|
||||
headers: body.headers,
|
||||
body: body.body,
|
||||
auth_service: body.auth_service,
|
||||
credentials_required: body.credentials_required,
|
||||
exposure_consent: resolveConsentForRecord(body.exposure_consent, existing?.exposure_consent),
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
// 寫入兩個 KV key
|
||||
await Promise.all([
|
||||
c.env.RECIPES.put(`recipe:${canonicalId}`, JSON.stringify(recipe)),
|
||||
c.env.RECIPES.put(`idx:${hashId}`, canonicalId),
|
||||
]);
|
||||
|
||||
return c.json({ success: true, recipe });
|
||||
});
|
||||
|
||||
// GET /recipes/:id — 讀取 recipe(支援 canonical_id 或 rec_hash)
|
||||
recipesRouter.get('/recipes/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const recipe = await resolveRecipe(id, c.env.RECIPES);
|
||||
if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404);
|
||||
return c.json({ success: true, recipe });
|
||||
});
|
||||
|
||||
// GET /recipes — 列出所有 recipe
|
||||
recipesRouter.get('/recipes', async (c) => {
|
||||
const list = await c.env.RECIPES.list({ prefix: 'recipe:' });
|
||||
const recipes = await Promise.all(
|
||||
list.keys.map(k => c.env.RECIPES.get(k.name, 'json'))
|
||||
);
|
||||
return c.json({ success: true, recipes: recipes.filter(Boolean), count: recipes.length });
|
||||
});
|
||||
|
||||
// DELETE /recipes/:id — 刪除 recipe
|
||||
recipesRouter.delete('/recipes/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const recipe = await resolveRecipe(id, c.env.RECIPES);
|
||||
if (!recipe) return c.json({ success: false, error: `找不到 recipe: ${id}` }, 404);
|
||||
|
||||
await Promise.all([
|
||||
c.env.RECIPES.delete(`recipe:${recipe.canonical_id}`),
|
||||
c.env.RECIPES.delete(`idx:${recipe.hash_id}`),
|
||||
]);
|
||||
|
||||
return c.json({ success: true, deleted: recipe.canonical_id });
|
||||
});
|
||||
|
||||
/** 用 canonical_id 或 rec_hash 查 recipe */
|
||||
export async function resolveRecipe(
|
||||
id: string,
|
||||
kv: KVNamespace,
|
||||
): Promise<RecipeDefinition | null> {
|
||||
// rec_xxxxxxxx → 先查 idx 反查 canonical_id
|
||||
if (id.startsWith('rec_')) {
|
||||
const canonicalId = await kv.get(`idx:${id}`);
|
||||
if (!canonicalId) return null;
|
||||
return kv.get(`recipe:${canonicalId}`, 'json');
|
||||
}
|
||||
// 直接用 canonical_id
|
||||
return kv.get(`recipe:${id}`, 'json');
|
||||
}
|
||||
|
||||
// ── Auth Recipe ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AuthPrimitive = 'static_key' | 'oauth2' | 'service_account' | 'mtls';
|
||||
|
||||
export interface SecretRequirement {
|
||||
key: string; // CREDENTIALS_KV 的名稱(e.g. "notion_token")
|
||||
label: string; // CLI/UI 顯示(e.g. "Internal Integration Token")
|
||||
type?: 'string' | 'json_blob'; // default: string
|
||||
help?: string;
|
||||
help_url?: string;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthInjectSpec {
|
||||
header?: Record<string, string>; // e.g. { Authorization: "Bearer {{secret.token}}" }
|
||||
query?: Record<string, string>;
|
||||
body?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AuthRecipeDefinition {
|
||||
kind: 'auth_recipe';
|
||||
service: string; // canonical_id,e.g. "notion"
|
||||
version: number;
|
||||
primitive: AuthPrimitive;
|
||||
base_url: string;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
|
||||
// service_account 專用
|
||||
service_account_kind?: 'google_jwt';
|
||||
token_exchange?: {
|
||||
endpoint: string;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
required_secrets: SecretRequirement[];
|
||||
inject: AuthInjectSpec;
|
||||
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/** 查 auth recipe(KV key: auth_recipe:{service})*/
|
||||
export async function resolveAuthRecipe(
|
||||
service: string,
|
||||
kv: KVNamespace,
|
||||
): Promise<AuthRecipeDefinition | null> {
|
||||
return kv.get(`auth_recipe:${service}`, 'json');
|
||||
}
|
||||
|
||||
// POST /auth-recipes — 新增或更新 auth recipe
|
||||
recipesRouter.post('/auth-recipes', async (c) => {
|
||||
let body: Partial<AuthRecipeDefinition>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
|
||||
}
|
||||
|
||||
const service = (body.service ?? '').trim().toLowerCase();
|
||||
if (!service) return c.json({ success: false, error: 'service 必填' }, 400);
|
||||
if (!body.primitive) return c.json({ success: false, error: 'primitive 必填' }, 400);
|
||||
if (!body.base_url) return c.json({ success: false, error: 'base_url 必填' }, 400);
|
||||
if (!body.required_secrets?.length) return c.json({ success: false, error: 'required_secrets 必填' }, 400);
|
||||
if (!body.inject) return c.json({ success: false, error: 'inject 必填' }, 400);
|
||||
|
||||
const now = Date.now();
|
||||
const existing = await c.env.RECIPES.get(`auth_recipe:${service}`, 'json') as AuthRecipeDefinition | null;
|
||||
|
||||
const recipe: AuthRecipeDefinition = {
|
||||
kind: 'auth_recipe',
|
||||
service,
|
||||
version: body.version ?? 1,
|
||||
primitive: body.primitive,
|
||||
base_url: body.base_url,
|
||||
display_name: body.display_name,
|
||||
description: body.description,
|
||||
service_account_kind: body.service_account_kind,
|
||||
token_exchange: body.token_exchange,
|
||||
required_secrets: body.required_secrets,
|
||||
inject: body.inject,
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
await c.env.RECIPES.put(`auth_recipe:${service}`, JSON.stringify(recipe));
|
||||
return c.json({ success: true, recipe });
|
||||
});
|
||||
|
||||
// GET /auth-recipes — 列出所有 auth recipe
|
||||
recipesRouter.get('/auth-recipes', async (c) => {
|
||||
const list = await c.env.RECIPES.list({ prefix: 'auth_recipe:' });
|
||||
const recipes = await Promise.all(
|
||||
list.keys.map(k => c.env.RECIPES.get(k.name, 'json'))
|
||||
);
|
||||
return c.json({ success: true, recipes: recipes.filter(Boolean), count: recipes.length });
|
||||
});
|
||||
|
||||
// GET /auth-recipes/:service — 讀取單一 auth recipe
|
||||
recipesRouter.get('/auth-recipes/:service', async (c) => {
|
||||
const service = c.req.param('service');
|
||||
const recipe = await resolveAuthRecipe(service, c.env.RECIPES);
|
||||
if (!recipe) return c.json({ success: false, error: `找不到 auth recipe: ${service}` }, 404);
|
||||
return c.json({ success: true, recipe });
|
||||
});
|
||||
|
||||
// DELETE /auth-recipes/:service — 刪除 auth recipe
|
||||
recipesRouter.delete('/auth-recipes/:service', async (c) => {
|
||||
const service = c.req.param('service');
|
||||
const recipe = await resolveAuthRecipe(service, c.env.RECIPES);
|
||||
if (!recipe) return c.json({ success: false, error: `找不到 auth recipe: ${service}` }, 404);
|
||||
await c.env.RECIPES.delete(`auth_recipe:${service}`);
|
||||
return c.json({ success: true, deleted: service });
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// POST /register — API Key 發放
|
||||
// email → HMAC-SHA256(email, ENCRYPTION_KEY) → api_key (ak_ 前綴)
|
||||
// 同一個 email 永遠得到相同的 Key,無需資料庫
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export const registerRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
registerRouter.post('/register', async (c) => {
|
||||
let email: string;
|
||||
try {
|
||||
const body = await c.req.json() as { email?: string };
|
||||
email = (body.email ?? '').trim().toLowerCase();
|
||||
} catch {
|
||||
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
|
||||
}
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
return c.json({ success: false, error: 'email 格式不正確' }, 400);
|
||||
}
|
||||
|
||||
const encryptionKey = c.env.ENCRYPTION_KEY;
|
||||
if (!encryptionKey || encryptionKey.length < 32) {
|
||||
return c.json({ success: false, error: 'server configuration error' }, 500);
|
||||
}
|
||||
|
||||
// HMAC-SHA256(email, ENCRYPTION_KEY) → hex → 取前 32 字元 → ak_ 前綴
|
||||
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('');
|
||||
const apiKey = 'ak_' + hex.slice(0, 32);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
api_key: apiKey,
|
||||
encryption_key: encryptionKey, // 用戶需要此 key 才能加密上傳 credential
|
||||
email,
|
||||
message: 'API Key 已發放,請妥善保存。相同 email 永遠得到相同的 Key。',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* POST /workflows/resume
|
||||
* Webhook callback 進來時,從 paused state 撿起來繼續跑下游節點
|
||||
* SDD: matrix/arcrun/.agents/specs/resumable-workflow/design.md Phase 3
|
||||
*
|
||||
* 安全:因為這是 daemon 主動 callback,沒有 partner key(daemon 不知道用戶 key)
|
||||
* 靠 task_id 為 nonce + 24h TTL + idempotent consume 保護
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { WorkflowPaused } from '../types';
|
||||
import { GraphExecutor } from '../graph-executor';
|
||||
import { createComponentLoader } from '../lib/component-loader';
|
||||
import { consumePausedRun } from '../lib/paused-runs';
|
||||
|
||||
export const resumeRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
resumeRouter.post('/workflows/resume', async (c) => {
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: 'request body 必須為 JSON' }, 400);
|
||||
}
|
||||
|
||||
const taskId = typeof body.task_id === 'string' ? body.task_id : undefined;
|
||||
if (!taskId) {
|
||||
return c.json({ error: 'task_id 必填' }, 400);
|
||||
}
|
||||
|
||||
// consume = load + delete(idempotent:重複 callback 第二次找不到 state,回 200)
|
||||
const state = await consumePausedRun(c.env.EXEC_CONTEXT, taskId);
|
||||
if (!state) {
|
||||
return c.json({
|
||||
success: true,
|
||||
noop: true,
|
||||
reason: `paused state 不存在或已過期 (task_id=${taskId})`,
|
||||
});
|
||||
}
|
||||
|
||||
const callbackResult = {
|
||||
success: body.success ?? true,
|
||||
data: body.data,
|
||||
error: body.error,
|
||||
};
|
||||
|
||||
const loader = createComponentLoader(c.env);
|
||||
const executor = new GraphExecutor(loader, undefined, c.env, state.api_key);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const result = await executor.resumeFromPaused({
|
||||
graph: state.graph,
|
||||
paused_node_id: state.paused_node_id,
|
||||
paused_context: state.paused_context,
|
||||
callback_result: callbackResult,
|
||||
prior_trace: state.trace_so_far,
|
||||
kvNamespace: c.env.EXEC_CONTEXT,
|
||||
recipe_output_format: state.recipe_output_format,
|
||||
recipe_output_required_fields: state.recipe_output_required_fields,
|
||||
});
|
||||
const duration_ms = Date.now() - start;
|
||||
return c.json({
|
||||
success: true,
|
||||
resumed: true,
|
||||
task_id: taskId,
|
||||
run_id: state.run_id,
|
||||
data: result.data,
|
||||
trace: result.trace,
|
||||
duration_ms,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowPaused) {
|
||||
// resume 後又遇到 pending(v2 nested 情境)— v1 仍持久化但回 paused-again
|
||||
return c.json({
|
||||
success: true,
|
||||
paused_again: true,
|
||||
task_id: err.task_id,
|
||||
run_id: err.run_id,
|
||||
paused_node_id: err.paused_node_id,
|
||||
});
|
||||
}
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ success: false, error: errMsg, task_id: taskId, run_id: state.run_id }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { graphSchema } from '../lib/schemas';
|
||||
import { recordTelemetry } from '../lib/telemetry';
|
||||
|
||||
export const validateRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// POST /validate — 驗證圖定義(不執行)
|
||||
validateRouter.post('/validate', async (c) => {
|
||||
const start = Date.now();
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
const userAgent = c.req.header('User-Agent') ?? undefined;
|
||||
|
||||
const body = await c.req.json();
|
||||
const parsed = graphSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
recordTelemetry(c.env, apiKey, {
|
||||
event_type: 'validation_error',
|
||||
error_code: 'schema_failed',
|
||||
duration_ms: Date.now() - start,
|
||||
agent_user_agent: userAgent,
|
||||
}, c.executionCtx);
|
||||
return c.json({ valid: false, errors: parsed.error.issues }, 400);
|
||||
}
|
||||
|
||||
const nodeIds = new Set(parsed.data.nodes.map(n => n.id));
|
||||
const invalidEdges = parsed.data.edges.filter(e => !nodeIds.has(e.from) || !nodeIds.has(e.to));
|
||||
|
||||
if (invalidEdges.length > 0) {
|
||||
recordTelemetry(c.env, apiKey, {
|
||||
event_type: 'validation_error',
|
||||
error_code: 'edge_node_missing',
|
||||
duration_ms: Date.now() - start,
|
||||
agent_user_agent: userAgent,
|
||||
}, c.executionCtx);
|
||||
return c.json({
|
||||
valid: false,
|
||||
errors: invalidEdges.map(e => `邊 ${e.from} → ${e.to} 指向不存在的節點`),
|
||||
}, 400);
|
||||
}
|
||||
|
||||
return c.json({ valid: true, nodeCount: parsed.data.nodes.length, edgeCount: parsed.data.edges.length });
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { validateAndParseWebhook } from '../actions/webhook-handlers';
|
||||
|
||||
export const webhooksCrudRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
type WebhookRecord = {
|
||||
graph: Record<string, unknown>;
|
||||
description: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// GET /webhooks/:token — 查詢 Webhook 基本資訊
|
||||
webhooksCrudRouter.get('/webhooks/:token', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
const raw = await c.env.WEBHOOKS.get(token, 'text');
|
||||
if (!raw) return c.json({ error: 'not found' }, 404);
|
||||
|
||||
const record = await validateAndParseWebhook(raw);
|
||||
if (!record) return c.json({ error: '資料損毀' }, 500);
|
||||
|
||||
return c.json({
|
||||
token,
|
||||
description: record.description,
|
||||
created_at: record.created_at,
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /webhooks/:token — 更新 Webhook 定義
|
||||
webhooksCrudRouter.put('/webhooks/:token', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
if (!token || token.length < 16) {
|
||||
return c.json({ error: 'invalid token' }, 400);
|
||||
}
|
||||
|
||||
const raw = await c.env.WEBHOOKS.get(token, 'text');
|
||||
if (!raw) return c.json({ error: 'webhook not found' }, 404);
|
||||
|
||||
const existing = await validateAndParseWebhook(raw);
|
||||
if (!existing) return c.json({ error: 'webhook 定義損毀' }, 500);
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body) return c.json({ error: 'invalid json' }, 400);
|
||||
|
||||
const updatedRecord: WebhookRecord = {
|
||||
graph: existing.graph,
|
||||
description: existing.description,
|
||||
created_at: existing.created_at,
|
||||
};
|
||||
|
||||
if (body.description !== undefined) {
|
||||
updatedRecord.description = typeof body.description === 'string' ? body.description : existing.description;
|
||||
}
|
||||
|
||||
if (body.graph !== undefined) {
|
||||
updatedRecord.graph = body.graph;
|
||||
}
|
||||
|
||||
await c.env.WEBHOOKS.put(token, JSON.stringify(updatedRecord));
|
||||
|
||||
const baseUrl = new URL(c.req.url).origin;
|
||||
return c.json({
|
||||
token,
|
||||
webhook_url: `${baseUrl}/webhooks/${token}/trigger`,
|
||||
description: updatedRecord.description,
|
||||
created_at: updatedRecord.created_at,
|
||||
updated: true,
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /webhooks/:token — 刪除 Webhook
|
||||
webhooksCrudRouter.delete('/webhooks/:token', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
if (!token || token.length < 16) {
|
||||
return c.json({ error: 'invalid token' }, 400);
|
||||
}
|
||||
|
||||
const existing = await c.env.WEBHOOKS.get(token, 'text');
|
||||
if (!existing) return c.json({ error: 'webhook not found' }, 404);
|
||||
|
||||
await c.env.WEBHOOKS.delete(token);
|
||||
return c.json({ deleted: true, token });
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { validateAndParseWebhook } from '../actions/webhook-handlers';
|
||||
|
||||
export const webhooksListRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// GET /webhooks — 列出所有 Webhooks(需要授權標頭)
|
||||
webhooksListRouter.get('/webhooks', async (c) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader) {
|
||||
return c.json({ error: 'unauthorized: missing Authorization header' }, 401);
|
||||
}
|
||||
|
||||
const list = await c.env.WEBHOOKS.list();
|
||||
const webhooks = [];
|
||||
|
||||
for (const key of list.keys) {
|
||||
const raw = await c.env.WEBHOOKS.get(key.name, 'text');
|
||||
if (!raw) continue;
|
||||
|
||||
const record = await validateAndParseWebhook(raw);
|
||||
if (!record) continue;
|
||||
|
||||
webhooks.push({
|
||||
token: key.name,
|
||||
description: record.description,
|
||||
created_at: record.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ webhooks, total: webhooks.length });
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Named Webhook(acr push 使用)
|
||||
*
|
||||
* POST /webhooks/named
|
||||
* Header: X-Arcrun-API-Key
|
||||
* Body: { name, graph, config?, description? }
|
||||
* → 以 {api_key}:wf:{name} 存入 WEBHOOKS KV
|
||||
* → 回傳 webhook_url
|
||||
*
|
||||
* POST /webhooks/named/:name/trigger
|
||||
* Header: X-Arcrun-API-Key
|
||||
* Body: 任意 JSON(作為 trigger context)
|
||||
* → 以 {api_key}:wf:{name} 讀取執行圖,執行後回傳結果
|
||||
*
|
||||
* GET /webhooks/named
|
||||
* Header: X-Arcrun-API-Key
|
||||
* → 列出當前 api_key 下所有 named webhook
|
||||
*
|
||||
* DELETE /webhooks/named/:name
|
||||
* Header: X-Arcrun-API-Key
|
||||
* → 刪除指定 workflow
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { executeWebhookGraph } from '../actions/webhook-handlers';
|
||||
import { writeExecutionVerdict } from '../actions/execution-logger';
|
||||
import type { GraphNode } from '../types';
|
||||
import { extractCronExpr } from '../lib/cron-match';
|
||||
import { recordTelemetry } from '../lib/telemetry';
|
||||
import { checkExposureConsent, resolveConsentForRecord } from '../lib/exposure-consent';
|
||||
import type { ExposureConsent } from '../lib/exposure-consent';
|
||||
|
||||
export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
type NamedWorkflowRecord = {
|
||||
name: string;
|
||||
graph: Record<string, unknown>;
|
||||
config?: Record<string, unknown>;
|
||||
description: string;
|
||||
created_at: string;
|
||||
// 若首節點是 cron 零件,extract cron_expr 存進來供 scheduled() 比對
|
||||
// 對應 SDD: arcrun.md 三-A P1 #3
|
||||
cron_expr?: string;
|
||||
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。
|
||||
// 存人類明示同意憑證(法律憑證,可審)。SDD: data-exfil-warning §7
|
||||
exposure_consent?: ExposureConsent;
|
||||
};
|
||||
|
||||
function kvKey(apiKey: string, name: string): string {
|
||||
return `${apiKey}:wf:${name}`;
|
||||
}
|
||||
|
||||
/** 輕量 cron index entry — scheduled() 只列這個 prefix(每分鐘 tick 不掃全量 KV)*/
|
||||
function cronIndexKey(apiKey: string, name: string): string {
|
||||
return `cron-idx:${apiKey}:${name}`;
|
||||
}
|
||||
|
||||
// POST /webhooks/named — 部署(acr push 呼叫)
|
||||
webhooksNamedRouter.post('/webhooks/named', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
|
||||
const body = await c.req.json().catch(() => null) as {
|
||||
name?: string;
|
||||
graph?: Record<string, unknown>;
|
||||
config?: Record<string, unknown>;
|
||||
description?: string;
|
||||
exposure_consent?: ExposureConsent;
|
||||
} | null;
|
||||
|
||||
if (!body?.name || !body.graph) {
|
||||
return c.json({ error: '缺少必要欄位:name, graph' }, 400);
|
||||
}
|
||||
|
||||
const name = body.name.trim();
|
||||
if (!/^[\w-]+$/.test(name)) {
|
||||
return c.json({ error: 'workflow name 只能包含英文字母、數字、底線和連字號' }, 400);
|
||||
}
|
||||
|
||||
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。
|
||||
// 首次部署某 workflow 需人類明示同意;已同意(含 suppress_future)則放行(§3 首次問記住)。
|
||||
const priorRaw = await c.env.WEBHOOKS.get(kvKey(apiKey, name));
|
||||
const priorRecord = priorRaw ? (JSON.parse(priorRaw) as NamedWorkflowRecord) : null;
|
||||
const consentError = checkExposureConsent(body.exposure_consent, priorRecord?.exposure_consent);
|
||||
if (consentError !== null) {
|
||||
return c.json({ error: consentError, requires: 'exposure_consent' }, 403);
|
||||
}
|
||||
|
||||
// 偵測首節點是 cron 零件 → 抽 cron_expr 存進 record + 建輕量 index 給 scheduled()
|
||||
const cronExpr = extractCronExpr(body.graph);
|
||||
|
||||
const record: NamedWorkflowRecord = {
|
||||
name,
|
||||
graph: body.graph,
|
||||
config: body.config,
|
||||
description: typeof body.description === 'string' ? body.description : '',
|
||||
created_at: new Date().toISOString(),
|
||||
cron_expr: cronExpr ?? undefined,
|
||||
// 法律憑證:存人類明示同意(本次新同意或沿用既有)
|
||||
exposure_consent: resolveConsentForRecord(body.exposure_consent, priorRecord?.exposure_consent),
|
||||
};
|
||||
|
||||
const start = Date.now();
|
||||
await c.env.WEBHOOKS.put(kvKey(apiKey, name), JSON.stringify(record));
|
||||
|
||||
// 維護 cron index:有 cron_expr 就寫 / 沒有就刪除(避免 push 改 yaml 拿掉 cron 後殘留)
|
||||
if (cronExpr) {
|
||||
await c.env.WEBHOOKS.put(cronIndexKey(apiKey, name), JSON.stringify({ cron_expr: cronExpr }));
|
||||
} else {
|
||||
await c.env.WEBHOOKS.delete(cronIndexKey(apiKey, name));
|
||||
}
|
||||
|
||||
// Implicit telemetry (LI M1.2)
|
||||
recordTelemetry(c.env, apiKey, {
|
||||
event_type: 'deploy_success',
|
||||
workflow_name: name,
|
||||
duration_ms: Date.now() - start,
|
||||
agent_user_agent: c.req.header('User-Agent') ?? undefined,
|
||||
}, c.executionCtx);
|
||||
|
||||
const baseUrl = new URL(c.req.url).origin;
|
||||
return c.json({
|
||||
name,
|
||||
webhook_url: `${baseUrl}/webhooks/named/${name}/trigger`,
|
||||
description: record.description,
|
||||
created_at: record.created_at,
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// POST /webhooks/named/:name/trigger — 觸發執行
|
||||
webhooksNamedRouter.post('/webhooks/named/:name/trigger', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
|
||||
const name = c.req.param('name');
|
||||
const raw = await c.env.WEBHOOKS.get(kvKey(apiKey, name), 'text');
|
||||
if (!raw) {
|
||||
return c.json({ error: `找不到 workflow "${name}",請先執行 acr push` }, 404);
|
||||
}
|
||||
|
||||
let record: NamedWorkflowRecord;
|
||||
try {
|
||||
record = JSON.parse(raw) as NamedWorkflowRecord;
|
||||
} catch {
|
||||
return c.json({ error: 'workflow 定義損毀' }, 500);
|
||||
}
|
||||
|
||||
let triggerContext: Record<string, unknown> = {};
|
||||
try {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (body && typeof body === 'object') {
|
||||
triggerContext = body as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// 無 body 時使用空 context
|
||||
}
|
||||
|
||||
const graph = record.graph as { id?: string; nodes?: unknown[] };
|
||||
const workflowId = graph.id ?? name;
|
||||
const nodes = Array.isArray(graph.nodes) ? (graph.nodes as GraphNode[]) : [];
|
||||
const userAgent = c.req.header('User-Agent') ?? undefined;
|
||||
|
||||
// resumable-workflow SDD §5:?async=1 → 背景執行(waitUntil)+ 立回 202,不依賴呼叫端連線。
|
||||
// 不帶 ?async=1 維持原同步行為(向後相容)。
|
||||
if (c.req.query('async') === '1') {
|
||||
c.executionCtx.waitUntil(
|
||||
executeWebhookGraph(c.env, record.graph, triggerContext, name, apiKey, c.executionCtx, userAgent)
|
||||
.then(result =>
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
||||
),
|
||||
);
|
||||
return c.json({ accepted: true }, 202);
|
||||
}
|
||||
|
||||
const result = await executeWebhookGraph(
|
||||
c.env,
|
||||
record.graph,
|
||||
triggerContext,
|
||||
name,
|
||||
apiKey,
|
||||
c.executionCtx,
|
||||
userAgent,
|
||||
);
|
||||
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
||||
);
|
||||
|
||||
return c.json(result, result.success ? 200 : 500);
|
||||
});
|
||||
|
||||
// GET /webhooks/named — 列出當前 api_key 下所有 workflow
|
||||
webhooksNamedRouter.get('/webhooks/named', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
|
||||
const prefix = `${apiKey}:wf:`;
|
||||
const list = await c.env.WEBHOOKS.list({ prefix });
|
||||
|
||||
const workflows = list.keys.map(k => {
|
||||
const name = k.name.slice(prefix.length);
|
||||
return { name };
|
||||
});
|
||||
|
||||
const baseUrl = new URL(c.req.url).origin;
|
||||
const result = workflows.map(w => ({
|
||||
name: w.name,
|
||||
webhook_url: `${baseUrl}/webhooks/named/${w.name}/trigger`,
|
||||
}));
|
||||
|
||||
return c.json({ workflows: result, total: result.length });
|
||||
});
|
||||
|
||||
// DELETE /webhooks/named/:name — 刪除 workflow
|
||||
webhooksNamedRouter.delete('/webhooks/named/:name', async (c) => {
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key');
|
||||
if (!apiKey) {
|
||||
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
|
||||
}
|
||||
|
||||
const name = c.req.param('name');
|
||||
const existing = await c.env.WEBHOOKS.get(kvKey(apiKey, name), 'text');
|
||||
if (!existing) {
|
||||
return c.json({ error: `找不到 workflow "${name}"` }, 404);
|
||||
}
|
||||
|
||||
await c.env.WEBHOOKS.delete(kvKey(apiKey, name));
|
||||
await c.env.WEBHOOKS.delete(cronIndexKey(apiKey, name));
|
||||
return c.json({ deleted: true, name });
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { generateToken, validateAndParseWebhook, executeWebhookGraph } from '../actions/webhook-handlers';
|
||||
import { resolveWebhookGraph } from '../actions/webhook-graph-resolver';
|
||||
import { writeExecutionVerdict } from '../actions/execution-logger';
|
||||
|
||||
export const webhooksRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
type WebhookRecord = {
|
||||
graph: Record<string, unknown>;
|
||||
description: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// POST /webhooks — 接受 graph、triplets 或直接 nodes/edges
|
||||
webhooksRouter.post('/webhooks', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body) return c.json({ error: 'invalid json' }, 400);
|
||||
|
||||
const description = typeof body.description === 'string' ? body.description : '';
|
||||
const resolved = await resolveWebhookGraph(body as Record<string, unknown>, description, c.env);
|
||||
|
||||
if (resolved.error) {
|
||||
return c.json({ error: resolved.error }, 400);
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const record: WebhookRecord = {
|
||||
graph: resolved.resolvedGraph,
|
||||
description,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await c.env.WEBHOOKS.put(token, JSON.stringify(record));
|
||||
|
||||
const baseUrl = new URL(c.req.url).origin;
|
||||
return c.json({
|
||||
token,
|
||||
webhook_url: `${baseUrl}/webhooks/${token}/trigger`,
|
||||
description: record.description,
|
||||
created_at: record.created_at,
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// POST /webhooks/:token/trigger — 觸發執行
|
||||
webhooksRouter.post('/webhooks/:token/trigger', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
if (!token || token.length < 16) {
|
||||
return c.json({ error: 'invalid token' }, 400);
|
||||
}
|
||||
|
||||
const raw = await c.env.WEBHOOKS.get(token, 'text');
|
||||
if (!raw) return c.json({ error: 'webhook not found' }, 404);
|
||||
|
||||
const record = await validateAndParseWebhook(raw);
|
||||
if (!record) return c.json({ error: 'webhook 定義損毀' }, 500);
|
||||
|
||||
let triggerContext: Record<string, unknown> = {};
|
||||
try {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (body && typeof body === 'object') {
|
||||
triggerContext = body as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// 無 body 時使用空 context
|
||||
}
|
||||
|
||||
const apiKey = c.req.header('X-Arcrun-API-Key') ?? undefined;
|
||||
const result = await executeWebhookGraph(c.env, record.graph, triggerContext, token, apiKey);
|
||||
|
||||
// fire-and-forget analytics(不阻擋回應)
|
||||
const graph = record.graph as { id?: string; nodes?: unknown[] };
|
||||
const workflowId = graph.id ?? token;
|
||||
const nodes = Array.isArray(graph.nodes) ? (graph.nodes as import('../types').GraphNode[]) : [];
|
||||
c.executionCtx.waitUntil(
|
||||
writeExecutionVerdict(c.env, workflowId, nodes, result.success ? 'success' : 'failed', result.duration_ms, result.error ?? ''),
|
||||
);
|
||||
|
||||
return c.json(result, result.success ? 200 : 500);
|
||||
});
|
||||
Reference in New Issue
Block a user