@absol-labs/agent 0.8.0 → 0.9.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/discovery/registry.d.ts +110 -305
- package/dist/discovery/registry.d.ts.map +1 -1
- package/dist/discovery/registry.js +141 -318
- package/dist/discovery/registry.js.map +1 -1
- package/dist/frameworks/agentkit.d.ts.map +1 -1
- package/dist/frameworks/agentkit.js +23 -6
- package/dist/frameworks/agentkit.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/wallet/cdp-sdk.d.ts +23 -0
- package/dist/wallet/cdp-sdk.d.ts.map +1 -0
- package/dist/wallet/cdp-sdk.js +27 -0
- package/dist/wallet/cdp-sdk.js.map +1 -0
- package/dist/wallet/provider.d.ts +1 -1
- package/dist/wallet/provider.d.ts.map +1 -1
- package/dist/wallet/provider.js +8 -3
- package/dist/wallet/provider.js.map +1 -1
- package/dist/zktls/reclaim-js-sdk.d.ts +24 -0
- package/dist/zktls/reclaim-js-sdk.d.ts.map +1 -0
- package/dist/zktls/reclaim-js-sdk.js +29 -0
- package/dist/zktls/reclaim-js-sdk.js.map +1 -0
- package/dist/zktls/reclaim.d.ts +14 -2
- package/dist/zktls/reclaim.d.ts.map +1 -1
- package/dist/zktls/reclaim.js +29 -6
- package/dist/zktls/reclaim.js.map +1 -1
- package/dist/zktls/t2-delivery-proof.d.ts +8 -1
- package/dist/zktls/t2-delivery-proof.d.ts.map +1 -1
- package/dist/zktls/t2-delivery-proof.js +22 -6
- package/dist/zktls/t2-delivery-proof.js.map +1 -1
- package/docs/agent-layer.md +150 -0
- package/docs/autonomous-privy-wallet.md +133 -0
- package/docs/crewai.md +70 -0
- package/docs/eliza.md +109 -0
- package/docs/langchain.md +63 -0
- package/docs/mcp-hosted.md +137 -0
- package/docs/privy-embedded-wallet.md +102 -0
- package/docs/quickstart.md +370 -0
- package/docs/threat-model.md +160 -0
- package/package.json +19 -6
- package/src/discovery/registry.ts +242 -414
- package/src/frameworks/agentkit.ts +24 -5
- package/src/index.ts +5 -0
- package/src/wallet/cdp-sdk.ts +33 -0
- package/src/wallet/provider.ts +16 -9
- package/src/zktls/reclaim-js-sdk.ts +50 -0
- package/src/zktls/reclaim.ts +57 -23
- package/src/zktls/t2-delivery-proof.ts +28 -10
|
@@ -1,40 +1,56 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
type
|
|
8
|
-
|
|
2
|
+
RegistryUnavailableError,
|
|
3
|
+
discoverServices as sdkDiscoverServices,
|
|
4
|
+
type DiscoverResult,
|
|
5
|
+
type DiscoveredService,
|
|
6
|
+
type FetchLike,
|
|
7
|
+
type RegistryConfig,
|
|
8
|
+
} from "@absol-labs/sdk";
|
|
9
|
+
import type {
|
|
10
|
+
SignedServiceBinding,
|
|
11
|
+
SignedServiceDescriptor,
|
|
9
12
|
} from "@absol-labs/shared";
|
|
10
|
-
import { isHex } from "viem";
|
|
11
|
-
import { z } from "zod";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Service discovery for the Metrik verified-service marketplace.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* ## One implementation, two shapes
|
|
18
|
+
* The verification and transport logic lives in ONE place: `@absol-labs/sdk`'s
|
|
19
|
+
* {@link sdkDiscoverServices}. This module is a thin, documented adapter over it
|
|
20
|
+
* that projects the SDK's richer {@link DiscoveredService} onto the agent layer's
|
|
21
|
+
* {@link ServiceListing}. Previously both packages shipped their own
|
|
22
|
+
* `discoverServices` with different defaults and OPPOSITE failure semantics —
|
|
23
|
+
* the SDK threw on an unreachable registry while this one silently returned a
|
|
24
|
+
* hardcoded listing. There is now a single trust decision, a single default
|
|
25
|
+
* endpoint, and a single error type ({@link RegistryUnavailableError}) across
|
|
26
|
+
* both packages. Need the SDK's full result (tier, verification summary,
|
|
27
|
+
* `registryAvailable`, `source`, filter notes)? Call
|
|
28
|
+
* {@link discoverServicesDetailed}, which returns it verbatim.
|
|
21
29
|
*
|
|
22
|
-
* ##
|
|
23
|
-
* The
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* 2. `deriveServiceRef(binding)` equals the envelope's own `serviceRef`.
|
|
29
|
-
* `discoverServices` therefore NEVER returns an unverifiable listing.
|
|
30
|
+
* ## Zero configuration
|
|
31
|
+
* The default read source is {@link DEFAULT_METRIK_PUBLIC_REGISTRY_URL} — the
|
|
32
|
+
* oracle's public, credential-free, CORS-enabled listings endpoint. A
|
|
33
|
+
* third-party developer needs no Supabase key and no configuration. (The old
|
|
34
|
+
* default was a raw Supabase PostgREST URL that `401`s for anyone without
|
|
35
|
+
* Metrik's anon key.)
|
|
30
36
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
37
|
+
* ## Untrusted transport
|
|
38
|
+
* The oracle is a TRANSPORT, not an authority. Every row it serves is treated as
|
|
39
|
+
* an untrusted envelope: schema-validated, its `serviceRef` re-derived from the
|
|
40
|
+
* signed content, and its operator EIP-712 signature recovered and matched.
|
|
41
|
+
* Anything that fails is DROPPED, so a compromised registry or oracle can
|
|
42
|
+
* neither inject a listing nor redirect an invocation URL. Rows dropped this way
|
|
43
|
+
* are reported through {@link DiscoverServicesOptions.logger}.
|
|
44
|
+
*
|
|
45
|
+
* ## Loud failure
|
|
46
|
+
* An unreachable registry throws {@link RegistryUnavailableError}. It never
|
|
47
|
+
* degrades to embedded seed data: a hardcoded listing outlives the service it
|
|
48
|
+
* points at and cannot be revoked without a release, and a caller that reads
|
|
49
|
+
* "one service" and invokes it has been actively misled.
|
|
34
50
|
*/
|
|
35
51
|
|
|
36
52
|
/** A verified, hireable service listing. `serviceRef`/`operator`/`publicUrl` are
|
|
37
|
-
* derived from the SIGNED
|
|
53
|
+
* derived from the SIGNED record, never from unsigned envelope columns. */
|
|
38
54
|
export interface ServiceListing {
|
|
39
55
|
readonly serviceRef: `0x${string}`;
|
|
40
56
|
readonly operator: `0x${string}`;
|
|
@@ -44,26 +60,48 @@ export interface ServiceListing {
|
|
|
44
60
|
readonly accessUrl: string;
|
|
45
61
|
/** The schema-validated and cryptographically verified signed record. */
|
|
46
62
|
readonly signed: SignedServiceBinding | SignedServiceDescriptor;
|
|
47
|
-
/** Unsigned informational category from the
|
|
63
|
+
/** Unsigned informational category from the registry row (may be absent). */
|
|
48
64
|
readonly category: string | null;
|
|
49
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* @deprecated No longer populated. The signed-record schema is strict, and the
|
|
67
|
+
* public discovery endpoint carries no unsigned resolver hints. Retained so
|
|
68
|
+
* existing code compiles.
|
|
69
|
+
*/
|
|
50
70
|
readonly targetMetadata?: Record<string, string>;
|
|
51
71
|
}
|
|
52
72
|
|
|
53
73
|
export interface DiscoverServicesOptions {
|
|
54
|
-
/**
|
|
55
|
-
*
|
|
74
|
+
/**
|
|
75
|
+
* Registry endpoint. Defaults to {@link DEFAULT_METRIK_PUBLIC_REGISTRY_URL}
|
|
76
|
+
* (env override: `METRIK_AGENT_REGISTRY_URL`).
|
|
77
|
+
*
|
|
78
|
+
* With no {@link DiscoverServicesOptions.apiKey} this is read as a plain
|
|
79
|
+
* credential-free `GET` against exactly this URL.
|
|
80
|
+
*
|
|
81
|
+
* With an `apiKey` it is read as Supabase/PostgREST, and this must be either a
|
|
82
|
+
* project base URL (`https://<ref>.supabase.co`, to which `/rest/v1/listings`
|
|
83
|
+
* is appended) or a URL already ending in `/rest/v1` or `/rest/v1/listings`.
|
|
84
|
+
* Any query string is stripped — the SDK appends its own `select`/`order`.
|
|
85
|
+
* Supplying an `apiKey` without setting this explicitly is a configuration
|
|
86
|
+
* error and throws: the public default is not a PostgREST base, and silently
|
|
87
|
+
* appending to it would produce a 404 on every read.
|
|
88
|
+
*/
|
|
56
89
|
readonly registryUrl?: string;
|
|
57
|
-
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
|
|
90
|
+
/**
|
|
91
|
+
* @deprecated No-op. Discovery no longer ships an embedded seed listing: an
|
|
92
|
+
* unreachable registry throws {@link RegistryUnavailableError} instead of
|
|
93
|
+
* quietly serving stale local data.
|
|
94
|
+
*/
|
|
62
95
|
readonly includeDemoSeed?: boolean;
|
|
63
|
-
/** PostgREST apikey (Supabase anon key). Env override:
|
|
64
|
-
*
|
|
96
|
+
/** PostgREST apikey (Supabase anon key) for an authenticated read. Env override:
|
|
97
|
+
* `METRIK_AGENT_REGISTRY_APIKEY`. Not required — the default endpoint is public. */
|
|
65
98
|
readonly apiKey?: string;
|
|
66
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* Optional filter on the unsigned `category` column (case-insensitive).
|
|
101
|
+
* The public discovery endpoint serves signed records only and carries no
|
|
102
|
+
* category column, so this filter matches nothing there; use it against an
|
|
103
|
+
* authenticated PostgREST read.
|
|
104
|
+
*/
|
|
67
105
|
readonly category?: string;
|
|
68
106
|
/** Optional cap on the number of verified listings returned. */
|
|
69
107
|
readonly limit?: number;
|
|
@@ -71,439 +109,229 @@ export interface DiscoverServicesOptions {
|
|
|
71
109
|
readonly fetch?: typeof fetch;
|
|
72
110
|
/** Request timeout in milliseconds. Defaults to 5000. */
|
|
73
111
|
readonly timeoutMs?: number;
|
|
74
|
-
/** Warning sink for dropped rows /
|
|
112
|
+
/** Warning sink for dropped rows / filter notes. Defaults to `console`. */
|
|
75
113
|
readonly logger?: { warn: (...args: unknown[]) => void };
|
|
76
114
|
}
|
|
77
115
|
|
|
78
116
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
117
|
+
* The oracle's public, credential-free, CORS-enabled discovery endpoint. Serves
|
|
118
|
+
* operator-signed envelopes verbatim so they can be verified client-side, and
|
|
119
|
+
* answers an unreachable upstream with `503` and NO `listings` key — precisely so
|
|
120
|
+
* it cannot be misread as "zero services".
|
|
81
121
|
*/
|
|
82
|
-
export const
|
|
83
|
-
"https://
|
|
84
|
-
|
|
85
|
-
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
122
|
+
export const DEFAULT_METRIK_PUBLIC_REGISTRY_URL =
|
|
123
|
+
"https://oracle.metrik.live/listings";
|
|
86
124
|
|
|
87
125
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
126
|
+
* @deprecated Renamed to {@link DEFAULT_METRIK_PUBLIC_REGISTRY_URL}. The value
|
|
127
|
+
* changed in 0.9.0: it used to be a raw Supabase PostgREST URL that `401`s
|
|
128
|
+
* without Metrik's anon key.
|
|
90
129
|
*/
|
|
91
|
-
export const
|
|
92
|
-
"0x79e57358e98a35273c1cd9d5f1bf9ae1e0af8705db432eb706459dc4f88b6fe0" as const;
|
|
93
|
-
/** @deprecated Use {@link LIVE_DATA_SEED_SERVICE_REF}. */
|
|
94
|
-
export const DEMO_SEED_SERVICE_REF = LIVE_DATA_SEED_SERVICE_REF;
|
|
130
|
+
export const DEFAULT_METRIK_REGISTRY_URL = DEFAULT_METRIK_PUBLIC_REGISTRY_URL;
|
|
95
131
|
|
|
96
132
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* ServiceBinding — safe to ship in source. It is **NOT** a trust bypass: when seeded
|
|
102
|
-
* it is fed through the exact same fail-closed {@link verifyRow} path as any registry
|
|
103
|
-
* row, so it only ever surfaces if BOTH its EIP-712 signature recovers to
|
|
104
|
-
* `binding.operator` AND `deriveServiceRef(binding)` equals its `serviceRef`. Tamper
|
|
105
|
-
* with any field here and the seed silently drops itself, identically to a bad
|
|
106
|
-
* registry row. The `category` column is unsigned informational metadata only.
|
|
133
|
+
* @deprecated Removed in 0.9.0. The embedded seed listing is gone: it was
|
|
134
|
+
* unrevocable without a release, and masking an unreachable registry with stale
|
|
135
|
+
* local data is exactly the bug this package now refuses to ship. Kept as an
|
|
136
|
+
* empty object so existing imports still compile.
|
|
107
137
|
*/
|
|
108
|
-
export const LIVE_DATA_SEED_LISTING = {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
descriptor: {
|
|
116
|
-
version: 3,
|
|
117
|
-
operator: "0xB3e162711920dFD8933609019Dc35C73cC1a09F0",
|
|
118
|
-
publicUrl: "https://livedata.137.23.50.249.sslip.io",
|
|
119
|
-
issuedAt: 1_787_772_057,
|
|
120
|
-
interface: {
|
|
121
|
-
baseUrl: "https://livedata.137.23.50.249.sslip.io",
|
|
122
|
-
healthPath: "/health",
|
|
123
|
-
endpoints: [
|
|
124
|
-
{
|
|
125
|
-
name: "health",
|
|
126
|
-
path: "/health",
|
|
127
|
-
method: "GET",
|
|
128
|
-
description: "Liveness probe.",
|
|
129
|
-
},
|
|
130
|
-
{
|
|
131
|
-
name: "readiness",
|
|
132
|
-
path: "/readiness",
|
|
133
|
-
method: "GET",
|
|
134
|
-
description:
|
|
135
|
-
"Non-value dependency readiness backed by the same real BTC cache policy.",
|
|
136
|
-
},
|
|
137
|
-
{
|
|
138
|
-
name: "price",
|
|
139
|
-
path: "/price",
|
|
140
|
-
method: "GET",
|
|
141
|
-
description:
|
|
142
|
-
"Real live crypto spot price (?symbol=BTC|ETH|SOL) from CoinGecko's public API.",
|
|
143
|
-
},
|
|
144
|
-
],
|
|
145
|
-
authModel: "caller-auth",
|
|
146
|
-
example: {
|
|
147
|
-
endpoint: "price",
|
|
148
|
-
request: { symbol: "BTC" },
|
|
149
|
-
response: { symbol: "BTC", source: "coingecko-public-api" },
|
|
150
|
-
description:
|
|
151
|
-
"Real, nondeterministic live price — no re-execution guarantee.",
|
|
152
|
-
},
|
|
153
|
-
expectedStatuses: [200],
|
|
154
|
-
},
|
|
155
|
-
verification: {
|
|
156
|
-
canaries: [
|
|
157
|
-
{
|
|
158
|
-
path: "/readiness",
|
|
159
|
-
method: "GET",
|
|
160
|
-
matcher: {
|
|
161
|
-
type: "json-field-equals",
|
|
162
|
-
field: "source",
|
|
163
|
-
expected: "coingecko-public-api",
|
|
164
|
-
},
|
|
165
|
-
nonce: {
|
|
166
|
-
inject: { in: "query", name: "nonce" },
|
|
167
|
-
echo: { from: "json-path", path: "nonce" },
|
|
168
|
-
},
|
|
169
|
-
},
|
|
170
|
-
{
|
|
171
|
-
path: "/canary",
|
|
172
|
-
method: "GET",
|
|
173
|
-
matcher: {
|
|
174
|
-
type: "includes",
|
|
175
|
-
expected: "metrik-canary:live-data:",
|
|
176
|
-
},
|
|
177
|
-
nonce: {
|
|
178
|
-
inject: { in: "query", name: "nonce" },
|
|
179
|
-
echo: { from: "json-path", path: "nonce" },
|
|
180
|
-
},
|
|
181
|
-
},
|
|
182
|
-
],
|
|
183
|
-
schema: {
|
|
184
|
-
path: "/readiness?nonce=metrik-schema",
|
|
185
|
-
required: [
|
|
186
|
-
{ pointer: "ready", type: "boolean" },
|
|
187
|
-
{ pointer: "source", type: "string", nonEmpty: true },
|
|
188
|
-
{ pointer: "fetchedAt", type: "string", nonEmpty: true },
|
|
189
|
-
{ pointer: "priceAgeMs", type: "number" },
|
|
190
|
-
{ pointer: "freshness", type: "string", nonEmpty: true },
|
|
191
|
-
],
|
|
192
|
-
},
|
|
193
|
-
sla: { maxLatencyMs: 5000, freshnessSeconds: 60 },
|
|
194
|
-
},
|
|
195
|
-
commercial: {
|
|
196
|
-
minRatePerSecond: "100",
|
|
197
|
-
maxRatePerSecond: "10000",
|
|
198
|
-
currency: "USDC",
|
|
199
|
-
slaDescription:
|
|
200
|
-
"Failed or unproven intervals do not advance verified cumulative entitlement; the stream remains active until buyer close or expiry.",
|
|
201
|
-
refundPolicy:
|
|
202
|
-
"The buyer reclaims unspent escrow using the latest checkpoint after close/expiry, or the unverified escape path after its grace window.",
|
|
203
|
-
},
|
|
204
|
-
access: "gated",
|
|
205
|
-
callerAuth: {
|
|
206
|
-
mechanism: "on-chain",
|
|
207
|
-
role: "buyer",
|
|
208
|
-
accessUrl: "https://gateway.137.23.50.249.sslip.io",
|
|
209
|
-
},
|
|
210
|
-
},
|
|
211
|
-
signature:
|
|
212
|
-
"0x2f4d2784eb40b1542517bc19f12534fb2e97da4ade5a3ff56193fcfb97fbef921309f77af3445e336cb07a4379011b080bba700033a098f672b0b2f096197f161b",
|
|
213
|
-
},
|
|
214
|
-
} as const;
|
|
215
|
-
/** @deprecated Use {@link LIVE_DATA_SEED_LISTING}. */
|
|
216
|
-
export const DEMO_SEED_LISTING = LIVE_DATA_SEED_LISTING;
|
|
217
|
-
|
|
218
|
-
const signature65HexSchema = z
|
|
219
|
-
.string()
|
|
220
|
-
.refine(
|
|
221
|
-
(value) => isHex(value, { strict: true }) && value.length === 132,
|
|
222
|
-
"must be a 65-byte hex signature",
|
|
223
|
-
);
|
|
138
|
+
export const LIVE_DATA_SEED_LISTING: Record<string, never> = Object.freeze({});
|
|
139
|
+
/** @deprecated Removed in 0.9.0. See {@link LIVE_DATA_SEED_LISTING}. */
|
|
140
|
+
export const DEMO_SEED_LISTING: Record<string, never> = LIVE_DATA_SEED_LISTING;
|
|
141
|
+
/** @deprecated Removed in 0.9.0. There is no seeded listing to identify. */
|
|
142
|
+
export const LIVE_DATA_SEED_SERVICE_REF: null = null;
|
|
143
|
+
/** @deprecated Removed in 0.9.0. See {@link LIVE_DATA_SEED_SERVICE_REF}. */
|
|
144
|
+
export const DEMO_SEED_SERVICE_REF: null = null;
|
|
224
145
|
|
|
225
|
-
const
|
|
226
|
-
.string()
|
|
227
|
-
.refine(
|
|
228
|
-
(value) => isHex(value, { strict: true }) && value.length === 66,
|
|
229
|
-
"must be bytes32 hex",
|
|
230
|
-
);
|
|
146
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
231
147
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const signedBindingRecordSchema = z.object({
|
|
235
|
-
serviceRef: bytes32HexSchema,
|
|
236
|
-
binding: serviceBindingSchema,
|
|
237
|
-
signature: signature65HexSchema,
|
|
238
|
-
targetMetadata: z.record(z.string()).optional(),
|
|
239
|
-
});
|
|
148
|
+
export { RegistryUnavailableError };
|
|
149
|
+
export type { DiscoverResult, DiscoveredService };
|
|
240
150
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
151
|
+
/**
|
|
152
|
+
* Discover verified services and return the SDK's full {@link DiscoverResult}
|
|
153
|
+
* (typed listings plus `registryAvailable`, `source`, `matched`, `filteredOut`
|
|
154
|
+
* and an explanatory `note` when a non-empty source was filtered to nothing).
|
|
155
|
+
*
|
|
156
|
+
* Throws {@link RegistryUnavailableError} when the configured read source cannot
|
|
157
|
+
* be read at all.
|
|
158
|
+
*/
|
|
159
|
+
export async function discoverServicesDetailed(
|
|
160
|
+
opts: DiscoverServicesOptions = {},
|
|
161
|
+
): Promise<DiscoverResult> {
|
|
162
|
+
const logger = opts.logger ?? console;
|
|
163
|
+
const { config, rowCounter } = buildRegistryConfig(opts, logger);
|
|
164
|
+
const result = await sdkDiscoverServices(config, {
|
|
165
|
+
...(opts.category === undefined ? {} : { category: opts.category }),
|
|
166
|
+
});
|
|
247
167
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
168
|
+
// The SDK drops any row that fails schema / serviceRef / signature
|
|
169
|
+
// verification. Surface that as a warning: a silently shrinking result set is
|
|
170
|
+
// how a tampering registry stays invisible.
|
|
171
|
+
const dropped = rowCounter.rows - result.services.length - result.filteredOut;
|
|
172
|
+
if (dropped > 0) {
|
|
173
|
+
logger.warn(
|
|
174
|
+
`metrik discovery: ${dropped} of ${rowCounter.rows} registry row(s) dropped by fail-closed verification (bad schema, serviceRef mismatch, or signature that does not recover to the operator)`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (result.note !== undefined) {
|
|
178
|
+
logger.warn(`metrik discovery: ${result.note}`);
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
262
182
|
|
|
263
183
|
/**
|
|
264
184
|
* Discover verified services from the Metrik listings registry.
|
|
265
185
|
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
186
|
+
* Zero configuration: reads {@link DEFAULT_METRIK_PUBLIC_REGISTRY_URL}, the
|
|
187
|
+
* oracle's credential-free public endpoint.
|
|
188
|
+
*
|
|
189
|
+
* Fail-closed: every row is schema-validated, its `serviceRef` re-derived and
|
|
190
|
+
* its operator signature recovered; anything that fails is dropped and logged,
|
|
191
|
+
* so an unverifiable listing is never returned.
|
|
192
|
+
*
|
|
193
|
+
* LOUD, not silent: an unreachable / erroring / non-200 registry throws
|
|
194
|
+
* {@link RegistryUnavailableError} rather than returning `[]` or embedded seed
|
|
195
|
+
* data. Catch it if your caller can genuinely handle "the marketplace was never
|
|
196
|
+
* reached" — but do not confuse it with an empty marketplace.
|
|
270
197
|
*/
|
|
271
198
|
export async function discoverServices(
|
|
272
199
|
opts: DiscoverServicesOptions = {},
|
|
273
200
|
): Promise<ServiceListing[]> {
|
|
274
201
|
const logger = opts.logger ?? console;
|
|
275
|
-
|
|
276
|
-
opts.registryUrl ??
|
|
277
|
-
process.env.METRIK_AGENT_REGISTRY_URL ??
|
|
278
|
-
DEFAULT_METRIK_REGISTRY_URL;
|
|
279
|
-
const apiKey = opts.apiKey ?? process.env.METRIK_AGENT_REGISTRY_APIKEY;
|
|
280
|
-
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
281
|
-
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
282
|
-
const includeDemoSeed = opts.includeDemoSeed ?? true;
|
|
283
|
-
|
|
284
|
-
// `registryUnavailable` = the registry could not be queried at all (no fetch /
|
|
285
|
-
// network error / non-200). It never throws into the hire flow; instead it
|
|
286
|
-
// degrades to the built-in verified live-data seed (when enabled) so a cold user
|
|
287
|
-
// still discovers a hireable service.
|
|
288
|
-
let rows: unknown[] = [];
|
|
289
|
-
let registryUnavailable = false;
|
|
290
|
-
if (typeof fetchImpl !== "function") {
|
|
202
|
+
if (opts.includeDemoSeed !== undefined) {
|
|
291
203
|
logger.warn(
|
|
292
|
-
"metrik discovery: no
|
|
204
|
+
"metrik discovery: `includeDemoSeed` is a deprecated no-op — the embedded seed listing was removed in 0.9.0; an unreachable registry now throws RegistryUnavailableError",
|
|
293
205
|
);
|
|
294
|
-
registryUnavailable = true;
|
|
295
|
-
} else {
|
|
296
|
-
try {
|
|
297
|
-
rows = await fetchListingRows(fetchImpl, registryUrl, apiKey, timeoutMs);
|
|
298
|
-
} catch (error) {
|
|
299
|
-
// Fail-safe / buyer-favouring: a down, unreachable, or 401 registry (the
|
|
300
|
-
// Supabase default rejects credential-free reads) must never throw into the
|
|
301
|
-
// agent's hire flow — it degrades to the live-data seed.
|
|
302
|
-
logger.warn(
|
|
303
|
-
`metrik discovery: listings registry unavailable (${errorMessage(error)}); falling back to live-data seed only`,
|
|
304
|
-
);
|
|
305
|
-
registryUnavailable = true;
|
|
306
|
-
}
|
|
307
206
|
}
|
|
308
207
|
|
|
208
|
+
const result = await discoverServicesDetailed(opts);
|
|
309
209
|
const listings: ServiceListing[] = [];
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const listing = await verifyRow(row, index, logger);
|
|
210
|
+
for (const service of result.services) {
|
|
211
|
+
const listing = toServiceListing(service, logger);
|
|
313
212
|
if (listing === null) {
|
|
314
213
|
continue;
|
|
315
214
|
}
|
|
316
|
-
if (eqHex(listing.serviceRef, DEMO_SEED_SERVICE_REF)) {
|
|
317
|
-
seedAlreadyPresent = true;
|
|
318
|
-
}
|
|
319
|
-
if (opts.category !== undefined && listing.category !== opts.category) {
|
|
320
|
-
continue;
|
|
321
|
-
}
|
|
322
215
|
listings.push(listing);
|
|
323
216
|
if (opts.limit !== undefined && listings.length >= opts.limit) {
|
|
324
|
-
|
|
325
|
-
return listings;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Append the built-in verified live-data listing when the registry did not already
|
|
330
|
-
// surface it. Runs through the SAME fail-closed verifyRow path, so it is
|
|
331
|
-
// verified exactly like a registry row and can never be a trust bypass.
|
|
332
|
-
if (includeDemoSeed && !seedAlreadyPresent) {
|
|
333
|
-
const seed = await verifyRow(DEMO_SEED_LISTING, "live-data-seed", logger);
|
|
334
|
-
if (seed === null) {
|
|
335
|
-
// Defensive: only reachable if the embedded constant were corrupted.
|
|
336
|
-
logger.warn(
|
|
337
|
-
"metrik discovery: built-in live-data seed failed fail-closed verification; not seeding",
|
|
338
|
-
);
|
|
339
|
-
} else if (opts.category === undefined || seed.category === opts.category) {
|
|
340
|
-
logger.warn(
|
|
341
|
-
registryUnavailable
|
|
342
|
-
? "metrik discovery: registry unavailable — serving the built-in verified live-data listing"
|
|
343
|
-
: "metrik discovery: registry did not include live data — appending the built-in verified listing",
|
|
344
|
-
);
|
|
345
|
-
listings.push(seed);
|
|
217
|
+
break;
|
|
346
218
|
}
|
|
347
219
|
}
|
|
348
|
-
|
|
349
220
|
return listings;
|
|
350
221
|
}
|
|
351
222
|
|
|
352
|
-
async function fetchListingRows(
|
|
353
|
-
fetchImpl: typeof fetch,
|
|
354
|
-
url: string,
|
|
355
|
-
apiKey: string | undefined,
|
|
356
|
-
timeoutMs: number,
|
|
357
|
-
): Promise<unknown[]> {
|
|
358
|
-
const controller = new AbortController();
|
|
359
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
360
|
-
try {
|
|
361
|
-
const headers: Record<string, string> = {};
|
|
362
|
-
if (apiKey !== undefined && apiKey.length > 0) {
|
|
363
|
-
headers.apikey = apiKey;
|
|
364
|
-
headers.Authorization = `Bearer ${apiKey}`;
|
|
365
|
-
}
|
|
366
|
-
const response = await fetchImpl(url, {
|
|
367
|
-
signal: controller.signal,
|
|
368
|
-
...(Object.keys(headers).length === 0 ? {} : { headers }),
|
|
369
|
-
});
|
|
370
|
-
if (!response.ok) {
|
|
371
|
-
throw new Error(
|
|
372
|
-
`listings registry responded with status ${response.status}`,
|
|
373
|
-
);
|
|
374
|
-
}
|
|
375
|
-
const body: unknown = await response.json();
|
|
376
|
-
if (!Array.isArray(body)) {
|
|
377
|
-
throw new Error("listings registry response was not a JSON array");
|
|
378
|
-
}
|
|
379
|
-
return body;
|
|
380
|
-
} finally {
|
|
381
|
-
clearTimeout(timeout);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
223
|
/**
|
|
386
|
-
*
|
|
387
|
-
*
|
|
224
|
+
* Project one verified {@link DiscoveredService} onto a {@link ServiceListing},
|
|
225
|
+
* or `null` when it carries no signed invocation origin (a legacy record that a
|
|
226
|
+
* caller could otherwise invoke against an unsigned URL).
|
|
388
227
|
*/
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
index: number | string,
|
|
228
|
+
function toServiceListing(
|
|
229
|
+
service: DiscoveredService,
|
|
392
230
|
logger: { warn: (...args: unknown[]) => void },
|
|
393
|
-
):
|
|
394
|
-
|
|
395
|
-
if (!parsed.success) {
|
|
231
|
+
): ServiceListing | null {
|
|
232
|
+
if (service.accessUrl === null) {
|
|
396
233
|
logger.warn(
|
|
397
|
-
`metrik discovery:
|
|
398
|
-
);
|
|
399
|
-
return null;
|
|
400
|
-
}
|
|
401
|
-
const { signed_binding: record, category } = parsed.data;
|
|
402
|
-
|
|
403
|
-
const signedIdentity =
|
|
404
|
-
"descriptor" in record
|
|
405
|
-
? {
|
|
406
|
-
operator: record.descriptor.operator,
|
|
407
|
-
publicUrl: record.descriptor.publicUrl,
|
|
408
|
-
}
|
|
409
|
-
: {
|
|
410
|
-
operator: record.binding.operator,
|
|
411
|
-
publicUrl: record.binding.publicUrl,
|
|
412
|
-
};
|
|
413
|
-
if (
|
|
414
|
-
(parsed.data.service_ref !== undefined &&
|
|
415
|
-
!eqHex(parsed.data.service_ref, record.serviceRef)) ||
|
|
416
|
-
(parsed.data.operator !== undefined &&
|
|
417
|
-
!eqHex(parsed.data.operator, signedIdentity.operator)) ||
|
|
418
|
-
(parsed.data.public_url !== undefined &&
|
|
419
|
-
parsed.data.public_url !== signedIdentity.publicUrl)
|
|
420
|
-
) {
|
|
421
|
-
logger.warn(
|
|
422
|
-
`metrik discovery: row ${index} dropped — flat identity columns disagree with signed record`,
|
|
234
|
+
`metrik discovery: listing ${service.serviceRef} dropped — no signed invocation URL`,
|
|
423
235
|
);
|
|
424
236
|
return null;
|
|
425
237
|
}
|
|
238
|
+
return {
|
|
239
|
+
serviceRef: service.serviceRef,
|
|
240
|
+
operator: service.operator,
|
|
241
|
+
publicUrl: service.publicUrl,
|
|
242
|
+
access: service.access,
|
|
243
|
+
accessUrl: service.accessUrl,
|
|
244
|
+
signed: service.signed,
|
|
245
|
+
category: service.category,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
426
248
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
"descriptor" in record
|
|
431
|
-
? await recoverServiceDescriptorSigner(
|
|
432
|
-
record.descriptor,
|
|
433
|
-
record.signature as `0x${string}`,
|
|
434
|
-
)
|
|
435
|
-
: await recoverServiceBindingSigner(
|
|
436
|
-
record.binding,
|
|
437
|
-
record.signature as `0x${string}`,
|
|
438
|
-
)
|
|
439
|
-
) as `0x${string}` | null;
|
|
440
|
-
} catch (error) {
|
|
441
|
-
logger.warn(
|
|
442
|
-
`metrik discovery: row ${index} dropped — signature recovery threw (${errorMessage(error)})`,
|
|
443
|
-
);
|
|
444
|
-
return null;
|
|
445
|
-
}
|
|
446
|
-
if (signer === null || !eqHex(signer, signedIdentity.operator)) {
|
|
447
|
-
logger.warn(
|
|
448
|
-
`metrik discovery: row ${index} dropped — signature does not recover to operator (recovered ${signer ?? "null"})`,
|
|
449
|
-
);
|
|
450
|
-
return null;
|
|
451
|
-
}
|
|
249
|
+
interface RowCounter {
|
|
250
|
+
rows: number;
|
|
251
|
+
}
|
|
452
252
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
253
|
+
/**
|
|
254
|
+
* Translate the agent-layer options into an SDK {@link RegistryConfig}.
|
|
255
|
+
*
|
|
256
|
+
* With an api key the read is authenticated PostgREST (the SDK appends its own
|
|
257
|
+
* `select`/`order`, so a query string on the supplied URL is stripped);
|
|
258
|
+
* otherwise it is the credential-free public `GET`.
|
|
259
|
+
*/
|
|
260
|
+
function buildRegistryConfig(
|
|
261
|
+
opts: DiscoverServicesOptions,
|
|
262
|
+
logger: { warn: (...args: unknown[]) => void },
|
|
263
|
+
): { config: RegistryConfig; rowCounter: RowCounter } {
|
|
264
|
+
const configuredUrl =
|
|
265
|
+
opts.registryUrl ?? process.env.METRIK_AGENT_REGISTRY_URL;
|
|
266
|
+
const registryUrl = configuredUrl ?? DEFAULT_METRIK_PUBLIC_REGISTRY_URL;
|
|
267
|
+
const apiKey = opts.apiKey ?? process.env.METRIK_AGENT_REGISTRY_APIKEY;
|
|
462
268
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
? record.descriptor.callerAuth?.accessUrl
|
|
469
|
-
: (record.descriptor.interface?.baseUrl ?? record.descriptor.publicUrl)
|
|
470
|
-
: record.binding.publicUrl;
|
|
269
|
+
// An apiKey switches the read to PostgREST, where the SDK appends
|
|
270
|
+
// `/rest/v1/<table>` to the configured base. The public default is NOT a
|
|
271
|
+
// PostgREST base, so appending to it yields `.../listings/rest/v1/listings`
|
|
272
|
+
// and 404s on every read. Fail loudly at the call site instead of shipping a
|
|
273
|
+
// discovery path that can never succeed.
|
|
471
274
|
if (
|
|
472
|
-
|
|
473
|
-
|
|
275
|
+
apiKey !== undefined &&
|
|
276
|
+
apiKey.length > 0 &&
|
|
277
|
+
(configuredUrl === undefined || configuredUrl.length === 0)
|
|
474
278
|
) {
|
|
475
|
-
|
|
476
|
-
|
|
279
|
+
throw new Error(
|
|
280
|
+
"metrik discovery: an apiKey was supplied without a registryUrl. An authenticated read needs a Supabase project URL (https://<ref>.supabase.co) or a URL ending in /rest/v1[/listings] — set `registryUrl` or METRIK_AGENT_REGISTRY_URL. Omit the apiKey to use the credential-free public endpoint instead.",
|
|
477
281
|
);
|
|
478
|
-
return null;
|
|
479
282
|
}
|
|
283
|
+
const rowCounter: RowCounter = { rows: 0 };
|
|
284
|
+
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
480
285
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
...(record.targetMetadata === undefined
|
|
490
|
-
? {}
|
|
491
|
-
: { targetMetadata: record.targetMetadata }),
|
|
286
|
+
const config: RegistryConfig = {
|
|
287
|
+
...(apiKey !== undefined && apiKey.length > 0
|
|
288
|
+
? { url: stripQuery(registryUrl), key: apiKey }
|
|
289
|
+
: { publicUrl: registryUrl }),
|
|
290
|
+
...(typeof fetchImpl === "function"
|
|
291
|
+
? { fetch: countingFetch(fetchImpl, rowCounter, logger) }
|
|
292
|
+
: {}),
|
|
293
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
492
294
|
};
|
|
295
|
+
return { config, rowCounter };
|
|
493
296
|
}
|
|
494
297
|
|
|
495
|
-
function
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
} catch {
|
|
499
|
-
return false;
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
function eqHex(a: string, b: string): boolean {
|
|
504
|
-
return a.toLowerCase() === b.toLowerCase();
|
|
298
|
+
function stripQuery(url: string): string {
|
|
299
|
+
const queryStart = url.indexOf("?");
|
|
300
|
+
return queryStart === -1 ? url : url.slice(0, queryStart);
|
|
505
301
|
}
|
|
506
302
|
|
|
507
|
-
|
|
508
|
-
|
|
303
|
+
/**
|
|
304
|
+
* Wrap `fetch` to count the rows the registry actually served, so the number
|
|
305
|
+
* dropped by fail-closed verification can be reported. Purely observational: the
|
|
306
|
+
* response is passed through untouched (the body is read from a clone), and any
|
|
307
|
+
* failure to inspect it leaves the count at 0 rather than failing discovery.
|
|
308
|
+
*/
|
|
309
|
+
function countingFetch(
|
|
310
|
+
fetchImpl: typeof fetch,
|
|
311
|
+
rowCounter: RowCounter,
|
|
312
|
+
logger: { warn: (...args: unknown[]) => void },
|
|
313
|
+
): FetchLike {
|
|
314
|
+
return async (input, init) => {
|
|
315
|
+
const response = await fetchImpl(input, init);
|
|
316
|
+
if (!response.ok) {
|
|
317
|
+
return response;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
const payload: unknown = await response.clone().json();
|
|
321
|
+
if (Array.isArray(payload)) {
|
|
322
|
+
rowCounter.rows += payload.length;
|
|
323
|
+
} else if (
|
|
324
|
+
typeof payload === "object" &&
|
|
325
|
+
payload !== null &&
|
|
326
|
+
Array.isArray((payload as { listings?: unknown }).listings)
|
|
327
|
+
) {
|
|
328
|
+
rowCounter.rows += (payload as { listings: unknown[] }).listings.length;
|
|
329
|
+
}
|
|
330
|
+
} catch {
|
|
331
|
+
logger.warn(
|
|
332
|
+
"metrik discovery: could not inspect the registry payload for a dropped-row count",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
return response;
|
|
336
|
+
};
|
|
509
337
|
}
|