From 6be3efcb3cac23e5239cd7a26dbec5b32549c3fa Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sun, 9 Aug 2026 11:33:34 +0800 Subject: [PATCH 1/6] fix(plugin-protocol): accept opaque list cursors Signed-off-by: fmfsaisai --- .../src/__tests__/delivery.test.ts | 16 ++++++++++++++-- packages/plugin-protocol/src/delivery.ts | 6 +++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/plugin-protocol/src/__tests__/delivery.test.ts b/packages/plugin-protocol/src/__tests__/delivery.test.ts index 8b4dc96..c01a82e 100644 --- a/packages/plugin-protocol/src/__tests__/delivery.test.ts +++ b/packages/plugin-protocol/src/__tests__/delivery.test.ts @@ -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, diff --git a/packages/plugin-protocol/src/delivery.ts b/packages/plugin-protocol/src/delivery.ts index 19b4a16..df10654 100644 --- a/packages/plugin-protocol/src/delivery.ts +++ b/packages/plugin-protocol/src/delivery.ts @@ -125,7 +125,7 @@ export interface ListPluginsResponse { schemaVersion: typeof PLUGIN_API_SCHEMA_VERSION; /** 当前页内对请求身份可见的 Plugin 摘要。 */ plugins: VisiblePluginSummary[]; - /** 下一页使用的 Plugin 资源 ID;没有下一页时为 `null`。 */ + /** 下一页使用的不透明游标;调用方只可原样回传,没有下一页时为 `null`。 */ nextCursor: string | null; /** 对请求身份可见的清理通告;服务端每页重复完整下发,缺失时规范化为空数组。 */ removals: PluginRemovalNotice[]; @@ -378,8 +378,8 @@ function parseVisiblePluginDetail(value: unknown, path: string): VisiblePluginDe function nextCursor(value: unknown): string | null { if (value === null) return null; - if (!isValidPluginResourceId(value)) { - throw new PluginProtocolError('response.nextCursor 必须是合法 Plugin ID 或 null'); + if (typeof value !== 'string' || value.length === 0) { + throw new PluginProtocolError('response.nextCursor 必须是非空字符串或 null'); } return value; } From 749b82d2943dfd0a758140f84182c67f6e72ed05 Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sun, 9 Aug 2026 11:33:34 +0800 Subject: [PATCH 2/6] feat(plugin-protocol): define member upload contract Signed-off-by: fmfsaisai --- packages/plugin-protocol/package.json | 3 +- .../src/__tests__/memberUpload.test.ts | 167 ++++++++++ packages/plugin-protocol/src/index.ts | 1 + packages/plugin-protocol/src/memberUpload.ts | 305 ++++++++++++++++++ 4 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-protocol/src/__tests__/memberUpload.test.ts create mode 100644 packages/plugin-protocol/src/memberUpload.ts diff --git a/packages/plugin-protocol/package.json b/packages/plugin-protocol/package.json index b7812aa..c49284d 100644 --- a/packages/plugin-protocol/package.json +++ b/packages/plugin-protocol/package.json @@ -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" }, "scripts": { "build": "tsc --noEmit", diff --git a/packages/plugin-protocol/src/__tests__/memberUpload.test.ts b/packages/plugin-protocol/src/__tests__/memberUpload.test.ts new file mode 100644 index 0000000..52f0d19 --- /dev/null +++ b/packages/plugin-protocol/src/__tests__/memberUpload.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; + +import { + PLUGIN_MEMBER_RELEASE_REVIEW_STATUSES, + PLUGIN_MEMBER_UPLOAD_FAILURE_CODES, + PLUGIN_MEMBER_UPLOAD_MAX_ARCHIVE_BYTES, + PLUGIN_MEMBER_UPLOAD_MAX_UNCOMPRESSED_BYTES, + PLUGIN_MEMBER_UPLOAD_MAX_ZIP_ENTRIES, + PLUGIN_MEMBER_UPLOAD_PUBLISH_SOURCE, + PLUGIN_MEMBER_UPLOAD_STATUSES, + parseCommitPluginMemberUploadRequest, + parseListMyPluginMemberReleasesResponse, + parsePluginMemberUploadStatusResponse, + parsePreparePluginMemberUploadRequest, + parsePreparePluginMemberUploadResponse, + type CommitPluginMemberUploadRequest, + type PreparePluginMemberUploadRequest, +} from '../memberUpload.js'; + +const SHA256 = 'a'.repeat(64); +const NOW = '2026-08-09T03:00:00.000Z'; + +function succeededStatus() { + return { + uploadId: 'upload-1', + status: 'succeeded', + pluginId: 'plugin-1', + releaseId: 'release-1', + ghostId: 'release-helper', + version: '1.0.0', + reviewStatus: 'pending', + failure: null, + }; +} + +describe('member upload contract', () => { + it('exports the Forge package limits as the protocol authority', () => { + expect(PLUGIN_MEMBER_UPLOAD_MAX_ARCHIVE_BYTES).toBe(128 * 1024 * 1024); + expect(PLUGIN_MEMBER_UPLOAD_MAX_UNCOMPRESSED_BYTES).toBe(256 * 1024 * 1024); + expect(PLUGIN_MEMBER_UPLOAD_MAX_ZIP_ENTRIES).toBe(2_048); + }); + + it('keeps source, task states, review states and asynchronous failures unique', () => { + expect(PLUGIN_MEMBER_UPLOAD_PUBLISH_SOURCE).toBe('member_upload'); + expect(PLUGIN_MEMBER_UPLOAD_STATUSES).toEqual([ + 'awaiting_upload', + 'validating', + 'publishing', + 'succeeded', + 'failed', + 'expired', + ]); + expect(PLUGIN_MEMBER_RELEASE_REVIEW_STATUSES).toEqual(['pending', 'approved', 'rejected']); + expect(new Set(PLUGIN_MEMBER_UPLOAD_FAILURE_CODES).size).toBe( + PLUGIN_MEMBER_UPLOAD_FAILURE_CODES.length, + ); + }); + + it('validates prepare size/hash and rejects identity or package metadata overrides', () => { + expect( + parsePreparePluginMemberUploadRequest({ + sizeBytes: PLUGIN_MEMBER_UPLOAD_MAX_ARCHIVE_BYTES, + sha256: SHA256, + }), + ).toEqual({ sizeBytes: PLUGIN_MEMBER_UPLOAD_MAX_ARCHIVE_BYTES, sha256: SHA256 }); + expect(() => + parsePreparePluginMemberUploadRequest({ + sizeBytes: PLUGIN_MEMBER_UPLOAD_MAX_ARCHIVE_BYTES + 1, + sha256: SHA256, + }), + ).toThrow(/sizeBytes/); + expect(() => + parsePreparePluginMemberUploadRequest({ + sizeBytes: 1, + sha256: SHA256.toUpperCase(), + }), + ).toThrow(/sha256/); + expect(() => + parsePreparePluginMemberUploadRequest({ + sizeBytes: 1, + sha256: SHA256, + organizationId: 'forged-org', + }), + ).toThrow(/organizationId/); + expect(parseCommitPluginMemberUploadRequest(undefined)).toEqual({}); + expect(parseCommitPluginMemberUploadRequest({})).toEqual({}); + expect(() => parseCommitPluginMemberUploadRequest({ ghostId: 'forged' })).toThrow(/ghostId/); + }); + + it('validates the private PUT ticket without weakening HTTPS or header safety', () => { + const response = { + uploadId: 'upload-1', + putUrl: 'https://uploads.example.test/private?signature=test', + headers: { 'content-type': 'application/octet-stream', 'x-oss-forbid-overwrite': 'true' }, + expiresAt: NOW, + status: 'awaiting_upload', + }; + expect(parsePreparePluginMemberUploadResponse(response)).toEqual(response); + expect(() => + parsePreparePluginMemberUploadResponse({ + ...response, + putUrl: 'http://uploads.example.test/private', + }), + ).toThrow(/HTTPS/); + expect(() => + parsePreparePluginMemberUploadResponse({ + ...response, + headers: { 'x-test': 'ok\r\nAuthorization: forged' }, + }), + ).toThrow(/换行/); + }); + + it('keeps upload task status separate from Release review status', () => { + expect(parsePluginMemberUploadStatusResponse(succeededStatus())).toEqual(succeededStatus()); + expect( + parsePluginMemberUploadStatusResponse({ + uploadId: 'upload-2', + status: 'failed', + pluginId: null, + releaseId: null, + ghostId: null, + version: null, + reviewStatus: null, + failure: { code: 'PLUGIN_PACKAGE_INVALID', message: 'ghost.json 不合法' }, + }).failure?.code, + ).toBe('PLUGIN_PACKAGE_INVALID'); + expect(() => + parsePluginMemberUploadStatusResponse({ + ...succeededStatus(), + reviewStatus: null, + }), + ).toThrow(/审核状态/); + expect(() => + parsePluginMemberUploadStatusResponse({ + ...succeededStatus(), + status: 'failed', + failure: null, + reviewStatus: null, + }), + ).toThrow(/failure/); + }); + + it('round-trips my-publishes timestamps and opaque cursor', () => { + const cursor = Buffer.from(JSON.stringify({ createdAt: NOW, uploadId: 'upload-1' })).toString( + 'base64url', + ); + const response = { + releases: [{ ...succeededStatus(), createdAt: NOW, updatedAt: NOW }], + nextCursor: cursor, + }; + expect(parseListMyPluginMemberReleasesResponse(response)).toEqual(response); + expect(() => + parseListMyPluginMemberReleasesResponse({ + ...response, + releases: [{ ...response.releases[0], updatedAt: 'tomorrow' }], + }), + ).toThrow(/updatedAt/); + }); + + it('exports request types that cannot carry actor identity', () => { + expectTypeOf().toEqualTypeOf<{ + sizeBytes: number; + sha256: string; + }>(); + expectTypeOf().toEqualTypeOf>(); + }); +}); diff --git a/packages/plugin-protocol/src/index.ts b/packages/plugin-protocol/src/index.ts index e4f2c90..cead958 100644 --- a/packages/plugin-protocol/src/index.ts +++ b/packages/plugin-protocol/src/index.ts @@ -1,2 +1,3 @@ export * from './manifest.js'; export * from './delivery.js'; +export * from './memberUpload.js'; diff --git a/packages/plugin-protocol/src/memberUpload.ts b/packages/plugin-protocol/src/memberUpload.ts new file mode 100644 index 0000000..0478314 --- /dev/null +++ b/packages/plugin-protocol/src/memberUpload.ts @@ -0,0 +1,305 @@ +import { PluginProtocolError } from './delivery.js'; + +/** Organization member Plugin publishing wire contract. */ + +/** Maximum accepted `.cindy` archive size, aligned with Cindy Forge Node packages. */ +export const PLUGIN_MEMBER_UPLOAD_MAX_ARCHIVE_BYTES = 128 * 1024 * 1024; +/** Maximum aggregate uncompressed ZIP size. */ +export const PLUGIN_MEMBER_UPLOAD_MAX_UNCOMPRESSED_BYTES = 256 * 1024 * 1024; +/** Maximum number of ZIP entries. */ +export const PLUGIN_MEMBER_UPLOAD_MAX_ZIP_ENTRIES = 2_048; + +/** Persistent source recorded for an interactive member upload. */ +export const PLUGIN_MEMBER_UPLOAD_PUBLISH_SOURCE = 'member_upload' as const; + +/** Persistent upload-task states returned by commit, status and "my publishes" APIs. */ +export const PLUGIN_MEMBER_UPLOAD_STATUSES = [ + 'awaiting_upload', + 'validating', + 'publishing', + 'succeeded', + 'failed', + 'expired', +] as const; +export type PluginMemberUploadStatus = (typeof PLUGIN_MEMBER_UPLOAD_STATUSES)[number]; + +/** Release review state after a member upload has produced a Release. */ +export const PLUGIN_MEMBER_RELEASE_REVIEW_STATUSES = ['pending', 'approved', 'rejected'] as const; +export type PluginMemberReleaseReviewStatus = + (typeof PLUGIN_MEMBER_RELEASE_REVIEW_STATUSES)[number]; + +/** Stable asynchronous failure codes persisted on an upload task. */ +export const PLUGIN_MEMBER_UPLOAD_FAILURE_CODES = [ + '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', +] as const; +export type PluginMemberUploadFailureCode = (typeof PLUGIN_MEMBER_UPLOAD_FAILURE_CODES)[number]; + +/** Identity and idempotency are carried by verified auth/header context, not this body. */ +export interface PreparePluginMemberUploadRequest { + sizeBytes: number; + sha256: string; +} + +export interface PreparePluginMemberUploadResponse { + uploadId: string; + putUrl: string; + headers: Record; + expiresAt: string; + status: 'awaiting_upload'; +} + +/** ghostId, version and actor identity cannot be overridden by the commit body. */ +export type CommitPluginMemberUploadRequest = Record; + +export interface CommitPluginMemberUploadResponse { + uploadId: string; + status: PluginMemberUploadStatus; +} + +export interface PluginMemberUploadFailure { + code: PluginMemberUploadFailureCode; + /** Safe, user-facing reason. Must not contain internal object keys or audit details. */ + message: string; +} + +export interface PluginMemberUploadStatusResponse { + uploadId: string; + status: PluginMemberUploadStatus; + pluginId: string | null; + releaseId: string | null; + ghostId: string | null; + version: string | null; + /** Null until a Release exists; changes independently after upload success. */ + reviewStatus: PluginMemberReleaseReviewStatus | null; + failure: PluginMemberUploadFailure | null; +} + +export interface PluginMemberReleaseSummary extends PluginMemberUploadStatusResponse { + createdAt: string; + updatedAt: string; +} + +export interface ListMyPluginMemberReleasesResponse { + releases: PluginMemberReleaseSummary[]; + nextCursor: string | null; +} + +function object(value: unknown, path: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new PluginProtocolError(`${path} 必须是对象`); + } + return value as Record; +} + +function string(value: unknown, path: string, max = 256): string { + if (typeof value !== 'string' || value.length === 0 || value.length > max) { + throw new PluginProtocolError(`${path} 必须是 1–${max} 字符的字符串`); + } + return value; +} + +function nullableString(value: unknown, path: string, max = 256): string | null { + return value === null ? null : string(value, path, max); +} + +function sha256(value: unknown, path: string): string { + const text = string(value, path, 64); + if (!/^[a-f0-9]{64}$/.test(text)) { + throw new PluginProtocolError(`${path} 必须是 64 位小写十六进制`); + } + return text; +} + +function isoDate(value: unknown, path: string): string { + const text = string(value, path, 64); + if ( + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(text) || + Number.isNaN(Date.parse(text)) || + new Date(text).toISOString() !== text + ) { + throw new PluginProtocolError(`${path} 必须是 ISO 8601 UTC 时间`); + } + return text; +} + +function uploadStatus(value: unknown, path: string): PluginMemberUploadStatus { + if (!PLUGIN_MEMBER_UPLOAD_STATUSES.includes(value as PluginMemberUploadStatus)) { + throw new PluginProtocolError(`${path} 不在成员上传状态集合中`); + } + return value as PluginMemberUploadStatus; +} + +function reviewStatus(value: unknown, path: string): PluginMemberReleaseReviewStatus | null { + if (value === null) return null; + if (!PLUGIN_MEMBER_RELEASE_REVIEW_STATUSES.includes(value as PluginMemberReleaseReviewStatus)) { + throw new PluginProtocolError(`${path} 不在 Release 审核状态集合中`); + } + return value as PluginMemberReleaseReviewStatus; +} + +function failure(value: unknown, path: string): PluginMemberUploadFailure | null { + if (value === null) return null; + const raw = object(value, path); + if (!PLUGIN_MEMBER_UPLOAD_FAILURE_CODES.includes(raw.code as PluginMemberUploadFailureCode)) { + throw new PluginProtocolError(`${path}.code 不在成员上传失败码集合中`); + } + return { + code: raw.code as PluginMemberUploadFailureCode, + message: string(raw.message, `${path}.message`, 1_000), + }; +} + +/** Validates the prepare body and rejects actor/package metadata overrides. */ +export function parsePreparePluginMemberUploadRequest( + value: unknown, +): PreparePluginMemberUploadRequest { + const raw = object(value, 'request'); + const unknownKeys = Object.keys(raw).filter((key) => key !== 'sizeBytes' && key !== 'sha256'); + if (unknownKeys.length > 0) { + throw new PluginProtocolError(`request 不接受字段: ${unknownKeys.join(', ')}`); + } + if ( + typeof raw.sizeBytes !== 'number' || + !Number.isSafeInteger(raw.sizeBytes) || + raw.sizeBytes <= 0 || + raw.sizeBytes > PLUGIN_MEMBER_UPLOAD_MAX_ARCHIVE_BYTES + ) { + throw new PluginProtocolError( + `request.sizeBytes 必须是 1–${PLUGIN_MEMBER_UPLOAD_MAX_ARCHIVE_BYTES} 的安全整数`, + ); + } + return { sizeBytes: raw.sizeBytes, sha256: sha256(raw.sha256, 'request.sha256') }; +} + +/** Validates that commit carries no package, path or identity override fields. */ +export function parseCommitPluginMemberUploadRequest( + value: unknown, +): CommitPluginMemberUploadRequest { + if (value === undefined || value === null) return {}; + const raw = object(value, 'request'); + const keys = Object.keys(raw); + if (keys.length > 0) { + throw new PluginProtocolError(`request 不接受字段: ${keys.join(', ')}`); + } + return {}; +} + +export function parsePreparePluginMemberUploadResponse( + value: unknown, +): PreparePluginMemberUploadResponse { + const raw = object(value, 'response'); + if (raw.status !== 'awaiting_upload') { + throw new PluginProtocolError('response.status 必须是 awaiting_upload'); + } + const putUrl = string(raw.putUrl, 'response.putUrl', 8_192); + try { + if (new URL(putUrl).protocol !== 'https:') throw new Error('unsupported protocol'); + } catch { + throw new PluginProtocolError('response.putUrl 必须是 HTTPS URL'); + } + const rawHeaders = object(raw.headers, 'response.headers'); + const headers: Record = {}; + for (const [name, value] of Object.entries(rawHeaders)) { + if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) { + throw new PluginProtocolError('response.headers 包含非法 HTTP header 名'); + } + const headerValue = string(value, `response.headers.${name}`, 8_192); + if (/[\r\n]/.test(headerValue)) { + throw new PluginProtocolError(`response.headers.${name} 不得包含换行`); + } + headers[name] = headerValue; + } + return { + uploadId: string(raw.uploadId, 'response.uploadId', 128), + putUrl, + headers, + expiresAt: isoDate(raw.expiresAt, 'response.expiresAt'), + status: 'awaiting_upload', + }; +} + +export function parseCommitPluginMemberUploadResponse( + value: unknown, +): CommitPluginMemberUploadResponse { + const raw = object(value, 'response'); + return { + uploadId: string(raw.uploadId, 'response.uploadId', 128), + status: uploadStatus(raw.status, 'response.status'), + }; +} + +export function parsePluginMemberUploadStatusResponse( + value: unknown, +): PluginMemberUploadStatusResponse { + const raw = object(value, 'response'); + const status = uploadStatus(raw.status, 'response.status'); + const parsedReviewStatus = reviewStatus(raw.reviewStatus, 'response.reviewStatus'); + const parsedFailure = failure(raw.failure, 'response.failure'); + const result: PluginMemberUploadStatusResponse = { + uploadId: string(raw.uploadId, 'response.uploadId', 128), + status, + pluginId: nullableString(raw.pluginId, 'response.pluginId', 128), + releaseId: nullableString(raw.releaseId, 'response.releaseId', 128), + ghostId: nullableString(raw.ghostId, 'response.ghostId', 32), + version: nullableString(raw.version, 'response.version', 32), + reviewStatus: parsedReviewStatus, + failure: parsedFailure, + }; + if (status === 'succeeded') { + if ( + result.pluginId === null || + result.releaseId === null || + result.ghostId === null || + result.version === null || + result.reviewStatus === null + ) { + throw new PluginProtocolError('succeeded 状态必须包含 Release 标识与审核状态'); + } + if (result.failure !== null) { + throw new PluginProtocolError('succeeded 状态不得包含 failure'); + } + } else if (result.reviewStatus !== null) { + throw new PluginProtocolError('仅 succeeded 状态可包含 reviewStatus'); + } + if (status === 'failed' && result.failure === null) { + throw new PluginProtocolError('failed 状态必须包含 failure'); + } + if (status !== 'failed' && result.failure !== null) { + throw new PluginProtocolError('仅 failed 状态可包含 failure'); + } + return result; +} + +function parseMemberReleaseSummary(value: unknown, path: string): PluginMemberReleaseSummary { + const raw = object(value, path); + const status = parsePluginMemberUploadStatusResponse(raw); + return { + ...status, + createdAt: isoDate(raw.createdAt, `${path}.createdAt`), + updatedAt: isoDate(raw.updatedAt, `${path}.updatedAt`), + }; +} + +export function parseListMyPluginMemberReleasesResponse( + value: unknown, +): ListMyPluginMemberReleasesResponse { + const raw = object(value, 'response'); + if (!Array.isArray(raw.releases)) { + throw new PluginProtocolError('response.releases 必须是数组'); + } + return { + releases: raw.releases.map((release, index) => + parseMemberReleaseSummary(release, `response.releases[${index}]`), + ), + nextCursor: + raw.nextCursor === null ? null : string(raw.nextCursor, 'response.nextCursor', 4_096), + }; +} From 3331c354b0c3f00597016c6adf08de503e121664 Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sun, 9 Aug 2026 12:13:34 +0800 Subject: [PATCH 3/6] refactor(plugin-protocol): share parsing primitives and unify cursor rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽出包内共享解析原语 internal/parse.ts(object/string/isoDate/sha256/ httpsUrl/nextCursor),delivery 与 memberUpload 不再各持一份副本 - 不透明分页游标统一为 1–4096 字符单一规则,消除同包两套游标约束 - isoDate 消除双重日期解析;memberUpload 枚举校验收敛为 enumValue - 删除被通用 failure 规则覆盖的 succeeded 冗余分支 - 注明成员上传限额仅约束成员通道,接线前 plugin-server 既有限额不变 Co-Authored-By: Claude Signed-off-by: fmfsaisai --- packages/plugin-protocol/src/delivery.ts | 91 ++++-------------- .../plugin-protocol/src/internal/parse.ts | 67 +++++++++++++ packages/plugin-protocol/src/memberUpload.ts | 96 ++++++++----------- 3 files changed, 124 insertions(+), 130 deletions(-) create mode 100644 packages/plugin-protocol/src/internal/parse.ts diff --git a/packages/plugin-protocol/src/delivery.ts b/packages/plugin-protocol/src/delivery.ts index df10654..f251761 100644 --- a/packages/plugin-protocol/src/delivery.ts +++ b/packages/plugin-protocol/src/delivery.ts @@ -4,6 +4,17 @@ import { validateGhostManifest, type GhostManifest, } from './manifest.js'; +import { + PluginProtocolError, + httpsUrl, + isoDate, + nextCursor, + object, + sha256, + string, +} from './internal/parse.js'; + +export { PluginProtocolError } from './internal/parse.js'; /** Plugin 客户端 HTTP list/detail envelope 版本;与 ghost.json 版本独立演进。 */ export const PLUGIN_API_SCHEMA_VERSION = 2 as const; @@ -151,45 +162,11 @@ export interface PluginDownloadResponse { sizeBytes: number; } -/** Plugin HTTP 响应违反共享契约时由解析器抛出的错误。 */ -export class PluginProtocolError extends Error { - constructor(message: string) { - super(message); - this.name = 'PluginProtocolError'; - } -} - -function object(value: unknown, path: string): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new PluginProtocolError(`${path} 必须是对象`); - } - return value as Record; -} - -function string(value: unknown, path: string, max = 256): string { - if (typeof value !== 'string' || value.length === 0 || value.length > max) { - throw new PluginProtocolError(`${path} 必须是 1–${max} 字符的字符串`); - } - return value; -} - /** 判断值是否符合 plugin-server 当前使用的 Plugin 资源 ID 形状。 */ export function isValidPluginResourceId(value: unknown): value is string { return typeof value === 'string' && /^c[a-z0-9]{24}$/.test(value); } -function isoDate(value: unknown, path: string): string { - const text = string(value, path, 64); - if ( - !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(text) || - Number.isNaN(Date.parse(text)) || - new Date(text).toISOString() !== text - ) { - throw new PluginProtocolError(`${path} 必须是 ISO 8601 UTC 时间`); - } - return text; -} - function parseIconMetadata(value: unknown, path: string): PluginIconMetadata | null { // 老的 v2 服务端尚未提供 icon 字段时,客户端继续使用本地兜底图标。 if (value === null || value === undefined) return null; @@ -198,10 +175,6 @@ function parseIconMetadata(value: unknown, path: string): PluginIconMetadata | n if (!/^image\/[a-z0-9.+-]+$/i.test(mimeType)) { throw new PluginProtocolError(`${path}.mimeType 必须是 image/* MIME 类型`); } - const sha256 = string(raw.sha256, `${path}.sha256`, 64); - if (!/^[a-f0-9]{64}$/.test(sha256)) { - throw new PluginProtocolError(`${path}.sha256 必须是 64 位小写十六进制`); - } if ( typeof raw.sizeBytes !== 'number' || !Number.isSafeInteger(raw.sizeBytes) || @@ -209,18 +182,11 @@ function parseIconMetadata(value: unknown, path: string): PluginIconMetadata | n ) { throw new PluginProtocolError(`${path}.sizeBytes 必须是正整数`); } - const url = string(raw.url, `${path}.url`, 8192); - try { - const parsed = new URL(url); - if (parsed.protocol !== 'https:') throw new Error('unsupported protocol'); - } catch { - throw new PluginProtocolError(`${path}.url 必须是 HTTPS URL`); - } return { mimeType, - sha256, + sha256: sha256(raw.sha256, `${path}.sha256`), sizeBytes: raw.sizeBytes, - url, + url: httpsUrl(raw.url, `${path}.url`), expiresAt: isoDate(raw.expiresAt, `${path}.expiresAt`), }; } @@ -228,10 +194,6 @@ function parseIconMetadata(value: unknown, path: string): PluginIconMetadata | n function parseReleaseSummary(value: unknown, path: string): PluginReleaseSummary { const raw = object(value, path); const version = string(raw.version, `${path}.version`, 32); - const sha256 = string(raw.sha256, `${path}.sha256`, 64); - if (!/^[a-f0-9]{64}$/.test(sha256)) { - throw new PluginProtocolError(`${path}.sha256 必须是 64 位小写十六进制`); - } if ( typeof raw.sizeBytes !== 'number' || !Number.isSafeInteger(raw.sizeBytes) || @@ -242,7 +204,7 @@ function parseReleaseSummary(value: unknown, path: string): PluginReleaseSummary return { id: string(raw.id, `${path}.id`, 128), version, - sha256, + sha256: sha256(raw.sha256, `${path}.sha256`), sizeBytes: raw.sizeBytes, publishedAt: isoDate(raw.publishedAt, `${path}.publishedAt`), icon: parseIconMetadata(raw.icon, `${path}.icon`), @@ -376,14 +338,6 @@ function parseVisiblePluginDetail(value: unknown, path: string): VisiblePluginDe }; } -function nextCursor(value: unknown): string | null { - if (value === null) return null; - if (typeof value !== 'string' || value.length === 0) { - throw new PluginProtocolError('response.nextCursor 必须是非空字符串或 null'); - } - return value; -} - function parseRemovalNotice(value: unknown, path: string): PluginRemovalNotice | null { const raw = object(value, path); if (!isValidPluginResourceId(raw.pluginId)) { @@ -435,7 +389,7 @@ export function parseListPluginsResponse(value: unknown): ListPluginsResponse { return { schemaVersion: PLUGIN_API_SCHEMA_VERSION, plugins: raw.plugins.map(parseVisiblePluginSummary), - nextCursor: nextCursor(raw.nextCursor), + nextCursor: nextCursor(raw.nextCursor, 'response.nextCursor'), removals: parseRemovals(raw.removals), }; } @@ -465,17 +419,6 @@ export function parseGetPluginResponse(value: unknown): GetPluginResponse { */ export function parsePluginDownloadResponse(value: unknown): PluginDownloadResponse { const raw = object(value, 'response'); - const url = string(raw.url, 'response.url', 8192); - try { - const parsed = new URL(url); - if (parsed.protocol !== 'https:') throw new Error('unsupported protocol'); - } catch { - throw new PluginProtocolError('response.url 必须是 HTTPS URL'); - } - const sha256 = string(raw.sha256, 'response.sha256', 64); - if (!/^[a-f0-9]{64}$/.test(sha256)) { - throw new PluginProtocolError('response.sha256 必须是 64 位小写十六进制'); - } if ( typeof raw.sizeBytes !== 'number' || !Number.isSafeInteger(raw.sizeBytes) || @@ -484,9 +427,9 @@ export function parsePluginDownloadResponse(value: unknown): PluginDownloadRespo throw new PluginProtocolError('response.sizeBytes 必须是正整数'); } return { - url, + url: httpsUrl(raw.url, 'response.url'), expiresAt: isoDate(raw.expiresAt, 'response.expiresAt'), - sha256, + sha256: sha256(raw.sha256, 'response.sha256'), sizeBytes: raw.sizeBytes, }; } diff --git a/packages/plugin-protocol/src/internal/parse.ts b/packages/plugin-protocol/src/internal/parse.ts new file mode 100644 index 0000000..68a55ce --- /dev/null +++ b/packages/plugin-protocol/src/internal/parse.ts @@ -0,0 +1,67 @@ +/** plugin-protocol 包内共享的响应解析原语;仅供包内模块使用,不进入对外导出面。 */ + +/** Plugin HTTP 响应违反共享契约时由解析器抛出的错误。 */ +export class PluginProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = 'PluginProtocolError'; + } +} + +export function object(value: unknown, path: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new PluginProtocolError(`${path} 必须是对象`); + } + return value as Record; +} + +export function string(value: unknown, path: string, max = 256): string { + if (typeof value !== 'string' || value.length === 0 || value.length > max) { + throw new PluginProtocolError(`${path} 必须是 1–${max} 字符的字符串`); + } + return value; +} + +export function isoDate(value: unknown, path: string): string { + const text = string(value, path, 64); + const parsed = new Date(text); + if ( + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(text) || + Number.isNaN(parsed.getTime()) || + parsed.toISOString() !== text + ) { + throw new PluginProtocolError(`${path} 必须是 ISO 8601 UTC 时间`); + } + return text; +} + +export function sha256(value: unknown, path: string): string { + const text = string(value, path, 64); + if (!/^[a-f0-9]{64}$/.test(text)) { + throw new PluginProtocolError(`${path} 必须是 64 位小写十六进制`); + } + return text; +} + +export function httpsUrl(value: unknown, path: string, max = 8_192): string { + const text = string(value, path, max); + let parsed: URL; + try { + parsed = new URL(text); + } catch { + throw new PluginProtocolError(`${path} 必须是 HTTPS URL`); + } + if (parsed.protocol !== 'https:') { + throw new PluginProtocolError(`${path} 必须是 HTTPS URL`); + } + return text; +} + +/** 列表接口的不透明分页游标:非空、有界,客户端原样回传,不解析内部结构。 */ +export function nextCursor(value: unknown, path: string): string | null { + if (value === null) return null; + if (typeof value !== 'string' || value.length === 0 || value.length > 4_096) { + throw new PluginProtocolError(`${path} 必须是 1–4096 字符的字符串或 null`); + } + return value; +} diff --git a/packages/plugin-protocol/src/memberUpload.ts b/packages/plugin-protocol/src/memberUpload.ts index 0478314..98b3c40 100644 --- a/packages/plugin-protocol/src/memberUpload.ts +++ b/packages/plugin-protocol/src/memberUpload.ts @@ -1,6 +1,20 @@ -import { PluginProtocolError } from './delivery.js'; +import { + PluginProtocolError, + httpsUrl, + isoDate, + nextCursor, + object, + sha256, + string, +} from './internal/parse.js'; -/** Organization member Plugin publishing wire contract. */ +/** + * Organization member Plugin publishing wire contract. + * + * The limits below govern the member-upload channel only. Until plugin-server + * wires this channel in, its existing publisher-path validation limits remain + * authoritative for the paths it already serves. + */ /** Maximum accepted `.cindy` archive size, aligned with Cindy Forge Node packages. */ export const PLUGIN_MEMBER_UPLOAD_MAX_ARCHIVE_BYTES = 128 * 1024 * 1024; @@ -92,67 +106,46 @@ export interface ListMyPluginMemberReleasesResponse { nextCursor: string | null; } -function object(value: unknown, path: string): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new PluginProtocolError(`${path} 必须是对象`); - } - return value as Record; -} - -function string(value: unknown, path: string, max = 256): string { - if (typeof value !== 'string' || value.length === 0 || value.length > max) { - throw new PluginProtocolError(`${path} 必须是 1–${max} 字符的字符串`); - } - return value; -} - function nullableString(value: unknown, path: string, max = 256): string | null { return value === null ? null : string(value, path, max); } -function sha256(value: unknown, path: string): string { - const text = string(value, path, 64); - if (!/^[a-f0-9]{64}$/.test(text)) { - throw new PluginProtocolError(`${path} 必须是 64 位小写十六进制`); - } - return text; -} - -function isoDate(value: unknown, path: string): string { - const text = string(value, path, 64); - if ( - !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(text) || - Number.isNaN(Date.parse(text)) || - new Date(text).toISOString() !== text - ) { - throw new PluginProtocolError(`${path} 必须是 ISO 8601 UTC 时间`); +function enumValue( + values: readonly T[], + value: unknown, + path: string, + description: string, +): T { + if (typeof value !== 'string' || !values.includes(value as T)) { + throw new PluginProtocolError(`${path} ${description}`); } - return text; + return value as T; } function uploadStatus(value: unknown, path: string): PluginMemberUploadStatus { - if (!PLUGIN_MEMBER_UPLOAD_STATUSES.includes(value as PluginMemberUploadStatus)) { - throw new PluginProtocolError(`${path} 不在成员上传状态集合中`); - } - return value as PluginMemberUploadStatus; + return enumValue(PLUGIN_MEMBER_UPLOAD_STATUSES, value, path, '不在成员上传状态集合中'); } function reviewStatus(value: unknown, path: string): PluginMemberReleaseReviewStatus | null { if (value === null) return null; - if (!PLUGIN_MEMBER_RELEASE_REVIEW_STATUSES.includes(value as PluginMemberReleaseReviewStatus)) { - throw new PluginProtocolError(`${path} 不在 Release 审核状态集合中`); - } - return value as PluginMemberReleaseReviewStatus; + return enumValue( + PLUGIN_MEMBER_RELEASE_REVIEW_STATUSES, + value, + path, + '不在 Release 审核状态集合中', + ); } function failure(value: unknown, path: string): PluginMemberUploadFailure | null { if (value === null) return null; const raw = object(value, path); - if (!PLUGIN_MEMBER_UPLOAD_FAILURE_CODES.includes(raw.code as PluginMemberUploadFailureCode)) { - throw new PluginProtocolError(`${path}.code 不在成员上传失败码集合中`); - } return { - code: raw.code as PluginMemberUploadFailureCode, + code: enumValue( + PLUGIN_MEMBER_UPLOAD_FAILURE_CODES, + raw.code, + `${path}.code`, + '不在成员上传失败码集合中', + ), message: string(raw.message, `${path}.message`, 1_000), }; } @@ -199,12 +192,7 @@ export function parsePreparePluginMemberUploadResponse( if (raw.status !== 'awaiting_upload') { throw new PluginProtocolError('response.status 必须是 awaiting_upload'); } - const putUrl = string(raw.putUrl, 'response.putUrl', 8_192); - try { - if (new URL(putUrl).protocol !== 'https:') throw new Error('unsupported protocol'); - } catch { - throw new PluginProtocolError('response.putUrl 必须是 HTTPS URL'); - } + const putUrl = httpsUrl(raw.putUrl, 'response.putUrl'); const rawHeaders = object(raw.headers, 'response.headers'); const headers: Record = {}; for (const [name, value] of Object.entries(rawHeaders)) { @@ -263,9 +251,6 @@ export function parsePluginMemberUploadStatusResponse( ) { throw new PluginProtocolError('succeeded 状态必须包含 Release 标识与审核状态'); } - if (result.failure !== null) { - throw new PluginProtocolError('succeeded 状态不得包含 failure'); - } } else if (result.reviewStatus !== null) { throw new PluginProtocolError('仅 succeeded 状态可包含 reviewStatus'); } @@ -299,7 +284,6 @@ export function parseListMyPluginMemberReleasesResponse( releases: raw.releases.map((release, index) => parseMemberReleaseSummary(release, `response.releases[${index}]`), ), - nextCursor: - raw.nextCursor === null ? null : string(raw.nextCursor, 'response.nextCursor', 4_096), + nextCursor: nextCursor(raw.nextCursor, 'response.nextCursor'), }; } From d97fe3dea7d58bc927d17ab8412742c73c3524d2 Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sun, 9 Aug 2026 14:31:27 +0800 Subject: [PATCH 4/6] fix(plugin-protocol): address member upload review Document the public member-upload contract, preserve nested parser paths, and validate upload ghost IDs with the manifest rule. Signed-off-by: fmfsaisai Co-Authored-By: Claude --- docs/plugin-protocol.md | 140 ++++++++++++++++-- .../src/__tests__/memberUpload.test.ts | 42 ++++++ packages/plugin-protocol/src/memberUpload.ts | 38 +++-- 3 files changed, 196 insertions(+), 24 deletions(-) diff --git a/docs/plugin-protocol.md b/docs/plugin-protocol.md index 97588dc..b6e8937 100644 --- a/docs/plugin-protocol.md +++ b/docs/plugin-protocol.md @@ -21,6 +21,7 @@ import { PluginProtocolError, parseGetPluginResponse, parseListPluginsResponse, + parsePluginMemberUploadStatusResponse, parsePluginDownloadResponse, validateGhostManifest, type GhostManifest, @@ -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 @@ -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` 标识服务端管理的安装实例。 @@ -366,6 +368,10 @@ try { - Plugin HTTP list/detail envelope 当前只接受 `PLUGIN_API_SCHEMA_VERSION=2`;v2 将 `global` 替换为 `public` 并新增 `personal`; - 两个版本号独立演进,不能相互替代。 +v2 的 `nextCursor` 是服务端生成、客户端原样回传的不透明字符串。plugin-server 在本协议修复前已经使用 +`sortOrder + Plugin.id` 的复合游标,同时兼容接收滚动期旧 Plugin ID;因此解析器接受非空、有界字符串是对 +既有 v2 线上响应的兼容修复,不是新的 envelope 形状,也不提升 `PLUGIN_API_SCHEMA_VERSION`。 + 校验器对未知字段保持宽容,对已知字段和值严格校验。新增可选字段不要求服务端和 Desktop 同时发布;破坏性格式变化必须提升对应 schema version。 未知字段只用于前向兼容,不会出现在校验后的返回对象中。消费方不得依赖当前版本未声明的字段。 @@ -382,6 +388,114 @@ 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 的请求体。 + +| 操作 | HTTP 契约 | DTO | +| ------------ | ---------------------------------------------- | ------------------------------------------------------------------------ | +| prepare | `POST /api/publisher/uploads` | `PreparePluginMemberUploadRequest` → `PreparePluginMemberUploadResponse` | +| 上传文件 | `PUT ` | 原始 `.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; + 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; + +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` 规则。 +- `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 diff --git a/packages/plugin-protocol/src/__tests__/memberUpload.test.ts b/packages/plugin-protocol/src/__tests__/memberUpload.test.ts index 52f0d19..f32bdb7 100644 --- a/packages/plugin-protocol/src/__tests__/memberUpload.test.ts +++ b/packages/plugin-protocol/src/__tests__/memberUpload.test.ts @@ -140,6 +140,48 @@ describe('member upload contract', () => { ).toThrow(/failure/); }); + it('rejects invalid ghost IDs in status and my-publishes responses', () => { + expect(() => + parsePluginMemberUploadStatusResponse({ + ...succeededStatus(), + ghostId: 'Bad Ghost Id', + }), + ).toThrow('response.ghostId 不合法'); + + expect(() => + parseListMyPluginMemberReleasesResponse({ + releases: [ + { + ...succeededStatus(), + ghostId: 'con', + createdAt: NOW, + updatedAt: NOW, + }, + ], + nextCursor: null, + }), + ).toThrow('response.releases[0].ghostId 不合法'); + }); + + it('preserves top-level paths and reports nested my-publishes paths', () => { + expect(() => + parsePluginMemberUploadStatusResponse({ + ...succeededStatus(), + status: 'unknown', + }), + ).toThrow('response.status 不在成员上传状态集合中'); + + expect(() => + parseListMyPluginMemberReleasesResponse({ + releases: [ + { ...succeededStatus(), createdAt: NOW, updatedAt: NOW }, + { ...succeededStatus(), status: 'unknown', createdAt: NOW, updatedAt: NOW }, + ], + nextCursor: null, + }), + ).toThrow('response.releases[1].status 不在成员上传状态集合中'); + }); + it('round-trips my-publishes timestamps and opaque cursor', () => { const cursor = Buffer.from(JSON.stringify({ createdAt: NOW, uploadId: 'upload-1' })).toString( 'base64url', diff --git a/packages/plugin-protocol/src/memberUpload.ts b/packages/plugin-protocol/src/memberUpload.ts index 98b3c40..e8ddd1f 100644 --- a/packages/plugin-protocol/src/memberUpload.ts +++ b/packages/plugin-protocol/src/memberUpload.ts @@ -7,6 +7,7 @@ import { sha256, string, } from './internal/parse.js'; +import { isValidGhostId } from './manifest.js'; /** * Organization member Plugin publishing wire contract. @@ -110,6 +111,14 @@ function nullableString(value: unknown, path: string, max = 256): string | null return value === null ? null : string(value, path, max); } +function nullableGhostId(value: unknown, path: string): string | null { + if (value === null) return null; + if (!isValidGhostId(value)) { + throw new PluginProtocolError(`${path} 不合法`); + } + return value; +} + function enumValue( values: readonly T[], value: unknown, @@ -224,20 +233,21 @@ export function parseCommitPluginMemberUploadResponse( }; } -export function parsePluginMemberUploadStatusResponse( +function parsePluginMemberUploadStatusResponseAtPath( value: unknown, + path: string, ): PluginMemberUploadStatusResponse { - const raw = object(value, 'response'); - const status = uploadStatus(raw.status, 'response.status'); - const parsedReviewStatus = reviewStatus(raw.reviewStatus, 'response.reviewStatus'); - const parsedFailure = failure(raw.failure, 'response.failure'); + const raw = object(value, path); + const status = uploadStatus(raw.status, `${path}.status`); + const parsedReviewStatus = reviewStatus(raw.reviewStatus, `${path}.reviewStatus`); + const parsedFailure = failure(raw.failure, `${path}.failure`); const result: PluginMemberUploadStatusResponse = { - uploadId: string(raw.uploadId, 'response.uploadId', 128), + uploadId: string(raw.uploadId, `${path}.uploadId`, 128), status, - pluginId: nullableString(raw.pluginId, 'response.pluginId', 128), - releaseId: nullableString(raw.releaseId, 'response.releaseId', 128), - ghostId: nullableString(raw.ghostId, 'response.ghostId', 32), - version: nullableString(raw.version, 'response.version', 32), + pluginId: nullableString(raw.pluginId, `${path}.pluginId`, 128), + releaseId: nullableString(raw.releaseId, `${path}.releaseId`, 128), + ghostId: nullableGhostId(raw.ghostId, `${path}.ghostId`), + version: nullableString(raw.version, `${path}.version`, 32), reviewStatus: parsedReviewStatus, failure: parsedFailure, }; @@ -263,9 +273,15 @@ export function parsePluginMemberUploadStatusResponse( return result; } +export function parsePluginMemberUploadStatusResponse( + value: unknown, +): PluginMemberUploadStatusResponse { + return parsePluginMemberUploadStatusResponseAtPath(value, 'response'); +} + function parseMemberReleaseSummary(value: unknown, path: string): PluginMemberReleaseSummary { const raw = object(value, path); - const status = parsePluginMemberUploadStatusResponse(raw); + const status = parsePluginMemberUploadStatusResponseAtPath(raw, path); return { ...status, createdAt: isoDate(raw.createdAt, `${path}.createdAt`), From c81235526dd979ec504c7e5ea26288051ef67e1f Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sun, 9 Aug 2026 14:40:30 +0800 Subject: [PATCH 5/6] docs(plugin-protocol): remove duplicate cursor details Co-Authored-By: Claude Signed-off-by: fmfsaisai --- docs/plugin-protocol.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/plugin-protocol.md b/docs/plugin-protocol.md index b6e8937..f44a392 100644 --- a/docs/plugin-protocol.md +++ b/docs/plugin-protocol.md @@ -368,9 +368,7 @@ try { - Plugin HTTP list/detail envelope 当前只接受 `PLUGIN_API_SCHEMA_VERSION=2`;v2 将 `global` 替换为 `public` 并新增 `personal`; - 两个版本号独立演进,不能相互替代。 -v2 的 `nextCursor` 是服务端生成、客户端原样回传的不透明字符串。plugin-server 在本协议修复前已经使用 -`sortOrder + Plugin.id` 的复合游标,同时兼容接收滚动期旧 Plugin ID;因此解析器接受非空、有界字符串是对 -既有 v2 线上响应的兼容修复,不是新的 envelope 形状,也不提升 `PLUGIN_API_SCHEMA_VERSION`。 +v2 的 `nextCursor` 语义见上文字段表;解析器接受这种非空、有界字符串,是对既有 v2 线上响应的兼容修复,不是新的 envelope 形状,也不提升 `PLUGIN_API_SCHEMA_VERSION`。 校验器对未知字段保持宽容,对已知字段和值严格校验。新增可选字段不要求服务端和 Desktop 同时发布;破坏性格式变化必须提升对应 schema version。 From 6639df196c0d4dc2cf820308ccf84d40d57be13f Mon Sep 17 00:00:00 2001 From: fmfsaisai Date: Sun, 9 Aug 2026 15:00:32 +0800 Subject: [PATCH 6/6] fix(plugin-protocol): validate member upload identities Signed-off-by: fmfsaisai Co-Authored-By: Claude --- docs/plugin-protocol.md | 11 ++++++++ .../src/__tests__/memberUpload.test.ts | 26 ++++++++++++++++++- .../src/__tests__/memberUploadExports.test.ts | 17 ++++++++++++ packages/plugin-protocol/src/delivery.ts | 7 ++--- .../src/internal/pluginResourceId.ts | 4 +++ packages/plugin-protocol/src/memberUpload.ts | 13 +++++++++- 6 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 packages/plugin-protocol/src/__tests__/memberUploadExports.test.ts create mode 100644 packages/plugin-protocol/src/internal/pluginResourceId.ts diff --git a/docs/plugin-protocol.md b/docs/plugin-protocol.md index f44a392..6da401e 100644 --- a/docs/plugin-protocol.md +++ b/docs/plugin-protocol.md @@ -392,6 +392,16 @@ HTTP 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` | @@ -483,6 +493,7 @@ interface ListMyPluginMemberReleasesResponse { `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 自报覆盖。 diff --git a/packages/plugin-protocol/src/__tests__/memberUpload.test.ts b/packages/plugin-protocol/src/__tests__/memberUpload.test.ts index f32bdb7..59a705c 100644 --- a/packages/plugin-protocol/src/__tests__/memberUpload.test.ts +++ b/packages/plugin-protocol/src/__tests__/memberUpload.test.ts @@ -19,12 +19,13 @@ import { const SHA256 = 'a'.repeat(64); const NOW = '2026-08-09T03:00:00.000Z'; +const PLUGIN_ID = `c${'p'.repeat(24)}`; function succeededStatus() { return { uploadId: 'upload-1', status: 'succeeded', - pluginId: 'plugin-1', + pluginId: PLUGIN_ID, releaseId: 'release-1', ghostId: 'release-helper', version: '1.0.0', @@ -163,6 +164,29 @@ describe('member upload contract', () => { ).toThrow('response.releases[0].ghostId 不合法'); }); + it('rejects invalid Plugin resource IDs in status and my-publishes responses', () => { + expect(() => + parsePluginMemberUploadStatusResponse({ + ...succeededStatus(), + pluginId: 'plugin-1', + }), + ).toThrow('response.pluginId 不合法'); + + expect(() => + parseListMyPluginMemberReleasesResponse({ + releases: [ + { + ...succeededStatus(), + pluginId: 'plugin-1', + createdAt: NOW, + updatedAt: NOW, + }, + ], + nextCursor: null, + }), + ).toThrow('response.releases[0].pluginId 不合法'); + }); + it('preserves top-level paths and reports nested my-publishes paths', () => { expect(() => parsePluginMemberUploadStatusResponse({ diff --git a/packages/plugin-protocol/src/__tests__/memberUploadExports.test.ts b/packages/plugin-protocol/src/__tests__/memberUploadExports.test.ts new file mode 100644 index 0000000..d1b94e2 --- /dev/null +++ b/packages/plugin-protocol/src/__tests__/memberUploadExports.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { PluginProtocolError as DeliveryPluginProtocolError } from '@cindy/plugin-protocol/delivery'; +import { + PluginProtocolError as MemberUploadPluginProtocolError, + parsePluginMemberUploadStatusResponse, +} from '@cindy/plugin-protocol/member-upload'; + +describe('member-upload public subpath', () => { + it('re-exports the shared PluginProtocolError class identity', () => { + expect(MemberUploadPluginProtocolError).toBe(DeliveryPluginProtocolError); + + expect(() => parsePluginMemberUploadStatusResponse(null)).toThrow( + MemberUploadPluginProtocolError, + ); + }); +}); diff --git a/packages/plugin-protocol/src/delivery.ts b/packages/plugin-protocol/src/delivery.ts index f251761..6a631ca 100644 --- a/packages/plugin-protocol/src/delivery.ts +++ b/packages/plugin-protocol/src/delivery.ts @@ -13,8 +13,10 @@ import { sha256, string, } from './internal/parse.js'; +import { isValidPluginResourceId } from './internal/pluginResourceId.js'; export { PluginProtocolError } from './internal/parse.js'; +export { isValidPluginResourceId } from './internal/pluginResourceId.js'; /** Plugin 客户端 HTTP list/detail envelope 版本;与 ghost.json 版本独立演进。 */ export const PLUGIN_API_SCHEMA_VERSION = 2 as const; @@ -162,11 +164,6 @@ export interface PluginDownloadResponse { sizeBytes: number; } -/** 判断值是否符合 plugin-server 当前使用的 Plugin 资源 ID 形状。 */ -export function isValidPluginResourceId(value: unknown): value is string { - return typeof value === 'string' && /^c[a-z0-9]{24}$/.test(value); -} - function parseIconMetadata(value: unknown, path: string): PluginIconMetadata | null { // 老的 v2 服务端尚未提供 icon 字段时,客户端继续使用本地兜底图标。 if (value === null || value === undefined) return null; diff --git a/packages/plugin-protocol/src/internal/pluginResourceId.ts b/packages/plugin-protocol/src/internal/pluginResourceId.ts new file mode 100644 index 0000000..f884010 --- /dev/null +++ b/packages/plugin-protocol/src/internal/pluginResourceId.ts @@ -0,0 +1,4 @@ +/** 判断值是否符合 plugin-server 当前使用的 Plugin 资源 ID 形状。 */ +export function isValidPluginResourceId(value: unknown): value is string { + return typeof value === 'string' && /^c[a-z0-9]{24}$/.test(value); +} diff --git a/packages/plugin-protocol/src/memberUpload.ts b/packages/plugin-protocol/src/memberUpload.ts index e8ddd1f..b244227 100644 --- a/packages/plugin-protocol/src/memberUpload.ts +++ b/packages/plugin-protocol/src/memberUpload.ts @@ -7,8 +7,11 @@ import { sha256, string, } from './internal/parse.js'; +import { isValidPluginResourceId } from './internal/pluginResourceId.js'; import { isValidGhostId } from './manifest.js'; +export { PluginProtocolError } from './internal/parse.js'; + /** * Organization member Plugin publishing wire contract. * @@ -119,6 +122,14 @@ function nullableGhostId(value: unknown, path: string): string | null { return value; } +function nullablePluginId(value: unknown, path: string): string | null { + if (value === null) return null; + if (!isValidPluginResourceId(value)) { + throw new PluginProtocolError(`${path} 不合法`); + } + return value; +} + function enumValue( values: readonly T[], value: unknown, @@ -244,7 +255,7 @@ function parsePluginMemberUploadStatusResponseAtPath( const result: PluginMemberUploadStatusResponse = { uploadId: string(raw.uploadId, `${path}.uploadId`, 128), status, - pluginId: nullableString(raw.pluginId, `${path}.pluginId`, 128), + pluginId: nullablePluginId(raw.pluginId, `${path}.pluginId`), releaseId: nullableString(raw.releaseId, `${path}.releaseId`, 128), ghostId: nullableGhostId(raw.ghostId, `${path}.ghostId`), version: nullableString(raw.version, `${path}.version`, 32),