@coda-console/protocol 1.6.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,9 +4,21 @@ Coda 与插件之间的约定,Coda 和插件都引用它,独立演进:
4
4
 
5
5
  - **插件清单的类型**(`schemas/plugin/`):插件目录 `package.json` 里 `contributes` 的每个键是一种类型,各有一份 JSON Schema(draft 2020-12,`$id` 为 `https://coda.pub/schemas/plugin/v1/<文件>`)。`plugin.json` 是整份清单,`setting.json` 是一项设置的声明。插件写的合乎它用到的类型就对;写给别的插件扩展点的键由那个插件声明,扩展点可以带自己的 schema(`extensionPoints.<名>.schema`,声明它的插件目录里的一个文件),写给它的东西合乎那份 schema 才对。
6
6
  - **通用校验器**(`validate.mjs`):用 Ajv 对清单做 schema 校验。JSON Schema 说不了的只用五个通用关键字(`x-unique-by`、`x-max-contains`、`x-ref`、`x-placeholders`、`x-file`,照 Kubernetes 的 `x-kubernetes-*`),每个在哪里写都是同一个意思,写法见文件开头。校验器不随功能变长:类型加字段就是改 schema。
7
- - **流程方言**(`workflow/`):项目和插件的流程(`.coda/workflows/*.bpmn`,BPMN 2.0)在 Coda 的命名空间 `https://coda.pub/schema/bpmn/1.0` 里能写什么。`moddle.json` 是 `coda:` 属性与元素(bpmn-moddle 的描述格式),`values.json` 是每个值能写什么(每个 `coda:` 类型的属性组成的对象的 JSON Schema,一个元素里同种 `coda:` 元素成组的 schema),`actions.json` 是 `coda:action` 的各个动作和它们接受的参数,`dialect.json` 是流程种类、Coda 自己发的事件、步骤失败的错误码、表达式能读的运行变量和运行的输入能是哪些类型。`workflow.mjs` 用它们读一份流程:方言里没有的、值不合 schema 的都拒绝,说法是「位置:schema 对那里的说明」。值的 schema 用清单的那几个关键字,另加三个:`x-feel`(是 FEEL:「=」开头是表达式,否则文字里的 `{{ … }}` 是,按这份流程里的名字读得通)、`x-duration`(ISO 8601 时长,不为负)、`x-number`(文字换算成数字是个有限的数)。流程的结构对不对(元素与事件、连接、补偿、走不到的步骤、事件启动),现在仍由 Coda 读流程时检查。
7
+ - **流程方言**(`workflow/`):项目和插件的流程(`.coda/workflows/*.bpmn`,BPMN 2.0)在 Coda 的命名空间 `https://coda.pub/schema/bpmn/1.0` 里能写什么。`moddle.json` 是 `coda:` 属性与元素(bpmn-moddle 的描述格式),`values.json` 是每个值能写什么(每个 `coda:` 类型的属性组成的对象的 JSON Schema,一个元素里同种 `coda:` 元素成组的 schema),`actions.json` 是 `coda:action` 的各个动作和它们接受的参数,`dialect.json` 是流程种类、Coda 自己发的事件、步骤失败的错误码、表达式能读的运行变量和运行的输入能是哪些类型。`workflow.mjs` 用它们读一份流程:方言里没有的、值不合 schema 的都拒绝,说法是「位置:schema 对那里的说明」。值的 schema 用清单的那几个关键字,另加三个:`x-feel`(是 FEEL:「=」开头是表达式,否则文字里的 `{{ … }}` 是,按这份流程里的名字读得通)、`x-duration`(ISO 8601 时长,不为负)、`x-number`(文字换算成数字是个有限的数)。`structure.json` 声明流程结构规则,`structure.mjs` 负责跨节点检查。
8
8
  - **Agent 接入协议**(`docs/agent-protocol.md`):Coda 怎样用 ACP 接 Agent,以及 Coda 加的扩展(会话指令、插话、`_coda/wait`、执行失败、工具调用的审核)。
9
9
 
