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:
@@ -0,0 +1,80 @@
|
||||
canonical_id: "auth_oauth2"
|
||||
display_name: "Auth Primitive — OAuth2"
|
||||
category: "auth"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 200
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [action, api_key, service]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [authenticate, needs_refresh, refresh]
|
||||
description: |
|
||||
authenticate — 用 refresh_token 換 access_token,展開 inject 模板
|
||||
needs_refresh — 檢查 token 是否需要 refresh(expires_at < now+300s)
|
||||
refresh — 強制重新 refresh,更新 CREDENTIALS_KV 中的 access_token/expires_at
|
||||
api_key:
|
||||
type: string
|
||||
description: 租戶識別(ak_ 前綴),用來組 {api_key}:cred:{name} KV key
|
||||
service:
|
||||
type: string
|
||||
description: auth recipe 名稱,對應 auth_recipe:{service} 的 KV 記錄
|
||||
request:
|
||||
type: object
|
||||
description: 下游零件的 HTTP request 上下文(保留,auth_oauth2 當前不使用)
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
needs_refresh:
|
||||
type: boolean
|
||||
description: action=needs_refresh 時有效
|
||||
auth_headers:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
auth_query:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
auth_body:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
runtime:
|
||||
type: object
|
||||
description: 含 access_token(action=authenticate/refresh 時有效)
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 api_key"
|
||||
given: '{"action":"authenticate","service":"google"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "找不到 auth recipe"
|
||||
given: '{"action":"authenticate","api_key":"ak_test","service":"nonexistent_oauth2_svc"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "needs_refresh 無 expires_at"
|
||||
given: '{"action":"needs_refresh","api_key":"ak_test","service":"google"}'
|
||||
then_contains: '"needs_refresh":true'
|
||||
tags: [auth, credential, primitive, oauth2]
|
||||
description: |
|
||||
OAuth2 auth primitive。讀取 auth_recipe(含 token_endpoint、client_id、client_secret)
|
||||
+ 解密 refresh_token + 呼叫 token endpoint 換 access_token + 展開 {{runtime.access_token}}。
|
||||
支援 authenticate / needs_refresh / refresh 三個 action。
|
||||
透過 host function kv_get + crypto_decrypt + http_request,plaintext 永不離開 WASM。
|
||||
config_example: |
|
||||
auth_step:
|
||||
component: "auth_oauth2"
|
||||
action: "authenticate"
|
||||
service: "google_drive" # 對應 auth_recipe:google_drive 的 KV 記錄
|
||||
@@ -0,0 +1,514 @@
|
||||
// auth_oauth2 — OAuth2 auth primitive
|
||||
//
|
||||
// 讀取 auth_recipe:{service}(含 token_endpoint、client_id、client_secret)
|
||||
// + 解密 refresh_token + POST token endpoint 換 access_token
|
||||
// + 展開 {{runtime.access_token}} 模板。
|
||||
//
|
||||
// Actions:
|
||||
// - authenticate: 用 refresh_token 換 access_token,回傳注入後的 headers/query/body
|
||||
// - needs_refresh: 檢查 access_token 是否快過期(expires_at < now+300s)
|
||||
// - refresh: 強制重新 refresh,更新 KV 中的 cached_access_token/expires_at
|
||||
//
|
||||
// Host imports:
|
||||
// - u6u.kv_get — 讀 RECIPES + CREDENTIALS_KV
|
||||
// - u6u.kv_put — 寫回 cached_access_token/expires_at
|
||||
// - u6u.crypto_decrypt — AES-GCM 解密 refresh_token
|
||||
// - u6u.http_request — POST token endpoint
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// ── host function 宣告 ───────────────────────────────────────────────────────
|
||||
|
||||
//go:wasmimport u6u kv_get
|
||||
func hostKvGet(
|
||||
keyPtr uintptr, keyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
//go:wasmimport u6u kv_put
|
||||
func hostKvPut(
|
||||
keyPtr uintptr, keyLen uint32,
|
||||
valPtr uintptr, valLen uint32,
|
||||
ttlSeconds uint32,
|
||||
) uint32
|
||||
|
||||
//go:wasmimport u6u crypto_decrypt
|
||||
func hostCryptoDecrypt(
|
||||
encPtr uintptr, encLen uint32,
|
||||
ivPtr uintptr, ivLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
// ── 型別 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Input struct {
|
||||
Action string `json:"action"`
|
||||
APIKey string `json:"api_key"`
|
||||
Service string `json:"service"`
|
||||
Request json.RawMessage `json:"request,omitempty"`
|
||||
}
|
||||
|
||||
type SecretRequirement struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Optional bool `json:"optional,omitempty"`
|
||||
}
|
||||
|
||||
type AuthInjectSpec struct {
|
||||
Header map[string]string `json:"header,omitempty"`
|
||||
Query map[string]string `json:"query,omitempty"`
|
||||
Body map[string]string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
type OAuth2Config struct {
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
}
|
||||
|
||||
type AuthRecipe struct {
|
||||
Kind string `json:"kind"`
|
||||
Service string `json:"service"`
|
||||
Primitive string `json:"primitive"`
|
||||
OAuth2 *OAuth2Config `json:"oauth2,omitempty"`
|
||||
RequiredSecrets []SecretRequirement `json:"required_secrets"`
|
||||
Inject AuthInjectSpec `json:"inject"`
|
||||
}
|
||||
|
||||
type EncryptedRecord struct {
|
||||
Encrypted string `json:"encrypted"`
|
||||
IV string `json:"iv"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
// ── main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if input.APIKey == "" {
|
||||
writeError("api_key 必填")
|
||||
return
|
||||
}
|
||||
if input.Service == "" {
|
||||
writeError("service 必填")
|
||||
return
|
||||
}
|
||||
|
||||
action := input.Action
|
||||
if action == "" {
|
||||
action = "authenticate"
|
||||
}
|
||||
|
||||
// 讀 auth recipe
|
||||
recipeJSON, status := kvGet("auth_recipe:" + input.Service)
|
||||
if status == 2 {
|
||||
writeError("找不到 auth recipe: " + input.Service)
|
||||
return
|
||||
}
|
||||
if status != 0 {
|
||||
writeError("kv_get 失敗(auth_recipe)")
|
||||
return
|
||||
}
|
||||
|
||||
var recipe AuthRecipe
|
||||
if err := json.Unmarshal([]byte(recipeJSON), &recipe); err != nil {
|
||||
writeError("auth recipe JSON 解析失敗: " + err.Error())
|
||||
return
|
||||
}
|
||||
if recipe.Primitive != "oauth2" {
|
||||
writeError("auth recipe " + input.Service + " 的 primitive 不是 oauth2(是 " + recipe.Primitive + ")")
|
||||
return
|
||||
}
|
||||
if recipe.OAuth2 == nil || recipe.OAuth2.TokenEndpoint == "" {
|
||||
writeError("auth recipe " + input.Service + " 缺少 oauth2.token_endpoint")
|
||||
return
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "needs_refresh":
|
||||
handleNeedsRefresh(input, recipe)
|
||||
case "refresh":
|
||||
handleRefresh(input, recipe)
|
||||
case "authenticate":
|
||||
handleAuthenticate(input, recipe)
|
||||
default:
|
||||
writeError("不支援的 action: " + action)
|
||||
}
|
||||
}
|
||||
|
||||
// ── action handlers ───────────────────────────────────────────────────────────
|
||||
|
||||
func handleNeedsRefresh(input Input, recipe AuthRecipe) {
|
||||
// 讀 cached expires_at(若無,視為需要 refresh)
|
||||
expiresKey := input.APIKey + ":oauth2:" + input.Service + ":expires_at"
|
||||
expiresStr, status := kvGet(expiresKey)
|
||||
if status != 0 {
|
||||
// 找不到 = 需要 refresh
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"needs_refresh": true,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt, err := strconv.ParseInt(strings.TrimSpace(expiresStr), 10, 64)
|
||||
if err != nil {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"needs_refresh": true,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
return
|
||||
}
|
||||
|
||||
// 提前 5 分鐘視為需要 refresh
|
||||
needsRefresh := time.Now().Unix()+300 >= expiresAt
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"needs_refresh": needsRefresh,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func handleRefresh(input Input, recipe AuthRecipe) {
|
||||
accessToken, expiresAt, ok := doRefresh(input, recipe)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 快取新 token
|
||||
cacheAccessToken(input.APIKey, input.Service, accessToken, expiresAt)
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"runtime": map[string]string{"access_token": accessToken},
|
||||
"auth_headers": map[string]string{},
|
||||
"auth_query": map[string]string{},
|
||||
"auth_body": map[string]string{},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func handleAuthenticate(input Input, recipe AuthRecipe) {
|
||||
// 先嘗試讀 cached access_token
|
||||
cachedKey := input.APIKey + ":oauth2:" + input.Service + ":access_token"
|
||||
expiresKey := input.APIKey + ":oauth2:" + input.Service + ":expires_at"
|
||||
|
||||
cachedToken, cStatus := kvGet(cachedKey)
|
||||
expiresStr, eStatus := kvGet(expiresKey)
|
||||
|
||||
var accessToken string
|
||||
var expiresAt int64
|
||||
|
||||
useCache := false
|
||||
if cStatus == 0 && eStatus == 0 && cachedToken != "" {
|
||||
if exp, err := strconv.ParseInt(strings.TrimSpace(expiresStr), 10, 64); err == nil {
|
||||
if time.Now().Unix()+300 < exp {
|
||||
accessToken = cachedToken
|
||||
expiresAt = exp
|
||||
useCache = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !useCache {
|
||||
var ok bool
|
||||
accessToken, expiresAt, ok = doRefresh(input, recipe)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cacheAccessToken(input.APIKey, input.Service, accessToken, expiresAt)
|
||||
}
|
||||
|
||||
runtime := map[string]string{"access_token": accessToken}
|
||||
secrets := map[string]string{} // oauth2 inject 只用 runtime.*,不用 secret.*
|
||||
authHeaders := interpolateRecord(recipe.Inject.Header, secrets, runtime)
|
||||
authQuery := interpolateRecord(recipe.Inject.Query, secrets, runtime)
|
||||
authBody := interpolateRecord(recipe.Inject.Body, secrets, runtime)
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"auth_headers": authHeaders,
|
||||
"auth_query": authQuery,
|
||||
"auth_body": authBody,
|
||||
"runtime": runtime,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// doRefresh 解密 refresh_token,打 token endpoint,回傳 (access_token, expires_at_unix, ok)
|
||||
func doRefresh(input Input, recipe AuthRecipe) (string, int64, bool) {
|
||||
if len(recipe.RequiredSecrets) == 0 {
|
||||
writeError("auth recipe " + input.Service + " 缺少 required_secrets(需要 refresh_token)")
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
// 慣例:required_secrets[0] 是 refresh_token
|
||||
rtReq := recipe.RequiredSecrets[0]
|
||||
kvKey := input.APIKey + ":cred:" + rtReq.Key
|
||||
encJSON, s := kvGet(kvKey)
|
||||
if s == 2 {
|
||||
writeError("缺少 credential: " + rtReq.Key + "(" + rtReq.Label + ")。執行 acr creds push 推送")
|
||||
return "", 0, false
|
||||
}
|
||||
if s != 0 {
|
||||
writeError("kv_get 失敗(credential " + rtReq.Key + ")")
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
var rec EncryptedRecord
|
||||
if err := json.Unmarshal([]byte(encJSON), &rec); err != nil {
|
||||
writeError("credential " + rtReq.Key + " 格式錯誤: " + err.Error())
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
refreshToken, ok := cryptoDecrypt(rec.Encrypted, rec.IV)
|
||||
if !ok {
|
||||
writeError("credential " + rtReq.Key + " 解密失敗")
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
// POST token endpoint(form-urlencoded)
|
||||
cfg := recipe.OAuth2
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", refreshToken)
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
form.Set("client_secret", cfg.ClientSecret)
|
||||
formBody := form.Encode()
|
||||
|
||||
headersJSON := `{"Content-Type":"application/x-www-form-urlencoded"}`
|
||||
respStr, ok2 := httpRequest(cfg.TokenEndpoint, "POST", headersJSON, formBody)
|
||||
if !ok2 {
|
||||
writeError("token endpoint HTTP 請求失敗")
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
var tokenResp TokenResponse
|
||||
if err := json.Unmarshal([]byte(respStr), &tokenResp); err != nil {
|
||||
writeError("token endpoint 回應解析失敗: " + err.Error())
|
||||
return "", 0, false
|
||||
}
|
||||
if tokenResp.AccessToken == "" {
|
||||
msg := tokenResp.Error
|
||||
if tokenResp.ErrorDesc != "" {
|
||||
msg += ": " + tokenResp.ErrorDesc
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "access_token 為空"
|
||||
}
|
||||
writeError("token exchange 失敗: " + msg)
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
expiresIn := tokenResp.ExpiresIn
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = 3600
|
||||
}
|
||||
expiresAt := time.Now().Unix() + int64(expiresIn)
|
||||
|
||||
// 若 token endpoint 回傳新的 refresh_token(部分服務會 rotate),更新快取
|
||||
// 注意:寫回加密 KV 需要 host 支援;此處只快取 access_token(明文短效)
|
||||
// refresh_token 的 rotation 需要 kv_put_encrypted host function(未來擴充)
|
||||
|
||||
return tokenResp.AccessToken, expiresAt, true
|
||||
}
|
||||
|
||||
func cacheAccessToken(apiKey, service, accessToken string, expiresAt int64) {
|
||||
// 快取明文 access_token(短效,TTL = expires_at - now + 60s buffer)
|
||||
ttl := uint32(expiresAt - time.Now().Unix() + 60)
|
||||
if ttl > 7200 {
|
||||
ttl = 7200
|
||||
}
|
||||
|
||||
cachedKey := apiKey + ":oauth2:" + service + ":access_token"
|
||||
expiresKey := apiKey + ":oauth2:" + service + ":expires_at"
|
||||
expiresStr := strconv.FormatInt(expiresAt, 10)
|
||||
|
||||
kvPut(cachedKey, accessToken, ttl)
|
||||
kvPut(expiresKey, expiresStr, ttl)
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": msg,
|
||||
"auth_headers": map[string]string{},
|
||||
"auth_query": map[string]string{},
|
||||
"auth_body": map[string]string{},
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func kvGet(key string) (string, uint32) {
|
||||
keyBytes := []byte(key)
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
status := hostKvGet(
|
||||
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", status
|
||||
}
|
||||
return string(outBuf[:outLen]), 0
|
||||
}
|
||||
|
||||
func kvPut(key, value string, ttlSeconds uint32) {
|
||||
keyBytes := []byte(key)
|
||||
valBytes := []byte(value)
|
||||
if len(keyBytes) == 0 || len(valBytes) == 0 {
|
||||
return
|
||||
}
|
||||
hostKvPut(
|
||||
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
|
||||
uintptr(unsafe.Pointer(&valBytes[0])), uint32(len(valBytes)),
|
||||
ttlSeconds,
|
||||
)
|
||||
}
|
||||
|
||||
func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
|
||||
encBytes := []byte(encB64)
|
||||
ivBytes := []byte(ivB64)
|
||||
if len(encBytes) == 0 || len(ivBytes) == 0 {
|
||||
return "", false
|
||||
}
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
status := hostCryptoDecrypt(
|
||||
uintptr(unsafe.Pointer(&encBytes[0])), uint32(len(encBytes)),
|
||||
uintptr(unsafe.Pointer(&ivBytes[0])), uint32(len(ivBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", false
|
||||
}
|
||||
return string(outBuf[:outLen]), true
|
||||
}
|
||||
|
||||
func httpRequest(reqURL, method, headersJSON, body string) (string, bool) {
|
||||
urlBytes := []byte(reqURL)
|
||||
methodBytes := []byte(method)
|
||||
headersBytes := []byte(headersJSON)
|
||||
bodyBytes := []byte(body)
|
||||
|
||||
if len(urlBytes) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
var bodyPtr uintptr
|
||||
if len(bodyBytes) > 0 {
|
||||
bodyPtr = uintptr(unsafe.Pointer(&bodyBytes[0]))
|
||||
}
|
||||
var headersPtr uintptr
|
||||
if len(headersBytes) > 0 {
|
||||
headersPtr = uintptr(unsafe.Pointer(&headersBytes[0]))
|
||||
}
|
||||
|
||||
status := hostHttpRequest(
|
||||
uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)),
|
||||
uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)),
|
||||
headersPtr, uint32(len(headersBytes)),
|
||||
bodyPtr, uint32(len(bodyBytes)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", false
|
||||
}
|
||||
return string(outBuf[:outLen]), true
|
||||
}
|
||||
|
||||
func interpolateTemplate(template string, secrets, runtime map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(template))
|
||||
i := 0
|
||||
for i < len(template) {
|
||||
start := strings.Index(template[i:], "{{")
|
||||
if start < 0 {
|
||||
b.WriteString(template[i:])
|
||||
break
|
||||
}
|
||||
b.WriteString(template[i : i+start])
|
||||
openIdx := i + start
|
||||
closeRel := strings.Index(template[openIdx+2:], "}}")
|
||||
if closeRel < 0 {
|
||||
b.WriteString(template[openIdx:])
|
||||
break
|
||||
}
|
||||
inner := template[openIdx+2 : openIdx+2+closeRel]
|
||||
advance := openIdx + 2 + closeRel + 2
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(inner, "secret."):
|
||||
key := inner[len("secret."):]
|
||||
b.WriteString(secrets[key])
|
||||
case strings.HasPrefix(inner, "runtime."):
|
||||
key := inner[len("runtime."):]
|
||||
b.WriteString(runtime[key])
|
||||
default:
|
||||
b.WriteString(template[openIdx:advance])
|
||||
}
|
||||
i = advance
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func interpolateRecord(record map[string]string, secrets, runtime map[string]string) map[string]string {
|
||||
if record == nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
result := make(map[string]string, len(record))
|
||||
for k, v := range record {
|
||||
result[k] = interpolateTemplate(v, secrets, runtime)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
canonical_id: "claude_api"
|
||||
display_name: "Claude AI 對話"
|
||||
category: "ai"
|
||||
version: "v2"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 80
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [mira_token, prompt]
|
||||
properties:
|
||||
mira_token:
|
||||
type: string
|
||||
description: Mira daemon Bearer token(Hetzner cloud-cto Mira daemon 的 MIRA_TOKEN)
|
||||
prompt:
|
||||
type: string
|
||||
description: 要送給 Mira 的訊息(已內建 Mira 副駕 persona,不需重複設角色)
|
||||
mira_url:
|
||||
type: string
|
||||
description: Mira daemon URL,預設 https://mira.uncle6.me
|
||||
default: "https://mira.uncle6.me"
|
||||
timeout_ms:
|
||||
type: integer
|
||||
description: Daemon 協商模式 timeout,預設 25000ms(協商上限)
|
||||
default: 25000
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
description: 同步完成時的回應
|
||||
properties:
|
||||
text: { type: string, description: Mira 的回覆文字 }
|
||||
task_id: { type: string }
|
||||
model: { type: string, description: 「實際 routing 用的模型(haiku / sonnet)」 }
|
||||
pending:
|
||||
type: boolean
|
||||
description: 「true 時表示 daemon 切到非同步模式,task 還在跑,需 polling」
|
||||
task_id:
|
||||
type: string
|
||||
description: pending=true 時用此 id polling
|
||||
poll_url:
|
||||
type: string
|
||||
description: GET 此 URL 查詢任務進度 / 結果
|
||||
error:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "缺 mira_token"
|
||||
given: '{"prompt":"hi"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "簡短對話 25s 內回完"
|
||||
given: '{"mira_token":"...","prompt":"1+1=?"}'
|
||||
then_contains: 'success'
|
||||
tags: [ai, llm, claude, mira, primitive]
|
||||
description: "呼叫 Mira daemon (Hetzner cloud-cto) 進行 AI 對話。Daemon 內部用 Claude Agent SDK,內建 Mira 副駕 persona,可長執行任務。所有 mira-app 的 AI workflow(自動回覆、wiki 合成、新聞註解)都用此零件。"
|
||||
config_example: |
|
||||
ai_reply:
|
||||
mira_token: "{{secret.mira_token}}"
|
||||
prompt: |
|
||||
用戶 leo 在 mira 河道發了這則貼文:
|
||||
「{{trigger.post_content}}」
|
||||
|
||||
請以副駕 AI 的身份留言回應,簡短繁中,務實。
|
||||
timeout_ms: 25000
|
||||
@@ -0,0 +1,3 @@
|
||||
module claude_api
|
||||
|
||||
go 1.21
|
||||
@@ -24,10 +24,22 @@ func hostHttpRequest(
|
||||
) uint32
|
||||
|
||||
type Input struct {
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// dummy byte for safe zero-length unsafe.Pointer operations
|
||||
var dummy [1]byte
|
||||
|
||||
// safePtr returns a valid pointer for an empty-or-nonempty byte slice.
|
||||
// TinyGo panics with "index out of range" when taking &b[0] on empty b.
|
||||
func safePtr(b []byte) (uintptr, uint32) {
|
||||
if len(b) == 0 {
|
||||
return uintptr(unsafe.Pointer(&dummy[0])), 0
|
||||
}
|
||||
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -36,11 +48,13 @@ func main() {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if input.URL == "" {
|
||||
writeError("url 必填")
|
||||
return
|
||||
@@ -51,33 +65,29 @@ func main() {
|
||||
method = "GET"
|
||||
}
|
||||
|
||||
// 序列化 headers
|
||||
headersJSON := "{}"
|
||||
if len(input.Headers) > 0 {
|
||||
b, _ := json.Marshal(input.Headers)
|
||||
headersJSON = string(b)
|
||||
}
|
||||
|
||||
// body
|
||||
bodyStr := ""
|
||||
if len(input.Body) > 0 {
|
||||
bodyStr = string(input.Body)
|
||||
}
|
||||
|
||||
// 呼叫 host function
|
||||
urlBytes := []byte(input.URL)
|
||||
methodBytes := []byte(method)
|
||||
headersBytes := []byte(headersJSON)
|
||||
bodyBytes := []byte(bodyStr)
|
||||
|
||||
bodyBytes := []byte(input.Body)
|
||||
outBuf := make([]byte, 65536) // 64KB output buffer
|
||||
var outLen uint32
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
headersPtr, headersLen := safePtr(headersBytes)
|
||||
bodyPtr, bodyLen := safePtr(bodyBytes)
|
||||
|
||||
result := hostHttpRequest(
|
||||
uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)),
|
||||
uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)),
|
||||
uintptr(unsafe.Pointer(&headersBytes[0])), uint32(len(headersBytes)),
|
||||
uintptr(unsafe.Pointer(&bodyBytes[0])), uint32(len(bodyBytes)),
|
||||
urlPtr, urlLen,
|
||||
methodPtr, methodLen,
|
||||
headersPtr, headersLen,
|
||||
bodyPtr, bodyLen,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
canonical_id: "kbdb_create_block"
|
||||
display_name: "KBDB 建立 Block"
|
||||
category: "data"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [api_key, content]
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: KBDB partner key(pk_live_xxx 或 ak_xxx)
|
||||
content:
|
||||
type: string
|
||||
description: block 內容
|
||||
type:
|
||||
type: string
|
||||
description: block type(note / chat / page 等,預設 block)
|
||||
parent_id:
|
||||
type: string
|
||||
description: 父 block id(留言鏈用)
|
||||
user_id:
|
||||
type: string
|
||||
description: 擁有者 user_id / namespace
|
||||
source:
|
||||
type: string
|
||||
description: 來源標記
|
||||
page_name:
|
||||
type: string
|
||||
description: 所屬 page
|
||||
tags_json:
|
||||
type: string
|
||||
description: tags JSON 字串
|
||||
kbdb_url:
|
||||
type: string
|
||||
description: KBDB API base(預設 https://kbdb.finally.click)
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
description: KBDB 回傳(含新 block 的 id)
|
||||
error:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "缺 content"
|
||||
given: '{"api_key":"pk_live_x"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "建立留言(type=chat + parent_id)"
|
||||
given: '{"api_key":"pk_live_x","content":"hi","type":"chat","parent_id":"abc"}'
|
||||
then_contains: 'success'
|
||||
tags: [data, storage, kbdb, create, primitive]
|
||||
description: "建立單一 KBDB block(POST /blocks),不切多 chunks。支援 parent_id 給留言鏈用。Mira 留言/AI 回覆使用,本零件為 P0 必備。"
|
||||
config_example: |
|
||||
reply:
|
||||
api_key: "{{secret.kbdb_key}}"
|
||||
content: "我的留言"
|
||||
type: "chat"
|
||||
parent_id: "{{previous_node.output.block_id}}"
|
||||
user_id: "inkstone_leo"
|
||||
page_name: "my-post"
|
||||
@@ -0,0 +1,3 @@
|
||||
module kbdb_create_block
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,152 @@
|
||||
// kbdb_create_block — POST 一個單一 block 到 KBDB(支援 parent_id,給留言鏈用)
|
||||
// 對應 KBDB endpoint: POST /blocks(不是 ingest)
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
type Input struct {
|
||||
KBDBUrl string `json:"kbdb_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
ParentID string `json:"parent_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Source string `json:"source"`
|
||||
PageName string `json:"page_name"`
|
||||
TagsJSON string `json:"tags_json"`
|
||||
}
|
||||
|
||||
var dummy [1]byte
|
||||
|
||||
func safePtr(b []byte) (uintptr, uint32) {
|
||||
if len(b) == 0 {
|
||||
return uintptr(unsafe.Pointer(&dummy[0])), 0
|
||||
}
|
||||
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if input.APIKey == "" {
|
||||
writeError("api_key 必填")
|
||||
return
|
||||
}
|
||||
if input.Content == "" {
|
||||
writeError("content 必填")
|
||||
return
|
||||
}
|
||||
|
||||
kbdbURL := input.KBDBUrl
|
||||
if kbdbURL == "" {
|
||||
kbdbURL = "https://kbdb.finally.click"
|
||||
}
|
||||
|
||||
// 構造 KBDB POST /blocks body(只放有值的欄位)
|
||||
body := make(map[string]interface{})
|
||||
body["content"] = input.Content
|
||||
if input.Type != "" {
|
||||
body["type"] = input.Type
|
||||
}
|
||||
if input.ParentID != "" {
|
||||
body["parent_id"] = input.ParentID
|
||||
}
|
||||
if input.UserID != "" {
|
||||
body["user_id"] = input.UserID
|
||||
}
|
||||
if input.Source != "" {
|
||||
body["source"] = input.Source
|
||||
}
|
||||
if input.PageName != "" {
|
||||
body["page_name"] = input.PageName
|
||||
}
|
||||
if input.TagsJSON != "" {
|
||||
body["tags_json"] = input.TagsJSON
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + input.APIKey,
|
||||
}
|
||||
headersBytes, _ := json.Marshal(headers)
|
||||
|
||||
url := kbdbURL + "/blocks"
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte("POST")
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
headersPtr, headersLen := safePtr(headersBytes)
|
||||
bodyPtr, bodyLenU := safePtr(bodyBytes)
|
||||
|
||||
result := hostHttpRequest(
|
||||
urlPtr, urlLen,
|
||||
methodPtr, methodLen,
|
||||
headersPtr, headersLen,
|
||||
bodyPtr, bodyLenU,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if result != 0 {
|
||||
writeError("KBDB POST request failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
|
||||
respStr := string(outBuf[:outLen])
|
||||
var kbdbResp map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(respStr), &kbdbResp); err != nil {
|
||||
writeError("KBDB returned non-JSON: " + respStr)
|
||||
return
|
||||
}
|
||||
|
||||
if _, hasErr := kbdbResp["error"]; hasErr {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": kbdbResp["error"],
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
return
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": kbdbResp,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
canonical_id: "kbdb_ingest"
|
||||
display_name: "KBDB 寫入"
|
||||
category: "data"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [api_key, text, user_id]
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: KBDB partner key(pk_live_xxx 或 ak_xxx,後者為 arcrun OAuth 取得)
|
||||
text:
|
||||
type: string
|
||||
description: 要寫入的 block 內容
|
||||
user_id:
|
||||
type: string
|
||||
description: namespace prefix(partner key 必須對應同一 namespace)
|
||||
source:
|
||||
type: string
|
||||
description: 來源標記(例如 km-writer / rss-tech-news / telegram)
|
||||
page_name:
|
||||
type: string
|
||||
description: 頁面名稱(選填)
|
||||
kbdb_url:
|
||||
type: string
|
||||
description: KBDB API base(選填,預設 https://kbdb.finally.click)
|
||||
default: "https://kbdb.finally.click"
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
description: KBDB 回傳原始物件(含 blocks_injected 等)
|
||||
error:
|
||||
type: string
|
||||
description: 錯誤訊息(success=false 時)
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 text"
|
||||
given: '{"api_key":"pk_live_x","user_id":"ns_x"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "缺少 api_key"
|
||||
given: '{"text":"x","user_id":"ns_x"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "正確寫入"
|
||||
given: '{"api_key":"pk_live_xxx","text":"hello","user_id":"inkstone_test","source":"smoke"}'
|
||||
then_contains: '{"success":true'
|
||||
tags: [data, storage, kbdb, ingest, primitive]
|
||||
description: "把單一 block 寫入 KBDB(POST /blocks/ingest),硬編碼 skip_llm=true(不觸發 LLM triplet 抽取)。Mira 等定型貼文場景使用,本零件為 P0 必備。"
|
||||
config_example: |
|
||||
ingest_block: # 節點名稱(可自訂)
|
||||
api_key: "{{secret.kbdb_key}}"
|
||||
text: "{{previous_node.output.content}}"
|
||||
user_id: "inkstone_leo"
|
||||
source: "rss-tech-news"
|
||||
@@ -0,0 +1,3 @@
|
||||
module kbdb_ingest
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,155 @@
|
||||
// kbdb_ingest — 把 input 寫入 KBDB(POST /blocks/ingest)
|
||||
// thin wrapper:透過 host function http_request 呼叫 KBDB API
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
type Input struct {
|
||||
KBDBUrl string `json:"kbdb_url"` // optional, default https://kbdb.finally.click
|
||||
APIKey string `json:"api_key"` // 必填(pk_live_xxx 或 ak_xxx)
|
||||
Text string `json:"text"` // 必填(block 內容)
|
||||
UserID string `json:"user_id"` // 必填(namespace prefix 對應)
|
||||
Source string `json:"source"` // optional
|
||||
PageName string `json:"page_name"` // optional
|
||||
// 註:本零件硬編碼 skip_llm=true(mira 場景定型貼文,不需 KBDB triplet 抽取)。
|
||||
// 若需 LLM 抽取,未來另建 kbdb_ingest_with_llm 零件。
|
||||
}
|
||||
|
||||
var dummy [1]byte
|
||||
|
||||
func safePtr(b []byte) (uintptr, uint32) {
|
||||
if len(b) == 0 {
|
||||
return uintptr(unsafe.Pointer(&dummy[0])), 0
|
||||
}
|
||||
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if input.APIKey == "" {
|
||||
writeError("api_key 必填")
|
||||
return
|
||||
}
|
||||
if input.Text == "" {
|
||||
writeError("text 必填")
|
||||
return
|
||||
}
|
||||
if input.UserID == "" {
|
||||
writeError("user_id 必填")
|
||||
return
|
||||
}
|
||||
|
||||
kbdbURL := input.KBDBUrl
|
||||
if kbdbURL == "" {
|
||||
kbdbURL = "https://kbdb.finally.click"
|
||||
}
|
||||
|
||||
// 構造 KBDB ingest 的 body(只含 KBDB 認得的欄位)
|
||||
type ingestBody struct {
|
||||
Text string `json:"text"`
|
||||
UserID string `json:"user_id"`
|
||||
Source string `json:"source,omitempty"`
|
||||
PageName string `json:"page_name,omitempty"`
|
||||
SkipLLM *bool `json:"skip_llm,omitempty"`
|
||||
}
|
||||
skipLLM := true
|
||||
body := ingestBody{
|
||||
Text: input.Text,
|
||||
UserID: input.UserID,
|
||||
Source: input.Source,
|
||||
PageName: input.PageName,
|
||||
SkipLLM: &skipLLM,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + input.APIKey,
|
||||
}
|
||||
headersBytes, _ := json.Marshal(headers)
|
||||
|
||||
url := kbdbURL + "/blocks/ingest"
|
||||
method := "POST"
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte(method)
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
headersPtr, headersLen := safePtr(headersBytes)
|
||||
bodyPtr, bodyLenU := safePtr(bodyBytes)
|
||||
|
||||
result := hostHttpRequest(
|
||||
urlPtr, urlLen,
|
||||
methodPtr, methodLen,
|
||||
headersPtr, headersLen,
|
||||
bodyPtr, bodyLenU,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if result != 0 {
|
||||
writeError("KBDB ingest request failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
|
||||
// KBDB 回傳格式:{"blocks_injected": N, "triplets_injected": M, ...}
|
||||
respStr := string(outBuf[:outLen])
|
||||
|
||||
// 嘗試 parse 確認是 JSON(若 KBDB 回 error 也透傳)
|
||||
var kbdbResp map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(respStr), &kbdbResp); err != nil {
|
||||
writeError("KBDB returned non-JSON: " + respStr)
|
||||
return
|
||||
}
|
||||
|
||||
// 若 KBDB 回 error 欄位(401/400 etc.),透傳
|
||||
if _, hasErr := kbdbResp["error"]; hasErr {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": kbdbResp["error"],
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
return
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": kbdbResp,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
canonical_id: "kbdb_patch_block"
|
||||
display_name: "KBDB Block 部分更新"
|
||||
category: "data"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [api_key, block_id]
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description: KBDB partner key(pk_live_xxx 或 ak_xxx)
|
||||
block_id:
|
||||
type: string
|
||||
description: 要更新的 block UUID
|
||||
content:
|
||||
type: string
|
||||
description: 新內容(傳入則覆寫;不傳則不動)
|
||||
tags:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: tags 陣列(完整覆寫;不傳則不動)
|
||||
refs:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: refs 陣列(完整覆寫;不傳則不動)
|
||||
source:
|
||||
type: string
|
||||
description: 來源標記(傳入則覆寫)
|
||||
metadata_json:
|
||||
type: object
|
||||
description: 任意附加資料(完整覆寫)
|
||||
kbdb_url:
|
||||
type: string
|
||||
description: KBDB API base(預設 https://kbdb.finally.click)
|
||||
default: "https://kbdb.finally.click"
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
description: KBDB 回傳的更新後 block
|
||||
error:
|
||||
type: string
|
||||
gherkin_tests:
|
||||
- scenario: "缺 block_id"
|
||||
given: '{"api_key":"pk_live_x"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "至少要一個欄位"
|
||||
given: '{"api_key":"pk_live_x","block_id":"b_x"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "改 content"
|
||||
given: '{"api_key":"pk_live_x","block_id":"b_x","content":"new"}'
|
||||
then_contains: 'success'
|
||||
tags: [data, storage, kbdb, patch, edit, primitive]
|
||||
description: "PATCH 一個既有 KBDB block 的欄位(content / tags / refs / source / metadata_json)。透過 host function 呼叫 KBDB PATCH /blocks/:id。Mira 前端 inline edit 與 AI 自我修正使用,本零件為 P0 必備。"
|
||||
config_example: |
|
||||
patch_block:
|
||||
api_key: "{{secret.kbdb_key}}"
|
||||
block_id: "{{previous_node.output.block_id}}"
|
||||
content: "新內容"
|
||||
tags: ["news", "ai"]
|
||||
@@ -0,0 +1,3 @@
|
||||
module kbdb_patch_block
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,155 @@
|
||||
// kbdb_patch_block — PATCH 一個既有 block 的部分欄位
|
||||
// 對應 KBDB endpoint: PATCH /blocks/{id}
|
||||
// SDD: matrix/kbdb/.agents/specs/blocks-edit-api/design.md §2
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
type Input struct {
|
||||
KBDBUrl string `json:"kbdb_url"` // optional
|
||||
APIKey string `json:"api_key"` // 必填
|
||||
BlockID string `json:"block_id"` // 必填
|
||||
Content *string `json:"content"` // optional(pointer 區分「未傳」vs「設空字串」)
|
||||
Tags []string `json:"tags"` // optional 完整覆寫
|
||||
Refs []string `json:"refs"` // optional 完整覆寫
|
||||
Source *string `json:"source"` // optional
|
||||
Metadata map[string]interface{} `json:"metadata_json"` // optional
|
||||
}
|
||||
|
||||
var dummy [1]byte
|
||||
|
||||
func safePtr(b []byte) (uintptr, uint32) {
|
||||
if len(b) == 0 {
|
||||
return uintptr(unsafe.Pointer(&dummy[0])), 0
|
||||
}
|
||||
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if input.APIKey == "" {
|
||||
writeError("api_key 必填")
|
||||
return
|
||||
}
|
||||
if input.BlockID == "" {
|
||||
writeError("block_id 必填")
|
||||
return
|
||||
}
|
||||
|
||||
// 至少要有一個欄位(避免 KBDB 回 400)
|
||||
if input.Content == nil && input.Tags == nil && input.Refs == nil &&
|
||||
input.Source == nil && input.Metadata == nil {
|
||||
writeError("至少要傳一個更新欄位(content / tags / refs / source / metadata_json)")
|
||||
return
|
||||
}
|
||||
|
||||
kbdbURL := input.KBDBUrl
|
||||
if kbdbURL == "" {
|
||||
kbdbURL = "https://kbdb.finally.click"
|
||||
}
|
||||
|
||||
// 構造 PATCH body:只放有值的欄位(pointer 控制)
|
||||
body := make(map[string]interface{})
|
||||
if input.Content != nil {
|
||||
body["content"] = *input.Content
|
||||
}
|
||||
if input.Tags != nil {
|
||||
body["tags"] = input.Tags
|
||||
}
|
||||
if input.Refs != nil {
|
||||
body["refs"] = input.Refs
|
||||
}
|
||||
if input.Source != nil {
|
||||
body["source"] = *input.Source
|
||||
}
|
||||
if input.Metadata != nil {
|
||||
body["metadata_json"] = input.Metadata
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + input.APIKey,
|
||||
}
|
||||
headersBytes, _ := json.Marshal(headers)
|
||||
|
||||
url := kbdbURL + "/blocks/" + input.BlockID
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte("PATCH")
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
headersPtr, headersLen := safePtr(headersBytes)
|
||||
bodyPtr, bodyLenU := safePtr(bodyBytes)
|
||||
|
||||
result := hostHttpRequest(
|
||||
urlPtr, urlLen,
|
||||
methodPtr, methodLen,
|
||||
headersPtr, headersLen,
|
||||
bodyPtr, bodyLenU,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if result != 0 {
|
||||
writeError("KBDB PATCH request failed (host_http_request returned non-zero)")
|
||||
return
|
||||
}
|
||||
|
||||
respStr := string(outBuf[:outLen])
|
||||
var kbdbResp map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(respStr), &kbdbResp); err != nil {
|
||||
writeError("KBDB returned non-JSON: " + respStr)
|
||||
return
|
||||
}
|
||||
|
||||
if _, hasErr := kbdbResp["error"]; hasErr {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": kbdbResp["error"],
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
return
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": kbdbResp,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
canonical_id: "km_writer"
|
||||
display_name: "KM Writer"
|
||||
category: "api"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: false
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [action, mira_url, token]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
description: "操作類型:read_journal | read_journal_date | append_journal | list_pages | read_page | write_page"
|
||||
enum: [read_journal, read_journal_date, append_journal, list_pages, read_page, write_page]
|
||||
mira_url:
|
||||
type: string
|
||||
description: "Mira 服務基礎 URL(例:https://mira.uncle6.me)"
|
||||
token:
|
||||
type: string
|
||||
description: "Mira MIRA_TOKEN(Bearer token)"
|
||||
content:
|
||||
type: string
|
||||
description: "內容(append_journal / write_page 時必填)"
|
||||
timestamp:
|
||||
type: string
|
||||
description: "ISO 8601 時間戳(append_journal 時選填,影響日期和時間顯示)"
|
||||
date:
|
||||
type: string
|
||||
description: "日期 YYYY-MM-DD(read_journal_date 時必填)"
|
||||
name:
|
||||
type: string
|
||||
description: "頁面名稱(read_page / write_page 時必填)"
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
description: "Mira API 回應資料"
|
||||
error:
|
||||
type: string
|
||||
description: "錯誤訊息(success=false 時)"
|
||||
gherkin_tests:
|
||||
- scenario: "缺少 action"
|
||||
given: '{"mira_url":"https://mira.uncle6.me","token":"abc"}'
|
||||
then_contains: '{"success":false'
|
||||
- scenario: "缺少 token"
|
||||
given: '{"action":"list_pages","mira_url":"https://mira.uncle6.me"}'
|
||||
then_contains: '{"success":false'
|
||||
tags: [km, journal, logseq, mira, knowledge-management]
|
||||
description: "讀寫 Mira leo-graph 的 journals 和 pages。透過 host function 呼叫 Mira /km/* API,支援讀取、新增日誌條目,以及讀寫頁面。"
|
||||
config_example: |
|
||||
append_to_journal:
|
||||
action: "append_journal"
|
||||
mira_url: "https://mira.uncle6.me"
|
||||
token: "<mira_token>"
|
||||
content: "今天完成了 arcrun km_writer 元件"
|
||||
@@ -0,0 +1,3 @@
|
||||
module component
|
||||
|
||||
go 1.21
|
||||
@@ -0,0 +1,177 @@
|
||||
// km_writer — 讀寫 Mira leo-graph(journals + pages)
|
||||
// 透過 host function 呼叫 Mira /km/* API
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
methodPtr uintptr, methodLen uint32,
|
||||
headersPtr uintptr, headersLen uint32,
|
||||
bodyPtr uintptr, bodyLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
// Input actions:
|
||||
// read_journal — GET today's journal (requires: mira_url, token)
|
||||
// read_journal_date — GET journal by date (requires: mira_url, token, date)
|
||||
// append_journal — POST append entry (requires: mira_url, token, content; optional: timestamp)
|
||||
// list_pages — GET all pages (requires: mira_url, token)
|
||||
// read_page — GET page by name (requires: mira_url, token, name)
|
||||
// write_page — PUT write page (requires: mira_url, token, name, content)
|
||||
|
||||
type Input struct {
|
||||
Action string `json:"action"`
|
||||
MiraURL string `json:"mira_url"`
|
||||
Token string `json:"token"`
|
||||
Content string `json:"content"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Date string `json:"date"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
var inp Input
|
||||
if err := json.Unmarshal(raw, &inp); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
if inp.Action == "" {
|
||||
writeError("action 必填")
|
||||
return
|
||||
}
|
||||
if inp.MiraURL == "" {
|
||||
writeError("mira_url 必填")
|
||||
return
|
||||
}
|
||||
if inp.Token == "" {
|
||||
writeError("token 必填")
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := fmt.Sprintf(`{"Authorization":"Bearer %s","Content-Type":"application/json"}`, inp.Token)
|
||||
|
||||
switch inp.Action {
|
||||
case "read_journal":
|
||||
result := doRequest(inp.MiraURL+"/km/journal", "GET", authHeader, "")
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "read_journal_date":
|
||||
if inp.Date == "" {
|
||||
writeError("date 必填(格式 YYYY-MM-DD)")
|
||||
return
|
||||
}
|
||||
result := doRequest(inp.MiraURL+"/km/journal/"+inp.Date, "GET", authHeader, "")
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "append_journal":
|
||||
if inp.Content == "" {
|
||||
writeError("content 必填")
|
||||
return
|
||||
}
|
||||
bodyMap := map[string]string{"content": inp.Content}
|
||||
if inp.Timestamp != "" {
|
||||
bodyMap["timestamp"] = inp.Timestamp
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(bodyMap)
|
||||
result := doRequest(inp.MiraURL+"/km/journal", "POST", authHeader, string(bodyBytes))
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "list_pages":
|
||||
result := doRequest(inp.MiraURL+"/km/pages", "GET", authHeader, "")
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "read_page":
|
||||
if inp.Name == "" {
|
||||
writeError("name 必填")
|
||||
return
|
||||
}
|
||||
result := doRequest(inp.MiraURL+"/km/page/"+inp.Name, "GET", authHeader, "")
|
||||
os.Stdout.Write(result)
|
||||
|
||||
case "write_page":
|
||||
if inp.Name == "" {
|
||||
writeError("name 必填")
|
||||
return
|
||||
}
|
||||
if inp.Content == "" {
|
||||
writeError("content 必填")
|
||||
return
|
||||
}
|
||||
bodyMap := map[string]string{"content": inp.Content}
|
||||
bodyBytes, _ := json.Marshal(bodyMap)
|
||||
result := doRequest(inp.MiraURL+"/km/page/"+inp.Name, "PUT", authHeader, string(bodyBytes))
|
||||
os.Stdout.Write(result)
|
||||
|
||||
default:
|
||||
writeError("未知 action: " + inp.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func doRequest(url, method, headersJSON, body string) []byte {
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte(method)
|
||||
headersBytes := []byte(headersJSON)
|
||||
bodyBytes := []byte(body)
|
||||
|
||||
outBuf := make([]byte, 131072) // 128KB
|
||||
var outLen uint32
|
||||
|
||||
if len(bodyBytes) == 0 {
|
||||
bodyBytes = []byte{}
|
||||
}
|
||||
|
||||
var bodyPtr uintptr
|
||||
var bodyLen uint32
|
||||
if len(bodyBytes) > 0 {
|
||||
bodyPtr = uintptr(unsafe.Pointer(&bodyBytes[0]))
|
||||
bodyLen = uint32(len(bodyBytes))
|
||||
}
|
||||
|
||||
code := hostHttpRequest(
|
||||
uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)),
|
||||
uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)),
|
||||
uintptr(unsafe.Pointer(&headersBytes[0])), uint32(len(headersBytes)),
|
||||
bodyPtr, bodyLen,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
if code != 0 {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": "HTTP request failed"})
|
||||
return out
|
||||
}
|
||||
|
||||
responseStr := string(outBuf[:outLen])
|
||||
|
||||
// Try to parse the response as JSON to forward it
|
||||
var parsed interface{}
|
||||
if err := json.Unmarshal([]byte(responseStr), &parsed); err != nil {
|
||||
// Not JSON — wrap it
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": true, "data": responseStr})
|
||||
return out
|
||||
}
|
||||
|
||||
// Forward the parsed response as-is, wrapped in success
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": true, "data": parsed})
|
||||
return out
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
canonical_id: "platform_crypto"
|
||||
display_name: "Platform Crypto Primitive"
|
||||
category: "platform"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "stable"
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
no_filesystem_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
input_schema:
|
||||
type: object
|
||||
required: [action]
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [generate_api_key, encrypt, random_token]
|
||||
email:
|
||||
type: string
|
||||
description: generate_api_key 用
|
||||
plaintext:
|
||||
type: string
|
||||
description: encrypt 用
|
||||
bytes:
|
||||
type: integer
|
||||
description: random_token 用,預設 32
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
api_key:
|
||||
type: string
|
||||
description: generate_api_key 結果,ak_ 前綴
|
||||
encrypted:
|
||||
type: string
|
||||
description: encrypt 結果,base64
|
||||
iv:
|
||||
type: string
|
||||
description: encrypt 結果,base64
|
||||
token:
|
||||
type: string
|
||||
description: random_token 結果,hex
|
||||
tags: [platform, crypto, internal]
|
||||
description: |
|
||||
平台內部 crypto primitive。
|
||||
- generate_api_key: HMAC-SHA256(email, ENCRYPTION_KEY) → ak_xxx
|
||||
- encrypt: AES-GCM(plaintext, ENCRYPTION_KEY) → {encrypted, iv}(base64)
|
||||
- random_token: crypto random bytes → hex string
|
||||
ENCRYPTION_KEY 由 host 持有,永不進入 WASM。
|
||||
@@ -0,0 +1,206 @@
|
||||
// platform_crypto — Arcrun 平台內部 crypto primitive
|
||||
//
|
||||
// Actions:
|
||||
// generate_api_key — HMAC-SHA256(email, ENCRYPTION_KEY) → ak_{hex[:32]}
|
||||
// encrypt — AES-GCM(plaintext, ENCRYPTION_KEY) → {encrypted, iv}(base64)
|
||||
// random_token — crypto random bytes → hex string
|
||||
//
|
||||
// ENCRYPTION_KEY 由 host 持有,永不進入 WASM。
|
||||
//
|
||||
// Host imports:
|
||||
// u6u.crypto_hmac_sha256 — HMAC-SHA256(data, key=ENCRYPTION_KEY) → raw bytes
|
||||
// u6u.crypto_aes_encrypt — AES-GCM(plaintext, key=ENCRYPTION_KEY) → encrypted_b64 + iv_b64
|
||||
// u6u.crypto_random_bytes — crypto-random bytes → hex string
|
||||
//
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// ── host function 宣告 ───────────────────────────────────────────────────────
|
||||
|
||||
// crypto_hmac_sha256(dataPtr, dataLen, outPtr, outLenPtr) → 0 成功
|
||||
// key = host 的 ENCRYPTION_KEY,output = raw bytes(hex encode 由 WASM 做)
|
||||
//
|
||||
//go:wasmimport u6u crypto_hmac_sha256
|
||||
func hostCryptoHmacSha256(
|
||||
dataPtr uintptr, dataLen uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
// crypto_aes_encrypt(plaintextPtr, plaintextLen, outEncPtr, outEncLenPtr, outIvPtr, outIvLenPtr) → 0 成功
|
||||
// output: encrypted(base64)放 outEnc,iv(base64)放 outIv
|
||||
//
|
||||
//go:wasmimport u6u crypto_aes_encrypt
|
||||
func hostCryptoAesEncrypt(
|
||||
plaintextPtr uintptr, plaintextLen uint32,
|
||||
outEncPtr uintptr, outEncLenPtr uintptr,
|
||||
outIvPtr uintptr, outIvLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
// crypto_random_bytes(numBytes, outPtr, outLenPtr) → 0 成功
|
||||
// output: hex string
|
||||
//
|
||||
//go:wasmimport u6u crypto_random_bytes
|
||||
func hostCryptoRandomBytes(
|
||||
numBytes uint32,
|
||||
outPtr uintptr, outLenPtr uintptr,
|
||||
) uint32
|
||||
|
||||
// ── 型別 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Input struct {
|
||||
Action string `json:"action"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Plaintext string `json:"plaintext,omitempty"`
|
||||
Bytes int `json:"bytes,omitempty"`
|
||||
}
|
||||
|
||||
// ── main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func main() {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("failed to read stdin: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var input Input
|
||||
if err := json.Unmarshal(raw, &input); err != nil {
|
||||
writeError("invalid input JSON: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
switch input.Action {
|
||||
case "generate_api_key":
|
||||
if input.Email == "" {
|
||||
writeError("email 必填")
|
||||
return
|
||||
}
|
||||
sig, ok := hmacSha256([]byte(input.Email))
|
||||
if !ok {
|
||||
writeError("HMAC-SHA256 失敗")
|
||||
return
|
||||
}
|
||||
apiKey := "ak_" + hex(sig)[:32]
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"api_key": apiKey,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
|
||||
case "encrypt":
|
||||
if input.Plaintext == "" {
|
||||
writeError("plaintext 必填")
|
||||
return
|
||||
}
|
||||
encB64, ivB64, ok := aesEncrypt([]byte(input.Plaintext))
|
||||
if !ok {
|
||||
writeError("AES-GCM 加密失敗")
|
||||
return
|
||||
}
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"encrypted": encB64,
|
||||
"iv": ivB64,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
|
||||
case "random_token":
|
||||
n := input.Bytes
|
||||
if n <= 0 {
|
||||
n = 32
|
||||
}
|
||||
token, ok := randomBytes(uint32(n))
|
||||
if !ok {
|
||||
writeError("random bytes 失敗")
|
||||
return
|
||||
}
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": true,
|
||||
"token": token,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
|
||||
default:
|
||||
writeError("不支援的 action: " + input.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": msg,
|
||||
})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
func hmacSha256(data []byte) ([]byte, bool) {
|
||||
if len(data) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
outBuf := make([]byte, 64) // SHA-256 = 32 bytes raw
|
||||
var outLen uint32
|
||||
status := hostCryptoHmacSha256(
|
||||
uintptr(unsafe.Pointer(&data[0])), uint32(len(data)),
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return nil, false
|
||||
}
|
||||
return outBuf[:outLen], true
|
||||
}
|
||||
|
||||
func aesEncrypt(plaintext []byte) (string, string, bool) {
|
||||
if len(plaintext) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
encBuf := make([]byte, 65536)
|
||||
ivBuf := make([]byte, 64)
|
||||
var encLen, ivLen uint32
|
||||
status := hostCryptoAesEncrypt(
|
||||
uintptr(unsafe.Pointer(&plaintext[0])), uint32(len(plaintext)),
|
||||
uintptr(unsafe.Pointer(&encBuf[0])), uintptr(unsafe.Pointer(&encLen)),
|
||||
uintptr(unsafe.Pointer(&ivBuf[0])), uintptr(unsafe.Pointer(&ivLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return string(encBuf[:encLen]), string(ivBuf[:ivLen]), true
|
||||
}
|
||||
|
||||
func randomBytes(n uint32) (string, bool) {
|
||||
outBuf := make([]byte, n*2+4) // hex = 2 chars per byte
|
||||
var outLen uint32
|
||||
status := hostCryptoRandomBytes(
|
||||
n,
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", false
|
||||
}
|
||||
return string(outBuf[:outLen]), true
|
||||
}
|
||||
|
||||
// hex encodes raw bytes to lowercase hex string
|
||||
func hex(b []byte) string {
|
||||
const hexChars = "0123456789abcdef"
|
||||
out := make([]byte, len(b)*2)
|
||||
for i, v := range b {
|
||||
out[i*2] = hexChars[v>>4]
|
||||
out[i*2+1] = hexChars[v&0xf]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// strings import 只為了 strings.Builder(interpolate 用,這裡不需要但 import 要保留給未來)
|
||||
var _ = strings.Builder{}
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250219.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.1.0",
|
||||
"wrangler": "^4.0.0"
|
||||
|
||||
Generated
+16
@@ -18,6 +18,9 @@ importers:
|
||||
'@cloudflare/workers-types':
|
||||
specifier: ^4.20250219.0
|
||||
version: 4.20260414.1
|
||||
js-yaml:
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
@@ -750,6 +753,9 @@ packages:
|
||||
'@vitest/utils@3.2.4':
|
||||
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -834,6 +840,10 @@ packages:
|
||||
js-tokens@9.0.1:
|
||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
|
||||
kleur@4.1.5:
|
||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1502,6 +1512,8 @@ snapshots:
|
||||
loupe: 3.2.1
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
blake3-wasm@2.1.5: {}
|
||||
@@ -1607,6 +1619,10 @@ snapshots:
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
kleur@4.1.5: {}
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
Reference in New Issue
Block a user