genesis-mesh-sdk 0.59.0 → 0.60.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 +51 -13
- package/README.md +21 -5
- package/dist/cjs/attestation.js +23 -1
- package/dist/cjs/auth.js +197 -18
- package/dist/cjs/boundary.js +20 -2
- package/dist/cjs/canonical.js +89 -0
- package/dist/cjs/client.js +180 -34
- package/dist/cjs/errors.js +63 -1
- package/dist/cjs/evidence_store.js +172 -0
- package/dist/cjs/execution.js +121 -0
- package/dist/cjs/governance.js +177 -0
- package/dist/cjs/health.js +59 -0
- package/dist/cjs/index.js +77 -2
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/policy.js +47 -0
- package/dist/cjs/types.js +1 -1
- package/dist/cjs/validation.js +103 -0
- package/dist/cjs/verify.js +299 -0
- package/dist/esm/attestation.js +23 -1
- package/dist/esm/attestation.js.map +1 -1
- package/dist/esm/auth.js +187 -19
- package/dist/esm/auth.js.map +1 -1
- package/dist/esm/boundary.js +20 -2
- package/dist/esm/boundary.js.map +1 -1
- package/dist/esm/canonical.js +73 -0
- package/dist/esm/canonical.js.map +1 -0
- package/dist/esm/client.js +181 -36
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/errors.js +58 -0
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/evidence_store.js +169 -0
- package/dist/esm/evidence_store.js.map +1 -0
- package/dist/esm/execution.js +116 -0
- package/dist/esm/execution.js.map +1 -0
- package/dist/esm/governance.js +170 -0
- package/dist/esm/governance.js.map +1 -0
- package/dist/esm/health.js +56 -0
- package/dist/esm/health.js.map +1 -0
- package/dist/esm/index.js +26 -4
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/policy.js +44 -0
- package/dist/esm/policy.js.map +1 -0
- package/dist/esm/types.js +1 -1
- package/dist/esm/validation.js +99 -0
- package/dist/esm/validation.js.map +1 -0
- package/dist/esm/verify.js +287 -0
- package/dist/esm/verify.js.map +1 -0
- package/dist/types/attestation.d.ts +21 -1
- package/dist/types/attestation.d.ts.map +1 -1
- package/dist/types/auth.d.ts +52 -9
- package/dist/types/auth.d.ts.map +1 -1
- package/dist/types/boundary.d.ts +40 -2
- package/dist/types/boundary.d.ts.map +1 -1
- package/dist/types/canonical.d.ts +29 -0
- package/dist/types/canonical.d.ts.map +1 -0
- package/dist/types/client.d.ts +59 -12
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/errors.d.ts +25 -0
- package/dist/types/errors.d.ts.map +1 -1
- package/dist/types/evidence_store.d.ts +94 -0
- package/dist/types/evidence_store.d.ts.map +1 -0
- package/dist/types/execution.d.ts +52 -0
- package/dist/types/execution.d.ts.map +1 -0
- package/dist/types/governance.d.ts +128 -0
- package/dist/types/governance.d.ts.map +1 -0
- package/dist/types/health.d.ts +23 -0
- package/dist/types/health.d.ts.map +1 -0
- package/dist/types/index.d.ts +26 -5
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/policy.d.ts +32 -0
- package/dist/types/policy.d.ts.map +1 -0
- package/dist/types/types.d.ts +443 -12
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/validation.d.ts +12 -0
- package/dist/types/validation.d.ts.map +1 -0
- package/dist/types/verify.d.ts +51 -0
- package/dist/types/verify.d.ts.map +1 -0
- package/docs/evidence-store.md +67 -0
- package/docs/governance.md +77 -0
- package/docs/high-availability.md +86 -0
- package/docs/offline-verification.md +80 -0
- package/package.json +16 -10
package/dist/cjs/client.js
CHANGED
|
@@ -1,76 +1,222 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
3
|
* HTTP transport layer for the Genesis Mesh SDK.
|
|
4
|
-
* Handles request dispatch, response parsing, and
|
|
4
|
+
* Handles request dispatch, response parsing, error mapping and opt-in retries.
|
|
5
5
|
* Admin-route authentication is delegated to auth.ts.
|
|
6
6
|
*/
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
8
|
exports.HttpTransport = void 0;
|
|
9
|
+
exports.buildPath = buildPath;
|
|
9
10
|
const auth_js_1 = require("./auth.js");
|
|
10
11
|
const errors_js_1 = require("./errors.js");
|
|
12
|
+
const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
|
|
13
|
+
function buildPath(path, query) {
|
|
14
|
+
if (!query)
|
|
15
|
+
return path;
|
|
16
|
+
const params = new URLSearchParams();
|
|
17
|
+
for (const [key, value] of Object.entries(query)) {
|
|
18
|
+
if (value !== undefined && value !== null)
|
|
19
|
+
params.append(key, String(value));
|
|
20
|
+
}
|
|
21
|
+
const qs = params.toString();
|
|
22
|
+
return qs ? `${path}?${qs}` : path;
|
|
23
|
+
}
|
|
24
|
+
const FAILOVER_STATUS = new Set([502, 503, 504]);
|
|
25
|
+
/** Errors meaning no connection was made, so the request was never sent. */
|
|
26
|
+
const CONNECT_FAILURE_CODES = new Set([
|
|
27
|
+
'ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH', 'ENETUNREACH', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT',
|
|
28
|
+
]);
|
|
29
|
+
function isConnectFailure(err) {
|
|
30
|
+
const cause = err?.cause;
|
|
31
|
+
if (!cause)
|
|
32
|
+
return false;
|
|
33
|
+
if (typeof cause.code === 'string' && CONNECT_FAILURE_CODES.has(cause.code))
|
|
34
|
+
return true;
|
|
35
|
+
return Array.isArray(cause.errors) && cause.errors.length > 0
|
|
36
|
+
&& cause.errors.every(e => typeof e?.code === 'string' && CONNECT_FAILURE_CODES.has(e.code));
|
|
37
|
+
}
|
|
11
38
|
class HttpTransport {
|
|
12
|
-
|
|
13
|
-
|
|
39
|
+
/** Every configured NA endpoint, in preference order. */
|
|
40
|
+
baseUrls;
|
|
41
|
+
active = 0;
|
|
42
|
+
/** Key ID sent in X-Admin-Key-Id. */
|
|
14
43
|
keyId;
|
|
44
|
+
signer;
|
|
15
45
|
timeout;
|
|
46
|
+
retry;
|
|
47
|
+
headers;
|
|
16
48
|
_fetch;
|
|
17
49
|
constructor(options) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
50
|
+
const urls = options.baseUrls ?? (options.baseUrl !== undefined ? [options.baseUrl] : []);
|
|
51
|
+
if (urls.length === 0 || urls.some(u => typeof u !== 'string' || !/^https?:\/\//.test(u))) {
|
|
52
|
+
throw new Error('baseUrl or baseUrls must give at least one http(s) URL');
|
|
53
|
+
}
|
|
54
|
+
if (options.baseUrl !== undefined && options.baseUrls !== undefined) {
|
|
55
|
+
throw new Error('pass either baseUrl or baseUrls, not both');
|
|
56
|
+
}
|
|
57
|
+
this.baseUrls = Object.freeze(urls.map(u => u.replace(/\/$/, '')));
|
|
58
|
+
this.signer = options.signer
|
|
59
|
+
?? (options.signingKeyBase64
|
|
60
|
+
? (0, auth_js_1.seedSigner)(options.signingKeyBase64, options.keyId ?? 'operator-local')
|
|
61
|
+
: undefined);
|
|
62
|
+
this.keyId = this.signer?.keyId ?? options.keyId ?? 'operator-local';
|
|
21
63
|
this.timeout = options.timeout ?? 10_000;
|
|
64
|
+
this.retry = { attempts: options.retry?.attempts ?? 0, baseDelayMs: options.retry?.baseDelayMs ?? 200 };
|
|
65
|
+
if (!Number.isSafeInteger(this.retry.attempts) || this.retry.attempts < 0 || this.retry.attempts > 10
|
|
66
|
+
|| !Number.isFinite(this.retry.baseDelayMs) || this.retry.baseDelayMs < 0 || this.retry.baseDelayMs > 60_000) {
|
|
67
|
+
throw new Error('retry requires 0..10 attempts and a finite baseDelayMs between 0 and 60000');
|
|
68
|
+
}
|
|
69
|
+
this.headers = options.headers ?? {};
|
|
22
70
|
this._fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
23
71
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
72
|
+
/** The endpoint requests currently go to. */
|
|
73
|
+
get baseUrl() {
|
|
74
|
+
return this.baseUrls[this.active];
|
|
75
|
+
}
|
|
76
|
+
/** Move to the next endpoint (no-op with one endpoint). */
|
|
77
|
+
failover() {
|
|
78
|
+
if (this.baseUrls.length > 1)
|
|
79
|
+
this.active = (this.active + 1) % this.baseUrls.length;
|
|
80
|
+
}
|
|
81
|
+
/** Signed admin POST. Not retried unless `idempotent` is set. */
|
|
82
|
+
async adminPost(path, body, idempotent = false) {
|
|
83
|
+
const response = await this._send({ method: 'POST', path, body, admin: true, idempotent });
|
|
84
|
+
return this._parse(response);
|
|
85
|
+
}
|
|
86
|
+
/** Signed admin GET (the NA verifies the signature over an empty body). */
|
|
87
|
+
async adminGet(path, query) {
|
|
88
|
+
const response = await this._send({ method: 'GET', path, query, admin: true, idempotent: true });
|
|
89
|
+
return this._parse(response);
|
|
90
|
+
}
|
|
91
|
+
/** Signed admin GET returning the raw response text (e.g. NDJSON export). */
|
|
92
|
+
async adminGetText(path, query) {
|
|
93
|
+
const response = await this._send({ method: 'GET', path, query, admin: true, idempotent: true });
|
|
94
|
+
if (!response.ok)
|
|
95
|
+
return this._parse(response);
|
|
96
|
+
return response.text();
|
|
97
|
+
}
|
|
98
|
+
/** Unauthenticated POST. Verification routes are idempotent; pass `true` to allow retries. */
|
|
99
|
+
async publicPost(path, body, idempotent = false) {
|
|
100
|
+
const response = await this._send({ method: 'POST', path, body, admin: false, idempotent });
|
|
101
|
+
return this._parse(response);
|
|
102
|
+
}
|
|
103
|
+
async publicGet(path, query) {
|
|
104
|
+
const response = await this._send({ method: 'GET', path, query, admin: false, idempotent: true });
|
|
105
|
+
return this._parse(response);
|
|
106
|
+
}
|
|
107
|
+
async _send(spec) {
|
|
108
|
+
// Idempotent requests may try every other endpoint once, plus the
|
|
109
|
+
// configured retries (with backoff). Non-idempotent requests move to
|
|
110
|
+
// another endpoint only when the connection was never established; after
|
|
111
|
+
// any other failure they are not replayed, and the next request starts at
|
|
112
|
+
// another endpoint.
|
|
113
|
+
const failovers = this.baseUrls.length - 1;
|
|
114
|
+
const retries = spec.idempotent ? this.retry.attempts : 0;
|
|
115
|
+
let failoversLeft = failovers;
|
|
116
|
+
let retriesUsed = 0;
|
|
117
|
+
for (;;) {
|
|
118
|
+
let response;
|
|
119
|
+
let error;
|
|
120
|
+
try {
|
|
121
|
+
response = await this._attempt(spec);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
if (!(err instanceof errors_js_1.NetworkError))
|
|
125
|
+
throw err;
|
|
126
|
+
error = err;
|
|
127
|
+
}
|
|
128
|
+
const failoverWorthy = error !== undefined || (response !== undefined && FAILOVER_STATUS.has(response.status));
|
|
129
|
+
if (failoverWorthy)
|
|
130
|
+
this.failover();
|
|
131
|
+
// A request that may have reached an instance is replayed elsewhere only
|
|
132
|
+
// if it is idempotent; one that never connected can always move on.
|
|
133
|
+
const mayMove = spec.idempotent || (error instanceof errors_js_1.NetworkError && error.connectFailed);
|
|
134
|
+
if (failoverWorthy && mayMove && failoversLeft > 0) {
|
|
135
|
+
failoversLeft -= 1;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const retryable = error !== undefined || (response !== undefined && RETRYABLE_STATUS.has(response.status));
|
|
139
|
+
if (retryable && retriesUsed < retries) {
|
|
140
|
+
await this._backoff(retriesUsed);
|
|
141
|
+
retriesUsed += 1;
|
|
142
|
+
failoversLeft = failovers;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (error !== undefined)
|
|
146
|
+
throw error;
|
|
147
|
+
return response;
|
|
27
148
|
}
|
|
28
|
-
const adminHeaders = (0, auth_js_1.buildAdminHeaders)(body, this.keyId, this.signingKeyBase64);
|
|
29
|
-
return this._post(path, body, adminHeaders);
|
|
30
149
|
}
|
|
31
|
-
|
|
32
|
-
|
|
150
|
+
/** One request to the active endpoint, with the body fully read (a dropped body is a NetworkError). */
|
|
151
|
+
async _attempt(spec) {
|
|
152
|
+
const res = await this._once(spec);
|
|
153
|
+
let body;
|
|
154
|
+
try {
|
|
155
|
+
body = await res.text();
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
throw new errors_js_1.NetworkError(`Failed to read response body (${spec.method} ${spec.path})`);
|
|
159
|
+
}
|
|
160
|
+
return { ok: res.ok, status: res.status, text: async () => body };
|
|
33
161
|
}
|
|
34
|
-
|
|
35
|
-
|
|
162
|
+
/** Unauthenticated GET against one specific endpoint (no failover), e.g. per-instance readiness. */
|
|
163
|
+
async publicGetAt(baseUrl, path) {
|
|
164
|
+
const url = baseUrl.replace(/\/$/, '') + path;
|
|
36
165
|
let response;
|
|
37
166
|
try {
|
|
38
|
-
response = await this._fetch(url, {
|
|
39
|
-
method: 'GET',
|
|
40
|
-
signal: AbortSignal.timeout(this.timeout),
|
|
41
|
-
});
|
|
167
|
+
response = await this._fetch(url, { method: 'GET', headers: { ...this.headers }, signal: AbortSignal.timeout(this.timeout) });
|
|
42
168
|
}
|
|
43
169
|
catch (err) {
|
|
44
|
-
throw new errors_js_1.NetworkError(`GET ${
|
|
170
|
+
throw new errors_js_1.NetworkError(`GET ${url} failed: ${err.message}`);
|
|
171
|
+
}
|
|
172
|
+
const text = await response.text();
|
|
173
|
+
try {
|
|
174
|
+
return { status: response.status, body: (0, auth_js_1.parseJson)(text) };
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
throw new errors_js_1.NetworkError(`Failed to parse response body from ${url} (HTTP ${response.status})`);
|
|
45
178
|
}
|
|
46
|
-
return this._parse(response);
|
|
47
179
|
}
|
|
48
|
-
async
|
|
49
|
-
const
|
|
50
|
-
|
|
180
|
+
async _once(spec) {
|
|
181
|
+
const headers = { ...this.headers };
|
|
182
|
+
if (spec.admin) {
|
|
183
|
+
if (!this.signer) {
|
|
184
|
+
throw new Error('signingKeyBase64 is required for admin routes (or pass a signer)');
|
|
185
|
+
}
|
|
186
|
+
Object.assign(headers, await (0, auth_js_1.buildAdminHeadersWithSigner)(spec.method === 'GET' ? {} : spec.body, this.signer));
|
|
187
|
+
}
|
|
188
|
+
const init = { method: spec.method, headers, signal: AbortSignal.timeout(this.timeout) };
|
|
189
|
+
if (spec.method === 'POST') {
|
|
190
|
+
headers['Content-Type'] = 'application/json';
|
|
191
|
+
init.body = (0, auth_js_1.canonicalJson)(spec.body);
|
|
192
|
+
}
|
|
193
|
+
const path = buildPath(spec.path, spec.query);
|
|
51
194
|
try {
|
|
52
|
-
|
|
53
|
-
method: 'POST',
|
|
54
|
-
headers: { 'Content-Type': 'application/json', ...extraHeaders },
|
|
55
|
-
body: JSON.stringify(body),
|
|
56
|
-
signal: AbortSignal.timeout(this.timeout),
|
|
57
|
-
});
|
|
195
|
+
return await this._fetch(this.baseUrl + path, init);
|
|
58
196
|
}
|
|
59
197
|
catch (err) {
|
|
60
|
-
|
|
198
|
+
const error = new errors_js_1.NetworkError(`${spec.method} ${spec.path} failed: ${err.message}`);
|
|
199
|
+
error.connectFailed = isConnectFailure(err);
|
|
200
|
+
throw error;
|
|
61
201
|
}
|
|
62
|
-
|
|
202
|
+
}
|
|
203
|
+
_backoff(attempt) {
|
|
204
|
+
const delay = Math.min(60_000, this.retry.baseDelayMs * 2 ** attempt);
|
|
205
|
+
return new Promise(resolve => setTimeout(resolve, delay));
|
|
63
206
|
}
|
|
64
207
|
async _parse(response) {
|
|
65
208
|
let data;
|
|
66
209
|
try {
|
|
67
|
-
data = await response.
|
|
210
|
+
data = (0, auth_js_1.parseJson)(await response.text());
|
|
68
211
|
}
|
|
69
212
|
catch {
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
throw new errors_js_1.GenesisMeshError(`HTTP ${response.status} with a non-JSON body`, 'unknown', response.status);
|
|
215
|
+
}
|
|
70
216
|
throw new errors_js_1.NetworkError(`Failed to parse response body (HTTP ${response.status})`);
|
|
71
217
|
}
|
|
72
218
|
if (!response.ok) {
|
|
73
|
-
throw (0, errors_js_1.fromHttpError)(response.status, data);
|
|
219
|
+
throw (0, errors_js_1.fromHttpError)(response.status, (data ?? {}));
|
|
74
220
|
}
|
|
75
221
|
return data;
|
|
76
222
|
}
|
package/dist/cjs/errors.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/** Typed error classes mirroring the NA HTTP error surface. */
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
exports.BadRequestError = exports.NetworkError = exports.RateLimitError = exports.NotFoundError = exports.ValidationError = exports.UnauthorizedError = exports.GenesisMeshError = void 0;
|
|
4
|
+
exports.BadRequestError = exports.NetworkError = exports.RateLimitError = exports.NotFoundError = exports.ServiceUnavailableError = exports.ConflictError = exports.ForbiddenError = exports.ValidationError = exports.UnauthorizedError = exports.GenesisMeshError = void 0;
|
|
5
5
|
exports.fromHttpError = fromHttpError;
|
|
6
|
+
exports.isRetryableConflict = isRetryableConflict;
|
|
6
7
|
class GenesisMeshError extends Error {
|
|
7
8
|
code;
|
|
8
9
|
status;
|
|
10
|
+
/** Structured `error.details` from the NA envelope (empty when absent). */
|
|
11
|
+
details = {};
|
|
12
|
+
/** `error.request_id` from the NA envelope, for correlating with NA logs. */
|
|
13
|
+
requestId = null;
|
|
9
14
|
constructor(message, code, status) {
|
|
10
15
|
super(message);
|
|
11
16
|
this.name = 'GenesisMeshError';
|
|
@@ -29,6 +34,28 @@ class ValidationError extends GenesisMeshError {
|
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
exports.ValidationError = ValidationError;
|
|
37
|
+
class ForbiddenError extends GenesisMeshError {
|
|
38
|
+
constructor(message = 'Forbidden', code = 'forbidden') {
|
|
39
|
+
super(message, code, 403);
|
|
40
|
+
this.name = 'ForbiddenError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
exports.ForbiddenError = ForbiddenError;
|
|
44
|
+
/** 409: the request conflicts with stored state (e.g. `evidence_conflict`, `executor_key_exists`). */
|
|
45
|
+
class ConflictError extends GenesisMeshError {
|
|
46
|
+
constructor(message, code = 'conflict') {
|
|
47
|
+
super(message, code, 409);
|
|
48
|
+
this.name = 'ConflictError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
exports.ConflictError = ConflictError;
|
|
52
|
+
class ServiceUnavailableError extends GenesisMeshError {
|
|
53
|
+
constructor(message = 'Service unavailable', code = 'service_unavailable') {
|
|
54
|
+
super(message, code, 503);
|
|
55
|
+
this.name = 'ServiceUnavailableError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
exports.ServiceUnavailableError = ServiceUnavailableError;
|
|
32
59
|
class NotFoundError extends GenesisMeshError {
|
|
33
60
|
constructor(message, code = 'not_found') {
|
|
34
61
|
super(message, code, 404);
|
|
@@ -44,6 +71,12 @@ class RateLimitError extends GenesisMeshError {
|
|
|
44
71
|
}
|
|
45
72
|
exports.RateLimitError = RateLimitError;
|
|
46
73
|
class NetworkError extends GenesisMeshError {
|
|
74
|
+
/**
|
|
75
|
+
* True when the connection could not be established (refused, unresolvable,
|
|
76
|
+
* unreachable): the request never reached the NA, so another instance may
|
|
77
|
+
* take it even when it is not idempotent (v0.60).
|
|
78
|
+
*/
|
|
79
|
+
connectFailed = false;
|
|
47
80
|
constructor(message, code = 'network_error') {
|
|
48
81
|
super(message, code, 0);
|
|
49
82
|
this.name = 'NetworkError';
|
|
@@ -67,21 +100,50 @@ function fromHttpError(status, body) {
|
|
|
67
100
|
const errorField = body['error'];
|
|
68
101
|
let message;
|
|
69
102
|
let code;
|
|
103
|
+
let details;
|
|
104
|
+
let requestId;
|
|
70
105
|
if (typeof errorField === 'object' && errorField !== null) {
|
|
71
106
|
const nested = errorField;
|
|
72
107
|
message = String(nested['message'] ?? 'Unknown error');
|
|
73
108
|
code = String(nested['code'] ?? 'unknown');
|
|
109
|
+
details = nested['details'];
|
|
110
|
+
requestId = nested['request_id'];
|
|
74
111
|
}
|
|
75
112
|
else {
|
|
76
113
|
message = String(errorField ?? body['message'] ?? 'Unknown error');
|
|
77
114
|
code = String(body['code'] ?? 'unknown');
|
|
78
115
|
}
|
|
116
|
+
const error = errorForStatus(status, message, code);
|
|
117
|
+
if (typeof details === 'object' && details !== null && !Array.isArray(details)) {
|
|
118
|
+
error.details = details;
|
|
119
|
+
}
|
|
120
|
+
if (typeof requestId === 'string')
|
|
121
|
+
error.requestId = requestId;
|
|
122
|
+
return error;
|
|
123
|
+
}
|
|
124
|
+
function errorForStatus(status, message, code) {
|
|
79
125
|
switch (status) {
|
|
80
126
|
case 400: return new BadRequestError(message, code);
|
|
81
127
|
case 401: return new UnauthorizedError(message, code);
|
|
128
|
+
case 403: return new ForbiddenError(message, code);
|
|
82
129
|
case 404: return new NotFoundError(message, code);
|
|
130
|
+
case 409: return new ConflictError(message, code);
|
|
83
131
|
case 422: return new ValidationError(message, code);
|
|
84
132
|
case 429: return new RateLimitError(message, code);
|
|
133
|
+
case 503: return new ServiceUnavailableError(message, code);
|
|
85
134
|
default: return new GenesisMeshError(message, code, status);
|
|
86
135
|
}
|
|
87
136
|
}
|
|
137
|
+
const HA_CONFLICT_CODES = new Set([
|
|
138
|
+
'boundary_policy_activation_conflict',
|
|
139
|
+
'boundary_policy_version_conflict',
|
|
140
|
+
'crl_publish_contention',
|
|
141
|
+
'retention_in_progress',
|
|
142
|
+
]);
|
|
143
|
+
/**
|
|
144
|
+
* True for a 409 that only means another NA instance won a race the database
|
|
145
|
+
* decided (v0.60). The request changed nothing and can be retried.
|
|
146
|
+
*/
|
|
147
|
+
function isRetryableConflict(err) {
|
|
148
|
+
return err instanceof ConflictError && HA_CONFLICT_CODES.has(err.code);
|
|
149
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EvidenceStoreClient = void 0;
|
|
4
|
+
const execution_js_1 = require("./execution.js");
|
|
5
|
+
const canonical_js_1 = require("./canonical.js");
|
|
6
|
+
const errors_js_1 = require("./errors.js");
|
|
7
|
+
const verify_js_1 = require("./verify.js");
|
|
8
|
+
const MAX_PAGE = 1000;
|
|
9
|
+
function resourcePath(resourceId) {
|
|
10
|
+
return resourceId.split('/').map(encodeURIComponent).join('/');
|
|
11
|
+
}
|
|
12
|
+
/** The NA evidence store (v0.59): controller submission, operator search, history, export. */
|
|
13
|
+
class EvidenceStoreClient {
|
|
14
|
+
http;
|
|
15
|
+
constructor(http) {
|
|
16
|
+
this.http = http;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Submit one signed ExecutionEvidence record. Authenticated by the executor
|
|
20
|
+
* signature, not by operator headers. An identical resubmission returns
|
|
21
|
+
* `status: "duplicate"`, so it is safe to retry.
|
|
22
|
+
*/
|
|
23
|
+
async submit(evidence) {
|
|
24
|
+
const reason = (0, execution_js_1.checkMetadataOnly)(evidence.execution_parameters, evidence.outcome_detail);
|
|
25
|
+
if (reason)
|
|
26
|
+
throw new execution_js_1.SecretMaterialError(reason);
|
|
27
|
+
return this.http.publicPost('/evidence/execution', { evidence }, true);
|
|
28
|
+
}
|
|
29
|
+
/** Search stored entries (admin). Use `next_after_sequence` as the next `after_sequence`. */
|
|
30
|
+
search(params = {}) {
|
|
31
|
+
return this.http.adminGet('/admin/evidence', { ...params });
|
|
32
|
+
}
|
|
33
|
+
/** Every matching entry, following pages (admin). */
|
|
34
|
+
async *iterate(params = {}) {
|
|
35
|
+
let after = 0;
|
|
36
|
+
while (after !== null) {
|
|
37
|
+
const page = await this.search({ ...params, after_sequence: after });
|
|
38
|
+
if (page.next_after_sequence !== null && (!Number.isSafeInteger(page.next_after_sequence) || page.next_after_sequence <= after)) {
|
|
39
|
+
throw new Error('evidence search cursor did not advance');
|
|
40
|
+
}
|
|
41
|
+
yield* page.entries;
|
|
42
|
+
after = page.next_after_sequence;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Store mode, size, last sequence and retention checkpoint (admin). */
|
|
46
|
+
status() {
|
|
47
|
+
return this.http.adminGet('/admin/evidence/status');
|
|
48
|
+
}
|
|
49
|
+
/** Verify every stored entry, chain and signature on the NA (admin). */
|
|
50
|
+
verify() {
|
|
51
|
+
return this.http.adminGet('/admin/evidence/verify');
|
|
52
|
+
}
|
|
53
|
+
/** One resource's full history, decision to execution, verified by the NA (admin). */
|
|
54
|
+
resourceHistory(resourceId) {
|
|
55
|
+
return this.http.adminGet(`/admin/evidence/resources/${resourcePath(resourceId)}`);
|
|
56
|
+
}
|
|
57
|
+
/** A vendor's decisions and the evidence under them, verified by the NA (admin). */
|
|
58
|
+
vendorHistory(vendorId) {
|
|
59
|
+
return this.http.adminGet(`/admin/evidence/vendors/${encodeURIComponent(vendorId)}`);
|
|
60
|
+
}
|
|
61
|
+
/** One page of `gm.evidence.event` JSON Lines, unparsed (admin). */
|
|
62
|
+
exportText(params = {}) {
|
|
63
|
+
return this.http.adminGetText('/admin/evidence/export', { ...params });
|
|
64
|
+
}
|
|
65
|
+
/** One page of export events, parsed (admin). */
|
|
66
|
+
async export(params = {}) {
|
|
67
|
+
return (0, verify_js_1.parseExportLines)(await this.exportText(params));
|
|
68
|
+
}
|
|
69
|
+
/** Every event from `since_sequence` onward, following pages - an incremental SIEM pull (admin). */
|
|
70
|
+
async *exportAll(sinceSequence = 0, pageSize = MAX_PAGE) {
|
|
71
|
+
if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > MAX_PAGE
|
|
72
|
+
|| !Number.isSafeInteger(sinceSequence) || sinceSequence < 0) {
|
|
73
|
+
throw new Error('export requires a nonnegative cursor and page size between 1 and 1000');
|
|
74
|
+
}
|
|
75
|
+
let since = sinceSequence;
|
|
76
|
+
for (;;) {
|
|
77
|
+
const events = await this.export({ since_sequence: since, limit: pageSize });
|
|
78
|
+
if (events.length && events[events.length - 1].entry.store_sequence <= since) {
|
|
79
|
+
throw new Error('evidence export cursor did not advance');
|
|
80
|
+
}
|
|
81
|
+
yield* events;
|
|
82
|
+
if (events.length < pageSize)
|
|
83
|
+
return;
|
|
84
|
+
since = events[events.length - 1].entry.store_sequence;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Registered executor keys, retired keys included (admin). */
|
|
88
|
+
async listExecutorKeys() {
|
|
89
|
+
const body = await this.http.adminGet('/admin/evidence/executor-keys');
|
|
90
|
+
return body.executor_keys;
|
|
91
|
+
}
|
|
92
|
+
/** Register a controller's executor signing key (admin, privileged). */
|
|
93
|
+
registerExecutorKey(params) {
|
|
94
|
+
return this.http.adminPost('/admin/evidence/executor-keys', params);
|
|
95
|
+
}
|
|
96
|
+
/** Retire an executor key: it still verifies old records and can sign no new ones (admin, privileged). */
|
|
97
|
+
retireExecutorKey(keyId) {
|
|
98
|
+
return this.http.adminPost(`/admin/evidence/executor-keys/${encodeURIComponent(keyId)}/retire`, {});
|
|
99
|
+
}
|
|
100
|
+
/** Remove entries older than `olderThanDays` behind a signed checkpoint (admin, privileged). */
|
|
101
|
+
applyRetention(olderThanDays) {
|
|
102
|
+
return this.http.adminPost('/admin/evidence/retention/apply', { older_than_days: olderThanDays });
|
|
103
|
+
}
|
|
104
|
+
/** The most recent retention checkpoint in the store, or null (admin). */
|
|
105
|
+
async latestCheckpoint() {
|
|
106
|
+
let latest = null;
|
|
107
|
+
for await (const event of this.iterate({ entry_kind: 'retention_checkpoint' })) {
|
|
108
|
+
latest = event.payload;
|
|
109
|
+
}
|
|
110
|
+
return latest;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The head of a resource chain - what the next record must link to - or null
|
|
114
|
+
* for a resource with no history. Falls back to the latest retention
|
|
115
|
+
* checkpoint when retention removed every stored record (admin).
|
|
116
|
+
*/
|
|
117
|
+
async resourceHead(resourceId) {
|
|
118
|
+
let history;
|
|
119
|
+
try {
|
|
120
|
+
history = await this.resourceHistory(resourceId);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
if (err instanceof errors_js_1.NotFoundError && err.code === 'resource_not_found') {
|
|
124
|
+
return (await this.latestCheckpoint())?.resource_heads[resourceId] ?? null;
|
|
125
|
+
}
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
if (!history.verification?.verified)
|
|
129
|
+
throw new Error('resource history did not verify');
|
|
130
|
+
let head = null;
|
|
131
|
+
for (const event of history.entries) {
|
|
132
|
+
if (event.entry.entry_kind !== 'execution')
|
|
133
|
+
continue;
|
|
134
|
+
const record = event.payload;
|
|
135
|
+
if (record.resource_id !== resourceId || record.resource_sequence === undefined)
|
|
136
|
+
continue;
|
|
137
|
+
if (!head || record.resource_sequence > head.resource_sequence) {
|
|
138
|
+
head = { resource_sequence: record.resource_sequence, record_digest: (0, canonical_js_1.executionDigest)(record) };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return head;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Latest state of every resource with stored execution evidence, for
|
|
145
|
+
* reconciliation against a cloud inventory (admin).
|
|
146
|
+
*/
|
|
147
|
+
async resourceStates() {
|
|
148
|
+
const states = new Map();
|
|
149
|
+
for await (const event of this.iterate({ entry_kind: 'execution' })) {
|
|
150
|
+
const record = event.payload;
|
|
151
|
+
if (record.resource_id === undefined || record.resource_sequence === undefined)
|
|
152
|
+
continue;
|
|
153
|
+
const previous = states.get(record.resource_id);
|
|
154
|
+
if (previous && previous.resource_sequence >= record.resource_sequence)
|
|
155
|
+
continue;
|
|
156
|
+
const success = record.outcome === 'success';
|
|
157
|
+
states.set(record.resource_id, {
|
|
158
|
+
resource_id: record.resource_id,
|
|
159
|
+
resource_sequence: record.resource_sequence,
|
|
160
|
+
record_digest: (0, canonical_js_1.executionDigest)(record),
|
|
161
|
+
last_action: record.resource_action ?? null,
|
|
162
|
+
last_outcome: record.outcome,
|
|
163
|
+
last_success_action: success ? record.resource_action ?? null : previous?.last_success_action ?? null,
|
|
164
|
+
last_success_parameters: success ? record.execution_parameters : previous?.last_success_parameters ?? null,
|
|
165
|
+
last_executed_at: record.executed_at,
|
|
166
|
+
last_decision_id: record.decision_id,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return states;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
exports.EvidenceStoreClient = EvidenceStoreClient;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Execution evidence: build and sign records exactly as the Python reference
|
|
4
|
+
* `record_execution` does, and refuse secret material before anything is signed.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.ExecutionRecorder = exports.SecretMaterialError = exports.MAX_METADATA_BYTES = void 0;
|
|
8
|
+
exports.checkMetadataOnly = checkMetadataOnly;
|
|
9
|
+
const node_crypto_1 = require("node:crypto");
|
|
10
|
+
const auth_js_1 = require("./auth.js");
|
|
11
|
+
const canonical_js_1 = require("./canonical.js");
|
|
12
|
+
const errors_js_1 = require("./errors.js");
|
|
13
|
+
/** Limit on execution_parameters + outcome_detail, as enforced by the NA. */
|
|
14
|
+
exports.MAX_METADATA_BYTES = 16 * 1024;
|
|
15
|
+
const SECRET_KEYS = new Set([
|
|
16
|
+
'value', 'secret', 'secretvalue', 'password', 'passwd', 'passphrase', 'token',
|
|
17
|
+
'accesstoken', 'refreshtoken', 'bearer', 'privatekey', 'keymaterial',
|
|
18
|
+
'credential', 'credentials', 'clientsecret', 'apikey', 'pem', 'connectionstring',
|
|
19
|
+
]);
|
|
20
|
+
const LONG_OPAQUE = /^[A-Za-z0-9+/=_-]{120,}$/;
|
|
21
|
+
const JWT = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/;
|
|
22
|
+
/** Thrown before signing when a record would carry secret material; the NA would refuse it. */
|
|
23
|
+
class SecretMaterialError extends errors_js_1.GenesisMeshError {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message, 'evidence_secret_material', 0);
|
|
26
|
+
this.name = 'SecretMaterialError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
exports.SecretMaterialError = SecretMaterialError;
|
|
30
|
+
function normaliseKey(key) {
|
|
31
|
+
return key.toLowerCase().replace(/[-_.]/g, '');
|
|
32
|
+
}
|
|
33
|
+
function secretMaterial(value, path = '') {
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
for (let i = 0; i < value.length; i++) {
|
|
36
|
+
const found = secretMaterial(value[i], `${path}${i}.`);
|
|
37
|
+
if (found)
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else if (value !== null && typeof value === 'object') {
|
|
42
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
43
|
+
if (SECRET_KEYS.has(normaliseKey(key)))
|
|
44
|
+
return `field '${path}${key}' is not allowed in evidence metadata`;
|
|
45
|
+
const found = secretMaterial(inner, `${path}${key}.`);
|
|
46
|
+
if (found)
|
|
47
|
+
return found;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (typeof value === 'string') {
|
|
51
|
+
const field = path.replace(/\.$/, '');
|
|
52
|
+
if (value.includes('-----BEGIN'))
|
|
53
|
+
return `field '${field}' contains a PEM block`;
|
|
54
|
+
if (LONG_OPAQUE.test(value) || JWT.test(value))
|
|
55
|
+
return `field '${field}' looks like key or token material`;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Why the metadata would be refused as secret material, or null. A guard, not a
|
|
61
|
+
* guarantee: send identifiers, versions and timestamps, never secret values.
|
|
62
|
+
*/
|
|
63
|
+
function checkMetadataOnly(executionParameters, outcomeDetail = null) {
|
|
64
|
+
const size = Buffer.byteLength(JSON.stringify({ execution_parameters: executionParameters, outcome_detail: outcomeDetail }), 'utf-8');
|
|
65
|
+
if (size > exports.MAX_METADATA_BYTES)
|
|
66
|
+
return `metadata is ${size} bytes, over the ${exports.MAX_METADATA_BYTES}-byte limit`;
|
|
67
|
+
return secretMaterial(executionParameters) ?? (outcomeDetail ? secretMaterial({ outcome_detail: outcomeDetail }) : null);
|
|
68
|
+
}
|
|
69
|
+
function resourceHeadOf(prior) {
|
|
70
|
+
if ('record_digest' in prior)
|
|
71
|
+
return prior;
|
|
72
|
+
return { resource_sequence: prior.resource_sequence ?? 0, record_digest: (0, canonical_js_1.executionDigest)(prior) };
|
|
73
|
+
}
|
|
74
|
+
/** Builds and signs ExecutionEvidence for one executor. */
|
|
75
|
+
class ExecutionRecorder {
|
|
76
|
+
executorSovereignId;
|
|
77
|
+
signer;
|
|
78
|
+
constructor(options) {
|
|
79
|
+
this.executorSovereignId = options.executorSovereignId;
|
|
80
|
+
this.signer = options.signer;
|
|
81
|
+
}
|
|
82
|
+
get keyId() {
|
|
83
|
+
return this.signer.keyId;
|
|
84
|
+
}
|
|
85
|
+
async record(params) {
|
|
86
|
+
if ((params.resource_id === undefined) !== (params.resource_action === undefined)) {
|
|
87
|
+
throw new Error('resource_id and resource_action go together');
|
|
88
|
+
}
|
|
89
|
+
const execution_parameters = params.execution_parameters ?? {};
|
|
90
|
+
const outcome_detail = params.outcome_detail ?? null;
|
|
91
|
+
const secret = checkMetadataOnly(execution_parameters, outcome_detail);
|
|
92
|
+
if (secret)
|
|
93
|
+
throw new SecretMaterialError(secret);
|
|
94
|
+
const prior = params.prior_record ?? null;
|
|
95
|
+
const record = {
|
|
96
|
+
evidence_id: params.evidence_id ?? (0, node_crypto_1.randomUUID)(),
|
|
97
|
+
sequence_no: prior ? prior.sequence_no + 1 : 1,
|
|
98
|
+
decision_id: params.decision.decision_id,
|
|
99
|
+
context_id: params.decision.context_id,
|
|
100
|
+
agreement_id: params.decision.agreement_id,
|
|
101
|
+
executor_sovereign_id: this.executorSovereignId,
|
|
102
|
+
executed_capability: params.executed_capability,
|
|
103
|
+
execution_parameters,
|
|
104
|
+
executed_at: (0, auth_js_1.pythonTimestamp)(params.executed_at ?? new Date()),
|
|
105
|
+
outcome: params.outcome,
|
|
106
|
+
outcome_detail,
|
|
107
|
+
prev_evidence_digest: prior ? (0, canonical_js_1.executionDigest)(prior) : null,
|
|
108
|
+
signature: null,
|
|
109
|
+
};
|
|
110
|
+
if (params.resource_id !== undefined && params.resource_action !== undefined) {
|
|
111
|
+
const head = params.prior_resource ? resourceHeadOf(params.prior_resource) : null;
|
|
112
|
+
record.resource_id = params.resource_id;
|
|
113
|
+
record.resource_action = params.resource_action;
|
|
114
|
+
record.resource_sequence = head ? head.resource_sequence + 1 : 1;
|
|
115
|
+
record.prev_resource_digest = head ? head.record_digest : null;
|
|
116
|
+
}
|
|
117
|
+
record.signature = await (0, auth_js_1.signCanonical)((0, canonical_js_1.executionCanonical)(record), this.signer);
|
|
118
|
+
return record;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
exports.ExecutionRecorder = ExecutionRecorder;
|