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,653 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'gemini',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
display_name: 'Google Gemini',
|
||||
description: 'Google Gemini API — generateContent / embedContent(使用 API Key)',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'gemini_api_key',
|
||||
label: 'API Key',
|
||||
help: '至 https://aistudio.google.com/apikey 建立',
|
||||
help_url: 'https://aistudio.google.com/apikey',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
'x-goog-api-key': '{{secret.gemini_api_key}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'trello',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.trello.com/1',
|
||||
display_name: 'Trello',
|
||||
description: 'Trello API — boards / cards / lists(API key + token 走 query string)',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'trello_api_key',
|
||||
label: 'API Key',
|
||||
help: '至 https://trello.com/power-ups/admin 建立 Power-Up 後取得',
|
||||
help_url: 'https://trello.com/power-ups/admin',
|
||||
},
|
||||
{
|
||||
key: 'trello_token',
|
||||
label: 'Token',
|
||||
help: '於 Power-Up 頁面點「Generate Token」授權後取得',
|
||||
help_url: 'https://trello.com/power-ups/admin',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
query: {
|
||||
key: '{{secret.trello_api_key}}',
|
||||
token: '{{secret.trello_token}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'mailgun',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.mailgun.net/v3',
|
||||
display_name: 'Mailgun',
|
||||
description: 'Mailgun API — 寄信(username 固定 "api",password 為 Private API Key,走 Basic Auth)',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'mailgun_api_key',
|
||||
label: 'Private API Key',
|
||||
help: '至 Mailgun Dashboard → API Security → Sending Keys 建立',
|
||||
help_url: 'https://app.mailgun.com/mg/sending/domains',
|
||||
},
|
||||
{
|
||||
key: 'mailgun_domain',
|
||||
label: 'Sending Domain',
|
||||
help: '你在 Mailgun 設定好的 sending domain(例:mg.yourdomain.com)',
|
||||
help_url: 'https://app.mailgun.com/mg/sending/domains',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Basic api:{{secret.mailgun_api_key}}',
|
||||
},
|
||||
},
|
||||
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,365 @@
|
||||
/**
|
||||
* arcrun component loader
|
||||
*
|
||||
* 解析優先序:
|
||||
*
|
||||
* 0. trigger_workflow 內建 orchestration 零件(in-process call,繞 CF self-fetch 死鎖)
|
||||
* 1. 內建零件(BUILTIN_COMPONENTS)— 純 JS,最快
|
||||
* 2. 外部 URL(https://...)— 直接 fetch,n8n/MCP/任何 HTTP 服務
|
||||
* 3. cmp_xxxxxxxx hash → 查 WEBHOOKS KV idx → canonical_id → 邏輯 Worker
|
||||
* 4. rec_xxxxxxxx hash → 查 RECIPES KV idx → recipe 執行
|
||||
* 5. 邏輯零件 canonical_id → Service Binding(同帳號不走公網)
|
||||
* 5.5. Auth recipe(平台預建)→ Auth Recipe Runner
|
||||
* 6. KV recipe canonical_id → 從 RECIPES KV 讀取 recipe → fetch 外部 API
|
||||
* 7. WASM HTTP runner(auth primitive / API 零件 → 獨立 Worker URL)
|
||||
* 8. 找不到 → 報錯
|
||||
*/
|
||||
|
||||
import { BUILTIN_COMPONENTS } from './constants';
|
||||
import { isComponentHash, isRecipeHash } from './hash';
|
||||
import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes';
|
||||
import type { AuthRecipeDefinition } from '../routes/recipes';
|
||||
import type { Bindings, ComponentRunner, ServiceBinding } from '../types';
|
||||
|
||||
/**
|
||||
* WASM HTTP runner:canonical_id → 對應獨立 Worker URL。
|
||||
*
|
||||
* 所有 WASM 零件(auth primitive / API 零件 / 未來用戶自製)都是獨立部署的 Worker,
|
||||
* 以 `{canonical-id-kebab}.arcrun.dev` 為 URL 慣例。cypher-executor 不做 WASM
|
||||
* instantiate,只做 HTTP fetch。這層是 API 零件(及 auth primitive)的唯一入口。
|
||||
*
|
||||
* R2 動態注入 WASM 路徑作廢(CF workerd 不支援以 R2 物件臨時 instantiate)。
|
||||
*/
|
||||
// TODO(架構債,2026-05-07):白名單寫死違反 arcrun 「新零件無需改 cypher-executor」承諾
|
||||
// 應改為從 component-registry KV 動態查(registry 已有 backfill index,知道所有 canonical_id)
|
||||
// SDD 待開:cypher-executor-dynamic-component-discovery
|
||||
const WASM_HTTP_RUNNER_IDS: ReadonlySet<string> = new Set([
|
||||
// 通用 HTTP 零件
|
||||
'http_request',
|
||||
// gmail / telegram / line_notify / google_sheets 已降級為 recipe(2026-05-29 Phase 2):
|
||||
// recipe:gmail_send / telegram_send / line_notify_send / google_sheets_read|append
|
||||
// 走 step 6 KV recipe 解析,不再是零件。零件目錄已刪。
|
||||
'cron',
|
||||
// Auth primitives
|
||||
'auth_static_key',
|
||||
'auth_service_account',
|
||||
'auth_oauth2',
|
||||
'auth_mtls',
|
||||
]);
|
||||
|
||||
/**
|
||||
* canonical_id → component worker URL(走 workers.dev 子域,避開同 zone 自循環死鎖)
|
||||
*
|
||||
* 為何不用 *.arcrun.dev:cypher-executor 本身綁 cypher.arcrun.dev/*,
|
||||
* fetch 同 zone *.arcrun.dev 會撞 CF 的 zone 自循環防護回 522。
|
||||
* 詳見 arcrun.md P0 #9(2026-05-13)。
|
||||
*
|
||||
* subdomain 來自 wrangler.toml [vars] WORKER_SUBDOMAIN(預設 uncle6-me,self-hosted fork 改自己的)。
|
||||
*/
|
||||
export function wasmWorkerUrl(canonicalId: string, subdomain: string): string {
|
||||
const kebab = canonicalId.replace(/_/g, '-');
|
||||
// 平台慣例:component worker 名稱 = `arcrun-{kebab}`(見 rule 03 / rule 05),
|
||||
// 例如 canonical_id=http_request → worker 名 arcrun-http-request → URL arcrun-http-request.{subdomain}.workers.dev
|
||||
return `https://arcrun-${kebab}.${subdomain}.workers.dev`;
|
||||
}
|
||||
|
||||
/** 邏輯零件 canonical_id → Service Binding key */
|
||||
const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
|
||||
if_control: 'SVC_IF_CONTROL',
|
||||
switch: 'SVC_SWITCH',
|
||||
foreach_control: 'SVC_FOREACH_CONTROL',
|
||||
filter: 'SVC_FILTER',
|
||||
merge: 'SVC_MERGE',
|
||||
try_catch: 'SVC_TRY_CATCH',
|
||||
wait: 'SVC_WAIT',
|
||||
set: 'SVC_SET',
|
||||
array_ops: 'SVC_ARRAY_OPS',
|
||||
string_ops: 'SVC_STRING_OPS',
|
||||
number_ops: 'SVC_NUMBER_OPS',
|
||||
date_ops: 'SVC_DATE_OPS',
|
||||
validate_json: 'SVC_VALIDATE_JSON',
|
||||
// ai_transform_compile / ai_transform_run 已刪除(2026-05-29):
|
||||
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
|
||||
};
|
||||
|
||||
export function createComponentLoader(env: Bindings) {
|
||||
return async (componentId: string): Promise<ComponentRunner> => {
|
||||
|
||||
// 0. 平台內建 orchestration 零件(需要 env / 跨 workflow 能力)
|
||||
// 這類零件「是 orchestrator 的職責」(不是業務邏輯),故不違反「業務邏輯走 WASM」規則。
|
||||
// 目前只有 trigger_workflow:用 in-process call 觸發另一個 named workflow,
|
||||
// 繞掉 CF 同 zone self-fetch 死鎖(避免 cypher-executor 自打 http_request → 1042)。
|
||||
if (componentId === 'trigger_workflow') {
|
||||
return makeTriggerWorkflowRunner(env);
|
||||
}
|
||||
|
||||
// 1. 內建零件(純 JS,最優先)
|
||||
const builtin = BUILTIN_COMPONENTS.get(componentId);
|
||||
if (builtin) return builtin;
|
||||
|
||||
// 2. 外部 URL
|
||||
if (componentId.startsWith('http://') || componentId.startsWith('https://')) {
|
||||
return makeHttpRunner(componentId);
|
||||
}
|
||||
|
||||
// 3. cmp_hash → 查 WEBHOOKS KV idx → canonical_id → 邏輯 Worker
|
||||
if (isComponentHash(componentId)) {
|
||||
const canonicalId = await env.WEBHOOKS.get(`idx:${componentId}`);
|
||||
if (canonicalId) {
|
||||
const runner = makeLogicRunner(canonicalId, env);
|
||||
if (runner) return runner;
|
||||
}
|
||||
throw new Error(`找不到零件 hash "${componentId}",請確認已透過 acr push 上傳`);
|
||||
}
|
||||
|
||||
// 4. rec_hash → 查 RECIPES KV idx → recipe 執行
|
||||
if (isRecipeHash(componentId)) {
|
||||
const recipe = await resolveRecipe(componentId, env.RECIPES);
|
||||
if (recipe) return makeRecipeRunner(recipe);
|
||||
throw new Error(`找不到 recipe hash "${componentId}",請確認已透過 acr push 上傳`);
|
||||
}
|
||||
|
||||
// 5. 邏輯零件 canonical_id → Service Binding
|
||||
const logicRunner = makeLogicRunner(componentId, env);
|
||||
if (logicRunner) return logicRunner;
|
||||
|
||||
// 5.5 Auth recipe(平台預建,auth_recipe:{service} in RECIPES KV)
|
||||
const authRecipe = await resolveAuthRecipe(componentId, env.RECIPES);
|
||||
if (authRecipe) return makeAuthRecipeRunner(authRecipe);
|
||||
|
||||
// 6. KV recipe(動態,用戶 push 的)
|
||||
const kvRecipe = await resolveRecipe(componentId, env.RECIPES);
|
||||
if (kvRecipe) return makeRecipeRunner(kvRecipe);
|
||||
|
||||
// 7. WASM HTTP runner:auth primitive / API 零件 → 獨立 Worker URL
|
||||
// 白名單見 WASM_HTTP_RUNNER_IDS(http_request、5 個待降級 API 零件、4 個 auth primitive)。
|
||||
// 對應 Worker 部署於 arcrun-{canonical-id-kebab}.{WORKER_SUBDOMAIN}.workers.dev
|
||||
// (見 P0 #9 / rule 03)。
|
||||
if (WASM_HTTP_RUNNER_IDS.has(componentId)) {
|
||||
return makeHttpRunner(wasmWorkerUrl(componentId, env.WORKER_SUBDOMAIN));
|
||||
}
|
||||
|
||||
// 8. 找不到
|
||||
throw new Error(
|
||||
`找不到零件 "${componentId}"。\n` +
|
||||
`邏輯零件:${Object.keys(LOGIC_BINDING_MAP).join(', ')}\n` +
|
||||
`或傳入外部 URL(https://...)、recipe hash(rec_xxxxxxxx)、零件 hash(cmp_xxxxxxxx)`
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// ── 執行器工廠 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* trigger_workflow 內建 orchestration 零件
|
||||
*
|
||||
* 用途:在 workflow A 內 in-process 觸發 workflow B,繞 CF 同 zone self-fetch 死鎖。
|
||||
*
|
||||
* 動機:mira_feed_watcher 之前用 http_request 自打 cypher.arcrun.dev → CF 1042。
|
||||
* 就算改打 arcrun-cypher-executor.{subdomain}.workers.dev,Worker → 自身 URL 仍
|
||||
* 被 CF 「self subrequest」防護擋(即使 hostname 不同)。
|
||||
* 改用 in-process call executeWebhookGraph 徹底繞掉外部 HTTP。
|
||||
*
|
||||
* 不違反「業務邏輯走 WASM」鐵律:trigger_workflow 是 orchestrator 自己的 routing 能力
|
||||
* (像 CALLS_SUBFLOW),不是業務邏輯(不解密 / 不簽 JWT / 不打外部 API)。
|
||||
*
|
||||
* Input ctx:
|
||||
* - workflow_name: string (必填,目標 workflow 名稱)
|
||||
* - api_key: string (必填,KV 查 key prefix)
|
||||
* - input: object (可選,傳給子 workflow 當 triggerContext)
|
||||
* - wait: boolean (預設 true,await 完成;false = fire-and-forget 用 waitUntil)
|
||||
*
|
||||
* 動態 import webhook-handlers 避循環依賴(webhook-handlers → component-loader → 自己)。
|
||||
*/
|
||||
function makeTriggerWorkflowRunner(env: Bindings): ComponentRunner {
|
||||
return async (ctx: unknown) => {
|
||||
const c = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
|
||||
const workflowName = String(c.workflow_name ?? '');
|
||||
const apiKey = String(c.api_key ?? '');
|
||||
const input = (c.input && typeof c.input === 'object')
|
||||
? c.input as Record<string, unknown>
|
||||
: {};
|
||||
const wait = c.wait !== false; // 預設 true
|
||||
|
||||
if (!workflowName) return { success: false, error: 'trigger_workflow 缺 workflow_name' };
|
||||
if (!apiKey) return { success: false, error: 'trigger_workflow 缺 api_key' };
|
||||
|
||||
// 從 WEBHOOKS KV 撈目標 workflow 的 graph
|
||||
const wfKey = `${apiKey}:wf:${workflowName}`;
|
||||
const wfRaw = await env.WEBHOOKS.get(wfKey, 'text');
|
||||
if (!wfRaw) return { success: false, error: `找不到 workflow "${workflowName}" (key=${wfKey})` };
|
||||
|
||||
let record: { graph?: Record<string, unknown> };
|
||||
try { record = JSON.parse(wfRaw); }
|
||||
catch { return { success: false, error: `workflow "${workflowName}" KV 內容非 JSON` }; }
|
||||
if (!record.graph) return { success: false, error: `workflow "${workflowName}" 缺 graph 欄位` };
|
||||
|
||||
// 動態 import 避循環依賴
|
||||
const { executeWebhookGraph } = await import('../actions/webhook-handlers');
|
||||
|
||||
const triggerContext = { ...input, _triggered_by: 'trigger_workflow' };
|
||||
|
||||
if (wait) {
|
||||
const r = await executeWebhookGraph(env, record.graph, triggerContext, workflowName, apiKey);
|
||||
// paused 是預期狀態(claude_api 等待外部 callback resume),不算失敗
|
||||
// executeWebhookGraph 內部把 ExecutionError + "paused at node X" 包成 success:false + 含 error 字串
|
||||
//
|
||||
// 2026-05-16 rename per LI roadmap (block e924c231) 自評建議:
|
||||
// 舊 `paused_awaiting_resume` 容易被誤讀成「掛起出問題」
|
||||
// 新 `running_async` 強調「已接受,繼續在背景跑」— 行為一致,命名更清楚
|
||||
const isPaused = !r.success && typeof r.error === 'string' && /workflow paused/i.test(r.error);
|
||||
return {
|
||||
success: r.success || isPaused,
|
||||
triggered_workflow: workflowName,
|
||||
status: r.success ? 'completed' : (isPaused ? 'running_async' : 'failed'),
|
||||
sub_result: r,
|
||||
};
|
||||
} else {
|
||||
// fire-and-forget — 不 await,但因為沒拿到 ctx.waitUntil,這裡 promise 可能被 cancel
|
||||
// 目前不啟用,留 wait=true 為預設。未來想要 fire-and-forget 需 plumb ExecutionContext
|
||||
void executeWebhookGraph(env, record.graph, triggerContext, workflowName, apiKey)
|
||||
.catch((e) => console.error('[trigger_workflow] fire-and-forget fail', workflowName, e));
|
||||
return { success: true, triggered_workflow: workflowName, mode: 'fire_and_forget' };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function makeHttpRunner(url: string): ComponentRunner {
|
||||
return async (ctx: unknown) => {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(ctx),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return { success: false, status: res.status, error: text.slice(0, 200) };
|
||||
}
|
||||
try { return await res.json(); }
|
||||
catch { return { success: true, data: await res.text() }; }
|
||||
};
|
||||
}
|
||||
|
||||
function makeLogicRunner(canonicalId: string, env: Bindings): ComponentRunner | null {
|
||||
const bindingKey = LOGIC_BINDING_MAP[canonicalId];
|
||||
if (!bindingKey) return null;
|
||||
|
||||
const svc = env[bindingKey] as ServiceBinding | undefined;
|
||||
if (svc) {
|
||||
return async (ctx: unknown) => {
|
||||
const res = await svc.fetch(new Request('https://component/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(ctx),
|
||||
}));
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return { success: false, error: `${canonicalId} 回傳 ${res.status}: ${text.slice(0, 200)}` };
|
||||
}
|
||||
try { return await res.json(); }
|
||||
catch { return { success: false, error: `${canonicalId} 回傳非 JSON` }; }
|
||||
};
|
||||
}
|
||||
|
||||
// Service Binding 未配置時 fallback 到公網(自製零件 or 開發環境)
|
||||
// 走 workers.dev 子域避開同 zone 死鎖(P0 #9)
|
||||
return makeHttpRunner(wasmWorkerUrl(canonicalId, env.WORKER_SUBDOMAIN));
|
||||
}
|
||||
|
||||
function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition): ComponentRunner {
|
||||
return async (ctx: unknown) => {
|
||||
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
|
||||
|
||||
// 模板替換:{{key}} 從 ctx 取;{{auth.K}} 從 _auth_path 取
|
||||
// (_auth_path 由 auth primitive 解密後注入,供 URL path 用,如 telegram /bot{{auth.token}}/)
|
||||
const authPath = (ctxObj._auth_path as Record<string, string>) ?? {};
|
||||
const interpolate = (s: string) =>
|
||||
s.replace(/\{\{(auth\.)?(\w+)\}\}/g, (_, authPrefix, k) =>
|
||||
String(authPrefix ? (authPath[k] ?? '') : (ctxObj[k] ?? '')),
|
||||
);
|
||||
|
||||
const method = (recipe.method ?? 'POST').toUpperCase();
|
||||
const authHeaders = (ctxObj._auth_headers as Record<string, string>) ?? {};
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders,
|
||||
};
|
||||
for (const [k, v] of Object.entries(recipe.headers ?? {})) {
|
||||
headers[k] = interpolate(v);
|
||||
}
|
||||
|
||||
// body:把 recipe.body 裡的 {{key}} 都換掉
|
||||
let bodyStr: string | undefined;
|
||||
if (recipe.body) {
|
||||
bodyStr = interpolate(JSON.stringify(recipe.body));
|
||||
} else if (method !== 'GET') {
|
||||
// 沒指定 body template → 用 ctx 當 body,但剔除 _ 前綴的內部欄位
|
||||
// (_path / _auth_headers / _auth_query / _auth_body 不該漏進下游請求)
|
||||
const bodyObj = Object.fromEntries(
|
||||
Object.entries(ctxObj).filter(([k]) => !k.startsWith('_')),
|
||||
);
|
||||
bodyStr = JSON.stringify(bodyObj);
|
||||
}
|
||||
|
||||
const res = await fetch(interpolate(recipe.endpoint), {
|
||||
method,
|
||||
headers,
|
||||
body: bodyStr,
|
||||
});
|
||||
|
||||
const data = await readBodyOnce(res);
|
||||
return { success: res.ok, status: res.status, data };
|
||||
};
|
||||
}
|
||||
|
||||
// ── Auth Recipe Runner ────────────────────────────────────────────────────────
|
||||
//
|
||||
// credential-injector 已先將認證資訊注入為 _auth_headers / _auth_query / _auth_body。
|
||||
// 這裡只需要讀取這些欄位,合併進 fetch,再清除 _auth_* 不傳給下游。
|
||||
|
||||
function makeAuthRecipeRunner(recipe: AuthRecipeDefinition): ComponentRunner {
|
||||
return async (ctx: unknown) => {
|
||||
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
|
||||
|
||||
const authHeaders = (ctxObj._auth_headers as Record<string, string>) ?? {};
|
||||
const authQuery = (ctxObj._auth_query as Record<string, string>) ?? {};
|
||||
|
||||
// _path 讓呼叫者指定 endpoint 後綴(e.g. /pages, /messages),可選
|
||||
const path = typeof ctxObj._path === 'string' ? ctxObj._path : '';
|
||||
const method = ((ctxObj.method as string) ?? 'POST').toUpperCase();
|
||||
|
||||
const url = new URL(recipe.base_url.replace(/\/$/, '') + path);
|
||||
for (const [k, v] of Object.entries(authQuery)) {
|
||||
url.searchParams.set(k, v);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders,
|
||||
};
|
||||
|
||||
// body:剔除所有 _ 前綴的內部欄位,以及 method
|
||||
const bodyObj = Object.fromEntries(
|
||||
Object.entries(ctxObj).filter(([k]) => !k.startsWith('_') && k !== 'method'),
|
||||
);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method,
|
||||
headers,
|
||||
body: method !== 'GET' ? JSON.stringify(bodyObj) : undefined,
|
||||
});
|
||||
|
||||
const data = await readBodyOnce(res);
|
||||
return { success: res.ok, status: res.status, data };
|
||||
};
|
||||
}
|
||||
|
||||
// 讀 response body 一次:先取 text,再嘗試 parse JSON。
|
||||
// 不可用 `res.json().catch(() => res.text())` —— res.json() 失敗時 body 已被消費,
|
||||
// 第二次讀會丟 "Body has already been used"。
|
||||
async function readBodyOnce(res: Response): Promise<unknown> {
|
||||
const text = await res.text();
|
||||
try { return JSON.parse(text); }
|
||||
catch { return text; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ComponentRunner, EdgeType } from '../types';
|
||||
|
||||
export const VALID_EDGE_TYPES = new Set([
|
||||
// 現有
|
||||
'PIPE', 'IF', 'FOREACH', 'CONTINUE',
|
||||
// 新增:執行語意
|
||||
'IS_A', 'ON_SUCCESS', 'ON_FAIL',
|
||||
// 新增:觸發語意
|
||||
'ON_CLICK', 'CALLS_SUBFLOW',
|
||||
// 新增:結構語意(記錄圖結構,不執行)
|
||||
'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR',
|
||||
]);
|
||||
|
||||
/** 內建零件 ID 集合(不需要查 WASM_BUCKET,Worker 記憶體中已有實作)*/
|
||||
export const BUILTIN_IDS = new Set([
|
||||
'webhook', 'comp_passthrough', 'comp_uppercase', 'comp_counter',
|
||||
]);
|
||||
|
||||
/** 語意邊 → EdgeType 映射(ADR-057 u6u L1:支援中文語意關係詞)
|
||||
* 完成後 → PIPE(成功後觸發下一個)
|
||||
* 失敗時 → CONTINUE(失敗後繼續)
|
||||
* 對每個 → FOREACH(迭代執行)
|
||||
* 條件滿足時 → IF(條件分支)
|
||||
*/
|
||||
export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
|
||||
// 中文語意詞
|
||||
'完成後': 'PIPE',
|
||||
'失敗時': 'ON_FAIL',
|
||||
'對每個': 'FOREACH',
|
||||
'條件滿足時': 'IF',
|
||||
// 英文別名
|
||||
'SUCCESS': 'ON_SUCCESS',
|
||||
'FAIL': 'ON_FAIL',
|
||||
'CLICK': 'ON_CLICK',
|
||||
'SUBFLOW': 'CALLS_SUBFLOW',
|
||||
};
|
||||
|
||||
/**
|
||||
* 內建零件表(靜態函數,不需要 R2)
|
||||
* WASM 零件從 WASM_BUCKET R2 直接讀取
|
||||
*/
|
||||
export const BUILTIN_COMPONENTS = new Map<string, ComponentRunner>([
|
||||
['comp_passthrough', (ctx) => ctx],
|
||||
['comp_uppercase', (ctx) => {
|
||||
const c = ctx as Record<string, unknown>;
|
||||
return { ...c, text: String(c.text || '').toUpperCase() };
|
||||
}],
|
||||
['comp_counter', (ctx) => {
|
||||
const c = ctx as Record<string, unknown>;
|
||||
return { ...c, count: (Number(c.count) || 0) + 1 };
|
||||
}],
|
||||
]);
|
||||
|
||||
export const SCORE_THRESHOLD = 0.5;
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 最小 cron expression matcher:5 欄位(minute hour dayOfMonth month dayOfWeek)。
|
||||
*
|
||||
* 用於 cypher-executor scheduled() handler — 把 workflow 註冊的 cron_expr 跟
|
||||
* 每分鐘 tick 的 event.scheduledTime 比對,匹配就觸發該 workflow。
|
||||
*
|
||||
* 支援語法(夠用即可,未來再擴):
|
||||
* `*` — 任何值
|
||||
* `5` — 等於 5
|
||||
* `*/N` — 每 N 個(N>0)
|
||||
* `5,10,15` — 任一
|
||||
* `1-5` — range(含兩端)
|
||||
*
|
||||
* 不支援(暫):
|
||||
* `?` / `L` / `W` / `#` 等延伸語法
|
||||
* month / weekday 用名稱(jan/mon 等)
|
||||
*
|
||||
* 對應 SDD: arcrun.md 三-A P1 #3。
|
||||
*/
|
||||
|
||||
/** 一個欄位(如 'minute')的值是否匹配 expr 段 */
|
||||
function matchField(expr: string, value: number, min: number, max: number): boolean {
|
||||
if (expr === '*') return true;
|
||||
for (const part of expr.split(',')) {
|
||||
if (matchPart(part.trim(), value, min, max)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchPart(part: string, value: number, min: number, max: number): boolean {
|
||||
// `*/N`
|
||||
if (part.startsWith('*/')) {
|
||||
const step = parseInt(part.slice(2), 10);
|
||||
if (!Number.isFinite(step) || step <= 0) return false;
|
||||
return (value - min) % step === 0;
|
||||
}
|
||||
// `X-Y` 或 `X-Y/N`
|
||||
if (part.includes('-')) {
|
||||
const [rangePart, stepStr] = part.split('/');
|
||||
const [aStr, bStr] = rangePart.split('-');
|
||||
const a = parseInt(aStr, 10);
|
||||
const b = parseInt(bStr, 10);
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
|
||||
if (value < a || value > b) return false;
|
||||
if (stepStr === undefined) return true;
|
||||
const step = parseInt(stepStr, 10);
|
||||
if (!Number.isFinite(step) || step <= 0) return false;
|
||||
return (value - a) % step === 0;
|
||||
}
|
||||
// `N`
|
||||
const n = parseInt(part, 10);
|
||||
if (!Number.isFinite(n)) return false;
|
||||
if (n < min || n > max) return false;
|
||||
return value === n;
|
||||
}
|
||||
|
||||
/**
|
||||
* 比對 cron expr 跟某個時間點。
|
||||
* @param expr - 5 欄位 cron(minute hour dom month dow)
|
||||
* @param date - 要比對的時間(UTC)
|
||||
*/
|
||||
export function cronMatch(expr: string, date: Date): boolean {
|
||||
const fields = expr.trim().split(/\s+/);
|
||||
if (fields.length !== 5) return false;
|
||||
const [m, h, dom, mon, dow] = fields;
|
||||
// dow: 0=Sun ... 6=Sat (跟 JavaScript 一致;ISO Mon=1 暫不轉)
|
||||
return (
|
||||
matchField(m, date.getUTCMinutes(), 0, 59) &&
|
||||
matchField(h, date.getUTCHours(), 0, 23) &&
|
||||
matchField(dom, date.getUTCDate(), 1, 31) &&
|
||||
matchField(mon, date.getUTCMonth() + 1, 1, 12) &&
|
||||
matchField(dow, date.getUTCDay(), 0, 6)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 從 workflow YAML 的 config 找出 cron 零件節點的 cron_expr。
|
||||
* 找不到回 null(代表此 workflow 不是 cron-triggered)。
|
||||
*
|
||||
* @param graph - acr push 解析後的 ExecutionGraph
|
||||
*/
|
||||
export function extractCronExpr(graph: unknown): string | null {
|
||||
if (!graph || typeof graph !== 'object') return null;
|
||||
const nodes = (graph as { nodes?: Array<{ id: string; componentId?: string; data?: Record<string, unknown> }> }).nodes;
|
||||
if (!Array.isArray(nodes)) return null;
|
||||
for (const node of nodes) {
|
||||
if (node.componentId !== 'cron') continue;
|
||||
const expr = node.data?.cron_expr;
|
||||
if (typeof expr === 'string' && expr.trim()) return expr.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// 資料外流警示 — 同意憑證機制(data-exfil-warning SDD §7 法律憑證 + §1b API 層)
|
||||
//
|
||||
// 觸發策略(richblack):只在「資料變成可被外部呼叫」時要求同意(暴露面)。
|
||||
// webhook 部署(workflow 變對外 endpoint)、recipe push 都算。
|
||||
//
|
||||
// 同意 = 法律憑證:留 log(誰、何時、同意了什麼),真出事時有「用戶明示知情同意」證據,
|
||||
// 避免 arcrun 訴訟風險。「以後不要警示」(suppress_future)本身也 log。
|
||||
//
|
||||
// 誠實限制:AI 能偽造 confirmed_by_human。本機制的價值是「法律歸責 + 可審」,不是技術防偽。
|
||||
|
||||
/** 暴露同意憑證(人類明示知情同意把某資源開放/送出) */
|
||||
export interface ExposureConsent {
|
||||
confirmed_by_human: true; // 必須為 literal true
|
||||
understood: string; // 人類說明「我知道這會把什麼開放給誰」(非空)
|
||||
confirmed_at: string; // ISO timestamp
|
||||
suppress_future?: boolean; // 「以後不要對此資源警示」(本選擇也 log)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判斷一個暴露動作是否已取得有效同意。
|
||||
* @param consent 本次請求帶的同意憑證
|
||||
* @param priorConsent 既有 record 裡存的同意(首次問、記住:§3)
|
||||
* @returns null = 放行(已同意或已 suppress);string = 拒絕原因
|
||||
*/
|
||||
export function checkExposureConsent(
|
||||
consent: ExposureConsent | undefined,
|
||||
priorConsent: ExposureConsent | undefined,
|
||||
): string | null {
|
||||
// 既有同意且選了「以後不警示」→ 放行(首次問記住)
|
||||
if (priorConsent?.suppress_future) return null;
|
||||
// 既有有效同意(同資源已確認過)→ 放行
|
||||
if (priorConsent?.confirmed_by_human === true) return null;
|
||||
|
||||
// 本次請求帶了有效同意 → 放行
|
||||
if (
|
||||
consent?.confirmed_by_human === true &&
|
||||
typeof consent.understood === 'string' &&
|
||||
consent.understood.trim() !== ''
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
'此動作會把資源變成可被外部呼叫(暴露/送出資料)。需人類明示同意。\n' +
|
||||
'請用 CLI 互動確認(acr 會說明風險並提供保護選項),或帶 exposure_consent。\n' +
|
||||
'arcrun 可幫你保護:要求呼叫者帶 API Key / 設權限 / 限流。'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 正規化要存進 record 的同意憑證(法律憑證,可審)。
|
||||
* 優先用本次新同意,否則沿用既有。
|
||||
*/
|
||||
export function resolveConsentForRecord(
|
||||
consent: ExposureConsent | undefined,
|
||||
priorConsent: ExposureConsent | undefined,
|
||||
): ExposureConsent | undefined {
|
||||
if (consent?.confirmed_by_human === true) return consent;
|
||||
return priorConsent;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 穩定 ID 衍生工具
|
||||
*
|
||||
* 邏輯零件: cmp_<sha256(canonical_id)[:8]>
|
||||
* API recipe:rec_<sha256(canonical_id)[:8]>
|
||||
*
|
||||
* 同一個 canonical_id 永遠得到同一個 hash,
|
||||
* 讓 workflow 可以用 hash 引用零件,不受改名影響。
|
||||
*/
|
||||
|
||||
export async function deriveComponentHash(canonicalId: string): Promise<string> {
|
||||
return 'cmp_' + await sha256Prefix(canonicalId);
|
||||
}
|
||||
|
||||
export async function deriveRecipeHash(canonicalId: string): Promise<string> {
|
||||
return 'rec_' + await sha256Prefix(canonicalId);
|
||||
}
|
||||
|
||||
export function isComponentHash(id: string): boolean {
|
||||
return /^cmp_[0-9a-f]{8}$/.test(id);
|
||||
}
|
||||
|
||||
export function isRecipeHash(id: string): boolean {
|
||||
return /^rec_[0-9a-f]{8}$/.test(id);
|
||||
}
|
||||
|
||||
async function sha256Prefix(input: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(input);
|
||||
const buf = await crypto.subtle.digest('SHA-256', data);
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.slice(0, 8);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Magic vars — workflow YAML 內建變數
|
||||
*
|
||||
* 對應 LI SDD M2.x improvement(feedback block c47bf70b)。
|
||||
*
|
||||
* 任何以 `_` 開頭的變數名都是 reserved(system)。常見:時間、執行 metadata。
|
||||
* 用於 page_name / file path / URL 等需要時間戳的場景。
|
||||
*
|
||||
* 範例 YAML:
|
||||
* page_name: "roadmap-week-{{_iso_week}}" # roadmap-week-2026-W20
|
||||
* page_name: "log-{{_today}}" # log-2026-05-16
|
||||
* filename: "snapshot-{{_now_unix}}.json" # snapshot-1778940000123.json
|
||||
*
|
||||
* 不違反 §2.2:這是 orchestrator routing 提供的「環境變數」(像 shell 的 $DATE),
|
||||
* 不涉及 secret / credential / JWT,跟既有 ctx 變數展開同層。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 算 ISO 8601 週數(W01-W53)。
|
||||
* 週一為週首,W01 含當年首個週四(ISO 標準)。
|
||||
* https://en.wikipedia.org/wiki/ISO_week_date
|
||||
*/
|
||||
function isoWeekNumber(d: Date): { year: number; week: number } {
|
||||
const target = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
const dayNum = (target.getUTCDay() + 6) % 7; // Mon=0
|
||||
target.setUTCDate(target.getUTCDate() - dayNum + 3);
|
||||
const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
|
||||
const weekNum = 1 + Math.round(
|
||||
((target.getTime() - firstThursday.getTime()) / 86400000 -
|
||||
3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7
|
||||
);
|
||||
return { year: target.getUTCFullYear(), week: weekNum };
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 magic vars。每次 workflow 觸發時呼叫一次,貫穿整個執行。
|
||||
*
|
||||
* 設計:UTC 為基準(避免 worker 跨時區誤判)。需要本地時區的場景,
|
||||
* 用戶可自己組(例如 yaml 寫 `{{_today_utc}}` + 自己處理偏移)。
|
||||
*/
|
||||
export function buildMagicVars(now: Date = new Date()): Record<string, string | number> {
|
||||
const iso = now.toISOString(); // 2026-05-16T09:30:00.123Z
|
||||
const yyyy = now.getUTCFullYear();
|
||||
const mm = pad2(now.getUTCMonth() + 1);
|
||||
const dd = pad2(now.getUTCDate());
|
||||
const hh = pad2(now.getUTCHours());
|
||||
const mi = pad2(now.getUTCMinutes());
|
||||
const ss = pad2(now.getUTCSeconds());
|
||||
|
||||
const yesterday = new Date(now.getTime() - 86400000);
|
||||
const yMm = pad2(yesterday.getUTCMonth() + 1);
|
||||
const yDd = pad2(yesterday.getUTCDate());
|
||||
|
||||
const { year: isoYear, week: isoWeek } = isoWeekNumber(now);
|
||||
|
||||
return {
|
||||
// 日期 / 時間(UTC)
|
||||
_today: `${yyyy}-${mm}-${dd}`, // 2026-05-16
|
||||
_yesterday: `${yesterday.getUTCFullYear()}-${yMm}-${yDd}`, // 2026-05-15
|
||||
_now: iso, // ISO 8601
|
||||
_now_unix: now.getTime(), // unix ms
|
||||
_now_unix_s: Math.floor(now.getTime() / 1000), // unix sec
|
||||
|
||||
// 個別欄位(給 path / page_name 拼)
|
||||
_year: yyyy,
|
||||
_month: mm,
|
||||
_day: dd,
|
||||
_hour: hh,
|
||||
_minute: mi,
|
||||
_second: ss,
|
||||
|
||||
// ISO 週(roadmap weekly archive 必備)
|
||||
_iso_week: `${isoYear}-W${pad2(isoWeek)}`, // 2026-W20
|
||||
_iso_week_num: isoWeek,
|
||||
_iso_year: isoYear,
|
||||
|
||||
// 簡單時間 slot(cron-friendly)
|
||||
_yyyymm: `${yyyy}${mm}`, // 202605
|
||||
_yyyymmdd: `${yyyy}${mm}${dd}`, // 20260516
|
||||
|
||||
// 週幾(0=週日,1=週一 ... 6=週六;ISO 風格在 _iso_weekday)
|
||||
_weekday: now.getUTCDay(),
|
||||
_iso_weekday: ((now.getUTCDay() + 6) % 7) + 1, // 1=Mon...7=Sun
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
export const OPENAPI_SPEC = {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: 'arcrun cypher-executor API',
|
||||
description: 'AI Workflow Execution Engine — 透過三元組 Triplet 或圖 Graph 定義工作流,系統執行並回傳結果',
|
||||
version: '1.0.0',
|
||||
contact: {
|
||||
name: 'arcrun',
|
||||
url: 'https://github.com/arcrun/arcrun',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{ url: 'https://cypher.arcrun.dev', description: 'arcrun.dev Hosted' },
|
||||
{ url: 'http://localhost:8787', description: 'Local Development' },
|
||||
],
|
||||
paths: {
|
||||
'/': {
|
||||
get: {
|
||||
summary: 'Health Check',
|
||||
tags: ['Health'],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Service is running',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
service: { type: 'string' },
|
||||
version: { type: 'string' },
|
||||
status: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/cypher/search': {
|
||||
post: {
|
||||
summary: '搜尋工作流需要的零件',
|
||||
tags: ['Cypher'],
|
||||
description: '用三元組描述工作流,系統解析並從 Registry 查詢對應零件',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
triplets: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['start >> 完成後 >> get-data', 'get-data >> 完成後 >> done'],
|
||||
description: '三元組陣列,格式:\"FROM >> ACTION >> TO\"',
|
||||
},
|
||||
auto_publish: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: '缺失的零件是否自動產生發佈',
|
||||
},
|
||||
},
|
||||
required: ['triplets'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: '零件搜尋成功(含版本號和時戳,適合 Markdown 文檔追蹤)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
version: { type: 'string', example: 'search-v1-20260327-143022', description: '版本號(endpoint-v{major}-{timestamp})' },
|
||||
timestamp: { type: 'string', format: 'date-time', description: 'ISO 8601 時戳' },
|
||||
triplets: { type: 'array', items: { type: 'string' }, description: '回送的三元組列表' },
|
||||
nodes: { type: 'object', description: '搜尋到的零件及其狀態' },
|
||||
cypher: { type: 'object', description: '工作流圖(null 若有缺失零件)' },
|
||||
missing: { type: 'array', items: { type: 'string' }, description: '缺失零件列表' },
|
||||
auto_published: { type: 'object', description: '自動發佈的零件(若 auto_publish=true)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'400': { description: '無法解析三元組' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/cypher/execute': {
|
||||
post: {
|
||||
summary: '執行工作流',
|
||||
tags: ['Cypher'],
|
||||
description: '直接執行 triplets,回傳完整執行結果。支援自動發佈缺失零件。',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
triplets: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: '三元組陣列,格式:"FROM >> ACTION >> TO"',
|
||||
},
|
||||
context: {
|
||||
type: 'object',
|
||||
description: '執行上下文,傳入各節點作為初始參數',
|
||||
},
|
||||
auto_publish: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: '缺失的零件是否自動產生臨時實作',
|
||||
},
|
||||
},
|
||||
required: ['triplets'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: '執行成功(含版本號和時戳)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
version: { type: 'string', example: 'execute-v1-20260327-143022', description: '版本號(endpoint-v{major}-{timestamp})' },
|
||||
timestamp: { type: 'string', format: 'date-time', description: 'ISO 8601 時戳' },
|
||||
success: { type: 'boolean', enum: [true] },
|
||||
data: { type: 'object', description: '執行結果' },
|
||||
trace: { type: 'array', description: '執行跟蹤' },
|
||||
duration_ms: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'500': {
|
||||
description: '執行失敗或部份零件缺失(含版本號和時戳)',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
version: { type: 'string', example: 'execute-v1-20260327-143022', description: '版本號(endpoint-v{major}-{timestamp})' },
|
||||
timestamp: { type: 'string', format: 'date-time', description: 'ISO 8601 時戳' },
|
||||
success: { type: 'boolean', enum: [false] },
|
||||
error: { type: 'string' },
|
||||
missing: { type: 'array', items: { type: 'string' }, description: '無法自動發佈的缺失零件' },
|
||||
auto_published: {
|
||||
type: 'object',
|
||||
description: '自動發佈的零件資訊',
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
componentId: { type: 'string' },
|
||||
temporary_endpoint: { type: 'string', format: 'uri', description: '臨時實作的 URL' },
|
||||
implement_by: { type: 'string', format: 'date-time', description: '實作截止時間' },
|
||||
},
|
||||
},
|
||||
},
|
||||
duration_ms: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/webhooks': {
|
||||
post: {
|
||||
summary: '建立 Webhook',
|
||||
tags: ['Webhooks'],
|
||||
description: '將工作流註冊成 Webhook,得到公開 URL',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
triplets: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
description: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'201': {
|
||||
description: 'Webhook 建立成功',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
webhook_url: { type: 'string', format: 'uri' },
|
||||
description: { type: 'string' },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
get: {
|
||||
summary: '列出所有 Webhooks',
|
||||
tags: ['Webhooks'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'Authorization',
|
||||
in: 'header',
|
||||
required: true,
|
||||
schema: { type: 'string', example: 'Bearer u6u_xxxxx' },
|
||||
description: 'API Key 認證',
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Webhooks 列表',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
webhooks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
},
|
||||
total: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': { description: '未授權' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/webhooks/{token}': {
|
||||
get: {
|
||||
summary: '查詢單個 Webhook',
|
||||
tags: ['Webhooks'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'token',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Webhook 資訊',
|
||||
},
|
||||
'404': { description: 'Webhook 不存在' },
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
summary: '刪除 Webhook',
|
||||
tags: ['Webhooks'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'token',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': { description: 'Webhook 已刪除' },
|
||||
'404': { description: 'Webhook 不存在' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
ApiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'Authorization',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Paused workflow runs:節點回 pending 時把 run state 持久化進 KV,
|
||||
* webhook callback 進來時撿回繼續執行
|
||||
*
|
||||
* SDD: matrix/arcrun/.agents/specs/resumable-workflow/design.md §2.1
|
||||
*
|
||||
* KV key: paused_run:{task_id}
|
||||
* TTL: 24h(避免 KV 累積,超過就 GC)
|
||||
*
|
||||
* 設計筆記:
|
||||
* - 用 task_id 當 key(daemon 派的 unique id),不用 run_id(同 run 可能多 paused 節點 v2)
|
||||
* - consume = load + delete 原子操作(避免重複 callback 重複執行)
|
||||
*/
|
||||
|
||||
import type { ExecutionGraph, TraceStep } from '../types';
|
||||
|
||||
export interface PausedRunState {
|
||||
run_id: string;
|
||||
graph: ExecutionGraph;
|
||||
paused_node_id: string;
|
||||
paused_context: Record<string, unknown>;
|
||||
paused_pending_result: Record<string, unknown>; // 節點回的 {pending, task_id, ...}
|
||||
trace_so_far: TraceStep[];
|
||||
api_key?: string;
|
||||
expires_at: number; // unix ms
|
||||
// resume 時用來 parse callback result 的 recipe output 規格(resumable + recipe 整合)
|
||||
recipe_output_format?: 'text' | 'json';
|
||||
recipe_output_required_fields?: string[];
|
||||
}
|
||||
|
||||
const KEY_PREFIX = 'paused_run:';
|
||||
/**
|
||||
* Per-user paused index:列出某 api_key 當前 paused tasks 不依賴 CF KV list(強 eventual
|
||||
* consistent,30-60s 延遲)。改維護一個 user-keyed JSON list,list 操作改 single KV.get。
|
||||
*
|
||||
* Key: `paused_idx:{api_key}`
|
||||
* Value: JSON Array<{task_id, paused_node_id, run_id, workflow_name?, expires_at, persisted_at}>
|
||||
*
|
||||
* 對應 LI SDD M2.1 — /executions/paused endpoint 即時性。
|
||||
*/
|
||||
const IDX_PREFIX = 'paused_idx:';
|
||||
const TTL_SECONDS = 24 * 60 * 60;
|
||||
|
||||
export type PausedIndexEntry = {
|
||||
task_id: string;
|
||||
run_id: string;
|
||||
paused_node_id: string;
|
||||
workflow_name?: string;
|
||||
expires_at: number;
|
||||
persisted_at: number;
|
||||
};
|
||||
|
||||
type KvBinding = {
|
||||
get: (key: string) => Promise<string | null>;
|
||||
put: (key: string, value: string, options?: { expirationTtl?: number }) => Promise<void>;
|
||||
delete: (key: string) => Promise<void>;
|
||||
};
|
||||
|
||||
async function readIndex(kv: KvBinding, apiKey: string): Promise<PausedIndexEntry[]> {
|
||||
const raw = await kv.get(`${IDX_PREFIX}${apiKey}`);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const arr = JSON.parse(raw);
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIndex(kv: KvBinding, apiKey: string, entries: PausedIndexEntry[]): Promise<void> {
|
||||
// 過濾過期項目(避免 index 爆量)
|
||||
const now = Date.now();
|
||||
const fresh = entries.filter((e) => e.expires_at > now);
|
||||
await kv.put(`${IDX_PREFIX}${apiKey}`, JSON.stringify(fresh), { expirationTtl: TTL_SECONDS });
|
||||
}
|
||||
|
||||
export async function persistPausedRun(
|
||||
kv: KvBinding,
|
||||
taskId: string,
|
||||
state: PausedRunState,
|
||||
): Promise<void> {
|
||||
// 1) 寫單一 task state
|
||||
await kv.put(`${KEY_PREFIX}${taskId}`, JSON.stringify(state), { expirationTtl: TTL_SECONDS });
|
||||
|
||||
// 2) 維護 per-user index(讓 /executions/paused list 不靠 KV list 即時拿到)
|
||||
if (state.api_key) {
|
||||
const idx = await readIndex(kv, state.api_key);
|
||||
// 去重(重複 paused 同 task_id 時覆蓋)
|
||||
const filtered = idx.filter((e) => e.task_id !== taskId);
|
||||
filtered.unshift({
|
||||
task_id: taskId,
|
||||
run_id: state.run_id,
|
||||
paused_node_id: state.paused_node_id,
|
||||
workflow_name: state.graph.name,
|
||||
expires_at: state.expires_at,
|
||||
persisted_at: Date.now(),
|
||||
});
|
||||
// 限 100 筆避免 index 無限長(超過捨棄最舊)
|
||||
await writeIndex(kv, state.api_key, filtered.slice(0, 100));
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadPausedRun(
|
||||
kv: KvBinding,
|
||||
taskId: string,
|
||||
): Promise<PausedRunState | null> {
|
||||
const raw = await kv.get(`${KEY_PREFIX}${taskId}`);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as PausedRunState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列某 api_key 當前 paused tasks。走 per-user index(強 consistent,無 KV list 延遲)
|
||||
*/
|
||||
export async function listPausedRunsByApiKey(
|
||||
kv: KvBinding,
|
||||
apiKey: string,
|
||||
limit = 20,
|
||||
): Promise<PausedIndexEntry[]> {
|
||||
const idx = await readIndex(kv, apiKey);
|
||||
const now = Date.now();
|
||||
return idx.filter((e) => e.expires_at > now).slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子讀+刪:避免同 task_id 重複 callback 重複執行下游
|
||||
* (CF KV 沒真原子操作,但 delete 失敗不影響 load 已成功)
|
||||
*/
|
||||
export async function consumePausedRun(
|
||||
kv: KvBinding,
|
||||
taskId: string,
|
||||
): Promise<PausedRunState | null> {
|
||||
const state = await loadPausedRun(kv, taskId);
|
||||
if (!state) return null;
|
||||
await kv.delete(`${KEY_PREFIX}${taskId}`).catch(() => {
|
||||
// delete 失敗不擋,最多就重複執行一次(接受)
|
||||
});
|
||||
// 同步從 per-user index 移除
|
||||
if (state.api_key) {
|
||||
const idx = await readIndex(kv, state.api_key);
|
||||
const filtered = idx.filter((e) => e.task_id !== taskId);
|
||||
await writeIndex(kv, state.api_key, filtered).catch(() => {});
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/** 偵測 component result 是否為「需要 resume」的 pending pattern */
|
||||
export function isResumablePending(result: unknown): { task_id: string } | null {
|
||||
if (!result || typeof result !== 'object') return null;
|
||||
const r = result as Record<string, unknown>;
|
||||
if (r.pending !== true) return null;
|
||||
if (typeof r.task_id !== 'string' || !r.task_id) return null;
|
||||
return { task_id: r.task_id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse claude_api result with recipe output format.
|
||||
* 同步路徑跟 resume 路徑都用同一個解析器,避免邏輯歪掉。
|
||||
*
|
||||
* 輸入:result(可能是 {data:{text:"..."}} 或 {text:"..."})
|
||||
* 輸出:parsed object 或 fallback 結構
|
||||
*/
|
||||
export function parseRecipeOutput(
|
||||
result: unknown,
|
||||
format: 'text' | 'json' | undefined,
|
||||
requiredFields: string[] | undefined,
|
||||
): unknown {
|
||||
if (format !== 'json' || !result || typeof result !== 'object') return result;
|
||||
const r = result as Record<string, unknown>;
|
||||
const text = (r.data as Record<string, unknown> | undefined)?.text ?? r.text;
|
||||
if (typeof text !== 'string') return result;
|
||||
|
||||
// 剝除 ```json ... ``` markdown fence(Claude 常這樣包)
|
||||
let jsonText = String(text).trim();
|
||||
const fenceMatch = jsonText.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/);
|
||||
if (fenceMatch) jsonText = fenceMatch[1].trim();
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
if (requiredFields && parsed && typeof parsed === 'object') {
|
||||
const missing = requiredFields.filter((f) => !(f in (parsed as Record<string, unknown>)));
|
||||
if (missing.length > 0) {
|
||||
return { success: false, error: `recipe output 缺欄位: ${missing.join(', ')}`, raw: parsed };
|
||||
}
|
||||
}
|
||||
// 把 parsed 的欄位 spread 到 top-level,FOREACH / 下游 {{var}} 都好取
|
||||
return { success: true, data: parsed, ...(parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : {}) };
|
||||
} catch (e) {
|
||||
return { success: false, error: `recipe output JSON parse 失敗: ${e instanceof Error ? e.message : String(e)}`, raw_text: text };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* prompt_recipe Zod schema
|
||||
* SDD: matrix/arcrun/.agents/specs/recipe-system/design.md §2.1
|
||||
*
|
||||
* 平行於既有 auth_recipe / api_recipe,存 RECIPES KV (key: `prompt_recipe:{name}`)
|
||||
* 容器 + recipe 模式:claude_api 是容器,recipe 是配方
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Transform 白名單 ──────────────────────────────────────────────────────────
|
||||
// 限制 transform 種類避免變 mini-DSL;超過範圍請寫零件
|
||||
export const TRANSFORM_NAMES = [
|
||||
'json_array', // array → JSON.stringify 整體
|
||||
'to_string', // 任意值 → String(x)
|
||||
'join', // array → join(sep),sep 預設換行
|
||||
'markdown_list', // array → "- a\n- b\n- c"
|
||||
'extract_field', // array of object → 抽 field 後的 array(再可串其他 transform)
|
||||
'first', // array → first element(取單一)
|
||||
'pluck_content', // KBDB blocks array → 抽 content 後 join 雙換行(草稿合併常用)
|
||||
] as const;
|
||||
|
||||
/** transform 表示法:name 或 name:arg(如 extract_field:page_name) */
|
||||
export const TransformSchema = z.string().regex(/^[a-z_]+(:.+)?$/, 'transform 必須為 name 或 name:arg 格式');
|
||||
|
||||
// ── Fragment:從 KBDB / KV 抓固定資料 ──────────────────────────────────────────
|
||||
export const KBDBBlockFragmentSchema = z.object({
|
||||
var: z.string().min(1), // prompt template 內的變數名
|
||||
source: z.literal('kbdb_block'),
|
||||
block_id: z.string().optional(), // 二擇一
|
||||
block_page_name: z.string().optional(), // 比 block_id 穩定
|
||||
field: z.string().default('content'), // 抓 block 的哪個欄位
|
||||
});
|
||||
|
||||
export const KVFragmentSchema = z.object({
|
||||
var: z.string().min(1),
|
||||
source: z.literal('kv'),
|
||||
key: z.string().min(1),
|
||||
});
|
||||
|
||||
// discriminatedUnion 對 refined zod object 不支援,故拆成驗證後 + 單獨檢查 block_id|page_name
|
||||
export const FragmentSchema = z.discriminatedUnion('source', [
|
||||
KBDBBlockFragmentSchema,
|
||||
KVFragmentSchema,
|
||||
]).superRefine((d, ctx) => {
|
||||
if (d.source === 'kbdb_block' && !d.block_id && !d.block_page_name) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'block_id 或 block_page_name 必填其一',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Input:從 workflow context 取值(含 transform) ────────────────────────────
|
||||
export const InputSchema = z.object({
|
||||
var: z.string().min(1),
|
||||
from: z.string().min(1), // JSONPath-lite,如 "ctx.read_drafts.blocks"
|
||||
transform: TransformSchema.optional(),
|
||||
default: z.unknown().optional(), // from 取不到時的預設值(避免炸 prompt)
|
||||
});
|
||||
|
||||
// ── Prompt 組裝 ──────────────────────────────────────────────────────────────
|
||||
export const PromptAssemblySchema = z.object({
|
||||
system: z.string().min(1), // 模板,可含 {{var}}
|
||||
user: z.string().min(1),
|
||||
});
|
||||
|
||||
// ── 輸出規格 ──────────────────────────────────────────────────────────────────
|
||||
export const OutputSpecSchema = z.object({
|
||||
format: z.enum(['text', 'json']).default('text'),
|
||||
// 若 format=json,可選 schema 做 parse 後驗證(簡化版,列必填欄位即可)
|
||||
required_fields: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ── 完整 prompt_recipe 定義 ────────────────────────────────────────────────────
|
||||
export const PromptRecipeSchema = z.object({
|
||||
kind: z.literal('prompt_recipe'),
|
||||
name: z.string().min(1).regex(/^[a-z][a-z0-9_]*$/, 'name 為 lowercase + underscore'),
|
||||
version: z.number().int().positive().default(1),
|
||||
description: z.string().optional(),
|
||||
model: z.enum(['haiku', 'sonnet', 'opus']).default('sonnet'),
|
||||
fragments: z.array(FragmentSchema).default([]),
|
||||
inputs: z.array(InputSchema).default([]),
|
||||
prompt_assembly: PromptAssemblySchema,
|
||||
output: OutputSpecSchema.default({ format: 'text' }),
|
||||
});
|
||||
|
||||
export type PromptRecipe = z.infer<typeof PromptRecipeSchema>;
|
||||
export type Fragment = z.infer<typeof FragmentSchema>;
|
||||
export type RecipeInput = z.infer<typeof InputSchema>;
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Recipe expander:把 prompt_recipe 展開成 claude_api 的實際 input
|
||||
* SDD: matrix/arcrun/.agents/specs/recipe-system/design.md §2.2 + Phase 2.1
|
||||
*
|
||||
* 流程:
|
||||
* 1. loadPromptRecipe 取定義
|
||||
* 2. fragments → 用 KBDB API 抓 block content
|
||||
* 3. inputs → 從 workflow context 取值 + 跑 transform
|
||||
* 4. 套進 prompt_assembly.system / .user 的 {{var}} 模板
|
||||
* 5. 回傳 { prompt, model, output_format, output_required_fields }
|
||||
*/
|
||||
|
||||
import { loadPromptRecipe, RecipeLoadError } from './recipe-loader';
|
||||
import { applyTransform } from './recipe-transforms';
|
||||
import type { Fragment, RecipeInput } from './prompt-recipe-schema';
|
||||
|
||||
type ExpanderEnv = {
|
||||
RECIPES: { get: (key: string) => Promise<string | null> };
|
||||
KBDB_BASE_URL?: string;
|
||||
};
|
||||
|
||||
export interface ExpandedRecipe {
|
||||
prompt: string; // user prompt(system + user 用 \n\n--- system ---\n 分隔)
|
||||
model: 'haiku' | 'sonnet' | 'opus';
|
||||
output_format: 'text' | 'json';
|
||||
output_required_fields?: string[];
|
||||
}
|
||||
|
||||
/** 從 path 取嵌套值,例如 "ctx.read_drafts.blocks" / "loop.item" */
|
||||
function getByPath(ctx: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.split('.');
|
||||
let cur: unknown = ctx;
|
||||
for (const p of parts) {
|
||||
if (cur === null || cur === undefined) return undefined;
|
||||
if (typeof cur !== 'object') return undefined;
|
||||
cur = (cur as Record<string, unknown>)[p];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** {{var}} 模板替換(top-level vars 物件) */
|
||||
function interpolate(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (vars[key] !== undefined ? vars[key] : `{{${key}}}`));
|
||||
}
|
||||
|
||||
async function fetchKbdbBlock(
|
||||
env: ExpanderEnv,
|
||||
apiKey: string,
|
||||
fragment: Extract<Fragment, { source: 'kbdb_block' }>,
|
||||
): Promise<unknown> {
|
||||
const base = (env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, '');
|
||||
let url: string;
|
||||
if (fragment.block_id) {
|
||||
url = `${base}/blocks/${encodeURIComponent(fragment.block_id)}`;
|
||||
} else {
|
||||
url = `${base}/blocks?page_name=${encodeURIComponent(fragment.block_page_name!)}&limit=1`;
|
||||
}
|
||||
const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
|
||||
if (!res.ok) throw new Error(`KBDB fragment 抓取失敗 (${res.status}): ${url}`);
|
||||
const data = (await res.json()) as Record<string, unknown>;
|
||||
|
||||
// page_name 模式回 {blocks:[]},block_id 模式直接回 block 物件
|
||||
const block: Record<string, unknown> = fragment.block_id
|
||||
? data
|
||||
: ((data.blocks as unknown[])?.[0] as Record<string, unknown>) ?? {};
|
||||
if (!block) throw new Error(`KBDB block 不存在: ${fragment.block_id ?? fragment.block_page_name}`);
|
||||
|
||||
const fieldVal = block[fragment.field];
|
||||
if (fieldVal === undefined) throw new Error(`block 缺欄位 "${fragment.field}"`);
|
||||
return fieldVal;
|
||||
}
|
||||
|
||||
async function resolveFragment(
|
||||
env: ExpanderEnv,
|
||||
apiKey: string,
|
||||
frag: Fragment,
|
||||
): Promise<{ var: string; value: unknown }> {
|
||||
if (frag.source === 'kv') {
|
||||
const val = await env.RECIPES.get(frag.key);
|
||||
if (val === null) throw new Error(`KV 找不到 key: ${frag.key}`);
|
||||
return { var: frag.var, value: val };
|
||||
}
|
||||
return { var: frag.var, value: await fetchKbdbBlock(env, apiKey, frag) };
|
||||
}
|
||||
|
||||
function resolveInput(input: RecipeInput, ctx: Record<string, unknown>): { var: string; value: unknown } {
|
||||
let val = getByPath(ctx, input.from);
|
||||
const beforeDefault = val;
|
||||
if (val === undefined) val = input.default;
|
||||
try {
|
||||
if (input.transform) val = applyTransform(val, input.transform);
|
||||
return { var: input.var, value: val };
|
||||
} catch (e) {
|
||||
// 把 path 跟原值放進錯誤訊息,方便 debug recipe
|
||||
const valType = Array.isArray(beforeDefault) ? `array(${beforeDefault.length})`
|
||||
: beforeDefault === undefined ? 'undefined(default applied)'
|
||||
: typeof beforeDefault;
|
||||
throw new Error(`${e instanceof Error ? e.message : String(e)} [path=${input.from}, type=${valType}]`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 主入口:展開 recipe → 組 prompt */
|
||||
export async function expandPromptRecipe(
|
||||
recipeRef: string,
|
||||
ctx: Record<string, unknown>,
|
||||
env: ExpanderEnv,
|
||||
apiKey: string, // KBDB partner key(從 workflow auth 來)
|
||||
): Promise<ExpandedRecipe> {
|
||||
const recipe = await loadPromptRecipe(recipeRef, env.RECIPES);
|
||||
|
||||
const vars: Record<string, string> = {};
|
||||
|
||||
for (const frag of recipe.fragments) {
|
||||
const { var: name, value } = await resolveFragment(env, apiKey, frag);
|
||||
vars[name] = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
}
|
||||
for (const inp of recipe.inputs) {
|
||||
const { var: name, value } = resolveInput(inp, ctx);
|
||||
vars[name] = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
const system = interpolate(recipe.prompt_assembly.system, vars);
|
||||
const user = interpolate(recipe.prompt_assembly.user, vars);
|
||||
|
||||
// claude_api 容器目前吃單一 prompt 字串 → system + user 用分隔線拼
|
||||
const prompt = `${system}\n\n--- USER ---\n\n${user}`;
|
||||
|
||||
return {
|
||||
prompt,
|
||||
model: recipe.model,
|
||||
output_format: recipe.output.format,
|
||||
output_required_fields: recipe.output.required_fields,
|
||||
};
|
||||
}
|
||||
|
||||
export { RecipeLoadError };
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Prompt recipe loader:從 RECIPES KV 抓 prompt_recipe 定義並驗證
|
||||
* SDD: matrix/arcrun/.agents/specs/recipe-system/design.md Phase 1.3
|
||||
*
|
||||
* KV key 格式:prompt_recipe:{name}
|
||||
* KV value:JSON 字串(不用 YAML,避免引入 yaml parser 進 worker)
|
||||
*/
|
||||
|
||||
import { PromptRecipeSchema, type PromptRecipe } from './prompt-recipe-schema';
|
||||
|
||||
type KvBinding = { get: (key: string) => Promise<string | null> };
|
||||
|
||||
export class RecipeLoadError extends Error {
|
||||
constructor(message: string, public readonly recipe: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 從 RECIPES KV 抓 + parse + validate */
|
||||
export async function loadPromptRecipe(
|
||||
recipeRef: string, // 完整 key 如 "prompt_recipe:wiki_synthesis",或裸名 "wiki_synthesis"
|
||||
recipesKv: KvBinding,
|
||||
): Promise<PromptRecipe> {
|
||||
const key = recipeRef.startsWith('prompt_recipe:')
|
||||
? recipeRef
|
||||
: `prompt_recipe:${recipeRef}`;
|
||||
|
||||
const raw = await recipesKv.get(key);
|
||||
if (!raw) {
|
||||
throw new RecipeLoadError(`找不到 recipe: ${key}`, key);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new RecipeLoadError(
|
||||
`recipe ${key} 不是合法 JSON: ${e instanceof Error ? e.message : String(e)}`,
|
||||
key,
|
||||
);
|
||||
}
|
||||
|
||||
const result = PromptRecipeSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
|
||||
throw new RecipeLoadError(`recipe ${key} schema 驗證失敗: ${issues}`, key);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Recipe transform 白名單實作
|
||||
* SDD: matrix/arcrun/.agents/specs/recipe-system/design.md §2.1
|
||||
*
|
||||
* 每個 transform 接 unknown,回 unknown。
|
||||
* 失敗策略:一律 throw,由 expander 包成 recipe 錯誤
|
||||
*/
|
||||
|
||||
export type TransformFn = (value: unknown, arg?: string) => unknown;
|
||||
|
||||
const transforms: Record<string, TransformFn> = {
|
||||
json_array: (v) => JSON.stringify(v ?? []),
|
||||
|
||||
to_string: (v) => {
|
||||
if (v === null || v === undefined) return '';
|
||||
if (typeof v === 'object') return JSON.stringify(v);
|
||||
return String(v);
|
||||
},
|
||||
|
||||
join: (v, sep) => {
|
||||
if (!Array.isArray(v)) throw new Error('join: input 不是 array');
|
||||
return v.map((x) => (typeof x === 'string' ? x : JSON.stringify(x))).join(sep ?? '\n');
|
||||
},
|
||||
|
||||
markdown_list: (v) => {
|
||||
if (!Array.isArray(v)) throw new Error('markdown_list: input 不是 array');
|
||||
return v.map((x) => `- ${typeof x === 'string' ? x : JSON.stringify(x)}`).join('\n');
|
||||
},
|
||||
|
||||
extract_field: (v, field) => {
|
||||
if (!field) throw new Error('extract_field: 需要 field 參數,例如 extract_field:page_name');
|
||||
if (!Array.isArray(v)) throw new Error('extract_field: input 不是 array');
|
||||
return v.map((x) => (x && typeof x === 'object' ? (x as Record<string, unknown>)[field] : undefined));
|
||||
},
|
||||
|
||||
first: (v) => {
|
||||
if (!Array.isArray(v)) return v;
|
||||
return v[0];
|
||||
},
|
||||
|
||||
pluck_content: (v) => {
|
||||
if (!Array.isArray(v)) throw new Error('pluck_content: input 不是 array');
|
||||
return v
|
||||
.map((b) => (b && typeof b === 'object' ? String((b as Record<string, unknown>).content ?? '') : ''))
|
||||
.filter((s) => s.length > 0)
|
||||
.join('\n\n---\n\n');
|
||||
},
|
||||
};
|
||||
|
||||
/** 解析 "name" 或 "name:arg" → 執行 transform */
|
||||
export function applyTransform(value: unknown, spec: string): unknown {
|
||||
const colonIdx = spec.indexOf(':');
|
||||
const name = colonIdx === -1 ? spec : spec.slice(0, colonIdx);
|
||||
const arg = colonIdx === -1 ? undefined : spec.slice(colonIdx + 1);
|
||||
const fn = transforms[name];
|
||||
if (!fn) throw new Error(`未知 transform: ${name}`);
|
||||
return fn(value, arg);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// 圖定義的 Zod Schema
|
||||
export const graphSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
nodes: z.array(z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['Input', 'Component', 'Output']),
|
||||
componentId: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
data: z.record(z.unknown()).optional(),
|
||||
})),
|
||||
edges: z.array(z.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
type: z.enum(['PIPE', 'IF', 'FOREACH', 'CONTINUE', 'IS_A', 'ON_SUCCESS', 'ON_FAIL', 'ON_CLICK', 'CALLS_SUBFLOW', 'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR']),
|
||||
condition: z.string().optional(),
|
||||
iterator: z.string().optional(),
|
||||
})),
|
||||
});
|
||||
|
||||
export const executeSchema = z.object({
|
||||
graph: graphSchema,
|
||||
context: z.record(z.unknown()).default({}),
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Implicit telemetry — 對應 SDD .agents/specs/llm-interface/ M1.2
|
||||
*
|
||||
* 每次 deploy / run / validate 失敗,cypher-executor 自動寫 KBDB block
|
||||
* type=agent-telemetry,含 event_type / workflow_name / error_code /
|
||||
* duration_ms / api_key_hash / agent_user_agent。
|
||||
*
|
||||
* 隱私:api_key SHA-256 截 16 字元(不可逆,可聚合),workflow 內容不 log。
|
||||
*
|
||||
* 設計:不阻擋主流程,fetch fire-and-forget;錯誤只 console.warn 不 throw。
|
||||
*
|
||||
* 注意:本 module 屬 orchestrator 自身能力(觀測自己),不違反「業務邏輯走 WASM」鐵律。
|
||||
* 跟 trigger_workflow / scheduled() 同類,是 cypher-executor 自我管理的一部分。
|
||||
*/
|
||||
|
||||
import type { Bindings, ExecutionContext } from '../types';
|
||||
|
||||
export type TelemetryEvent =
|
||||
| 'deploy_success'
|
||||
| 'deploy_fail'
|
||||
| 'run_success'
|
||||
| 'run_fail'
|
||||
| 'validation_error'
|
||||
| 'mcp_tool_call'
|
||||
| 'node_success' // 單一 node 跑完(給 step-level 效能分析用)
|
||||
| 'node_failure'; // 單一 node 失敗
|
||||
|
||||
export interface TelemetryRecord {
|
||||
event_type: TelemetryEvent;
|
||||
workflow_name?: string;
|
||||
component_id?: string;
|
||||
error_code?: string;
|
||||
duration_ms: number;
|
||||
api_key_hash: string;
|
||||
agent_user_agent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* api_key → SHA-256 hex 截前 16 字元
|
||||
* 不可逆,可用來聚合(同一用戶不同 event 統計),不會洩漏原 key
|
||||
*/
|
||||
export async function hashApiKey(apiKey: string): Promise<string> {
|
||||
if (!apiKey) return 'anon';
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(apiKey);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray
|
||||
.slice(0, 8) // 8 bytes = 16 hex chars
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* KBDB upsert URL(內部走 workers.dev 避同 zone 自循環)
|
||||
* 對應 .claude/rules/03-component-architecture.md
|
||||
*/
|
||||
function kbdbCreateBlockUrl(env: Bindings): string {
|
||||
const subdomain = env.WORKER_SUBDOMAIN || 'uncle6-me';
|
||||
return `https://arcrun-kbdb-create-block.${subdomain}.workers.dev`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 寫一筆 telemetry block 到 KBDB。fire-and-forget。
|
||||
*
|
||||
* 寫不進去也不擋主流程 —— 平台自己的觀測絕不能讓 user-facing 流程失敗。
|
||||
*
|
||||
* 用 ctx.waitUntil 確保即使主 request 已回,背景仍會跑完。
|
||||
*/
|
||||
export function recordTelemetry(
|
||||
env: Bindings,
|
||||
apiKey: string | undefined,
|
||||
record: Omit<TelemetryRecord, 'api_key_hash'>,
|
||||
ctx?: ExecutionContext,
|
||||
): void {
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const api_key_hash = await hashApiKey(apiKey ?? '');
|
||||
// platform telemetry 用一個系統 ak(讀 env.PLATFORM_API_KEY),所有 telemetry
|
||||
// 都聚集在 platform user_id 下,避免污染用戶自己的 KBDB namespace
|
||||
const platformKey = env.PLATFORM_API_KEY || apiKey || '';
|
||||
if (!platformKey) {
|
||||
// 沒 platform key + 沒用戶 key → 無處可寫,skip
|
||||
console.warn('[telemetry] no api_key, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
api_key: platformKey,
|
||||
type: 'agent-telemetry',
|
||||
source: 'cypher-executor',
|
||||
user_id: 'platform_telemetry',
|
||||
content: JSON.stringify(record),
|
||||
metadata_json: JSON.stringify({ ...record, api_key_hash }),
|
||||
tags_json: JSON.stringify([
|
||||
'agent-telemetry',
|
||||
`event:${record.event_type}`,
|
||||
]),
|
||||
};
|
||||
|
||||
const res = await fetch(kbdbCreateBlockUrl(env), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(
|
||||
'[telemetry] write failed',
|
||||
res.status,
|
||||
await res.text().catch(() => 'no body'),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[telemetry] exception', e);
|
||||
}
|
||||
})();
|
||||
|
||||
if (ctx?.waitUntil) {
|
||||
ctx.waitUntil(promise);
|
||||
}
|
||||
// 沒 ctx.waitUntil 的情況(直接從 host function call)也讓 promise 自己跑,可能被 cancel 也接受
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
/**
|
||||
* WASI preview1 輕量 shim
|
||||
* 只實作 stdin/stdout/stderr 所需的最小 syscall 集合。
|
||||
* 其餘 syscall 一律回傳 ENOSYS(76),確保零件無法呼叫網路或檔案系統。
|
||||
*
|
||||
* 不依賴任何外部套件(不使用 @cloudflare/workers-wasi)。
|
||||
* Requirements: 3.1, 3.3
|
||||
*/
|
||||
|
||||
/**
|
||||
* createArcrunHostFunctions 所需的最小 env 子集。
|
||||
* 不直接依賴 cypher-executor 的 Bindings,讓 auth primitive Worker 這類
|
||||
* 只綁 CREDENTIALS_KV / RECIPES / ENCRYPTION_KEY 的獨立 Worker 也能用。
|
||||
*/
|
||||
export interface ArcrunHostEnv {
|
||||
CREDENTIALS_KV: KVNamespace;
|
||||
RECIPES: KVNamespace;
|
||||
ENCRYPTION_KEY: string;
|
||||
}
|
||||
|
||||
const WASI_ESUCCESS = 0;
|
||||
const WASI_ENOSYS = 76;
|
||||
|
||||
// fd 常數
|
||||
const FD_STDIN = 0;
|
||||
const FD_STDOUT = 1;
|
||||
const FD_STDERR = 2;
|
||||
|
||||
export interface WasiShim {
|
||||
/** WebAssembly.Imports 物件,傳入 WebAssembly.instantiate */
|
||||
imports: WebAssembly.Imports;
|
||||
/** 取得 stdout 的完整輸出(合併所有 chunks) */
|
||||
getStdout(): string;
|
||||
/** 取得 stderr 的完整輸出 */
|
||||
getStderr(): string;
|
||||
/** 注入 WebAssembly.Memory(instantiate 後呼叫) */
|
||||
setMemory(memory: WebAssembly.Memory): void;
|
||||
/**
|
||||
* 執行 WASM _start,自動使用 WebAssembly.promising(JSPI)讓 async host
|
||||
* function 能正確 suspend/resume。若 JSPI 不可用則 fallback 同步執行。
|
||||
* 必須在 setMemory() 之後呼叫。
|
||||
*/
|
||||
run(instance: WebAssembly.Instance): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host function 注入介面
|
||||
* 讓 .wasm 零件能透過 host function 呼叫外部服務,而不需要網路 syscall
|
||||
*
|
||||
* 嚴格邊界:
|
||||
* - encryption key 只在 `crypto_decrypt` host function 內部使用,永遠不傳給 WASM
|
||||
* - `kv_get` 必須在 Worker 側檢查 key 前綴以防越權(見 auth-dispatcher.ts)
|
||||
*/
|
||||
export interface WasiHostFunctions {
|
||||
/** HTTP 請求 host function:.wasm 呼叫此函數發出 HTTP 請求 */
|
||||
http_request?: (url: string, method: string, headers: string, body: string) => Promise<string>;
|
||||
/** KV 讀取:key 前綴由 Worker 路由到對應 binding,並做越權檢查 */
|
||||
kv_get?: (key: string) => Promise<string | null>;
|
||||
/** KV 寫入:用於快取 access_token 等短效值,ttlSeconds=0 表示不設 TTL */
|
||||
kv_put?: (key: string, value: string, ttlSeconds: number) => Promise<void>;
|
||||
/** AES-GCM 解密:encryption key 由 Worker 保管,不暴露給 WASM */
|
||||
crypto_decrypt?: (encryptedB64: string, ivB64: string) => Promise<string>;
|
||||
/** RS256 簽章:用 crypto.subtle 做 RSASSA-PKCS1-v1_5 + SHA-256 */
|
||||
crypto_sign_rs256?: (data: Uint8Array, pkcs8: Uint8Array) => Promise<Uint8Array>;
|
||||
/** HMAC-SHA256(data, ENCRYPTION_KEY) → raw bytes */
|
||||
crypto_hmac_sha256?: (data: Uint8Array) => Promise<Uint8Array>;
|
||||
/** AES-GCM 加密(plaintext, ENCRYPTION_KEY) → {encryptedB64, ivB64} */
|
||||
crypto_aes_encrypt?: (plaintext: Uint8Array) => Promise<{ encryptedB64: string; ivB64: string }>;
|
||||
/** crypto random bytes → hex string */
|
||||
crypto_random_bytes?: (numBytes: number) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 WASI shim 實例
|
||||
* @param stdinData - 要寫入 stdin 的 UTF-8 字串(通常是 JSON.stringify(input))
|
||||
* @param hostFunctions - 可選的 host function 注入(讓 .wasm 呼叫外部服務)
|
||||
*/
|
||||
export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFunctions): WasiShim {
|
||||
const stdinBytes = new TextEncoder().encode(stdinData);
|
||||
let stdinOffset = 0;
|
||||
|
||||
const stdoutChunks: Uint8Array[] = [];
|
||||
const stderrChunks: Uint8Array[] = [];
|
||||
|
||||
let memory: WebAssembly.Memory | null = null;
|
||||
|
||||
function getMemoryView(): DataView {
|
||||
if (!memory) throw new Error('WASI memory not set — call setMemory() after instantiate');
|
||||
return new DataView(memory.buffer);
|
||||
}
|
||||
|
||||
// 寫入結果到 WASM 的 outPtr buffer(host function 共用)
|
||||
// 回傳 0 = 成功,1 = memory 不可用
|
||||
function writeOut(buf: ArrayBuffer, outPtr: number, outLenPtr: number, data: Uint8Array): number {
|
||||
try {
|
||||
new Uint8Array(buf, outPtr, data.length).set(data);
|
||||
new DataView(buf).setUint32(outLenPtr, data.length, true);
|
||||
return 0;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* fd_write: 將 iovec 陣列的資料寫入 fd(stdout=1 或 stderr=2)
|
||||
* iovec 結構:{ buf: i32, buf_len: i32 }(各 4 bytes,little-endian)
|
||||
*/
|
||||
function fd_write(fd: number, iovs: number, iovs_len: number, nwritten_ptr: number): number {
|
||||
if (fd !== FD_STDOUT && fd !== FD_STDERR) return WASI_ENOSYS;
|
||||
const view = getMemoryView();
|
||||
const buf = memory!.buffer;
|
||||
let totalWritten = 0;
|
||||
|
||||
for (let i = 0; i < iovs_len; i++) {
|
||||
const iov_base = view.getUint32(iovs + i * 8, true);
|
||||
const iov_len = view.getUint32(iovs + i * 8 + 4, true);
|
||||
if (iov_len === 0) continue;
|
||||
const chunk = new Uint8Array(buf, iov_base, iov_len);
|
||||
const copy = new Uint8Array(iov_len);
|
||||
copy.set(chunk);
|
||||
if (fd === FD_STDOUT) stdoutChunks.push(copy);
|
||||
else stderrChunks.push(copy);
|
||||
totalWritten += iov_len;
|
||||
}
|
||||
|
||||
view.setUint32(nwritten_ptr, totalWritten, true);
|
||||
return WASI_ESUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* fd_read: 從 stdin 讀取資料到 iovec 陣列
|
||||
*/
|
||||
function fd_read(fd: number, iovs: number, iovs_len: number, nread_ptr: number): number {
|
||||
if (fd !== FD_STDIN) return WASI_ENOSYS;
|
||||
const view = getMemoryView();
|
||||
const buf = memory!.buffer;
|
||||
let totalRead = 0;
|
||||
|
||||
for (let i = 0; i < iovs_len; i++) {
|
||||
const iov_base = view.getUint32(iovs + i * 8, true);
|
||||
const iov_len = view.getUint32(iovs + i * 8 + 4, true);
|
||||
if (iov_len === 0) continue;
|
||||
|
||||
const remaining = stdinBytes.length - stdinOffset;
|
||||
if (remaining <= 0) break;
|
||||
|
||||
const toCopy = Math.min(iov_len, remaining);
|
||||
const dest = new Uint8Array(buf, iov_base, toCopy);
|
||||
dest.set(stdinBytes.subarray(stdinOffset, stdinOffset + toCopy));
|
||||
stdinOffset += toCopy;
|
||||
totalRead += toCopy;
|
||||
}
|
||||
|
||||
view.setUint32(nread_ptr, totalRead, true);
|
||||
return WASI_ESUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* proc_exit: 零件呼叫 exit(),拋出 Error 中止執行
|
||||
*/
|
||||
function proc_exit(code: number): never {
|
||||
throw new Error(`wasm exit: ${code}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* random_get: 填充隨機 bytes(使用 Web Crypto API)
|
||||
*/
|
||||
function random_get(buf_ptr: number, buf_len: number): number {
|
||||
const view = new Uint8Array(memory!.buffer, buf_ptr, buf_len);
|
||||
crypto.getRandomValues(view);
|
||||
return WASI_ESUCCESS;
|
||||
}
|
||||
|
||||
// ── Asyncify protocol ──────────────────────────────────────────────────────
|
||||
// TinyGo WASI target 永遠使用 asyncify scheduler。Asyncify 讓 WASM 能在呼叫 host
|
||||
// function 時「unwind」(保存 call stack),待 async 工作完成後再「rewind」(恢復)。
|
||||
//
|
||||
// 協議流程(每次 async host function 呼叫):
|
||||
// 1. WASM 呼叫 host import(例如 http_request)
|
||||
// 2. Host 檢查 asyncify_get_state():
|
||||
// - state=1(Unwinding): 正在展開,host 應直接回傳 0(佔位值)
|
||||
// - state=2(Rewinding): 正在恢復,host 應回傳上一次 async 結果(已存在 asyncifyResult)
|
||||
// - state=0(Normal): 正常執行,host 啟動 async 工作並呼叫 asyncify_start_unwind
|
||||
// 3. WASM 的 _start 控制流回到 run()(asyncify 讓 _start 提前返回)
|
||||
// 4. run() await async 工作,呼叫 asyncify_start_rewind,再次呼叫 _start
|
||||
// 5. WASM 從 host import 返回點繼續執行,host 回傳儲存的結果
|
||||
//
|
||||
// 注意:每次 _start 呼叫只能處理一個 async 中斷點。若 WASM 有多個連續的 async host call,
|
||||
// run() 會在 while 迴圈裡重複 rewind 直到 asyncify_get_state() == 0(Normal)。
|
||||
|
||||
// Asyncify 資料緩衝區設定(TinyGo asyncify 用於保存 call stack)
|
||||
// 位址在 run() 中設定(WASM memory 末尾分配 1MB)
|
||||
let asyncifyDataPtr = 0;
|
||||
const ASYNCIFY_BUF_SIZE = 1024 * 1024; // 1MB stack buffer
|
||||
|
||||
// 儲存 async host function 的結果和 Promise
|
||||
let asyncifyPendingPromise: Promise<number> | null = null;
|
||||
let asyncifyResult: number = 0;
|
||||
|
||||
// asyncify exports(run() 設定後才可用)
|
||||
let asyncifyExports: {
|
||||
get_state: () => number;
|
||||
start_unwind: (ptr: number) => void;
|
||||
stop_unwind: () => void;
|
||||
start_rewind: (ptr: number) => void;
|
||||
stop_rewind: () => void;
|
||||
} | null = null;
|
||||
|
||||
// JSPI helper:若環境支援 WebAssembly.Suspending,用它包裝 async import function
|
||||
// 用於 scheduler=none 編譯的 WASM(無 asyncify exports)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function jspiSuspending<T extends (...args: any[]) => Promise<unknown>>(fn: T): T {
|
||||
const SuspendingCtor = (WebAssembly as unknown as Record<string, unknown>)['Suspending'] as
|
||||
(new (fn: T) => T) | undefined;
|
||||
return SuspendingCtor ? new SuspendingCtor(fn) : fn;
|
||||
}
|
||||
|
||||
// 建立一個 asyncify-aware 的 host function wrapper
|
||||
// 協議:Normal 時啟動 async 工作並呼叫 start_unwind;Rewinding 時回傳已存的結果
|
||||
// 用於 scheduler=asyncify 編譯的 WASM(有 asyncify exports)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function asyncifyWrap(fn: (...args: any[]) => Promise<number>): (...args: any[]) => number {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (...args: any[]): number => {
|
||||
if (!memory) return 1;
|
||||
|
||||
const ax = asyncifyExports;
|
||||
if (!ax) return 0; // asyncify 尚未初始化(sync fallback)
|
||||
|
||||
const state = ax.get_state();
|
||||
|
||||
if (state === 2) {
|
||||
// Rewinding:回傳上次 async 的真實結果
|
||||
return asyncifyResult;
|
||||
}
|
||||
|
||||
if (state === 1) {
|
||||
// Unwinding 中:直接回傳 0(WASM 在 unwind,不使用此值)
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Normal(state=0):啟動 async 工作,觸發 asyncify unwind
|
||||
asyncifyPendingPromise = fn(...args);
|
||||
|
||||
// asyncify_start_unwind 設定 WASM 內部 unwind flag;
|
||||
// host function 返回後 WASM 開始保存 call stack,最終 _start() 返回
|
||||
ax.start_unwind(asyncifyDataPtr);
|
||||
return 0; // WASM 忽略此值(正在 unwind)
|
||||
};
|
||||
}
|
||||
|
||||
// 根據 WASM 是否有 asyncify exports 決定使用哪種包裝方式
|
||||
// JSPI mode: scheduler=none WASM + WebAssembly.Suspending
|
||||
// asyncify mode: scheduler=asyncify WASM + asyncify protocol
|
||||
// 初始化時先用 asyncifyWrap,run() 後若沒有 asyncify exports 就切換到 jspiSuspending
|
||||
// 但因為 imports 在 instantiate 前就需要確定,這裡統一先用 asyncifyWrap
|
||||
// run() 時若發現沒有 asyncify exports 且有 JSPI,則使用 JSPI 模式
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function hostWrap(fn: (...args: any[]) => Promise<number>): (...args: any[]) => number | Promise<number> {
|
||||
// 嘗試使用 JSPI Suspending(若環境支援)
|
||||
const SuspendingCtor = (WebAssembly as unknown as Record<string, unknown>)['Suspending'] as
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(new (fn: any) => any) | undefined;
|
||||
|
||||
if (SuspendingCtor) {
|
||||
// JSPI 可用:包裝為 Suspending,讓 WASM 能 suspend 等待 async 結果
|
||||
// 這適用於 scheduler=none 的 WASM(無 asyncify 干擾)
|
||||
return new SuspendingCtor(fn);
|
||||
}
|
||||
|
||||
// fallback:asyncify 協議(scheduler=asyncify WASM)
|
||||
return asyncifyWrap(fn);
|
||||
}
|
||||
|
||||
const shim: WasiShim = {
|
||||
imports: {
|
||||
wasi_snapshot_preview1: { fd_write,
|
||||
fd_read,
|
||||
proc_exit,
|
||||
random_get,
|
||||
// 其餘 syscall 回傳 ENOSYS(不允許網路/檔案系統操作)
|
||||
fd_seek: () => WASI_ENOSYS,
|
||||
fd_close: () => WASI_ESUCCESS,
|
||||
fd_fdstat_get: () => WASI_ENOSYS,
|
||||
fd_prestat_get: () => WASI_ENOSYS,
|
||||
fd_prestat_dir_name: () => WASI_ENOSYS,
|
||||
environ_get: () => WASI_ESUCCESS,
|
||||
environ_sizes_get: (count_ptr: number, size_ptr: number) => {
|
||||
if (memory) {
|
||||
const view = getMemoryView();
|
||||
view.setUint32(count_ptr, 0, true);
|
||||
view.setUint32(size_ptr, 0, true);
|
||||
}
|
||||
return WASI_ESUCCESS;
|
||||
},
|
||||
args_get: () => WASI_ESUCCESS,
|
||||
args_sizes_get: (argc_ptr: number, argv_buf_size_ptr: number) => {
|
||||
if (memory) {
|
||||
const view = getMemoryView();
|
||||
view.setUint32(argc_ptr, 0, true);
|
||||
view.setUint32(argv_buf_size_ptr, 0, true);
|
||||
}
|
||||
return WASI_ESUCCESS;
|
||||
},
|
||||
clock_time_get: (id: number, precision: bigint, time_ptr: number) => {
|
||||
if (memory) {
|
||||
const view = getMemoryView();
|
||||
const now = BigInt(Date.now()) * 1_000_000n;
|
||||
view.setBigUint64(time_ptr, now, true);
|
||||
}
|
||||
return WASI_ESUCCESS;
|
||||
},
|
||||
clock_res_get: () => WASI_ENOSYS,
|
||||
poll_oneoff: () => WASI_ENOSYS,
|
||||
sched_yield: () => WASI_ESUCCESS,
|
||||
proc_raise: () => WASI_ENOSYS,
|
||||
sock_accept: () => WASI_ENOSYS,
|
||||
sock_recv: () => WASI_ENOSYS,
|
||||
sock_send: () => WASI_ENOSYS,
|
||||
sock_shutdown: () => WASI_ENOSYS,
|
||||
path_open: () => WASI_ENOSYS,
|
||||
path_create_directory: () => WASI_ENOSYS,
|
||||
path_remove_directory: () => WASI_ENOSYS,
|
||||
path_rename: () => WASI_ENOSYS,
|
||||
path_unlink_file: () => WASI_ENOSYS,
|
||||
path_filestat_get: () => WASI_ENOSYS,
|
||||
path_readlink: () => WASI_ENOSYS,
|
||||
path_symlink: () => WASI_ENOSYS,
|
||||
path_link: () => WASI_ENOSYS,
|
||||
},
|
||||
// u6u host functions:讓 .wasm 零件透過 host function 呼叫外部服務
|
||||
// .wasm 零件用 //go:wasmimport u6u <name> 宣告
|
||||
// 所有 async host function 透過 asyncifyWrap 包裝,實作 asyncify 協議
|
||||
u6u: {
|
||||
http_request: hostFunctions?.http_request
|
||||
? hostWrap(async (urlPtr: number, urlLen: number, methodPtr: number, methodLen: number,
|
||||
headersPtr: number, headersLen: number, bodyPtr: number, bodyLen: number,
|
||||
outPtr: number, outLenPtr: number): Promise<number> => {
|
||||
if (!memory) return 1;
|
||||
// 在 await 前讀完所有輸入(memory.buffer 在 await 後可能因 grow 而失效)
|
||||
const snapBuf = memory.buffer;
|
||||
const dec = new TextDecoder();
|
||||
const url = dec.decode(new Uint8Array(snapBuf, urlPtr, urlLen));
|
||||
const method = dec.decode(new Uint8Array(snapBuf, methodPtr, methodLen));
|
||||
const headers = dec.decode(new Uint8Array(snapBuf, headersPtr, headersLen));
|
||||
const body = dec.decode(new Uint8Array(snapBuf, bodyPtr, bodyLen));
|
||||
try {
|
||||
const result = await hostFunctions!.http_request!(url, method, headers, body);
|
||||
// await 後重新拿 memory.buffer(grow 會產生新的 ArrayBuffer)
|
||||
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
})
|
||||
: () => 1,
|
||||
|
||||
// kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 key
|
||||
kv_get: hostFunctions?.kv_get
|
||||
? hostWrap(async (keyPtr: number, keyLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
|
||||
if (!memory) { console.error('[kv_get] memory null'); return 1; }
|
||||
const key = new TextDecoder().decode(new Uint8Array(memory.buffer, keyPtr, keyLen));
|
||||
console.error(`[kv_get] key="${key}" keyPtr=${keyPtr} keyLen=${keyLen} outPtr=${outPtr} outLenPtr=${outLenPtr}`);
|
||||
try {
|
||||
const result = await hostFunctions!.kv_get!(key);
|
||||
console.error(`[kv_get] result=${result === null ? 'null' : result.slice(0, 80)}`);
|
||||
if (result === null) return 2;
|
||||
const encoded = new TextEncoder().encode(result);
|
||||
const status = writeOut(memory.buffer, outPtr, outLenPtr, encoded);
|
||||
console.error(`[kv_get] writeOut status=${status} encodedLen=${encoded.length} memBufLen=${memory.buffer.byteLength}`);
|
||||
return status;
|
||||
} catch (e) {
|
||||
console.error(`[kv_get] error: ${e}`);
|
||||
return 1;
|
||||
}
|
||||
})
|
||||
: () => 1,
|
||||
|
||||
// kv_put(keyPtr, keyLen, valPtr, valLen, ttlSeconds) → 0 成功;1 錯誤
|
||||
kv_put: hostFunctions?.kv_put
|
||||
? hostWrap(async (keyPtr: number, keyLen: number, valPtr: number, valLen: number, ttlSeconds: number): Promise<number> => {
|
||||
if (!memory) return 1;
|
||||
const dec = new TextDecoder();
|
||||
const key = dec.decode(new Uint8Array(memory.buffer, keyPtr, keyLen));
|
||||
const value = dec.decode(new Uint8Array(memory.buffer, valPtr, valLen));
|
||||
try {
|
||||
await hostFunctions!.kv_put!(key, value, ttlSeconds);
|
||||
return 0;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
})
|
||||
: () => 1,
|
||||
|
||||
// crypto_decrypt(encPtr, encLen, ivPtr, ivLen, outPtr, outLenPtr) → 0 成功
|
||||
// 輸入皆為 base64 字串(WASM 從 KV 讀到什麼就送什麼)
|
||||
crypto_decrypt: hostFunctions?.crypto_decrypt
|
||||
? hostWrap(async (encPtr: number, encLen: number, ivPtr: number, ivLen: number,
|
||||
outPtr: number, outLenPtr: number): Promise<number> => {
|
||||
if (!memory) return 1;
|
||||
const dec = new TextDecoder();
|
||||
const encB64 = dec.decode(new Uint8Array(memory.buffer, encPtr, encLen));
|
||||
const ivB64 = dec.decode(new Uint8Array(memory.buffer, ivPtr, ivLen));
|
||||
try {
|
||||
const plaintext = await hostFunctions!.crypto_decrypt!(encB64, ivB64);
|
||||
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(plaintext));
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
})
|
||||
: () => 1,
|
||||
|
||||
// crypto_sign_rs256(dataPtr, dataLen, pkcs8Ptr, pkcs8Len, outPtr, outLenPtr) → 0 成功
|
||||
crypto_sign_rs256: hostFunctions?.crypto_sign_rs256
|
||||
? hostWrap(async (dataPtr: number, dataLen: number, pkcs8Ptr: number, pkcs8Len: number,
|
||||
outPtr: number, outLenPtr: number): Promise<number> => {
|
||||
if (!memory) return 1;
|
||||
// await 前複製 typed array(避免 memory grow 後 buffer 失效)
|
||||
const data = new Uint8Array(new Uint8Array(memory.buffer, dataPtr, dataLen));
|
||||
const pkcs8 = new Uint8Array(new Uint8Array(memory.buffer, pkcs8Ptr, pkcs8Len));
|
||||
try {
|
||||
const sig = await hostFunctions!.crypto_sign_rs256!(data, pkcs8);
|
||||
return writeOut(memory.buffer, outPtr, outLenPtr, sig);
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
})
|
||||
: () => 1,
|
||||
|
||||
// crypto_hmac_sha256(dataPtr, dataLen, outPtr, outLenPtr) → 0 成功,output = raw bytes
|
||||
crypto_hmac_sha256: hostFunctions?.crypto_hmac_sha256
|
||||
? hostWrap(async (dataPtr: number, dataLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
|
||||
if (!memory) return 1;
|
||||
const data = new Uint8Array(new Uint8Array(memory.buffer, dataPtr, dataLen));
|
||||
try {
|
||||
const sig = await hostFunctions!.crypto_hmac_sha256!(data);
|
||||
return writeOut(memory.buffer, outPtr, outLenPtr, sig);
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
})
|
||||
: () => 1,
|
||||
|
||||
// crypto_aes_encrypt(plaintextPtr, plaintextLen, outEncPtr, outEncLenPtr, outIvPtr, outIvLenPtr) → 0 成功
|
||||
crypto_aes_encrypt: hostFunctions?.crypto_aes_encrypt
|
||||
? hostWrap(async (plaintextPtr: number, plaintextLen: number,
|
||||
outEncPtr: number, outEncLenPtr: number,
|
||||
outIvPtr: number, outIvLenPtr: number): Promise<number> => {
|
||||
if (!memory) return 1;
|
||||
const plaintext = new Uint8Array(new Uint8Array(memory.buffer, plaintextPtr, plaintextLen));
|
||||
try {
|
||||
const { encryptedB64, ivB64 } = await hostFunctions!.crypto_aes_encrypt!(plaintext);
|
||||
const encBytes = new TextEncoder().encode(encryptedB64);
|
||||
const ivBytes = new TextEncoder().encode(ivB64);
|
||||
const s1 = writeOut(memory.buffer, outEncPtr, outEncLenPtr, encBytes);
|
||||
const s2 = writeOut(memory.buffer, outIvPtr, outIvLenPtr, ivBytes);
|
||||
return s1 !== 0 ? s1 : s2;
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
})
|
||||
: () => 1,
|
||||
|
||||
// crypto_random_bytes(numBytes, outPtr, outLenPtr) → 0 成功,output = hex string
|
||||
crypto_random_bytes: hostFunctions?.crypto_random_bytes
|
||||
? (numBytes: number, outPtr: number, outLenPtr: number): number => {
|
||||
if (!memory) return 1;
|
||||
try {
|
||||
const hexStr = hostFunctions!.crypto_random_bytes!(numBytes);
|
||||
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(hexStr));
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
: () => 1,
|
||||
},
|
||||
},
|
||||
|
||||
setMemory(mem: WebAssembly.Memory) {
|
||||
memory = mem;
|
||||
},
|
||||
|
||||
async run(instance: WebAssembly.Instance): Promise<void> {
|
||||
const exp = instance.exports as Record<string, unknown>;
|
||||
const startFn = (exp._start ?? exp.main) as (() => void) | undefined;
|
||||
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const promisingFn = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
|
||||
((fn: () => void) => () => Promise<void>) | undefined;
|
||||
|
||||
// 若環境支援 JSPI(Cloudflare Workers 2025+),優先使用 WebAssembly.promising
|
||||
// hostWrap() 已將 imports 包裝為 WebAssembly.Suspending,不需要 asyncify 協議
|
||||
if (promisingFn) {
|
||||
try {
|
||||
await promisingFn(startFn)();
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// JSPI 不可用:使用 asyncify 協議(需要 WASM 有 asyncify exports)
|
||||
const asyncifyGetState = exp.asyncify_get_state as (() => number) | undefined;
|
||||
const asyncifyStartUnwind = exp.asyncify_start_unwind as ((ptr: number) => void) | undefined;
|
||||
const asyncifyStopUnwind = exp.asyncify_stop_unwind as (() => void) | undefined;
|
||||
const asyncifyStartRewind = exp.asyncify_start_rewind as ((ptr: number) => void) | undefined;
|
||||
const asyncifyStopRewind = exp.asyncify_stop_rewind as (() => void) | undefined;
|
||||
|
||||
if (asyncifyGetState && asyncifyStartUnwind && asyncifyStopUnwind &&
|
||||
asyncifyStartRewind && asyncifyStopRewind) {
|
||||
asyncifyExports = {
|
||||
get_state: asyncifyGetState,
|
||||
start_unwind: asyncifyStartUnwind,
|
||||
stop_unwind: asyncifyStopUnwind,
|
||||
start_rewind: asyncifyStartRewind,
|
||||
stop_rewind: asyncifyStopRewind,
|
||||
};
|
||||
|
||||
const mallocFn = exp.malloc as ((size: number) => number) | undefined;
|
||||
if (mallocFn && memory) {
|
||||
const totalSize = ASYNCIFY_BUF_SIZE;
|
||||
asyncifyDataPtr = mallocFn(totalSize);
|
||||
const view = new DataView(memory.buffer);
|
||||
view.setInt32(asyncifyDataPtr, asyncifyDataPtr + 8, true);
|
||||
view.setInt32(asyncifyDataPtr + 4, asyncifyDataPtr + totalSize, true);
|
||||
} else if (memory) {
|
||||
const memBytes = memory.buffer.byteLength;
|
||||
asyncifyDataPtr = memBytes - ASYNCIFY_BUF_SIZE;
|
||||
if (asyncifyDataPtr > 8) {
|
||||
const view = new DataView(memory.buffer);
|
||||
view.setInt32(asyncifyDataPtr, asyncifyDataPtr + 8, true);
|
||||
view.setInt32(asyncifyDataPtr + 4, asyncifyDataPtr + ASYNCIFY_BUF_SIZE, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JSPI 不可用且無 asyncify exports:同步執行(host function 不能 async)
|
||||
if (!asyncifyExports) {
|
||||
try { startFn(); } catch (e) {
|
||||
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 主執行迴圈:每次呼叫 _start,若 asyncify 捕捉到 pending promise 就 await 再 rewind
|
||||
let rewinding = false;
|
||||
while (true) {
|
||||
asyncifyPendingPromise = null;
|
||||
|
||||
try {
|
||||
if (rewinding) {
|
||||
asyncifyExports.start_rewind(asyncifyDataPtr);
|
||||
startFn();
|
||||
asyncifyExports.stop_rewind();
|
||||
} else {
|
||||
startFn();
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === 'wasm exit: 0') break;
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 若 asyncifyWrap 觸發了 unwind,_start 會因 unwind 返回(沒有 exit)
|
||||
// asyncifyWrap 已呼叫 start_unwind,這裡只需 stop_unwind 並 await promise
|
||||
if (asyncifyPendingPromise !== null) {
|
||||
asyncifyExports.stop_unwind();
|
||||
asyncifyResult = await asyncifyPendingPromise;
|
||||
asyncifyPendingPromise = null;
|
||||
rewinding = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 沒有 pending promise 且沒有 exit → 正常完成
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
getStdout(): string {
|
||||
if (stdoutChunks.length === 0) return '';
|
||||
const total = stdoutChunks.reduce((n, c) => n + c.length, 0);
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of stdoutChunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return new TextDecoder().decode(merged);
|
||||
},
|
||||
|
||||
getStderr(): string {
|
||||
if (stderrChunks.length === 0) return '';
|
||||
const total = stderrChunks.reduce((n, c) => n + c.length, 0);
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of stderrChunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return new TextDecoder().decode(merged);
|
||||
},
|
||||
};
|
||||
|
||||
return shim;
|
||||
}
|
||||
|
||||
// ── Worker 端 host function 實作(Phase 0.6)──────────────────────────────────
|
||||
//
|
||||
// 唯一合法位置:AES-GCM 解密與 RS256 簽章只准出現在本檔(02-forbidden.md §2.2)。
|
||||
// 由 component-loader 的 WASM runner 路徑呼叫,注入進 createWasiShim。
|
||||
//
|
||||
// 安全邊界:
|
||||
// 1. `ENCRYPTION_KEY` 只在 `crypto_decrypt` 內部讀 env,絕不經 stdin/回傳值傳給 WASM
|
||||
// 2. `kv_get` 依 key 前綴路由,且 `{api_key}:cred:*` 必須符合 stdin 傳入的 api_key(越權檢查)
|
||||
// 3. 未知前綴回傳 null(WASM 收到 kv_get 回傳 2 = 找不到)
|
||||
|
||||
function hexToUint8Array(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function base64ToUint8Array(b64: string): Uint8Array {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 依 key 前綴路由到對應 KV binding,並做越權檢查。
|
||||
* - `auth_recipe:{service}` → env.RECIPES
|
||||
* - `{apiKey}:cred:{name}` → env.CREDENTIALS_KV(前綴必須等於 caller 的 apiKey)
|
||||
* - 其他前綴 → null(拒絕)
|
||||
*/
|
||||
async function routedKvGet(env: ArcrunHostEnv, apiKey: string, key: string): Promise<string | null> {
|
||||
if (key.startsWith('auth_recipe:')) {
|
||||
return env.RECIPES.get(key);
|
||||
}
|
||||
const credMatch = key.match(/^([^:]+):cred:.+$/);
|
||||
if (credMatch) {
|
||||
if (credMatch[1] !== apiKey) {
|
||||
// 越權:WASM 嘗試讀其他租戶的 credential
|
||||
return null;
|
||||
}
|
||||
return env.CREDENTIALS_KV.get(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 依 key 前綴路由寫入 KV。只允許寫 oauth2 cache key(短效 access_token)。
|
||||
* - `{apiKey}:oauth2:{service}:*` → env.CREDENTIALS_KV(越權檢查)
|
||||
*/
|
||||
async function routedKvPut(env: ArcrunHostEnv, apiKey: string, key: string, value: string, ttlSeconds: number): Promise<void> {
|
||||
const oauth2Match = key.match(/^([^:]+):oauth2:.+$/);
|
||||
if (oauth2Match && oauth2Match[1] === apiKey) {
|
||||
const opts = ttlSeconds > 0 ? { expirationTtl: ttlSeconds } : undefined;
|
||||
await env.CREDENTIALS_KV.put(key, value, opts);
|
||||
return;
|
||||
}
|
||||
// 其他 key 前綴拒絕寫入(安全邊界)
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-GCM 解密。encryption key 由 env.ENCRYPTION_KEY 在本 function 內讀取,
|
||||
* 永不傳給 WASM。輸入為 base64 字串,輸出為 UTF-8 plaintext。
|
||||
*/
|
||||
async function aesGcmDecrypt(env: ArcrunHostEnv, encryptedB64: string, ivB64: string): Promise<string> {
|
||||
const keyBytes = hexToUint8Array(env.ENCRYPTION_KEY);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt'],
|
||||
);
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: base64ToUint8Array(ivB64) },
|
||||
cryptoKey,
|
||||
base64ToUint8Array(encryptedB64),
|
||||
);
|
||||
return new TextDecoder().decode(plaintext);
|
||||
}
|
||||
|
||||
/**
|
||||
* RSASSA-PKCS1-v1_5 + SHA-256 簽章。private key 以 PKCS8 bytes 傳入(由 WASM 零件解析 PEM 後送進來)。
|
||||
*/
|
||||
async function rsaPkcs1Sha256Sign(data: Uint8Array, pkcs8: Uint8Array): Promise<Uint8Array> {
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
pkcs8,
|
||||
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', cryptoKey, data);
|
||||
return new Uint8Array(sig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 arcrun host function 組合(kv_get / crypto_decrypt / crypto_sign_rs256)。
|
||||
* 由 WASM runner(component-loader 的 WASM 路徑)呼叫,與 api_key 綁定以做越權檢查。
|
||||
*
|
||||
* http_request 不由本 factory 提供 — auth primitive WASM 與 API WASM 零件若需要
|
||||
* 發 HTTP,由呼叫者(component-loader)另外注入,以便個別限制可達主機。
|
||||
*/
|
||||
export function createArcrunHostFunctions(env: ArcrunHostEnv, apiKey: string): WasiHostFunctions {
|
||||
return {
|
||||
kv_get: (key: string) => routedKvGet(env, apiKey, key),
|
||||
kv_put: (key: string, value: string, ttlSeconds: number) => routedKvPut(env, apiKey, key, value, ttlSeconds),
|
||||
crypto_decrypt: (encB64: string, ivB64: string) => aesGcmDecrypt(env, encB64, ivB64),
|
||||
crypto_sign_rs256: (data: Uint8Array, pkcs8: Uint8Array) => rsaPkcs1Sha256Sign(data, pkcs8),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 platform_crypto host functions。
|
||||
* 不需要 apiKey 或 KV routing,只提供加密操作。
|
||||
* ENCRYPTION_KEY 在 closure 內,永不傳給 WASM。
|
||||
*/
|
||||
export function createPlatformCryptoHostFunctions(encryptionKey: string): WasiHostFunctions {
|
||||
const toB64 = (buf: ArrayBuffer): string => btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
|
||||
return {
|
||||
crypto_hmac_sha256: async (data: Uint8Array): Promise<Uint8Array> => {
|
||||
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
const sig = await crypto.subtle.sign('HMAC', cryptoKey, data);
|
||||
return new Uint8Array(sig);
|
||||
},
|
||||
|
||||
crypto_aes_encrypt: async (plaintext: Uint8Array): Promise<{ encryptedB64: string; ivB64: 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, plaintext);
|
||||
return { encryptedB64: toB64(enc), ivB64: toB64(iv.buffer) };
|
||||
},
|
||||
|
||||
crypto_random_bytes: (numBytes: number): string => {
|
||||
const arr = crypto.getRandomValues(new Uint8Array(numBytes));
|
||||
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user