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:
2026-05-07 16:52:01 +08:00
parent e8fca33f80
commit 519423cb0d
127 changed files with 23909 additions and 264 deletions
@@ -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 是否需要 refreshexpires_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_tokenaction=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_requestplaintext 永不離開 WASM。
config_example: |
auth_step:
component: "auth_oauth2"
action: "authenticate"
service: "google_drive" # 對應 auth_recipe:google_drive 的 KV 記錄
+514
View File
@@ -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 endpointform-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 tokenHetzner 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
+3
View File
@@ -0,0 +1,3 @@
module claude_api
go 1.21
+27 -17
View File
@@ -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 keypk_live_xxx 或 ak_xxx
content:
type: string
description: block 內容
type:
type: string
description: block typenote / 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 blockPOST /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 keypk_live_xxx 或 ak_xxx,後者為 arcrun OAuth 取得)
text:
type: string
description: 要寫入的 block 內容
user_id:
type: string
description: namespace prefixpartner 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 寫入 KBDBPOST /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"
+3
View File
@@ -0,0 +1,3 @@
module kbdb_ingest
go 1.21
+155
View File
@@ -0,0 +1,155 @@
// kbdb_ingest — 把 input 寫入 KBDBPOST /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=truemira 場景定型貼文,不需 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 keypk_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"` // optionalpointer 區分「未傳」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_TOKENBearer token"
content:
type: string
description: "內容(append_journal / write_page 時必填)"
timestamp:
type: string
description: "ISO 8601 時間戳(append_journal 時選填,影響日期和時間顯示)"
date:
type: string
description: "日期 YYYY-MM-DDread_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 元件"
+3
View File
@@ -0,0 +1,3 @@
module component
go 1.21
+177
View File
@@ -0,0 +1,177 @@
// km_writer — 讀寫 Mira leo-graphjournals + 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。
+206
View File
@@ -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_KEYoutput = raw byteshex 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: encryptedbase64)放 outEncivbase64)放 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.Builderinterpolate 用,這裡不需要但 import 要保留給未來)
var _ = strings.Builder{}
+1
View File
@@ -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"
+16
View File
@@ -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: {}