@absol-labs/agent 0.7.3 → 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.
Files changed (80) hide show
  1. package/README.md +19 -4
  2. package/dist/discovery/registry.d.ts +110 -305
  3. package/dist/discovery/registry.d.ts.map +1 -1
  4. package/dist/discovery/registry.js +141 -318
  5. package/dist/discovery/registry.js.map +1 -1
  6. package/dist/frameworks/agentkit.d.ts.map +1 -1
  7. package/dist/frameworks/agentkit.js +23 -6
  8. package/dist/frameworks/agentkit.js.map +1 -1
  9. package/dist/gateway/caller-auth-gateway.d.ts +103 -2
  10. package/dist/gateway/caller-auth-gateway.d.ts.map +1 -1
  11. package/dist/gateway/caller-auth-gateway.js +176 -19
  12. package/dist/gateway/caller-auth-gateway.js.map +1 -1
  13. package/dist/gateway/http-server.d.ts +12 -0
  14. package/dist/gateway/http-server.d.ts.map +1 -1
  15. package/dist/gateway/http-server.js +45 -1
  16. package/dist/gateway/http-server.js.map +1 -1
  17. package/dist/index.d.ts +3 -3
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -3
  20. package/dist/index.js.map +1 -1
  21. package/dist/wallet/autonomous-wallet-store.d.ts +123 -0
  22. package/dist/wallet/autonomous-wallet-store.d.ts.map +1 -0
  23. package/dist/wallet/autonomous-wallet-store.js +318 -0
  24. package/dist/wallet/autonomous-wallet-store.js.map +1 -0
  25. package/dist/wallet/autonomous-wallet.d.ts +14 -39
  26. package/dist/wallet/autonomous-wallet.d.ts.map +1 -1
  27. package/dist/wallet/autonomous-wallet.js +12 -145
  28. package/dist/wallet/autonomous-wallet.js.map +1 -1
  29. package/dist/wallet/cdp-sdk.d.ts +23 -0
  30. package/dist/wallet/cdp-sdk.d.ts.map +1 -0
  31. package/dist/wallet/cdp-sdk.js +27 -0
  32. package/dist/wallet/cdp-sdk.js.map +1 -0
  33. package/dist/wallet/encrypted-file-credential-store.d.ts +41 -0
  34. package/dist/wallet/encrypted-file-credential-store.d.ts.map +1 -0
  35. package/dist/wallet/encrypted-file-credential-store.js +221 -0
  36. package/dist/wallet/encrypted-file-credential-store.js.map +1 -0
  37. package/dist/wallet/provider.d.ts +1 -1
  38. package/dist/wallet/provider.d.ts.map +1 -1
  39. package/dist/wallet/provider.js +8 -3
  40. package/dist/wallet/provider.js.map +1 -1
  41. package/dist/wallet/secret-service-probe.d.ts +56 -0
  42. package/dist/wallet/secret-service-probe.d.ts.map +1 -0
  43. package/dist/wallet/secret-service-probe.js +407 -0
  44. package/dist/wallet/secret-service-probe.js.map +1 -0
  45. package/dist/zktls/reclaim-js-sdk.d.ts +24 -0
  46. package/dist/zktls/reclaim-js-sdk.d.ts.map +1 -0
  47. package/dist/zktls/reclaim-js-sdk.js +29 -0
  48. package/dist/zktls/reclaim-js-sdk.js.map +1 -0
  49. package/dist/zktls/reclaim.d.ts +14 -2
  50. package/dist/zktls/reclaim.d.ts.map +1 -1
  51. package/dist/zktls/reclaim.js +29 -6
  52. package/dist/zktls/reclaim.js.map +1 -1
  53. package/dist/zktls/t2-delivery-proof.d.ts +8 -1
  54. package/dist/zktls/t2-delivery-proof.d.ts.map +1 -1
  55. package/dist/zktls/t2-delivery-proof.js +22 -6
  56. package/dist/zktls/t2-delivery-proof.js.map +1 -1
  57. package/docs/agent-layer.md +150 -0
  58. package/docs/autonomous-privy-wallet.md +133 -0
  59. package/docs/crewai.md +70 -0
  60. package/docs/eliza.md +109 -0
  61. package/docs/langchain.md +63 -0
  62. package/docs/mcp-hosted.md +137 -0
  63. package/docs/privy-embedded-wallet.md +102 -0
  64. package/docs/quickstart.md +370 -0
  65. package/docs/threat-model.md +160 -0
  66. package/package.json +19 -6
  67. package/src/discovery/registry.ts +242 -414
  68. package/src/frameworks/agentkit.ts +24 -5
  69. package/src/gateway/caller-auth-gateway.ts +281 -15
  70. package/src/gateway/http-server.ts +64 -0
  71. package/src/index.ts +24 -0
  72. package/src/wallet/autonomous-wallet-store.ts +487 -0
  73. package/src/wallet/autonomous-wallet.ts +57 -224
  74. package/src/wallet/cdp-sdk.ts +33 -0
  75. package/src/wallet/encrypted-file-credential-store.ts +341 -0
  76. package/src/wallet/provider.ts +16 -9
  77. package/src/wallet/secret-service-probe.ts +487 -0
  78. package/src/zktls/reclaim-js-sdk.ts +50 -0
  79. package/src/zktls/reclaim.ts +57 -23
  80. package/src/zktls/t2-delivery-proof.ts +28 -10
