@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.
@@ -0,0 +1,36 @@
1
+ import { bootstrap } from '@evomap/evolver-core';
2
+ export declare const ANTI_ABUSE_SCHEMA_VERSION = "anti_abuse.v1";
3
+ export declare const ANTI_ABUSE_REDACTION_VERSION = "anti_abuse_redaction.v1";
4
+ export declare const DEFAULT_ANTI_ABUSE_TTL_DAYS = 90;
5
+ export declare const MAX_INTEGRITY_FILE_BYTES: number;
6
+ export type AntiAbuseTelemetryMode = 'heartbeat' | 'off';
7
+ export interface AntiAbuseTelemetryOptions {
8
+ env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
9
+ envFingerprint?: bootstrap.EnvFingerprint;
10
+ now?: Date | string;
11
+ packageRoot?: string;
12
+ proxyPortConfigured?: boolean;
13
+ salt?: string;
14
+ saltId?: string | null;
15
+ source?: string;
16
+ nodeId?: string;
17
+ evolverVersion?: string;
18
+ taskMeta?: Record<string, unknown>;
19
+ workspaceId?: string;
20
+ }
21
+ export interface IntegrityHashes {
22
+ package_json_hash: string | null;
23
+ cli_entry_hash: string | null;
24
+ lockfile_hashes: Record<string, string>;
25
+ }
26
+ export declare function antiAbuseTelemetryMode(env?: NodeJS.ProcessEnv | Record<string, string | undefined>): AntiAbuseTelemetryMode;
27
+ export declare function hmacPseudonym(value: unknown, opts?: {
28
+ salt?: string;
29
+ purpose?: string;
30
+ }): string | null;
31
+ export declare function antiAbuseEnvFingerprintKey(fp: bootstrap.EnvFingerprint): string;
32
+ export declare function resolveAdapterPackageRoot(startDir?: string): string;
33
+ export declare function resolveWorkspaceRoot(startDir?: string): string;
34
+ export declare function resolveCliPackageRoot(packageRoot?: string, workspaceRoot?: string): string | null;
35
+ export declare function collectIntegrityHashes(packageRoot?: string): IntegrityHashes;
36
+ export declare function buildHeartbeatAntiAbuseTelemetry(opts?: AntiAbuseTelemetryOptions): Record<string, unknown>;
@@ -0,0 +1,267 @@
1
+ import { createHash, createHmac } from 'node:crypto';
2
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, resolve, sep } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { bootstrap } from '@evomap/evolver-core';
7
+ export const ANTI_ABUSE_SCHEMA_VERSION = 'anti_abuse.v1';
8
+ export const ANTI_ABUSE_REDACTION_VERSION = 'anti_abuse_redaction.v1';
9
+ export const DEFAULT_ANTI_ABUSE_TTL_DAYS = 90;
10
+ export const MAX_INTEGRITY_FILE_BYTES = 10 * 1024 * 1024;
11
+ export function antiAbuseTelemetryMode(env = process.env) {
12
+ const raw = env['EVOLVER_ANTI_ABUSE_TELEMETRY'];
13
+ const v = String(raw ?? '').trim().toLowerCase();
14
+ if (v.length === 0)
15
+ return 'heartbeat';
16
+ if (v === '0' || v === 'false' || v === 'no' || v === 'off')
17
+ return 'off';
18
+ if (v === '1' || v === 'true' || v === 'yes' || v === 'on' || v === 'heartbeat')
19
+ return 'heartbeat';
20
+ return 'off';
21
+ }
22
+ export function hmacPseudonym(value, opts = {}) {
23
+ const raw = value == null ? '' : String(value);
24
+ const salt = opts.salt ? String(opts.salt) : '';
25
+ const purpose = opts.purpose ? String(opts.purpose) : 'anti_abuse';
26
+ if (!raw || !salt)
27
+ return null;
28
+ return createHmac('sha256', salt).update(purpose).update('\0').update(raw).digest('hex').slice(0, 32);
29
+ }
30
+ export function antiAbuseEnvFingerprintKey(fp) {
31
+ const nodeMajor = (fp.node_version || '').replace(/^v/, '').split('.')[0] ?? '';
32
+ return [fp.platform, fp.arch, `node${nodeMajor}`, fp.region ?? '', fp.container ? 'container' : 'host'].join('|');
33
+ }
34
+ export function resolveAdapterPackageRoot(startDir = dirname(fileURLToPath(import.meta.url))) {
35
+ let dir = startDir;
36
+ for (let i = 0; i < 8; i++) {
37
+ try {
38
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
39
+ if (pkg.name === '@evomap/evolver-adapter-public')
40
+ return dir;
41
+ }
42
+ catch {
43
+ // Source and built layouts place package.json at different depths.
44
+ }
45
+ const parent = dirname(dir);
46
+ if (parent === dir)
47
+ break;
48
+ dir = parent;
49
+ }
50
+ return startDir;
51
+ }
52
+ export function resolveWorkspaceRoot(startDir = resolveAdapterPackageRoot()) {
53
+ let dir = startDir;
54
+ for (let i = 0; i < 12; i++) {
55
+ if (existsSync(join(dir, 'pnpm-workspace.yaml')) || existsSync(join(dir, 'pnpm-lock.yaml')))
56
+ return dir;
57
+ const parent = dirname(dir);
58
+ if (parent === dir)
59
+ break;
60
+ dir = parent;
61
+ }
62
+ return startDir;
63
+ }
64
+ export function resolveCliPackageRoot(packageRoot = resolveAdapterPackageRoot(), workspaceRoot = resolveWorkspaceRoot(packageRoot)) {
65
+ const candidates = [
66
+ join(workspaceRoot, 'packages', 'evolver-cli'),
67
+ join(workspaceRoot, 'node_modules', '@evomap', 'evolver-cli'),
68
+ join(dirname(packageRoot), 'evolver-cli'),
69
+ ];
70
+ for (const candidate of candidates) {
71
+ const pkgPath = containedRealPath(candidate, join(candidate, 'package.json'));
72
+ const pkg = pkgPath ? safeJsonFile(pkgPath) : {};
73
+ if (pkg.name === '@evomap/evolver-cli')
74
+ return candidate;
75
+ }
76
+ return null;
77
+ }
78
+ export function collectIntegrityHashes(packageRoot = resolveAdapterPackageRoot()) {
79
+ const pkgPath = containedRealPath(packageRoot, join(packageRoot, 'package.json'));
80
+ const workspaceRoot = resolveWorkspaceRoot(packageRoot);
81
+ const cliRoot = resolveCliPackageRoot(packageRoot, workspaceRoot);
82
+ const cliPkgPath = cliRoot ? containedRealPath(cliRoot, join(cliRoot, 'package.json')) : null;
83
+ const cliPkg = cliPkgPath ? safeJsonFile(cliPkgPath) : {};
84
+ const bin = typeof cliPkg.bin === 'object' && cliPkg.bin && typeof cliPkg.bin['evolver'] === 'string' ? cliPkg.bin['evolver'] : null;
85
+ const binPath = bin && cliRoot ? containedRealPath(cliRoot, resolve(cliRoot, bin)) : null;
86
+ const lockfileHashes = {};
87
+ for (const name of ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lockb']) {
88
+ const p = containedRealPath(workspaceRoot, join(workspaceRoot, name));
89
+ const digest = p ? sha256File(p) : null;
90
+ if (digest)
91
+ lockfileHashes[name] = digest;
92
+ }
93
+ return {
94
+ package_json_hash: pkgPath ? sha256File(pkgPath) : null,
95
+ cli_entry_hash: binPath ? sha256File(binPath) : null,
96
+ lockfile_hashes: lockfileHashes,
97
+ };
98
+ }
99
+ export function buildHeartbeatAntiAbuseTelemetry(opts = {}) {
100
+ const env = opts.env ?? process.env;
101
+ const fp = opts.envFingerprint ?? bootstrap.captureEnvFingerprint({ env });
102
+ const salt = opts.salt ?? env['EVOLVER_ANTI_ABUSE_SALT'];
103
+ const saltId = opts.saltId ?? env['EVOLVER_ANTI_ABUSE_SALT_ID'] ?? (salt ? 'env' : null);
104
+ const pseudonymStatus = salt ? 'salt_configured' : 'salt_missing';
105
+ const devicePseudonym = hmacPseudonym(fp.device, { salt, purpose: 'device' });
106
+ const workspacePseudonym = hmacPseudonym(opts.workspaceId ?? process.cwd(), { salt, purpose: 'workspace' });
107
+ const unavailableFields = [
108
+ ...(devicePseudonym ? [] : [unavailable('device_pseudonym', 'anti_abuse_salt_missing', 'signed_policy_or_env')]),
109
+ ...(workspacePseudonym ? [] : [unavailable('workspace_pseudonym', 'anti_abuse_salt_missing', 'signed_policy_or_env')]),
110
+ unavailable('client_ip', 'server_observed_required', 'hub_edge'),
111
+ unavailable('asn', 'server_observed_required', 'hub_edge'),
112
+ unavailable('proxy_vpn_tor_datacenter_class', 'server_observed_required', 'hub_edge'),
113
+ unavailable('account_security', 'account_service_required', 'hub_account'),
114
+ unavailable('payout_method_token', 'payments_service_required', 'hub_payments'),
115
+ unavailable('risk_action_case', 'risk_engine_required', 'hub_risk'),
116
+ ];
117
+ return {
118
+ schema_version: ANTI_ABUSE_SCHEMA_VERSION,
119
+ event_type: 'node.heartbeat',
120
+ purpose: 'anti_abuse',
121
+ pii_class: 'medium',
122
+ consent_level: 'default',
123
+ retention_ttl_days: ttlDays(env),
124
+ policy_version: env['EVOLVER_ANTI_ABUSE_POLICY_VERSION'] ?? 'local-default',
125
+ redaction_version: ANTI_ABUSE_REDACTION_VERSION,
126
+ source: opts.source ?? 'evolver-client',
127
+ generated_at: nowIso(opts.now),
128
+ source_confidence: {
129
+ node_identity: 'client_attested',
130
+ device_integrity: 'client_attested',
131
+ task_metrics: 'client_attested',
132
+ network_source: 'server_observed_required',
133
+ account_security: 'server_observed_required',
134
+ payout: 'server_observed_required',
135
+ risk_decision: 'server_observed_required',
136
+ },
137
+ identity: {
138
+ node_id: opts.nodeId ?? null,
139
+ account_id: null,
140
+ org_id: null,
141
+ },
142
+ device: {
143
+ device_pseudonym: devicePseudonym,
144
+ workspace_pseudonym: workspacePseudonym,
145
+ pseudonym_salt_id: saltId,
146
+ pseudonym_status: pseudonymStatus,
147
+ ...(pseudonymStatus === 'salt_missing' ? { pseudonym_warning: 'anti_abuse_salt_missing' } : {}),
148
+ env_fingerprint_key: antiAbuseEnvFingerprintKey(fp),
149
+ platform: fp.platform,
150
+ arch: fp.arch,
151
+ os_release: fp.os_release,
152
+ node_version: fp.node_version,
153
+ // Underlying LLM model, BEST-EFFORT from this node's OWN env (detectModelName() → 'unknown' fallback).
154
+ // Anti-sybil clustering signal (v1 PR #174). Deliberately env-only: the long-lived evolver-proxy daemon and
155
+ // the standalone evolver-llm-proxy are SEPARATE processes by design (the LLM proxy must not require a hub
156
+ // credential), so the heartbeat can't see the per-request model the LLM proxy observes — piping it across
157
+ // would re-couple the two binaries. Consequence: a proxy daemon whose env lacks a model var emits 'unknown'
158
+ // here (honest, never fabricated). To populate it, set EVOLVER_MODEL_NAME in the daemon's environment. The
159
+ // AUTHORITATIVE per-asset model lives on the capsule (threaded from input.model), so 'unknown' on a heartbeat
160
+ // is "this node didn't say", never a contradiction of a capsule's real model.
161
+ model: fp.model,
162
+ evolver_version: opts.evolverVersion ?? null,
163
+ client: '@evomap/evolver-adapter-public',
164
+ client_version: null,
165
+ region: fp.region ?? null,
166
+ container: fp.container,
167
+ },
168
+ integrity: collectIntegrityHashes(opts.packageRoot),
169
+ local_security_boundary: {
170
+ proxy_bind_address_class: 'loopback',
171
+ proxy_port_configured: opts.proxyPortConfigured != null
172
+ ? Boolean(opts.proxyPortConfigured)
173
+ : boolFromEnv(env['EVOMAP_PROXY']) || Boolean(env['EVOMAP_PROXY_PORT']),
174
+ settings_permission_class: settingsPermissionClass(env),
175
+ },
176
+ task_timing: normalizeTaskMetrics(opts.taskMeta),
177
+ unavailable_fields: unavailableFields,
178
+ };
179
+ }
180
+ function containedRealPath(root, candidate) {
181
+ try {
182
+ const realRoot = realpathSync(root);
183
+ const real = realpathSync(candidate);
184
+ return real.startsWith(realRoot + sep) ? real : null;
185
+ }
186
+ catch {
187
+ return null;
188
+ }
189
+ }
190
+ function sha256File(filePath) {
191
+ try {
192
+ const st = statSync(filePath);
193
+ if (!st.isFile() || st.size > MAX_INTEGRITY_FILE_BYTES)
194
+ return null;
195
+ return createHash('sha256').update(readFileSync(filePath)).digest('hex');
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ }
201
+ function safeJsonFile(filePath) {
202
+ try {
203
+ return JSON.parse(readFileSync(filePath, 'utf8'));
204
+ }
205
+ catch {
206
+ return {};
207
+ }
208
+ }
209
+ function nowIso(now) {
210
+ if (now instanceof Date)
211
+ return now.toISOString();
212
+ if (typeof now === 'string' && now.length > 0)
213
+ return now;
214
+ return new Date().toISOString();
215
+ }
216
+ function boolFromEnv(value) {
217
+ const v = String(value ?? '').trim().toLowerCase();
218
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on';
219
+ }
220
+ function ttlDays(env) {
221
+ const raw = Number(env['EVOLVER_ANTI_ABUSE_TTL_DAYS']);
222
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_ANTI_ABUSE_TTL_DAYS;
223
+ }
224
+ function settingsPermissionClass(env) {
225
+ const settingsDir = env['EVOLVER_SETTINGS_DIR']
226
+ ?? env['EVOMAP_DIR']
227
+ ?? env['EVOLVER_HOME']
228
+ ?? env['EVOMAP_HOME']
229
+ ?? join(homedir(), '.evomap');
230
+ return filePermissionClass(join(settingsDir, 'settings.json'));
231
+ }
232
+ function filePermissionClass(filePath) {
233
+ try {
234
+ const st = statSync(filePath);
235
+ if (!st.isFile())
236
+ return 'not_file';
237
+ const mode = st.mode & 0o777;
238
+ if ((mode & 0o077) === 0)
239
+ return 'owner_only';
240
+ if ((mode & 0o007) !== 0)
241
+ return 'world_accessible';
242
+ return 'group_accessible';
243
+ }
244
+ catch {
245
+ return existsSync(filePath) ? 'unreadable' : 'missing';
246
+ }
247
+ }
248
+ function normalizeTaskMetrics(taskMeta) {
249
+ const metrics = taskMeta?.['task_metrics'];
250
+ if (!metrics || typeof metrics !== 'object')
251
+ return null;
252
+ const m = metrics;
253
+ return {
254
+ pending: finiteNumberOrNull(m['pending']),
255
+ claimed: finiteNumberOrNull(m['claimed']),
256
+ completed: finiteNumberOrNull(m['completed']),
257
+ failed: finiteNumberOrNull(m['failed']),
258
+ avg_completion_ms: finiteNumberOrNull(m['avg_completion_ms']),
259
+ };
260
+ }
261
+ function finiteNumberOrNull(value) {
262
+ const n = Number(value);
263
+ return Number.isFinite(n) ? n : null;
264
+ }
265
+ function unavailable(field, reason, expectedSource) {
266
+ return { field, reason, expected_source: expectedSource };
267
+ }
package/dist/atp.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { hub } from '@evomap/evolver-core';
2
+ import { ATP_EXECUTION_MODES, ATP_PROOF_STATUSES, ATP_ROLES, ATP_ROUTING_MODES, ATP_VERIFY_ACTIONS, ATP_VERIFY_MODES } from '@evomap/atp-sdk';
3
+ import { type FetchLike } from './hubFetch.js';
4
+ export { ATP_EXECUTION_MODES, ATP_PROOF_STATUSES, ATP_ROLES, ATP_ROUTING_MODES, ATP_VERIFY_ACTIONS, ATP_VERIFY_MODES, };
5
+ export type AtpVerifyMode = (typeof ATP_VERIFY_MODES)[number];
6
+ export type AtpVerifyAction = (typeof ATP_VERIFY_ACTIONS)[number];
7
+ export type AtpRoutingMode = (typeof ATP_ROUTING_MODES)[number];
8
+ export type AtpProofStatus = (typeof ATP_PROOF_STATUSES)[number];
9
+ export type AtpRole = (typeof ATP_ROLES)[number];
10
+ export type AtpExecutionMode = (typeof ATP_EXECUTION_MODES)[number];
11
+ export interface AtpResult<T = unknown> {
12
+ ok: boolean;
13
+ data?: T;
14
+ error?: string;
15
+ status?: number;
16
+ }
17
+ export interface AtpOrderOptions {
18
+ capabilities: readonly string[];
19
+ budget?: number;
20
+ routingMode?: AtpRoutingMode | string;
21
+ verifyMode?: AtpVerifyMode | string;
22
+ question?: string;
23
+ signals?: readonly string[];
24
+ minReputation?: number;
25
+ }
26
+ export interface AtpListProofsOptions {
27
+ nodeId?: string;
28
+ role?: AtpRole | string;
29
+ status?: AtpProofStatus | string;
30
+ limit?: number;
31
+ }
32
+ export interface AtpClientOptions {
33
+ baseUrl: string;
34
+ auth: hub.AuthProvider;
35
+ fetchFn: FetchLike;
36
+ senderId: () => string | undefined;
37
+ }
38
+ export declare class AtpHubClient {
39
+ private readonly opts;
40
+ private readonly http;
41
+ constructor(opts: AtpClientOptions);
42
+ placeOrder<T = unknown>(opts: AtpOrderOptions): Promise<AtpResult<T>>;
43
+ submitDelivery<T = unknown>(orderId: string, proofPayload?: unknown): Promise<AtpResult<T>>;
44
+ verifyDelivery<T = unknown>(orderId: string, action?: AtpVerifyAction | string): Promise<AtpResult<T>>;
45
+ settleOrder<T = unknown>(orderId: string): Promise<AtpResult<T>>;
46
+ disputeOrder<T = unknown>(orderId: string, reason: string): Promise<AtpResult<T>>;
47
+ getMerchantTier<T = unknown>(nodeId?: string): Promise<AtpResult<T>>;
48
+ getOrderStatus<T = unknown>(orderId: string): Promise<AtpResult<T>>;
49
+ listProofs<T = unknown>(opts?: AtpListProofsOptions): Promise<AtpResult<T>>;
50
+ getAtpPolicy<T = unknown>(): Promise<AtpResult<T>>;
51
+ listMyTasks<T = unknown>(limit?: number, nodeId?: string): Promise<AtpResult<T>>;
52
+ private callResult;
53
+ }
54
+ export declare function normalizeAtpResult<T = unknown>(raw: unknown): AtpResult<T>;
package/dist/atp.js ADDED
@@ -0,0 +1,140 @@
1
+ import { ATP_EXECUTION_MODES, ATP_PROOF_STATUSES, ATP_ROLES, ATP_ROUTING_MODES, ATP_VERIFY_ACTIONS, ATP_VERIFY_MODES, } from '@evomap/atp-sdk';
2
+ import { HubClientError, HubFetch } from './hubFetch.js';
3
+ export { ATP_EXECUTION_MODES, ATP_PROOF_STATUSES, ATP_ROLES, ATP_ROUTING_MODES, ATP_VERIFY_ACTIONS, ATP_VERIFY_MODES, };
4
+ export class AtpHubClient {
5
+ opts;
6
+ http;
7
+ constructor(opts) {
8
+ this.opts = opts;
9
+ this.http = new HubFetch({ baseUrl: opts.baseUrl, auth: opts.auth, fetchFn: opts.fetchFn, senderId: opts.senderId });
10
+ }
11
+ async placeOrder(opts) {
12
+ const capabilities = opts.capabilities.map((s) => String(s).trim()).filter(Boolean);
13
+ if (capabilities.length === 0)
14
+ throw new Error('ATP order requires at least one capability');
15
+ const body = {
16
+ capabilities,
17
+ budget: clampBudget(opts.budget),
18
+ routing_mode: enumValue(opts.routingMode ?? 'fastest', ATP_ROUTING_MODES, 'routingMode'),
19
+ verify_mode: enumValue(opts.verifyMode ?? 'auto', ATP_VERIFY_MODES, 'verifyMode'),
20
+ };
21
+ if (opts.question !== undefined)
22
+ body['question'] = opts.question;
23
+ if (opts.signals !== undefined)
24
+ body['signals'] = opts.signals;
25
+ if (opts.minReputation !== undefined)
26
+ body['min_reputation'] = opts.minReputation;
27
+ return this.callResult('POST', '/a2a/atp/order', body);
28
+ }
29
+ async submitDelivery(orderId, proofPayload = {}) {
30
+ return this.callResult('POST', '/a2a/atp/deliver', {
31
+ order_id: nonEmpty(orderId, 'orderId'),
32
+ proof_payload: proofPayload,
33
+ });
34
+ }
35
+ async verifyDelivery(orderId, action = 'confirm') {
36
+ return this.callResult('POST', '/a2a/atp/verify', {
37
+ order_id: nonEmpty(orderId, 'orderId'),
38
+ action: enumValue(action, ATP_VERIFY_ACTIONS, 'action'),
39
+ });
40
+ }
41
+ async settleOrder(orderId) {
42
+ return this.callResult('POST', '/a2a/atp/settle', { order_id: nonEmpty(orderId, 'orderId') });
43
+ }
44
+ async disputeOrder(orderId, reason) {
45
+ return this.callResult('POST', '/a2a/atp/dispute', {
46
+ order_id: nonEmpty(orderId, 'orderId'),
47
+ reason: nonEmpty(reason, 'reason'),
48
+ });
49
+ }
50
+ async getMerchantTier(nodeId) {
51
+ const nid = nodeId ?? this.opts.senderId();
52
+ return this.callResult('GET', '/a2a/atp/merchant/tier', undefined, nid ? { node_id: nid } : undefined);
53
+ }
54
+ async getOrderStatus(orderId) {
55
+ return this.callResult('GET', `/a2a/atp/order/${encodeURIComponent(nonEmpty(orderId, 'orderId'))}`);
56
+ }
57
+ async listProofs(opts = {}) {
58
+ const query = {
59
+ node_id: opts.nodeId ?? this.opts.senderId(),
60
+ role: opts.role === undefined ? undefined : enumValue(opts.role, ATP_ROLES, 'role'),
61
+ status: opts.status === undefined ? undefined : enumValue(opts.status, ATP_PROOF_STATUSES, 'status'),
62
+ limit: opts.limit === undefined ? undefined : clampLimit(opts.limit),
63
+ };
64
+ return this.callResult('GET', '/a2a/atp/proofs', undefined, query);
65
+ }
66
+ async getAtpPolicy() {
67
+ return this.callResult('GET', '/a2a/atp/policy');
68
+ }
69
+ async listMyTasks(limit, nodeId) {
70
+ const nid = nodeId ?? this.opts.senderId();
71
+ const query = {
72
+ node_id: nid,
73
+ limit: limit === undefined ? undefined : clampLimit(limit),
74
+ };
75
+ return this.callResult('GET', '/a2a/task/my', undefined, query);
76
+ }
77
+ async callResult(method, path, body, query) {
78
+ try {
79
+ const raw = await this.http.call(method, path, body, query);
80
+ return normalizeAtpResult(raw);
81
+ }
82
+ catch (err) {
83
+ if (err instanceof HubClientError) {
84
+ return normalizeAtpError(err.status, err.body);
85
+ }
86
+ throw err;
87
+ }
88
+ }
89
+ }
90
+ export function normalizeAtpResult(raw) {
91
+ const rec = asRecord(raw);
92
+ const data = rec ? (rec['data'] ?? rec['payload'] ?? raw) : raw;
93
+ if (typeof rec?.['ok'] === 'boolean') {
94
+ if (rec['ok'])
95
+ return { ok: true, data: data };
96
+ return { ok: false, data: data, error: extractError(raw, 'atp_error') };
97
+ }
98
+ return { ok: true, data: data };
99
+ }
100
+ function normalizeAtpError(status, raw) {
101
+ const data = asRecord(raw)?.['data'] ?? asRecord(raw)?.['payload'] ?? raw;
102
+ return { ok: false, status, data: data, error: extractError(raw, `hub ${status}`) };
103
+ }
104
+ function asRecord(value) {
105
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
106
+ }
107
+ function extractError(value, fallback) {
108
+ if (typeof value === 'string' && value.trim())
109
+ return value;
110
+ const rec = asRecord(value);
111
+ const payload = asRecord(rec?.['payload']);
112
+ const data = asRecord(rec?.['data']);
113
+ const direct = rec?.['error'] ?? rec?.['message'] ?? payload?.['error'] ?? payload?.['message'] ?? data?.['error'] ?? data?.['message'];
114
+ return typeof direct === 'string' && direct.trim() ? direct : fallback;
115
+ }
116
+ function enumValue(value, allowed, name) {
117
+ const v = String(value);
118
+ if (!allowed.includes(v)) {
119
+ throw new Error(`invalid ATP ${name}: ${v} (expected ${allowed.join('|')})`);
120
+ }
121
+ return v;
122
+ }
123
+ function nonEmpty(value, name) {
124
+ const v = String(value ?? '').trim();
125
+ if (!v)
126
+ throw new Error(`ATP ${name} is required`);
127
+ return v;
128
+ }
129
+ function clampBudget(value) {
130
+ const n = Math.round(Number(value) || 10);
131
+ // budget is a spend cap that goes on the wire: a non-finite value (e.g. `1e400` parses to Infinity)
132
+ // JSON-serializes to `null`, which the hub could read as "no cap". Force a finite positive integer so the
133
+ // wire never carries null/Infinity/NaN as a budget. (An explicit upper MAX_BUDGET ceiling is a separate
134
+ // policy decision for the hub/maintainer; this only guarantees finiteness.)
135
+ return Number.isFinite(n) ? Math.max(1, n) : 10;
136
+ }
137
+ function clampLimit(value) {
138
+ const n = Math.round(Number(value) || 20);
139
+ return Math.max(1, Math.min(100, n));
140
+ }
@@ -0,0 +1,8 @@
1
+ import type { hub } from '@evomap/evolver-core';
2
+ /** 凭证持久化(~/.evomap, 0600). token/keypair 私钥都经此, 文件权限收紧. */
3
+ export declare class CredentialStore {
4
+ private readonly path;
5
+ constructor(path: string);
6
+ load(): hub.Credential | null;
7
+ save(cred: hub.Credential): void;
8
+ }
@@ -0,0 +1,23 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ /** 凭证持久化(~/.evomap, 0600). token/keypair 私钥都经此, 文件权限收紧. */
4
+ export class CredentialStore {
5
+ path;
6
+ constructor(path) {
7
+ this.path = path;
8
+ }
9
+ load() {
10
+ if (!existsSync(this.path))
11
+ return null;
12
+ try {
13
+ return JSON.parse(readFileSync(this.path, 'utf8'));
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ save(cred) {
20
+ mkdirSync(dirname(this.path), { recursive: true });
21
+ writeFileSync(this.path, JSON.stringify(cred), { mode: 0o600 });
22
+ }
23
+ }
@@ -0,0 +1,42 @@
1
+ import type { hub } from '@evomap/evolver-core';
2
+ export interface KeypairProviderOptions {
3
+ credPath: string;
4
+ /** 注册公钥到 hub(注入; M6-6 真 HTTP). 返回 hub 侧凭证 id. */
5
+ registerPublicKey: (publicKeyPem: string) => Promise<{
6
+ credentialId: string;
7
+ }>;
8
+ revokeRemote?: (credentialId: string) => Promise<void>;
9
+ /** Injected clock (test determinism). Default Date.now. */
10
+ now?: () => number;
11
+ /** Injected per-request nonce generator (test determinism). Default 16 random bytes hex. */
12
+ nonceGen?: () => string;
13
+ }
14
+ /**
15
+ * Ed25519 keypair 认证(M6-5, 进阶/审计级). 私钥只存本机 0600, authenticate 对 body 签名(审计可信源).
16
+ * rotate=生成新对+注册+撤旧. 实现 core AuthProvider.
17
+ */
18
+ export declare class KeypairProvider implements hub.AuthProvider {
19
+ private readonly opts;
20
+ readonly kind: "keypair";
21
+ private readonly store;
22
+ constructor(opts: KeypairProviderOptions);
23
+ login(): Promise<hub.Credential>;
24
+ private generate;
25
+ authenticate(req: hub.HttpRequestLike): Promise<hub.SignedRequest>;
26
+ rotate(): Promise<hub.Credential>;
27
+ revoke(credentialId: string): Promise<void>;
28
+ /**
29
+ * Verify a signed request (reference for the hub side). Checks all three: (1) Ed25519 signature over
30
+ * method+path+body+timestamp+nonce, (2) freshness — timestamp within ±maxSkewMs of now, (3) replay —
31
+ * the nonce has not been seen (when a seenNonces set is supplied; it is mutated to record this nonce).
32
+ * Returns false on any failure (bad sig / stale / replayed). The hub keeps seenNonces with a short TTL.
33
+ */
34
+ static verify(publicKeyPem: string, req: hub.HttpRequestLike, signatureB64: string, proof: {
35
+ timestamp: string;
36
+ nonce: string;
37
+ }, opts?: {
38
+ now?: () => number;
39
+ maxSkewMs?: number;
40
+ seenNonces?: Set<string>;
41
+ }): boolean;
42
+ }
@@ -0,0 +1,80 @@
1
+ import { generateKeyPairSync, sign as edSign, verify as edVerify, createPublicKey, createPrivateKey, randomBytes } from 'node:crypto';
2
+ import { CredentialStore } from './credentialStore.js';
3
+ /** 把 PEM 私钥还原成 KeyObject 用于签名. */
4
+ function privFromPem(pem) { return createPrivateKey(pem); }
5
+ /**
6
+ * Ed25519 keypair 认证(M6-5, 进阶/审计级). 私钥只存本机 0600, authenticate 对 body 签名(审计可信源).
7
+ * rotate=生成新对+注册+撤旧. 实现 core AuthProvider.
8
+ */
9
+ export class KeypairProvider {
10
+ opts;
11
+ kind = 'keypair';
12
+ store;
13
+ constructor(opts) {
14
+ this.opts = opts;
15
+ this.store = new CredentialStore(opts.credPath);
16
+ }
17
+ async login() {
18
+ const existing = this.store.load();
19
+ if (existing && 'privateKey' in existing)
20
+ return existing;
21
+ return this.generate();
22
+ }
23
+ async generate() {
24
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
25
+ const pubPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
26
+ const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
27
+ const { credentialId } = await this.opts.registerPublicKey(pubPem);
28
+ const cred = { id: credentialId, kind: 'keypair', publicKey: pubPem, privateKey: privPem };
29
+ this.store.save(cred);
30
+ return cred;
31
+ }
32
+ async authenticate(req) {
33
+ const cred = this.store.load();
34
+ if (!cred || !('privateKey' in cred))
35
+ throw new Error('keypair 未初始化, 先 login()');
36
+ const body = req.body ?? '';
37
+ // Sign method+path+body+timestamp+nonce (#9): timestamp+nonce defeat REPLAY (a captured request can't be
38
+ // re-sent — verify rejects a stale timestamp or a seen nonce), not just substitution. Both travel as headers
39
+ // so the verifier can rebuild the exact signing string.
40
+ const timestamp = String((this.opts.now ?? Date.now)());
41
+ const nonce = (this.opts.nonceGen ?? (() => randomBytes(16).toString('hex')))();
42
+ const signing = `${req.method}\n${req.path}\n${body}\n${timestamp}\n${nonce}`;
43
+ const bodySignature = edSign(null, Buffer.from(signing), privFromPem(cred.privateKey)).toString('base64');
44
+ return {
45
+ headers: { 'x-evomap-key-id': cred.id, 'x-evomap-signature': bodySignature, 'x-evomap-timestamp': timestamp, 'x-evomap-nonce': nonce },
46
+ bodySignature,
47
+ };
48
+ }
49
+ async rotate() {
50
+ const old = this.store.load();
51
+ const fresh = await this.generate();
52
+ if (old && this.opts.revokeRemote)
53
+ await this.opts.revokeRemote(old.id).catch(() => { });
54
+ return fresh;
55
+ }
56
+ async revoke(credentialId) {
57
+ if (this.opts.revokeRemote)
58
+ await this.opts.revokeRemote(credentialId);
59
+ }
60
+ /**
61
+ * Verify a signed request (reference for the hub side). Checks all three: (1) Ed25519 signature over
62
+ * method+path+body+timestamp+nonce, (2) freshness — timestamp within ±maxSkewMs of now, (3) replay —
63
+ * the nonce has not been seen (when a seenNonces set is supplied; it is mutated to record this nonce).
64
+ * Returns false on any failure (bad sig / stale / replayed). The hub keeps seenNonces with a short TTL.
65
+ */
66
+ static verify(publicKeyPem, req, signatureB64, proof, opts = {}) {
67
+ const now = (opts.now ?? Date.now)();
68
+ const maxSkew = opts.maxSkewMs ?? 300_000; // 5 min
69
+ const ts = Number(proof.timestamp);
70
+ if (!Number.isFinite(ts) || Math.abs(now - ts) > maxSkew)
71
+ return false; // stale / future-dated → reject
72
+ if (opts.seenNonces) {
73
+ if (opts.seenNonces.has(proof.nonce))
74
+ return false; // replay → reject
75
+ opts.seenNonces.add(proof.nonce);
76
+ }
77
+ const signing = `${req.method}\n${req.path}\n${req.body ?? ''}\n${proof.timestamp}\n${proof.nonce}`;
78
+ return edVerify(null, Buffer.from(signing), createPublicKey(publicKeyPem), Buffer.from(signatureB64, 'base64'));
79
+ }
80
+ }