@affiant/core 0.1.0-alpha.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/LICENSE +202 -0
- package/README.md +354 -0
- package/dist/context.d.ts +136 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +30 -0
- package/dist/context.js.map +1 -0
- package/dist/docket/entry.d.ts +421 -0
- package/dist/docket/entry.d.ts.map +1 -0
- package/dist/docket/entry.js +155 -0
- package/dist/docket/entry.js.map +1 -0
- package/dist/docket/expiry.d.ts +82 -0
- package/dist/docket/expiry.d.ts.map +1 -0
- package/dist/docket/expiry.js +106 -0
- package/dist/docket/expiry.js.map +1 -0
- package/dist/docket/memory.d.ts +163 -0
- package/dist/docket/memory.d.ts.map +1 -0
- package/dist/docket/memory.js +528 -0
- package/dist/docket/memory.js.map +1 -0
- package/dist/docket/store.d.ts +387 -0
- package/dist/docket/store.d.ts.map +1 -0
- package/dist/docket/store.js +51 -0
- package/dist/docket/store.js.map +1 -0
- package/dist/errors.d.ts +153 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +164 -0
- package/dist/errors.js.map +1 -0
- package/dist/gate/coverage.d.ts +152 -0
- package/dist/gate/coverage.d.ts.map +1 -0
- package/dist/gate/coverage.js +114 -0
- package/dist/gate/coverage.js.map +1 -0
- package/dist/gate/decide.d.ts +207 -0
- package/dist/gate/decide.d.ts.map +1 -0
- package/dist/gate/decide.js +559 -0
- package/dist/gate/decide.js.map +1 -0
- package/dist/gate/gate.d.ts +212 -0
- package/dist/gate/gate.d.ts.map +1 -0
- package/dist/gate/gate.js +175 -0
- package/dist/gate/gate.js.map +1 -0
- package/dist/gate/pipeline.d.ts +285 -0
- package/dist/gate/pipeline.d.ts.map +1 -0
- package/dist/gate/pipeline.js +515 -0
- package/dist/gate/pipeline.js.map +1 -0
- package/dist/gate/policy.d.ts +272 -0
- package/dist/gate/policy.d.ts.map +1 -0
- package/dist/gate/policy.js +396 -0
- package/dist/gate/policy.js.map +1 -0
- package/dist/gate/wrap.d.ts +107 -0
- package/dist/gate/wrap.d.ts.map +1 -0
- package/dist/gate/wrap.js +164 -0
- package/dist/gate/wrap.js.map +1 -0
- package/dist/index.d.ts +95 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +111 -0
- package/dist/index.js.map +1 -0
- package/dist/model/affidavit.d.ts +354 -0
- package/dist/model/affidavit.d.ts.map +1 -0
- package/dist/model/affidavit.js +417 -0
- package/dist/model/affidavit.js.map +1 -0
- package/dist/model/amendments.d.ts +160 -0
- package/dist/model/amendments.d.ts.map +1 -0
- package/dist/model/amendments.js +183 -0
- package/dist/model/amendments.js.map +1 -0
- package/dist/model/canonical.d.ts +311 -0
- package/dist/model/canonical.d.ts.map +1 -0
- package/dist/model/canonical.js +665 -0
- package/dist/model/canonical.js.map +1 -0
- package/dist/model/money.d.ts +127 -0
- package/dist/model/money.d.ts.map +1 -0
- package/dist/model/money.js +177 -0
- package/dist/model/money.js.map +1 -0
- package/dist/model/provenance.d.ts +315 -0
- package/dist/model/provenance.d.ts.map +1 -0
- package/dist/model/provenance.js +223 -0
- package/dist/model/provenance.js.map +1 -0
- package/dist/ports.d.ts +269 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +34 -0
- package/dist/ports.js.map +1 -0
- package/dist/store-memory.d.ts +21 -0
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/store-memory.js +20 -0
- package/dist/store-memory.js.map +1 -0
- package/dist/telemetry-keys.d.ts +65 -0
- package/dist/telemetry-keys.d.ts.map +1 -0
- package/dist/telemetry-keys.js +72 -0
- package/dist/telemetry-keys.js.map +1 -0
- package/dist/telemetry.d.ts +77 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +43 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/testing.d.ts +574 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +1291 -0
- package/dist/testing.js.map +1 -0
- package/package.json +75 -0
- package/telemetry-keys.json +92 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical form of an Affidavit and its accepted amendments, and the SHA-256
|
|
3
|
+
* hash over it.
|
|
4
|
+
*
|
|
5
|
+
* **Rules served: SR-1, SR-2, RT-1.**
|
|
6
|
+
*
|
|
7
|
+
* **SR-1** — *the canonical form of a filed proposal is a deterministic byte
|
|
8
|
+
* sequence over the Affidavit **and its accepted amendments** (the amended field
|
|
9
|
+
* values with their reviewer-act tags, in the same field order), with UTF-8, object
|
|
10
|
+
* keys sorted by Unicode code point, no insignificant whitespace, numbers as their
|
|
11
|
+
* shortest round-trip decimal representation, `null` written and absent omitted.
|
|
12
|
+
* `canonicalHash` is the SHA-256 of that form.*
|
|
13
|
+
*
|
|
14
|
+
* **Why the amendments are inside the form and not beside it.** A host's execution
|
|
15
|
+
* grant binds to `canonicalHash(Affidavit + amendments)`. If the form covered the
|
|
16
|
+
* Affidavit alone, a grant minted for the proposal a reviewer *was shown* would
|
|
17
|
+
* still validate the proposal they *amended* — the one substitution the whole
|
|
18
|
+
* framework exists to prevent. Two conformance fixtures in this package's vector
|
|
19
|
+
* set differ only by an amendment, and their hashes differ; that is the rule made
|
|
20
|
+
* checkable. On a Docket row the same thing is spelled {@link canonicalizeEntry}:
|
|
21
|
+
* the row keeps the proposal and the accepted state separately, and the form is
|
|
22
|
+
* taken over the accepted state where there is one.
|
|
23
|
+
*
|
|
24
|
+
* **What the form is taken over: the Affidavit as the schema defines it.** Every
|
|
25
|
+
* property of the record is in the bytes, `protocolVersion` included — the schema
|
|
26
|
+
* requires it on an Affidavit, and the promoted byte vectors carry it. The card
|
|
27
|
+
* *envelope*'s presentation is not: `allowedValues`, `pattern`, `warnings` and
|
|
28
|
+
* `requiresConfirmation` are a host's rendering of a proposal rather than its sworn
|
|
29
|
+
* substance, and putting a rendering decision inside a hash a grant is checked
|
|
30
|
+
* against would let restyling an input invalidate a grant minted over evidence that
|
|
31
|
+
* did not change. Until this version the runtime model omitted `protocolVersion`
|
|
32
|
+
* and only `toWire` stamped it, so the bytes a Docket row's hash was taken over
|
|
33
|
+
* were not the bytes of the same record on a card. The model carries it now
|
|
34
|
+
* (`model/affidavit.ts`), and both paths produce one document for one record.
|
|
35
|
+
*
|
|
36
|
+
* **What applying an amendment does, exactly**, is `model/amendments.ts`'s answer and
|
|
37
|
+
* not this module's: the tag in force comes from `amendmentTag`, so the bytes a
|
|
38
|
+
* decision produces here and the row that decision writes cannot disagree. That
|
|
39
|
+
* carries AF-1's clearing rule with it — a cleared **mandatory** field stays, tagged
|
|
40
|
+
* `Empty` at confidence `0`, and a cleared **optional** field leaves the field list,
|
|
41
|
+
* because a field the write no longer proposes is absent rather than present with
|
|
42
|
+
* nothing in it — and AF-4's recompute, where the document carries an aggregate.
|
|
43
|
+
*
|
|
44
|
+
* **Why a canonical form at all.** Three things depend on two independent
|
|
45
|
+
* implementations agreeing byte for byte: the conformance suite compares canonical
|
|
46
|
+
* forms across the .NET and TypeScript lines; an `utterance-span` provenance
|
|
47
|
+
* binding hashes the span it points at, so an auditor can re-derive it years later;
|
|
48
|
+
* and the execution grant above. `JSON.stringify` cannot do any of that — its key
|
|
49
|
+
* order is insertion order, so the same Affidavit built by two code paths produces
|
|
50
|
+
* two different documents and two different hashes.
|
|
51
|
+
*
|
|
52
|
+
* ## The form, precisely
|
|
53
|
+
*
|
|
54
|
+
* A JSON document with no space, tab or newline between tokens. Within it:
|
|
55
|
+
*
|
|
56
|
+
* - **Objects** — `{`, then each own enumerable string-keyed property whose value is
|
|
57
|
+
* not `undefined`, sorted by **Unicode code point** of the key, as `"key":value`
|
|
58
|
+
* separated by `,`, then `}`. Sorting by code point rather than by UTF-16 code
|
|
59
|
+
* unit matters above U+FFFF: a key starting with an emoji sorts *after* a key
|
|
60
|
+
* starting with U+E000, though a naive JavaScript `<` puts it first. Symbol keys
|
|
61
|
+
* are not JSON and are ignored; inherited properties are not own properties and
|
|
62
|
+
* are ignored.
|
|
63
|
+
* - **Arrays** — `[`, elements in their own order, separated by `,`, then `]`.
|
|
64
|
+
* Array order is data, never sorted. An `undefined` element is refused rather
|
|
65
|
+
* than written as `null`: "absent omitted" is meaningful for a property and
|
|
66
|
+
* meaningless for a position, and silently substituting `null` would put a value
|
|
67
|
+
* on an audit record that the producer never wrote.
|
|
68
|
+
* - **Strings** — JSON escaping and nothing more: `"` and `\` escaped, the C0
|
|
69
|
+
* control characters escaped (`\b`, `\t`, `\n`, `\f`, `\r`, else `\u00xx` with
|
|
70
|
+
* lowercase hex), unpaired surrogates escaped as `\udxxx`. Every other character
|
|
71
|
+
* is written as itself and encoded as UTF-8 — **no `\u` escapes for non-ASCII**,
|
|
72
|
+
* so `"é"` is two bytes and not six, and `/` is never escaped.
|
|
73
|
+
* - **Numbers** — the shortest round-trip decimal, written positionally: never an
|
|
74
|
+
* exponent, `-0` written as `0`, non-finite refused. See {@link canonicalJson}
|
|
75
|
+
* for what "positionally" costs and why it is the safer half of the trade.
|
|
76
|
+
* - **`null`** — written. **`true` / `false`** — written.
|
|
77
|
+
* - **Money** — `{ amount, currency }` is two strings (SR-2). A value shaped like
|
|
78
|
+
* money whose amount is a number is refused here rather than hashed, because a
|
|
79
|
+
* float amount that reached the canonical form would be sworn to.
|
|
80
|
+
*
|
|
81
|
+
* ## What is refused
|
|
82
|
+
*
|
|
83
|
+
* A non-finite number is a `RangeError`. Everything else that has no canonical form
|
|
84
|
+
* is a `TypeError`: `undefined` in an array position, a `bigint`, a function, a
|
|
85
|
+
* symbol, a `Date` or `Map` or typed array (anything whose `Object.prototype.toString`
|
|
86
|
+
* tag is not `[object Object]` or `[object Array]`), a cycle, and money with a
|
|
87
|
+
* numeric amount. An audit form never guesses: a value it cannot write exactly is a
|
|
88
|
+
* value it refuses to write at all.
|
|
89
|
+
*
|
|
90
|
+
* ## Integers beyond 2^53 (the documented limit of the rule)
|
|
91
|
+
*
|
|
92
|
+
* JSON numbers are IEEE 754 doubles by the time any implementation sees them, so
|
|
93
|
+
* `9007199254740993` has already become `9007199254740992` in the parser, before the
|
|
94
|
+
* canonical form is reached. This module therefore writes the shortest round-trip
|
|
95
|
+
* decimal of the double it was given and **does not** convert large numbers to strings: a
|
|
96
|
+
* canonical form cannot recover precision the parse already lost, and a form that
|
|
97
|
+
* quietly restringified some numbers and not others would be harder to reimplement,
|
|
98
|
+
* not easier. A host that needs exact integers beyond 2^53 — an account number, a
|
|
99
|
+
* minor-unit amount, an accounting record's id — carries them as strings on the
|
|
100
|
+
* wire. Money
|
|
101
|
+
* already does, by SR-2.
|
|
102
|
+
*
|
|
103
|
+
* ## Runtime (RT-1)
|
|
104
|
+
*
|
|
105
|
+
* `canonicalHash` digests through `globalThis.crypto.subtle`, which has no
|
|
106
|
+
* synchronous form, so every hash path in this framework is asynchronous end to end
|
|
107
|
+
* — the stated portability choice, and the reason `canonicalHash` returns a
|
|
108
|
+
* `Promise` on Node, on Bun and inside workerd alike. No `node:crypto`, no
|
|
109
|
+
* filesystem, no Node global.
|
|
110
|
+
*
|
|
111
|
+
* @packageDocumentation
|
|
112
|
+
*/
|
|
113
|
+
import { amendmentTag, resolveAmendments } from "./amendments.js";
|
|
114
|
+
import { MONEY_AMOUNT_PATTERN, MONEY_CURRENCY_PATTERN } from "./money.js";
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Amendments
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
/**
|
|
119
|
+
* Apply an {@link AmendmentMap} to an Affidavit, returning a new object: each
|
|
120
|
+
* amended field's `value` replaced and the reviewer's own tag put in force on its
|
|
121
|
+
* provenance chain, with the tag it supersedes preserved beneath it.
|
|
122
|
+
*
|
|
123
|
+
* **The tag is `model/amendments.ts`'s, not this module's.** `amendmentTag` is the
|
|
124
|
+
* single definition of what an accepted amendment does to a field's provenance, and
|
|
125
|
+
* both paths call it — so the bytes this function produces for a decision are the
|
|
126
|
+
* bytes the Docket row's own `amendedAffidavit` produces for the same decision.
|
|
127
|
+
* There is no second, nearly-identical tag to drift.
|
|
128
|
+
*
|
|
129
|
+
* **DK-2**: `null` under a key clears the field; an absent key leaves it untouched.
|
|
130
|
+
* The two are never conflated.
|
|
131
|
+
*
|
|
132
|
+
* **AF-1**, on what a clear does: a **mandatory** field keeps its place with value
|
|
133
|
+
* `null` under an `Empty` tag at confidence `0`, and an **optional** field leaves
|
|
134
|
+
* `fields[]` entirely — a reviewer clearing an optional field is saying "do not
|
|
135
|
+
* write this one", and a field the write no longer proposes is absent rather than
|
|
136
|
+
* present with nothing in it. A field with no `isMandatory` property is read as
|
|
137
|
+
* optional, which is what the property's absence means everywhere else.
|
|
138
|
+
*
|
|
139
|
+
* **AF-4**: every confidence number the document carries — the aggregate, the
|
|
140
|
+
* populated minimum and the empty-field count — is recomputed over the amended
|
|
141
|
+
* fields. A canonical form that kept a pre-correction number would let a grant bind
|
|
142
|
+
* to an Affidavit whose own summary contradicts its fields.
|
|
143
|
+
*
|
|
144
|
+
* @param affidavit The Affidavit as filed.
|
|
145
|
+
* @param amendments The reviewer's accepted amendments, keyed by field name.
|
|
146
|
+
* @param act The decision the amendments arrived on (PV-2).
|
|
147
|
+
* @throws TypeError when the input carries no `fields` array, when a field is not
|
|
148
|
+
* an object, or when an amendment names a field the Affidavit does not
|
|
149
|
+
* carry — an amendment to something nobody swore to is a bug in the caller,
|
|
150
|
+
* and swallowing it would let two implementations disagree in silence.
|
|
151
|
+
* @throws RangeError when `amendments` holds `undefined` under a key (DK-2).
|
|
152
|
+
*/
|
|
153
|
+
export function applyAmendmentsForCanonical(affidavit, amendments, act) {
|
|
154
|
+
const resolved = resolveAmendments(amendments);
|
|
155
|
+
if (resolved.length === 0)
|
|
156
|
+
return affidavit;
|
|
157
|
+
const byName = new Map(resolved.map((entry) => [entry.name, entry.amendment]));
|
|
158
|
+
const record = asRecord(affidavit, "the Affidavit");
|
|
159
|
+
const fields = record["fields"];
|
|
160
|
+
if (!Array.isArray(fields)) {
|
|
161
|
+
throw new TypeError("SR-1: amendments are applied per field, so the Affidavit must carry a fields array; " +
|
|
162
|
+
`received ${describe(fields)} under "fields".`);
|
|
163
|
+
}
|
|
164
|
+
const conversationTurn = typeof record["conversationTurn"] === "number" ? record["conversationTurn"] : null;
|
|
165
|
+
const amended = new Set();
|
|
166
|
+
const nextFields = [];
|
|
167
|
+
for (const [index, field] of fields.entries()) {
|
|
168
|
+
const entry = asRecord(field, `fields[${String(index)}]`);
|
|
169
|
+
const name = entry["name"];
|
|
170
|
+
if (typeof name !== "string") {
|
|
171
|
+
nextFields.push(field);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const amendment = byName.get(name);
|
|
175
|
+
if (amendment === undefined) {
|
|
176
|
+
nextFields.push(field);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
amended.add(name);
|
|
180
|
+
// AF-1: a cleared optional field is a field the write no longer proposes.
|
|
181
|
+
if (amendment.kind === "clear" && entry["isMandatory"] !== true)
|
|
182
|
+
continue;
|
|
183
|
+
nextFields.push({
|
|
184
|
+
...entry,
|
|
185
|
+
value: amendment.kind === "clear" ? null : amendment.value,
|
|
186
|
+
provenance: withReviewerAct(entry["provenance"], amendmentTag(amendment, act, conversationTurn)),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
for (const { name } of resolved) {
|
|
190
|
+
if (!amended.has(name)) {
|
|
191
|
+
throw new TypeError(`DK-2: the amendment map names the field ${JSON.stringify(name)}, which this Affidavit ` +
|
|
192
|
+
`does not carry. An amendment applies to a field that was sworn to; a key with no ` +
|
|
193
|
+
`field is a caller bug, not an empty amendment.`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const numbers = confidenceOf(nextFields);
|
|
197
|
+
const next = { ...record, fields: nextFields };
|
|
198
|
+
// Each of the three is rewritten only where the document already carries it, so
|
|
199
|
+
// this function never adds a property to a record that did not have one.
|
|
200
|
+
if (typeof record["aggregateConfidence"] === "number") {
|
|
201
|
+
next["aggregateConfidence"] = numbers.aggregateConfidence;
|
|
202
|
+
}
|
|
203
|
+
if (Object.hasOwn(record, "populatedConfidence")) {
|
|
204
|
+
next["populatedConfidence"] = numbers.populatedConfidence;
|
|
205
|
+
}
|
|
206
|
+
if (typeof record["emptyFieldCount"] === "number") {
|
|
207
|
+
next["emptyFieldCount"] = numbers.emptyFieldCount;
|
|
208
|
+
}
|
|
209
|
+
return next;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* AF-2's three numbers over already-serialized fields.
|
|
213
|
+
*
|
|
214
|
+
* - `aggregateConfidence` — the minimum confidence, with an `Empty` tag counting as
|
|
215
|
+
* `0` and no proposed field at all counting as `0`.
|
|
216
|
+
* - `populatedConfidence` — the minimum over the non-`Empty` fields, `null` when
|
|
217
|
+
* there are none.
|
|
218
|
+
* - `emptyFieldCount` — how many fields are tagged `Empty`.
|
|
219
|
+
*
|
|
220
|
+
* Written out here rather than reached for from `model/affidavit.ts` because this
|
|
221
|
+
* module serializes *whatever object it is handed*, including the wire shape whose
|
|
222
|
+
* fields are not core `AffidavitField`s. A field whose chain says nothing readable
|
|
223
|
+
* contributes `0`: an unreadable grade is not evidence of a good one.
|
|
224
|
+
*
|
|
225
|
+
* **Why all three and not only the aggregate.** AF-4 says an accepted amendment
|
|
226
|
+
* recomputes the numbers, and the v0.1 record carries three of them. Recomputing
|
|
227
|
+
* one and carrying the other two over from the proposal would produce an accepted
|
|
228
|
+
* state that contradicts its own fields — a record whose only remaining field is
|
|
229
|
+
* sworn at `1` while `populatedConfidence` still reads the pre-correction `0.9` —
|
|
230
|
+
* and SR-1 defines the canonical form over the accepted state, so those bytes would
|
|
231
|
+
* be what a host's execution grant binds to. The seed-shaped record carried one
|
|
232
|
+
* number and is unaffected: a property that is not there is not written.
|
|
233
|
+
*/
|
|
234
|
+
function confidenceOf(fields) {
|
|
235
|
+
let lowest = 1;
|
|
236
|
+
let populated = null;
|
|
237
|
+
let emptyFieldCount = 0;
|
|
238
|
+
for (const field of fields) {
|
|
239
|
+
const chain = field.provenance;
|
|
240
|
+
const current = chain?.current;
|
|
241
|
+
const isEmpty = current?.source === "Empty";
|
|
242
|
+
const confidence = isEmpty || typeof current?.confidence !== "number" ? 0 : current.confidence;
|
|
243
|
+
if (confidence < lowest)
|
|
244
|
+
lowest = confidence;
|
|
245
|
+
if (isEmpty) {
|
|
246
|
+
emptyFieldCount += 1;
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
populated = populated === null ? confidence : Math.min(populated, confidence);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
aggregateConfidence: fields.length === 0 ? 0 : lowest,
|
|
254
|
+
populatedConfidence: populated,
|
|
255
|
+
emptyFieldCount,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Put `tag` in force on a provenance chain, preserving the tag it supersedes.
|
|
260
|
+
*
|
|
261
|
+
* Two spellings of the history array are accepted — `prior`, which the protocol's
|
|
262
|
+
* seed schemas, the wire fixtures and the core model all use, and `history`, which
|
|
263
|
+
* an earlier working draft used. A chain that carries neither gets `prior`.
|
|
264
|
+
*/
|
|
265
|
+
function withReviewerAct(chain, tag) {
|
|
266
|
+
if (chain === null || chain === undefined)
|
|
267
|
+
return { current: tag, prior: [] };
|
|
268
|
+
const record = asRecord(chain, "provenance");
|
|
269
|
+
const key = !Array.isArray(record["prior"]) && Array.isArray(record["history"]) ? "history" : "prior";
|
|
270
|
+
const existing = record[key];
|
|
271
|
+
const history = Array.isArray(existing) ? existing : [];
|
|
272
|
+
const superseded = record["current"];
|
|
273
|
+
return {
|
|
274
|
+
...record,
|
|
275
|
+
current: tag,
|
|
276
|
+
[key]: superseded === undefined ? [...history] : [superseded, ...history],
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// The public canonicalization entry points
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
/**
|
|
283
|
+
* The canonical form of `affidavit` and its accepted `amendments`, as UTF-8 bytes
|
|
284
|
+
* (SR-1).
|
|
285
|
+
*
|
|
286
|
+
* @param affidavit The Affidavit as filed.
|
|
287
|
+
* @param amendments The accepted amendments, or `null` / omitted for none. An empty
|
|
288
|
+
* map is the same as none and needs no `reviewerAct`.
|
|
289
|
+
* @param options {@link CanonicalizeOptions.reviewerAct}, required whenever
|
|
290
|
+
* `amendments` is non-empty.
|
|
291
|
+
* @throws RangeError on a non-finite number; TypeError on anything else with no
|
|
292
|
+
* canonical form (see the module header).
|
|
293
|
+
*/
|
|
294
|
+
export function canonicalize(affidavit, amendments, options) {
|
|
295
|
+
return new TextEncoder().encode(canonicalString(affidavit, amendments, options));
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The canonical form of `affidavit` and its accepted `amendments`, as a string —
|
|
299
|
+
* the same document {@link canonicalize} returns, before UTF-8 encoding.
|
|
300
|
+
*
|
|
301
|
+
* Useful where the bytes are not what is wanted: a fixture that has to be readable
|
|
302
|
+
* in a diff, a log line, a comparison in a test. The bytes are the contract; this is
|
|
303
|
+
* the same document one encoding step earlier.
|
|
304
|
+
*/
|
|
305
|
+
export function canonicalString(affidavit, amendments, options) {
|
|
306
|
+
return canonicalJson(withAmendments(affidavit, amendments, options));
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* The SHA-256 of the canonical form, as lowercase hex (SR-1, RT-1).
|
|
310
|
+
*
|
|
311
|
+
* Asynchronous on every runtime because Web Crypto has no synchronous digest, and
|
|
312
|
+
* Web Crypto is the only digest a package that must run on Node, Bun and workerd
|
|
313
|
+
* can reach (RT-1). This is a stated portability choice, not an oversight: the
|
|
314
|
+
* .NET line may hash synchronously, since the conformance fixtures assert *values*,
|
|
315
|
+
* not call shapes.
|
|
316
|
+
*
|
|
317
|
+
* @returns 64 lowercase hex characters.
|
|
318
|
+
*/
|
|
319
|
+
export async function canonicalHash(affidavit, amendments, options) {
|
|
320
|
+
return sha256Hex(canonicalize(affidavit, amendments, options));
|
|
321
|
+
}
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// The canonical form of a Docket row
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
/**
|
|
326
|
+
* The Affidavit a Docket row's canonical form is taken over: the state a reviewer's
|
|
327
|
+
* amendments produced if there is one, and the proposal otherwise (SR-1).
|
|
328
|
+
*
|
|
329
|
+
* This is what SR-1's "the Affidavit **and its accepted amendments**" means on a
|
|
330
|
+
* row. The row keeps both — `affidavit` as the agent proposed it, never edited, and
|
|
331
|
+
* `amendedAffidavit` as the approval accepted it — so the sworn form and the
|
|
332
|
+
* proposal are separately readable and only one of them is what a grant binds to.
|
|
333
|
+
*/
|
|
334
|
+
export function swornAffidavitOf(entry) {
|
|
335
|
+
return entry.amendedAffidavit ?? entry.affidavit;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* The canonical form of a Docket row, as UTF-8 bytes (SR-1).
|
|
339
|
+
*
|
|
340
|
+
* Equivalent to `canonicalize(entry.amendedAffidavit ?? entry.affidavit)`, with no
|
|
341
|
+
* amendment argument: the amendments were applied when the approval was recorded,
|
|
342
|
+
* by the same `amendmentTag` this module uses, so there is nothing left to apply.
|
|
343
|
+
*
|
|
344
|
+
* **This is the function a host's execution grant hashes over.** Binding a grant to
|
|
345
|
+
* the proposal instead would let a grant minted for the Affidavit a reviewer was
|
|
346
|
+
* shown validate the one they amended — the substitution the framework exists to
|
|
347
|
+
* prevent.
|
|
348
|
+
*/
|
|
349
|
+
export function canonicalizeEntry(entry) {
|
|
350
|
+
return canonicalize(swornAffidavitOf(entry));
|
|
351
|
+
}
|
|
352
|
+
/** The canonical form of a Docket row as a string — {@link canonicalizeEntry} one encoding step earlier. */
|
|
353
|
+
export function canonicalStringEntry(entry) {
|
|
354
|
+
return canonicalString(swornAffidavitOf(entry));
|
|
355
|
+
}
|
|
356
|
+
/** The SHA-256 of a Docket row's canonical form, as lowercase hex (SR-1, RT-1). */
|
|
357
|
+
export async function canonicalHashEntry(entry) {
|
|
358
|
+
return canonicalHash(swornAffidavitOf(entry));
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* SHA-256 over arbitrary bytes, as lowercase hex, through Web Crypto (RT-1).
|
|
362
|
+
*
|
|
363
|
+
* Exported because the canonical form is not the only thing this framework hashes:
|
|
364
|
+
* an `utterance-span` binding hashes the span it points at, so an auditor can
|
|
365
|
+
* re-derive it, and a host's execution grant hashes what it grants over.
|
|
366
|
+
*
|
|
367
|
+
* @throws Error when the runtime exposes no `crypto.subtle` — which, on a runtime
|
|
368
|
+
* this package claims to support, means the host has replaced a standard
|
|
369
|
+
* global rather than that the digest is unavailable.
|
|
370
|
+
*/
|
|
371
|
+
export async function sha256Hex(bytes) {
|
|
372
|
+
const subtle = globalThis.crypto?.subtle;
|
|
373
|
+
if (subtle === undefined) {
|
|
374
|
+
throw new Error("RT-1: this package hashes through Web Crypto only (globalThis.crypto.subtle), which is " +
|
|
375
|
+
"present on Node 22, Bun and workerd. There is no node:crypto fallback by design: a " +
|
|
376
|
+
"synchronous fallback on one runtime would make the hash path synchronous there and " +
|
|
377
|
+
"asynchronous everywhere else, and the contract is asynchronous end to end.");
|
|
378
|
+
}
|
|
379
|
+
// Copied into a view this function owns: `digest` takes an ArrayBuffer-backed
|
|
380
|
+
// source, and a caller's array may be backed by a SharedArrayBuffer another
|
|
381
|
+
// thread can write to while the digest runs. A canonical form is small, and a
|
|
382
|
+
// hash of bytes that changed underneath it would be worse than a copy.
|
|
383
|
+
const digest = await subtle.digest("SHA-256", new Uint8Array(bytes));
|
|
384
|
+
const view = new Uint8Array(digest);
|
|
385
|
+
let hex = "";
|
|
386
|
+
for (const byte of view)
|
|
387
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
388
|
+
return hex;
|
|
389
|
+
}
|
|
390
|
+
/** Apply the amendments if there are any, refusing an amendment with no reviewer act. */
|
|
391
|
+
function withAmendments(affidavit, amendments, options) {
|
|
392
|
+
if (amendments === null || amendments === undefined)
|
|
393
|
+
return affidavit;
|
|
394
|
+
if (Object.keys(amendments).length === 0)
|
|
395
|
+
return affidavit;
|
|
396
|
+
const act = options?.reviewerAct;
|
|
397
|
+
if (act === undefined) {
|
|
398
|
+
throw new TypeError("PV-2: applying amendments to the canonical form needs options.reviewerAct — the Docket " +
|
|
399
|
+
"decision the amendments arrived on, its instant and its principal. The reviewer-act " +
|
|
400
|
+
"binding names that decision, and a binding whose source cannot be checked is not a " +
|
|
401
|
+
"binding.");
|
|
402
|
+
}
|
|
403
|
+
return applyAmendmentsForCanonical(affidavit, amendments, act);
|
|
404
|
+
}
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// The writer
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
/**
|
|
409
|
+
* The canonical form of any JSON value (SR-1), as a string.
|
|
410
|
+
*
|
|
411
|
+
* The general writer the Affidavit entry points are built on, exported because
|
|
412
|
+
* SR-1's form is defined over JSON and not over one type: a binding, a grant
|
|
413
|
+
* payload and a fixture all need the same bytes.
|
|
414
|
+
*
|
|
415
|
+
* **Numbers, and the one place this is stricter than RFC 8785.** The shortest
|
|
416
|
+
* round-trip decimal for a value is what JavaScript's `Number#toString` produces,
|
|
417
|
+
* and that is the form RFC 8785 (JSON Canonicalization Scheme) adopts wholesale —
|
|
418
|
+
* exponent notation included, so `1e21` serializes as `1e+21`. This writer instead
|
|
419
|
+
* writes every number **positionally**: `1000000000000000000000`, and `1e-7` as
|
|
420
|
+
* `0.0000001`. The digits are the same digits; only the decimal point moves, so the
|
|
421
|
+
* value denoted is identical and it parses back to the identical double. What is
|
|
422
|
+
* bought is that a second implementation — in .NET, in Go, in a database function —
|
|
423
|
+
* has to agree about *digits*, not about ECMAScript's exponent thresholds and its
|
|
424
|
+
* `e+21` spelling, which is the part of RFC 8785 that needs an appendix. What is
|
|
425
|
+
* paid is length: a denormal near `5e-324` writes out as roughly a thousand
|
|
426
|
+
* characters. That is a bad number to have in an audit record for reasons that have
|
|
427
|
+
* nothing to do with this function.
|
|
428
|
+
*
|
|
429
|
+
* "Positionally" means the **shortest round-trip digits** with the point moved, not
|
|
430
|
+
* the double's exact binary value: `1.2345678901234569e23` is written
|
|
431
|
+
* `123456789012345690000000`, not the exact `123456789012345685803008`. The two
|
|
432
|
+
* parse to the same double, and SR-1 asks for the shortest round-trip form — so the
|
|
433
|
+
* digits are the ones `Number#toString` chose, and nothing else.
|
|
434
|
+
*
|
|
435
|
+
* @throws RangeError on `NaN` or an infinity — a number with no decimal form at all.
|
|
436
|
+
* @throws TypeError on a value with no canonical form (see the module header).
|
|
437
|
+
*/
|
|
438
|
+
export function canonicalJson(value) {
|
|
439
|
+
const out = [];
|
|
440
|
+
writeValue(value, out, "", new Set());
|
|
441
|
+
return out.join("");
|
|
442
|
+
}
|
|
443
|
+
function writeValue(value, out, path, open) {
|
|
444
|
+
if (value === null) {
|
|
445
|
+
out.push("null");
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (typeof value === "boolean") {
|
|
449
|
+
out.push(value ? "true" : "false");
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (typeof value === "string") {
|
|
453
|
+
out.push(JSON.stringify(value));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (typeof value === "number") {
|
|
457
|
+
out.push(writeNumber(value, path));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (typeof value === "undefined") {
|
|
461
|
+
throw new TypeError(`SR-1: undefined has no canonical form and cannot be written at ${at(path)}. A property ` +
|
|
462
|
+
`whose value is undefined is omitted; an undefined anywhere else is a caller bug.`);
|
|
463
|
+
}
|
|
464
|
+
if (typeof value === "bigint") {
|
|
465
|
+
throw new TypeError(`SR-1: a bigint has no JSON form, so none at ${at(path)}. Carry an integer beyond 2^53 as ` +
|
|
466
|
+
`a string — see this module's header on why the canonical form does not restring numbers ` +
|
|
467
|
+
`for you.`);
|
|
468
|
+
}
|
|
469
|
+
if (typeof value === "function" || typeof value === "symbol") {
|
|
470
|
+
throw new TypeError(`SR-1: a ${typeof value} has no canonical form; found one at ${at(path)}.`);
|
|
471
|
+
}
|
|
472
|
+
if (open.has(value)) {
|
|
473
|
+
throw new TypeError(`SR-1: the value contains a cycle, which reaches ${at(path)}. A canonical form is a finite ` +
|
|
474
|
+
`document; an Affidavit is a tree.`);
|
|
475
|
+
}
|
|
476
|
+
open.add(value);
|
|
477
|
+
try {
|
|
478
|
+
if (Array.isArray(value)) {
|
|
479
|
+
writeArray(value, out, path, open);
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
writeObject(value, out, path, open);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
finally {
|
|
486
|
+
open.delete(value);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function writeArray(value, out, path, open) {
|
|
490
|
+
out.push("[");
|
|
491
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
492
|
+
if (index > 0)
|
|
493
|
+
out.push(",");
|
|
494
|
+
const element = value[index];
|
|
495
|
+
const elementPath = `${path}/${String(index)}`;
|
|
496
|
+
if (element === undefined) {
|
|
497
|
+
throw new TypeError(`SR-1: undefined at ${at(elementPath)}. "Absent omitted" is a rule about properties; a ` +
|
|
498
|
+
`position cannot be omitted without moving every element after it, and writing null ` +
|
|
499
|
+
`instead would put a value on the record that the producer never wrote.`);
|
|
500
|
+
}
|
|
501
|
+
writeValue(element, out, elementPath, open);
|
|
502
|
+
}
|
|
503
|
+
out.push("]");
|
|
504
|
+
}
|
|
505
|
+
function writeObject(value, out, path, open) {
|
|
506
|
+
const tag = Object.prototype.toString.call(value);
|
|
507
|
+
if (tag !== "[object Object]") {
|
|
508
|
+
throw new TypeError(`SR-1: ${tag} has no canonical form; found one at ${at(path)}. The form is defined over ` +
|
|
509
|
+
`JSON — objects, arrays, strings, numbers, booleans and null. A Date, a Map, a Set or a ` +
|
|
510
|
+
`typed array is converted by the host before it is sworn to, so the record shows what the ` +
|
|
511
|
+
`host meant rather than what a serializer guessed.`);
|
|
512
|
+
}
|
|
513
|
+
const record = value;
|
|
514
|
+
assertNotFloatMoney(record, path);
|
|
515
|
+
const keys = Object.keys(record)
|
|
516
|
+
.filter((key) => record[key] !== undefined)
|
|
517
|
+
.sort(compareCodePoints);
|
|
518
|
+
out.push("{");
|
|
519
|
+
for (let index = 0; index < keys.length; index += 1) {
|
|
520
|
+
const key = keys[index];
|
|
521
|
+
if (index > 0)
|
|
522
|
+
out.push(",");
|
|
523
|
+
out.push(JSON.stringify(key), ":");
|
|
524
|
+
writeValue(record[key], out, `${path}/${key}`, open);
|
|
525
|
+
}
|
|
526
|
+
out.push("}");
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* SR-2, enforced where it can be enforced without a schema.
|
|
530
|
+
*
|
|
531
|
+
* This writer sees JSON, not field types, so it cannot know which field is
|
|
532
|
+
* monetary. What it can recognise is the money *shape*: an object carrying both
|
|
533
|
+
* `amount` and `currency`, where `currency` already looks like an ISO 4217 code. In
|
|
534
|
+
* that one case the amount must be a decimal string, and a number there is refused
|
|
535
|
+
* rather than hashed — the whole point of SR-2 is that a float never becomes the
|
|
536
|
+
* thing a reviewer swore to, and the canonical form is the last place to catch it.
|
|
537
|
+
*
|
|
538
|
+
* Narrow on purpose: an unrelated object that happens to carry an `amount` and a
|
|
539
|
+
* `currency` that is not a three-letter uppercase code is left alone.
|
|
540
|
+
*/
|
|
541
|
+
function assertNotFloatMoney(record, path) {
|
|
542
|
+
const currency = record["currency"];
|
|
543
|
+
if (typeof currency !== "string" || !MONEY_CURRENCY_PATTERN.test(currency))
|
|
544
|
+
return;
|
|
545
|
+
if (!Object.prototype.hasOwnProperty.call(record, "amount"))
|
|
546
|
+
return;
|
|
547
|
+
const amount = record["amount"];
|
|
548
|
+
if (typeof amount === "string" && MONEY_AMOUNT_PATTERN.test(amount))
|
|
549
|
+
return;
|
|
550
|
+
if (typeof amount === "number") {
|
|
551
|
+
throw new TypeError(`SR-2: money at ${at(path)} carries a JSON number amount (${String(amount)}). Money on the ` +
|
|
552
|
+
`wire is { amount: "<decimal string>", currency: "<ISO 4217>" }; a binary float cannot ` +
|
|
553
|
+
`hold the amount a reviewer read, and the canonical form is what a grant binds to.`);
|
|
554
|
+
}
|
|
555
|
+
throw new TypeError(`SR-2: money at ${at(path)} carries an amount that is not a decimal string ` +
|
|
556
|
+
`(${describe(amount)}). Expected ${String(MONEY_AMOUNT_PATTERN)}: no exponent, no ` +
|
|
557
|
+
`thousands separators, no leading "+".`);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* The shortest round-trip decimal for `value`, written positionally.
|
|
561
|
+
*
|
|
562
|
+
* `String(n)` is the shortest decimal that parses back to `n` — the ECMAScript
|
|
563
|
+
* `Number::toString` algorithm, identical on every conforming runtime. It uses
|
|
564
|
+
* exponent notation above `1e21` and below `1e-6`; {@link expandExponent} moves the
|
|
565
|
+
* decimal point back into place without touching a digit.
|
|
566
|
+
*/
|
|
567
|
+
function writeNumber(value, path) {
|
|
568
|
+
if (!Number.isFinite(value)) {
|
|
569
|
+
throw new RangeError(`SR-1: ${String(value)} at ${at(path)} has no decimal form, so it has no canonical form. ` +
|
|
570
|
+
`NaN and the infinities are not JSON numbers; a host that means "unknown" writes null.`);
|
|
571
|
+
}
|
|
572
|
+
if (Object.is(value, -0))
|
|
573
|
+
return "0";
|
|
574
|
+
return expandExponent(`${value}`);
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* `"1e+21"` to `"1000000000000000000000"`, `"1.5e-9"` to `"0.0000000015"`; anything
|
|
578
|
+
* without an exponent unchanged.
|
|
579
|
+
*
|
|
580
|
+
* Purely textual: the digits are the digits `Number#toString` produced, and only the
|
|
581
|
+
* position of the decimal point changes, so the decimal value — and therefore the
|
|
582
|
+
* double it parses back to — is untouched.
|
|
583
|
+
*/
|
|
584
|
+
function expandExponent(literal) {
|
|
585
|
+
const marker = literal.indexOf("e");
|
|
586
|
+
if (marker === -1)
|
|
587
|
+
return literal;
|
|
588
|
+
const mantissa = literal.slice(0, marker);
|
|
589
|
+
const exponent = Number.parseInt(literal.slice(marker + 1), 10);
|
|
590
|
+
const negative = mantissa.startsWith("-");
|
|
591
|
+
const unsigned = negative ? mantissa.slice(1) : mantissa;
|
|
592
|
+
const point = unsigned.indexOf(".");
|
|
593
|
+
const integerDigits = point === -1 ? unsigned : unsigned.slice(0, point);
|
|
594
|
+
const fractionDigits = point === -1 ? "" : unsigned.slice(point + 1);
|
|
595
|
+
const digits = integerDigits + fractionDigits;
|
|
596
|
+
const pointAt = integerDigits.length + exponent;
|
|
597
|
+
let written;
|
|
598
|
+
if (pointAt <= 0)
|
|
599
|
+
written = `0.${"0".repeat(-pointAt)}${digits}`;
|
|
600
|
+
else if (pointAt >= digits.length)
|
|
601
|
+
written = digits + "0".repeat(pointAt - digits.length);
|
|
602
|
+
else
|
|
603
|
+
written = `${digits.slice(0, pointAt)}.${digits.slice(pointAt)}`;
|
|
604
|
+
return negative ? `-${written}` : written;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Compare two strings by Unicode **code point**, which is what SR-1 says and what
|
|
608
|
+
* JavaScript's `<` does not do.
|
|
609
|
+
*
|
|
610
|
+
* `<` compares UTF-16 code units, so a character above U+FFFF — an emoji, most of
|
|
611
|
+
* the CJK extensions, every historic script — compares as its surrogate pair,
|
|
612
|
+
* starting at U+D800, and sorts *before* U+E000 to U+FFFF instead of after. Every
|
|
613
|
+
* key in a Latin-script Affidavit sorts identically either way, which is exactly
|
|
614
|
+
* what makes this a trap: an implementation that used `<` would pass every test
|
|
615
|
+
* anyone wrote until the day a key carried an emoji, and then two implementations
|
|
616
|
+
* would hash the same Affidavit differently.
|
|
617
|
+
*
|
|
618
|
+
* Iterating a string yields code points, not code units, so the fix is to compare
|
|
619
|
+
* what the iterator yields.
|
|
620
|
+
*/
|
|
621
|
+
function compareCodePoints(left, right) {
|
|
622
|
+
const leftPoints = left[Symbol.iterator]();
|
|
623
|
+
const rightPoints = right[Symbol.iterator]();
|
|
624
|
+
for (;;) {
|
|
625
|
+
const a = leftPoints.next();
|
|
626
|
+
const b = rightPoints.next();
|
|
627
|
+
if (a.done === true)
|
|
628
|
+
return b.done === true ? 0 : -1;
|
|
629
|
+
if (b.done === true)
|
|
630
|
+
return 1;
|
|
631
|
+
const x = a.value.codePointAt(0) ?? 0;
|
|
632
|
+
const y = b.value.codePointAt(0) ?? 0;
|
|
633
|
+
if (x !== y)
|
|
634
|
+
return x < y ? -1 : 1;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/** Every own enumerable property of `value`, or a refusal naming where it was found. */
|
|
638
|
+
function asRecord(value, where) {
|
|
639
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
640
|
+
throw new TypeError(`SR-1: expected ${where} to be an object; received ${describe(value)}.`);
|
|
641
|
+
}
|
|
642
|
+
return value;
|
|
643
|
+
}
|
|
644
|
+
/** A JSON-pointer-ish location for an error message; the root has no path. */
|
|
645
|
+
function at(path) {
|
|
646
|
+
return path === "" ? "the root value" : path;
|
|
647
|
+
}
|
|
648
|
+
/** A short, safe rendering of an arbitrary value for an error message. */
|
|
649
|
+
function describe(value) {
|
|
650
|
+
if (value === null)
|
|
651
|
+
return "null";
|
|
652
|
+
if (value === undefined)
|
|
653
|
+
return "undefined";
|
|
654
|
+
if (typeof value === "string") {
|
|
655
|
+
const text = value.length <= 60 ? value : `${value.slice(0, 57)}...`;
|
|
656
|
+
return `the string ${JSON.stringify(text)}`;
|
|
657
|
+
}
|
|
658
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
659
|
+
return `${typeof value} ${String(value)}`;
|
|
660
|
+
}
|
|
661
|
+
if (Array.isArray(value))
|
|
662
|
+
return `an array of ${String(value.length)}`;
|
|
663
|
+
return Object.prototype.toString.call(value);
|
|
664
|
+
}
|
|
665
|
+
//# sourceMappingURL=canonical.js.map
|