feat: 15 logic component Workers + cypher-executor auth/credentials routing
Component Workers: - Deploys if_control, switch, filter, merge, try_catch, wait, set, array_ops, string_ops, number_ops, date_ops, validate_json, ai_transform_compile, ai_transform_run, foreach_control as independent Workers, backing cypher-executor's SVC_* service bindings (fast internal RPC for logic components). cypher-executor routing: - New routes: /auth (recipe resolution), /credentials (CRUD), /webhooks/named (user-friendly alias for cmp_/rec_ hashes). - auth-recipe-seeds.ts: 20 pre-built platform auth recipes (Google Sheets, Gmail, Telegram, etc.) seeded into RECIPES KV. - graph-executor + cypher-handlers + search-nodes updated for the new resolution chain. - scripts/seed-auth-recipes.ts: one-shot tool to push seeds to KV. - wrangler.toml: 15 SVC_* bindings wired to the new logic Workers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* seed-auth-recipes.ts
|
||||
*
|
||||
* 將 auth-recipe-seeds.ts 中定義的 20 個 auth recipe 上傳至 cypher.arcrun.dev。
|
||||
*
|
||||
* 執行:
|
||||
* npx tsx scripts/seed-auth-recipes.ts
|
||||
*
|
||||
* 環境變數:
|
||||
* ARCRUN_API_URL - 預設 https://cypher.arcrun.dev
|
||||
*/
|
||||
|
||||
import { AUTH_RECIPE_SEEDS } from '../src/lib/auth-recipe-seeds.js';
|
||||
|
||||
const BASE_URL = process.env.ARCRUN_API_URL ?? 'https://cypher.arcrun.dev';
|
||||
|
||||
async function main() {
|
||||
console.log(`\n Seeding ${AUTH_RECIPE_SEEDS.length} auth recipes → ${BASE_URL}\n`);
|
||||
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
|
||||
for (const recipe of AUTH_RECIPE_SEEDS) {
|
||||
process.stdout.write(` ${recipe.service.padEnd(24)} `);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/auth-recipes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(recipe),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
console.log(`✓`);
|
||||
ok++;
|
||||
} else {
|
||||
const err = await res.text().catch(() => '');
|
||||
console.log(`✗ HTTP ${res.status}: ${err.slice(0, 100)}`);
|
||||
fail++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`✗ ${e instanceof Error ? e.message : String(e)}`);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n 完成:${ok} 成功,${fail} 失敗\n`);
|
||||
if (fail > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -17,11 +17,7 @@ export async function handleCypherSearch(
|
||||
throw new Error('無法解析任何節點');
|
||||
}
|
||||
|
||||
const { nodeResults, missingNodes } = await searchNodes(parsed, env.WASM_BUCKET);
|
||||
|
||||
if (missingNodes.length > 0) {
|
||||
return { nodes: nodeResults, cypher: null, missing: missingNodes };
|
||||
}
|
||||
const { nodeResults } = searchNodes(parsed);
|
||||
|
||||
const graph = buildExecutionGraph(parsed, nodeResults, 'cypher-search-result', 'Cypher Search Result');
|
||||
return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: [] };
|
||||
@@ -35,20 +31,14 @@ export async function handleCypherExecute(
|
||||
config: Record<string, Record<string, unknown>> | undefined,
|
||||
env: Bindings,
|
||||
waitUntil: (promise: Promise<void>) => void,
|
||||
apiKey?: string,
|
||||
): Promise<{ success: boolean; data?: unknown; error?: string; trace?: unknown; duration_ms: number; graph?: ExecutionGraph }> {
|
||||
const parsed = parseTriplets(triplets as unknown[]);
|
||||
if (!parsed) {
|
||||
throw new Error('無法解析任何節點');
|
||||
}
|
||||
|
||||
const { nodeResults, missingNodes } = await searchNodes(parsed, env.WASM_BUCKET);
|
||||
|
||||
if (missingNodes.length > 0) {
|
||||
throw new Error(
|
||||
`以下零件不存在於 WASM_BUCKET:${missingNodes.join(', ')}\n` +
|
||||
`修復:執行 acr parts 查看可用零件清單,或執行 acr validate <workflow.yaml> 進行完整驗證。`
|
||||
);
|
||||
}
|
||||
const { nodeResults } = searchNodes(parsed, config);
|
||||
|
||||
const graph = buildExecutionGraph(parsed, nodeResults, graphId, graphName, config);
|
||||
const parseResult = graphSchema.safeParse(graph);
|
||||
@@ -57,7 +47,7 @@ export async function handleCypherExecute(
|
||||
}
|
||||
|
||||
const loader = createComponentLoader(env);
|
||||
const executor = new GraphExecutor(loader, undefined, env);
|
||||
const executor = new GraphExecutor(loader, undefined, env, apiKey);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { BUILTIN_IDS } from '../lib/constants';
|
||||
import type { ParsedTriplets, NodeRole } from './triplet-parser';
|
||||
import { resolveNodeRole } from './triplet-parser';
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export type SearchResult = {
|
||||
nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }>;
|
||||
@@ -9,53 +7,34 @@ export type SearchResult = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 對所有節點進行解析,確認每個節點對應的零件是否存在。
|
||||
* 對所有節點進行解析,確認每個節點對應的零件 ID。
|
||||
*
|
||||
* 注意:此步驟只做靜態解析,不做遠端查找。
|
||||
* 零件是否真的存在由 component-loader 在執行時決定(Service Binding / KV / URL)。
|
||||
*
|
||||
* 優先序:
|
||||
* 1. Input/Output 角色:自動標記,不需查找
|
||||
* 2. 內建零件(BUILTIN_IDS):直接標記 found
|
||||
* 3. WASM_BUCKET 查找:確認 {componentId}/{componentId}.wasm 是否存在
|
||||
* 1. Input/Output 角色:自動標記,componentId = 小寫節點名稱
|
||||
* 2. config[nodeName].component 已指定:使用 config 提供的 componentId
|
||||
* 3. 其他:componentId = 節點名稱(交給 component-loader 在執行時解析)
|
||||
*/
|
||||
export async function searchNodes(
|
||||
export function searchNodes(
|
||||
parsed: ParsedTriplets,
|
||||
wasmBucket: R2Bucket,
|
||||
): Promise<SearchResult> {
|
||||
config?: Record<string, Record<string, unknown>>,
|
||||
): SearchResult {
|
||||
const nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }> = {};
|
||||
const missingNodes: string[] = [];
|
||||
|
||||
for (const nodeName of parsed.nodeNames) {
|
||||
const role = resolveNodeRole(nodeName, parsed);
|
||||
|
||||
// 事件源節點(起始點):自動標記 Input,不查 WASM_BUCKET
|
||||
if (role === 'Input') {
|
||||
if (role === 'Input' || role === 'Output') {
|
||||
nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role };
|
||||
continue;
|
||||
}
|
||||
|
||||
// 輸出節點
|
||||
if (role === 'Output') {
|
||||
nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role };
|
||||
continue;
|
||||
}
|
||||
|
||||
// 內建零件:直接標記 found
|
||||
if (BUILTIN_IDS.has(nodeName)) {
|
||||
nodeResults[nodeName] = { status: 'found', componentId: nodeName, type: role };
|
||||
continue;
|
||||
}
|
||||
|
||||
// WASM_BUCKET 查找:確認 {nodeName}/{nodeName}.wasm 是否存在
|
||||
// 節點名稱即零件 canonical_id(如 "gmail"、"telegram")
|
||||
const wasmKey = `${nodeName}/${nodeName}.wasm`;
|
||||
const obj = await wasmBucket.head(wasmKey);
|
||||
|
||||
if (obj) {
|
||||
nodeResults[nodeName] = { status: 'found', componentId: nodeName, type: role };
|
||||
} else {
|
||||
nodeResults[nodeName] = { status: 'missing', type: role };
|
||||
missingNodes.push(nodeName);
|
||||
}
|
||||
const configComponent = config?.[nodeName]?.component as string | undefined;
|
||||
const componentId = configComponent ?? nodeName;
|
||||
nodeResults[nodeName] = { status: 'found', componentId, type: role };
|
||||
}
|
||||
|
||||
return { nodeResults, missingNodes };
|
||||
return { nodeResults, missingNodes: [] };
|
||||
}
|
||||
|
||||
@@ -8,16 +8,13 @@ export async function resolveWebhookGraph(
|
||||
body: Record<string, unknown>,
|
||||
description: string,
|
||||
env: Bindings,
|
||||
): Promise<{ resolvedGraph: Record<string, unknown>; error?: string; missingNodes?: string[] }> {
|
||||
): Promise<{ resolvedGraph: Record<string, unknown>; error?: string }> {
|
||||
// 路徑 A:triplets 格式
|
||||
if (Array.isArray(body.triplets) && body.triplets.length > 0) {
|
||||
const parsed = parseTriplets(body.triplets as unknown[]);
|
||||
if (!parsed) return { resolvedGraph: {}, error: '無法解析 triplets' };
|
||||
|
||||
const { nodeResults, missingNodes } = await searchNodes(parsed, env.WASM_BUCKET);
|
||||
if (missingNodes.length > 0) {
|
||||
return { resolvedGraph: {}, error: `以下零件不存在:${missingNodes.join(', ')}。請執行 acr validate 確認所有零件已上傳。`, missingNodes };
|
||||
}
|
||||
const { nodeResults } = searchNodes(parsed);
|
||||
|
||||
const graphId = `webhook-${Date.now()}`;
|
||||
const graphName = description || `Webhook ${new Date().toISOString()}`;
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function executeWebhookGraph(
|
||||
graph: Record<string, unknown>,
|
||||
triggerContext: Record<string, unknown>,
|
||||
token: string,
|
||||
apiKey?: string,
|
||||
): Promise<{ success: boolean; data?: unknown; error?: string; trace?: unknown; duration_ms: number }> {
|
||||
const parsed = graphSchema.safeParse(graph);
|
||||
if (!parsed.success) {
|
||||
@@ -35,7 +36,7 @@ export async function executeWebhookGraph(
|
||||
}
|
||||
|
||||
const loader = createComponentLoader(env);
|
||||
const executor = new GraphExecutor(loader, undefined, env);
|
||||
const executor = new GraphExecutor(loader, undefined, env, apiKey);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { ExecutionGraph, GraphNode, TraceStep, ComponentRunner, KVContextStore, EdgeType, Bindings } from './types';
|
||||
import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError } from './types';
|
||||
import { injectCredentials } from './actions/credential-injector';
|
||||
import { tryAuthDispatch } from './actions/auth-dispatcher';
|
||||
|
||||
export type ComponentLoader = (componentId: string) => Promise<ComponentRunner>;
|
||||
export type WorkflowLoader = (workflowId: string) => Promise<ExecutionGraph>;
|
||||
@@ -13,12 +14,14 @@ export class GraphExecutor {
|
||||
private loader: ComponentLoader;
|
||||
private workflowLoader?: WorkflowLoader;
|
||||
private env?: Bindings;
|
||||
private apiKey?: string;
|
||||
public recordComponentReference?: (componentId: string, workflowId: string) => Promise<void>;
|
||||
|
||||
constructor(loader: ComponentLoader, workflowLoader?: WorkflowLoader, env?: Bindings) {
|
||||
constructor(loader: ComponentLoader, workflowLoader?: WorkflowLoader, env?: Bindings, apiKey?: string) {
|
||||
this.loader = loader;
|
||||
this.workflowLoader = workflowLoader;
|
||||
this.env = env;
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async execute(
|
||||
@@ -106,15 +109,29 @@ export class GraphExecutor {
|
||||
case 'Component': {
|
||||
if (!node.componentId) throw new Error(`節點 ${node.id} 缺少 componentId`);
|
||||
const runner = await this.loader(node.componentId);
|
||||
const ctx = context as Record<string, unknown>;
|
||||
// node.data 的 string 值支援 {{variable}} 替換(從 context 取值)
|
||||
const resolvedData = interpolateData(node.data, ctx);
|
||||
// 優先順序:node.data(靜態參數,如 pattern/sheet)> context(全局參數)
|
||||
let mergedContext: Record<string, unknown> = {
|
||||
...(context as Record<string, unknown>),
|
||||
...(node.data ?? {}),
|
||||
...ctx,
|
||||
...resolvedData,
|
||||
};
|
||||
|
||||
// Credential 注入:在 WASM 執行前自動注入 credentials_required 中宣告的 token
|
||||
if (this.env) {
|
||||
mergedContext = await injectCredentials(node.componentId, mergedContext, this.env);
|
||||
// 先試 auth dispatcher(新路徑,走 auth primitive WASM Worker via HTTP)
|
||||
// 命中才 return;否則 fallback 到舊 injectCredentials(Phase 1.9 會刪除)
|
||||
if (this.apiKey) {
|
||||
const dispatched = await tryAuthDispatch(node.componentId, mergedContext, this.env, this.apiKey);
|
||||
if (dispatched) {
|
||||
mergedContext = dispatched;
|
||||
} else {
|
||||
mergedContext = await injectCredentials(node.componentId, mergedContext, this.env, this.apiKey);
|
||||
}
|
||||
} else {
|
||||
mergedContext = await injectCredentials(node.componentId, mergedContext, this.env, this.apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
nodeInput = mergedContext;
|
||||
@@ -207,18 +224,16 @@ export class GraphExecutor {
|
||||
}
|
||||
|
||||
case 'ON_SUCCESS': {
|
||||
// 只在上游節點成功(無 error)時執行
|
||||
const hasError = result && typeof result === 'object' && 'error' in (result as object);
|
||||
if (!hasError) {
|
||||
// 只在上游節點成功時執行:success !== false 且無 error key
|
||||
if (!isFailure(result)) {
|
||||
result = await this.executeNode(nextNode, graph, result, visited, trace, fanIn, kvStore);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ON_FAIL': {
|
||||
// 只在上游節點失敗(有 error)時執行,傳遞 error context
|
||||
const hasError = result && typeof result === 'object' && 'error' in (result as object);
|
||||
if (hasError) {
|
||||
// 只在上游節點失敗時執行:success === false 或有 error key
|
||||
if (isFailure(result)) {
|
||||
result = await this.executeNode(nextNode, graph, result, visited, trace, fanIn, kvStore);
|
||||
}
|
||||
break;
|
||||
@@ -295,6 +310,33 @@ export class GraphExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/** node.data 的 string 值支援 {{variable}} 替換,從 context 取值 */
|
||||
function interpolateData(
|
||||
data: Record<string, unknown> | undefined,
|
||||
ctx: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (!data) return {};
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (typeof v === 'string') {
|
||||
result[k] = v.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
||||
const val = ctx[key];
|
||||
return val !== undefined ? String(val) : `{{${key}}}`;
|
||||
});
|
||||
} else {
|
||||
result[k] = v;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 判斷節點執行結果是否為失敗:success === false 或含有 error key */
|
||||
function isFailure(result: unknown): boolean {
|
||||
if (!result || typeof result !== 'object') return false;
|
||||
const r = result as Record<string, unknown>;
|
||||
return r['success'] === false || 'error' in r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全條件評估(不使用 new Function)
|
||||
* 支援格式:ctx.key === value, ctx.key > value, ctx.key(truthy)
|
||||
|
||||
@@ -12,6 +12,9 @@ import { webhooksCrudRouter } from './routes/webhooks-crud';
|
||||
import { webhooksListRouter } from './routes/webhooks-list';
|
||||
import { registerRouter } from './routes/register';
|
||||
import { recipesRouter } from './routes/recipes';
|
||||
import { credentialsRouter } from './routes/credentials';
|
||||
import { webhooksNamedRouter } from './routes/webhooks-named';
|
||||
import { authRouter } from './routes/auth';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -25,10 +28,13 @@ app.route('/', executeRouter);
|
||||
app.route('/', cypherRouter);
|
||||
app.route('/', validateRouter);
|
||||
app.route('/', webhooksRouter);
|
||||
app.route('/', webhooksNamedRouter); // 必須在 webhooksCrudRouter 前(避免 /webhooks/:token 攔截 /webhooks/named)
|
||||
app.route('/', webhooksCrudRouter);
|
||||
app.route('/', webhooksListRouter);
|
||||
app.route('/', registerRouter);
|
||||
app.route('/', recipesRouter);
|
||||
app.route('/', credentialsRouter);
|
||||
app.route('/', authRouter);
|
||||
|
||||
// Worker 導出
|
||||
export default app;
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
/**
|
||||
* Auth Recipe Seeds
|
||||
*
|
||||
* 平台預建的 auth recipe 定義,部署時寫入 RECIPES KV。
|
||||
* 新增服務 = 在此加一筆,不需改其他程式碼。
|
||||
*
|
||||
* KV key: auth_recipe:{service}
|
||||
*/
|
||||
|
||||
import type { AuthRecipeDefinition } from '../routes/recipes';
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
export const AUTH_RECIPE_SEEDS: AuthRecipeDefinition[] = [
|
||||
// ── Static Key 類 ──────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'notion',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.notion.com/v1',
|
||||
display_name: 'Notion',
|
||||
description: 'Notion API — 頁面、資料庫讀寫',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'notion_token',
|
||||
label: 'Internal Integration Token',
|
||||
help: '至 https://www.notion.so/my-integrations 建立 Integration',
|
||||
help_url: 'https://www.notion.so/my-integrations',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{secret.notion_token}}',
|
||||
'Notion-Version': '2022-06-28',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'slack',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://slack.com/api',
|
||||
display_name: 'Slack',
|
||||
description: 'Slack Bot API — 發訊息、查頻道',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'slack_bot_token',
|
||||
label: 'Bot User OAuth Token (xoxb-...)',
|
||||
help: '至 https://api.slack.com/apps 建立 App,取得 Bot Token',
|
||||
help_url: 'https://api.slack.com/apps',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{secret.slack_bot_token}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'github',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.github.com',
|
||||
display_name: 'GitHub',
|
||||
description: 'GitHub REST API — repo、issue、PR 操作',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'github_token',
|
||||
label: 'Personal Access Token (classic 或 fine-grained)',
|
||||
help: '至 https://github.com/settings/tokens 建立',
|
||||
help_url: 'https://github.com/settings/tokens',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{secret.github_token}}',
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'openai',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
display_name: 'OpenAI',
|
||||
description: 'OpenAI API — Chat Completions、Embeddings 等',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'openai_api_key',
|
||||
label: 'API Key (sk-...)',
|
||||
help: '至 https://platform.openai.com/api-keys 建立',
|
||||
help_url: 'https://platform.openai.com/api-keys',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{secret.openai_api_key}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'anthropic',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.anthropic.com/v1',
|
||||
display_name: 'Anthropic (Claude)',
|
||||
description: 'Anthropic API — Claude 模型呼叫',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'anthropic_api_key',
|
||||
label: 'API Key',
|
||||
help: '至 https://console.anthropic.com/settings/keys 建立',
|
||||
help_url: 'https://console.anthropic.com/settings/keys',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
'x-api-key': '{{secret.anthropic_api_key}}',
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'airtable',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.airtable.com/v0',
|
||||
display_name: 'Airtable',
|
||||
description: 'Airtable API — 讀寫 Base 資料',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'airtable_token',
|
||||
label: 'Personal Access Token',
|
||||
help: '至 https://airtable.com/create/tokens 建立',
|
||||
help_url: 'https://airtable.com/create/tokens',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{secret.airtable_token}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'discord',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://discord.com/api/v10',
|
||||
display_name: 'Discord',
|
||||
description: 'Discord Bot API — 發訊息、管理伺服器',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'discord_bot_token',
|
||||
label: 'Bot Token',
|
||||
help: '至 https://discord.com/developers/applications 建立 Bot,取得 Token',
|
||||
help_url: 'https://discord.com/developers/applications',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bot {{secret.discord_bot_token}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'stripe',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.stripe.com/v1',
|
||||
display_name: 'Stripe',
|
||||
description: 'Stripe API — 支付、客戶、訂閱管理',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'stripe_secret_key',
|
||||
label: 'Secret Key (sk_live_... 或 sk_test_...)',
|
||||
help: '至 https://dashboard.stripe.com/apikeys 取得',
|
||||
help_url: 'https://dashboard.stripe.com/apikeys',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{secret.stripe_secret_key}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'twilio',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.twilio.com/2010-04-01',
|
||||
display_name: 'Twilio',
|
||||
description: 'Twilio API — SMS、電話、WhatsApp',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'twilio_account_sid',
|
||||
label: 'Account SID',
|
||||
help: '至 https://console.twilio.com/ 取得',
|
||||
help_url: 'https://console.twilio.com/',
|
||||
},
|
||||
{
|
||||
key: 'twilio_auth_token',
|
||||
label: 'Auth Token',
|
||||
help: '至 https://console.twilio.com/ 取得',
|
||||
help_url: 'https://console.twilio.com/',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Basic {{secret.twilio_account_sid}}:{{secret.twilio_auth_token}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'sendgrid',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.sendgrid.com/v3',
|
||||
display_name: 'SendGrid',
|
||||
description: 'SendGrid Email API — 發送交易郵件',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'sendgrid_api_key',
|
||||
label: 'API Key (SG....)',
|
||||
help: '至 https://app.sendgrid.com/settings/api_keys 建立',
|
||||
help_url: 'https://app.sendgrid.com/settings/api_keys',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{secret.sendgrid_api_key}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'hubspot',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.hubapi.com',
|
||||
display_name: 'HubSpot',
|
||||
description: 'HubSpot CRM API — 聯絡人、公司、交易管理',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'hubspot_token',
|
||||
label: 'Private App Access Token',
|
||||
help: '至 HubSpot Settings → Integrations → Private Apps 建立',
|
||||
help_url: 'https://developers.hubspot.com/docs/api/private-apps',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{secret.hubspot_token}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'linear',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.linear.app',
|
||||
display_name: 'Linear',
|
||||
description: 'Linear API — Issue、Project 管理',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'linear_api_key',
|
||||
label: 'Personal API Key',
|
||||
help: '至 https://linear.app/settings/api 建立',
|
||||
help_url: 'https://linear.app/settings/api',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: '{{secret.linear_api_key}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'shopify',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://{{secret.shopify_store}}.myshopify.com/admin/api/2024-01',
|
||||
display_name: 'Shopify',
|
||||
description: 'Shopify Admin API — 訂單、商品、客戶管理',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'shopify_access_token',
|
||||
label: 'Admin API Access Token',
|
||||
help: '至 Shopify Admin → Apps → App and sales channel settings → Private apps',
|
||||
help_url: 'https://shopify.dev/docs/apps/auth/admin-app-access-tokens',
|
||||
},
|
||||
{
|
||||
key: 'shopify_store',
|
||||
label: 'Store subdomain(不含 .myshopify.com)',
|
||||
help: '例如 my-store(對應 my-store.myshopify.com)',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
'X-Shopify-Access-Token': '{{secret.shopify_access_token}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'resend',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.resend.com',
|
||||
display_name: 'Resend',
|
||||
description: 'Resend Email API — 發送交易郵件',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'resend_api_key',
|
||||
label: 'API Key (re_...)',
|
||||
help: '至 https://resend.com/api-keys 建立',
|
||||
help_url: 'https://resend.com/api-keys',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{secret.resend_api_key}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'supabase',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://{{secret.supabase_project_ref}}.supabase.co/rest/v1',
|
||||
display_name: 'Supabase',
|
||||
description: 'Supabase REST API — 資料庫讀寫',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'supabase_service_key',
|
||||
label: 'Service Role Key (eyJ...)',
|
||||
help: '至 Supabase Project Settings → API → service_role key',
|
||||
help_url: 'https://supabase.com/dashboard',
|
||||
},
|
||||
{
|
||||
key: 'supabase_project_ref',
|
||||
label: 'Project Reference ID(URL 中的 xxx.supabase.co 的 xxx)',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{secret.supabase_service_key}}',
|
||||
apikey: '{{secret.supabase_service_key}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'typeform',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.typeform.com',
|
||||
display_name: 'Typeform',
|
||||
description: 'Typeform API — 表單、問卷回應讀取',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'typeform_token',
|
||||
label: 'Personal Access Token',
|
||||
help: '至 https://admin.typeform.com/account#/section/tokens 建立',
|
||||
help_url: 'https://developer.typeform.com/get-started/',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{secret.typeform_token}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'jira',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://{{secret.jira_domain}}.atlassian.net/rest/api/3',
|
||||
display_name: 'Jira',
|
||||
description: 'Jira API — Issue、Sprint、Project 管理',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'jira_api_token',
|
||||
label: 'API Token',
|
||||
help: '至 https://id.atlassian.com/manage-profile/security/api-tokens 建立',
|
||||
help_url: 'https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/',
|
||||
},
|
||||
{
|
||||
key: 'jira_email',
|
||||
label: '你的 Atlassian 帳號 Email',
|
||||
},
|
||||
{
|
||||
key: 'jira_domain',
|
||||
label: 'Jira 子網域(xxx.atlassian.net 的 xxx)',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Basic {{secret.jira_email}}:{{secret.jira_api_token}}',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
// ── Service Account 類(Google 家族,共用同一份 service_account_json)────────
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'google_sheets_sa',
|
||||
version: 1,
|
||||
primitive: 'service_account',
|
||||
service_account_kind: 'google_jwt',
|
||||
base_url: 'https://sheets.googleapis.com/v4',
|
||||
display_name: 'Google Sheets (Service Account)',
|
||||
description: 'Google Sheets API — 試算表讀寫(使用 Service Account)',
|
||||
token_exchange: {
|
||||
endpoint: 'https://oauth2.googleapis.com/token',
|
||||
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
||||
},
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'google_service_account',
|
||||
label: 'Service Account JSON(整份貼上)',
|
||||
type: 'json_blob',
|
||||
help: '至 GCP Console → IAM → Service Accounts → Keys → Add Key → JSON,下載後整份貼入',
|
||||
help_url: 'https://console.cloud.google.com/iam-admin/serviceaccounts',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{runtime.access_token}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'google_gmail_sa',
|
||||
version: 1,
|
||||
primitive: 'service_account',
|
||||
service_account_kind: 'google_jwt',
|
||||
base_url: 'https://gmail.googleapis.com/gmail/v1',
|
||||
display_name: 'Gmail (Service Account)',
|
||||
description: 'Gmail API — 發送郵件(使用 Service Account + Domain-Wide Delegation)',
|
||||
token_exchange: {
|
||||
endpoint: 'https://oauth2.googleapis.com/token',
|
||||
scopes: ['https://www.googleapis.com/auth/gmail.send'],
|
||||
},
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'google_service_account',
|
||||
label: 'Service Account JSON(整份貼上)',
|
||||
type: 'json_blob',
|
||||
help: '需要 Domain-Wide Delegation,至 GCP Console → IAM → Service Accounts 設定',
|
||||
help_url: 'https://developers.google.com/workspace/guides/create-credentials#service-account',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{runtime.access_token}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'google_drive_sa',
|
||||
version: 1,
|
||||
primitive: 'service_account',
|
||||
service_account_kind: 'google_jwt',
|
||||
base_url: 'https://www.googleapis.com/drive/v3',
|
||||
display_name: 'Google Drive (Service Account)',
|
||||
description: 'Google Drive API — 檔案上傳、下載、管理(使用 Service Account)',
|
||||
token_exchange: {
|
||||
endpoint: 'https://oauth2.googleapis.com/token',
|
||||
scopes: ['https://www.googleapis.com/auth/drive'],
|
||||
},
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'google_service_account',
|
||||
label: 'Service Account JSON(整份貼上)',
|
||||
type: 'json_blob',
|
||||
help: '至 GCP Console → IAM → Service Accounts → Keys → Add Key → JSON',
|
||||
help_url: 'https://console.cloud.google.com/iam-admin/serviceaccounts',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{runtime.access_token}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,414 @@
|
||||
// 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);
|
||||
}
|
||||
|
||||
/** 產生隨機 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: 'select_account',
|
||||
});
|
||||
|
||||
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 };
|
||||
|
||||
// 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;
|
||||
|
||||
} 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 };
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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; 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; 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);
|
||||
|
||||
// Invalidate all current sessions for this user (simple: sessions will re-auth on next request)
|
||||
// (Full invalidation would require listing all sessions, skip for now)
|
||||
|
||||
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; 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 });
|
||||
});
|
||||
@@ -57,6 +57,8 @@ cypherRouter.post('/cypher/execute', async (c) => {
|
||||
// 版本號格式: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[],
|
||||
@@ -66,6 +68,7 @@ cypherRouter.post('/cypher/execute', async (c) => {
|
||||
body.config,
|
||||
c.env,
|
||||
(p) => c.executionCtx.waitUntil(p),
|
||||
apiKey,
|
||||
);
|
||||
// 包裝成開發友善格式(execute 成功時)
|
||||
const response = {
|
||||
|
||||
@@ -123,3 +123,118 @@ export async function resolveRecipe(
|
||||
// 直接用 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 });
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ registerRouter.post('/register', async (c) => {
|
||||
return c.json({
|
||||
success: true,
|
||||
api_key: apiKey,
|
||||
encryption_key: encryptionKey, // 用戶需要此 key 才能加密上傳 credential
|
||||
email,
|
||||
message: 'API Key 已發放,請妥善保存。相同 email 永遠得到相同的 Key。',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
type NamedWorkflowRecord = {
|
||||
name: string;
|
||||
graph: Record<string, unknown>;
|
||||
config?: Record<string, unknown>;
|
||||
description: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
function kvKey(apiKey: string, name: string): string {
|
||||
return `${apiKey}:wf:${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;
|
||||
} | 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);
|
||||
}
|
||||
|
||||
const record: NamedWorkflowRecord = {
|
||||
name,
|
||||
graph: body.graph,
|
||||
config: body.config,
|
||||
description: typeof body.description === 'string' ? body.description : '',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await c.env.WEBHOOKS.put(kvKey(apiKey, name), JSON.stringify(record));
|
||||
|
||||
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 result = await executeWebhookGraph(c.env, record.graph, triggerContext, name, apiKey);
|
||||
|
||||
const graph = record.graph as { id?: string; nodes?: unknown[] };
|
||||
const workflowId = graph.id ?? name;
|
||||
const nodes = Array.isArray(graph.nodes) ? (graph.nodes as 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);
|
||||
});
|
||||
|
||||
// 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));
|
||||
return c.json({ deleted: true, name });
|
||||
});
|
||||
@@ -21,11 +21,7 @@ webhooksRouter.post('/webhooks', async (c) => {
|
||||
const resolved = await resolveWebhookGraph(body as Record<string, unknown>, description, c.env);
|
||||
|
||||
if (resolved.error) {
|
||||
const statusCode = resolved.missingNodes ? 422 : 400;
|
||||
return c.json(
|
||||
{ error: resolved.error, ...(resolved.missingNodes && { missing: resolved.missingNodes }) },
|
||||
statusCode,
|
||||
);
|
||||
return c.json({ error: resolved.error }, 400);
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
@@ -69,7 +65,8 @@ webhooksRouter.post('/webhooks/:token/trigger', async (c) => {
|
||||
// 無 body 時使用空 context
|
||||
}
|
||||
|
||||
const result = await executeWebhookGraph(c.env, record.graph, triggerContext, token);
|
||||
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[] };
|
||||
|
||||
@@ -34,12 +34,22 @@ export type Bindings = {
|
||||
ANALYTICS_KV: KVNamespace;
|
||||
// R2 Bucket:WASM 零件二進位
|
||||
WASM_BUCKET: R2Bucket;
|
||||
// Users:OAuth 登入用戶帳號(key = user:{provider}:{provider_id})
|
||||
USERS_KV: KVNamespace;
|
||||
// Sessions:登入 session(key = sess:{session_id},TTL 7d)
|
||||
SESSIONS_KV: KVNamespace;
|
||||
// Workers AI
|
||||
AI: Ai;
|
||||
// 環境變數
|
||||
ENVIRONMENT: string;
|
||||
ENCRYPTION_KEY: string; // hex-encoded 256-bit AES key(wrangler secret)
|
||||
MULTI_TENANT?: string; // "false" = Self-hosted 單租戶模式,預設 "true"
|
||||
// OAuth Secrets(wrangler secret)
|
||||
GOOGLE_CLIENT_ID?: string;
|
||||
GOOGLE_CLIENT_SECRET?: string;
|
||||
GITHUB_CLIENT_ID?: string;
|
||||
GITHUB_CLIENT_SECRET?: string;
|
||||
SESSION_SIGNING_SECRET?: string; // 用於 HMAC session ID(可選,也可直接用 UUID)
|
||||
};
|
||||
|
||||
// 圖結構定義
|
||||
|
||||
@@ -23,6 +23,14 @@ id = "a43b7997c8e54a34886c2995a853c720"
|
||||
binding = "RECIPES"
|
||||
id = "9cf9db905c6241f78503199e58b2ffe0"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "USERS_KV"
|
||||
id = "25bef01d079148919578894434d58c4d"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "SESSIONS_KV"
|
||||
id = "455d0505c7534883a4d4985ab8295857"
|
||||
|
||||
[[r2_buckets]]
|
||||
binding = "WASM_BUCKET"
|
||||
bucket_name = "arcrun-wasm"
|
||||
|
||||
Reference in New Issue
Block a user