@clawcash/forge 0.1.8 → 0.2.1

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/index.js CHANGED
@@ -36,136 +36,8 @@ function defineAgentService(config) {
36
36
  };
37
37
  }
38
38
 
39
- // src/mount.ts
40
- import { Router as createRouter } from "express";
41
- import { paymentMiddleware, x402ResourceServer } from "@x402/express";
42
- import { HTTPFacilitatorClient } from "@x402/core/server";
43
- import { ExactEvmScheme } from "@x402/evm/exact/server";
44
-
45
- // src/base-url.ts
46
- import { randomBytes } from "crypto";
47
- function resolveBaseUrlFromEnv(explicit) {
48
- const fromExplicit = explicit?.trim();
49
- if (fromExplicit) return stripSlash(fromExplicit);
50
- const fromEnv = process.env.PUBLIC_BASE_URL?.trim();
51
- if (fromEnv) return stripSlash(fromEnv);
52
- const railway = process.env.RAILWAY_PUBLIC_DOMAIN?.trim();
53
- if (railway) return `https://${stripSlash(railway)}`;
54
- const render = process.env.RENDER_EXTERNAL_URL?.trim();
55
- if (render) return stripSlash(render);
56
- const vercel = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim() || process.env.VERCEL_URL?.trim();
57
- if (vercel) return `https://${stripSlash(vercel)}`;
58
- const fly = process.env.FLY_APP_NAME?.trim();
59
- if (fly) return `https://${fly}.fly.dev`;
60
- const heroku = process.env.HEROKU_APP_NAME?.trim();
61
- if (heroku) return `https://${heroku}.herokuapp.com`;
62
- return null;
63
- }
64
- function stripSlash(url) {
65
- return url.replace(/\/$/, "");
66
- }
67
- var MAX_VERIFY_ATTEMPTS = 5;
68
- var VERIFY_TIMEOUT_MS = 3e3;
69
- var PublicBaseUrlResolver = class {
70
- /** Per-boot nonce, echoed by /forge/health for self-verification. */
71
- instanceNonce;
72
- resolved;
73
- verifying = false;
74
- rejected = /* @__PURE__ */ new Set();
75
- attempts = 0;
76
- onResolved;
77
- constructor(opts) {
78
- this.instanceNonce = randomBytes(16).toString("hex");
79
- this.resolved = opts.initial;
80
- this.onResolved = opts.onResolved ?? null;
81
- }
82
- get() {
83
- return this.resolved;
84
- }
85
- /**
86
- * Feed an inbound Forge-surface request. No-op once resolved. Kicks off
87
- * async self-verification of the candidate URL derived from the request.
88
- */
89
- considerRequest(req) {
90
- if (this.resolved || this.verifying) return;
91
- if (this.attempts >= MAX_VERIFY_ATTEMPTS) return;
92
- const candidate = candidateFromRequest(req);
93
- if (!candidate || this.rejected.has(candidate)) return;
94
- this.verifying = true;
95
- this.attempts += 1;
96
- void this.verify(candidate).then((ok) => {
97
- if (ok) {
98
- this.resolved = candidate;
99
- console.log(`[forge] public base URL inferred and verified: ${candidate}`);
100
- this.onResolved?.(candidate);
101
- } else {
102
- this.rejected.add(candidate);
103
- console.warn(
104
- `[forge] candidate base URL failed self-verification, ignoring: ${candidate}`
105
- );
106
- }
107
- }).finally(() => {
108
- this.verifying = false;
109
- });
110
- }
111
- /** Fetch our own /forge/health at the candidate and match the boot nonce. */
112
- async verify(candidate) {
113
- try {
114
- const controller = new AbortController();
115
- const timer = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
116
- const res = await fetch(`${candidate}/forge/health`, {
117
- signal: controller.signal,
118
- headers: { accept: "application/json" },
119
- redirect: "manual"
120
- });
121
- clearTimeout(timer);
122
- if (!res.ok) return false;
123
- const json = await res.json();
124
- return json.instance === this.instanceNonce;
125
- } catch {
126
- return false;
127
- }
128
- }
129
- };
130
- function candidateFromRequest(req) {
131
- const host = firstHeaderValue(req.headers["x-forwarded-host"]) ?? req.headers.host;
132
- if (!host || !isValidHost(host)) return null;
133
- const proto = firstHeaderValue(req.headers["x-forwarded-proto"]) ?? (req.protocol || "http");
134
- if (proto !== "https" && proto !== "http") return null;
135
- const isLocal = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(host);
136
- if (proto === "http" && !isLocal) return null;
137
- return `${proto}://${host}`;
138
- }
139
- function firstHeaderValue(value) {
140
- const raw = Array.isArray(value) ? value[0] : value;
141
- const first = raw?.split(",")[0]?.trim();
142
- return first || null;
143
- }
144
- function isValidHost(host) {
145
- return /^[a-z0-9.-]+(:\d{1,5})?$/i.test(host) || /^\[[0-9a-f:]+\](:\d{1,5})?$/i.test(host);
146
- }
147
-
148
- // src/gateway.ts
149
- async function heartbeatGateway(opts) {
150
- const gateway = opts.gatewayUrl.replace(/\/$/, "");
151
- const res = await fetch(`${gateway}/register/heartbeat`, {
152
- method: "POST",
153
- headers: {
154
- "content-type": "application/json",
155
- authorization: `Bearer ${opts.registerSecret}`
156
- },
157
- body: JSON.stringify({
158
- handle: opts.handle.trim().toLowerCase(),
159
- baseUrl: opts.baseUrl.replace(/\/$/, "")
160
- })
161
- });
162
- if (!res.ok) {
163
- const text = await res.text();
164
- throw new Error(
165
- `gateway heartbeat failed (${res.status}): ${text.slice(0, 240)}`
166
- );
167
- }
168
- }
39
+ // src/worker.ts
40
+ import os from "os";
169
41
 