10
+ 流程方言的 **结构规则**从 1.8.0 起也由 `readWorkflow` / `workflowProblem` 检查。`workflow/structure.json` 声明支持的顶层元素与不支持的流程元素、各位置的事件定义、`coda:` 属性的位置限制、任务与多实例的局部写法、脚本数量、定时间隔和生命周期等待类型。`rules` 使用 JSON Schema 检查节点的无环投影(原始标量属性与父级、事件种类等固定事实),复用 `newAjv()` 和已有关键字;`moddle.json` 的 `extends` 仍声明属性可附着的基础类型。`CODA_STRUCTURE` 导出同一份表。新增局部规则改数据,不增加校验器分支。
11
+
12
+ `structure.mjs` 的固定检查处理数据表无法单独表达的关系:事件子流程与顺序流、连接事件按作用域配对、事件网关的出口、补偿关联、经过子流程与边界事件的可达性、事件启动以及生命周期的「聋状态」。Coda 的事件名通过 `dialect.json` 的 `eventRoles` 读取;等待动作与节点类型也来自数据表。ISO 8601 重复间隔用 `@0dep/piso` 解析,脚本变量边界用固定的词法扫描检查,条件和 documentation 用同一份 FEEL 检查。图和嵌套树使用显式栈与 Map/Set,不递归、不把大数组展开成函数参数。报错尽量保留 Coda 的原位置与原话。
13
+
14
+ 读取定义时原有的 Coda 结构规则已全部纳入协议。Coda 接入 1.8.0 后,`parseBpmn` 只需调用 `readWorkflow`,再把 `root` 编译成引擎、任务、调度和图所需的结构;运行时才能知道的表达式结果、动态字段引用、调度设置实际值及能力是否存在,仍由宿主在运行时检查。
15
+
16
+ 流程方言的 **卡片字段**由 `workflow/fields.json` 定义:七种字段各能写哪些属性、必填项、文字长度与单行限制、路径与网址、视图参数、输入框与密钥选项;`values.json` 的 `Field` 引用它,`Fields` 声明读出 JSON 字段列表的表达式,`UserTask.children` 限定最多 12 个静态字段、id 不重复,以及全部字段已知时答复的 `fields` 只能引用输入字段(含按住出现的答复)。
17
+
18
+ 定义和运行时共用这一份 schema。`x-deferred` 包住一个 schema:读定义时,文字先检查 FEEL,表达式或含 `{{ … }}` 的模板留到打开任务后查;运行时只按包住的 schema 严格检查,不再把结果当表达式。只有内容属性使用它,`type`、`id`、布尔值、枚举和 `maxLength` 不放宽。另有 `x-length`(沿用 Coda 的 UTF-16 长度,可用 `{ "property": "maxLength" }` 读取同一对象的上限)、`x-json`(解析 JSON 文字或检查 JSON 值,按序列化后的 UTF-8 字节数限制大小);`absolute-path`、`https-url` 两个 format 按本机路径与 URL 解析器检查。已有的 `x-ref` 增加 `key`(按数组元素的属性引用)和 `split`(逐个检查分隔后的名字)。这些操作都不改写输入。
19
+
20
+ `fieldProblem(field, where)` 检查一个已求值的字段;`fieldsProblem(fields, where)` 检查展开并应用 `host`/`dir` 后的数组,复用同一份字段 schema、数量和唯一性规则。两者合法时返回 `null`,否则返回「位置:说明」;`where` 是调用方给出的完整位置。schema 的 `default` 仅作标注:默认值填充、去空白、域名转小写、`args` 解析后的保留、表达式求值、动态字段展开和用户答复的处理仍由 Coda 完成。`coda:fields` 的 `host`/`dir` 是可能被覆盖或忽略的备用值,定义时只检查 FEEL,合并后才检查实际字段。动态字段展开后,答复的输入引用仍须由 Coda 检查。
21
+
10
22
  ## 用法
11
23
 
12
24
  ```sh
@@ -25,12 +37,16 @@ pointProblem(panelsSchema, manifest.contributes.panels, 'contributes.panels', 'p
25
37
  ```
26
38
 
