circuitjson-toolkit 1.3.0 → 1.4.1

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.
@@ -57,18 +57,62 @@ export class CircuitJsonValidationProof {
57
57
  * @returns {Record<string, any>} The same read-only document envelope.
58
58
  */
59
59
  static validateAndAttach(document, options = {}) {
60
+ const model = CircuitJsonValidationProof.#validateModel(document)
61
+ return CircuitJsonValidationProof.#attachAndSeal(
62
+ document,
63
+ model,
64
+ options
65
+ )
66
+ }
67
+
68
+ /**
69
+ * Validates a clone-owned model, yields to the host, and then seals its
70
+ * extension envelope without weakening the matching runtime proof.
71
+ * @param {Record<string, any>} document Canonical document envelope.
72
+ * @param {{ standardBuiltins?: boolean, yield?: () => Promise<void> | void }} [options] Proven metadata provenance and host scheduler.
73
+ * @returns {Promise<Record<string, any>>} The same read-only document envelope.
74
+ */
75
+ static async validateAndAttachAsync(document, options = {}) {
76
+ const model = CircuitJsonValidationProof.#validateModel(document)
77
+ await CircuitJsonValidationProof.#yieldToHost(options?.yield)
78
+ return CircuitJsonValidationProof.#attachAndSealAsync(document, model, {
79
+ ...options,
80
+ yield: () => CircuitJsonValidationProof.#yieldToHost(options?.yield)
81
+ })
82
+ }
83
+
84
+ /**
85
+ * Validates and freezes one exact model before any cooperative yield.
86
+ * @param {Record<string, any>} document Canonical document envelope.
87
+ * @returns {object[]} Stable validated model.
88
+ */
89
+ static #validateModel(document) {
60
90
  const model = CircuitJsonValidationProof.#requireModelData(document)
