From fbcc342c5f9a54606acc8b2de127f309d9175197 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 3 Aug 2026 19:05:26 +0000 Subject: [PATCH] =?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=A0=87=E5=87=86=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=20TypeScript=20=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- .dockerignore | 6 ++ Dockerfile | 23 ++++++ README.md | 18 +++++ compose.yaml | 35 +++++++++ package-lock.json | 47 ++++++++++++ package.json | 28 +++++++ src/fixtures/index.ts | 18 +++++ src/index.ts | 2 + src/parser.ts | 49 +++++++++++++ src/protocol.ts | 166 ++++++++++++++++++++++++++++++++++++++++++ test/parser.test.ts | 22 ++++++ tsconfig.build.json | 9 +++ tsconfig.json | 16 ++++ 13 files changed, 439 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/fixtures/index.ts create mode 100644 src/index.ts create mode 100644 src/parser.ts create mode 100644 src/protocol.ts create mode 100644 test/parser.test.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..76c0003 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +dist-tests +.git +.env +npm-debug.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8e079c4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22.18.0-alpine3.22 AS dependencies +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts + +FROM dependencies AS test +COPY tsconfig.json tsconfig.build.json ./ +COPY src ./src +COPY test ./test +CMD ["npm", "test"] + +FROM dependencies AS build +COPY tsconfig.json tsconfig.build.json ./ +COPY src ./src +RUN npm run build + +FROM node:22.18.0-alpine3.22 AS production +WORKDIR /app +ENV NODE_ENV=production +COPY package.json ./ +COPY --from=build /app/dist ./dist +USER node +CMD ["node", "--input-type=module", "--eval", "import('@gaza/protocol')"] diff --git a/README.md b/README.md index c5ac6a1..0351dae 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,20 @@ # gaza +GUI 与兼容 Teamserver 之间的首版强类型 TypeScript 协议模型。本包只定义标准对象与运行时解析边界,不包含网络请求、认证或私有 C2 适配。 + +## 使用 + +```ts +import { parseSession, type Session } from "@gaza/protocol"; + +const session: Session = parseSession(teamserverPayload); +``` + +`ServerId`、`ProjectId`、`SessionId` 等采用品牌类型,避免在编译期误用不同层级 ID。枚举使用前向兼容字符串表达,解析器保留未知枚举和扩展字段。 + +## 验证 + +```bash +docker compose run --rm test +docker compose build production +``` diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..9147ed5 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,35 @@ +services: + test: + image: gaza-protocol-test:local + build: + context: . + target: test + read_only: true + tmpfs: + - /tmp + - /app/dist + - /app/dist-tests + security_opt: + - no-new-privileges:true + + build: + image: gaza-protocol-build:local + build: + context: . + target: build + command: ["npm", "run", "build"] + read_only: true + tmpfs: + - /tmp + - /app/dist + security_opt: + - no-new-privileges:true + + production: + image: gaza-protocol:local + build: + context: . + target: production + read_only: true + security_opt: + - no-new-privileges:true diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a2212a5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,47 @@ +{ + "name": "@gaza/protocol", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@gaza/protocol", + "version": "0.1.0", + "devDependencies": { + "@types/node": "22.17.2", + "typescript": "5.9.2" + } + }, + "node_modules/@types/node": { + "version": "22.17.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.2.tgz", + "integrity": "sha512-gL6z5N9Jm9mhY+U2KXZpteb+09zyffliRkZyZOHODGATyC5B1Jt/7TzuuiLkFsSUMLbS1OLmlj/E+/3KF4Q/4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d0d5e5d --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "@gaza/protocol", + "version": "0.1.0", + "private": true, + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./fixtures": { + "types": "./dist/fixtures/index.d.ts", + "import": "./dist/fixtures/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "check": "tsc -p tsconfig.json --noEmit", + "test": "npm run build && tsc -p tsconfig.json && node --test dist-tests/test/**/*.test.js" + }, + "devDependencies": { + "@types/node": "22.17.2", + "typescript": "5.9.2" + } +} diff --git a/src/fixtures/index.ts b/src/fixtures/index.ts new file mode 100644 index 0000000..826a5ca --- /dev/null +++ b/src/fixtures/index.ts @@ -0,0 +1,18 @@ +import type { Session } from "../protocol.js"; + +/** UI 迁移期可直接使用的最小 Session fixture。 */ +export const demoSession = { + server_id: "demo-server", + project_id: "demo-project", + session_id: "demo-session", + name: "演示会话", + state: "ONLINE", + hostname: "demo-host", + username: "operator", + os: "linux", + architecture: "amd64", + first_seen_at: "2026-08-03T00:00:00Z", + last_active_at: "2026-08-03T00:00:05Z", + tags: ["fixture"], + capabilities: ["session.overview"], +} as unknown as Session; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..4ab556b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * from "./protocol.js"; +export * from "./parser.js"; diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..526a7c0 --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,49 @@ +import type { ProjectContext, ProtocolVersion, Session, SessionContext } from "./protocol.js"; + +export class ProtocolParseError extends Error { + constructor(message: string) { + super(message); + this.name = "ProtocolParseError"; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasString(value: Record, key: string): boolean { + return typeof value[key] === "string" && value[key] !== ""; +} + +export function isProtocolVersion(value: unknown): value is ProtocolVersion { + return isRecord(value) && ["major", "minor", "patch"].every( + (key) => Number.isInteger(value[key]) && (value[key] as number) >= 0, + ); +} + +export function isProjectContext(value: unknown): value is ProjectContext { + return isRecord(value) && hasString(value, "server_id") && hasString(value, "project_id"); +} + +export function isSessionContext(value: unknown): value is SessionContext { + return isProjectContext(value) && hasString(value, "session_id"); +} + +export function isSession(value: unknown): value is Session { + if (!isRecord(value) || !isSessionContext(value)) return false; + return hasString(value, "name") + && hasString(value, "state") + && hasString(value, "first_seen_at") + && hasString(value, "last_active_at") + && Array.isArray(value.tags) + && value.tags.every((tag) => typeof tag === "string") + && Array.isArray(value.capabilities) + && value.capabilities.every((capability) => typeof capability === "string"); +} + +export function parseSession(value: unknown): Session { + if (!isSession(value)) { + throw new ProtocolParseError("无效的 Session:缺少上下文字段或标准字段类型错误"); + } + return value; +} diff --git a/src/protocol.ts b/src/protocol.ts new file mode 100644 index 0000000..82e2cae --- /dev/null +++ b/src/protocol.ts @@ -0,0 +1,166 @@ +/** 协议对象允许保留服务端新增字段,GUI 不应因未知字段而拒绝整个对象。 */ +export interface Extensible { + readonly [extension: string]: unknown; +} + +declare const brand: unique symbol; +export type Brand = Value & { readonly [brand]: Name }; +export type ServerId = Brand; +export type ProjectId = Brand; +export type SessionId = Brand; +export type TaskId = Brand; +export type EventId = Brand; +export type ArtifactId = Brand; +export type AuditId = Brand; +export type CapabilityId = Brand; + +export type Timestamp = Brand; +export type Cursor = Brand; + +/** 已知值保留自动补全,同时允许服务端返回未来新增值。 */ +export type ForwardCompatible = Known | (string & {}); + +export interface ProtocolVersion extends Extensible { + readonly major: number; + readonly minor: number; + readonly patch: number; +} + +export interface ProtocolEnvelope extends Extensible { + readonly protocol_version: ProtocolVersion; + readonly request_id?: string; +} + +export interface ServerContext extends Extensible { + readonly server_id: ServerId; +} + +export interface ProjectContext extends ServerContext { + readonly project_id: ProjectId; +} + +export interface SessionContext extends ProjectContext { + readonly session_id: SessionId; +} + +export interface PageRequest extends Extensible { + readonly cursor?: Cursor; + readonly page_size?: number; +} + +export interface PageInfo extends Extensible { + readonly next_cursor?: Cursor; + readonly has_more: boolean; +} + +export interface Page extends Extensible { + readonly items: readonly T[]; + readonly page_info: PageInfo; +} + +export type ConnectionState = ForwardCompatible< + | "DISCONNECTED" | "CONNECTING" | "TLS_HANDSHAKE" | "AUTHENTICATING" + | "NEGOTIATING" | "SYNCHRONIZING" | "CONNECTED" | "DEGRADED" + | "RECONNECTING" | "AUTH_EXPIRED" | "FAILED" +>; + +export interface Teamserver extends ServerContext, Extensible { + readonly name: string; + readonly protocol_version: ProtocolVersion; + readonly connection_state: ConnectionState; + readonly capabilities: readonly CapabilityId[]; +} + +export interface Project extends ProjectContext, Extensible { + readonly name: string; + readonly description?: string; + readonly capabilities: readonly CapabilityId[]; +} + +export type SessionState = ForwardCompatible<"ONLINE" | "OFFLINE" | "DORMANT" | "LOST">; + +export interface Session extends SessionContext, Extensible { + readonly name: string; + readonly state: SessionState; + readonly hostname?: string; + readonly username?: string; + readonly os?: string; + readonly architecture?: string; + readonly first_seen_at: Timestamp; + readonly last_active_at: Timestamp; + readonly tags: readonly string[]; + readonly capabilities: readonly CapabilityId[]; +} + +export type JsonSchema = Readonly>; + +export interface Capability extends ProjectContext, Extensible { + readonly capability_id: CapabilityId; + readonly name: string; + readonly description?: string; + readonly input_schema: JsonSchema; + readonly output_schema?: JsonSchema; + readonly supports_batch: boolean; +} + +export type TaskState = ForwardCompatible< + "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED" | "CANCELLED" +>; + +export interface Task extends SessionContext, Extensible { + readonly task_id: TaskId; + readonly capability_id: CapabilityId; + readonly state: TaskState; + readonly cancellable: boolean; + readonly created_at: Timestamp; + readonly updated_at: Timestamp; + readonly result?: unknown; + readonly error?: ProtocolError; +} + +export interface EventContext extends ServerContext, Extensible { + readonly project_id?: ProjectId; + readonly session_id?: SessionId; + readonly task_id?: TaskId; +} + +export interface Event extends Extensible { + readonly event_id: EventId; + readonly type: ForwardCompatible<"SESSION_UPDATED" | "TASK_UPDATED" | "TASK_OUTPUT" | "ARTIFACT_CREATED">; + readonly timestamp: Timestamp; + readonly cursor: Cursor; + readonly sequence?: number; + readonly context: EventContext; + readonly payload: unknown; +} + +export interface Artifact extends SessionContext, Extensible { + readonly artifact_id: ArtifactId; + readonly task_id?: TaskId; + readonly name: string; + readonly media_type: string; + readonly size_bytes: number; + readonly sha256?: string; + readonly created_at: Timestamp; +} + +export interface AuditRecord extends ProjectContext, Extensible { + readonly audit_id: AuditId; + readonly actor: string; + readonly action: string; + readonly target: Readonly>; + readonly parameter_summary?: Readonly>; + readonly outcome: ForwardCompatible<"SUCCEEDED" | "FAILED" | "DENIED">; + readonly request_id: string; + readonly timestamp: Timestamp; +} + +export interface ProtocolError extends Extensible { + readonly code: ForwardCompatible< + "NETWORK" | "TLS" | "AUTHENTICATION" | "INCOMPATIBLE_VERSION" | + "PERMISSION_DENIED" | "RATE_LIMITED" | "TIMEOUT" | "INVALID_MESSAGE" | "INTERNAL" + >; + readonly message: string; + readonly retryable: boolean; + readonly details?: Readonly>; +} diff --git a/test/parser.test.ts b/test/parser.test.ts new file mode 100644 index 0000000..3e3d5a0 --- /dev/null +++ b/test/parser.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { demoSession } from "../src/fixtures/index.js"; +import { isProtocolVersion, isSession, parseSession, ProtocolParseError } from "../src/index.js"; + +test("接受完整 Session 并保留未知枚举和扩展字段", () => { + const futureSession = { ...demoSession, state: "SUSPENDED_BY_POLICY", vendor_status: 42 }; + const parsed = parseSession(futureSession); + assert.equal(parsed.state, "SUSPENDED_BY_POLICY"); + assert.equal(parsed.vendor_status, 42); +}); + +test("拒绝缺少 project_id 的跨上下文 Session", () => { + const { project_id: _projectId, ...invalid } = demoSession; + assert.equal(isSession(invalid), false); + assert.throws(() => parseSession(invalid), ProtocolParseError); +}); + +test("协议版本必须由非负整数构成", () => { + assert.equal(isProtocolVersion({ major: 1, minor: 0, patch: 0, future: true }), true); + assert.equal(isProtocolVersion({ major: 1, minor: -1, patch: 0 }), false); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..0cb1229 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["test"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d54991b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "declaration": true, + "sourceMap": true, + "rootDir": ".", + "outDir": "dist-tests", + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +}