@@ -22,6 +22,12 @@ import type { ExposureConsent } from '../lib/exposure-consent';
export const recipesRouter = new Hono < { Bindings : Bindings } > ( ) ;
export interface RecipeDefinition {
// UUID 身份模型(kbdb-base §7.5.5):每個 recipe 一誕生領 uuid = 唯一身份。
// canonical_id / author / 公私 都是屬性,不是身份。身份(uuid) 與歸屬(author) 分離。
// 舊 recipe 無 uuid → resolveRecipe / migration 兼容(migration 增量補 uuid,不刪舊 key)。
uuid? : string ; // 唯一身份;舊資料可能缺,讀取時容忍
author? : string ; // 該 uuid 投稿者(誰投誰負責那版市場數據);'system' = init-seed 種子
derived_from? : string ; // 可選溯源:fork 自哪個 uuid( Leo 改 John 版時記 John 的 uuid)
canonical_id : string ;
hash_id : string ; // rec_xxxxxxxx
display_name? : string ;
@@ -47,6 +53,36 @@ export interface RecipeDefinition {
updated_at : number ;
}
// ── UUID 身份模型 KV key( kbdb-base §7.5.5)────────────────────────────────────
// recipe:{uuid} → recipe 本體(唯一身份)
// idx:canonical:{canonical_id} → JSON array of uuid(同 canonical 多作者版本並存,公庫用)
// idx:installed:{canonical_id} → 單一 uuid(本部署執行時用哪個版本;pull/submit 時定)
// idx:{hash_id} → canonical_id(既有 rec_hash 反查,保留)
// 舊資料 recipe:{canonical_id} 不刪,resolveRecipe fallback 讀得到(migration 增量補,不破現況)。
const kIdxCanonical = ( canonicalId : string ) = > ` idx:canonical: ${ canonicalId } ` ;
const kIdxInstalled = ( canonicalId : string ) = > ` idx:installed: ${ canonicalId } ` ;
/**
* 寫一份 recipe( UUID 身份模型):給定 recipe 已含 uuid → 寫 recipe:{uuid}、
* 把 uuid 併進 idx:canonical:{canonical_id} 清單、設為本部署 installed(執行時用此版本)、
* 維護 idx:{hash_id} 反查。private(POST /recipes) 與 public(submit-p) 共用此寫入。
*/
export async function installRecipeRecord ( kv : KVNamespace , recipe : RecipeDefinition ) : Promise < void > {
const uuid = recipe . uuid ! ;
const { canonical_id , hash_id } = recipe ;
const listRaw = await kv . get ( kIdxCanonical ( canonical_id ) ) ;
const uuids : string [ ] = listRaw ? JSON . parse ( listRaw ) : [ ] ;
if ( ! uuids . includes ( uuid ) ) uuids . push ( uuid ) ;
await Promise . all ( [
kv . put ( ` recipe: ${ uuid } ` , JSON . stringify ( recipe ) ) ,
kv . put ( kIdxCanonical ( canonical_id ) , JSON . stringify ( uuids ) ) ,
kv . put ( kIdxInstalled ( canonical_id ) , uuid ) ,
kv . put ( ` idx: ${ hash_id } ` , canonical_id ) ,
] ) ;
}
// POST /recipes — 新增或更新 recipe
recipesRouter . post ( '/recipes' , async ( c ) = > {
let body : Partial < RecipeDefinition > ;
@@ -63,8 +99,10 @@ recipesRouter.post('/recipes', async (c) => {
const hashId = await deriveRecipeHash ( canonicalId ) ;
const now = Date . now ( ) ;
// 讀取現有版本(保留 created_at + 既有同意憑證)
const existing = await c . env . RECIPES . get ( ` recipe: ${ canonicalId } ` , 'json' ) as RecipeDefinition | null ;
// 私庫(POST /recipes) = 自己地盤,同 canonical 就地更新自己安裝的那份(沿用既有 uuid)。
// 既有 installed → 沿用其 uuid + created_at;無 → 新領 uuid(首次裝這個 canonical)。
// 讀取順序:先 UUID 模型(installed→uuid),fallback 舊 key( migration 前的種子)。
const existing = await resolveRecipe ( canonicalId , c . env . RECIPES ) ;
// 資料外流警示:recipe 定義資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
const consentError = checkExposureConsent ( body . exposure_consent , existing ? . exposure_consent ) ;
@@ -73,6 +111,9 @@ recipesRouter.post('/recipes', async (c) => {
}
const recipe : RecipeDefinition = {
uuid : existing?.uuid ? ? crypto . randomUUID ( ) ,
author : body.author ? ? existing ? . author ? ? 'local' ,
derived_from : body.derived_from ? ? existing ? . derived_from ,
canonical_id : canonicalId ,
hash_id : hashId ,
display_name : body.display_name ,
@@ -88,16 +129,124 @@ recipesRouter.post('/recipes', async (c) => {
updated_at : now ,
} ;
// 寫入兩個 KV key
await Promise . all ( [
c . env . RECIPES . put ( ` recipe: ${ canonicalId } ` , JSON . stringify ( recipe ) ) ,
c . env . RECIPES . put ( ` idx: ${ hashId } ` , canonicalId ) ,
] ) ;
await installRecipeRecord ( c . env . RECIPES , recipe ) ;
return c . json ( { success : true , recipe } ) ;
} ) ;
// GE T /recipes/:id — 讀取 recipe(支援 canonical_id 或 rec_hash)
// POS T /recipes/submit — 公共庫投稿(submit-p)。kbdb-base SDD §7.2/§7.3。
//
// 兩套部署模型:self-hosted cypher = 私庫(直接 POST /recipes 寫自己 KV);
// 官方 cypher = 公共庫,外部投稿者把修好的 recipe 送來這個端點。
//
// app-store / UUID 模型(§7.5.5):submit-p = **新增一個作者版本(領新 uuid)**,
// 不覆蓋同 canonical_id。同 canonical 多作者並存(Leo 版、John 版各自 uuid + 市場數據)。
// 公共庫 = 暴露面 → 強制 exposure_consent( mindset §6:暴露需人類明示同意)。
// 投稿者帶的 stat 只當「存證」(誰在何時投了什麼、聲稱打通幾次),寫進 KBDB 一筆
// recipe_submission entry, **不**併進 recipe-stat 真實計數(避免自報數污染市場數據,§7.3)。
// 市場信任靠真實使用累積(5.1),不拿投稿者自報數當門檻 → 不造債。
recipesRouter . post ( '/recipes/submit' , async ( c ) = > {
let body : Partial < RecipeDefinition > & {
stat ? : { success_count? : number ; failure_count? : number } ;
submitter? : string ;
} ;
try {
body = await c . req . json ( ) ;
} catch {
return c . json ( { success : false , error : 'request body 必須為 JSON' } , 400 ) ;
}
const canonicalId = ( body . canonical_id ? ? '' ) . trim ( ) . toLowerCase ( ) ;
if ( ! canonicalId ) return c . json ( { success : false , error : 'canonical_id 必填' } , 400 ) ;
if ( ! body . endpoint ) return c . json ( { success : false , error : 'endpoint 必填' } , 400 ) ;
const hashId = await deriveRecipeHash ( canonicalId ) ;
const now = Date . now ( ) ;
// 公共庫投稿一定是暴露 → 需明示同意(無同意直接擋)。投稿是新版本,不沿用既有同意。
const consentError = checkExposureConsent ( body . exposure_consent , undefined ) ;
if ( consentError !== null ) {
return c . json ( { success : false , error : consentError , requires : 'exposure_consent' } , 403 ) ;
}
// app-store 模型:**領新 uuid = 新增作者版本**,不覆蓋既有 canonical(§7.5.5)。
const recipe : RecipeDefinition = {
uuid : crypto.randomUUID ( ) ,
author : body.author ? ? body . submitter ? ? 'anonymous' ,
derived_from : body.derived_from ,
canonical_id : canonicalId ,
hash_id : hashId ,
display_name : body.display_name ,
description : body.description ,
endpoint : body.endpoint ,
method : ( body . method ? ? 'POST' ) . toUpperCase ( ) ,
headers : body.headers ,
body : body.body ,
auth_service : body.auth_service ,
credentials_required : body.credentials_required ,
exposure_consent : resolveConsentForRecord ( body . exposure_consent , undefined ) ,
created_at : now ,
updated_at : now ,
} ;
// 新增作者版本:寫 recipe:{uuid} + 併進 idx:canonical 清單(同 canonical 多版本並存)。
// installed 也指向這個新版本(官方部署投稿後預設用最新;market 選擇由 §7.5.5 端點處理)。
await installRecipeRecord ( c . env . RECIPES , recipe ) ;
// stat 存證:寫一筆 recipe_submission entry 進 KBDB(不當門檻,當法律歸責軌跡)。
// fire-and-forget:存證失敗不擋投稿成功。
const kbdbBase = ( c . env . KBDB_BASE_URL ? ? 'https://kbdb.finally.click' ) . replace ( /\/$/ , '' ) ;
const evidence = {
content : canonicalId ,
entry_type : 'recipe_submission' ,
metadata_json : JSON.stringify ( {
uuid : recipe.uuid ,
canonical_id : canonicalId ,
author : recipe.author ,
submitter : body.submitter ? ? 'unknown' ,
claimed_stat : body.stat ? ? null ,
submitted_at : now ,
} ) ,
} ;
const kbdbHeaders : Record < string , string > = { 'Content-Type' : 'application/json' } ;
if ( c . env . KBDB_INTERNAL_TOKEN ) kbdbHeaders [ 'Authorization' ] = ` Bearer ${ c . env . KBDB_INTERNAL_TOKEN } ` ;
c . executionCtx . waitUntil (
fetch ( ` ${ kbdbBase } /entries ` , {
method : 'POST' ,
headers : kbdbHeaders ,
body : JSON.stringify ( evidence ) ,
} ) . catch ( ( ) = > undefined ) ,
) ;
return c . json ( { success : true , recipe , evidence_recorded : true } ) ;
} ) ;
// POST /recipes/migrate-uuid — 一次性 migration:把 migration 前的舊 key recipe:{canonical_id}
// (無 uuid)轉成 UUID 身份模型(§7.5.5)。增量寫、**不刪舊 key**(失敗也不破現況;resolveRecipe
// 本就 fallback 舊 key)。冪等:已有 uuid 的跳過。重跑安全。
recipesRouter . post ( '/recipes/migrate-uuid' , async ( c ) = > {
const list = await c . env . RECIPES . list ( { prefix : 'recipe:' } ) ;
let migrated = 0 , skipped = 0 ;
const errors : string [ ] = [ ] ;
for ( const k of list . keys ) {
try {
const rec = await c . env . RECIPES . get ( k . name , 'json' ) as RecipeDefinition | null ;
if ( ! rec || ! rec . canonical_id ) { skipped ++ ; continue ; }
if ( rec . uuid ) { skipped ++ ; continue ; } // 已是新模型
const migrated_recipe : RecipeDefinition = {
. . . rec ,
uuid : crypto.randomUUID ( ) ,
author : rec.author ? ? 'system' , // 舊種子歸 system
} ;
await installRecipeRecord ( c . env . RECIPES , migrated_recipe ) ;
migrated ++ ;
} catch ( e ) {
errors . push ( ` ${ k . name } : ${ e instanceof Error ? e.message : String ( e ) } ` ) ;
}
}
return c . json ( { success : errors.length === 0 , migrated , skipped , errors } ) ;
} ) ;
// GET /recipes/:id — 讀取 recipe(支援 canonical_id / rec_hash / uuid)
recipesRouter . get ( '/recipes/:id' , async ( c ) = > {
const id = c . req . param ( 'id' ) ;
const recipe = await resolveRecipe ( id , c . env . RECIPES ) ;
@@ -105,42 +254,193 @@ recipesRouter.get('/recipes/:id', async (c) => {
return c . json ( { success : true , recipe } ) ;
} ) ;
// GET /recipes — 列出所有 recipe
// GET /recipes — 列出所有 recipe(本部署 KV 全部版本,含多作者)。
// prefix recipe: 同時命中 recipe:{uuid}(新)與 recipe:{canonical_id}( migration 前舊 key)。
// 去重:同 canonical_id 若已有帶 uuid 的版本,捨棄無 uuid 的舊 key 重複項。
recipesRouter . get ( '/recipes' , async ( c ) = > {
const list = await c . env . RECIPES . list ( { prefix : 'recipe:' } ) ;
const recipes = await Promise . all (
list . keys . map ( k = > c . env . RECIPES . get ( k . name , 'json' ) )
) ;
return c . json ( { success : true , recipes : recipes.filter ( Boolean ) , count : recipes.length } ) ;
const all = ( await Promise . all (
list . keys . map ( k = > c . env . RECIPES . get ( k . name , 'json' ) as Promise < RecipeDefinition | null > )
) ) . filter ( Boolean ) as RecipeDefinition [ ] ;
// canonical → 是否已有帶 uuid 的版本
const hasUuidVersion = new Set ( all . filter ( r = > r . uuid ) . map ( r = > r . canonical_id ) ) ;
const recipes = all . filter ( r = > r . uuid || ! hasUuidVersion . has ( r . canonical_id ) ) ;
return c . json ( { success : true , recipes , count : recipes.length } ) ;
} ) ;
// DELETE /recipes/:id — 刪除 recipe
// ── 公庫只讀端點(kbdb-base §7.5.4,公→私 pull + 瀏覽的後端基礎)──────────────────
// 官方 cypher 開公開只讀(無需 api_key,公庫本就公共)。語意 = 「這是公庫,給 self-hosted pull/瀏覽」,
// 含作者維度 + 市場星數(與內部 /recipes 分開命名,公庫的多作者/排序不污染內部)。
/** 從 KBDB 抓 recipe 市場星數(5.1 記的 success/failure)。失敗回 null(端點仍可用,星數缺省)。*/
async function fetchMarketStat (
env : Bindings ,
canonicalId : string ,
) : Promise < { success_count : number ; failure_count : number } | null > {
try {
const base = ( env . KBDB_BASE_URL ? ? 'https://kbdb.finally.click' ) . replace ( /\/$/ , '' ) ;
const headers : Record < string , string > = { } ;
if ( env . KBDB_INTERNAL_TOKEN ) headers [ 'Authorization' ] = ` Bearer ${ env . KBDB_INTERNAL_TOKEN } ` ;
const res = await fetch ( ` ${ base } /recipe-stats/ ${ encodeURIComponent ( canonicalId ) } ` , { headers } ) ;
if ( ! res . ok ) return null ;
const json = await res . json ( ) as { stat ? : { success_count? : number ; failure_count? : number } } ;
if ( ! json . stat ) return null ;
return {
success_count : json.stat.success_count ? ? 0 ,
failure_count : json.stat.failure_count ? ? 0 ,
} ;
} catch {
return null ;
}
}
// 收集本部署 KV 全部 recipe(去重,與 GET /recipes 同邏輯),給公庫端點共用。
async function listAllRecipes ( kv : KVNamespace ) : Promise < RecipeDefinition [ ] > {
const list = await kv . list ( { prefix : 'recipe:' } ) ;
const all = ( await Promise . all (
list . keys . map ( k = > kv . get ( k . name , 'json' ) as Promise < RecipeDefinition | null > ) ,
) ) . filter ( Boolean ) as RecipeDefinition [ ] ;
const hasUuid = new Set ( all . filter ( r = > r . uuid ) . map ( r = > r . canonical_id ) ) ;
return all . filter ( r = > r . uuid || ! hasUuid . has ( r . canonical_id ) ) ;
}
// GET /public-recipes?q=&limit=&offset= — 搜尋/列出公庫 recipe。
// 同 canonical_id 回多筆(多作者),各附市場星數,供 CC 依數據選(§7.5.5)。
// 落空(q 無命中)→ 回 found:false + 創作引導(§7.5.6),不回空陣列乾等。
recipesRouter . get ( '/public-recipes' , async ( c ) = > {
const q = ( c . req . query ( 'q' ) ? ? '' ) . trim ( ) . toLowerCase ( ) ;
const limit = Math . min ( Number ( c . req . query ( 'limit' ) ? ? 50 ) , 200 ) ;
const offset = Number ( c . req . query ( 'offset' ) ? ? 0 ) ;
const all = await listAllRecipes ( c . env . RECIPES ) ;
const matched = q
? all . filter ( r = >
r . canonical_id . toLowerCase ( ) . includes ( q ) ||
( r . display_name ? ? '' ) . toLowerCase ( ) . includes ( q ) ||
( r . description ? ? '' ) . toLowerCase ( ) . includes ( q ) )
: all ;
if ( q && matched . length === 0 ) {
// 落空 = 創作入口(§7.5.6):讓 CC 知道「公庫沒有,可自己做一個成為作者」。
return c . json ( {
found : false ,
query : q ,
hint : ` 公庫無符合「 ${ q } 」的 recipe。可自行建立並 submit-p 投稿成為作者(app-store 模型)。 ` ,
} ) ;
}
const page = matched . slice ( offset , offset + limit ) ;
const withStats = await Promise . all (
page . map ( async r = > ( {
uuid : r.uuid ,
canonical_id : r.canonical_id ,
author : r.author ,
display_name : r.display_name ,
description : r.description ,
market_stat : await fetchMarketStat ( c . env , r . uuid ? ? r . canonical_id ) , // §7.5.h per-uuid
} ) ) ,
) ;
return c . json ( { found : true , recipes : withStats , count : matched.length } ) ;
} ) ;
// GET /public-recipes/:canonical_id?author= — 取單一 recipe 全文(pull 用)。
// 不指定 author → 回市場最佳版本(success_count 最高)。落空 → found:false 創作引導(§7.5.6)。
recipesRouter . get ( '/public-recipes/:canonical_id' , async ( c ) = > {
const canonicalId = c . req . param ( 'canonical_id' ) . trim ( ) . toLowerCase ( ) ;
const author = c . req . query ( 'author' ) ;
const all = await listAllRecipes ( c . env . RECIPES ) ;
let versions = all . filter ( r = > r . canonical_id === canonicalId ) ;
if ( author ) versions = versions . filter ( r = > r . author === author ) ;
if ( versions . length === 0 ) {
return c . json ( {
found : false ,
canonical_id : canonicalId ,
hint : ` 公庫無 recipe「 ${ canonicalId } 」 ${ author ? ` ( author=${ author } ) ` : '' } 。可自行建立並 submit-p 投稿成為作者(app-store 模型)。 ` ,
} ) ;
}
// 多作者 → 選市場最佳(success_count 最高;無 stat 視為 0)。
// §7.5.h:星數 per-uuid( 5.1 記 uuid)→ 能真正區分 Leo 版/John 版。舊資料無 uuid fallback canonical_id。
let best = versions [ 0 ] ;
let bestStat : { success_count : number ; failure_count : number } | null = null ;
let bestScore = - 1 ;
for ( const v of versions ) {
const stat = await fetchMarketStat ( c . env , v . uuid ? ? v . canonical_id ) ;
const score = stat ? . success_count ? ? 0 ;
if ( score > bestScore ) { bestScore = score ; best = v ; bestStat = stat ; }
}
return c . json ( { found : true , recipe : best , market_stat : bestStat } ) ;
} ) ;
// DELETE /recipes/:id — 刪除(依 UUID 模型清掉 recipe:{uuid} + installed + canonical 清單裡的該 uuid + 舊 key)
recipesRouter . delete ( '/recipes/:id' , async ( c ) = > {
const id = c . req . param ( 'id' ) ;
const recipe = await resolveRecipe ( id , c . env . RECIPES ) ;
if ( ! recipe ) return c . json ( { success : false , error : ` 找不到 recipe: ${ id } ` } , 404 ) ;
await Promise . all ( [
c . env . RECIPES . delete ( ` recipe: ${ recipe . canonical_id } ` ) ,
const canonicalId = recipe . canonical_id ;
const ops : Promise < unknown > [ ] = [
c . env . RECIPES . delete ( ` idx: ${ recipe . hash_id } ` ) ,
] ) ;
return c . json ( { success : true , deleted : recipe.canonical_id } ) ;
c . env . RECIPES . delete ( ` recipe: ${ canonicalId } ` ) , // 舊 key(若存在)
] ;
if ( recipe . uuid ) {
ops . push ( c . env . RECIPES . delete ( ` recipe: ${ recipe . uuid } ` ) ) ;
// 從 canonical 清單移除此 uuid;若清單空了連 installed 一起清
const listRaw = await c . env . RECIPES . get ( kIdxCanonical ( canonicalId ) ) ;
const uuids : string [ ] = listRaw ? JSON . parse ( listRaw ) : [ ] ;
const left = uuids . filter ( u = > u !== recipe . uuid ) ;
if ( left . length > 0 ) {
ops . push ( c . env . RECIPES . put ( kIdxCanonical ( canonicalId ) , JSON . stringify ( left ) ) ) ;
// installed 若指向被刪的 uuid → 改指剩下第一個
const installed = await c . env . RECIPES . get ( kIdxInstalled ( canonicalId ) ) ;
if ( installed === recipe . uuid ) ops . push ( c . env . RECIPES . put ( kIdxInstalled ( canonicalId ) , left [ 0 ] ) ) ;
} else {
ops . push ( c . env . RECIPES . delete ( kIdxCanonical ( canonicalId ) ) ) ;
ops . push ( c . env . RECIPES . delete ( kIdxInstalled ( canonicalId ) ) ) ;
}
}
await Promise . all ( ops ) ;
return c . json ( { success : true , deleted : recipe.uuid ? ? canonicalId } ) ;
} ) ;
/** 用 canonical_id 或 rec_hash 查 recipe */
/**
* 用 canonical_id / rec_hash / uuid 查 recipe(執行時的解析入口)。
* UUID 身份模型(§7.5.5) + 向後相容(migration 前的舊 key):
* 1. id 是 uuid( recipe:{uuid} 直接存在)→ 直接回。
* 2. rec_xxxxxxxx → idx:{hash} 反查 canonical_id → 再走 canonical 解析。
* 3. canonical_id → 先查 idx:installed:{canonical_id}(本部署安裝的唯一版本)→ recipe:{uuid};
* 查不到 fallback 舊 key recipe:{canonical_id}(種子 / migration 前資料)。
* 執行鏈路(component-loader/auth-dispatcher/credential-injector)都經此 → 不破執行。
*/
export async function resolveRecipe (
id : string ,
kv : KVNamespace ,
) : Promise < RecipeDefinition | null > {
// rec_xxxxxxxx → 先查 idx 反查 canonical_id
// 1. 直接 uuid( pull / market 指定版本時用)
const direct = await kv . get ( ` recipe: ${ id } ` , 'json' ) as RecipeDefinition | null ;
if ( direct && direct . uuid ) return direct ;
// direct 命中但無 uuid = 舊 key recipe:{canonical_id}( migration 前)→ 仍可用,但繼續嘗試 installed 拿新版
// ( installed 優先:migration 後新版在 recipe:{uuid},舊 key 為 fallback)
// 2. rec_hash 反查 canonical_id
let canonicalId = id ;
if ( id . startsWith ( 'rec_' ) ) {
const canonicalId = await kv . get ( ` idx: ${ id } ` ) ;
if ( ! canonicalId ) return null ;
return kv . get ( ` recipe: ${ canonicalId } ` , 'json' ) ;
const looked = await kv . get ( ` idx: ${ id } ` ) ;
if ( ! looked ) return direct ; // hash 查不到,回 step1 結果(通常 null)
canonicalId = looked ;
}
// 直接用 canonical_id
return kv . get ( ` recipe: ${ id } ` , 'json' ) ;
// 3. canonical → installed uuid → recipe:{uuid}; fallback 舊 key
const installedUuid = await kv . get ( kIdxInstalled ( canonicalId ) ) ;
if ( installedUuid ) {
const byUuid = await kv . get ( ` recipe: ${ installedUuid } ` , 'json' ) as RecipeDefinition | null ;
if ( byUuid ) return byUuid ;
}
// fallback:舊 key recipe:{canonical_id}( direct 若正是它,已在手上)
return direct ? ? ( await kv . get ( ` recipe: ${ canonicalId } ` , 'json' ) ) ;
}
// ── Auth Recipe ────────────────────────────────────────────────────────────────