27
39
  ```js
28
- import { workflowProblem, readWorkflow, CODA_MODDLE, CODA_ACTIONS } from '@coda-console/protocol/workflow';
40
+ import { workflowProblem, readWorkflow, fieldProblem, fieldsProblem, CODA_FIELDS } from '@coda-console/protocol/workflow';
29
41
 
30
42
  // 一份流程(名字不带 .bpmn,用在说明里):方言里都有就返回 null,否则是一句说明。
31
43
  const problem = await workflowProblem(readFileSync('.coda/workflows/deploy.bpmn', 'utf8'), 'deploy');
32
44
  // 要接着读它的:{ root } 是解析好的 definitions,或 { problem }。
33
45
  const { root } = await readWorkflow(source, 'deploy');
46
+ // 任务打开、接受答复时检查已求值的字段,不填默认值,不执行表达式。
47
+ fieldProblem({ type: 'text', id: 'name', label: '名称' }, 'ask 的字段 name');
48
+ fieldsProblem(JSON.parse(fieldsJSON), 'ask');
49
+ // CODA_FIELDS 是同一份 fields.json,也可从 @coda-console/protocol/workflow/fields.json 导入。
34
50
  ```
35
51
 
36
52
  扩展点的 schema 和这里的并列:可以 `$ref` 协议的 schema(`common.json#/$defs/line40`),可以用五个关键字;它自己写的 `$id` 不算数,指针只指它校验的那份值里的位置。
@@ -49,7 +65,7 @@ const { root } = await readWorkflow(source, 'deploy');
49
65
 
50
66
  ## 谁在用
51
67
 
52
- - **Coda**:读插件时用它校验清单,再整理成自己的结构;只另看插件身份(ID、设置名在 ID 之下),以及装到一台机器上才知道的(来源、已装插件的冲突)。读流程时先用 `readWorkflow` 按方言解析,再查每个值。
68
+ - **Coda**:读插件时用它校验清单,再整理成自己的结构;只另看插件身份(ID、设置名在 ID 之下),以及装到一台机器上才知道的(来源、已装插件的冲突)。读流程时用 `readWorkflow` 同时校验方言、值与结构,再编译成引擎需要的数据。
53
69
  - **coda-plugins 的契约检查包**(`@coda-console/plugin-check`):在写插件的地方用同一个校验器判清单。
54
70
 
55
71
  ## 版本
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coda-console/protocol",
3
- "version": "1.6.1",
3
+ "version": "1.8.0",
4
4
  "description": "Coda 的协议:插件清单的类型(JSON Schema)、通用校验器、流程方言与 Agent 接入协议",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/validate.mjs CHANGED
@@ -10,7 +10,8 @@
10
10
  // items having it, and so is `part`'s first group of each of its texts (a directory's last name), and `key` among the
11
11
  // items and those in their `nested` arrays, at every depth (a task's answers and those offered on holding one);
12
12
  // - `x-max-contains`: { match, max } — of an object's values, at most `max` satisfy `match` (`maxContains` of an object);
13
- // - `x-ref`: { pointer, part?, match? } — the value (or `part`'s first group of it) names a key of the object, or an element
13
+ // - `x-ref`: { pointer, part?, key?, split?, match? } — the value (or `part`'s first group of it), or each nonempty token
14
+ // separated by `split`, names a key of the object, or an element (with `key`, that property of an element)
14
15
  // of the array, at `pointer` in the manifest: absolute (`/contributes/configuration/properties`), or relative to the value
15
16
  // (`2/properties`: two levels up, then down); what it names satisfies `match` when that is given;
16
17
  // - `x-placeholders`: { names?, config? } — every `${…}` in the text is one of `names`, or `config:<setting>` naming one of
