Skip to content
This repository was archived by the owner on Aug 11, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 136 additions & 13 deletions docs/plugin-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
PluginProtocolError,
parseGetPluginResponse,
parseListPluginsResponse,
parsePluginMemberUploadStatusResponse,
parsePluginDownloadResponse,
validateGhostManifest,
type GhostManifest,
Expand All @@ -33,8 +34,9 @@ import {

- Ghost 包的 `ghost.json` 类型、格式常量和 `validateGhostManifest`;
- Desktop 消费的 Plugin 列表、详情与下载响应 DTO、枚举和解析器。
- organization 成员上传 `.cindy` 包时由 plugin-server、Cindy Host 和发布者插件共享的 DTO、限制常量和解析器。

本包不包含服务端数据模型、管理 API DTO、Plugin 生命周期、受众策略、鉴权、对象存储、安装目录、启停状态、IPC、panel 布局或其他 Desktop 运行时逻辑。管理面尚无跨仓 TypeScript 消费方,相关类型由 plugin-server 本地维护;未来出现真实共享消费者时再抽取。
本包不包含服务端数据模型、organization 管理 API DTO、Plugin 生命周期实现、受众策略实现、鉴权、对象存储实现、安装目录、启停状态、IPC、panel 布局或其他 Desktop 运行时逻辑。管理面尚无跨仓 TypeScript 消费方,相关类型由 plugin-server 本地维护;未来出现真实共享消费者时再抽取。

## 校验 Ghost manifest

Expand Down Expand Up @@ -337,18 +339,18 @@ try {

## 字段语义

| 字段 | 语义 |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `Plugin.id` | plugin-server 生成的永久资源 ID;用于详情、下载、分页和本地 managed marker,不等于包内名称。 |
| `ghostId` | `ghost.json.id`;在同一 owner 内唯一,不同 Public、Organization、Personal owner 间允许相同。 |
| `scope` | `public` 对任意已登录 Cindy 身份可用;`organization` 只对对应组织可用;`personal` 只对发布者本人可用。 |
| `organizationId` | Organization 必须是非空组织 ID;Public 和 Personal 恒为 `null`。 |
| `defaultInstall` | 对当前请求身份计算后的有效默认安装值;表示未安装时自动安装,不表示强制安装或强制启用。 |
| `minCindyVersion` | Release 的最低 Cindy 版本;必须是合法 SemVer。通常可选且缺失表示兼容所有版本;`ios-simulator` 等 Host-only slot 可要求必须声明。 |
| `X-Cindy-Version` | 客户端请求列表、详情和下载时携带的 Cindy SemVer;共享常量为 `CINDY_CLIENT_VERSION_HEADER`,HTTP 头名称大小写不敏感。 |
| `currentRelease` | 服务端为当前客户端选择的 Release;优先服务端 current,不兼容时回退到最新且仍有效的历史兼容 Release。列表只含摘要,详情额外包含 manifest。 |
| `currentRelease.icon` | 所选兼容 Release 的可直接展示图标元数据;为 `null` 时使用客户端兜底图标,URL 为短期授权地址。 |
| `nextCursor` | 下一页游标;为本页最后一个 `Plugin.id` 或 `null` |
| 字段 | 语义 |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Plugin.id` | plugin-server 生成的永久资源 ID;用于详情、下载和本地 managed marker,不等于包内名称。 |
| `ghostId` | `ghost.json.id`;在同一 owner 内唯一,不同 Public、Organization、Personal owner 间允许相同。 |
| `scope` | `public` 对任意已登录 Cindy 身份可用;`organization` 只对对应组织可用;`personal` 只对发布者本人可用。 |
| `organizationId` | Organization 必须是非空组织 ID;Public 和 Personal 恒为 `null`。 |
| `defaultInstall` | 对当前请求身份计算后的有效默认安装值;表示未安装时自动安装,不表示强制安装或强制启用。 |
| `minCindyVersion` | Release 的最低 Cindy 版本;必须是合法 SemVer。通常可选且缺失表示兼容所有版本;`ios-simulator` 等 Host-only slot 可要求必须声明。 |
| `X-Cindy-Version` | 客户端请求列表、详情和下载时携带的 Cindy SemVer;共享常量为 `CINDY_CLIENT_VERSION_HEADER`,HTTP 头名称大小写不敏感。 |
| `currentRelease` | 服务端为当前客户端选择的 Release;优先服务端 current,不兼容时回退到最新且仍有效的历史兼容 Release。列表只含摘要,详情额外包含 manifest。 |
| `currentRelease.icon` | 所选兼容 Release 的可直接展示图标元数据;为 `null` 时使用客户端兜底图标,URL 为短期授权地址。 |
| `nextCursor` | 下一页不透明游标或 `null`;客户端不得解析其内部结构,只能原样回传。当前服务端使用 `sortOrder + Plugin.id` 的复合位置,并兼容接收滚动期旧 Plugin ID 游标。 |

`parseGetPluginResponse` 还会校验 `ghostId === manifest.id`、Release `version === manifest.version`、顶层 `name/description/author` 与当前 manifest 一致,以及声明 `oidc-token` 的 manifest 只能属于 `organization` scope。调用方不能用 `ghostId` 合并不同来源的记录,应以 `Plugin.id` 标识服务端管理的安装实例。

Expand All @@ -366,6 +368,8 @@ try {
- Plugin HTTP list/detail envelope 当前只接受 `PLUGIN_API_SCHEMA_VERSION=2`;v2 将 `global` 替换为 `public` 并新增 `personal`;
- 两个版本号独立演进,不能相互替代。

v2 的 `nextCursor` 语义见上文字段表;解析器接受这种非空、有界字符串,是对既有 v2 线上响应的兼容修复,不是新的 envelope 形状,也不提升 `PLUGIN_API_SCHEMA_VERSION`。

校验器对未知字段保持宽容,对已知字段和值严格校验。新增可选字段不要求服务端和 Desktop 同时发布;破坏性格式变化必须提升对应 schema version。

未知字段只用于前向兼容,不会出现在校验后的返回对象中。消费方不得依赖当前版本未声明的字段。
Expand All @@ -382,6 +386,125 @@ plugin-server 上传 Release 时使用本包校验 `ghost.json`,不支持的 m

HTTP envelope 版本不支持时,客户端停止本轮远程对账并保留本地状态。本期不提供多 current Release、capability 上报或其他协商机制。

## Organization 成员上传契约

成员上传是独立于市场 list/detail envelope 的异步发布契约,通过
`@cindy/plugin-protocol/member-upload` 或包根入口消费。它不改变
`PLUGIN_API_SCHEMA_VERSION`,也不复用现有 CI raw POST 的请求体。

```ts
import {
PluginProtocolError,
parsePluginMemberUploadStatusResponse,
} from '@cindy/plugin-protocol/member-upload';
```

成员上传子路径与 delivery 子路径导出同一个 `PluginProtocolError` 类,调用方可以稳定使用
`instanceof PluginProtocolError` 识别解析失败。

| 操作 | HTTP 契约 | DTO |
| ------------ | ---------------------------------------------- | ------------------------------------------------------------------------ |
| prepare | `POST /api/publisher/uploads` | `PreparePluginMemberUploadRequest` → `PreparePluginMemberUploadResponse` |
| 上传文件 | `PUT <putUrl>` | 原始 `.cindy` body;使用 prepare 响应的 headers |
| commit | `POST /api/publisher/uploads/:uploadId/commit` | `CommitPluginMemberUploadRequest` → `CommitPluginMemberUploadResponse` |
| status | `GET /api/publisher/uploads/:uploadId` | `PluginMemberUploadStatusResponse` |
| my-publishes | `GET /api/publisher/releases/mine` | `ListMyPluginMemberReleasesResponse` |

### Forge 包限制

成员通道统一使用以下权威限制:

| 常量 | 值 | 含义 |
| --------------------------------------------- | ------: | ------------------------------ |
| `PLUGIN_MEMBER_UPLOAD_MAX_ARCHIVE_BYTES` | 128 MiB | 单个 `.cindy` 压缩包最大字节数 |
| `PLUGIN_MEMBER_UPLOAD_MAX_UNCOMPRESSED_BYTES` | 256 MiB | ZIP 条目解压后的累计最大字节数 |
| `PLUGIN_MEMBER_UPLOAD_MAX_ZIP_ENTRIES` | 2048 | ZIP 条目数上限 |

这些常量只约束新的成员上传通道。plugin-server 接入该通道前,既有发布路径继续使用自己的现行限制;
消费方不得因为协议常量已发布,就假定尚未接线的服务端已经接受 128 MiB 包。

### Prepare

```ts
interface PreparePluginMemberUploadRequest {
sizeBytes: number;
sha256: string;
}

interface PreparePluginMemberUploadResponse {
uploadId: string;
putUrl: string;
headers: Record<string, string>;
expiresAt: string;
status: 'awaiting_upload';
}
```

- `sizeBytes` 必须是 `1..PLUGIN_MEMBER_UPLOAD_MAX_ARCHIVE_BYTES` 的安全整数;`sha256` 是 64 位小写十六进制。
- `putUrl` 必须是 HTTPS;调用方按响应提供的 headers 上传,不把 Connection JWT 注入对象存储地址。
- organization、membership、passport、ghostId、version、对象 key 和幂等身份都不在 body 中。身份只来自服务端已验证的鉴权上下文;幂等键使用 `Idempotency-Key` 请求头。
- 相同身份范围和 `Idempotency-Key` 的 prepare 重放返回同一 upload session,不创建第二份发布。

### Commit

```ts
type CommitPluginMemberUploadRequest = Record<string, never>;

interface CommitPluginMemberUploadResponse {
uploadId: string;
status: PluginMemberUploadStatus;
}
```

commit body 必须为空,不能覆盖 actor、organization、ghostId、version、hash、大小或对象位置。
commit 只启动或复用持久化异步任务;相同身份范围和 `Idempotency-Key` 的重放必须观察同一任务/result,
不能重复创建 Release 或审计记录。

### Status 与 my-publishes

```ts
interface PluginMemberUploadStatusResponse {
uploadId: string;
status: 'awaiting_upload' | 'validating' | 'publishing' | 'succeeded' | 'failed' | 'expired';
pluginId: string | null;
releaseId: string | null;
ghostId: string | null;
version: string | null;
reviewStatus: 'pending' | 'approved' | 'rejected' | null;
failure: { code: PluginMemberUploadFailureCode; message: string } | null;
}

interface PluginMemberReleaseSummary extends PluginMemberUploadStatusResponse {
createdAt: string;
updatedAt: string;
}

interface ListMyPluginMemberReleasesResponse {
releases: PluginMemberReleaseSummary[];
nextCursor: string | null;
}
```

- 上传任务状态与 Release 审核状态相互独立。`succeeded` 必须同时带非空
`pluginId/releaseId/ghostId/version/reviewStatus`;其他任务状态的 `reviewStatus` 必须为 `null`。
- `failed` 必须带 `failure`,其他任务状态不得带;`message` 是处理后的用户可见原因,不得包含内部对象 key、审计 detail、凭证或签名 URL。
- 稳定异步失败码为:`UPLOAD_OBJECT_MISSING`、`UPLOAD_SIZE_MISMATCH`、
`UPLOAD_SHA256_MISMATCH`、`PLUGIN_PACKAGE_INVALID`、`MEMBERSHIP_INACTIVE`、
`PUBLISH_NOT_AUTHORIZED`、`PLUGIN_GHOST_ID_CONFLICT`、`PUBLISH_STORAGE_UNAVAILABLE`、
`PUBLISH_INTERNAL_ERROR`。
- 非空 `ghostId` 必须满足与 `ghost.json.id` 相同的 `isValidGhostId` 规则。
- 非空 `pluginId` 必须满足与市场 delivery 响应相同的 `isValidPluginResourceId` 规则。
- `createdAt/updatedAt/expiresAt` 均为带毫秒的 UTC ISO 8601 时间。
- `nextCursor` 是 1–4096 字符的不透明字符串或 `null`;调用方只能原样回传,不得从中推导成员、Release 或时间信息。
- my-publishes 只包含当前已验证成员有权查看的发布记录;身份和筛选范围不接受 body/query 自报覆盖。

### Rollout 与降级边界

- 此协议包先提供唯一 DTO、限制和解析规则;plugin-server、Cindy Host 和发布者插件分别 bump 后才能开启成员上传。
- 在三方实现和部署完成前,成员发布入口必须保持关闭;不能让任一消费方靠手抄字段或数字提前接线。
- 新成员上传解析器对已知字段和值 fail-closed。状态响应不合法或 PUT 结果不确定时,调用方不得把任务当成功,也不得自动重放 prepare/commit;应先重新查询服务端 status。
- 该契约不改变既有 GitHub OIDC CI 发布路径,也不改变市场 list/detail/download 的 v2 兼容策略。

## 消费顺序

本期由 plugin-server 和 Desktop 共同消费该包。协议合并后,两个消费方仓库分别 bump
Expand Down
3 changes: 2 additions & 1 deletion packages/plugin-protocol/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"exports": {
".": "./src/index.ts",
"./manifest": "./src/manifest.ts",
"./delivery": "./src/delivery.ts"
"./delivery": "./src/delivery.ts",
"./member-upload": "./src/memberUpload.ts"
Comment thread
fmfsaisai marked this conversation as resolved.
},
"scripts": {
"build": "tsc --noEmit",
Expand Down
16 changes: 14 additions & 2 deletions packages/plugin-protocol/src/__tests__/delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,15 +370,27 @@ describe('plugin delivery contract', () => {
).toThrow(PluginProtocolError);
});

it('rejects an invalid list cursor and a detail manifest mismatch', () => {
it('round-trips an opaque list cursor and rejects an empty cursor', () => {
const cursor = Buffer.from(JSON.stringify({ sortOrder: 7, id: 'plugin-1' })).toString(
'base64url',
);
expect(
parseListPluginsResponse({
schemaVersion: PLUGIN_API_SCHEMA_VERSION,
plugins: [],
nextCursor: cursor,
}).nextCursor,
).toBe(cursor);
expect(() =>
parseListPluginsResponse({
schemaVersion: PLUGIN_API_SCHEMA_VERSION,
plugins: [],
nextCursor: 'INVALID',
nextCursor: '',
}),
).toThrow(PluginProtocolError);
});

it('rejects a detail manifest mismatch', () => {
expect(() =>
parseGetPluginResponse({
schemaVersion: PLUGIN_API_SCHEMA_VERSION,
Expand Down
Loading
Loading