@emilia-protocol/gate 0.15.1 → 0.16.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.
- package/CHANGELOG.md +52 -0
- package/README.md +17 -0
- package/authority-allocation.js +2 -0
- package/consequence-actuator.js +3 -0
- package/discovery-permit-resolver.js +3 -0
- package/dist/authority-allocation.d.ts +200 -0
- package/dist/authority-allocation.d.ts.map +1 -0
- package/dist/authority-allocation.js +984 -0
- package/dist/authority-allocation.js.map +1 -0
- package/dist/consequence-actuator.d.ts +226 -0
- package/dist/consequence-actuator.d.ts.map +1 -0
- package/dist/consequence-actuator.js +685 -0
- package/dist/consequence-actuator.js.map +1 -0
- package/dist/discovery-permit-resolver.d.ts +46 -0
- package/dist/discovery-permit-resolver.d.ts.map +1 -0
- package/dist/discovery-permit-resolver.js +428 -0
- package/dist/discovery-permit-resolver.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/proposal-to-effect-postgres.d.ts.map +1 -1
- package/dist/proposal-to-effect-postgres.js +9 -5
- package/dist/proposal-to-effect-postgres.js.map +1 -1
- package/package.json +17 -2
- package/src/authority-allocation.ts +1268 -0
- package/src/consequence-actuator.ts +1122 -0
- package/src/discovery-permit-resolver.ts +496 -0
- package/src/index.ts +3 -0
- package/src/proposal-to-effect-postgres.ts +9 -5
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Address-pinned, manually redirected resolver for Discovery-to-Permit
|
|
4
|
+
* Continuity. No global or plain fetch path exists.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
canonicalizeDiscoveryPermit,
|
|
9
|
+
digestDiscoveryPermit,
|
|
10
|
+
digestDiscoveryPermitRaw,
|
|
11
|
+
evaluateDiscoveryPermitContinuity,
|
|
12
|
+
pinDiscoveryPermitTrust,
|
|
13
|
+
type DiscoveryPermitDocumentProvenance,
|
|
14
|
+
type DiscoveryPermitDocumentRole,
|
|
15
|
+
type DiscoveryPermitResolution,
|
|
16
|
+
type DiscoveryPermitTrustPins,
|
|
17
|
+
type DiscoveryPermitTrustPinsInput,
|
|
18
|
+
} from '@emilia-protocol/verify/discovery-permit-contract';
|
|
19
|
+
import { strictJsonGate } from '@emilia-protocol/verify/strict-json';
|
|
20
|
+
|
|
21
|
+
export interface AddressPinnedFetchContext {
|
|
22
|
+
hostname: string;
|
|
23
|
+
approvedAddresses: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AddressPinnedFetchResult {
|
|
27
|
+
response: any;
|
|
28
|
+
connectedAddress: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AddressPinnedTransport {
|
|
32
|
+
resolveAddresses(
|
|
33
|
+
hostname: string,
|
|
34
|
+
context: { signal?: AbortSignal },
|
|
35
|
+
): readonly string[] | Promise<readonly string[]>;
|
|
36
|
+
fetchPinned(
|
|
37
|
+
url: string,
|
|
38
|
+
init: RequestInit,
|
|
39
|
+
context: AddressPinnedFetchContext,
|
|
40
|
+
): AddressPinnedFetchResult | Promise<AddressPinnedFetchResult>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DiscoveryPermitResolverOptions {
|
|
44
|
+
pins: DiscoveryPermitTrustPins | DiscoveryPermitTrustPinsInput;
|
|
45
|
+
transport: AddressPinnedTransport;
|
|
46
|
+
clock?: () => number;
|
|
47
|
+
timeout_ms?: number;
|
|
48
|
+
max_body_bytes?: number;
|
|
49
|
+
max_json_depth?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DiscoveryPermitResolveInput {
|
|
53
|
+
caid: string;
|
|
54
|
+
action: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface RetrievedDocument {
|
|
58
|
+
document: unknown;
|
|
59
|
+
provenance: DiscoveryPermitDocumentProvenance;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class DiscoveryPermitResolverError extends Error {
|
|
63
|
+
readonly code: string;
|
|
64
|
+
|
|
65
|
+
constructor(code: string, message = code) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = 'DiscoveryPermitResolverError';
|
|
68
|
+
this.code = code;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const INPUT_KEYS = new Set(['caid', 'action']);
|
|
73
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
74
|
+
const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
|
|
75
|
+
const DEFAULT_MAX_JSON_DEPTH = 16;
|
|
76
|
+
|
|
77
|
+
function fail(code: string, message = code): never {
|
|
78
|
+
throw new DiscoveryPermitResolverError(code, message);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isObject(value: unknown): value is Record<string, any> {
|
|
82
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function exactInput(value: unknown): value is DiscoveryPermitResolveInput {
|
|
86
|
+
if (!isObject(value)) return false;
|
|
87
|
+
const keys = Object.keys(value);
|
|
88
|
+
return keys.length === INPUT_KEYS.size
|
|
89
|
+
&& keys.every((key) => INPUT_KEYS.has(key))
|
|
90
|
+
&& typeof value.caid === 'string';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function header(response: any, name: string): string | null {
|
|
94
|
+
const headers = response?.headers;
|
|
95
|
+
if (headers && typeof headers.get === 'function') {
|
|
96
|
+
const value = headers.get(name);
|
|
97
|
+
return typeof value === 'string' ? value : null;
|
|
98
|
+
}
|
|
99
|
+
if (isObject(headers)) {
|
|
100
|
+
const entry = Object.entries(headers)
|
|
101
|
+
.find(([key]) => key.toLowerCase() === name.toLowerCase());
|
|
102
|
+
return typeof entry?.[1] === 'string' ? entry[1] : null;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeHost(value: unknown): string {
|
|
108
|
+
return typeof value === 'string'
|
|
109
|
+
? value.trim().toLowerCase().replace(/\.$/, '').replace(/^\[|\]$/g, '')
|
|
110
|
+
: '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isIPv4(value: string): boolean {
|
|
114
|
+
const parts = value.split('.');
|
|
115
|
+
return parts.length === 4
|
|
116
|
+
&& parts.every((part) => /^\d{1,3}$/.test(part)
|
|
117
|
+
&& Number(part) >= 0
|
|
118
|
+
&& Number(part) <= 255);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeIpAddress(value: unknown): string | null {
|
|
122
|
+
const raw = normalizeHost(value);
|
|
123
|
+
if (isIPv4(raw)) {
|
|
124
|
+
return raw.split('.').map((part) => String(Number(part))).join('.');
|
|
125
|
+
}
|
|
126
|
+
if (!raw.includes(':')) return null;
|
|
127
|
+
try {
|
|
128
|
+
const parsed = new URL(`https://[${raw}]/`);
|
|
129
|
+
const normalized = normalizeHost(parsed.hostname);
|
|
130
|
+
return normalized.includes(':') ? normalized : null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isPrivateIPv4(host: string): boolean {
|
|
137
|
+
const [a, b, c] = host.split('.').map(Number);
|
|
138
|
+
return a === 0
|
|
139
|
+
|| a === 10
|
|
140
|
+
|| a === 127
|
|
141
|
+
|| (a === 169 && b === 254)
|
|
142
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
143
|
+
|| (a === 192 && b === 168)
|
|
144
|
+
|| (a === 100 && b >= 64 && b <= 127)
|
|
145
|
+
|| (a === 192 && b === 0)
|
|
146
|
+
|| (a === 192 && b === 88 && c === 99)
|
|
147
|
+
|| (a === 198 && (b === 18 || b === 19))
|
|
148
|
+
|| (a === 198 && b === 51 && c === 100)
|
|
149
|
+
|| (a === 203 && b === 0 && c === 113)
|
|
150
|
+
|| a >= 224;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isPrivateIPv6(host: string): boolean {
|
|
154
|
+
const normalized = normalizeIpAddress(host);
|
|
155
|
+
if (!normalized || !normalized.includes(':')) return true;
|
|
156
|
+
if (normalized === '::' || normalized === '::1') return true;
|
|
157
|
+
|
|
158
|
+
const mappedDotted = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
159
|
+
if (mappedDotted && isIPv4(mappedDotted[1])) return isPrivateIPv4(mappedDotted[1]);
|
|
160
|
+
const mappedHex = normalized.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
161
|
+
if (mappedHex) {
|
|
162
|
+
const high = Number.parseInt(mappedHex[1], 16);
|
|
163
|
+
const low = Number.parseInt(mappedHex[2], 16);
|
|
164
|
+
return isPrivateIPv4(`${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const first = Number.parseInt(normalized.split(':', 1)[0] || '0', 16);
|
|
168
|
+
if ((first & 0xe000) !== 0x2000) return true;
|
|
169
|
+
return normalized === '2001::'
|
|
170
|
+
|| normalized.startsWith('2001::')
|
|
171
|
+
|| normalized.startsWith('2001:0:')
|
|
172
|
+
|| normalized.startsWith('2001:2:')
|
|
173
|
+
|| normalized.startsWith('2001:10:')
|
|
174
|
+
|| normalized.startsWith('2001:20:')
|
|
175
|
+
|| normalized === '2001:db8::'
|
|
176
|
+
|| normalized.startsWith('2001:db8:')
|
|
177
|
+
|| normalized === '2002::'
|
|
178
|
+
|| normalized.startsWith('2002:')
|
|
179
|
+
|| normalized.startsWith('3fff:');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function validateAddresses(values: unknown): readonly string[] {
|
|
183
|
+
if (!Array.isArray(values) || values.length === 0) fail('address_resolution_empty');
|
|
184
|
+
const approved: string[] = [];
|
|
185
|
+
for (const raw of values) {
|
|
186
|
+
const address = normalizeIpAddress(raw);
|
|
187
|
+
if (!address) fail('address_invalid');
|
|
188
|
+
const blocked = isIPv4(address)
|
|
189
|
+
? isPrivateIPv4(address)
|
|
190
|
+
: isPrivateIPv6(address);
|
|
191
|
+
if (blocked) fail('address_not_public');
|
|
192
|
+
if (!approved.includes(address)) approved.push(address);
|
|
193
|
+
}
|
|
194
|
+
return Object.freeze(approved);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function jsonDepth(value: unknown): number {
|
|
198
|
+
if (value === null || typeof value !== 'object') return 0;
|
|
199
|
+
const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 1 }];
|
|
200
|
+
let maximum = 0;
|
|
201
|
+
while (stack.length > 0) {
|
|
202
|
+
const current = stack.pop()!;
|
|
203
|
+
maximum = Math.max(maximum, current.depth);
|
|
204
|
+
if (current.value !== null && typeof current.value === 'object') {
|
|
205
|
+
const children = Array.isArray(current.value)
|
|
206
|
+
? current.value
|
|
207
|
+
: Object.values(current.value as Record<string, unknown>);
|
|
208
|
+
for (const child of children) {
|
|
209
|
+
if (child !== null && typeof child === 'object') {
|
|
210
|
+
stack.push({ value: child, depth: current.depth + 1 });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return maximum;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function readBoundedBody(response: any, maximum: number): Promise<Uint8Array> {
|
|
219
|
+
const contentLength = header(response, 'content-length');
|
|
220
|
+
if (contentLength !== null) {
|
|
221
|
+
const parsed = Number(contentLength);
|
|
222
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) fail('content_length_invalid');
|
|
223
|
+
if (parsed > maximum) fail('body_too_large');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const body = response?.body;
|
|
227
|
+
if (typeof body === 'string') {
|
|
228
|
+
const bytes = Buffer.from(body, 'utf8');
|
|
229
|
+
if (bytes.byteLength > maximum) fail('body_too_large');
|
|
230
|
+
return bytes;
|
|
231
|
+
}
|
|
232
|
+
if (body instanceof Uint8Array) {
|
|
233
|
+
if (body.byteLength > maximum) fail('body_too_large');
|
|
234
|
+
return new Uint8Array(body);
|
|
235
|
+
}
|
|
236
|
+
if (body && typeof body.getReader === 'function') {
|
|
237
|
+
const reader = body.getReader();
|
|
238
|
+
const chunks: Uint8Array[] = [];
|
|
239
|
+
let total = 0;
|
|
240
|
+
try {
|
|
241
|
+
while (true) {
|
|
242
|
+
const result = await reader.read();
|
|
243
|
+
if (result.done) break;
|
|
244
|
+
if (!(result.value instanceof Uint8Array)) fail('body_chunk_invalid');
|
|
245
|
+
total += result.value.byteLength;
|
|
246
|
+
if (total > maximum) {
|
|
247
|
+
await reader.cancel();
|
|
248
|
+
fail('body_too_large');
|
|
249
|
+
}
|
|
250
|
+
chunks.push(result.value);
|
|
251
|
+
}
|
|
252
|
+
} finally {
|
|
253
|
+
try {
|
|
254
|
+
reader.releaseLock();
|
|
255
|
+
} catch {
|
|
256
|
+
// The response stream may already be closed or cancelled.
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const combined = new Uint8Array(total);
|
|
260
|
+
let offset = 0;
|
|
261
|
+
for (const chunk of chunks) {
|
|
262
|
+
combined.set(chunk, offset);
|
|
263
|
+
offset += chunk.byteLength;
|
|
264
|
+
}
|
|
265
|
+
return combined;
|
|
266
|
+
}
|
|
267
|
+
if (typeof response?.arrayBuffer === 'function') {
|
|
268
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
269
|
+
if (bytes.byteLength > maximum) fail('body_too_large');
|
|
270
|
+
return bytes;
|
|
271
|
+
}
|
|
272
|
+
fail('response_body_unavailable');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function timeoutController(timeoutMs: number): {
|
|
276
|
+
signal?: AbortSignal;
|
|
277
|
+
clear: () => void;
|
|
278
|
+
} {
|
|
279
|
+
if (typeof AbortController === 'undefined') return { clear: () => {} };
|
|
280
|
+
const controller = new AbortController();
|
|
281
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
282
|
+
return {
|
|
283
|
+
signal: controller.signal,
|
|
284
|
+
clear: () => clearTimeout(timer),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function boundedInteger(
|
|
289
|
+
value: unknown,
|
|
290
|
+
fallback: number,
|
|
291
|
+
minimum: number,
|
|
292
|
+
maximum: number,
|
|
293
|
+
code: string,
|
|
294
|
+
): number {
|
|
295
|
+
const selected = value === undefined ? fallback : value;
|
|
296
|
+
if (!Number.isSafeInteger(selected)
|
|
297
|
+
|| (selected as number) < minimum
|
|
298
|
+
|| (selected as number) > maximum) fail(code);
|
|
299
|
+
return selected as number;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export class DiscoveryPermitResolver {
|
|
303
|
+
readonly pins: DiscoveryPermitTrustPins;
|
|
304
|
+
readonly timeout_ms: number;
|
|
305
|
+
readonly max_body_bytes: number;
|
|
306
|
+
readonly max_json_depth: number;
|
|
307
|
+
|
|
308
|
+
readonly #resolveAddresses: AddressPinnedTransport['resolveAddresses'];
|
|
309
|
+
readonly #fetchPinned: AddressPinnedTransport['fetchPinned'];
|
|
310
|
+
readonly #clock: () => number;
|
|
311
|
+
|
|
312
|
+
constructor(options: DiscoveryPermitResolverOptions) {
|
|
313
|
+
if (!isObject(options)) fail('resolver_options_invalid');
|
|
314
|
+
this.pins = pinDiscoveryPermitTrust(options.pins);
|
|
315
|
+
if (!isObject(options.transport)
|
|
316
|
+
|| typeof options.transport.resolveAddresses !== 'function'
|
|
317
|
+
|| typeof options.transport.fetchPinned !== 'function') {
|
|
318
|
+
fail('address_pinned_transport_required');
|
|
319
|
+
}
|
|
320
|
+
this.#resolveAddresses = options.transport.resolveAddresses.bind(options.transport);
|
|
321
|
+
this.#fetchPinned = options.transport.fetchPinned.bind(options.transport);
|
|
322
|
+
this.#clock = typeof options.clock === 'function' ? options.clock : Date.now;
|
|
323
|
+
this.timeout_ms = boundedInteger(
|
|
324
|
+
options.timeout_ms,
|
|
325
|
+
DEFAULT_TIMEOUT_MS,
|
|
326
|
+
1,
|
|
327
|
+
60_000,
|
|
328
|
+
'timeout_invalid',
|
|
329
|
+
);
|
|
330
|
+
this.max_body_bytes = boundedInteger(
|
|
331
|
+
options.max_body_bytes,
|
|
332
|
+
DEFAULT_MAX_BODY_BYTES,
|
|
333
|
+
128,
|
|
334
|
+
4 * 1024 * 1024,
|
|
335
|
+
'max_body_bytes_invalid',
|
|
336
|
+
);
|
|
337
|
+
this.max_json_depth = boundedInteger(
|
|
338
|
+
options.max_json_depth,
|
|
339
|
+
DEFAULT_MAX_JSON_DEPTH,
|
|
340
|
+
1,
|
|
341
|
+
64,
|
|
342
|
+
'max_json_depth_invalid',
|
|
343
|
+
);
|
|
344
|
+
Object.freeze(this);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async #retrieve(
|
|
348
|
+
initialUrl: string,
|
|
349
|
+
role: DiscoveryPermitDocumentRole,
|
|
350
|
+
): Promise<RetrievedDocument> {
|
|
351
|
+
let currentUrl = initialUrl;
|
|
352
|
+
const redirectChain = [initialUrl];
|
|
353
|
+
const visited = new Set<string>();
|
|
354
|
+
|
|
355
|
+
for (let hop = 0; hop <= Object.keys(this.pins.redirect_map).length; hop += 1) {
|
|
356
|
+
if (visited.has(currentUrl)) fail('redirect_cycle');
|
|
357
|
+
visited.add(currentUrl);
|
|
358
|
+
const parsed = new URL(currentUrl);
|
|
359
|
+
const timer = timeoutController(this.timeout_ms);
|
|
360
|
+
try {
|
|
361
|
+
const addresses = await this.#resolveAddresses(parsed.hostname, {
|
|
362
|
+
signal: timer.signal,
|
|
363
|
+
});
|
|
364
|
+
if (timer.signal?.aborted) fail('transport_timeout');
|
|
365
|
+
const approvedAddresses = validateAddresses(addresses);
|
|
366
|
+
const init: RequestInit = {
|
|
367
|
+
method: 'GET',
|
|
368
|
+
redirect: 'manual',
|
|
369
|
+
headers: { accept: 'application/json' },
|
|
370
|
+
};
|
|
371
|
+
if (timer.signal) init.signal = timer.signal;
|
|
372
|
+
const result = await this.#fetchPinned(currentUrl, init, {
|
|
373
|
+
hostname: parsed.hostname,
|
|
374
|
+
approvedAddresses,
|
|
375
|
+
});
|
|
376
|
+
if (!isObject(result) || !Object.hasOwn(result, 'response')) {
|
|
377
|
+
fail('pinned_transport_result_invalid');
|
|
378
|
+
}
|
|
379
|
+
const connectedAddress = normalizeIpAddress(result.connectedAddress);
|
|
380
|
+
if (!connectedAddress || !approvedAddresses.includes(connectedAddress)) {
|
|
381
|
+
fail('connected_address_not_approved');
|
|
382
|
+
}
|
|
383
|
+
const response = result.response;
|
|
384
|
+
if (!response || response.redirected === true || response.type === 'opaqueredirect') {
|
|
385
|
+
fail('redirect_followed_by_transport');
|
|
386
|
+
}
|
|
387
|
+
const status = response.status;
|
|
388
|
+
if (!Number.isSafeInteger(status)) fail('http_status_invalid');
|
|
389
|
+
|
|
390
|
+
if (status >= 300 && status < 400) {
|
|
391
|
+
const expectedTarget = this.pins.redirect_map[currentUrl];
|
|
392
|
+
if (!expectedTarget) fail('redirect_not_pinned');
|
|
393
|
+
const location = header(response, 'location');
|
|
394
|
+
if (!location) fail('redirect_location_missing');
|
|
395
|
+
let actualTarget: string;
|
|
396
|
+
try {
|
|
397
|
+
actualTarget = new URL(location, currentUrl).href;
|
|
398
|
+
} catch {
|
|
399
|
+
fail('redirect_location_invalid');
|
|
400
|
+
}
|
|
401
|
+
if (actualTarget !== expectedTarget) fail('redirect_target_mismatch');
|
|
402
|
+
currentUrl = actualTarget;
|
|
403
|
+
redirectChain.push(currentUrl);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (status < 200 || status >= 300) fail('http_error', `HTTP ${status}`);
|
|
407
|
+
|
|
408
|
+
const contentType = header(response, 'content-type');
|
|
409
|
+
const mediaType = contentType?.split(';', 1)[0].trim().toLowerCase();
|
|
410
|
+
if (mediaType !== 'application/json') fail('content_type_invalid');
|
|
411
|
+
const rawBytes = await readBoundedBody(response, this.max_body_bytes);
|
|
412
|
+
let raw: string;
|
|
413
|
+
try {
|
|
414
|
+
raw = new TextDecoder('utf-8', { fatal: true }).decode(rawBytes);
|
|
415
|
+
} catch {
|
|
416
|
+
fail('utf8_invalid');
|
|
417
|
+
}
|
|
418
|
+
const strict = strictJsonGate(raw);
|
|
419
|
+
if (!strict.ok) fail('json_not_strict', strict.reason);
|
|
420
|
+
let document: unknown;
|
|
421
|
+
try {
|
|
422
|
+
document = JSON.parse(raw);
|
|
423
|
+
} catch {
|
|
424
|
+
fail('json_invalid');
|
|
425
|
+
}
|
|
426
|
+
if (!isObject(document)) fail('document_type_invalid');
|
|
427
|
+
if (jsonDepth(document) > this.max_json_depth) fail('json_depth_exceeded');
|
|
428
|
+
|
|
429
|
+
let canonicalDigest;
|
|
430
|
+
try {
|
|
431
|
+
canonicalDigest = digestDiscoveryPermit(document);
|
|
432
|
+
} catch {
|
|
433
|
+
fail('document_not_canonicalizable');
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
document,
|
|
437
|
+
provenance: Object.freeze({
|
|
438
|
+
role,
|
|
439
|
+
requested_url: initialUrl,
|
|
440
|
+
resolved_url: currentUrl,
|
|
441
|
+
connected_address: connectedAddress,
|
|
442
|
+
media_type: 'application/json' as const,
|
|
443
|
+
byte_length: rawBytes.byteLength,
|
|
444
|
+
raw_digest: digestDiscoveryPermitRaw(rawBytes),
|
|
445
|
+
canonical_digest: canonicalDigest,
|
|
446
|
+
redirect_chain: Object.freeze([...redirectChain]),
|
|
447
|
+
}),
|
|
448
|
+
};
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (error instanceof DiscoveryPermitResolverError) throw error;
|
|
451
|
+
const code = timer.signal?.aborted ? 'transport_timeout' : 'transport_failure';
|
|
452
|
+
fail(code, error instanceof Error ? error.message : code);
|
|
453
|
+
} finally {
|
|
454
|
+
timer.clear();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
fail('redirect_limit_exceeded');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async resolve(input: DiscoveryPermitResolveInput): Promise<DiscoveryPermitResolution> {
|
|
461
|
+
if (!exactInput(input)) fail('transaction_fields_not_allowed');
|
|
462
|
+
const caid = input.caid;
|
|
463
|
+
let action: unknown;
|
|
464
|
+
try {
|
|
465
|
+
// Snapshot into plain JSON before the first await. This prevents caller
|
|
466
|
+
// mutation, accessors, or prototype state from changing the bound action
|
|
467
|
+
// while address resolution and retrieval are in flight.
|
|
468
|
+
action = JSON.parse(canonicalizeDiscoveryPermit(input.action));
|
|
469
|
+
} catch {
|
|
470
|
+
fail('action_not_canonicalizable');
|
|
471
|
+
}
|
|
472
|
+
const now = this.#clock();
|
|
473
|
+
if (!Number.isFinite(now)) fail('clock_invalid');
|
|
474
|
+
|
|
475
|
+
const discovery = await this.#retrieve(this.pins.discovery_url, 'discovery');
|
|
476
|
+
const permit = await this.#retrieve(this.pins.permit_url, 'permit');
|
|
477
|
+
return evaluateDiscoveryPermitContinuity({
|
|
478
|
+
pins: this.pins,
|
|
479
|
+
discovery: discovery.document,
|
|
480
|
+
binding: permit.document,
|
|
481
|
+
caid,
|
|
482
|
+
action,
|
|
483
|
+
now,
|
|
484
|
+
provenance: {
|
|
485
|
+
discovery: discovery.provenance,
|
|
486
|
+
permit: permit.provenance,
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function createDiscoveryPermitResolver(
|
|
493
|
+
options: DiscoveryPermitResolverOptions,
|
|
494
|
+
): DiscoveryPermitResolver {
|
|
495
|
+
return new DiscoveryPermitResolver(options);
|
|
496
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -231,6 +231,7 @@ export {
|
|
|
231
231
|
reconcileCapabilityOperation,
|
|
232
232
|
delegateCapabilityReceipt,
|
|
233
233
|
} from './capability-receipt.js';
|
|
234
|
+
export * from './authority-allocation.js';
|
|
234
235
|
export {
|
|
235
236
|
ZK_RANGE_RECEIPT_VERSION,
|
|
236
237
|
ZK_RANGE_SCHEME,
|
|
@@ -342,6 +343,8 @@ export {
|
|
|
342
343
|
AEB_CONSUMPTION_SQL,
|
|
343
344
|
createPostgresAebDurableConsumptionStore,
|
|
344
345
|
} from './aeb-consumption-store.js';
|
|
346
|
+
export * from './consequence-actuator.js';
|
|
347
|
+
export * from './discovery-permit-resolver.js';
|
|
345
348
|
export const ASSURANCE_TIERS = ['software', 'class_a', 'quorum'];
|
|
346
349
|
const TIER_RANK = { software: 0, class_a: 1, quorum: 2 };
|
|
347
350
|
const CAPABILITY_FAILURE_STATUS = 409;
|
|
@@ -901,11 +901,11 @@ BEGIN
|
|
|
901
901
|
attempts.owner_generation,
|
|
902
902
|
pg_catalog.to_char(
|
|
903
903
|
attempts.last_heartbeat_at AT TIME ZONE 'UTC',
|
|
904
|
-
'YYYY-MM-DD"T"HH24:MI:SS.
|
|
904
|
+
'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'
|
|
905
905
|
),
|
|
906
906
|
pg_catalog.to_char(
|
|
907
907
|
attempts.lease_expires_at AT TIME ZONE 'UTC',
|
|
908
|
-
'YYYY-MM-DD"T"HH24:MI:SS.
|
|
908
|
+
'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'
|
|
909
909
|
),
|
|
910
910
|
attempts.lease_expires_at <= pg_catalog.clock_timestamp()
|
|
911
911
|
FROM proposal_to_effect_private.consequence_attempts AS attempts
|
|
@@ -1091,9 +1091,13 @@ function assertDigest(value: unknown, name: string): asserts value is AebDigest
|
|
|
1091
1091
|
|
|
1092
1092
|
function assertInstant(value: unknown, name: string): asserts value is string {
|
|
1093
1093
|
if (typeof value !== 'string'
|
|
1094
|
-
|| !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)
|
|
1095
|
-
|
|
1096
|
-
|
|
1094
|
+
|| !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3,6}Z$/.test(value)) {
|
|
1095
|
+
throw new TypeError(`${name} is invalid`);
|
|
1096
|
+
}
|
|
1097
|
+
const parsed = Date.parse(value);
|
|
1098
|
+
const millisecondInstant = value.replace(/\.(\d{3})\d{0,3}Z$/, '.$1Z');
|
|
1099
|
+
if (!Number.isFinite(parsed)
|
|
1100
|
+
|| new Date(parsed).toISOString() !== millisecondInstant) {
|
|
1097
1101
|
throw new TypeError(`${name} is invalid`);
|
|
1098
1102
|
}
|
|
1099
1103
|
}
|