@@ -56,7 +57,7 @@ const META = {
56
57
  'x-unique-by': { type: 'array', minItems: 1, items: { anyOf: [{ type: 'string' },
57
58
  { type: 'object', properties: { part: { type: 'string' } }, required: ['part'], additionalProperties: false },
58
59
  { type: 'object', properties: { key: { type: 'string' }, nested: { type: 'string' } }, required: ['key', 'nested'], additionalProperties: false }] } },
59
- 'x-ref': { type: 'object', properties: { pointer: { type: 'string', pattern: '^(?:\\d+(?:/.*)?|/.*)$' }, part: { type: 'string' }, match: SCHEMA_VALUE },
60
+ 'x-ref': { type: 'object', properties: { pointer: { type: 'string', pattern: '^(?:\\d+(?:/.*)?|/.*)$' }, part: { type: 'string' }, key: { type: 'string' }, split: { type: 'string' }, match: SCHEMA_VALUE },
60
61
  required: ['pointer'], additionalProperties: false },
61
62
  'x-placeholders': { type: 'object', properties: { names: { type: 'array', items: { type: 'string' } }, config: SCHEMA_VALUE }, additionalProperties: false },
62
63
  'x-file': { type: 'object', properties: { type: { enum: ['file', 'directory'] }, under: { type: 'string' }, suffix: { type: 'string' }, contains: { type: 'string' },
@@ -124,16 +125,24 @@ function keywords(ajv) {
124
125
  // A reference: whether `name` is a key (or element) of what `pointer` names, and that satisfies `match`.
125
126
  function resolves(self, ref, matches, name, cxt) {
126
127
  const { at, path } = locate(ref.pointer, cxt);
127
- const key = Array.isArray(at) ? at.indexOf(name) : at !== null && typeof at === 'object' && Object.hasOwn(at, name) ? name : -1;
128
+ const key = Array.isArray(at) ? ref.key === undefined ? at.indexOf(name)
129
+ : at.findIndex(item => item !== null && typeof item === 'object' && Object.hasOwn(item, ref.key) && item[ref.key] === name)
130
+ : at !== null && typeof at === 'object' && Object.hasOwn(at, name) ? name : -1;
128
131
  return key !== -1 && (!matches || matches(self, at[key], `${path}/${escape(key)}`, cxt));
129
132
  }
130
133
  ajv.addKeyword({
131
- keyword: 'x-ref', type: 'string', schemaType: 'object', metaSchema: META['x-ref'],
132
- compile(ref) {
134
+ keyword: 'x-ref', type: 'string', schemaType: 'object', metaSchema: META['x-ref'], errors: true,
135
+ compile(ref, parentSchema) {
133
136
  const matches = ref.match !== undefined ? inner(ajv, ref.match) : null, part = ref.part !== undefined ? new RegExp(ref.part, 'u') : null;
134
- return function (value, cxt) {
135
- const name = part ? part.exec(value)?.[1] : value;
136
- return name !== undefined && resolves(this, ref, matches, name, cxt);
137
+ const split = ref.split === undefined ? null : new RegExp(ref.split, 'u');
138
+ return function reference(value, cxt) {
139
+ for (const token of split ? value.split(split).filter(Boolean) : [value]) {
140
+ const name = part ? part.exec(token)?.[1] : token;
141
+ if (name !== undefined && resolves(this, ref, matches, name, cxt)) continue;
142
+ reference.errors = [{ keyword: 'x-ref', params: { value: token }, parentSchema, message: '引用不存在或类型不符' }];
143
+ return false;
144
+ }
145
+ return true;
137
146
  };
138
147
  },
139
148
  });
@@ -14,6 +14,7 @@
14
14
  "workflow.dispatch": "手动发起(workflow_run、客户端的按钮):由事件启动的流程也订阅它,才能手动发起",
15
15
  "workflow.cancel": "要它停下:在等它的运行被告知,走到自己的结束,以取消结束"
16
16
  },
17
+ "eventRoles": {"lifecycleStart": "session.opened", "lifecycleFacts": "session.facts", "dispatch": "workflow.dispatch", "cancel": "workflow.cancel"},
17
18
  "userMessage": "^user\\.[a-z][a-z0-9-]{0,39}$",
18
19
  "stepFailed": "STEP_FAILED",
19
20
  "variables": ["inputs", "run", "github", "vars"],
@@ -0,0 +1,230 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://coda.pub/schema/bpmn/1.0/fields.json",
4
+ "title": "人工任务卡片的字段",
5
+ "description": "字段须为对象",
6
+ "type": "object",
7
+ "allOf": [
8
+ {"required": ["type"], "description": "字段的 type 只能是 markdown、code、image、text、secret、link、view"},
9
+ {"properties": {"type": {"enum": ["markdown", "code", "image", "text", "secret", "link", "view"], "description": "字段的 type 只能是 markdown、code、image、text、secret、link、view"}}},
10
+ {"if": {"properties": {"type": {"const": "markdown"}}, "required": ["type"]}, "then": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/markdown"}},
11
+ {"if": {"properties": {"type": {"const": "code"}}, "required": ["type"]}, "then": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/code"}},
12
+ {"if": {"properties": {"type": {"const": "image"}}, "required": ["type"]}, "then": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/image"}},
13
+ {"if": {"properties": {"type": {"const": "text"}}, "required": ["type"]}, "then": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/text"}},
14
+ {"if": {"properties": {"type": {"const": "secret"}}, "required": ["type"]}, "then": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/secret"}},
15
+ {"if": {"properties": {"type": {"const": "link"}}, "required": ["type"]}, "then": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/link"}},
16
+ {"if": {"properties": {"type": {"const": "view"}}, "required": ["type"]}, "then": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/view"}}
17
+ ],
18
+ "$defs": {
19
+ "markdown": {
20
+ "allOf": [
21
+ {
22
+ "type": "object",
23
+ "properties": {
24
+ "type": {"const": "markdown"},
25
+ "id": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/id"},
26
+ "label": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/label"},
27
+ "text": {"x-deferred": {"type": "string", "pattern": "\\S", "x-length": 4000, "description": "text 须为1–4000 个字"}}
28
+ },
29
+ "additionalProperties": false
30
+ },
31
+ {"required": ["text"], "description": "须写 text"}
32
+ ]
33
+ },
34
+ "code": {
35
+ "allOf": [
36
+ {
37
+ "type": "object",
38
+ "properties": {
39
+ "type": {"const": "code"},
40
+ "id": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/id"},
41
+ "label": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/label"},
42
+ "text": {"x-deferred": {"type": "string", "pattern": "\\S", "x-length": 16384, "description": "text 须为1–16384 个字"}}
43
+ },
44
+ "additionalProperties": false
45
+ },
46
+ {"required": ["text"], "description": "须写 text"}
47
+ ]
48
+ },
49
+ "image": {
50
+ "allOf": [
51
+ {
52
+ "type": "object",
53
+ "properties": {
54
+ "type": {"const": "image"},
55
+ "id": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/id"},
56
+ "label": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/label"},
57
+ "src": {
58
+ "x-deferred": {
59
+ "allOf": [
60
+ {"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": 4096, "description": "src 须为一行、1–4096 个字"},
61
+ {"format": "absolute-path", "description": "src 须为本机文件的绝对路径"}
62
+ ]
63
+ }
64
+ }
65
+ },
66
+ "additionalProperties": false
67
+ },
68
+ {"required": ["src"], "description": "须写 src"}
69
+ ]
70
+ },
71
+ "text": {
72
+ "allOf": [
73
+ {
74
+ "type": "object",
75
+ "properties": {
76
+ "type": {"const": "text"},
77
+ "id": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/id"},
78
+ "label": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/label"},
79
+ "required": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/required"},
80
+ "default": {},
81
+ "maxLength": {"type": "integer", "minimum": 1, "maximum": 4000, "description": "maxLength 须为 1–4000 的整数"},
82
+ "multiline": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/multiline"},
83
+ "autofill": {"enum": ["username", "otp"], "description": "autofill 只能是 username、otp"}
84
+ },
85
+ "additionalProperties": false
86
+ },
87
+ {"required": ["id"], "description": "须有 id(字母或数字开头的字母、数字、「_」「-」,最长 40 个字符)"},
88
+ {"required": ["label"], "description": "须写 label"},
89
+ {
90
+ "if": {"properties": {"multiline": {"const": true}}, "required": ["multiline"]},
91
+ "then": {
92
+ "if": {"required": ["maxLength"]},
93
+ "then": {"properties": {"default": {"x-deferred": {"type": "string", "pattern": "\\S", "x-length": {"property": "maxLength"}, "description": "default 须为1–{maxLength} 个字"}}}},
94
+ "else": {"properties": {"default": {"x-deferred": {"type": "string", "pattern": "\\S", "x-length": 4000, "description": "default 须为1–4000 个字"}}}}
95
+ },
96
+ "else": {
97
+ "if": {"required": ["maxLength"]},
98
+ "then": {
99
+ "properties": {
100
+ "default": {"x-deferred": {"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": {"property": "maxLength"}, "description": "default 须为一行、1–{maxLength} 个字"}}
101
+ }
102
+ },
103
+ "else": {"properties": {"default": {"x-deferred": {"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": 200, "description": "default 须为一行、1–200 个字"}}}}
104
+ }
105
+ },
106
+ {
107
+ "if": {"required": ["multiline"], "properties": {"multiline": {"const": true}}},
108
+ "then": {"properties": {"maxLength": {"default": 4000}}},
109
+ "else": {"properties": {"maxLength": {"default": 200}}}
110
+ }
111
+ ]
112
+ },
113
+ "secret": {
114
+ "allOf": [
115
+ {
116
+ "type": "object",
117
+ "properties": {
118
+ "type": {"const": "secret"},
119
+ "id": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/id"},
120
+ "label": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/label"},
121
+ "required": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/required"},
122
+ "maxLength": {"type": "integer", "minimum": 1, "maximum": 65536, "description": "maxLength 须为 1–65536 的整数", "default": 65536},
123
+ "multiline": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/multiline"},
124
+ "masked": {"type": "boolean", "description": "masked 须为 true 或 false"},
125
+ "autofill": {"enum": ["username", "password", "otp"], "description": "autofill 只能是 username、password、otp"},
126
+ "keep": {
127
+ "enum": ["session", "memory", "use"],
128
+ "description": "keep 只能是 session(写进会话的密钥目录,流程拿到路径)、memory(只在后端内存里,本会话的桌面动作可反复用,流程拿到句柄)或 use(只在后端内存里,流程拿到句柄,用一次)",
129
+ "default": "session"
130
+ },
131
+ "host": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/host"},
132
+ "dir": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/dir"}
133
+ },
134
+ "additionalProperties": false
135
+ },
136
+ {"required": ["id"], "description": "须有 id(字母或数字开头的字母、数字、「_」「-」,最长 40 个字符)"},
137
+ {"required": ["label"], "description": "须写 label"},
138
+ {
139
+ "if": {"required": ["keep"], "properties": {"keep": {"const": "use"}}},
140
+ "then": {"not": {"required": ["dir"]}, "description": "dir 只用于 keep=\"session\" 或 keep=\"memory\" 的密文"}
141
+ },
142
+ {
143
+ "if": {"required": ["autofill"], "properties": {"autofill": {"enum": ["username", "otp"]}}},
144
+ "then": {"properties": {"masked": {"default": false}}},
145
+ "else": {"properties": {"masked": {"default": true}}}
146
+ }
147
+ ]
148
+ },
149
+ "link": {
150
+ "allOf": [
151
+ {
152
+ "type": "object",
153
+ "properties": {
154
+ "type": {"const": "link"},
155
+ "id": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/id"},
156
+ "label": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/label"},
157
+ "href": {
158
+ "x-deferred": {
159
+ "allOf": [
160
+ {"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": 2000, "description": "href 须为一行、1–2000 个字"},
161
+ {"format": "https-url", "description": "href 须为 https 网址"}
162
+ ]
163
+ }
164
+ }
165
+ },
166
+ "additionalProperties": false
167
+ },
168
+ {"required": ["label"], "description": "须写 label"},
169
+ {"required": ["href"], "description": "须写 href"}
170
+ ]
171
+ },
172
+ "view": {
173
+ "allOf": [
174
+ {
175
+ "type": "object",
176
+ "properties": {
177
+ "type": {"const": "view"},
178
+ "id": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/id"},
179
+ "label": {"$ref": "https://coda.pub/schema/bpmn/1.0/fields.json#/$defs/label"},
180
+ "plugin": {
181
+ "x-deferred": {
182
+ "allOf": [
183
+ {"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": 64, "description": "plugin 须为一行、1–64 个字"},
184
+ {"pattern": "^\\s*[a-z0-9][a-z0-9-]{0,63}\\s*$", "description": "plugin 须为插件 ID(小写字母、数字和「-」)"}
185
+ ]
186
+ }
187
+ },
188
+ "view": {
189
+ "x-deferred": {
190
+ "allOf": [
191
+ {"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": 40, "description": "view 须为一行、1–40 个字"},
192
+ {"pattern": "^\\s*[a-z][a-z0-9-]{0,39}\\s*$", "description": "view 须为插件声明的视图 ID(小写字母开头的小写字母、数字和「-」)"}
193
+ ]
194
+ }
195
+ },
196
+ "args": {"x-deferred": {"x-json": {"schema": {"type": "object", "description": "args 须为不超过 4096 字节的 JSON 对象"}, "maxBytes": 4096}, "description": "args 须为 JSON 对象"}},
197
+ "presentation": {"enum": ["window", "inline", "side"], "description": "presentation 只能是 window、inline 或 side"}
198
+ },
199
+ "additionalProperties": false
200
+ },
201
+ {"required": ["label"], "description": "须写 label"},
202
+ {"required": ["plugin"], "description": "须写 plugin"},
203
+ {"required": ["view"], "description": "须写 view"}
204
+ ]
205
+ },
206
+ "id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$", "description": "须有 id(字母或数字开头的字母、数字、「_」「-」,最长 40 个字符)"},
207
+ "label": {"x-deferred": {"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": 40, "description": "label 须为一行、1–40 个字"}},
208
+ "required": {"type": "boolean", "description": "required 须为 true 或 false", "default": true},
209
+ "multiline": {"type": "boolean", "description": "multiline 须为 true 或 false"},
210
+ "host": {
211
+ "x-deferred": {
212
+ "allOf": [
213
+ {"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": 253, "description": "host 须为一行、1–253 个字"},
214
+ {
215
+ "pattern": "^\\s*(?=[a-zA-Z0-9.-]{4,253}\\s*$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\s*$",
216
+ "description": "host 须为网站域名,如 signin.aws.amazon.com"
217
+ }
218
+ ]
219
+ }
220
+ },
221
+ "dir": {
222
+ "x-deferred": {
223
+ "allOf": [
224
+ {"type": "string", "pattern": "^\\s*\\S(?:[^\\r\\n]*\\S)?\\s*$", "x-length": 80, "description": "dir 须为一行、1–80 个字"},
225
+ {"pattern": "^\\s*[A-Za-z0-9][A-Za-z0-9._-]{0,79}\\s*$", "description": "dir 只能是字母、数字、点、下划线和连字符,以字母或数字开头,最长 80 个字符"}
226
+ ]
227
+ }
228
+ }
229
+ }
230
+ }
@@ -0,0 +1,104 @@
1
+ // Value operations JSON Schema cannot express: defer checking an expression until it is read, measure UTF-16 text
2
+ // (with a limit optionally named by a sibling), and validate JSON supplied as text or as a value, bounded in UTF-8 bytes.
3
+ // None knows what a card field is. Schemas select the operations and their limits; validation never changes the input.
4
+ import { isAbsolute } from 'node:path';
5
+
6
+ // The byte length of a JSON value, without recursion or serializing the whole object. Stop as soon as the budget is
7
+ // exceeded. Only JSON values count: cycles, non-finite numbers and non-JSON objects cannot turn into a different value.
8
+ function jsonFits(value, max) {
9
+ let bytes = 0;
10
+ const active = new Set(), stack = [{ value }];
11
+ while (stack.length && bytes <= max) {
12
+ const frame = stack.at(-1);
13
+ if (frame.keys) {
14
+ if (frame.index === frame.keys.length) { active.delete(frame.value); stack.pop(); continue; }
15
+ const key = frame.keys[frame.index++];
16
+ bytes += (frame.index > 1 ? 1 : 0) + Buffer.byteLength(JSON.stringify(key)) + 1;
17
+ stack.push({ value: frame.value[key] });
18
+ continue;
19
+ }
20
+ if (frame.array) {
21
+ if (frame.index === frame.value.length) { active.delete(frame.value); stack.pop(); continue; }
22
+ if (frame.index++) bytes++;
23
+ stack.push({ value: frame.value[frame.index - 1] });
24
+ continue;
25
+ }
26
+ const item = frame.value;
27
+ if (item !== null && typeof item === 'object') {
28
+ if (active.has(item)) return false;
29
+ active.add(item);
30
+ bytes += 2;
31
+ frame.index = 0;
32
+ if (Array.isArray(item)) {
33
+ if (item.length > max) return false;
34
+ frame.array = true;
35
+ } else {
36
+ if (![Object.prototype, null].includes(Object.getPrototypeOf(item))) return false;
37
+ frame.keys = Object.keys(item);
38
+ if (frame.keys.length > max) return false;
39
+ }
40
+ } else {
41
+ if (typeof item === 'number' && !Number.isFinite(item)) return false;
42
+ if (item !== null && !['string', 'number', 'boolean'].includes(typeof item)) return false;
43
+ // JSON escaping cannot shorten a string, so avoid serializing one already over budget.
44
+ if (typeof item === 'string' && item.length > max - bytes) return false;
45
+ bytes += Buffer.byteLength(JSON.stringify(item));
46
+ stack.pop();
47
+ }
48
+ }
49
+ return bytes <= max;
50
+ }
51
+
52
+ export function valueKeywords(ajv, { feelProblem, isExpression }) {
53
+ ajv.addFormat('absolute-path', value => isAbsolute(value.trim()));
54
+ ajv.addFormat('https-url', value => { try { return new URL(value.trim()).protocol === 'https:'; } catch { return false; } });
55
+ ajv.addKeyword({
56
+ keyword: 'x-length', type: 'string', schemaType: ['number', 'object'], errors: true,
57
+ metaSchema: { anyOf: [{ type: 'integer', minimum: 0 }, { type: 'object', properties: { property: { type: 'string' } }, required: ['property'], additionalProperties: false }] },
58
+ compile(spec, parentSchema) {
59
+ return function length(value, cxt) {
60
+ const limit = typeof spec === 'number' ? spec : cxt.parentData && Object.hasOwn(cxt.parentData, spec.property) ? cxt.parentData[spec.property] : undefined;
61
+ if (Number.isInteger(limit) && limit >= 0 && value.length <= limit) return true;
62
+ length.errors = [{ keyword: 'x-length', params: { limit }, parentSchema, message: '文字过长' }];
63
+ return false;
64
+ };
65
+ },
66
+ });
67
+ ajv.addKeyword({
68
+ keyword: 'x-deferred', schemaType: ['object', 'boolean'], errors: true,
69
+ compile(schema) {
70
+ const validate = ajv.compile(schema);
71
+ return function deferred(value, cxt) {
72
+ // In a definition every textual value is also read as FEEL. At runtime even an expression-looking result is
73
+ // literal content; only a definition may postpone the wrapped schema's checks.
74
+ if (this?.definition && typeof value === 'string') {
75
+ const problem = feelProblem(value, this.names);
76
+ if (problem) { deferred.errors = [{ keyword: 'x-feel', params: {}, message: problem }]; return false; }
77
+ if (isExpression(value) || value.includes('{{')) return true;
78
+ }
79
+ const valid = validate.call(this, value, cxt);
80
+ deferred.errors = validate.errors;
81
+ return valid;
82
+ };
83
+ },
84
+ });
85
+ ajv.addKeyword({
86
+ keyword: 'x-json', schemaType: 'object', errors: true,
87
+ metaSchema: { type: 'object', properties: { schema: { type: ['object', 'boolean'] }, maxBytes: { type: 'integer', minimum: 0 } }, required: ['schema', 'maxBytes'], additionalProperties: false },
88
+ compile(spec, parentSchema) {
89
+ const validate = ajv.compile(spec.schema);
90
+ return function json(value, cxt) {
91
+ let parsed = value;
92
+ if (typeof value === 'string') try { parsed = JSON.parse(value); }
93
+ catch { json.errors = [{ keyword: 'x-json', params: {}, parentSchema, message: '不是 JSON' }]; return false; }
94
+ if (!jsonFits(parsed, spec.maxBytes)) {
95
+ json.errors = [{ keyword: 'x-json', params: {}, parentSchema: spec.schema, message: `须为不超过 ${spec.maxBytes} 字节的 JSON` }];
96
+ return false;
97
+ }
98
+ const valid = validate.call(this, parsed, cxt);
99
+ json.errors = validate.errors;
100
+ return valid;
101
+ };
102
+ },
103
+ });
104
+ }