@openwop/openwop-conformance 1.58.0 → 1.64.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 +2 -2
- package/coverage.md +24 -0
- package/package.json +1 -1
- package/schemas/README.md +1 -0
- package/schemas/artifact-type-pack-manifest.schema.json +108 -24
- package/schemas/chat-card-pack-manifest.schema.json +125 -30
- package/schemas/connection-pack-manifest.schema.json +211 -38
- package/schemas/form-content-pack-manifest.schema.json +243 -0
- package/schemas/frontend-plugin-manifest.schema.json +10 -0
- package/schemas/node-pack-manifest.schema.json +311 -68
- package/schemas/prompt-pack-manifest.schema.json +48 -11
- package/schemas/registry-version-manifest.schema.json +16 -5
- package/schemas/workflow-chain-pack-manifest.schema.json +176 -40
- package/src/lib/artifactTypes.ts +76 -3
- package/src/lib/behavior-gate.ts +17 -0
- package/src/lib/cardPacks.ts +10 -3
- package/src/lib/formContentPacks.ts +133 -0
- package/src/lib/workflow-chain-expansion.ts +27 -0
- package/src/scenarios/artifact-type-pack-install.test.ts +17 -6
- package/src/scenarios/artifact-type-store-without-render.test.ts +17 -4
- package/src/scenarios/chat-card-pack-execution.test.ts +17 -5
- package/src/scenarios/form-content-instantiation.test.ts +168 -0
- package/src/scenarios/form-content-packs.test.ts +415 -0
- package/src/scenarios/pack-manifest-extension-opacity.test.ts +179 -0
- package/src/scenarios/pack-manifest-extensions.test.ts +203 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form-content packs (RFC 0137, `Active`).
|
|
3
|
+
*
|
|
4
|
+
* A form-content pack (`kind: "form-content"`) distributes FORM TEMPLATES — a
|
|
5
|
+
* named, versioned, ordered set of typed input fields a host instantiates into
|
|
6
|
+
* an ordinary, fully editable form through its normal create path. It is the
|
|
7
|
+
* sixth declarative pack kind under RFC 0107 and is purely inert: no `runtime`,
|
|
8
|
+
* no entry point, no handler, no submission surface the host would not
|
|
9
|
+
* otherwise accept.
|
|
10
|
+
*
|
|
11
|
+
* Always-on + server-free. Three parts:
|
|
12
|
+
*
|
|
13
|
+
* PART 1 — contract present. `form-content-packs.md` carries the
|
|
14
|
+
* instantiation rules + the F1 trust boundary; `registry-operations.md`
|
|
15
|
+
* §"Validation flow" selects the per-kind source schema for `form-content`
|
|
16
|
+
* and skips the runtime check for it. Guards against the requirement being
|
|
17
|
+
* silently dropped.
|
|
18
|
+
*
|
|
19
|
+
* PART 2 — the version-manifest schema admits the kind and still rejects
|
|
20
|
+
* malformed ones. Includes an explicit leg for the `anyOf` payload gate:
|
|
21
|
+
* extending the `kind` enum and declaring `templates` is NECESSARY BUT NOT
|
|
22
|
+
* SUFFICIENT — without a `templates` branch in `anyOf`, every form-content
|
|
23
|
+
* manifest is rejected. That omission is believed to be the second face of
|
|
24
|
+
* the CI failure that motivated RFC 0137, so it gets its own assertion.
|
|
25
|
+
*
|
|
26
|
+
* PART 3 — the field vocabulary is SHARED, not forked. `fields[].type` in
|
|
27
|
+
* `form-content-pack-manifest.schema.json` MUST be byte-identical to
|
|
28
|
+
* `InputField.type` in `chat-card-pack-manifest.schema.json`. Two declarative
|
|
29
|
+
* kinds that both collect typed user input, rendered by the same host
|
|
30
|
+
* machinery, MUST agree on what a field type means. This is the regression
|
|
31
|
+
* guard for RFC 0137 R2: it fails the moment either kind's vocabulary is
|
|
32
|
+
* widened alone.
|
|
33
|
+
*
|
|
34
|
+
* @see spec/v1/form-content-packs.md
|
|
35
|
+
* @see spec/v1/chat-card-packs.md §"Input fields — a closed portable subset"
|
|
36
|
+
* @see spec/v1/registry-operations.md §"Validation flow"
|
|
37
|
+
* @see schemas/form-content-pack-manifest.schema.json
|
|
38
|
+
* @see RFCS/0137-form-content-packs.md, RFCS/0107-publishable-declarative-pack-kinds.md
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { describe, it, expect } from 'vitest';
|
|
42
|
+
import { readFileSync } from 'node:fs';
|
|
43
|
+
import { join } from 'node:path';
|
|
44
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
45
|
+
import addFormats from 'ajv-formats';
|
|
46
|
+
import { SCHEMAS_DIR, V1_DIR } from '../lib/paths.js';
|
|
47
|
+
|
|
48
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
49
|
+
|
|
50
|
+
const readSchema = (name: string): Record<string, unknown> =>
|
|
51
|
+
JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8'));
|
|
52
|
+
|
|
53
|
+
describe('form-content-packs: contract present in the corpus (RFC 0137, server-free)', () => {
|
|
54
|
+
const registryDoc = V1_DIR ? readFileSync(join(V1_DIR, 'registry-operations.md'), 'utf8') : '';
|
|
55
|
+
const formDoc = V1_DIR ? readFileSync(join(V1_DIR, 'form-content-packs.md'), 'utf8') : '';
|
|
56
|
+
|
|
57
|
+
it.skipIf(V1_DIR === null)('registry-operations.md §Validation flow selects the form-content source schema by `kind`', () => {
|
|
58
|
+
expect(
|
|
59
|
+
/form-content[\s\S]{0,160}form-content-pack-manifest\.schema\.json/.test(registryDoc),
|
|
60
|
+
why('registry-operations.md §Validation flow #3', '`kind: "form-content"` validates against its own source schema (RFC 0137)'),
|
|
61
|
+
).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it.skipIf(V1_DIR === null)('registry-operations.md skips the runtime-support check for form-content', () => {
|
|
65
|
+
expect(
|
|
66
|
+
/declarative[\s\S]{0,200}form-content/.test(registryDoc),
|
|
67
|
+
why('registry-operations.md §Validation flow #7', 'form-content is a declarative kind — the runtime check is skipped'),
|
|
68
|
+
).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it.skipIf(V1_DIR === null)('registry-operations.md extends the declarative-id denormalization to templateId', () => {
|
|
72
|
+
expect(
|
|
73
|
+
/templates\[\]\.templateId/.test(registryDoc),
|
|
74
|
+
why('registry-operations.md §Type-ID indexing', 'a registry SHOULD denormalize `templates[].templateId` (RFC 0137)'),
|
|
75
|
+
).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it.skipIf(V1_DIR === null)('form-content-packs.md requires the host to use its NORMAL create path and execute nothing', () => {
|
|
79
|
+
expect(
|
|
80
|
+
/MUST[\s\S]{0,120}normal[\s\S]{0,40}create path/i.test(formDoc),
|
|
81
|
+
why('form-content-packs.md §Instantiation', 'the host MUST instantiate through its normal create path'),
|
|
82
|
+
).toBe(true);
|
|
83
|
+
expect(
|
|
84
|
+
/MUST NOT execute anything from the pack/i.test(formDoc),
|
|
85
|
+
why('form-content-packs.md §Instantiation', 'the host MUST NOT execute anything from the pack — the kind is inert'),
|
|
86
|
+
).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it.skipIf(V1_DIR === null)('form-content-packs.md carries the F1 trust boundary, incl. "a signature is not content trust"', () => {
|
|
90
|
+
expect(
|
|
91
|
+
/untrusted/i.test(formDoc) && /contentTrust/.test(formDoc),
|
|
92
|
+
why('form-content-packs.md §Trust boundary', 'pack-authored strings are untrusted; prompts propagate meta.contentTrust'),
|
|
93
|
+
).toBe(true);
|
|
94
|
+
expect(
|
|
95
|
+
/signature proves[\s\S]{0,80}not[\s\S]{0,60}trustworthy|MUST NOT treat pack provenance as content trust/i.test(formDoc),
|
|
96
|
+
why('form-content-packs.md §Trust boundary', 'a signature proves authorship, NOT that the authored bytes are safe'),
|
|
97
|
+
).toBe(true);
|
|
98
|
+
expect(
|
|
99
|
+
/Length bounds are not a trust boundary/i.test(formDoc),
|
|
100
|
+
why('form-content-packs.md §Trust boundary', 'maxLength is a resource guard, NOT sanitization'),
|
|
101
|
+
).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it.skipIf(V1_DIR === null)('the spec distinguishes DEGRADE (well-formed extension) from REFUSE (malformed value)', () => {
|
|
105
|
+
expect(
|
|
106
|
+
/Degrade applies to \*extensions\*, not to malformed values/i.test(formDoc),
|
|
107
|
+
why('form-content-packs.md §Instantiation', 'MUST-degrade is scoped to vendor.*/x- extensions, not bare unknowns'),
|
|
108
|
+
).toBe(true);
|
|
109
|
+
expect(
|
|
110
|
+
/MUST NOT collapse these into one rule in either direction/i.test(formDoc),
|
|
111
|
+
why(
|
|
112
|
+
'form-content-packs.md §Instantiation',
|
|
113
|
+
'refusing a well-formed extension breaks forward compat; degrading a malformed value hides an authoring error',
|
|
114
|
+
),
|
|
115
|
+
).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it.skipIf(V1_DIR === null)('form-content-packs.md forbids minting a second field-type vocabulary', () => {
|
|
119
|
+
expect(
|
|
120
|
+
/MUST NOT\W{0,4}\s*define its own field-type vocabulary/i.test(formDoc),
|
|
121
|
+
why('form-content-packs.md §Field types', 'the kind reuses the RFC 0071 portable subset rather than defining its own'),
|
|
122
|
+
).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('form-content-packs: version-manifest schema admits the kind (RFC 0137, server-free)', () => {
|
|
127
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
128
|
+
addFormats(ajv);
|
|
129
|
+
const versionManifestSchema = readSchema('registry-version-manifest.schema.json');
|
|
130
|
+
const validate = ajv.compile(versionManifestSchema);
|
|
131
|
+
|
|
132
|
+
const base = { name: 'core.openwop.forms', version: '1.0.0', engines: { openwop: '>=1.0.0' }, integrity: 'sha256-abc=' };
|
|
133
|
+
const template = {
|
|
134
|
+
templateId: 'core.openwop.form.rsvp',
|
|
135
|
+
version: '1.0.0',
|
|
136
|
+
label: 'RSVP',
|
|
137
|
+
title: 'Will you be joining us?',
|
|
138
|
+
fields: [{ id: 'guestName', type: 'text', label: 'Your name' }],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
it('`form-content` is in the kind enum and `templates` is a declared property', () => {
|
|
142
|
+
const props = (versionManifestSchema.properties ?? {}) as Record<string, { enum?: string[] }>;
|
|
143
|
+
expect(props.kind?.enum, why('registry-version-manifest.schema.json', '`form-content` joins the kind enum (RFC 0137)')).toEqual(
|
|
144
|
+
expect.arrayContaining(['node', 'artifact-type', 'connection', 'card', 'form-content']),
|
|
145
|
+
);
|
|
146
|
+
expect(!!props.templates, why('registry-version-manifest.schema.json', '`templates` payload property declared (additionalProperties:false)')).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('the `anyOf` payload gate carries a `templates` branch (NECESSARY — enum + property alone are not sufficient)', () => {
|
|
150
|
+
const anyOf = (versionManifestSchema.anyOf ?? []) as Array<{ required?: string[] }>;
|
|
151
|
+
expect(
|
|
152
|
+
anyOf.some((branch) => (branch.required ?? []).includes('templates')),
|
|
153
|
+
why('registry-version-manifest.schema.json §anyOf', 'without a `templates` branch every form-content manifest is rejected (RFC 0137 §Proposal 1)'),
|
|
154
|
+
).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('a published form-content version manifest validates (kind + templates, no runtime)', () => {
|
|
158
|
+
const ok = validate({ ...base, kind: 'form-content', templates: [template] });
|
|
159
|
+
expect(ok, why('registry-operations.md §Validation flow', 'form-content manifest publishes (RFC 0137)')).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('a form-content manifest carrying `runtime` is REJECTED (declarative kinds carry no runtime)', () => {
|
|
163
|
+
const ok = validate({ ...base, kind: 'form-content', templates: [template], runtime: { language: 'javascript' } });
|
|
164
|
+
expect(ok, why('registry-version-manifest.schema.json §allOf', 'a declarative kind MUST NOT carry runtime')).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('an unchanged node version manifest still validates (RFC 0137 is additive)', () => {
|
|
168
|
+
const ok = validate({
|
|
169
|
+
...base,
|
|
170
|
+
runtime: { language: 'javascript' },
|
|
171
|
+
nodes: [{ typeId: 'core.openwop.x.n', version: '1.0.0', category: 'data', role: 'pure' }],
|
|
172
|
+
});
|
|
173
|
+
expect(ok, why('COMPATIBILITY.md §2.1', 'RFC 0137 is additive — node manifests validate unchanged')).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('form-content-packs: source manifest contract (RFC 0137, server-free)', () => {
|
|
178
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
179
|
+
addFormats(ajv);
|
|
180
|
+
const sourceSchema = readSchema('form-content-pack-manifest.schema.json');
|
|
181
|
+
const validate = ajv.compile(sourceSchema);
|
|
182
|
+
|
|
183
|
+
const pack = (templates: unknown[]): Record<string, unknown> => ({
|
|
184
|
+
name: 'core.openwop.forms.starters',
|
|
185
|
+
version: '1.0.0',
|
|
186
|
+
kind: 'form-content',
|
|
187
|
+
engines: { openwop: '>=1.1.0 <2.0.0' },
|
|
188
|
+
templates,
|
|
189
|
+
});
|
|
190
|
+
const field = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
|
|
191
|
+
id: 'guestName',
|
|
192
|
+
type: 'text',
|
|
193
|
+
label: 'Your name',
|
|
194
|
+
...over,
|
|
195
|
+
});
|
|
196
|
+
const template = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
|
|
197
|
+
templateId: 'core.openwop.form.rsvp',
|
|
198
|
+
version: '1.0.0',
|
|
199
|
+
label: 'RSVP',
|
|
200
|
+
title: 'Will you be joining us?',
|
|
201
|
+
fields: [field()],
|
|
202
|
+
...over,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('schema discipline: draft 2020-12, canonical $id, closed objects', () => {
|
|
206
|
+
expect(sourceSchema.$schema, why('CONTRIBUTING.md §JSON Schemas', 'draft 2020-12')).toBe(
|
|
207
|
+
'https://json-schema.org/draft/2020-12/schema',
|
|
208
|
+
);
|
|
209
|
+
expect(sourceSchema.$id, why('CONTRIBUTING.md §JSON Schemas', 'canonical $id URL')).toBe(
|
|
210
|
+
'https://openwop.dev/spec/v1/form-content-pack-manifest.schema.json',
|
|
211
|
+
);
|
|
212
|
+
expect(sourceSchema.additionalProperties, why('CONTRIBUTING.md §JSON Schemas', 'additionalProperties:false')).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('a well-formed form-content pack validates', () => {
|
|
216
|
+
expect(validate(pack([template()])), why('form-content-packs.md §Manifest format', 'the canonical example validates')).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('the full portable field-type subset is accepted', () => {
|
|
220
|
+
for (const type of ['text', 'longtext', 'number', 'boolean', 'select', 'multiselect', 'file', 'artifact-ref']) {
|
|
221
|
+
expect(
|
|
222
|
+
validate(pack([template({ fields: [field({ type })] })])),
|
|
223
|
+
why('chat-card-packs.md §Input fields', `portable type \`${type}\` is accepted (RFC 0071 G9 subset)`),
|
|
224
|
+
).toBe(true);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('vendor.* and x- host extensions are accepted (other hosts degrade to plain text)', () => {
|
|
229
|
+
for (const type of ['vendor.myndhyve.color', 'x-signature-pad']) {
|
|
230
|
+
expect(
|
|
231
|
+
validate(pack([template({ fields: [field({ type })] })])),
|
|
232
|
+
why('form-content-packs.md §Field types', `host extension \`${type}\` is accepted`),
|
|
233
|
+
).toBe(true);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('`email` and `textarea` are REJECTED as field types (they are a format and a widget)', () => {
|
|
238
|
+
expect(
|
|
239
|
+
validate(pack([template({ fields: [field({ type: 'email' })] })])),
|
|
240
|
+
why('form-content-packs.md §Validation formats are not types', '`email` is a format constraint, not a data kind — use text + format'),
|
|
241
|
+
).toBe(false);
|
|
242
|
+
expect(
|
|
243
|
+
validate(pack([template({ fields: [field({ type: 'textarea' })] })])),
|
|
244
|
+
why('chat-card-packs.md §Input fields', '`textarea` is a widget name — the portable data kind is `longtext`'),
|
|
245
|
+
).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('`format: "email"` on a text field IS accepted (the supported spelling)', () => {
|
|
249
|
+
expect(
|
|
250
|
+
validate(pack([template({ fields: [field({ id: 'email', type: 'text', format: 'email' })] })])),
|
|
251
|
+
why('form-content-packs.md §Validation formats are not types', 'email validation rides `format`, not `type`'),
|
|
252
|
+
).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('a field using `key` instead of `id` is REJECTED (aligns with chat-card InputField.id)', () => {
|
|
256
|
+
const bad = { key: 'guestName', type: 'text', label: 'Your name' };
|
|
257
|
+
expect(
|
|
258
|
+
validate(pack([template({ fields: [bad] })])),
|
|
259
|
+
why('form-content-packs.md §Manifest format', 'the field identifier is `id`, not `key`'),
|
|
260
|
+
).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('an integer `templates[].version` is REJECTED (SemVer axis, not the integer schemaVersion axis)', () => {
|
|
264
|
+
expect(
|
|
265
|
+
validate(pack([template({ version: 3 })])),
|
|
266
|
+
why('form-content-packs.md §Manifest format', '`templates[].version` is SemVer 2.0.0'),
|
|
267
|
+
).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('an empty `fields[]` and an empty `templates[]` are REJECTED', () => {
|
|
271
|
+
expect(validate(pack([template({ fields: [] })])), why('form-content-pack-manifest.schema.json', 'a template MUST declare ≥1 field')).toBe(false);
|
|
272
|
+
expect(validate(pack([])), why('form-content-pack-manifest.schema.json', 'a pack MUST declare ≥1 template')).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('a `runtime` block is REJECTED at the source manifest too (the kind is inert)', () => {
|
|
276
|
+
expect(
|
|
277
|
+
validate({ ...pack([template()]), runtime: { language: 'javascript' } }),
|
|
278
|
+
why('form-content-packs.md §Pack kind', 'a form-content pack carries no runtime'),
|
|
279
|
+
).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('form-content-packs: a template carries NO submission routing (RFC 0137 §F2, invariant)', () => {
|
|
284
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
285
|
+
addFormats(ajv);
|
|
286
|
+
const sourceSchema = readSchema('form-content-pack-manifest.schema.json') as {
|
|
287
|
+
$defs: { FormTemplate: { properties: Record<string, unknown>; additionalProperties?: boolean } };
|
|
288
|
+
};
|
|
289
|
+
const validate = ajv.compile(sourceSchema);
|
|
290
|
+
const formDoc = V1_DIR ? readFileSync(join(V1_DIR, 'form-content-packs.md'), 'utf8') : '';
|
|
291
|
+
|
|
292
|
+
const withTemplateKey = (key: string, value: unknown): Record<string, unknown> => ({
|
|
293
|
+
name: 'core.openwop.forms.starters',
|
|
294
|
+
version: '1.0.0',
|
|
295
|
+
kind: 'form-content',
|
|
296
|
+
engines: { openwop: '>=1.1.0 <2.0.0' },
|
|
297
|
+
templates: [
|
|
298
|
+
{
|
|
299
|
+
templateId: 'core.openwop.form.rsvp',
|
|
300
|
+
version: '1.0.0',
|
|
301
|
+
label: 'RSVP',
|
|
302
|
+
title: 'RSVP',
|
|
303
|
+
fields: [{ id: 'a', type: 'text', label: 'A' }],
|
|
304
|
+
[key]: value,
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('FormTemplate declares NO routing-shaped property and is closed', () => {
|
|
310
|
+
const props = Object.keys(sourceSchema.$defs.FormTemplate.properties);
|
|
311
|
+
for (const banned of ['intakeBinding', 'destination', 'webhook', 'webhookUrl', 'listId', 'mailbox', 'crmObject', 'routing', 'submitTo']) {
|
|
312
|
+
expect(props, why('form-content-packs.md §No submission routing', `FormTemplate MUST NOT declare a \`${banned}\` property`)).not.toContain(banned);
|
|
313
|
+
}
|
|
314
|
+
expect(
|
|
315
|
+
sourceSchema.$defs.FormTemplate.additionalProperties,
|
|
316
|
+
why('form-content-packs.md §No submission routing', 'FormTemplate is closed, so an undeclared routing key is rejected'),
|
|
317
|
+
).toBe(false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('a template attempting to carry routing config is REJECTED', () => {
|
|
321
|
+
for (const [key, value] of [
|
|
322
|
+
['intakeBinding', { listId: 'abc' }],
|
|
323
|
+
['destination', 'https://attacker.example/collect'],
|
|
324
|
+
['webhookUrl', 'https://attacker.example/hook'],
|
|
325
|
+
] as Array<[string, unknown]>) {
|
|
326
|
+
expect(
|
|
327
|
+
validate(withTemplateKey(key, value)),
|
|
328
|
+
why('form-content-packs.md §No submission routing', `a pack MUST NOT bind a submission destination via \`${key}\``),
|
|
329
|
+
).toBe(false);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it.skipIf(V1_DIR === null)('the spec scopes the routing ban to the PACK, leaving operator-configured routing free', () => {
|
|
334
|
+
expect(
|
|
335
|
+
/This constrains the pack, not the host/i.test(formDoc),
|
|
336
|
+
why('form-content-packs.md §No submission routing', 'a host MAY route wherever its OPERATOR configures; only pack-declared routing is banned'),
|
|
337
|
+
).toBe(true);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it.skipIf(V1_DIR === null)('the spec binds FUTURE routing surfaces to operator consent, not pack declaration', () => {
|
|
341
|
+
expect(
|
|
342
|
+
/MUST NOT let a pack bind a destination unilaterally|routing MUST be a host-side decision/i.test(formDoc),
|
|
343
|
+
why('form-content-packs.md §No submission routing', 'a later routing RFC MUST keep the decision host-side, behind operator consent'),
|
|
344
|
+
).toBe(true);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe('form-content-packs: identifier uniqueness + resource bounds (RFC 0137 amendment)', () => {
|
|
349
|
+
const formDoc = V1_DIR ? readFileSync(join(V1_DIR, 'form-content-packs.md'), 'utf8') : '';
|
|
350
|
+
const sourceSchema = readSchema('form-content-pack-manifest.schema.json') as {
|
|
351
|
+
properties: { templates: { maxItems: number } };
|
|
352
|
+
$defs: {
|
|
353
|
+
FormTemplate: { properties: { fields: { maxItems: number } } };
|
|
354
|
+
FormField: { properties: { label: { maxLength: number }; options: { maxItems: number } } };
|
|
355
|
+
};
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
it.skipIf(V1_DIR === null)('duplicate `fields[].id` is a normative refusal, framed as data integrity', () => {
|
|
359
|
+
expect(
|
|
360
|
+
/each `fields\[\]\.id` MUST be unique within its template/i.test(formDoc),
|
|
361
|
+
why('form-content-packs.md §Unique identifiers', 'duplicate field ids MUST be refused'),
|
|
362
|
+
).toBe(true);
|
|
363
|
+
expect(
|
|
364
|
+
/silently overwrite/i.test(formDoc),
|
|
365
|
+
why('form-content-packs.md §Unique identifiers', 'the rationale is silent data loss, not style'),
|
|
366
|
+
).toBe(true);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('resource bounds admit real-world content (long consent labels, a country list)', () => {
|
|
370
|
+
expect(
|
|
371
|
+
sourceSchema.$defs.FormField.properties.label.maxLength,
|
|
372
|
+
why('form-content-pack-manifest.schema.json', 'a lawful consent label is legitimately long-form'),
|
|
373
|
+
).toBeGreaterThanOrEqual(1000);
|
|
374
|
+
expect(
|
|
375
|
+
sourceSchema.$defs.FormField.properties.options.maxItems,
|
|
376
|
+
why('form-content-pack-manifest.schema.json', 'a country list is ~195 entries — the cap must clear it'),
|
|
377
|
+
).toBeGreaterThanOrEqual(250);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('outer resource caps exist on both arrays (render-bomb guard, not product policy)', () => {
|
|
381
|
+
expect(sourceSchema.properties.templates.maxItems, why('form-content-pack-manifest.schema.json', 'templates[] carries an outer cap')).toBeGreaterThan(0);
|
|
382
|
+
expect(sourceSchema.$defs.FormTemplate.properties.fields.maxItems, why('form-content-pack-manifest.schema.json', 'fields[] carries an outer cap')).toBeGreaterThan(0);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe('form-content-packs: the field vocabulary is SHARED with chat-card packs, not forked (RFC 0137 R2)', () => {
|
|
387
|
+
const formSchema = readSchema('form-content-pack-manifest.schema.json') as {
|
|
388
|
+
$defs: { FormField: { properties: { type: { pattern: string } } } };
|
|
389
|
+
};
|
|
390
|
+
const cardSchema = readSchema('chat-card-pack-manifest.schema.json') as {
|
|
391
|
+
$defs: { InputField: { properties: { type: { pattern: string } } } };
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
it('`fields[].type` and `inputs[].type` share one byte-identical pattern', () => {
|
|
395
|
+
const formPattern = formSchema.$defs.FormField.properties.type.pattern;
|
|
396
|
+
const cardPattern = cardSchema.$defs.InputField.properties.type.pattern;
|
|
397
|
+
expect(
|
|
398
|
+
formPattern,
|
|
399
|
+
why(
|
|
400
|
+
'form-content-packs.md §Field types',
|
|
401
|
+
'RFC 0137 reuses the RFC 0071 portable subset VERBATIM — two input-collecting declarative kinds MUST agree on what a field type means. If this fails, one kind\'s vocabulary was widened without the other and the wire contract has forked.',
|
|
402
|
+
),
|
|
403
|
+
).toBe(cardPattern);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('the shared pattern still admits every portable data kind and both extension prefixes', () => {
|
|
407
|
+
const pattern = new RegExp(formSchema.$defs.FormField.properties.type.pattern);
|
|
408
|
+
for (const type of ['text', 'longtext', 'number', 'boolean', 'select', 'multiselect', 'file', 'artifact-ref']) {
|
|
409
|
+
expect(pattern.test(type), why('chat-card-packs.md §Input fields', `\`${type}\` is in the portable subset`)).toBe(true);
|
|
410
|
+
}
|
|
411
|
+
expect(pattern.test('vendor.acme.rating'), why('chat-card-packs.md §Input fields', 'vendor.<org>.<kind> extensions are admitted')).toBe(true);
|
|
412
|
+
expect(pattern.test('x-rating'), why('chat-card-packs.md §Input fields', 'x-<kind> extensions are admitted')).toBe(true);
|
|
413
|
+
expect(pattern.test('textarea'), why('chat-card-packs.md §Input fields', 'widget names are NOT in the subset')).toBe(false);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pack-manifest-extension-opacity — RFC 0139, the host-side witness for
|
|
3
|
+
* RFC 0138's "ignore means ignore" clause.
|
|
4
|
+
*
|
|
5
|
+
* RFC 0138 made pack manifests carry `^(x-|vendor\.)` extensions and defined
|
|
6
|
+
* ignoring one normatively: a consumer MUST NOT render it, execute it,
|
|
7
|
+
* interpret it as markup or a templating directive, use it to select a code
|
|
8
|
+
* path, or persist it into a surface where it will later be interpreted.
|
|
9
|
+
* All 19 of RFC 0138's assertions are SERVER-FREE — they check that schemas
|
|
10
|
+
* admit the hatch and that the corpus states the rule. **Nothing verified any
|
|
11
|
+
* of it against a host.**
|
|
12
|
+
*
|
|
13
|
+
* ## Why presence is not the assertion
|
|
14
|
+
*
|
|
15
|
+
* The obvious leg — install an extension-bearing manifest, assert 2xx —
|
|
16
|
+
* proves almost nothing. A host that stores `x-evil.template` and later
|
|
17
|
+
* interpolates it into a rendered surface PASSES it. That is precisely the
|
|
18
|
+
* failure RFC 0138 calls "strictly worse than no hatch", because it converts
|
|
19
|
+
* a loud publication failure into a silent injection surface.
|
|
20
|
+
*
|
|
21
|
+
* ## The differential
|
|
22
|
+
*
|
|
23
|
+
* The load-bearing leg is **leg 3**: install the same manifest with and
|
|
24
|
+
* without an unrecognized extension and require the host's registration
|
|
25
|
+
* projection to be IDENTICAL (modulo the extension properties themselves).
|
|
26
|
+
*
|
|
27
|
+
* This is sink-agnostic. It does not ask WHERE an extension might leak — a
|
|
28
|
+
* suite cannot enumerate a host's sinks — it asks whether the host's
|
|
29
|
+
* observable behavior is a function of the extension at all. If it is not,
|
|
30
|
+
* every install-time sink is covered at once.
|
|
31
|
+
*
|
|
32
|
+
* The extension namespace is `vendor.conformance.*`, which no host can claim
|
|
33
|
+
* to recognize, so the UNRECOGNIZED branch is the one exercised. A host that
|
|
34
|
+
* recognizes and acts on its own extension is out of scope for this rule.
|
|
35
|
+
*
|
|
36
|
+
* ## WHAT THIS DOES NOT DISCRIMINATE — read before citing a green run
|
|
37
|
+
*
|
|
38
|
+
* - **A host that stores the extension and interprets it later**, at a
|
|
39
|
+
* moment this seam never reaches. Leg 3 covers INSTALL-TIME sinks only.
|
|
40
|
+
* No finite suite closes this; see RFC 0139 gap G3.
|
|
41
|
+
* - **A host that fakes the seam** by returning a constant projection.
|
|
42
|
+
* Legs 2 and 5 constrain the projection to be a function of something,
|
|
43
|
+
* but host-sample seams measure COOPERATING hosts — they are not an
|
|
44
|
+
* adversarial control (RFC 0139 risk R3).
|
|
45
|
+
* - **Pack kinds other than artifact-type.** Only this seam has a real host
|
|
46
|
+
* behind it; a speculative leg on an unimplemented kind would soft-skip
|
|
47
|
+
* everywhere and add coverage theatre (gap G2).
|
|
48
|
+
* - **`artifact.created` carrying `artifactType`** (RFC 0138 gap G8). This
|
|
49
|
+
* seam emits no run events — the reference host emits `artifact.created`
|
|
50
|
+
* only from a real run, not from `persistRunArtifact`. Explicitly out of
|
|
51
|
+
* scope rather than folded in to produce a green that covers nothing.
|
|
52
|
+
*
|
|
53
|
+
* Gated on `behaviorGate`, never a bare `return`: a host that advertises
|
|
54
|
+
* `artifactTypes.supported` and serves no seam FAILS under
|
|
55
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true` rather than reporting green.
|
|
56
|
+
*
|
|
57
|
+
* @see RFCS/0139-extension-opacity-host-witness.md
|
|
58
|
+
* @see spec/v1/node-packs.md §"Vendor extensions on pack manifests"
|
|
59
|
+
* @see SECURITY/invariants.yaml `pack-manifest-extension-opaque`
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { describe, it, expect } from 'vitest';
|
|
63
|
+
import { driver } from '../lib/driver.js';
|
|
64
|
+
import { behaviorGate, behaviorGatePresent } from '../lib/behavior-gate.js';
|
|
65
|
+
import {
|
|
66
|
+
readArtifactTypesCap,
|
|
67
|
+
artifactTypesSupported,
|
|
68
|
+
installArtifactTypePack,
|
|
69
|
+
sampleArtifactTypePack,
|
|
70
|
+
stripExtensions,
|
|
71
|
+
withExtensions,
|
|
72
|
+
canonicalJson,
|
|
73
|
+
} from '../lib/artifactTypes.js';
|
|
74
|
+
|
|
75
|
+
const PROFILE = 'openwop-artifact-type-packs';
|
|
76
|
+
|
|
77
|
+
/** An extension no host can claim to recognize — forces the unrecognized branch. */
|
|
78
|
+
const INERT_EXT = { 'vendor.conformance.opacity': { note: 'unrecognized by construction' } };
|
|
79
|
+
|
|
80
|
+
/** An extension whose VALUE is markup + a templating directive. */
|
|
81
|
+
const HOSTILE_EXT = {
|
|
82
|
+
'vendor.conformance.hostile': {
|
|
83
|
+
markup: '<img src=x onerror="alert(1)">',
|
|
84
|
+
template: '{{constructor.constructor("return 1")()}}',
|
|
85
|
+
directive: '${jndi:ldap://example.invalid/a}',
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Installs `manifest`, returning the projection or null when the seam is absent. */
|
|
90
|
+
async function install(manifest: unknown): Promise<{ status: number; json: unknown } | null> {
|
|
91
|
+
const { artifactTypeId, schema } = sampleArtifactTypePack();
|
|
92
|
+
return installArtifactTypePack(manifest, { [artifactTypeId]: schema });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ok = (status: number): boolean => status >= 200 && status < 300;
|
|
96
|
+
|
|
97
|
+
describe('pack-manifest-extension-opacity: a host MUST accept an extended manifest (RFC 0139)', () => {
|
|
98
|
+
it('leg 1 — an extension-bearing manifest installs', async () => {
|
|
99
|
+
if (!behaviorGate(PROFILE, artifactTypesSupported(await readArtifactTypesCap()))) return;
|
|
100
|
+
const { manifest } = sampleArtifactTypePack();
|
|
101
|
+
const res = await install(withExtensions(manifest, INERT_EXT));
|
|
102
|
+
if (!behaviorGatePresent(PROFILE, res)) return; // seam absent: skip default, FAIL strict
|
|
103
|
+
expect(
|
|
104
|
+
ok(res.status),
|
|
105
|
+
driver.describe('node-packs.md §"Vendor extensions on pack manifests"', 'a consumer MUST ignore an unrecognized extension and MUST NOT reject the pack for its presence'),
|
|
106
|
+
).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('leg 2 — the same manifest WITHOUT the extension installs (baseline)', async () => {
|
|
110
|
+
if (!behaviorGate(PROFILE, artifactTypesSupported(await readArtifactTypesCap()))) return;
|
|
111
|
+
const { manifest } = sampleArtifactTypePack();
|
|
112
|
+
const res = await install(manifest);
|
|
113
|
+
if (!behaviorGatePresent(PROFILE, res)) return;
|
|
114
|
+
expect(
|
|
115
|
+
ok(res.status),
|
|
116
|
+
driver.describe('RFC 0139 §Conformance', 'baseline — guards against a differential that passes because EVERYTHING fails'),
|
|
117
|
+
).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('pack-manifest-extension-opacity: the differential — behavior MUST NOT be a function of the extension (RFC 0139)', () => {
|
|
122
|
+
it('leg 3 — projections for M and M′ are equal modulo the extensions [LOAD-BEARING]', async () => {
|
|
123
|
+
if (!behaviorGate(PROFILE, artifactTypesSupported(await readArtifactTypesCap()))) return;
|
|
124
|
+
const { manifest } = sampleArtifactTypePack();
|
|
125
|
+
|
|
126
|
+
const plain = await install(manifest);
|
|
127
|
+
if (!behaviorGatePresent(PROFILE, plain)) return;
|
|
128
|
+
const extended = await install(withExtensions(manifest, INERT_EXT));
|
|
129
|
+
if (!behaviorGatePresent(PROFILE, extended)) return;
|
|
130
|
+
|
|
131
|
+
expect(
|
|
132
|
+
ok(plain.status) === ok(extended.status),
|
|
133
|
+
driver.describe('RFC 0139 §"The differential-install contract"', 'acceptance MUST NOT be a function of an unrecognized extension — a host MUST accept both or reject both for the same reason'),
|
|
134
|
+
).toBe(true);
|
|
135
|
+
|
|
136
|
+
expect(
|
|
137
|
+
canonicalJson(stripExtensions(extended.json)),
|
|
138
|
+
driver.describe('RFC 0139 §"The differential-install contract"', 'the registration projection MUST be identical modulo the extension properties — a difference means the host acted on an extension it does not recognize (rendering catalog, derived facet, or code-path switch)'),
|
|
139
|
+
).toBe(canonicalJson(stripExtensions(plain.json)));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('leg 4 — an extension carrying markup / a templating directive is not interpreted at install', async () => {
|
|
143
|
+
if (!behaviorGate(PROFILE, artifactTypesSupported(await readArtifactTypesCap()))) return;
|
|
144
|
+
const { manifest } = sampleArtifactTypePack();
|
|
145
|
+
|
|
146
|
+
const plain = await install(manifest);
|
|
147
|
+
if (!behaviorGatePresent(PROFILE, plain)) return;
|
|
148
|
+
const hostile = await install(withExtensions(manifest, HOSTILE_EXT));
|
|
149
|
+
if (!behaviorGatePresent(PROFILE, hostile)) return;
|
|
150
|
+
|
|
151
|
+
expect(
|
|
152
|
+
ok(hostile.status),
|
|
153
|
+
driver.describe('node-packs.md §"Vendor extensions on pack manifests"', 'an extension value is PACK-AUTHORED and therefore untrusted, but untrusted is not a rejection reason — the host MUST install and ignore'),
|
|
154
|
+
).toBe(true);
|
|
155
|
+
|
|
156
|
+
expect(
|
|
157
|
+
canonicalJson(stripExtensions(hostile.json)),
|
|
158
|
+
driver.describe('node-packs.md §"Vendor extensions on pack manifests"', 'MUST NOT render it, execute it, or interpret it as markup or a templating directive — a projection that differs from baseline means the value reached an interpreter'),
|
|
159
|
+
).toBe(canonicalJson(stripExtensions(plain.json)));
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('pack-manifest-extension-opacity: the HOST reader stays narrow (RFC 0139 leg 5)', () => {
|
|
164
|
+
it('leg 5 — a misspelled canonical field is still rejected by the host, not merely by the schema', async () => {
|
|
165
|
+
if (!behaviorGate(PROFILE, artifactTypesSupported(await readArtifactTypesCap()))) return;
|
|
166
|
+
const { manifest } = sampleArtifactTypePack();
|
|
167
|
+
|
|
168
|
+
// `dispalyName` matches no canonical property and no extension pattern.
|
|
169
|
+
const typo = JSON.parse(JSON.stringify(manifest)) as Record<string, unknown>;
|
|
170
|
+
(typo['artifactTypes'] as Record<string, unknown>[])[0]!['dispalyName'] = 'Note';
|
|
171
|
+
|
|
172
|
+
const res = await install(typo);
|
|
173
|
+
if (!behaviorGatePresent(PROFILE, res)) return;
|
|
174
|
+
expect(
|
|
175
|
+
ok(res.status),
|
|
176
|
+
driver.describe('node-packs.md §"Vendor extensions on pack manifests"', 'the hatch admits DECLARED extensions, not arbitrary keys — a host that widened its own reader to additionalProperties:true to "support extensions" accepts everything, which is not the same as accepting extensions'),
|
|
177
|
+
).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
});
|