170
42
  // src/gateway-auth.ts
171
43
  function getForgeApiKey(explicit) {
@@ -173,29 +45,7 @@ function getForgeApiKey(explicit) {
173
45
  if (!value || value.length < 16) return null;
174
46
  return value;
175
47
  }
176
- function getForgeGatewaySecret(explicit) {
177
- return getForgeApiKey(explicit);
178
- }
179
- function isForgeGatewayRequest(req, secret) {
180
- const expected = getForgeApiKey(secret);
181
- if (!expected) return false;
182
- const header = req.header("authorization") ?? "";
183
- const bearer = header.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : "";
184
- const alt = req.header("x-forge-api-key")?.trim() || req.header("x-clawcash-gateway")?.trim() || "";
185
- return bearer === expected || alt === expected;
186
- }
187
48
  var FORGE_GATEWAY_USER_ID = "forge_gateway_agent";
188
- function requireForgeGateway(secret) {
189
- return (req, res, next) => {
190
- if (!isForgeGatewayRequest(req, secret)) {
191
- res.status(401).json({
192
- error: "Unauthorized \u2014 Forge API key required (FORGE_API_KEY from Forge dashboard)"
193
- });
194
- return;
195
- }
196
- next();
197
- };
198
- }
199
49
  function forgeGatewayLoopbackHeaders(secret) {
200
50
  const expected = getForgeApiKey(secret);
201
51
  if (!expected) return {};
@@ -205,139 +55,7 @@ function forgeGatewayLoopbackHeaders(secret) {
205
55
  };
206
56
  }
207
57
 
208
- // src/payto.ts
209
- var ZERO = /^0x0{40}$/i;
210
- function isZeroAddress(address) {
211
- if (!address) return true;
212
- return ZERO.test(address.trim());
213
- }
214
- function normalizeBaseUrl(url) {
215
- return url.replace(/\/$/, "");
216
- }
217
- function pickForgeBaseUrl(source) {
218
- const fromEnv = process.env.FORGE_URL?.trim();
219
- if (fromEnv) return normalizeBaseUrl(fromEnv);
220
- const fromOpts = source?.baseUrl?.trim();
221
- if (!fromOpts) return null;
222
- const normalized = normalizeBaseUrl(fromOpts);
223
- if (/localhost|127\.0\.0\.1/i.test(normalized) && process.env.NODE_ENV === "production") {
224
- return null;
225
- }
226
- return normalized;
227
- }
228
- async function resolvePayTo(opts) {
229
- const envOverride = process.env.PAY_TO?.trim();
230
- if (envOverride && !isZeroAddress(envOverride)) {
231
- return envOverride;
232
- }
233
- const explicit = opts.payTo?.trim();
234
- if (explicit && !isZeroAddress(explicit) && !opts.forge) {
235
- return explicit;
236
- }
237
- const baseUrl = pickForgeBaseUrl(opts.forge);
238
- if (baseUrl && opts.forge) {
239
- const url = `${baseUrl}/api/public/pay-to/${encodeURIComponent(opts.forge.owner)}/${encodeURIComponent(opts.forge.repo)}`;
240
- try {
241
- const controller = new AbortController();
242
- const timer = setTimeout(
243
- () => controller.abort(),
244
- opts.timeoutMs ?? 2500
245
- );
246
- const res = await fetch(url, {
247
- signal: controller.signal,
248
- headers: { accept: "application/json" }
249
- });
250
- clearTimeout(timer);
251
- if (res.ok) {
252
- const json = await res.json();
253
- if (json.address && !isZeroAddress(json.address)) {
254
- return json.address.trim();
255
- }
256
- }
257
- } catch (err) {
258
- console.warn("[forge] live payTo fetch failed, using fallback:", err);
259
- }
260
- }
261
- if (explicit && !isZeroAddress(explicit)) return explicit;
262
- if (opts.fallback && !isZeroAddress(opts.fallback)) return opts.fallback;
263
- throw new Error(
264
- "resolvePayTo: no usable address (set Forge wallet / PAY_TO, or re-run Forge PR)"
265
- );
266
- }
267
-
268
- // src/status.ts
269
- var PACKAGE_VERSION = "0.1.8";
270
- var lastHeartbeat = {
271
- attempted: false,
272
- ok: null,
273
- at: null,
274
- error: null
275
- };
276
- function recordHeartbeatResult(result) {
277
- lastHeartbeat = {
278
- attempted: true,
279
- ok: result.ok,
280
- at: (/* @__PURE__ */ new Date()).toISOString(),
281
- error: result.ok ? null : result.error
282
- };
283
- }
284
- function getLastHeartbeat() {
285
- return { ...lastHeartbeat };
286
- }
287
- function buildForgeSurfaceStatus(opts) {
288
- const fulfillPath = opts.fulfillPath.replace(/\/$/, "") || "/forge/fulfill";
289
- const agentBasePath = opts.agentBasePath.replace(/\/$/, "") || "/agent";
290
- const ready = opts.fulfillMounted && Boolean(opts.publicBaseUrl) && opts.actions.length > 0;
291
- return {
292
- ok: true,
293
- forge: true,
294
- version: PACKAGE_VERSION,
295
- handle: opts.handle,
296
- publicBaseUrl: opts.publicBaseUrl,
297
- gatewayUrl: opts.gatewayUrl,
298
- fulfillMounted: opts.fulfillMounted,
299
- fulfillPath,
300
- agentBasePath,
301
- skipPayment: opts.skipPayment,
302
- actions: opts.actions,
303
- ready,
304
- heartbeat: getLastHeartbeat(),
305
- endpoints: {
306
- health: "/forge/health",
307
- status: "/forge/status",
308
- wellKnown: "/.well-known/clawcash.json",
309
- fulfill: fulfillPath,
310
- agent: agentBasePath
311
- }
312
- };
313
- }
314
- function buildWellKnownDocument(status) {
315
- return {
316
- forge: true,
317
- version: status.version,
318
- health: status.endpoints.health,
319
- status: status.endpoints.status,
320
- handle: status.handle,
321
- ready: status.ready
322
- };
323
- }
324
- function forgeSdkVersion() {
325
- return PACKAGE_VERSION;
326
- }
327
-
328
- // src/mount.ts
329
- var DEFAULT_NETWORK = "eip155:8453";
330
- var DEFAULT_FACILITATOR_URL = "https://facilitator.xpay.sh";
331
- var DEFAULT_GATEWAY_URL = "https://gateway.clawca.sh";
332
- function asNetwork(value) {
333
- return value ?? DEFAULT_NETWORK;
334
- }
335
- function createFacilitatorClient(options) {
336
- return new HTTPFacilitatorClient({
337
- url: options.facilitatorUrl ?? DEFAULT_FACILITATOR_URL,
338
- createAuthHeaders: options.createAuthHeaders
339
- });
340
- }
58
+ // src/validate.ts
341
59
  function validateInput(body, schema) {
342
60
  if (!body || typeof body !== "object" || Array.isArray(body)) {
343
61
  return { ok: false, error: "Request body must be a JSON object" };
@@ -383,305 +101,352 @@ function checkType(key, value, type) {
383
101
  return null;
384
102
  }
385
103
  }
386
- function formatPrice(amount) {
387
- const normalized = amount.startsWith("$") ? amount.slice(1) : amount;
388
- return `$${normalized}`;
104
+
105
+ // src/worker.ts
106
+ var DEFAULT_GATEWAY_URL = "https://gateway.clawca.sh";
107
+ var DEFAULT_WAIT_MS = 2e4;
108
+ var BACKOFF_STEPS_MS = [1e3, 2e3, 5e3, 1e4, 3e4];
109
+ function findService(services, action) {
110
+ const key = toKebabCase(action.trim());
111
+ return services.find((s) => s.path === key || toKebabCase(s.name) === key) ?? null;
112
+ }
113
+ async function postJson(url, apiKey, body, signal) {
114
+ const res = await fetch(url, {
115
+ method: "POST",
116
+ headers: {
117
+ "content-type": "application/json",
118
+ accept: "application/json",
119
+ authorization: `Bearer ${apiKey}`
120
+ },
121
+ body: JSON.stringify(body),
122
+ signal
123
+ });
124
+ const text = await res.text();
125
+ let json2 = {};
126
+ try {
127
+ json2 = text ? JSON.parse(text) : {};
128
+ } catch {
129
+ json2 = { raw: text };
130
+ }
131
+ return { status: res.status, json: json2 };
389
132
  }
390
- function mountFulfillmentRouter(app, services, options) {
391
- const secret = getForgeApiKey(options.gateway?.registerSecret);
392
- if (!secret) {
393
- console.warn(
394
- "[forge] /forge/fulfill not mounted \u2014 set FORGE_API_KEY (from Forge dashboard, min 16 chars) so the gateway can fulfill after payment"
133
+ function forgeWorker(options) {
134
+ const handle = (options.handle ?? process.env.FORGE_HANDLE ?? "").trim().toLowerCase();
135
+ if (!handle) {
136
+ throw new Error(
137
+ "forgeWorker: handle is required (option or FORGE_HANDLE env)"
395
138
  );
396
- return;
397
139
  }
398
- const fulfillPath = (options.fulfillPath ?? "/forge/fulfill").replace(/\/$/, "") || "/forge/fulfill";
399
- const router = createRouter();
400
- const context = createContext();
401
- const gate = requireForgeGateway(secret);
402
- for (const service of services) {
403
- router.post(
404
- `/${service.path}`,
405
- gate,
406
- async (req, res) => {
407
- try {
408
- const validated = validateInput(req.body, service.input);
409
- if (!validated.ok) {
410
- res.status(400).json({ error: validated.error });
411
- return;
412
- }
413
- const output = await service.execute(validated.value, context);
414
- res.json(output);
415
- } catch (err) {
416
- const message = err instanceof Error ? err.message : "Internal error";
417
- console.error(`[forge] fulfill ${service.name} failed:`, err);
418
- res.status(500).json({ error: message });
419
- }
140
+ const apiKey = getForgeApiKey(options.apiKey);
141
+ if (!apiKey) {
142
+ throw new Error(
143
+ "forgeWorker: FORGE_API_KEY is required (per-project key from the Forge dashboard, min 16 chars)"
144
+ );
145
+ }
146
+ if (!options.services?.length) {
147
+ throw new Error("forgeWorker: services must be a non-empty array");
148
+ }
149
+ const gatewayUrl = (options.gatewayUrl?.trim() || process.env.GATEWAY_URL?.trim() || DEFAULT_GATEWAY_URL).replace(/\/$/, "");
150
+ const workerId = options.workerId?.trim() || `${os.hostname()}-${process.pid}`;
151
+ const waitMs = options.waitMs ?? DEFAULT_WAIT_MS;
152
+ let running = true;
153
+ const controller = new AbortController();
154
+ let wakeSleeper = null;
155
+ const reportError = (err) => {
156
+ if (options.onError) options.onError(err);
157
+ else console.warn(`[forge-worker] ${err.message}`);
158
+ };
159
+ const sleep = (ms) => new Promise((resolve) => {
160
+ const timer = setTimeout(() => {
161
+ wakeSleeper = null;
162
+ resolve();
163
+ }, ms);
164
+ wakeSleeper = () => {
165
+ clearTimeout(timer);
166
+ wakeSleeper = null;
167
+ resolve();
168
+ };
169
+ });
170
+ async function runJob(job) {
171
+ const resultUrl = `${gatewayUrl}/jobs/${encodeURIComponent(job.id)}/result`;
172
+ const service = findService(options.services, job.action);
173
+ if (!service) {
174
+ await postJson(
175
+ resultUrl,
176
+ apiKey,
177
+ { ok: false, error: `no service registered for action ${job.action}` },
178
+ controller.signal
179
+ );
180
+ reportError(new Error(`job ${job.id}: unknown action ${job.action}`));
181
+ return;
182
+ }
183
+ try {
184
+ const validated = validateInput(job.input, service.input);
185
+ if (!validated.ok) {
186
+ await postJson(
187
+ resultUrl,
188
+ apiKey,
189
+ { ok: false, error: validated.error },
190
+ controller.signal
191
+ );
192
+ return;
193
+ }
194
+ const output = await service.execute(validated.value, createContext());
195
+ const res = await postJson(
196
+ resultUrl,
197
+ apiKey,
198
+ { ok: true, output },
199
+ controller.signal
200
+ );
201
+ if (res.status >= 400) {
202
+ reportError(
203
+ new Error(
204
+ `job ${job.id}: result rejected (${res.status}): ${JSON.stringify(res.json).slice(0, 300)}`
205
+ )
206
+ );
207
+ } else {
208
+ console.log(`[forge-worker] completed job ${job.id} (${job.action})`);
420
209
  }
210
+ } catch (err) {
211
+ const message = err instanceof Error ? err.message : "execute failed";
212
+ try {
213
+ await postJson(
214
+ resultUrl,
215
+ apiKey,
216
+ { ok: false, error: message },
217
+ controller.signal
218
+ );
219
+ } catch {
220
+ }
221
+ reportError(new Error(`job ${job.id} failed: ${message}`));
222
+ }
223
+ }
224
+ async function loop() {
225
+ console.log(
226
+ `[forge-worker] ${handle} polling ${gatewayUrl}/jobs/claim as ${workerId} (${options.services.length} action(s))`
421
227
  );
228
+ let backoffIndex = 0;
229
+ while (running) {
230
+ try {
231
+ const { status, json: json2 } = await postJson(
232
+ `${gatewayUrl}/jobs/claim`,
233
+ apiKey,
234
+ { handle, workerId, waitMs },
235
+ controller.signal
236
+ );
237
+ if (status === 401) {
238
+ throw new Error(
239
+ `claim rejected (401): ${JSON.stringify(json2).slice(0, 300)} \u2014 check FORGE_API_KEY matches the Forge dashboard key`
240
+ );
241
+ }
242
+ if (status === 404 || status === 409) {
243
+ const why = status === 404 ? `handle "${handle}" is not registered yet \u2014 press Go live on the Forge dashboard` : `merchant is not in worker mode yet \u2014 press Go live again`;
244
+ console.warn(
245
+ `[forge-worker] waiting: ${why} (${JSON.stringify(json2).slice(0, 160)})`
246
+ );
247
+ throw Object.assign(new Error(`claim waiting (${status})`), {
248
+ transient: true
249
+ });
250
+ }
251
+ if (status >= 400) {
252
+ throw Object.assign(
253
+ new Error(`claim failed (${status})`),
254
+ { transient: true }
255
+ );
256
+ }
257
+ backoffIndex = 0;
258
+ const job = json2.job;
259
+ if (job) {
260
+ await runJob(job);
261
+ }
262
+ } catch (err) {
263
+ if (!running) break;
264
+ const error = err instanceof Error ? err : new Error(String(err));
265
+ if (error.name === "AbortError") break;
266
+ reportError(error);
267
+ const transient = error.transient === true || !/claim rejected \(401\)/.test(error.message);
268
+ if (!transient) {
269
+ console.error(
270
+ "[forge-worker] fatal configuration error \u2014 stopping worker"
271
+ );
272
+ running = false;
273
+ break;
274
+ }
275
+ const delay = BACKOFF_STEPS_MS[Math.min(backoffIndex, BACKOFF_STEPS_MS.length - 1)];
276
+ backoffIndex += 1;
277
+ await sleep(delay);
278
+ }
279
+ }
280
+ console.log("[forge-worker] stopped");
422
281
  }
423
- app.use(fulfillPath, router);
424
- console.log(
425
- `[forge] fulfillment mounted at POST ${fulfillPath}/{action} (gateway secret)`
426
- );
282
+ void loop();
283
+ return {
284
+ stop() {
285
+ running = false;
286
+ controller.abort();
287
+ wakeSleeper?.();
288
+ },
289
+ get running() {
290
+ return running;
291
+ }
292
+ };
427
293
  }
428
- function mountForgeStatusRoutes(app, services, options, opts) {
429
- const getStatus = () => buildForgeSurfaceStatus({
294
+
295
+ // src/status.ts
296
+ var PACKAGE_VERSION = "0.2.0";
297
+ function buildForgeSurfaceStatus(opts) {
298
+ const fulfillPath = opts.fulfillPath.replace(/\/$/, "") || "/forge/fulfill";
299
+ const ready = opts.fulfillMounted && opts.actions.length > 0;
300
+ return {
301
+ ok: true,
302
+ forge: true,
303
+ version: PACKAGE_VERSION,
430
304
  handle: opts.handle,
431
- publicBaseUrl: opts.resolver.get(),
305
+ publicBaseUrl: opts.publicBaseUrl,
432
306
  gatewayUrl: opts.gatewayUrl,
433
307
  fulfillMounted: opts.fulfillMounted,
434
- fulfillPath: opts.fulfillPath,
435
- agentBasePath: opts.agentBasePath,
436
- skipPayment: Boolean(options.skipPayment),
437
- actions: services.map((s) => ({
438
- name: s.name,
439
- path: s.path,
440
- price: s.payment.amount
441
- }))
442
- });
443
- app.get("/forge/health", (req, res) => {
444
- opts.resolver.considerRequest(req);
445
- const status = getStatus();
446
- res.json({
447
- ok: true,
448
- forge: true,
449
- ready: status.ready,
450
- handle: status.handle,
451
- version: status.version,
452
- // Per-boot nonce so this instance can self-verify an inferred base URL.
453
- instance: opts.resolver.instanceNonce,
454
- actions: status.actions.map((a) => a.name)
455
- });
456
- });
457
- app.get("/forge/status", (req, res) => {
458
- opts.resolver.considerRequest(req);
459
- const status = getStatus();
460
- res.status(status.ready ? 200 : 503).json(status);
461
- });
462
- app.get("/.well-known/clawcash.json", (req, res) => {
463
- opts.resolver.considerRequest(req);
464
- res.json(buildWellKnownDocument(getStatus()));
308
+ fulfillPath,
309
+ actions: opts.actions,
310
+ ready,
311
+ endpoints: {
312
+ health: "/forge/health",
313
+ status: "/forge/status",
314
+ wellKnown: "/.well-known/clawcash.json",
315
+ fulfill: fulfillPath
316
+ }
317
+ };
318
+ }
319
+ function buildWellKnownDocument(status) {
320
+ return {
321
+ forge: true,
322
+ version: status.version,
323
+ health: status.endpoints.health,
324
+ status: status.endpoints.status,
325
+ handle: status.handle,
326
+ ready: status.ready
327
+ };
328
+ }
329
+ function forgeSdkVersion() {
330
+ return PACKAGE_VERSION;
331
+ }
332
+
333
+ // src/handler.ts
334
+ function json(body, status = 200) {
335
+ return new Response(JSON.stringify(body), {
336
+ status,
337
+ headers: { "content-type": "application/json" }
465
338
  });
466
- console.log(
467
- "[forge] liveliness: GET /forge/health, GET /forge/status, GET /.well-known/clawcash.json"
468
- );
469
339
  }
470
- async function mountAgentServices(app, services, options) {
471
- let payTo = "";
472
- if (!options.skipPayment) {
473
- const fallback = options.payToFallback?.trim() || options.payTo?.trim() || "";
474
- payTo = await resolvePayTo({
475
- fallback,
476
- payTo: options.payTo,
477
- forge: options.forge
478
- });
479
- if (isZeroAddress(payTo)) {
480
- throw new Error(
481
- "mountAgentServices: resolved payTo is the zero address. Re-run Forge to provision a wallet."
482
- );
483
- }
340
+ function isAuthorized(request, apiKey) {
341
+ if (!apiKey) return false;
342
+ const header = request.headers.get("authorization") ?? "";
343
+ const bearer = header.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : "";
344
+ const alt = request.headers.get("x-forge-api-key")?.trim() || request.headers.get("x-clawcash-gateway")?.trim() || "";
345
+ return bearer === apiKey || alt === apiKey;
346
+ }
347
+ function createForgeRequestMatcher(services, options = {}) {
348
+ if (!services?.length) {
349
+ throw new Error("createForgeHandler: services must be a non-empty array");
484
350
  }
485
- const basePath = (options.basePath ?? "/agent").replace(/\/$/, "") || "/agent";
486
351
  const fulfillPath = (options.fulfillPath ?? "/forge/fulfill").replace(/\/$/, "") || "/forge/fulfill";
487
- const router = createRouter();
352
+ const handle = options.handle?.trim().toLowerCase() || process.env.FORGE_HANDLE?.trim().toLowerCase() || null;
353
+ const gatewayUrl = options.gatewayUrl?.trim() || process.env.GATEWAY_URL?.trim() || "https://gateway.clawca.sh";
488
354
  const context = createContext();
489
- const routes = {};
490
- for (const service of services) {
491
- const routePath = `/${service.path}`;
492
- const fullPath = `${basePath}${routePath}`;
493
- const network = asNetwork(service.payment.network);
494
- if (!options.skipPayment) {
495
- routes[`POST ${fullPath}`] = {
496
- accepts: {
497
- scheme: "exact",
498
- price: formatPrice(service.payment.amount),
499
- network,
500
- payTo
501
- },
502
- description: service.description,
503
- mimeType: "application/json"
504
- };
355
+ const getStatus = () => {
356
+ const apiKey = getForgeApiKey(options.apiKey);
357
+ return buildForgeSurfaceStatus({
358
+ handle,
359
+ publicBaseUrl: options.publicBaseUrl ?? null,
360
+ gatewayUrl,
361
+ fulfillMounted: Boolean(apiKey),
362
+ fulfillPath,
363
+ actions: services.map((s) => ({
364
+ name: s.name,
365
+ path: s.path,
366
+ price: s.payment.amount
367
+ }))
368
+ });
369
+ };
370
+ return async (request) => {
371
+ const url = new URL(request.url);
372
+ const pathname = url.pathname.replace(/\/$/, "") || "/";
373
+ if (request.method === "GET") {
374
+ if (pathname === "/forge/health") {
375
+ const status = getStatus();
376
+ return json({
377
+ ok: true,
378
+ forge: true,
379
+ ready: status.ready,
380
+ handle: status.handle,
381
+ version: status.version,
382
+ actions: status.actions.map((a) => a.name)
383
+ });
384
+ }
385
+ if (pathname === "/forge/status") {
386
+ const status = getStatus();
387
+ return json(status, status.ready ? 200 : 503);
388
+ }
389
+ if (pathname === "/.well-known/clawcash.json") {
390
+ return json(buildWellKnownDocument(getStatus()));
391
+ }
392
+ return null;
505
393
  }
506
- router.post(routePath, async (req, res) => {
394
+ if (request.method === "POST" && pathname.startsWith(`${fulfillPath}/`)) {
395
+ const actionPath = pathname.slice(fulfillPath.length + 1);
396
+ const service = services.find((s) => s.path === actionPath) ?? null;
397
+ if (!service) {
398
+ return json({ error: `Unknown action ${actionPath}` }, 404);
399
+ }
400
+ const apiKey = getForgeApiKey(options.apiKey);
401
+ if (!isAuthorized(request, apiKey)) {
402
+ return json(
403
+ {
404
+ error: "Unauthorized \u2014 Forge API key required (FORGE_API_KEY from Forge dashboard)"
405
+ },
406
+ 401
407
+ );
408
+ }
409
+ let body;
410
+ try {
411
+ body = await request.json();
412
+ } catch {
413
+ return json({ error: "Request body must be JSON" }, 400);
414
+ }
415
+ const validated = validateInput(body, service.input);
416
+ if (!validated.ok) {
417
+ return json({ error: validated.error }, 400);
418
+ }
507
419
  try {
508
- const validated = validateInput(req.body, service.input);
509
- if (!validated.ok) {
510
- res.status(400).json({ error: validated.error });
511
- return;
512
- }
513
420
  const output = await service.execute(validated.value, context);
514
- res.json(output);
421
+ return json(output);
515
422
  } catch (err) {
516
423
  const message = err instanceof Error ? err.message : "Internal error";
517
- console.error(`[forge] ${service.name} failed:`, err);
518
- res.status(500).json({ error: message });
424
+ console.error(`[forge] fulfill ${service.name} failed:`, err);
425
+ return json({ error: message }, 500);
519
426
  }
520
- });
521
- }
522
- if (!options.skipPayment && Object.keys(routes).length > 0) {
523
- const facilitator = createFacilitatorClient(options);
524
- const networks = new Set(
525
- services.map((s) => asNetwork(s.payment.network))
526
- );
527
- const resourceServer = new x402ResourceServer(facilitator);
528
- for (const network of networks) {
529
- resourceServer.register(network, new ExactEvmScheme());
530
427
  }
531
- app.use(paymentMiddleware(routes, resourceServer, void 0, void 0, true));
532
- }
533
- app.use(basePath, router);
534
- const fulfillMounted = Boolean(
535
- getForgeApiKey(options.gateway?.registerSecret)
536
- );
537
- mountFulfillmentRouter(app, services, options);
538
- const gatewayUrl = options.gateway?.url?.trim() || process.env.GATEWAY_URL?.trim() || DEFAULT_GATEWAY_URL;
539
- const handle = options.gateway?.handle?.trim() || process.env.FORGE_HANDLE?.trim() || null;
540
- const registerSecret = options.gateway?.registerSecret?.trim() || process.env.FORGE_API_KEY?.trim() || process.env.GATEWAY_REGISTER_SECRET?.trim();
541
- const sendHeartbeat = (baseUrl) => {
542
- if (!handle || !registerSecret) return;
543
- void heartbeatGateway({
544
- gatewayUrl,
545
- handle,
546
- baseUrl,
547
- registerSecret
548
- }).then(() => {
549
- recordHeartbeatResult({ ok: true });
550
- }).catch((err) => {
551
- const message = err instanceof Error ? err.message : String(err);
552
- recordHeartbeatResult({ ok: false, error: message });
553
- console.warn("[forge] gateway heartbeat skipped:", err);
554
- });
428
+ return null;
555
429
  };
556
- const resolver = new PublicBaseUrlResolver({
557
- initial: resolveBaseUrlFromEnv(options.gateway?.publicBaseUrl),
558
- // Base URL learned late (inferred from the first probe): heartbeat now.
559
- onResolved: sendHeartbeat
560
- });
561
- mountForgeStatusRoutes(app, services, options, {
562
- fulfillMounted,
563
- fulfillPath,
564
- agentBasePath: basePath,
565
- handle,
566
- resolver,
567
- gatewayUrl
568
- });
569
- const bootBaseUrl = resolver.get();
570
- if (bootBaseUrl) {
571
- sendHeartbeat(bootBaseUrl);
572
- }
573
- return router;
574
- }
575
- function createForgeRouter(services, options) {
576
- return (app) => mountAgentServices(app, services, options);
577
430
  }
578
-
579
- // src/skill.ts
580
- function generateSkillMarkdown(services, meta) {
581
- const base = meta.baseUrl.replace(/\/$/, "");
582
- const lines = [
583
- `---`,
584
- `name: ${meta.name}`,
585
- `description: ${meta.description}`,
586
- `---`,
587
- ``,
588
- `# ${meta.name}`,
589
- ``,
590
- meta.description,
591
- ``
592
- ];
593
- if (meta.homepage) {
594
- lines.push(`Homepage: ${meta.homepage}`, ``);
595
- }
596
- lines.push(
597
- `## How to use`,
598
- ``,
599
- `1. Read this skill.`,
600
- `2. Call the agent endpoint with a JSON body matching the input schema.`,
601
- `3. If you receive HTTP 402, pay via x402 (USDC) and retry with the payment header.`,
602
- `4. Use the returned output fields.`,
603
- ``,
604
- `## Services`,
605
- ``
606
- );
607
- for (const service of services) {
608
- const path = `${base}/agent/${service.path}`;
609
- const price = service.payment.amount.startsWith("$") ? service.payment.amount : `$${service.payment.amount}`;
610
- lines.push(
611
- `### \`${service.name}\``,
612
- ``,
613
- service.description,
614
- ``,
615
- `- **Method:** \`POST\``,
616
- `- **URL:** \`${path}\``,
617
- `- **Price:** ${price} ${service.payment.currency} via ${service.payment.protocols.join(", ")}`,
618
- ``,
619
- `**Input**`,
620
- ``,
621
- "```json",
622
- JSON.stringify(
623
- Object.fromEntries(
624
- Object.entries(service.input).map(([k, t]) => [k, `<${t}>`])
625
- ),
626
- null,
627
- 2
628
- ),
629
- "```",
630
- ``,
631
- `**Output**`,
632
- ``,
633
- "```json",
634
- JSON.stringify(
635
- Object.fromEntries(
636
- Object.entries(service.output).map(([k, t]) => [k, `<${t}>`])
637
- ),
638
- null,
639
- 2
640
- ),
641
- "```",
642
- ``,
643
- `**Example**`,
644
- ``,
645
- "```bash",
646
- `curl -X POST ${path} \\`,
647
- ` -H "Content-Type: application/json" \\`,
648
- ` -d '${JSON.stringify(
649
- Object.fromEntries(
650
- Object.entries(service.input).map(([k, t]) => [
651
- k,
652
- t === "url" ? "https://example.com/image.png" : `example-${k}`
653
- ])
654
- )
655
- )}'`,
656
- "```",
657
- ``
658
- );
659
- }
660
- return lines.join("\n");
431
+ function createForgeHandler(services, options = {}) {
432
+ const match = createForgeRequestMatcher(services, options);
433
+ return async (request) => await match(request) ?? json({ error: "Not found" }, 404);
661
434
  }
662
435
  export {
663
436
  FORGE_GATEWAY_USER_ID,
664
- PublicBaseUrlResolver,
665
437
  buildForgeSurfaceStatus,
666
438
  buildWellKnownDocument,
439
+ checkType,
667
440
  createContext,
668
- createForgeRouter,
441
+ createForgeHandler,
442
+ createForgeRequestMatcher,
669
443
  defineAgentService,
670
444
  forgeGatewayLoopbackHeaders,
671
445
  forgeSdkVersion,
672
- generateSkillMarkdown,
446
+ forgeWorker,
673
447
  getForgeApiKey,
674
- getForgeGatewaySecret,
675
- getLastHeartbeat,
676
- heartbeatGateway,
677
- isForgeGatewayRequest,
678
- isZeroAddress,
679
- mountAgentServices,
680
- recordHeartbeatResult,
681
- requireForgeGateway,
682
- resolveBaseUrlFromEnv,
683
- resolvePayTo,
684
448
  toKebabCase,
449
+ validateInput,
685
450
  waitUntil
686
451
  };
687
452
  //# sourceMappingURL=index.js.map