@@ -1,40 +1,56 @@
1
1
  import {
2
- deriveServiceRef,
3
- recoverServiceBindingSigner,
4
- recoverServiceDescriptorSigner,
5
- serviceBindingSchema,
6
- serviceDescriptorSchema,
7
- type SignedServiceBinding,
8
- type SignedServiceDescriptor,
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
- * Before this module every agent surface (SDK, MCP, framework tools) assumed the
17
- * caller already knew a seller's `operator` + `serviceRef`. There was no way to
18
- * DISCOVER a service. {@link discoverServices} closes that gap: it reads the same
19
- * Supabase `listings` registry the oracle reads and returns typed, VERIFIED
20
- * listings an agent can hire directly.
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
- * ## Fail-closed trust model
23
- * The security-critical envelope lives entirely inside each row's `signed_binding`
24
- * (`{ binding, signature, serviceRef, targetMetadata }`). The flat `service_ref` /
25
- * `operator` / `public_url` columns are UNSIGNED denormalised copies for querying
26
- * only never trusted. A row is DROPPED (and logged) unless BOTH hold:
27
- * 1. its EIP-712 signature recovers to `binding.operator`; and
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
- * The unsigned `category` column is carried through as informational metadata only
32
- * (used for the optional client-side category filter); it is never part of the
33
- * trust decision.
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 binding, not the unsigned flat columns. */
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 listings row (may be absent). */
63
+ /** Unsigned informational category from the registry row (may be absent). */
48
64
  readonly category: string | null;
49
- /** Optional unsigned operator-supplied resolver hints; never used for invocation. */
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
- /** Registry endpoint. Defaults to {@link DEFAULT_METRIK_REGISTRY_URL} (env override:
55
- * `METRIK_AGENT_REGISTRY_URL`). Pass the full PostgREST `?select=...&order=...` URL. */
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
- /** Append the built-in {@link DEMO_SEED_LISTING} when the registry does not already
58
- * surface it (empty / unreachable / 401 / just missing), so a zero-config newcomer
59
- * still discovers one hireable, verified service. The seed is NOT trusted blindly —
60
- * it flows through the SAME fail-closed {@link verifyRow} path as any registry row.
61
- * Defaults to `true`. Pass `false` to get only what the registry itself returns. */
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: `METRIK_AGENT_REGISTRY_APIKEY`.
64
- * Sent as both the `apikey` header and `Authorization: Bearer`. */
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
- /** Optional client-side filter on the unsigned `category` column. */
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 / registry failures. Defaults to `console`. */
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
- * Default Metrik listings registry (Supabase PostgREST). Selects the columns the
80
- * oracle and the agent both need and orders newest-first.
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 DEFAULT_METRIK_REGISTRY_URL =
83
- "https://pzvnacjolaipeobmptsb.supabase.co/rest/v1/listings?select=service_ref,operator,public_url,category,signed_binding&order=created_at.desc";
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
- * `serviceRef` of the built-in verified live-data listing. Kept as a named constant so
89
- * callers can recognise (and dedupe against) the seeded row.
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 LIVE_DATA_SEED_SERVICE_REF =
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
- * A real, operator-signed live-data listing row embedded so `discoverServices()` returns
98
- * something hireable with ZERO configuration (no Supabase anon key, no registry URL).
99
- *
100
- * This is PUBLIC data a signed {@link https://github.com/Absol-Labs | Metrik}
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
- service_ref: LIVE_DATA_SEED_SERVICE_REF,
110
- operator: "0xB3e162711920dFD8933609019Dc35C73cC1a09F0",
111
- public_url: "https://livedata.137.23.50.249.sslip.io",
112
- category: "live-data",
113
- signed_binding: {
114
- serviceRef: LIVE_DATA_SEED_SERVICE_REF,
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 bytes32HexSchema = z
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
- /** The signed envelope inside a listings row. Mirrors the oracle's ingest schema
233
- * (includes the optional operator-supplied `targetMetadata`). */
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
- const signedDescriptorRecordSchema = z.object({
242
- serviceRef: bytes32HexSchema,
243
- descriptor: serviceDescriptorSchema,
244
- signature: signature65HexSchema,
245
- targetMetadata: z.record(z.string()).optional(),
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
- /** A Supabase `listings` row. Only `signed_binding` is trusted; `category` is
249
- * carried through as unsigned informational metadata. Unknown columns are ignored. */
250
- const listingRowSchema = z
251
- .object({
252
- service_ref: bytes32HexSchema.optional(),
253
- operator: z.string().optional(),
254
- public_url: z.string().optional(),
255
- category: z.string().nullish(),
256
- signed_binding: z.union([
257
- signedBindingRecordSchema,
258
- signedDescriptorRecordSchema,
259
- ]),
260
- })
261
- .passthrough();
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
- * Fail-closed: any row whose signed binding is malformed, whose signature does not
267
- * recover to its operator, or whose `serviceRef` does not match `deriveServiceRef`
268
- * is dropped and logged. Degrades gracefully: any network/registry failure returns
269
- * `[]` (with a logged warning) rather than throwing to the caller.
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
- const registryUrl =
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 fetch implementation available; falling back to live-data seed only",
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
- let seedAlreadyPresent = false;
311
- for (const [index, row] of rows.entries()) {
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
- // Limit satisfied by the registry alone — no need to seed.
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
- * Verify one raw registry row into a {@link ServiceListing}, or `null` if it fails
387
- * the fail-closed checks. Never throws.
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
- async function verifyRow(
390
- row: unknown,
391
- index: number | string,
228
+ function toServiceListing(
229
+ service: DiscoveredService,
392
230
  logger: { warn: (...args: unknown[]) => void },
393
- ): Promise<ServiceListing | null> {
394
- const parsed = listingRowSchema.safeParse(row);
395
- if (!parsed.success) {
231
+ ): ServiceListing | null {
232
+ if (service.accessUrl === null) {
396
233
  logger.warn(
397
- `metrik discovery: row ${index} dropped — invalid listing envelope (${parsed.error.issues[0]?.message ?? "schema error"})`,
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
- let signer: `0x${string}` | null;
428
- try {
429
- signer = (
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
- const derivedRef = deriveServiceRef(
454
- "descriptor" in record ? record.descriptor : record.binding,
455
- );
456
- if (!eqHex(derivedRef, record.serviceRef)) {
457
- logger.warn(
458
- `metrik discovery: row ${index} dropped serviceRef ${record.serviceRef} does not match derived ${derivedRef}`,
459
- );
460
- return null;
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
- const access =
464
- "descriptor" in record ? (record.descriptor.access ?? "public") : "public";
465
- const accessUrl =
466
- "descriptor" in record
467
- ? access === "gated"
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
- accessUrl === undefined ||
473
- (access === "gated" && !isHttpsUrl(accessUrl))
275
+ apiKey !== undefined &&
276
+ apiKey.length > 0 &&
277
+ (configuredUrl === undefined || configuredUrl.length === 0)
474
278
  ) {
475
- logger.warn(
476
- `metrik discovery: row ${index} droppedgated descriptor has no signed HTTPS callerAuth.accessUrl`,
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
- return {
482
- serviceRef: derivedRef,
483
- operator: signedIdentity.operator as `0x${string}`,
484
- publicUrl: signedIdentity.publicUrl,
485
- access,
486
- accessUrl,
487
- signed: record as SignedServiceBinding | SignedServiceDescriptor,
488
- category: category ?? null,
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 isHttpsUrl(value: string): boolean {
496
- try {
497
- return new URL(value).protocol === "https:";
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
- function errorMessage(error: unknown): string {
508
- return error instanceof Error ? error.message : String(error);
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
  }