@typedstandards/verify-core 0.13.0 → 0.14.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/CHANGELOG.md CHANGED
@@ -6,6 +6,37 @@ references are to `npstorey/civic-ai-tools-website` (#119 is the offline-crypto
6
6
  hardening arc; #116 is the standalone-verifier arc this package was extracted
7
7
  in).
8
8
 
9
+ ## 0.14.0 — 2026-10-16
10
+
11
+ The W5 release (civic-ai-tools#260, shipped in #146 and #147). **A minor bump**: new exports
12
+ (`CONTENT_LAYOUT`; `parseStrictJson` and `RepeatedMemberNameError`), and a type URI check #12 newly
13
+ reads `ok`.
14
+
15
+ - **`content/layout/v1` is registered (hub ADR-0034; spec §8.14).** New export `CONTENT_LAYOUT`
16
+ (`'content/layout/v1'`). `KNOWN_TYPE_URIS` holds eighteen entries: `content/analysis/v1`, then
17
+ `content/layout/v1`, then the sixteen attestation sub-types in the §8.12.1 table order, unchanged.
18
+ Check #12 reads `ok` for a layout node, where it read `unknown_type`. No payload check is added: a
19
+ layout node's envelope verifies as any node's (§8.14.5).
20
+ - **`parseStrictJson` and `RepeatedMemberNameError` (new exports; typedstandards#145,
21
+ specification §8.2 and §9.2 check #1).** `parseStrictJson(text: string | Uint8Array)`
22
+ refuses a JSON text that repeats a member name within an object, which is not I-JSON
23
+ (RFC 7493), the input RFC 8785 JCS requires. It throws `RepeatedMemberNameError`
24
+ (`name` `'RepeatedMemberNameError'`), carrying `memberName` (unescaped) and
25
+ `pointer`, the JSON Pointer (RFC 6901) of the object that holds it. Names compare
26
+ after unescaping; the same name in two different objects is not a repeat. A text with
27
+ no repeated name returns exactly what `JSON.parse` returns, and a text that is not
28
+ JSON throws what `JSON.parse` throws, unchanged. Bytes are decoded as UTF-8 with
29
+ `fatal`. The walk keeps its own stack, so nesting the platform parser accepts does
30
+ not overflow it. No dependency is added.
31
+ - **`verifyRecord` is unchanged:** it takes a parsed package, so the refusal belongs to
32
+ whoever reads the text. `JSON.parse` keeps the last occurrence of a repeated name, and
33
+ the envelope hash is computed over the parsed object, so a caller that parses a
34
+ package with it accepts a text the specification refuses.
35
+ - **A negative vector**, `src/__fixtures__/repeated-member-name.bundle.json` (not
36
+ published): the site's minted interop bundle with a second, earlier `summary` member in
37
+ its package, which under `JSON.parse` gives the signed package's envelope and content
38
+ hashes. Its provenance is in `src/__fixtures__/README.md`.
39
+
9
40
  ## 0.13.0 — 2026-09-27
10
41
 
11
42
  The core-minors release (typedstandards#113, shipped in #114, #115 and #118). **A minor bump** —
package/README.md CHANGED
@@ -64,6 +64,14 @@ const result = await verifyRecord(input, {
64
64
  // contentHash, typeResolution, signerIdentity, lifecycle, ...
65
65
  ```
66
66
 
67
+ > **Parse the package's text with `parseStrictJson`.** A record package's JSON text is
68
+ > I-JSON (RFC 7493), so a verifier refuses a text that repeats a member name within an
69
+ > object, before any hash is computed (spec §8.2, §9.2 check #1). `JSON.parse` keeps the
70
+ > last occurrence and drops the others, and `verifyRecord` takes the parsed object, so it
71
+ > cannot see a repeat. `parseStrictJson(text)` (a string, or bytes decoded as UTF-8)
72
+ > throws `RepeatedMemberNameError`, with `memberName` and `pointer` (the JSON Pointer of
73
+ > the object), on a repeated name, and otherwise returns what `JSON.parse` returns.
74
+
67
75
  > **`verifyEvidence` is the same function.** The 2026-08-19 vocabulary settlement
68
76
  > renamed the entry point to `verifyRecord` (spec Appendix J) — "evidence" is
69
77
  > retired from the artifact/infrastructure brand role and kept only for the
package/dist/checks.d.ts CHANGED
@@ -102,6 +102,7 @@ export interface ContentProfileCheck {
102
102
  * integrity failure: both labels are signature-covered.
103
103
  */
104
104
  export declare function checkContentProfile(pkg: Record<string, unknown>): ContentProfileCheck;
105
+ export declare const CONTENT_LAYOUT = "content/layout/v1";
105
106
  export declare const KNOWN_TYPE_URIS: readonly string[];
106
107
  export declare const TYPE_RESOLUTION_STATUSES: readonly ["ok", "implicit", "unknown_type"];
107
108
  export type TypeResolutionStatus = (typeof TYPE_RESOLUTION_STATUSES)[number];
package/dist/checks.js CHANGED
@@ -244,15 +244,22 @@ export function checkContentProfile(pkg) {
244
244
  return { status: 'ok', ...base };
245
245
  }
246
246
  // --- Typed-standards envelope checks (spec §9.2 checks #12, #14, #15) ---
247
- // v0.1 ratified node type URIs (spec §8.12.1). Recognized so check #12 resolves
248
- // them; only `content/analysis/v1` (+ the lifecycle sub-types) are operationalized
249
- // today, but the full ratified set is registered so conformant packages don't
250
- // render as `unknown_type`. The sixteen attestation sub-types are in the order
251
- // of the §8.12.1 table; `type-resolution.test.ts` holds that table's copy.
252
- // Exported and frozen so a check outside this package can compare the registered
253
- // set with the specification's list without being able to change it.
247
+ // The `content/layout/v1` sub-type (spec §8.14, hub ADR-0034): a standalone
248
+ // assertion of how a host's page arranges a node's parts. Registered for check
249
+ // #12; verify-core runs no payload check on it (§8.14.5).
250
+ export const CONTENT_LAYOUT = 'content/layout/v1';
251
+ // v0.1 ratified node type URIs. Recognized so check #12 resolves them; only
252
+ // `content/analysis/v1` (+ the lifecycle sub-types) are operationalized today,
253
+ // but the full ratified set is registered so conformant packages don't render
254
+ // as `unknown_type`. The two `content/*` sub-types come first:
255
+ // `content/analysis/v1`, then `content/layout/v1` (§8.14). The sixteen
256
+ // attestation sub-types follow in the order of the §8.12.1 table;
257
+ // `type-resolution.test.ts` holds that table's copy. Exported and frozen so a
258
+ // check outside this package can compare the registered set with the
259
+ // specification's list without being able to change it.
254
260
  export const KNOWN_TYPE_URIS = Object.freeze([
255
261
  'content/analysis/v1',
262
+ CONTENT_LAYOUT,
256
263
  'attestation/withdraws/v1',
257
264
  'attestation/reinstates/v1',
258
265
  'attestation/supersedes/v1',
package/dist/index.d.ts CHANGED
@@ -15,3 +15,4 @@ export * from './asn1.ts';
15
15
  export * from './x509.ts';
16
16
  export * from './rfc3161.ts';
17
17
  export * from './verify.ts';
18
+ export * from './strict-json.ts';
package/dist/index.js CHANGED
@@ -29,3 +29,4 @@ export * from "./asn1.js";
29
29
  export * from "./x509.js";
30
30
  export * from "./rfc3161.js";
31
31
  export * from "./verify.js";
32
+ export * from "./strict-json.js";
@@ -0,0 +1,24 @@
1
+ /** A JSON text repeats a member name within one object, so it is not I-JSON (RFC 7493). */
2
+ export declare class RepeatedMemberNameError extends Error {
3
+ /** The repeated member name, unescaped. */
4
+ readonly memberName: string;
5
+ /** The JSON Pointer (RFC 6901) of the object that holds it: `""` for the top-level value. */
6
+ readonly pointer: string;
7
+ constructor(memberName: string, pointer: string);
8
+ }
9
+ /**
10
+ * Parse a record's JSON text strictly: refuse a text that repeats a member name
11
+ * within an object (I-JSON, RFC 7493; specification §8.2), before any hash is
12
+ * computed over what it holds.
13
+ *
14
+ * - Bytes are decoded as UTF-8 with `fatal`, so malformed UTF-8 throws the
15
+ * decoder's `TypeError` rather than being replaced. A leading byte order mark is
16
+ * removed, as the decoder does by default.
17
+ * - A text that is not JSON throws what `JSON.parse` throws, unchanged.
18
+ * - A text that repeats a member name within one object throws
19
+ * `RepeatedMemberNameError`, naming the member and the JSON Pointer of the
20
+ * object. Names compare after unescaping, so `"a"` repeats `"a"`; the same
21
+ * name in two different objects is not a repeat.
22
+ * - Otherwise it returns `JSON.parse`'s value.
23
+ */
24
+ export declare function parseStrictJson(text: string | Uint8Array): unknown;
@@ -0,0 +1,109 @@
1
+ // The strict parse of a record's JSON text (typedstandards#145; specification §8.2,
2
+ // §9.2 check #1; hub ADR-0035).
3
+ //
4
+ // A record package's JSON text is I-JSON (RFC 7493), the input RFC 8785 JCS
5
+ // requires, and I-JSON allows no member name to repeat within an object. The
6
+ // platform's `JSON.parse` keeps the last occurrence of a repeated name and drops
7
+ // the earlier ones without a word, and the envelope hash is computed over the
8
+ // parsed object, so a text with a repeated name hashes as the text without it while
9
+ // a reader that keeps another occurrence sees other content. `parseStrictJson`
10
+ // refuses such a text, and otherwise returns exactly what `JSON.parse` returns.
11
+ //
12
+ // Pure: no I/O, no dependency, browser-safe. The walk keeps its own stack, so any
13
+ // nesting the platform parser accepts is walked without recursion.
14
+ /** A JSON text repeats a member name within one object, so it is not I-JSON (RFC 7493). */
15
+ export class RepeatedMemberNameError extends Error {
16
+ /** The repeated member name, unescaped. */
17
+ memberName;
18
+ /** The JSON Pointer (RFC 6901) of the object that holds it: `""` for the top-level value. */
19
+ pointer;
20
+ constructor(memberName, pointer) {
21
+ super(`JSON text is not I-JSON (RFC 7493): the member name ${JSON.stringify(memberName)} is repeated in the object at JSON Pointer ${JSON.stringify(pointer)}`);
22
+ this.name = 'RepeatedMemberNameError';
23
+ this.memberName = memberName;
24
+ this.pointer = pointer;
25
+ }
26
+ }
27
+ const QUOTE = 0x22;
28
+ const BACKSLASH = 0x5c;
29
+ const OPEN_OBJECT = 0x7b;
30
+ const CLOSE_OBJECT = 0x7d;
31
+ const OPEN_ARRAY = 0x5b;
32
+ const CLOSE_ARRAY = 0x5d;
33
+ const COMMA = 0x2c;
34
+ const escapeSegment = (s) => s.replace(/~/g, '~0').replace(/\//g, '~1');
35
+ /** The index of the quote that closes the string opening at `start`, in text known to be JSON. */
36
+ function stringEnd(text, start) {
37
+ let i = start + 1;
38
+ for (;;) {
39
+ const c = text.charCodeAt(i);
40
+ if (c === BACKSLASH)
41
+ i += 2;
42
+ else if (c === QUOTE)
43
+ return i;
44
+ else
45
+ i += 1;
46
+ }
47
+ }
48
+ /** Throw `RepeatedMemberNameError` at the first repeated name, in text `JSON.parse` has accepted. */
49
+ function refuseRepeatedNames(text) {
50
+ const stack = [];
51
+ const segments = [];
52
+ const n = text.length;
53
+ for (let i = 0; i < n; i++) {
54
+ const c = text.charCodeAt(i);
55
+ if (c === QUOTE) {
56
+ const end = stringEnd(text, i);
57
+ const top = stack[stack.length - 1];
58
+ if (top?.names && top.expectName) {
59
+ const raw = text.slice(i + 1, end);
60
+ const name = raw.includes('\\') ? JSON.parse(text.slice(i, end + 1)) : raw;
61
+ if (top.names.has(name))
62
+ throw new RepeatedMemberNameError(name, segments.map((s) => `/${escapeSegment(s)}`).join(''));
63
+ top.names.add(name);
64
+ top.segment = name;
65
+ top.expectName = false;
66
+ }
67
+ i = end;
68
+ }
69
+ else if (c === OPEN_OBJECT || c === OPEN_ARRAY) {
70
+ const parent = stack[stack.length - 1];
71
+ if (parent)
72
+ segments.push(parent.names ? parent.segment : String(parent.index));
73
+ stack.push({ names: c === OPEN_OBJECT ? new Set() : null, expectName: c === OPEN_OBJECT, segment: '', index: 0 });
74
+ }
75
+ else if (c === CLOSE_OBJECT || c === CLOSE_ARRAY) {
76
+ stack.pop();
77
+ if (stack.length > 0)
78
+ segments.pop();
79
+ }
80
+ else if (c === COMMA) {
81
+ const top = stack[stack.length - 1];
82
+ if (top.names)
83
+ top.expectName = true;
84
+ else
85
+ top.index += 1;
86
+ }
87
+ }
88
+ }
89
+ /**
90
+ * Parse a record's JSON text strictly: refuse a text that repeats a member name
91
+ * within an object (I-JSON, RFC 7493; specification §8.2), before any hash is
92
+ * computed over what it holds.
93
+ *
94
+ * - Bytes are decoded as UTF-8 with `fatal`, so malformed UTF-8 throws the
95
+ * decoder's `TypeError` rather than being replaced. A leading byte order mark is
96
+ * removed, as the decoder does by default.
97
+ * - A text that is not JSON throws what `JSON.parse` throws, unchanged.
98
+ * - A text that repeats a member name within one object throws
99
+ * `RepeatedMemberNameError`, naming the member and the JSON Pointer of the
100
+ * object. Names compare after unescaping, so `"a"` repeats `"a"`; the same
101
+ * name in two different objects is not a repeat.
102
+ * - Otherwise it returns `JSON.parse`'s value.
103
+ */
104
+ export function parseStrictJson(text) {
105
+ const source = typeof text === 'string' ? text : new TextDecoder('utf-8', { fatal: true }).decode(text);
106
+ const value = JSON.parse(source);
107
+ refuseRepeatedNames(source);
108
+ return value;
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typedstandards/verify-core",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Portable, browser-safe verification core for Typed Standards record packages — the §9.2 check suite, single-sourced so it verifies identically on a server and in the browser.",
5
5
  "license": "MIT",
6
6
  "author": "Nathan Storey",