91
+ if (CircuitJsonValidationProof.#matches(document, model)) return model
92
+ const errors = CircuitJsonValidationAuthority.validateAndFreeze(model)
93
+ if (errors.length) throw new TypeError(errors[0])
94
+ if (CircuitJsonValidationProof.#requireModelData(document) !== model) {
95
+ throw new TypeError(
96
+ 'CircuitJSON document model changed during validation.'
97
+ )
98
+ }
99
+ return model
100
+ }
101
+
102
+ /**
103
+ * Attaches the private proof and seals the matching document envelope.
104
+ * @param {Record<string, any>} document Canonical document envelope.
105
+ * @param {object[]} model Stable validated model.
106
+ * @param {{ standardBuiltins?: boolean }} options Proven metadata provenance.
107
+ * @returns {Record<string, any>} Read-only document envelope.
108
+ */
109
+ static #attachAndSeal(document, model, options) {
110
+ if (CircuitJsonValidationProof.#requireModelData(document) !== model) {
111
+ throw new TypeError(
112
+ 'CircuitJSON document model changed before sealing.'
113
+ )
114
+ }
61
115
  if (!CircuitJsonValidationProof.#matches(document, model)) {
62
- const errors =
63
- CircuitJsonValidationAuthority.validateAndFreeze(model)
64
- if (errors.length) throw new TypeError(errors[0])
65
- if (
66
- CircuitJsonValidationProof.#requireModelData(document) !== model
67
- ) {
68
- throw new TypeError(
69
- 'CircuitJSON document model changed during validation.'
70
- )
71
- }
72
116
  Object.defineProperty(document, VALIDATION_PROOF, {
73
117
  configurable: false,
74
118
  enumerable: false,
@@ -96,6 +140,74 @@ export class CircuitJsonValidationProof {
96
140
  return readonlyDocument
97
141
  }
98
142
 
143
+ /**
144
+ * Attaches the proof and cooperatively seals a matching clone-owned
145
+ * document envelope.
146
+ * @param {Record<string, any>} document Canonical document envelope.
147
+ * @param {object[]} model Stable validated model.
148
+ * @param {{ standardBuiltins?: boolean, yield: () => Promise<void> }} options Proven provenance and normalized scheduler.
149
+ * @returns {Promise<Record<string, any>>} Read-only document envelope.
150
+ */
151
+ static async #attachAndSealAsync(document, model, options) {
152
+ if (CircuitJsonValidationProof.#requireModelData(document) !== model) {
153
+ throw new TypeError(
154
+ 'CircuitJSON document model changed before sealing.'
155
+ )
156
+ }
157
+ if (!CircuitJsonValidationProof.#matches(document, model)) {
158
+ Object.defineProperty(document, VALIDATION_PROOF, {
159
+ configurable: false,
160
+ enumerable: false,
161
+ value: new CircuitJsonValidationToken(
162
+ model,
163
+ VALIDATION_TOKEN_SECRET
164
+ ),
165
+ writable: false
166
+ })
167
+ }
168
+ const readonlyDocument =
169
+ await CircuitJsonReadOnlyDocument.freezeValidatedAsync(
170
+ document,
171
+ model,
172
+ options
173
+ )
174
+ if (
175
+ CircuitJsonValidationProof.#requireModelData(readonlyDocument) !==
176
+ model ||
177
+ !CircuitJsonValidationProof.#matches(readonlyDocument, model)
178
+ ) {
179
+ throw new TypeError(
180
+ 'CircuitJSON document model changed while sealing its validation proof.'
181
+ )
182
+ }
183
+ return readonlyDocument
184
+ }
185
+
186
+ /**
187
+ * Yields through an injected scheduler, the browser scheduler API, or a
188
+ * zero-delay host task in that order.
189
+ * @param {(() => Promise<void> | void) | undefined} yieldControl Optional host scheduler.
190
+ * @returns {Promise<void>}
191
+ */
192
+ static async #yieldToHost(yieldControl) {
193
+ if (yieldControl !== undefined) {
194
+ if (typeof yieldControl !== 'function') {
195
+ throw new TypeError(
196
+ 'Structured-clone yield control must be a function.'
197
+ )
198
+ }
199
+ await yieldControl()
200
+ return
201
+ }
202
+ const scheduler = globalThis.scheduler
203
+ const schedulerYield = scheduler?.yield
204
+ if (typeof schedulerYield === 'function') {
205
+ await Reflect.apply(schedulerYield, scheduler, [])
206
+ return
207
+ }
208
+ await new Promise((resolve) => globalThis.setTimeout(resolve, 0))
209
+ }
210
+
99
211
  /**
100
212
  * Returns true when an envelope proof matches its current model reference.
101
213
  * @param {unknown} document Document candidate.
@@ -1,11 +1,226 @@
1
1
  import { BinaryDataSnapshot } from './BinaryDataSnapshot.mjs'
2
2
 
3
3
  const PROTECTED_BINARY_GETTERS = new WeakSet()
4
+ const CAPTURED_BINARY_PROPERTIES = new WeakSet()
5
+ const PENDING_BINARY_CAPTURES = new WeakMap()
6
+ const BINARY_COPY_CHUNK_BYTES = 64 * 1024
4
7
 
5
8
  /**
6
9
  * Protects byte-backed values embedded in canonical extension containers.
7
10
  */
8
11
  export class ProtectedExtensionBinaryBoundary {
12
+ /**
13
+ * Protects the exact binary properties collected by a proven clone
14
+ * adoption traversal without rescanning every ordinary container.
15
+ * @param {{ owner: object, key: PropertyKey }[]} properties Binary properties.
16
+ * @returns {void}
17
+ */
18
+ static protectProperties(properties) {
19
+ if (!Array.isArray(properties)) {
20
+ throw new TypeError(
21
+ 'Canonical extension binary properties must be an array.'
22
+ )
23
+ }
24
+ for (const property of properties) {
25
+ const owner = property?.owner
26
+ const key = property?.key
27
+ let descriptor
28
+ try {
29
+ descriptor = Object.getOwnPropertyDescriptor(owner, key)
30
+ } catch {
31
+ throw new TypeError(
32
+ 'Canonical extension data could not be inspected safely.'
33
+ )
34
+ }
35
+ if (ProtectedExtensionBinaryBoundary.isProtected(descriptor)) {
36
+ continue
37
+ }
38
+ const captured = ProtectedExtensionBinaryBoundary.captureProperty(
39
+ owner,
40
+ key,
41
+ descriptor
42
+ )
43
+ ProtectedExtensionBinaryBoundary.protectCapturedProperty(captured)
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Captures one binary property without changing its owner.
49
+ * @param {object} owner Binary property owner.
50
+ * @param {PropertyKey} key Binary property key.
51
+ * @param {PropertyDescriptor | undefined} [knownDescriptor] Previously validated descriptor.
52
+ * @returns {object} Opaque captured binary property.
53
+ */
54
+ static captureProperty(owner, key, knownDescriptor = undefined) {
55
+ const capture = ProtectedExtensionBinaryBoundary.beginPropertyCapture(
56
+ owner,
57
+ key,
58
+ knownDescriptor
59
+ )
60
+ while (
61
+ !ProtectedExtensionBinaryBoundary.copyPropertyCaptureChunk(
62
+ capture,
63
+ Number.MAX_SAFE_INTEGER
64
+ )
65
+ ) {
66
+ // The synchronous API deliberately owns the full payload atomically.
67
+ }
68
+ return ProtectedExtensionBinaryBoundary.finishPropertyCapture(capture)
69
+ }
70
+
71
+ /**
72
+ * Starts one branded binary property capture without copying its payload.
73
+ * @param {object} owner Binary property owner.
74
+ * @param {PropertyKey} key Binary property key.
75
+ * @param {PropertyDescriptor | undefined} [knownDescriptor] Previously validated descriptor.
76
+ * @returns {object} Opaque pending capture token.
77
+ * @internal
78
+ */
79
+ static beginPropertyCapture(owner, key, knownDescriptor = undefined) {
80
+ let descriptor = knownDescriptor
81
+ if (descriptor === undefined) {
82
+ try {
83
+ descriptor = Object.getOwnPropertyDescriptor(owner, key)
84
+ } catch {
85
+ throw new TypeError(
86
+ 'Canonical extension data could not be inspected safely.'
87
+ )
88
+ }
89
+ }
90
+ if (!descriptor || !Object.hasOwn(descriptor, 'value')) {
91
+ throw new TypeError(
92
+ 'Canonical extension data may contain only data properties.'
93
+ )
94
+ }
95
+ const binary = BinaryDataSnapshot.describeStandard(descriptor.value)
96
+ if (!binary) {
97
+ throw new TypeError(
98
+ 'Canonical extension binary property changed during adoption.'
99
+ )
100
+ }
101
+ const capture = Object.freeze({})
102
+ PENDING_BINARY_CAPTURES.set(capture, {
103
+ bytes: new Uint8Array(binary.byteLength),
104
+ descriptor: Object.freeze({ ...descriptor }),
105
+ key,
106
+ offset: 0,
107
+ owner,
108
+ source: descriptor.value,
109
+ sourceRange: Object.freeze({ ...binary })
110
+ })
111
+ return capture
112
+ }
113
+
114
+ /**
115
+ * Copies the next bounded portion of one branded binary capture.
116
+ * @param {object} capture Opaque pending capture token.
117
+ * @param {number} [maxBytes] Maximum bytes copied in this call.
118
+ * @returns {boolean} Whether the entire payload has been copied.
119
+ * @internal
120
+ */
121
+ static copyPropertyCaptureChunk(
122
+ capture,
123
+ maxBytes = BINARY_COPY_CHUNK_BYTES
124
+ ) {
125
+ const state = PENDING_BINARY_CAPTURES.get(capture)
126
+ if (!state || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
127
+ throw new TypeError(
128
+ 'Canonical extension binary capture is not pending.'
129
+ )
130
+ }
131
+ const remaining = state.sourceRange.byteLength - state.offset
132
+ const count = Math.min(remaining, maxBytes)
133
+ BinaryDataSnapshot.copyBytesInto(
134
+ state.sourceRange,
135
+ state.bytes,
136
+ state.offset,
137
+ count
138
+ )
139
+ state.offset += count
140
+ return state.offset === state.sourceRange.byteLength
141
+ }
142
+
143
+ /**
144
+ * Finalizes one completely copied binary property into a trusted record.
145
+ * @param {object} capture Opaque pending capture token.
146
+ * @returns {object} Opaque captured binary property.
147
+ * @internal
148
+ */
149
+ static finishPropertyCapture(capture) {
150
+ const state = PENDING_BINARY_CAPTURES.get(capture)
151
+ if (!state || state.offset !== state.sourceRange.byteLength) {
152
+ throw new TypeError(
153
+ 'Canonical extension binary capture is incomplete.'
154
+ )
155
+ }
156
+ PENDING_BINARY_CAPTURES.delete(capture)
157
+ const ownedBinary = BinaryDataSnapshot.cloneFromBytes(
158
+ state.source,
159
+ state.sourceRange,
160
+ state.bytes
161
+ )
162
+ const ownedRange = BinaryDataSnapshot.describeStandard(ownedBinary)
163
+ if (!ownedRange) {
164
+ throw new TypeError(
165
+ 'Canonical extension binary could not be isolated.'
166
+ )
167
+ }
168
+ const captured = Object.freeze({
169
+ descriptor: state.descriptor,
170
+ key: state.key,
171
+ ownedBinary,
172
+ ownedRange: Object.freeze({ ...ownedRange }),
173
+ owner: state.owner
174
+ })
175
+ CAPTURED_BINARY_PROPERTIES.add(captured)
176
+ return captured
177
+ }
178
+
179
+ /**
180
+ * Installs one previously captured binary as a defensive-copy getter after
181
+ * proving its property descriptor still matches the transferred source.
182
+ * @param {object} captured Opaque captured binary property.
183
+ * @returns {void}
184
+ */
185
+ static protectCapturedProperty(captured) {
186
+ if (!CAPTURED_BINARY_PROPERTIES.has(captured)) {
187
+ throw new TypeError(
188
+ 'Canonical extension binary capture is not trusted.'
189
+ )
190
+ }
191
+ CAPTURED_BINARY_PROPERTIES.delete(captured)
192
+ let descriptor
193
+ try {
194
+ descriptor = Object.getOwnPropertyDescriptor(
195
+ captured.owner,
196
+ captured.key
197
+ )
198
+ } catch {
199
+ throw new TypeError(
200
+ 'Canonical extension data could not be inspected safely.'
201
+ )
202
+ }
203
+ if (
204
+ !ProtectedExtensionBinaryBoundary.#sameDataDescriptor(
205
+ descriptor,
206
+ captured.descriptor
207
+ )
208
+ ) {
209
+ throw new TypeError(
210
+ 'Canonical extension binary property changed during adoption.'
211
+ )
212
+ }
213
+ /** @returns {ArrayBuffer | Uint8Array | DataView} A defensive binary copy. */
214
+ const readBinary = () =>
215
+ BinaryDataSnapshot.clone(captured.ownedBinary, captured.ownedRange)
216
+ PROTECTED_BINARY_GETTERS.add(readBinary)
217
+ Object.defineProperty(captured.owner, captured.key, {
218
+ configurable: false,
219
+ enumerable: descriptor.enumerable,
220
+ get: readBinary
221
+ })
222
+ }
223
+
9
224
  /**
10
225
  * Replaces owned binary data properties with defensive-copy getters.
11
226
  * @param {unknown} root Owned extension graph.
@@ -98,6 +313,24 @@ export class ProtectedExtensionBinaryBoundary {
98
313
  )
99
314
  }
100
315
 
316
+ /**
317
+ * Compares exact ordinary data descriptor semantics without coercion.
318
+ * @param {PropertyDescriptor | undefined} current Current descriptor.
319
+ * @param {PropertyDescriptor} expected Previously captured descriptor.
320
+ * @returns {boolean} Whether both descriptors still match.
321
+ */
322
+ static #sameDataDescriptor(current, expected) {
323
+ return Boolean(
324
+ current &&
325
+ Object.hasOwn(current, 'value') &&
326
+ Object.hasOwn(expected, 'value') &&
327
+ Object.is(current.value, expected.value) &&
328
+ current.configurable === expected.configurable &&
329
+ current.enumerable === expected.enumerable &&
330
+ current.writable === expected.writable
331
+ )
332
+ }
333
+
101
334
  /**
102
335
  * Returns true for plain extension containers.
103
336
  * @param {unknown} value Candidate value.