feat(arcrun): mira wiki page with tag filter + accumulated WIP
- landing/app/mira/wiki: tag=mira-wiki list now shows all wiki paragraphs (depends on KBDB tag filter exposed in matrix/kbdb commit, separate repo) - landing: app/mira hub + feed split + various WIP from prior sessions - registry/components: claude_api / kbdb_create_block / kbdb_get / km_writer / platform_crypto / auth_oauth2 contracts + main.go (accumulated) - .component-builds: pkg-lock updates + index.ts adjustments (WIP) - .agents/specs/arcrun/frontend-redesign: design notes - docs/test_credentials, docs/user_requirements/arcrun-landing-page: WIP docs - cypher-executor: auth-dispatcher / wasi-shim adjustments (WIP) Includes accumulated work from prior sessions plus the wiki UI tag-filter update that surfaces the AI-generated wiki paragraphs at /mira/wiki. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -9,8 +9,8 @@
|
||||
* - 那些全部在 auth primitive WASM 零件內執行(透過 host function `crypto_decrypt` 等)
|
||||
* - 本檔只做「查 recipe 決定走哪個 primitive Worker」+「HTTP fetch 取回注入結果」
|
||||
*
|
||||
* 目前階段(Phase 2)接上 `auth_static_key` + `auth_service_account`,
|
||||
* Phase 4(封測後)加 `auth_oauth2` / `auth_mtls`。
|
||||
* 目前階段接上 `auth_static_key` + `auth_service_account` + `auth_oauth2`,
|
||||
* Phase 4 剩 `auth_mtls`(mTLS handshake 在 Worker runtime 層)。
|
||||
*
|
||||
* 執行時機:graph-executor 在節點 runner 執行前呼叫,取回的 ctx 會:
|
||||
* 1. 先試本 dispatcher(命中才 return enriched ctx)
|
||||
@@ -22,7 +22,7 @@ import { resolveAuthRecipe } from '../routes/recipes';
|
||||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||||
|
||||
/** 對應 Phase 1-4 會部署的 auth primitive Worker */
|
||||
const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account']);
|
||||
const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account', 'oauth2']);
|
||||
|
||||
/** auth primitive 本身的 componentId(避免自引用) */
|
||||
const AUTH_PRIMITIVE_IDS = new Set([
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// KBDB Partner 同步工具
|
||||
// Arcrun 用戶登入/rotate/revoke 時,同步更新 KBDB partner 記錄
|
||||
// 讓 ak_xxx Key 可以直接存取 KBDB(不需要第二把 Key)
|
||||
|
||||
type KbdbEnv = {
|
||||
KBDB_INTERNAL_TOKEN?: string;
|
||||
KBDB_BASE_URL?: string;
|
||||
};
|
||||
|
||||
function kbdbBase(env: KbdbEnv): string {
|
||||
return (env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async function sha256Hex(input: string): Promise<string> {
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 KBDB 建立或更新 Arcrun 用戶的 partner 記錄。
|
||||
* 失敗時靜默 log,不影響 Arcrun 登入流程。
|
||||
*/
|
||||
export async function ensureKbdbPartner(env: KbdbEnv, email: string, apiKey: string): Promise<void> {
|
||||
const token = env.KBDB_INTERNAL_TOKEN;
|
||||
if (!token) {
|
||||
console.warn('[kbdb-partner] KBDB_INTERNAL_TOKEN not set, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKeyHash = await sha256Hex(apiKey);
|
||||
const base = kbdbBase(env);
|
||||
|
||||
const res = await fetch(`${base}/admin/partners/by-key-hash`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: `arcrun:${email}`,
|
||||
org_namespace: `arcrun:${email}`,
|
||||
api_key_hash: apiKeyHash,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
console.error(`[kbdb-partner] ensureKbdbPartner failed: ${res.status} ${body}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[kbdb-partner] ensureKbdbPartner error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤銷 KBDB 中對應的 partner 記錄(使用舊的 api_key_hash 找到並刪除)。
|
||||
* 失敗時靜默 log。
|
||||
*/
|
||||
export async function revokeKbdbPartner(env: KbdbEnv, oldApiKey: string): Promise<void> {
|
||||
const token = env.KBDB_INTERNAL_TOKEN;
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const oldHash = await sha256Hex(oldApiKey);
|
||||
const partnerId = `partner-arcrun-${oldHash.slice(0, 16)}`;
|
||||
const base = kbdbBase(env);
|
||||
|
||||
const res = await fetch(`${base}/admin/partners/${partnerId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok && res.status !== 404) {
|
||||
const body = await res.text().catch(() => '');
|
||||
console.error(`[kbdb-partner] revokeKbdbPartner failed: ${res.status} ${body}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[kbdb-partner] revokeKbdbPartner error:', err);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,12 @@ export interface WasiShim {
|
||||
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>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,10 +56,18 @@ export interface WasiHostFunctions {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,6 +171,107 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
||||
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,
|
||||
@@ -215,9 +330,10 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
||||
},
|
||||
// u6u host functions:讓 .wasm 零件透過 host function 呼叫外部服務
|
||||
// .wasm 零件用 //go:wasmimport u6u <name> 宣告
|
||||
// 所有 async host function 透過 asyncifyWrap 包裝,實作 asyncify 協議
|
||||
u6u: {
|
||||
http_request: hostFunctions?.http_request
|
||||
? async (urlPtr: number, urlLen: number, methodPtr: number, methodLen: number,
|
||||
? 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;
|
||||
@@ -235,28 +351,50 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
})
|
||||
: () => 1,
|
||||
|
||||
// kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 key
|
||||
kv_get: hostFunctions?.kv_get
|
||||
? async (keyPtr: number, keyLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
|
||||
if (!memory) return 1;
|
||||
? 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;
|
||||
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
|
||||
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
|
||||
? async (encPtr: number, encLen: number, ivPtr: number, ivLen: number,
|
||||
? hostWrap(async (encPtr: number, encLen: number, ivPtr: number, ivLen: number,
|
||||
outPtr: number, outLenPtr: number): Promise<number> => {
|
||||
if (!memory) return 1;
|
||||
const dec = new TextDecoder();
|
||||
@@ -268,12 +406,12 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
})
|
||||
: () => 1,
|
||||
|
||||
// crypto_sign_rs256(dataPtr, dataLen, pkcs8Ptr, pkcs8Len, outPtr, outLenPtr) → 0 成功
|
||||
crypto_sign_rs256: hostFunctions?.crypto_sign_rs256
|
||||
? async (dataPtr: number, dataLen: number, pkcs8Ptr: number, pkcs8Len: number,
|
||||
? 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 失效)
|
||||
@@ -285,6 +423,53 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
||||
} 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,
|
||||
},
|
||||
@@ -294,6 +479,102 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
||||
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);
|
||||
@@ -366,6 +647,20 @@ async function routedKvGet(env: ArcrunHostEnv, apiKey: string, key: string): Pro
|
||||
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。
|
||||
@@ -408,7 +703,39 @@ async function rsaPkcs1Sha256Sign(data: Uint8Array, pkcs8: Uint8Array): Promise<
|
||||
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('');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { ensureKbdbPartner, revokeKbdbPartner } from '../lib/kbdb-partner';
|
||||
|
||||
export const authRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -60,6 +61,24 @@ async function generateApiKey(email: string, encryptionKey: string): Promise<str
|
||||
return 'ak_' + hex.slice(0, 32);
|
||||
}
|
||||
|
||||
/** AES-GCM 加密,回傳 {encrypted, iv}(base64),與 SDK 格式相同 */
|
||||
async function aesEncrypt(plaintext: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> {
|
||||
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plaintext));
|
||||
const toB64 = (buf: ArrayBuffer | Uint8Array) => btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf)));
|
||||
return { encrypted: toB64(enc), iv: toB64(iv) };
|
||||
}
|
||||
|
||||
/** 幂等寫入 auth_recipe 到 RECIPES KV(若已存在相同版本則跳過) */
|
||||
async function upsertAuthRecipe(recipes: KVNamespace, recipe: Record<string, unknown>): Promise<void> {
|
||||
const key = `auth_recipe:${recipe.service}`;
|
||||
const existing = await recipes.get(key);
|
||||
if (existing) return; // 已存在,不覆蓋(用戶可能已自訂)
|
||||
await recipes.put(key, JSON.stringify({ ...recipe, created_at: Date.now(), updated_at: Date.now() }));
|
||||
}
|
||||
|
||||
/** 產生隨機 token(用於 session ID 和 state) */
|
||||
function randomToken(bytes = 32): string {
|
||||
const arr = new Uint8Array(bytes);
|
||||
@@ -129,7 +148,7 @@ authRouter.get('/auth/google/start', async (c) => {
|
||||
scope: 'openid profile email',
|
||||
state,
|
||||
access_type: 'offline',
|
||||
prompt: 'select_account',
|
||||
prompt: 'consent',
|
||||
});
|
||||
|
||||
return Response.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`, 302);
|
||||
@@ -207,7 +226,7 @@ authRouter.get('/auth/callback', async (c) => {
|
||||
}),
|
||||
});
|
||||
if (!tokenRes.ok) throw new Error('google token exchange failed');
|
||||
const tokenData = await tokenRes.json() as { access_token: string };
|
||||
const tokenData = await tokenRes.json() as { access_token: string; refresh_token?: string };
|
||||
|
||||
// Get user info
|
||||
const userRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
|
||||
@@ -222,6 +241,32 @@ authRouter.get('/auth/callback', async (c) => {
|
||||
avatarUrl = userInfo.picture;
|
||||
providerId = userInfo.sub;
|
||||
|
||||
// 存 Google refresh_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
|
||||
// Google 只在首次授權時回傳 refresh_token,後續登入 tokenData.refresh_token 為 undefined
|
||||
if (tokenData.refresh_token) {
|
||||
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:google_refresh_token`;
|
||||
const encrypted = await aesEncrypt(tokenData.refresh_token, encryptionKey);
|
||||
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
|
||||
|
||||
// 種 auth_recipe:google_user(用戶自己的 Google OAuth2)
|
||||
void upsertAuthRecipe(c.env.RECIPES, {
|
||||
kind: 'auth_recipe',
|
||||
service: 'google_user',
|
||||
version: 1,
|
||||
primitive: 'oauth2',
|
||||
base_url: 'https://www.googleapis.com',
|
||||
display_name: 'Google(用戶帳號)',
|
||||
oauth2: {
|
||||
token_endpoint: 'https://oauth2.googleapis.com/token',
|
||||
client_id: c.env.GOOGLE_CLIENT_ID ?? '',
|
||||
client_secret: c.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||
scopes: ['https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/spreadsheets'],
|
||||
},
|
||||
required_secrets: [{ key: 'google_refresh_token', label: 'Google Refresh Token' }],
|
||||
inject: { header: { Authorization: 'Bearer {{runtime.access_token}}' } },
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
// GitHub: exchange code for token
|
||||
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
|
||||
@@ -238,7 +283,7 @@ authRouter.get('/auth/callback', async (c) => {
|
||||
}),
|
||||
});
|
||||
if (!tokenRes.ok) throw new Error('github token exchange failed');
|
||||
const tokenData = await tokenRes.json() as { access_token: string };
|
||||
const tokenData = await tokenRes.json() as { access_token: string; token_type?: string };
|
||||
|
||||
// Get user info
|
||||
const userRes = await fetch('https://api.github.com/user', {
|
||||
@@ -274,6 +319,26 @@ authRouter.get('/auth/callback', async (c) => {
|
||||
displayName = userInfo.name ?? userInfo.login;
|
||||
avatarUrl = userInfo.avatar_url;
|
||||
providerId = String(userInfo.id);
|
||||
|
||||
// 存 GitHub access_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
|
||||
// GitHub 沒有 refresh_token,access_token 長效(直到 revoke)
|
||||
if (tokenData.access_token) {
|
||||
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:github_access_token`;
|
||||
const encrypted = await aesEncrypt(tokenData.access_token, encryptionKey);
|
||||
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
|
||||
|
||||
// GitHub access_token 長效無 refresh 概念,用 static_key primitive
|
||||
void upsertAuthRecipe(c.env.RECIPES, {
|
||||
kind: 'auth_recipe',
|
||||
service: 'github_user',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.github.com',
|
||||
display_name: 'GitHub(用戶帳號)',
|
||||
required_secrets: [{ key: 'github_access_token', label: 'GitHub Access Token' }],
|
||||
inject: { header: { Authorization: 'Bearer {{secret.github_access_token}}' } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert user record
|
||||
@@ -300,6 +365,9 @@ authRouter.get('/auth/callback', async (c) => {
|
||||
await c.env.USERS_KV.put(`apikey:${apiKey}`, userKey);
|
||||
}
|
||||
|
||||
// 同步 KBDB partner 記錄(允許 ak_xxx 直接存取 KBDB)
|
||||
void ensureKbdbPartner(c.env, email, apiKey);
|
||||
|
||||
// Create session (TTL 7 days)
|
||||
const sessionId = randomToken(32);
|
||||
const session: SessionRecord = {
|
||||
@@ -317,7 +385,7 @@ authRouter.get('/auth/callback', async (c) => {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `${landingOrigin}${redirectBack}`,
|
||||
'Set-Cookie': `arcrun_session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${7 * 24 * 60 * 60}`,
|
||||
'Set-Cookie': `arcrun_session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=${7 * 24 * 60 * 60}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -339,7 +407,7 @@ authRouter.post('/auth/logout', async (c) => {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `${landingOrigin}/`,
|
||||
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0',
|
||||
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=0',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -379,8 +447,9 @@ authRouter.put('/me/api-key/rotate', async (c) => {
|
||||
await c.env.USERS_KV.delete(`apikey:${oldKey}`);
|
||||
await c.env.USERS_KV.put(`apikey:${newKey}`, userKey);
|
||||
|
||||
// Invalidate all current sessions for this user (simple: sessions will re-auth on next request)
|
||||
// (Full invalidation would require listing all sessions, skip for now)
|
||||
// 更新 KBDB partner 記錄(舊 Key 撤銷,新 Key 建立)
|
||||
void revokeKbdbPartner(c.env, oldKey);
|
||||
void ensureKbdbPartner(c.env, user.email, newKey);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
@@ -400,6 +469,9 @@ authRouter.delete('/me/api-key', async (c) => {
|
||||
await c.env.USERS_KV.put(userKey, JSON.stringify(revoked));
|
||||
await c.env.USERS_KV.delete(`apikey:${user.api_key}`);
|
||||
|
||||
// 撤銷 KBDB partner 記錄
|
||||
void revokeKbdbPartner(c.env, user.api_key);
|
||||
|
||||
// Clear session cookie
|
||||
const sessId = getSessionId(c.req.raw);
|
||||
if (sessId) await c.env.SESSIONS_KV.delete(`sess:${sessId}`);
|
||||
@@ -408,7 +480,7 @@ authRouter.delete('/me/api-key', async (c) => {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0',
|
||||
'Set-Cookie': 'arcrun_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Domain=.arcrun.dev; Max-Age=0',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +102,8 @@ service = "arcrun-ai-transform-run"
|
||||
ENVIRONMENT = "production"
|
||||
# MULTI_TENANT = "true"
|
||||
# ENCRYPTION_KEY 透過 wrangler secret set 設定
|
||||
KBDB_BASE_URL = "https://kbdb.finally.click"
|
||||
# KBDB_INTERNAL_TOKEN 透過 wrangler secret set 設定
|
||||
|
||||
[[routes]]
|
||||
pattern = "cypher.arcrun.dev/*"
|
||||
|
||||
Reference in New Issue
Block a user