flare-plugin-host 0.1.1

Flare 能力插件的宿主接入库:声明、注册、注销
Documentation
# 自己开发一个插件

从零写一个能被平台调用、能授权、能计费的插件。全程不需要改平台一行代码。

想直接看能跑的代码:[`examples/echo-plugin`](../examples/echo-plugin) 是一个
约 130 行的完整插件,依赖全部来自 crates.io,`cargo run` 就起。

## 0. 先理解一件事:平台不认识你的插件

平台侧只有**能力 id**和 **JSON 载荷**两个概念,不生成也不依赖你的类型。
契约细节见[插件契约](./plugin-contract.md),这里只讲怎么把插件做出来。

## 1. 实现一个 gRPC 服务

一个 `ExtensionPlugin` 服务,只有一个方法:

```protobuf
service ExtensionPlugin {
  rpc Call(GenericRequest) returns (GenericResponse);
}
```

`operation` 决定走哪个分发臂,`payload.value` 是 JSON 字节,
`metadata` 里的上下文**必须由你自己还原**——平台是进程外调用你。

## 2. 声明你能做什么

启动后向平台注册。用 `flare-plugin-host` 而不是手写 gRPC 调用:

```bash
cargo add flare-plugin-host
```

```rust,no_run
use flare_plugin_host::{PluginDeclaration, PluginHost, SeatModel};

# async fn demo() -> Result<(), flare_plugin_host::HostError> {
let declaration = PluginDeclaration {
    tenant_id: "0".into(),
    plugin_id: "vendorx-invoice".into(),
    capability_id: "vendorx.invoice.create".into(),
    grpc_authority: "10.0.0.7:9000".into(),   // 平台按这个地址回调你
    plugin_version: env!("CARGO_PKG_VERSION").into(),
    api_version: "1".into(),
    manifest_sha256: String::new(),
    declared_operations: vec![
        "vendorx.invoice.create".into(),
        "vendorx.invoice.void".into(),
    ],
    labels: Default::default(),
    seat_model: SeatModel::Tenant,
};

PluginHost::connect("http://capability:50110")
    .await?
    .announce(&declaration)
    .await?;
# Ok(())
# }
```

`grpc_authority` 是**平台能连到**的地址,不是你监听地址的字面量 ——
容器里这两者常常不同,通告一个平台连不到的地址,症状会表现成健康检查失败
而不是配置错误。

### 为什么用库而不是自己调

不是省代码量,是**容易少调**。漏了 `declared_operations` 不会报错,只会让你的
插件退化成 `unverified`——声明边界从此对它失效。做成必须填满字段的结构体,
漏填在编译期就暴露。

### declared_operations 决定你承接什么

**注册一次就够**:平台的注册粒度是 `(租户, 插件)`,你的进程只有一条记录。

这条清单必须与你 `Call` 里实际处理的 operation 一致:

- 声明有、实现没有 → 请求打过来,你回 `UNKNOWN_OPERATION`,看着像插件坏了
- 实现有、声明没有 → 平台直接拒绝,你的进程收不到,看着像权限问题

两个方向的排查路线完全不同,所以**加一条测试双向比对**这两份清单。
示例里有一个可以直接抄的版本。

## 3. 选择计费单位:这是产品决策

`seat_model` 决定你的插件怎么授权,平台不替你决定:

| 取值 | 语义 | 适合 |
|---|---|---|
| `Tenant` | **租户装了就全员可用**,租户开关即安装状态 | 绝大多数插件 |
| `PerUser` | 还需逐人授权 | 有边际成本的(AI 按 token 烧钱)、需合规隔离的(导出只给特定角色) |

选 `Tenant` 意味着:管理员安装后,组织里**从未被单独授权过**的用户也能直接用。
选错的代价是真金白银——按租户卖一个按 token 烧钱的能力,用量越大亏越多。
所以这个字段没有默认值,必须显式写。

## 4. 授权是 fail-closed 的

平台在把请求交给你之前会先过授权。**拒绝就是拒绝,不会降级放行**:

```text
global 开关关闭            → 拒绝
租户开关 = false           → 拒绝(装过但停用)
租户开关缺失(tenant 模型)→ 拒绝(从未安装)
用户无授权(per_user 模型)→ 拒绝
```

注意最后两条的区别:`tenant` 模型下**开关缺失即拒**,因为「安装」这个动作
必须有效力;而 `per_user` 沿用旧语义。

与之相反,**可用性类失败会降级**:你的插件超时或不可达时,平台不会因此拒绝
整条链路,而是按健康状态把你的实例排到候选末尾。两类失败方向相反,别混淆。

## 5. 优雅停机

```rust,no_run
# async fn demo(host: &mut flare_plugin_host::PluginHost)
#     -> Result<(), flare_plugin_host::HostError> {
host.withdraw("0", "vendorx-invoice").await?;
# Ok(())
# }
```

不调也不会坏——健康检查最终会把你摘掉。但那要等一个检查周期,期间的调用会打到
正在退出的进程上。注销粒度是 `(租户, 插件)`,与注册粒度一致。

## 6. 自有存储

插件有自己的库表时,在清单里声明归属与卸载策略:

```json
"storage": {
  "prefix": "plugin_vendorx_",
  "onUninstall": "archive",
  "migrationsPath": "migrations/"
}
```

`onUninstall` **没有默认值**,必须显式写:默认保留会让卸载留下无人认领的表,
默认删除会让一次误操作毁掉用户数据。两种默认都不可接受。

## 7. 完整流程回顾

```text
你的进程启动
  → 起 gRPC 服务(先起服务再声明,否则平台可能在你还没监听时就来探活)
  → announce():声明 operations + seat_model + 版本
  → 平台把你记进路由簿,标记 verified
管理员安装(tenant 模型)或逐人授权(per_user 模型)
  → 调用方发起请求
  → 平台:授权校验 → 声明边界校验 → 路由到你 → ExtensionPlugin.Call
  → 你:还原上下文 → JSON 转你的类型 → 业务 → 转回 JSON
```

## 8. 常见坑(都有明确症状)

| 症状 | 真实原因 |
|---|---|
| 所有 op 报「找不到上下文」 | 没把 `metadata` 还原进请求上下文 |
| 某个 op 报 `UNKNOWN_OPERATION` | 声明了但没实现 |
| 请求根本到不了插件,报权限错 | 实现了但没声明 |
| 装了却全员不可用 | `seat_model` 没声明,退化成按人授权 |
| 注册被当场拒绝 | `capability_id` 不在自己的 `declared_operations`|
| 健康检查一直失败 | 通告的地址平台连不到,或声明了特殊探活协议而平台侧没有对应探针 |
| 用 prost 解 payload 报错 | `Any.value` 是 JSON 字节,不是 protobuf 编码 |

这些症状都是**刻意设计成可区分的**——每一条都指向唯一的原因,不需要猜。