@evomap/evolver-adapter-public 2.0.0-beta.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/dist/antiAbuseTelemetry.d.ts +36 -0
- package/dist/antiAbuseTelemetry.js +267 -0
- package/dist/atp.d.ts +54 -0
- package/dist/atp.js +140 -0
- package/dist/auth/credentialStore.d.ts +8 -0
- package/dist/auth/credentialStore.js +23 -0
- package/dist/auth/keypair.d.ts +42 -0
- package/dist/auth/keypair.js +80 -0
- package/dist/auth/legacyShim.d.ts +43 -0
- package/dist/auth/legacyShim.js +82 -0
- package/dist/auth/machineId.d.ts +20 -0
- package/dist/auth/machineId.js +38 -0
- package/dist/auth/oauthDeviceToken.d.ts +62 -0
- package/dist/auth/oauthDeviceToken.js +83 -0
- package/dist/auth/oauthHttpTransport.d.ts +33 -0
- package/dist/auth/oauthHttpTransport.js +93 -0
- package/dist/connect.d.ts +40 -0
- package/dist/connect.js +38 -0
- package/dist/hubCapability.d.ts +169 -0
- package/dist/hubCapability.js +899 -0
- package/dist/hubFetch.d.ts +116 -0
- package/dist/hubFetch.js +469 -0
- package/dist/hubReuse.d.ts +112 -0
- package/dist/hubReuse.js +292 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/offlinePermit.d.ts +74 -0
- package/dist/offlinePermit.js +309 -0
- package/dist/pricing/modelPrices.d.ts +16 -0
- package/dist/pricing/modelPrices.js +44 -0
- package/dist/wireMap.d.ts +28 -0
- package/dist/wireMap.js +99 -0
- package/package.json +29 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { util } from '@evomap/evolver-core';
|
|
5
|
+
import { AuthError, globalFetchLike, HubClientError, HubFetch } from './hubFetch.js';
|
|
6
|
+
export const DEFAULT_MAX_OFFLINE_SOLIDIFIES = 10;
|
|
7
|
+
export const DEFAULT_MAX_OFFLINE_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
8
|
+
export const DEFAULT_MAX_CLOCK_DRIFT_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
const OFFLINE_TOKEN_FILE = '.ot';
|
|
10
|
+
const LAST_VERIFY_FILE = '.lv';
|
|
11
|
+
const LOCK_SUFFIX = '.lock';
|
|
12
|
+
const ERROR_DETAIL_MAX_CHARS = 200;
|
|
13
|
+
const DEFAULT_OFFLINE_PERMIT_LOCK = { maxTries: 5, waitMs: 1 };
|
|
14
|
+
/**
|
|
15
|
+
* HMAC-backed local offline permit counter.
|
|
16
|
+
*
|
|
17
|
+
* This ports v1 PR #157's concurrency fix into v2: the full
|
|
18
|
+
* load -> cap-check -> increment -> write pipeline is serialized with the
|
|
19
|
+
* shared PID-liveness file lock, so daemon and CLI processes cannot both
|
|
20
|
+
* consume the same local offline quota slot.
|
|
21
|
+
*/
|
|
22
|
+
export class OfflinePermitStore {
|
|
23
|
+
opts;
|
|
24
|
+
now;
|
|
25
|
+
maxOfflineSolidifies;
|
|
26
|
+
maxOfflineDurationMs;
|
|
27
|
+
maxClockDriftMs;
|
|
28
|
+
constructor(opts) {
|
|
29
|
+
this.opts = opts;
|
|
30
|
+
this.now = opts.now ?? (() => Date.now());
|
|
31
|
+
this.maxOfflineSolidifies = opts.maxOfflineSolidifies ?? DEFAULT_MAX_OFFLINE_SOLIDIFIES;
|
|
32
|
+
this.maxOfflineDurationMs = opts.maxOfflineDurationMs ?? DEFAULT_MAX_OFFLINE_DURATION_MS;
|
|
33
|
+
this.maxClockDriftMs = opts.maxClockDriftMs ?? DEFAULT_MAX_CLOCK_DRIFT_MS;
|
|
34
|
+
}
|
|
35
|
+
offlineTokenPath() {
|
|
36
|
+
return join(this.opts.dir, OFFLINE_TOKEN_FILE);
|
|
37
|
+
}
|
|
38
|
+
lastVerifyPath() {
|
|
39
|
+
return join(this.opts.dir, LAST_VERIFY_FILE);
|
|
40
|
+
}
|
|
41
|
+
lockPath() {
|
|
42
|
+
return `${this.offlineTokenPath()}${LOCK_SUFFIX}`;
|
|
43
|
+
}
|
|
44
|
+
cacheOfflineToken(token) {
|
|
45
|
+
try {
|
|
46
|
+
const secret = this.nodeSecret();
|
|
47
|
+
if (!secret)
|
|
48
|
+
return false;
|
|
49
|
+
mkdirSync(dirname(this.offlineTokenPath()), { recursive: true });
|
|
50
|
+
const data = JSON.stringify(token);
|
|
51
|
+
const hmac = hmacSha256(secret, data);
|
|
52
|
+
writeFileSync(this.offlineTokenPath(), `${JSON.stringify({ data: token, hmac })}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
loadOfflineToken() {
|
|
60
|
+
try {
|
|
61
|
+
if (!existsSync(this.offlineTokenPath()))
|
|
62
|
+
return null;
|
|
63
|
+
const stored = JSON.parse(readFileSync(this.offlineTokenPath(), 'utf8'));
|
|
64
|
+
if (!isStoredOfflinePermitToken(stored))
|
|
65
|
+
return null;
|
|
66
|
+
const secret = this.nodeSecret();
|
|
67
|
+
if (!secret)
|
|
68
|
+
return null;
|
|
69
|
+
const expected = hmacSha256(secret, JSON.stringify(stored.data));
|
|
70
|
+
if (!safeHexEqual(expected, stored.hmac))
|
|
71
|
+
return null;
|
|
72
|
+
return stored.data;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
recordLastOnlineVerify(ts = this.now()) {
|
|
79
|
+
try {
|
|
80
|
+
mkdirSync(dirname(this.lastVerifyPath()), { recursive: true });
|
|
81
|
+
writeFileSync(this.lastVerifyPath(), String(ts), { encoding: 'utf8', mode: 0o600 });
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
getLastOnlineVerifyTs() {
|
|
89
|
+
try {
|
|
90
|
+
if (!existsSync(this.lastVerifyPath()))
|
|
91
|
+
return 0;
|
|
92
|
+
return Number.parseInt(readFileSync(this.lastVerifyPath(), 'utf8'), 10) || 0;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
consumeOfflinePermit() {
|
|
99
|
+
try {
|
|
100
|
+
mkdirSync(dirname(this.offlineTokenPath()), { recursive: true });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Let acquireLock surface the concrete filesystem error below; it is
|
|
104
|
+
// converted to the structured offline_lock_failed envelope.
|
|
105
|
+
}
|
|
106
|
+
let locked = false;
|
|
107
|
+
try {
|
|
108
|
+
util.acquireLock(this.lockPath(), this.opts.lock ?? DEFAULT_OFFLINE_PERMIT_LOCK);
|
|
109
|
+
locked = true;
|
|
110
|
+
return this.consumeLocked();
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
if (isLockTimeoutError(err))
|
|
114
|
+
return { ok: false, error: 'offline_permit_busy', offline: true };
|
|
115
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
116
|
+
return { ok: false, error: 'offline_lock_failed', offline: true, detail: this.errorDetail(message) };
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
if (locked)
|
|
120
|
+
util.releaseLock(this.lockPath());
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
consumeLocked() {
|
|
124
|
+
const token = this.loadOfflineToken();
|
|
125
|
+
if (!token)
|
|
126
|
+
return { ok: false, error: 'no_offline_token', offline: true };
|
|
127
|
+
const maxSolidifies = finiteNonNegativeNumber(token.maxOfflineSolidifies, this.maxOfflineSolidifies);
|
|
128
|
+
const expiresAt = finitePositiveNumber(token.expiresAt);
|
|
129
|
+
const usedCount = finiteNonNegativeNumber(token.usedCount, 0);
|
|
130
|
+
const now = this.now();
|
|
131
|
+
const lastOnline = this.getLastOnlineVerifyTs();
|
|
132
|
+
if (lastOnline > 0 && now < lastOnline - this.maxClockDriftMs) {
|
|
133
|
+
return { ok: false, error: 'clock_drift_detected', offline: true };
|
|
134
|
+
}
|
|
135
|
+
if (expiresAt === null || now > expiresAt) {
|
|
136
|
+
return { ok: false, error: 'offline_token_expired', offline: true };
|
|
137
|
+
}
|
|
138
|
+
if (lastOnline > 0 && now - lastOnline > this.maxOfflineDurationMs) {
|
|
139
|
+
return { ok: false, error: 'offline_duration_exceeded', offline: true };
|
|
140
|
+
}
|
|
141
|
+
if (usedCount >= maxSolidifies) {
|
|
142
|
+
return { ok: false, error: 'offline_quota_exhausted', offline: true };
|
|
143
|
+
}
|
|
144
|
+
token.usedCount = usedCount + 1;
|
|
145
|
+
if (!this.cacheOfflineToken(token)) {
|
|
146
|
+
return { ok: false, error: 'offline_lock_failed', offline: true, detail: 'offline token write failed' };
|
|
147
|
+
}
|
|
148
|
+
return { ok: true, offline: true, remaining: maxSolidifies - token.usedCount };
|
|
149
|
+
}
|
|
150
|
+
nodeSecret() {
|
|
151
|
+
const raw = typeof this.opts.nodeSecret === 'function' ? this.opts.nodeSecret() : this.opts.nodeSecret;
|
|
152
|
+
return typeof raw === 'string' && raw.length > 0 ? raw : null;
|
|
153
|
+
}
|
|
154
|
+
errorDetail(message) {
|
|
155
|
+
let out = message;
|
|
156
|
+
for (const sensitive of [this.lockPath(), this.offlineTokenPath(), this.lastVerifyPath(), this.opts.dir].sort((a, b) => b.length - a.length)) {
|
|
157
|
+
if (sensitive)
|
|
158
|
+
out = out.split(sensitive).join('<offline-permit-path>');
|
|
159
|
+
}
|
|
160
|
+
return out.slice(0, ERROR_DETAIL_MAX_CHARS);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export function createSolidifyPermitCheck(opts) {
|
|
164
|
+
const store = opts.store ?? new OfflinePermitStore({
|
|
165
|
+
dir: opts.dir,
|
|
166
|
+
nodeSecret: opts.nodeSecret,
|
|
167
|
+
...(opts.now ? { now: opts.now } : {}),
|
|
168
|
+
});
|
|
169
|
+
const http = new HubFetch({
|
|
170
|
+
baseUrl: opts.hubUrl.replace(/\/+$/, ''),
|
|
171
|
+
auth: opts.auth,
|
|
172
|
+
fetchFn: opts.fetchFn ?? globalFetchLike,
|
|
173
|
+
senderId: opts.senderId,
|
|
174
|
+
});
|
|
175
|
+
return async (ctx) => {
|
|
176
|
+
const online = await requestSolidifyPermit(http, opts, ctx, store);
|
|
177
|
+
if (online.ok)
|
|
178
|
+
return { ok: true, reason: online.reason ?? 'hub_solidify_authorized' };
|
|
179
|
+
if (!online.offline)
|
|
180
|
+
return { ok: false, reason: online.reason };
|
|
181
|
+
const offline = store.consumeOfflinePermit();
|
|
182
|
+
if (offline.ok)
|
|
183
|
+
return { ok: true, reason: 'offline_permit' };
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
reason: `hub_solidify_offline_denied:${offline.error}`,
|
|
187
|
+
...(offline.detail ? { detail: offline.detail } : {}),
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
async function requestSolidifyPermit(http, opts, ctx, store) {
|
|
192
|
+
try {
|
|
193
|
+
const body = buildVerifySolidifyBody(opts, ctx);
|
|
194
|
+
const raw = await http.call('POST', '/a2a/verify-solidify', body);
|
|
195
|
+
// Any 2xx response proves the hub's application layer answered, so the node
|
|
196
|
+
// is not actually offline — refresh lastOnlineVerify even on {ok:false}
|
|
197
|
+
// envelopes (quota_exceeded, rate_limited, validation). Previously this only
|
|
198
|
+
// fired on raw.ok===true, so a long streak of envelope errors let the
|
|
199
|
+
// offline-duration counter run past maxOfflineDurationMs (7d) and falsely
|
|
200
|
+
// trip offline_duration_exceeded while the hub was reachable the whole time.
|
|
201
|
+
// (ports v1 PR #149)
|
|
202
|
+
store.recordLastOnlineVerify(opts.now?.());
|
|
203
|
+
// Only trust an offline_token from a genuine {ok:true} envelope — an error
|
|
204
|
+
// envelope must never seed the offline-permit cache with a stale token.
|
|
205
|
+
if (raw['ok'] === true && isOfflinePermitToken(raw['offline_token'])) {
|
|
206
|
+
store.cacheOfflineToken(raw['offline_token']);
|
|
207
|
+
}
|
|
208
|
+
return normalizePermitResult(raw, false);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
// 4xx means the hub answered and explicitly rejected (bad auth, forbidden,
|
|
212
|
+
// client error). status < 500 still proves reachability, so refresh
|
|
213
|
+
// lastOnlineVerify here too — mirrors the non-5xx rule above. The solidify
|
|
214
|
+
// stays denied online (offline:false), so this never feeds the offline
|
|
215
|
+
// quota fallback. (ports v1 PR #149)
|
|
216
|
+
if (err instanceof AuthError) {
|
|
217
|
+
store.recordLastOnlineVerify(opts.now?.());
|
|
218
|
+
return { ok: false, offline: false, reason: `HTTP ${err.status}` };
|
|
219
|
+
}
|
|
220
|
+
if (err instanceof HubClientError) {
|
|
221
|
+
store.recordLastOnlineVerify(opts.now?.());
|
|
222
|
+
return { ok: false, offline: false, reason: permitError(rawRecord(err.body)) ?? `HTTP ${err.status}` };
|
|
223
|
+
}
|
|
224
|
+
// 5xx (plain Error), captive-portal/non-API responses (HubUnreachableError),
|
|
225
|
+
// and transport failures are genuinely offline → leave lastOnlineVerify
|
|
226
|
+
// untouched so the offline-duration guard still trips if the hub stays down.
|
|
227
|
+
return { ok: false, offline: true, reason: err instanceof Error ? err.message : String(err) };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function buildVerifySolidifyBody(opts, ctx) {
|
|
231
|
+
const ts = opts.now?.() ?? Date.now();
|
|
232
|
+
const signalsHash = hashCompact(JSON.stringify([...ctx.signals].slice(0, 8)));
|
|
233
|
+
const mutationHash = hashCompact(JSON.stringify(ctx.mutation));
|
|
234
|
+
const nodeId = opts.senderId();
|
|
235
|
+
const secret = resolveNodeSecret(opts.nodeSecret);
|
|
236
|
+
const signatureFields = secret && nodeId
|
|
237
|
+
? {
|
|
238
|
+
client_signature: hmacSha256(createHash('sha256').update(secret).digest('hex'), [nodeId, ctx.geneId || '', signalsHash, mutationHash, String(ts)].join('|')),
|
|
239
|
+
}
|
|
240
|
+
: {};
|
|
241
|
+
return {
|
|
242
|
+
gene_id: ctx.geneId,
|
|
243
|
+
signals_hash: signalsHash,
|
|
244
|
+
mutation_hash: mutationHash,
|
|
245
|
+
ts,
|
|
246
|
+
...signatureFields,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function hashCompact(input) {
|
|
250
|
+
return createHash('sha256').update(input).digest('hex').slice(0, 16);
|
|
251
|
+
}
|
|
252
|
+
function normalizePermitResult(raw, fallbackOffline) {
|
|
253
|
+
if (raw['ok'] === true)
|
|
254
|
+
return { ok: true, ...(raw['offline'] === true ? { offline: true } : {}) };
|
|
255
|
+
return { ok: false, offline: typeof raw['offline'] === 'boolean' ? raw['offline'] : fallbackOffline, reason: permitError(raw) ?? 'solidify_permit_denied' };
|
|
256
|
+
}
|
|
257
|
+
function permitError(raw) {
|
|
258
|
+
const value = raw?.['error'] ?? raw?.['reason'];
|
|
259
|
+
return typeof value === 'string' && value.length > 0 ? value.slice(0, ERROR_DETAIL_MAX_CHARS) : undefined;
|
|
260
|
+
}
|
|
261
|
+
function isOfflinePermitToken(value) {
|
|
262
|
+
return Boolean(value && typeof value === 'object');
|
|
263
|
+
}
|
|
264
|
+
function rawRecord(value) {
|
|
265
|
+
return value && typeof value === 'object' ? value : null;
|
|
266
|
+
}
|
|
267
|
+
function resolveNodeSecret(input) {
|
|
268
|
+
const raw = typeof input === 'function' ? input() : input;
|
|
269
|
+
return typeof raw === 'string' && raw.length > 0 ? raw : null;
|
|
270
|
+
}
|
|
271
|
+
export function hmacSha256(key, data) {
|
|
272
|
+
return createHmac('sha256', key).update(data).digest('hex');
|
|
273
|
+
}
|
|
274
|
+
function safeHexEqual(expected, received) {
|
|
275
|
+
try {
|
|
276
|
+
const exp = Buffer.from(expected, 'hex');
|
|
277
|
+
const got = Buffer.from(received, 'hex');
|
|
278
|
+
return exp.length === got.length && timingSafeEqual(exp, got);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function isStoredOfflinePermitToken(value) {
|
|
285
|
+
if (!value || typeof value !== 'object')
|
|
286
|
+
return false;
|
|
287
|
+
const v = value;
|
|
288
|
+
return Boolean(v.data && typeof v.data === 'object' && typeof v.hmac === 'string');
|
|
289
|
+
}
|
|
290
|
+
function finiteNonNegativeNumber(value, fallback) {
|
|
291
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
292
|
+
return fallback;
|
|
293
|
+
return Math.max(0, Math.floor(value));
|
|
294
|
+
}
|
|
295
|
+
function finitePositiveNumber(value) {
|
|
296
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
|
|
297
|
+
return null;
|
|
298
|
+
return Math.floor(value);
|
|
299
|
+
}
|
|
300
|
+
function isLockTimeoutError(err) {
|
|
301
|
+
if (!(err instanceof Error))
|
|
302
|
+
return false;
|
|
303
|
+
const code = err.code;
|
|
304
|
+
if (code === 'LOCK_TIMEOUT')
|
|
305
|
+
return true;
|
|
306
|
+
if (code)
|
|
307
|
+
return false;
|
|
308
|
+
return /文件锁|lock/i.test(err.message);
|
|
309
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ops } from '@evomap/evolver-core';
|
|
2
|
+
type ModelPrice = ops.ModelPrice;
|
|
3
|
+
type PriceTable = ops.PriceTable;
|
|
4
|
+
/**
|
|
5
|
+
* Load the model→price map from the JSON data file. A missing or malformed file degrades to an empty map (no
|
|
6
|
+
* prices) rather than throwing — the ledger then reports tokens saved without a cost figure, never crashes.
|
|
7
|
+
* @param path override the data-file location (tests / alternate price sets); defaults to the bundled file.
|
|
8
|
+
*/
|
|
9
|
+
export declare function loadModelPriceMap(path?: string): Record<string, ModelPrice>;
|
|
10
|
+
/**
|
|
11
|
+
* Build the injectable PriceTable from the JSON data file. This is what the composition layer hands to the
|
|
12
|
+
* value ledger's derive functions. Updating prices = editing modelPrices.json; this function and core are
|
|
13
|
+
* untouched.
|
|
14
|
+
*/
|
|
15
|
+
export declare function loadPriceTable(path?: string): PriceTable;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Model price DATA loader (#112). The price table is a DATA FILE (modelPrices.json) the adapter loads and
|
|
2
|
+
// injects into core's value ledger as a PriceTable. Core hardcodes no price — so a price update is editing the
|
|
3
|
+
// JSON next to this file, never a core code change (acceptance criterion). This loader is the only code that
|
|
4
|
+
// reads the file; it tolerates a missing/corrupt file by degrading to an empty table (an unlisted model simply
|
|
5
|
+
// contributes no cost to the ledger — never a thrown error in a long-running daemon).
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { ops } from '@evomap/evolver-core';
|
|
10
|
+
/** Resolve the JSON data file next to this module (works from src under test and dist at runtime). */
|
|
11
|
+
function defaultPricesPath() {
|
|
12
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
return join(here, 'modelPrices.json');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Load the model→price map from the JSON data file. A missing or malformed file degrades to an empty map (no
|
|
17
|
+
* prices) rather than throwing — the ledger then reports tokens saved without a cost figure, never crashes.
|
|
18
|
+
* @param path override the data-file location (tests / alternate price sets); defaults to the bundled file.
|
|
19
|
+
*/
|
|
20
|
+
export function loadModelPriceMap(path = defaultPricesPath()) {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
23
|
+
const models = parsed.models;
|
|
24
|
+
if (!models || typeof models !== 'object')
|
|
25
|
+
return {};
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const [model, price] of Object.entries(models)) {
|
|
28
|
+
if (price && typeof price === 'object')
|
|
29
|
+
out[model] = price;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build the injectable PriceTable from the JSON data file. This is what the composition layer hands to the
|
|
39
|
+
* value ledger's derive functions. Updating prices = editing modelPrices.json; this function and core are
|
|
40
|
+
* untouched.
|
|
41
|
+
*/
|
|
42
|
+
export function loadPriceTable(path) {
|
|
43
|
+
return ops.priceTableFromMap(loadModelPriceMap(path));
|
|
44
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { hub } from '@evomap/evolver-core';
|
|
2
|
+
/** 公版 inbound 消息(snake_case) → core AgentEvent. */
|
|
3
|
+
export declare function inboundToAgentEvent(m: Record<string, unknown>): hub.AgentEvent;
|
|
4
|
+
/**
|
|
5
|
+
* SearchQuery(camelCase) → 公版 /a2a/fetch wire(snake_case). 关键: signalsAny → signals(dev 实测 hub 读
|
|
6
|
+
* payload.signals, #69)。text 不是 fetch 字段(自由文本走 semantic-search 端点, 见 hubCapability.search)。
|
|
7
|
+
*/
|
|
8
|
+
export declare function searchQueryToFetchWire(q: hub.HubQuery): Record<string, unknown>;
|
|
9
|
+
/** core AgentEvent(出站) → 公版 outbound 消息(id+type 必填). */
|
|
10
|
+
export declare function agentEventToOutbound(e: hub.AgentEvent): Record<string, unknown>;
|
|
11
|
+
/** Retry policy for a non-2xx hub status, shared by every money-touching caller (anti-drift, #177). */
|
|
12
|
+
export type AtpRetryClass = 'permanent' | 'cooldown' | 'recoverable';
|
|
13
|
+
/**
|
|
14
|
+
* Canonical hub-status → retry policy. ONE source of truth so publish and auto-deliver can never drift on which
|
|
15
|
+
* status means what (the #177 root cause: each caller inlined its own classification).
|
|
16
|
+
* - permanent : structurally dead, NO retry ever helps — 400 bad-request / 404 gone / 409 duplicate /
|
|
17
|
+
* 422 invalid-payload (a malformed proof fails identically forever).
|
|
18
|
+
* - cooldown : the hub is explicitly rate-limiting — 429. Retrying next tick violates the cooldown AND
|
|
19
|
+
* hammers the economic endpoint, so a loop consumer MUST back off before retrying.
|
|
20
|
+
* - recoverable: environment-recoverable, retry-later is correct — 402 credit top-up / 403 node rebind, and
|
|
21
|
+
* 5xx / network (status 0) server-side blips.
|
|
22
|
+
* A one-shot caller (publishRespToReceipt) renders ALL non-2xx as `terminal: true` regardless of class — it
|
|
23
|
+
* does not auto-retry, the human re-acts. A LOOP caller (atpAutoDeliver) applies the class: permanent → give up,
|
|
24
|
+
* cooldown → backoff, recoverable → retry next tick. Same map, different retry policy per caller.
|
|
25
|
+
*/
|
|
26
|
+
export declare function atpRetryClass(status: number): AtpRetryClass;
|
|
27
|
+
/** /a2a/publish 响应 → PublishReceipt. 200=accepted; 402/4xx=rejected 终态. */
|
|
28
|
+
export declare function publishRespToReceipt(status: number, body: Record<string, unknown>): hub.PublishReceipt;
|
package/dist/wireMap.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** 公版 inbound 消息(snake_case) → core AgentEvent. */
|
|
2
|
+
export function inboundToAgentEvent(m) {
|
|
3
|
+
return {
|
|
4
|
+
id: String(m['id'] ?? ''),
|
|
5
|
+
type: String(m['type'] ?? ''),
|
|
6
|
+
payload: m['payload'],
|
|
7
|
+
priority: m['priority'] ?? 'medium',
|
|
8
|
+
...(m['cursor'] ? { cursor: String(m['cursor']) } : {}),
|
|
9
|
+
createdAt: typeof m['created_at'] === 'string' ? Date.parse(m['created_at']) : Number(m['created_at'] ?? 0),
|
|
10
|
+
...(m['ref_id'] ? { refId: String(m['ref_id']) } : {}),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* SearchQuery(camelCase) → 公版 /a2a/fetch wire(snake_case). 关键: signalsAny → signals(dev 实测 hub 读
|
|
15
|
+
* payload.signals, #69)。text 不是 fetch 字段(自由文本走 semantic-search 端点, 见 hubCapability.search)。
|
|
16
|
+
*/
|
|
17
|
+
export function searchQueryToFetchWire(q) {
|
|
18
|
+
const out = {};
|
|
19
|
+
if (q.signalsAny && q.signalsAny.length > 0)
|
|
20
|
+
out['signals'] = q.signalsAny;
|
|
21
|
+
if (q.kind)
|
|
22
|
+
out['kind'] = q.kind;
|
|
23
|
+
if (q.category)
|
|
24
|
+
out['category'] = q.category;
|
|
25
|
+
if (q.gene)
|
|
26
|
+
out['gene'] = q.gene;
|
|
27
|
+
if (q.limit !== undefined)
|
|
28
|
+
out['limit'] = q.limit;
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
/** core AgentEvent(出站) → 公版 outbound 消息(id+type 必填). */
|
|
32
|
+
export function agentEventToOutbound(e) {
|
|
33
|
+
return {
|
|
34
|
+
id: e.id,
|
|
35
|
+
type: e.type,
|
|
36
|
+
payload: e.payload,
|
|
37
|
+
priority: e.priority,
|
|
38
|
+
...(e.refId ? { ref_id: e.refId } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Canonical hub-status → retry policy. ONE source of truth so publish and auto-deliver can never drift on which
|
|
43
|
+
* status means what (the #177 root cause: each caller inlined its own classification).
|
|
44
|
+
* - permanent : structurally dead, NO retry ever helps — 400 bad-request / 404 gone / 409 duplicate /
|
|
45
|
+
* 422 invalid-payload (a malformed proof fails identically forever).
|
|
46
|
+
* - cooldown : the hub is explicitly rate-limiting — 429. Retrying next tick violates the cooldown AND
|
|
47
|
+
* hammers the economic endpoint, so a loop consumer MUST back off before retrying.
|
|
48
|
+
* - recoverable: environment-recoverable, retry-later is correct — 402 credit top-up / 403 node rebind, and
|
|
49
|
+
* 5xx / network (status 0) server-side blips.
|
|
50
|
+
* A one-shot caller (publishRespToReceipt) renders ALL non-2xx as `terminal: true` regardless of class — it
|
|
51
|
+
* does not auto-retry, the human re-acts. A LOOP caller (atpAutoDeliver) applies the class: permanent → give up,
|
|
52
|
+
* cooldown → backoff, recoverable → retry next tick. Same map, different retry policy per caller.
|
|
53
|
+
*/
|
|
54
|
+
export function atpRetryClass(status) {
|
|
55
|
+
if (status === 400 || status === 404 || status === 409 || status === 422)
|
|
56
|
+
return 'permanent';
|
|
57
|
+
if (status === 429)
|
|
58
|
+
return 'cooldown';
|
|
59
|
+
return 'recoverable';
|
|
60
|
+
}
|
|
61
|
+
/** /a2a/publish 响应 → PublishReceipt. 200=accepted; 402/4xx=rejected 终态. */
|
|
62
|
+
export function publishRespToReceipt(status, body) {
|
|
63
|
+
const payload = body['payload'] ?? body;
|
|
64
|
+
const assetIds = payload['asset_ids'];
|
|
65
|
+
const assetId = payload['asset_id'] ?? body['asset_id'] ?? assetIds?.[0];
|
|
66
|
+
const bundleId = payload['bundle_id'];
|
|
67
|
+
if (status >= 200 && status < 300) {
|
|
68
|
+
const decision = String(payload['decision'] ?? payload['status'] ?? 'accepted');
|
|
69
|
+
const accepted = decision === 'accepted' || decision === 'approved' || decision === 'ok';
|
|
70
|
+
return {
|
|
71
|
+
receiptId: String(payload['receipt_id'] ?? bundleId ?? payload['id'] ?? assetId ?? 'unknown'),
|
|
72
|
+
status: accepted ? 'accepted' : (decision === 'quarantine' ? 'quarantine' : 'rejected'),
|
|
73
|
+
...(assetId ? { assetId } : {}),
|
|
74
|
+
...(bundleId ? { bundleId } : {}),
|
|
75
|
+
...(assetIds ? { assetIds } : {}),
|
|
76
|
+
...(payload['reason'] ? { reason: String(payload['reason']) } : {}),
|
|
77
|
+
terminal: !accepted, // quarantine/rejected 终态不重试
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// M8-1: 按语义而非纯状态码区分(都终态不重试 = money-safety: 不反复打经济端点).
|
|
81
|
+
// 402=creditShortage(余额不足) / 403=node 失效需 rebind / 409=duplicate / 422=payload 须修 / 429=cooldown.
|
|
82
|
+
const reasonByStatus = { 402: 'credit_shortage', 403: 'node_unauthorized', 409: 'duplicate', 422: 'invalid_payload', 429: 'cooldown' };
|
|
83
|
+
const receipt = {
|
|
84
|
+
receiptId: String(payload['receipt_id'] ?? 'rejected'),
|
|
85
|
+
status: 'rejected',
|
|
86
|
+
reason: String(payload['reason'] ?? reasonByStatus[status] ?? `hub ${status}`),
|
|
87
|
+
...(assetId ? { assetId } : {}),
|
|
88
|
+
terminal: true,
|
|
89
|
+
};
|
|
90
|
+
if (status === 402) {
|
|
91
|
+
receipt.economic = {
|
|
92
|
+
creditShortage: true,
|
|
93
|
+
...(payload['required'] !== undefined ? { required: Number(payload['required']) } : {}),
|
|
94
|
+
...(payload['available'] !== undefined ? { available: Number(payload['available']) } : {}),
|
|
95
|
+
...(payload['balance_kind'] ? { balanceKind: payload['balance_kind'] } : {}),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return receipt;
|
|
99
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@evomap/evolver-adapter-public",
|
|
3
|
+
"version": "2.0.0-beta.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "公版 hub 适配器 (积分/治理)",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@evomap/atp-sdk": "^0.1.0",
|
|
17
|
+
"@evomap/evolver-core": "2.0.0-beta.0",
|
|
18
|
+
"undici": "^6.27.0"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public",
|
|
22
|
+
"tag": "v2-beta"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist/",
|
|
26
|
+
"README.md",
|
|
27
|
+
"package.json"
|
|
28
|
+
]
|
|
29
|
+
}
|