@ggui-ai/protocol 0.9.0 → 0.11.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/dist/gadgets/stdlib-gadgets.d.ts +1 -1
- package/dist/gadgets/stdlib-gadgets.d.ts.map +1 -1
- package/dist/gadgets/stdlib-gadgets.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/integrations/mcp-apps.d.ts +50 -1
- package/dist/integrations/mcp-apps.d.ts.map +1 -1
- package/dist/integrations/mcp-apps.js +67 -1
- package/dist/registry/props-schema-hash.d.ts +3 -0
- package/dist/registry/props-schema-hash.d.ts.map +1 -0
- package/dist/registry/props-schema-hash.js +23 -0
- package/dist/schemas/blueprint.d.ts +2 -0
- package/dist/schemas/blueprint.d.ts.map +1 -1
- package/dist/schemas/blueprint.js +9 -0
- package/dist/schemas/data-contract.d.ts +61 -7
- package/dist/schemas/data-contract.d.ts.map +1 -1
- package/dist/schemas/data-contract.js +73 -14
- package/dist/schemas/handshake-suggestion.d.ts +3 -6
- package/dist/schemas/handshake-suggestion.d.ts.map +1 -1
- package/dist/schemas/handshake-suggestion.js +11 -9
- package/dist/schemas/mcp.d.ts +20 -0
- package/dist/schemas/mcp.d.ts.map +1 -1
- package/dist/schemas/mcp.js +15 -1
- package/dist/schemas/ops-blueprint.d.ts +19 -10
- package/dist/schemas/ops-blueprint.d.ts.map +1 -1
- package/dist/schemas/ops-blueprint.js +39 -10
- package/dist/types/blueprint.d.ts +12 -0
- package/dist/types/blueprint.d.ts.map +1 -1
- package/dist/types/contract-inference.d.ts +5 -1
- package/dist/types/contract-inference.d.ts.map +1 -1
- package/dist/types/data-contract.d.ts +12 -2
- package/dist/types/data-contract.d.ts.map +1 -1
- package/dist/types/llm-route.d.ts +1 -1
- package/dist/types/llm-route.d.ts.map +1 -1
- package/dist/types/llm-route.js +1 -0
- package/dist/types/llm.d.ts +1 -1
- package/dist/types/llm.d.ts.map +1 -1
- package/dist/types/llm.js +22 -5
- package/dist/types/mcp.d.ts +26 -3
- package/dist/types/mcp.d.ts.map +1 -1
- package/dist/types/render.d.ts +33 -0
- package/dist/types/render.d.ts.map +1 -1
- package/dist/types/render.js +26 -1
- package/dist/validation/ajv-runtime.d.ts +26 -6
- package/dist/validation/ajv-runtime.d.ts.map +1 -1
- package/dist/validation/ajv-runtime.js +87 -9
- package/dist/validation/contract-validator.d.ts +104 -33
- package/dist/validation/contract-validator.d.ts.map +1 -1
- package/dist/validation/contract-validator.js +152 -42
- package/dist/validation/enforced-props-schema.d.ts +56 -0
- package/dist/validation/enforced-props-schema.d.ts.map +1 -0
- package/dist/validation/enforced-props-schema.js +232 -0
- package/dist/validation/schema-subset.d.ts.map +1 -1
- package/dist/validation/schema-subset.js +27 -10
- package/dist/version.d.ts +169 -2
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +168 -1
- package/package.json +7 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { deriveContextDefault } from '../types/data-contract.js';
|
|
2
|
-
import { compileForValidation, compileValidatorModule, mapAjvErrorsToViolations, prefixViolations, } from './ajv-runtime.js';
|
|
2
|
+
import { compileForValidation, compileValidatorFunctionExpr, compileValidatorModule, mapAjvErrorsToViolations, prefixViolations, } from './ajv-runtime.js';
|
|
3
3
|
import { checkCrossReferences } from './cross-references.js';
|
|
4
4
|
import { checkNameInvariants } from './name-invariants.js';
|
|
5
5
|
import { isRecord } from './is-record.js';
|
|
@@ -32,6 +32,28 @@ export function buildPropsWrapperSchema(spec) {
|
|
|
32
32
|
.map(([name]) => name),
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Validate runtime props against an EXPLICIT enforced schema — the
|
|
37
|
+
* persisted-schema render path of the schema-precise render contract
|
|
38
|
+
* (docs/plans/2026-08-19-schema-precise-render.md P1). The paired
|
|
39
|
+
* `ggui_handshake` persists the exact `buildEnforcedPropsSchema`
|
|
40
|
+
* artifact it returned on the wire; `ggui_render` validates against
|
|
41
|
+
* that PERSISTED schema rather than recomputing from the propsSpec,
|
|
42
|
+
* so the returned and enforced schemas cannot diverge under
|
|
43
|
+
* rolling-deploy version skew (the AUTHORITY obligation is structural,
|
|
44
|
+
* not best-effort). Closed-shape injection at compile is idempotent —
|
|
45
|
+
* a pre-injected enforced schema round-trips unchanged.
|
|
46
|
+
*/
|
|
47
|
+
export function validatePropsDataWithSchema(props, schema) {
|
|
48
|
+
const validate = compileForValidation(schema);
|
|
49
|
+
const ok = validate(props);
|
|
50
|
+
if (ok)
|
|
51
|
+
return { valid: true, violations: [] };
|
|
52
|
+
return {
|
|
53
|
+
valid: false,
|
|
54
|
+
violations: mapAjvErrorsToViolations(validate.errors, props),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
35
57
|
/**
|
|
36
58
|
* Validate runtime props data against a PropsSpec contract.
|
|
37
59
|
*
|
|
@@ -353,30 +375,80 @@ export function compileContractValidators(specs) {
|
|
|
353
375
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
354
376
|
}
|
|
355
377
|
/**
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
378
|
+
* Compile a contract's runtime-validated sub-schemas into
|
|
379
|
+
* expression-form validators ({@link ContractValidatorExprs}) — the
|
|
380
|
+
* same four surfaces, guards, and closed-shape semantics as
|
|
381
|
+
* {@link compileContractValidators}, differing only in emission form.
|
|
382
|
+
* Returns `undefined` when the contract declares no runtime-validated
|
|
383
|
+
* schema at all.
|
|
384
|
+
*/
|
|
385
|
+
export function compileContractValidatorExprs(specs) {
|
|
386
|
+
const compilePerEntry = (spec) => {
|
|
387
|
+
if (!spec)
|
|
388
|
+
return undefined;
|
|
389
|
+
const collected = {};
|
|
390
|
+
for (const [name, entry] of Object.entries(spec)) {
|
|
391
|
+
if (entry && entry.schema) {
|
|
392
|
+
collected[name] = compileValidatorFunctionExpr(entry.schema);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return Object.keys(collected).length > 0 ? collected : undefined;
|
|
396
|
+
};
|
|
397
|
+
const out = {};
|
|
398
|
+
if (specs.propsSpec &&
|
|
399
|
+
specs.propsSpec.properties &&
|
|
400
|
+
Object.keys(specs.propsSpec.properties).length > 0) {
|
|
401
|
+
out.props = compileValidatorFunctionExpr(buildPropsWrapperSchema(specs.propsSpec));
|
|
402
|
+
}
|
|
403
|
+
const actions = compilePerEntry(specs.actionSpec);
|
|
404
|
+
if (actions)
|
|
405
|
+
out.actions = actions;
|
|
406
|
+
const streams = compilePerEntry(specs.streamSpec);
|
|
407
|
+
if (streams)
|
|
408
|
+
out.streams = streams;
|
|
409
|
+
const context = compilePerEntry(specs.contextSpec);
|
|
410
|
+
if (context)
|
|
411
|
+
out.context = context;
|
|
412
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Wrap {@link ContractValidatorExprs} as the source text of ONE plain
|
|
416
|
+
* ES module whose `default` export carries the validate FUNCTIONS
|
|
417
|
+
* themselves — the v2 wire format of the content-addressable contract
|
|
418
|
+
* route (`GET /contract/<hash>.js`), ggui#522 slice 2.
|
|
419
|
+
*
|
|
420
|
+
* The v1 format exported the validator-module SOURCES as strings, so
|
|
421
|
+
* the iframe still needed one `blob:` dynamic import per validator —
|
|
422
|
+
* exactly the scheme-source grant a strict host CSP refuses, which
|
|
423
|
+
* made client-side validation silently fail open on such hosts. Here
|
|
424
|
+
* the expressions concatenate at BUILD time into ordinary code: the
|
|
425
|
+
* frame does one `import(validatorsUrl)` (a plain https module load,
|
|
426
|
+
* governed by `script-src` origins alone) and receives functions. No
|
|
427
|
+
* `blob:`, no `data:`, no eval anywhere.
|
|
428
|
+
*
|
|
429
|
+
* Key emission order follows the producer's iteration order, which is
|
|
430
|
+
* deterministic in V8/Node for string keys — {@link computeContractBundle}
|
|
431
|
+
* hashes the INPUT specs anyway, so byte-level determinism of this
|
|
432
|
+
* source is a courtesy, not a correctness requirement.
|
|
375
433
|
*
|
|
376
434
|
* @public
|
|
377
435
|
*/
|
|
378
|
-
export function
|
|
379
|
-
|
|
436
|
+
export function bundleValidatorExprsAsExecutableModule(exprs) {
|
|
437
|
+
const lines = ['"use strict";', 'const v = {};'];
|
|
438
|
+
if (exprs.props !== undefined) {
|
|
439
|
+
lines.push(`v.props = ${exprs.props};`);
|
|
440
|
+
}
|
|
441
|
+
for (const group of ['actions', 'streams', 'context']) {
|
|
442
|
+
const entries = exprs[group];
|
|
443
|
+
if (entries === undefined)
|
|
444
|
+
continue;
|
|
445
|
+
lines.push(`v.${group} = {};`);
|
|
446
|
+
for (const [name, expr] of Object.entries(entries)) {
|
|
447
|
+
lines.push(`v.${group}[${JSON.stringify(name)}] = ${expr};`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
lines.push('export default v;');
|
|
451
|
+
return `${lines.join('\n')}\n`;
|
|
380
452
|
}
|
|
381
453
|
/**
|
|
382
454
|
* Recursive canonical-JSON serializer with object keys sorted
|
|
@@ -410,21 +482,35 @@ function canonicalJsonStringify(value) {
|
|
|
410
482
|
return 'null';
|
|
411
483
|
}
|
|
412
484
|
/**
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
* `
|
|
427
|
-
*
|
|
485
|
+
* Version salt for {@link computeContractBundle}'s hash. The bundle's
|
|
486
|
+
* URL + store key are `Cache-Control: immutable` and the CodeStore is
|
|
487
|
+
* first-write-wins, so a FORMAT change under an unchanged key would be
|
|
488
|
+
* invisible to every cache in the chain — the salt moves the whole
|
|
489
|
+
* family to fresh keys instead. `v2` = the executable-module format
|
|
490
|
+
* ({@link bundleValidatorExprsAsExecutableModule}); v1 (unsalted) was
|
|
491
|
+
* the string-carrying format whose per-validator `blob:` loads a
|
|
492
|
+
* strict CSP refuses.
|
|
493
|
+
*/
|
|
494
|
+
const CONTRACT_BUNDLE_HASH_SALT = 'ggui-validators-v2\n';
|
|
495
|
+
/**
|
|
496
|
+
* Convenience over {@link compileContractValidatorExprs} +
|
|
497
|
+
* {@link bundleValidatorExprsAsExecutableModule} + sha256 — produces
|
|
498
|
+
* the `{contractHash, bundleSource, validators}` triple the emitter
|
|
499
|
+
* (render.ts / the resource read / the /state route) writes to the
|
|
500
|
+
* content-addressable store and emits as
|
|
501
|
+
* `{contractHash, validatorsUrl}` on the render slice.
|
|
502
|
+
*
|
|
503
|
+
* `contractHash` is `sha256(salt + canonicalJsonStringify(specs))`
|
|
504
|
+
* (hex). Hashing the INPUT specs — not the compiled output —
|
|
505
|
+
* guarantees a stable hash across server processes and Ajv version
|
|
506
|
+
* bumps: the same contract definition always lands at the same URL.
|
|
507
|
+
* Compiled output bytes may differ across calls (Ajv's standalone
|
|
508
|
+
* emitter uses incrementing counter names like `validate10`/
|
|
509
|
+
* `validate11`), but the CodeStore is idempotent (first write wins)
|
|
510
|
+
* and the URL response carries `Cache-Control: immutable`, so browsers
|
|
511
|
+
* + CDNs lock in the first-served bytes and never observe a
|
|
512
|
+
* counter-name reshuffle. The salt exists precisely because of that
|
|
513
|
+
* immutability — see {@link CONTRACT_BUNDLE_HASH_SALT}.
|
|
428
514
|
*
|
|
429
515
|
* Returns `undefined` when the contract declares no runtime-validated
|
|
430
516
|
* schema at all (matches {@link compileContractValidators}'s posture).
|
|
@@ -432,16 +518,16 @@ function canonicalJsonStringify(value) {
|
|
|
432
518
|
* @public
|
|
433
519
|
*/
|
|
434
520
|
export async function computeContractBundle(specs) {
|
|
435
|
-
const validators =
|
|
521
|
+
const validators = compileContractValidatorExprs(specs);
|
|
436
522
|
if (validators === undefined)
|
|
437
523
|
return undefined;
|
|
438
|
-
const bundleSource =
|
|
524
|
+
const bundleSource = bundleValidatorExprsAsExecutableModule(validators);
|
|
439
525
|
// Web Crypto's subtle.digest is universal (Node 19+, all modern
|
|
440
526
|
// browsers, Workers). The protocol package ships into iframe-runtime
|
|
441
527
|
// bundles too — `node:crypto` would force esbuild to mark a Node
|
|
442
528
|
// builtin unresolved at browser bundle time even though this
|
|
443
529
|
// function is server-only at call time.
|
|
444
|
-
const bytes = new TextEncoder().encode(canonicalJsonStringify(specs));
|
|
530
|
+
const bytes = new TextEncoder().encode(`${CONTRACT_BUNDLE_HASH_SALT}${canonicalJsonStringify(specs)}`);
|
|
445
531
|
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
|
|
446
532
|
const contractHash = Array.from(new Uint8Array(digest))
|
|
447
533
|
.map((b) => b.toString(16).padStart(2, '0'))
|
|
@@ -729,7 +815,12 @@ function validateSchemaStructure(schema, path, violations) {
|
|
|
729
815
|
});
|
|
730
816
|
return;
|
|
731
817
|
}
|
|
732
|
-
|
|
818
|
+
// `type` may be a draft-07 type ARRAY (`['array','null']`) since
|
|
819
|
+
// draft-2026-08-19 — read it as a SET so the structural rules below
|
|
820
|
+
// apply to nullable array/object schemas too (a single-string
|
|
821
|
+
// comparison silently skipped them).
|
|
822
|
+
const typeSet = new Set(Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : []);
|
|
823
|
+
if (typeSet.has('array') && !schema.items) {
|
|
733
824
|
violations.push({
|
|
734
825
|
field: path,
|
|
735
826
|
message: `Array schema at '${path}' has no items — cannot validate array elements`,
|
|
@@ -737,7 +828,7 @@ function validateSchemaStructure(schema, path, violations) {
|
|
|
737
828
|
received: 'undefined',
|
|
738
829
|
});
|
|
739
830
|
}
|
|
740
|
-
if (
|
|
831
|
+
if (typeSet.has('object') && schema.required?.length) {
|
|
741
832
|
const definedProps = Object.keys(schema.properties ?? {});
|
|
742
833
|
for (const req of schema.required) {
|
|
743
834
|
if (!definedProps.includes(req)) {
|
|
@@ -789,6 +880,19 @@ export class ContractViolationError extends Error {
|
|
|
789
880
|
violations;
|
|
790
881
|
tool;
|
|
791
882
|
hint;
|
|
883
|
+
/**
|
|
884
|
+
* sha256 (lowercase hex) of the RFC 8785 canonical bytes of the
|
|
885
|
+
* ENFORCED props schema this violation was validated against —
|
|
886
|
+
* present when the enforcing site holds one (the handshake-persisted
|
|
887
|
+
* schema on the render path). The breach classifier of the
|
|
888
|
+
* schema-precise render contract: a caller holding the handshake's
|
|
889
|
+
* `propsSchemaHash` compares it to this value — mismatch means the
|
|
890
|
+
* server enforced a different schema than it returned (server
|
|
891
|
+
* breach); match means the props themselves were at fault. Absent on
|
|
892
|
+
* violations produced without an enforced-schema identity (synthetic
|
|
893
|
+
* violations, stream/context/action validation).
|
|
894
|
+
*/
|
|
895
|
+
propsSchemaHash;
|
|
792
896
|
constructor(opts) {
|
|
793
897
|
const formattedViolations = formatViolations(opts.violations);
|
|
794
898
|
super(`Contract violation in ${opts.tool}:\n${formattedViolations}`);
|
|
@@ -796,6 +900,9 @@ export class ContractViolationError extends Error {
|
|
|
796
900
|
this.violations = opts.violations;
|
|
797
901
|
this.tool = opts.tool;
|
|
798
902
|
this.hint = opts.hint ?? defaultHintFor(opts.tool);
|
|
903
|
+
if (opts.propsSchemaHash !== undefined) {
|
|
904
|
+
this.propsSchemaHash = opts.propsSchemaHash;
|
|
905
|
+
}
|
|
799
906
|
}
|
|
800
907
|
/** Structured payload for MCP error response `data` field. */
|
|
801
908
|
toErrorData() {
|
|
@@ -804,6 +911,9 @@ export class ContractViolationError extends Error {
|
|
|
804
911
|
tool: this.tool,
|
|
805
912
|
violations: this.violations,
|
|
806
913
|
hint: this.hint,
|
|
914
|
+
...(this.propsSchemaHash !== undefined
|
|
915
|
+
? { propsSchemaHash: this.propsSchemaHash }
|
|
916
|
+
: {}),
|
|
807
917
|
};
|
|
808
918
|
}
|
|
809
919
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { JsonSchema, PropsSpec } from '../types/data-contract.js';
|
|
2
|
+
/** Frozen wire values for the handshake's `propsSchemaProfile` field. */
|
|
3
|
+
export type PropsSchemaProfile = 'grammar-safe' | 'full';
|
|
4
|
+
/**
|
|
5
|
+
* The grammar-safe core — the closed keyword set a `'grammar-safe'`
|
|
6
|
+
* schema may use (P3 pin 4; purely syntactic). `aliases` is enumerated
|
|
7
|
+
* now (it is the P4 in-contract alias keyword) so arming P4 needs no
|
|
8
|
+
* grammar change; until P4 ships it simply never appears in emitted
|
|
9
|
+
* bytes.
|
|
10
|
+
*/
|
|
11
|
+
export declare const GRAMMAR_SAFE_KEYWORDS: ReadonlySet<string>;
|
|
12
|
+
/**
|
|
13
|
+
* The restricted `format` vocabulary admitted to the grammar-safe core
|
|
14
|
+
* (P3 pin 5). A consumer grammar MAY enforce any subset its engine
|
|
15
|
+
* supports; unenforced formats remain server-validated — AUTHORITY is
|
|
16
|
+
* one-directional, so an under-enforcing grammar is safe. A schema
|
|
17
|
+
* carrying a format outside this list classifies `'full'`.
|
|
18
|
+
*/
|
|
19
|
+
export declare const GRAMMAR_SAFE_FORMATS: ReadonlySet<string>;
|
|
20
|
+
/**
|
|
21
|
+
* RFC 8785 (JCS) serialization of a props schema — the canonical
|
|
22
|
+
* bytes `propsSchemaHash` is computed over, and the byte form the
|
|
23
|
+
* emitted `propsSchema` value is constructed to match. Consumers
|
|
24
|
+
* verify by re-canonicalizing the received value with any JCS
|
|
25
|
+
* library and hashing — raw received bytes are equivalent whenever
|
|
26
|
+
* the transport preserves key order (JS enumeration reorders
|
|
27
|
+
* integer-like property names, so re-canonicalization is the
|
|
28
|
+
* guaranteed path).
|
|
29
|
+
*/
|
|
30
|
+
export declare function canonicalPropsSchemaBytes(schema: JsonSchema): string;
|
|
31
|
+
/**
|
|
32
|
+
* Build the enforced props schema for a {@link PropsSpec} — the exact
|
|
33
|
+
* schema `ggui_render` compiles and validates against for the paired
|
|
34
|
+
* handshake, in emission form (closed shape materialized, `nullable`
|
|
35
|
+
* rewritten, metadata keywords stripped, canonical key order).
|
|
36
|
+
*
|
|
37
|
+
* An empty / degenerate spec yields the empty closed wrapper
|
|
38
|
+
* `{additionalProperties:false, properties:{}, required:[], type:'object'}`
|
|
39
|
+
* — never omitted for a non-declined handshake: under it, any
|
|
40
|
+
* non-empty props are invalid, which makes the props-without-propsSpec
|
|
41
|
+
* rejection schema-derivable and the accept-path drop documented
|
|
42
|
+
* leniency (one-directional AUTHORITY).
|
|
43
|
+
*
|
|
44
|
+
* `injectClosedShape` is idempotent, so compiling this pre-injected
|
|
45
|
+
* tree (`compileForValidation`) enforces byte-identical semantics to
|
|
46
|
+
* `validatePropsData` over the source spec — the AUTHORITY property
|
|
47
|
+
* the conformance kit's drift fixture pins.
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildEnforcedPropsSchema(spec: PropsSpec): JsonSchema;
|
|
50
|
+
/**
|
|
51
|
+
* Classify an (emission-form) props schema against the grammar-safe
|
|
52
|
+
* core — P3 pins 4+5. Purely syntactic; the conformance kit's profile
|
|
53
|
+
* fixture asserts the wire flag agrees with this reference checker.
|
|
54
|
+
*/
|
|
55
|
+
export declare function classifyPropsSchemaProfile(schema: JsonSchema): PropsSchemaProfile;
|
|
56
|
+
//# sourceMappingURL=enforced-props-schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"enforced-props-schema.d.ts","sourceRoot":"","sources":["../../src/validation/enforced-props-schema.ts"],"names":[],"mappings":"AA4CA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAIvE,yEAAyE;AACzE,MAAM,MAAM,kBAAkB,GAAG,cAAc,GAAG,MAAM,CAAC;AAEzD;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,EAAE,WAAW,CAAC,MAAM,CAcpD,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,EAAE,WAAW,CAAC,MAAM,CAWnD,CAAC;AAEH;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAEpE;AAuED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,SAAS,GAAG,UAAU,CAIpE;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,UAAU,GACjB,kBAAkB,CAiCpB"}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENFORCED PROPS SCHEMA — the wire artifact of the schema-precise
|
|
3
|
+
* render arc (P1, docs/plans/2026-08-19-schema-precise-render.md;
|
|
4
|
+
* shape frozen 2026-08-19 per the P3 consumer review, guuey#271).
|
|
5
|
+
*
|
|
6
|
+
* `buildEnforcedPropsSchema(propsSpec)` produces the EXACT JSON Schema
|
|
7
|
+
* the paired `ggui_render` enforces for a handshake — the same
|
|
8
|
+
* synthesis (`buildPropsWrapperSchema`) and the same closed-shape
|
|
9
|
+
* injection (`injectClosedShape`) the render-time validator compiles,
|
|
10
|
+
* with the emission normalizations applied IN the bytes:
|
|
11
|
+
*
|
|
12
|
+
* - `additionalProperties: false` materialized at every object node
|
|
13
|
+
* (render injects it at Ajv compile; the wire artifact carries it
|
|
14
|
+
* explicitly so a consumer reading the schema sees the enforced
|
|
15
|
+
* closed shape, not a pre-injection approximation).
|
|
16
|
+
* - `nullable: true` rewritten to the canonical `type: [X, 'null']`
|
|
17
|
+
* union (the validating engine type-widens on `nullable`; a strict
|
|
18
|
+
* reader of the emitted schema must see the same acceptance).
|
|
19
|
+
* - Metadata keywords that are no-ops at the emission layer
|
|
20
|
+
* (`example`, `nullable`) stripped — they are non-standard at this
|
|
21
|
+
* layer and can fail a strict downstream compiler.
|
|
22
|
+
* - Constructed in canonical key order (sorted at every schema
|
|
23
|
+
* node), so the serialized value is byte-stable across authors'
|
|
24
|
+
* key ordering and across handshakes of the same contract —
|
|
25
|
+
* load-bearing for consumer compile caches keyed on the bytes.
|
|
26
|
+
*
|
|
27
|
+
* Canonical bytes are RFC 8785 (JCS) — the SAME standard the
|
|
28
|
+
* `contractHash` pipeline pins (`registry/canonicalize-contract.ts`),
|
|
29
|
+
* so an external implementation reproduces them with any JCS library.
|
|
30
|
+
* The hash over those bytes lives server-side at
|
|
31
|
+
* `@ggui-ai/protocol/props-schema-hash` (node:crypto — the
|
|
32
|
+
* `blueprint-key` subpath convention).
|
|
33
|
+
*
|
|
34
|
+
* `classifyPropsSchemaProfile` implements the frozen profile rule
|
|
35
|
+
* (pins 4+5): purely SYNTACTIC membership — a schema is
|
|
36
|
+
* `'grammar-safe'` iff every keyword appearing at any schema node is
|
|
37
|
+
* in {@link GRAMMAR_SAFE_KEYWORDS}, every `format` value is in
|
|
38
|
+
* {@link GRAMMAR_SAFE_FORMATS}, and every `additionalProperties` is
|
|
39
|
+
* the literal `false`. Anything else is `'full'` — the consumer falls
|
|
40
|
+
* back to schema-as-context instead of grammar compilation. Consumers
|
|
41
|
+
* MUST treat unknown profile values as `'full'` (new profiles are
|
|
42
|
+
* additive minors).
|
|
43
|
+
*/
|
|
44
|
+
import canonicalize from 'canonicalize';
|
|
45
|
+
import { buildPropsWrapperSchema } from './contract-validator.js';
|
|
46
|
+
import { injectClosedShape } from './ajv-runtime.js';
|
|
47
|
+
/**
|
|
48
|
+
* The grammar-safe core — the closed keyword set a `'grammar-safe'`
|
|
49
|
+
* schema may use (P3 pin 4; purely syntactic). `aliases` is enumerated
|
|
50
|
+
* now (it is the P4 in-contract alias keyword) so arming P4 needs no
|
|
51
|
+
* grammar change; until P4 ships it simply never appears in emitted
|
|
52
|
+
* bytes.
|
|
53
|
+
*/
|
|
54
|
+
export const GRAMMAR_SAFE_KEYWORDS = new Set([
|
|
55
|
+
'type',
|
|
56
|
+
'enum',
|
|
57
|
+
'const',
|
|
58
|
+
'properties',
|
|
59
|
+
'required',
|
|
60
|
+
'items',
|
|
61
|
+
'additionalProperties',
|
|
62
|
+
'oneOf',
|
|
63
|
+
'anyOf',
|
|
64
|
+
'format',
|
|
65
|
+
'description',
|
|
66
|
+
'title',
|
|
67
|
+
'aliases',
|
|
68
|
+
]);
|
|
69
|
+
/**
|
|
70
|
+
* The restricted `format` vocabulary admitted to the grammar-safe core
|
|
71
|
+
* (P3 pin 5). A consumer grammar MAY enforce any subset its engine
|
|
72
|
+
* supports; unenforced formats remain server-validated — AUTHORITY is
|
|
73
|
+
* one-directional, so an under-enforcing grammar is safe. A schema
|
|
74
|
+
* carrying a format outside this list classifies `'full'`.
|
|
75
|
+
*/
|
|
76
|
+
export const GRAMMAR_SAFE_FORMATS = new Set([
|
|
77
|
+
'date-time',
|
|
78
|
+
'time',
|
|
79
|
+
'date',
|
|
80
|
+
'duration',
|
|
81
|
+
'email',
|
|
82
|
+
'hostname',
|
|
83
|
+
'uri',
|
|
84
|
+
'ipv4',
|
|
85
|
+
'ipv6',
|
|
86
|
+
'uuid',
|
|
87
|
+
]);
|
|
88
|
+
/**
|
|
89
|
+
* RFC 8785 (JCS) serialization of a props schema — the canonical
|
|
90
|
+
* bytes `propsSchemaHash` is computed over, and the byte form the
|
|
91
|
+
* emitted `propsSchema` value is constructed to match. Consumers
|
|
92
|
+
* verify by re-canonicalizing the received value with any JCS
|
|
93
|
+
* library and hashing — raw received bytes are equivalent whenever
|
|
94
|
+
* the transport preserves key order (JS enumeration reorders
|
|
95
|
+
* integer-like property names, so re-canonicalization is the
|
|
96
|
+
* guaranteed path).
|
|
97
|
+
*/
|
|
98
|
+
export function canonicalPropsSchemaBytes(schema) {
|
|
99
|
+
return canonicalize(schema) ?? '{}';
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Schema-position-aware canonical rebuild of one node: strips
|
|
103
|
+
* emission-layer metadata keywords, rewrites `nullable`, recurses into
|
|
104
|
+
* the schema-valued positions (`properties` values, `items`,
|
|
105
|
+
* schema-valued `additionalProperties`, `oneOf`/`anyOf` branches), and
|
|
106
|
+
* inserts keys in sorted order. Data-valued positions (`enum` members,
|
|
107
|
+
* `const`, `default`) pass through verbatim — they are values, not
|
|
108
|
+
* schemas, and JCS canonicalizes their serialization regardless of
|
|
109
|
+
* construction order.
|
|
110
|
+
*/
|
|
111
|
+
function canonicalizeSchemaNode(node) {
|
|
112
|
+
const out = {};
|
|
113
|
+
const nullableWidens = node.nullable === true && typeof node.type === 'string';
|
|
114
|
+
for (const key of Object.keys(node).sort()) {
|
|
115
|
+
if (key === 'example' || key === 'nullable')
|
|
116
|
+
continue;
|
|
117
|
+
const value = node[key];
|
|
118
|
+
if (key === 'type') {
|
|
119
|
+
const t = node.type;
|
|
120
|
+
if (nullableWidens && typeof t === 'string') {
|
|
121
|
+
out.type = [t, 'null'];
|
|
122
|
+
}
|
|
123
|
+
else if (node.nullable === true && Array.isArray(t)) {
|
|
124
|
+
out.type = t.includes('null') ? [...t] : [...t, 'null'];
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
out.type = t;
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (key === 'properties' && isSchemaMap(value)) {
|
|
132
|
+
const props = {};
|
|
133
|
+
for (const name of Object.keys(value).sort()) {
|
|
134
|
+
props[name] = canonicalizeSchemaNode(value[name]);
|
|
135
|
+
}
|
|
136
|
+
out.properties = props;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (key === 'items' && isSchemaValue(value)) {
|
|
140
|
+
out.items = canonicalizeSchemaNode(value);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (key === 'additionalProperties' && isSchemaValue(value)) {
|
|
144
|
+
out.additionalProperties = canonicalizeSchemaNode(value);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (key === 'oneOf' || key === 'anyOf') {
|
|
148
|
+
const branches = key === 'oneOf' ? node.oneOf : node.anyOf;
|
|
149
|
+
if (branches !== undefined) {
|
|
150
|
+
out[key] = branches.map(canonicalizeSchemaNode);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
out[key] = value;
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
function isSchemaValue(value) {
|
|
159
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
160
|
+
}
|
|
161
|
+
function isSchemaMap(value) {
|
|
162
|
+
return (typeof value === 'object' &&
|
|
163
|
+
value !== null &&
|
|
164
|
+
!Array.isArray(value) &&
|
|
165
|
+
Object.values(value).every(isSchemaValue));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Build the enforced props schema for a {@link PropsSpec} — the exact
|
|
169
|
+
* schema `ggui_render` compiles and validates against for the paired
|
|
170
|
+
* handshake, in emission form (closed shape materialized, `nullable`
|
|
171
|
+
* rewritten, metadata keywords stripped, canonical key order).
|
|
172
|
+
*
|
|
173
|
+
* An empty / degenerate spec yields the empty closed wrapper
|
|
174
|
+
* `{additionalProperties:false, properties:{}, required:[], type:'object'}`
|
|
175
|
+
* — never omitted for a non-declined handshake: under it, any
|
|
176
|
+
* non-empty props are invalid, which makes the props-without-propsSpec
|
|
177
|
+
* rejection schema-derivable and the accept-path drop documented
|
|
178
|
+
* leniency (one-directional AUTHORITY).
|
|
179
|
+
*
|
|
180
|
+
* `injectClosedShape` is idempotent, so compiling this pre-injected
|
|
181
|
+
* tree (`compileForValidation`) enforces byte-identical semantics to
|
|
182
|
+
* `validatePropsData` over the source spec — the AUTHORITY property
|
|
183
|
+
* the conformance kit's drift fixture pins.
|
|
184
|
+
*/
|
|
185
|
+
export function buildEnforcedPropsSchema(spec) {
|
|
186
|
+
return canonicalizeSchemaNode(injectClosedShape(buildPropsWrapperSchema(spec)));
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Classify an (emission-form) props schema against the grammar-safe
|
|
190
|
+
* core — P3 pins 4+5. Purely syntactic; the conformance kit's profile
|
|
191
|
+
* fixture asserts the wire flag agrees with this reference checker.
|
|
192
|
+
*/
|
|
193
|
+
export function classifyPropsSchemaProfile(schema) {
|
|
194
|
+
for (const key of Object.keys(schema)) {
|
|
195
|
+
if (!GRAMMAR_SAFE_KEYWORDS.has(key))
|
|
196
|
+
return 'full';
|
|
197
|
+
const value = schema[key];
|
|
198
|
+
if (key === 'format') {
|
|
199
|
+
if (typeof value !== 'string' || !GRAMMAR_SAFE_FORMATS.has(value)) {
|
|
200
|
+
return 'full';
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (key === 'additionalProperties') {
|
|
205
|
+
if (value !== false)
|
|
206
|
+
return 'full';
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (key === 'properties' && isSchemaMap(value)) {
|
|
210
|
+
for (const nested of Object.values(value)) {
|
|
211
|
+
if (classifyPropsSchemaProfile(nested) === 'full')
|
|
212
|
+
return 'full';
|
|
213
|
+
}
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (key === 'items' && isSchemaValue(value)) {
|
|
217
|
+
if (classifyPropsSchemaProfile(value) === 'full')
|
|
218
|
+
return 'full';
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if ((key === 'oneOf' || key === 'anyOf') && Array.isArray(value)) {
|
|
222
|
+
for (const branch of value) {
|
|
223
|
+
if (!isSchemaValue(branch))
|
|
224
|
+
return 'full';
|
|
225
|
+
if (classifyPropsSchemaProfile(branch) === 'full')
|
|
226
|
+
return 'full';
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return 'grammar-safe';
|
|
232
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema-subset.d.ts","sourceRoot":"","sources":["../../src/validation/schema-subset.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiFG;AACH,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"schema-subset.d.ts","sourceRoot":"","sources":["../../src/validation/schema-subset.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiFG;AACH,OAAO,KAAK,EACV,UAAU,EAEV,SAAS,EACV,MAAM,2BAA2B,CAAC;AAEnC;;;;;GAKG;AACH,MAAM,MAAM,qBAAqB,GAC7B,eAAe;AACjB;sDACsD;GACpD,gBAAgB;AAClB;;;;mDAImD;GACjD,iBAAiB;AACnB;sEACsE;GACpE,kBAAkB;AACpB,mCAAmC;GACjC,gBAAgB;AAClB,sEAAsE;GACpE,8BAA8B;AAChC;;mDAEmD;GACjD,aAAa,CAAC;AAElB;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6BAA6B;IAC7B,QAAQ,CAAC,MAAM,EAAE,qBAAqB,CAAC;IACvC;;sDAEkD;IAClD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;4CACwC;IACxC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;gDAG4C;IAC5C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,SAAS,eAAe,EAAE,CAAC;CACjD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,UAAU,EACpB,MAAM,EAAE,UAAU,GACjB,kBAAkB,CAiBpB;AA8TD,YAAY,EAAE,SAAS,EAAE,CAAC"}
|
|
@@ -88,17 +88,22 @@ function compare(superset, subset, path, out) {
|
|
|
88
88
|
// Type match. Superset `undefined` is a wildcard (accepts any
|
|
89
89
|
// type). Subset `undefined` against a specific superset type is a
|
|
90
90
|
// violation — "no declared type" is wider than any specific type.
|
|
91
|
-
const sup =
|
|
92
|
-
const sub =
|
|
91
|
+
const sup = normalizeTypeSet(superset);
|
|
92
|
+
const sub = normalizeTypeSet(subset);
|
|
93
93
|
if (sup !== undefined) {
|
|
94
|
-
|
|
94
|
+
// Draft-07 type ARRAYS (e.g. `['string','null']`, the canonical
|
|
95
|
+
// nullability form the enforced-props-schema emission produces)
|
|
96
|
+
// compare as SETS: every type the subset can emit must be accepted
|
|
97
|
+
// by the superset.
|
|
98
|
+
const subsetOk = sub !== undefined && [...sub].every((t) => sup.has(t));
|
|
99
|
+
if (!subsetOk) {
|
|
95
100
|
out.push({
|
|
96
101
|
path,
|
|
97
102
|
reason: 'type-mismatch',
|
|
98
|
-
superset: sup,
|
|
99
|
-
subset: sub
|
|
103
|
+
superset: formatTypeSet(sup),
|
|
104
|
+
subset: sub === undefined ? '(unspecified)' : formatTypeSet(sub),
|
|
100
105
|
message: `${pathLabel(path)}: type mismatch — superset accepts ` +
|
|
101
|
-
`'${sup}' but subset ${sub === undefined ? 'does not declare a type' : `declares '${sub}'`}.`,
|
|
106
|
+
`'${formatTypeSet(sup)}' but subset ${sub === undefined ? 'does not declare a type' : `declares '${formatTypeSet(sub)}'`}.`,
|
|
102
107
|
});
|
|
103
108
|
// Type mismatch invalidates downstream object/array structural
|
|
104
109
|
// checks — if the types don't match, deeper comparison is noise.
|
|
@@ -106,14 +111,14 @@ function compare(superset, subset, path, out) {
|
|
|
106
111
|
}
|
|
107
112
|
}
|
|
108
113
|
// Object structure.
|
|
109
|
-
if (sup
|
|
114
|
+
if (sup?.has('object') || sub?.has('object')) {
|
|
110
115
|
compareObject(superset, subset, path, out);
|
|
111
116
|
}
|
|
112
117
|
// Array items. We descend through items only when both sides have
|
|
113
118
|
// `type: 'array'` (or superset omitted type and subset declares
|
|
114
119
|
// array — but that case is caught by the type block above as a
|
|
115
120
|
// mismatch). Omission on either side is permissive.
|
|
116
|
-
if (sup
|
|
121
|
+
if (sup?.has('array') || sub?.has('array')) {
|
|
117
122
|
compareArray(superset, subset, path, out);
|
|
118
123
|
}
|
|
119
124
|
}
|
|
@@ -235,8 +240,20 @@ function compareArray(superset, subset, path, out) {
|
|
|
235
240
|
const childPath = path === '' ? 'items' : `${path}.items`;
|
|
236
241
|
compare(supItems, subItems, childPath, out);
|
|
237
242
|
}
|
|
238
|
-
|
|
239
|
-
|
|
243
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
244
|
+
/**
|
|
245
|
+
* The declared type(s) as a SET — single declarations and draft-07
|
|
246
|
+
* type arrays normalize to the same shape so subset comparison is
|
|
247
|
+
* uniform. `undefined` = no type declared (permissive).
|
|
248
|
+
*/
|
|
249
|
+
function normalizeTypeSet(schema) {
|
|
250
|
+
const t = schema.type;
|
|
251
|
+
if (t === undefined)
|
|
252
|
+
return undefined;
|
|
253
|
+
return new Set(Array.isArray(t) ? t : [t]);
|
|
254
|
+
}
|
|
255
|
+
function formatTypeSet(set) {
|
|
256
|
+
return [...set].join('|');
|
|
240
257
|
}
|
|
241
258
|
function resolveAdditional(value) {
|
|
242
259
|
// JSON Schema draft-07: omitted ⇒ additionalProperties `true`.
|