@xtrape/capsule-agent-node 0.6.0 → 0.6.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/README.md +12 -2
- package/dist/index.cjs +126 -4
- package/dist/index.d.cts +70 -5
- package/dist/index.d.ts +70 -5
- package/dist/index.js +126 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,9 +48,11 @@ legacy `Capsule` concept.
|
|
|
48
48
|
|
|
49
49
|
### One-call bootstrap
|
|
50
50
|
|
|
51
|
-
`serveCeService` starts an HTTP listener (`POST /messages`,
|
|
51
|
+
`serveCeService` starts an HTTP listener (`POST /messages`, action endpoints,
|
|
52
|
+
`GET /health`),
|
|
52
53
|
registers the instance (sending the registration token if server-ce requires
|
|
53
|
-
one),
|
|
54
|
+
one), stores the per-instance access token returned by server-ce, and runs
|
|
55
|
+
authenticated heartbeats — so a service does not copy-paste lifecycle code:
|
|
54
56
|
|
|
55
57
|
For deployed or containerized use, set `address` to the callback URL that
|
|
56
58
|
`xtrape-server-ce` can actually reach and allowlist, for example
|
|
@@ -63,6 +65,13 @@ import { serveCeService } from "@xtrape/capsule-agent-node";
|
|
|
63
65
|
const svc = await serveCeService({
|
|
64
66
|
serverUrl: process.env.XTRAPE_SERVER_URL ?? "http://localhost:3000",
|
|
65
67
|
registrationToken: process.env.SERVER_CE_REGISTRATION_TOKEN,
|
|
68
|
+
// Optional compatibility fallback for older server-ce deployments. Current
|
|
69
|
+
// server-ce returns a per-instance access token during registration, and the
|
|
70
|
+
// SDK uses that token first for heartbeat/deregister.
|
|
71
|
+
controlToken: process.env.SERVER_CE_CONTROL_TOKEN,
|
|
72
|
+
// Optional: require authenticated callbacks from server-ce on POST /messages
|
|
73
|
+
// and action endpoints (x-xtrape-service-callback-token or Authorization: Bearer).
|
|
74
|
+
callbackToken: process.env.SERVICE_CALLBACK_TOKEN,
|
|
66
75
|
port: 8080,
|
|
67
76
|
// required in deployed/containerized environments
|
|
68
77
|
address: process.env.SERVICE_ADDRESS ?? "http://demo-service:8080",
|
|
@@ -89,6 +98,7 @@ import { CeServiceAgent } from "@xtrape/capsule-agent-node";
|
|
|
89
98
|
const agent = new CeServiceAgent({
|
|
90
99
|
serverUrl: "http://localhost:3000",
|
|
91
100
|
registrationToken: process.env.SERVER_CE_REGISTRATION_TOKEN,
|
|
101
|
+
controlToken: process.env.SERVER_CE_CONTROL_TOKEN, // compatibility fallback only
|
|
92
102
|
address: "http://127.0.0.1:8080",
|
|
93
103
|
manifest: /* ServiceManifest */ undefined as never,
|
|
94
104
|
});
|
package/dist/index.cjs
CHANGED
|
@@ -446,7 +446,9 @@ var CeServiceAgent = class {
|
|
|
446
446
|
address;
|
|
447
447
|
tenant;
|
|
448
448
|
registrationToken;
|
|
449
|
+
controlToken;
|
|
449
450
|
fetchImpl;
|
|
451
|
+
accessToken;
|
|
450
452
|
health = "HEALTHY";
|
|
451
453
|
hbTimer;
|
|
452
454
|
constructor(opts) {
|
|
@@ -457,8 +459,18 @@ var CeServiceAgent = class {
|
|
|
457
459
|
this.serviceId = opts.manifest.service.id;
|
|
458
460
|
this.instanceId = opts.instanceId ?? `${this.serviceId}-${(0, import_node_crypto.randomUUID)().slice(0, 8)}`;
|
|
459
461
|
this.registrationToken = opts.registrationToken;
|
|
462
|
+
this.controlToken = opts.controlToken;
|
|
460
463
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
461
464
|
}
|
|
465
|
+
/**
|
|
466
|
+
* Header carrying the per-instance access token returned by registration.
|
|
467
|
+
* Falls back to the transitional control token for compatibility with older
|
|
468
|
+
* server-ce deployments that did not mint per-instance access tokens yet.
|
|
469
|
+
*/
|
|
470
|
+
lifecycleAuthHeaders() {
|
|
471
|
+
if (this.accessToken) return { "x-xtrape-access-token": this.accessToken };
|
|
472
|
+
return this.controlToken ? { "x-xtrape-control-token": this.controlToken } : void 0;
|
|
473
|
+
}
|
|
462
474
|
/** The ServiceInstance this agent reports on register/heartbeat. */
|
|
463
475
|
instance() {
|
|
464
476
|
return import_capsule_contracts_node.ServiceInstanceSchema.parse({
|
|
@@ -474,8 +486,12 @@ var CeServiceAgent = class {
|
|
|
474
486
|
/** POST /v1/instances { manifest, instance } (sends the registration token if set). */
|
|
475
487
|
async register() {
|
|
476
488
|
const headers = this.registrationToken ? { "x-xtrape-registration-token": this.registrationToken } : void 0;
|
|
477
|
-
await this.post("/v1/instances", { manifest: this.manifest, instance: this.instance() }, headers);
|
|
478
|
-
|
|
489
|
+
const response = await this.post("/v1/instances", { manifest: this.manifest, instance: this.instance() }, headers);
|
|
490
|
+
const accessToken = typeof response === "object" && response !== null && "accessToken" in response ? response.accessToken : void 0;
|
|
491
|
+
if (typeof accessToken === "string" && accessToken.length > 0) {
|
|
492
|
+
this.accessToken = accessToken;
|
|
493
|
+
}
|
|
494
|
+
return { instanceId: this.instanceId, ...this.accessToken ? { accessToken: this.accessToken } : {} };
|
|
479
495
|
}
|
|
480
496
|
/**
|
|
481
497
|
* Lifecycle convenience: register, then start periodic heartbeats. Returns a
|
|
@@ -505,7 +521,7 @@ var CeServiceAgent = class {
|
|
|
505
521
|
health: this.health,
|
|
506
522
|
...metricsSummary ? { metricsSummary } : {}
|
|
507
523
|
};
|
|
508
|
-
await this.post(`/v1/instances/${encodeURIComponent(this.instanceId)}/heartbeat`, hb);
|
|
524
|
+
await this.post(`/v1/instances/${encodeURIComponent(this.instanceId)}/heartbeat`, hb, this.lifecycleAuthHeaders());
|
|
509
525
|
}
|
|
510
526
|
/** Start a periodic heartbeat; returns a stop function. Timer is unref'd. */
|
|
511
527
|
startHeartbeat(intervalMs = 1e4, metrics) {
|
|
@@ -525,7 +541,10 @@ var CeServiceAgent = class {
|
|
|
525
541
|
}
|
|
526
542
|
/** DELETE /v1/instances/{id} — graceful deregister. */
|
|
527
543
|
async deregister() {
|
|
528
|
-
await this.fetchJson(`/v1/instances/${encodeURIComponent(this.instanceId)}`, {
|
|
544
|
+
await this.fetchJson(`/v1/instances/${encodeURIComponent(this.instanceId)}`, {
|
|
545
|
+
method: "DELETE",
|
|
546
|
+
...this.lifecycleAuthHeaders() ? { headers: this.lifecycleAuthHeaders() } : {}
|
|
547
|
+
});
|
|
529
548
|
}
|
|
530
549
|
/**
|
|
531
550
|
* Validate an inbound message, run the handler, and build the reply message
|
|
@@ -597,6 +616,26 @@ function readBody(req) {
|
|
|
597
616
|
function isLocalOnlyHost(host) {
|
|
598
617
|
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
599
618
|
}
|
|
619
|
+
function extractCallbackToken(req) {
|
|
620
|
+
const raw = req.headers["x-xtrape-service-callback-token"];
|
|
621
|
+
const header = Array.isArray(raw) ? raw[0] : raw;
|
|
622
|
+
if (typeof header === "string" && header.length > 0) return header;
|
|
623
|
+
const auth = req.headers["authorization"];
|
|
624
|
+
if (typeof auth === "string" && auth.startsWith("Bearer ")) {
|
|
625
|
+
const token = auth.slice("Bearer ".length).trim();
|
|
626
|
+
return token.length > 0 ? token : void 0;
|
|
627
|
+
}
|
|
628
|
+
return void 0;
|
|
629
|
+
}
|
|
630
|
+
function requireCallbackAuthorized(req, res, callbackToken) {
|
|
631
|
+
if (!callbackToken || extractCallbackToken(req) === callbackToken) return true;
|
|
632
|
+
send(res, 401, {
|
|
633
|
+
code: "UNAUTHORIZED",
|
|
634
|
+
message: "service callback token is missing or invalid",
|
|
635
|
+
retryable: false
|
|
636
|
+
});
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
600
639
|
function defaultLocalAddress(host, port) {
|
|
601
640
|
if (host === "::1") return `http://[::1]:${port}`;
|
|
602
641
|
return `http://${host}:${port}`;
|
|
@@ -624,6 +663,10 @@ function sendMessageError(res, error) {
|
|
|
624
663
|
const message = error instanceof Error ? error.message : "message handler failed";
|
|
625
664
|
send(res, 500, { code: "HANDLER_FAILED", message, retryable: true });
|
|
626
665
|
}
|
|
666
|
+
function sendHandlerError(res, error) {
|
|
667
|
+
const message = error instanceof Error ? error.message : "action handler failed";
|
|
668
|
+
send(res, 500, { code: "ACTION_FAILED", message, retryable: false });
|
|
669
|
+
}
|
|
627
670
|
async function serveCeService(opts) {
|
|
628
671
|
const host = opts.host ?? "0.0.0.0";
|
|
629
672
|
let agent;
|
|
@@ -639,6 +682,7 @@ async function serveCeService(opts) {
|
|
|
639
682
|
send(res, 503, { code: "NOT_READY", message: "service not registered yet", retryable: true });
|
|
640
683
|
return;
|
|
641
684
|
}
|
|
685
|
+
if (!requireCallbackAuthorized(req, res, opts.callbackToken)) return;
|
|
642
686
|
const raw = await readBody(req);
|
|
643
687
|
let payload;
|
|
644
688
|
try {
|
|
@@ -655,6 +699,83 @@ async function serveCeService(opts) {
|
|
|
655
699
|
send(res, 200, reply);
|
|
656
700
|
return;
|
|
657
701
|
}
|
|
702
|
+
if (req.method === "GET" && req.url === "/actions") {
|
|
703
|
+
if (!requireCallbackAuthorized(req, res, opts.callbackToken)) return;
|
|
704
|
+
send(
|
|
705
|
+
res,
|
|
706
|
+
200,
|
|
707
|
+
(opts.actions ?? []).map((action) => ({
|
|
708
|
+
name: action.name,
|
|
709
|
+
label: action.label,
|
|
710
|
+
description: action.description,
|
|
711
|
+
category: action.category,
|
|
712
|
+
order: action.order,
|
|
713
|
+
requiresPrepare: typeof action.prepare === "function" ? true : void 0,
|
|
714
|
+
inputSchema: action.inputSchema
|
|
715
|
+
}))
|
|
716
|
+
);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const prepareMatch = req.url?.match(/^\/actions\/([^/]+)\/prepare$/);
|
|
720
|
+
if (req.method === "POST" && prepareMatch) {
|
|
721
|
+
if (!requireCallbackAuthorized(req, res, opts.callbackToken)) return;
|
|
722
|
+
const name = decodeURIComponent(prepareMatch[1]);
|
|
723
|
+
const action = (opts.actions ?? []).find((candidate) => candidate.name === name);
|
|
724
|
+
if (!action) {
|
|
725
|
+
send(res, 404, { code: "ACTION_NOT_FOUND", message: `action ${name} not found`, retryable: false });
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (typeof action.prepare !== "function") {
|
|
729
|
+
send(res, 400, { code: "ACTION_NOT_PREPARABLE", message: `action ${name} has no prepare step`, retryable: false });
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const raw = await readBody(req);
|
|
733
|
+
let payload;
|
|
734
|
+
try {
|
|
735
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
736
|
+
} catch (error) {
|
|
737
|
+
send(res, 400, {
|
|
738
|
+
code: "VALIDATION_FAILED",
|
|
739
|
+
message: error instanceof Error ? error.message : "invalid JSON request body",
|
|
740
|
+
retryable: false
|
|
741
|
+
});
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
try {
|
|
745
|
+
send(res, 200, await action.prepare(payload) ?? {});
|
|
746
|
+
} catch (error) {
|
|
747
|
+
sendHandlerError(res, error);
|
|
748
|
+
}
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const executeMatch = req.url?.match(/^\/actions\/([^/]+)\/execute$/);
|
|
752
|
+
if (req.method === "POST" && executeMatch) {
|
|
753
|
+
if (!requireCallbackAuthorized(req, res, opts.callbackToken)) return;
|
|
754
|
+
const name = decodeURIComponent(executeMatch[1]);
|
|
755
|
+
const action = (opts.actions ?? []).find((candidate) => candidate.name === name);
|
|
756
|
+
if (!action) {
|
|
757
|
+
send(res, 404, { code: "ACTION_NOT_FOUND", message: `action ${name} not found`, retryable: false });
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const raw = await readBody(req);
|
|
761
|
+
let payload;
|
|
762
|
+
try {
|
|
763
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
764
|
+
} catch (error) {
|
|
765
|
+
send(res, 400, {
|
|
766
|
+
code: "VALIDATION_FAILED",
|
|
767
|
+
message: error instanceof Error ? error.message : "invalid JSON request body",
|
|
768
|
+
retryable: false
|
|
769
|
+
});
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
try {
|
|
773
|
+
send(res, 200, await action.execute(payload));
|
|
774
|
+
} catch (error) {
|
|
775
|
+
sendHandlerError(res, error);
|
|
776
|
+
}
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
658
779
|
send(res, 404, { code: "NOT_FOUND", message: "not found", retryable: false });
|
|
659
780
|
} catch (e) {
|
|
660
781
|
sendMessageError(res, e);
|
|
@@ -678,6 +799,7 @@ async function serveCeService(opts) {
|
|
|
678
799
|
address,
|
|
679
800
|
instanceId: opts.instanceId,
|
|
680
801
|
registrationToken: opts.registrationToken,
|
|
802
|
+
controlToken: opts.controlToken,
|
|
681
803
|
fetchImpl: opts.fetchImpl
|
|
682
804
|
});
|
|
683
805
|
await agent.register();
|
package/dist/index.d.cts
CHANGED
|
@@ -283,9 +283,21 @@ interface CeServiceAgentOptions {
|
|
|
283
283
|
* `x-xtrape-registration-token` header on register.
|
|
284
284
|
*/
|
|
285
285
|
registrationToken?: string;
|
|
286
|
+
/**
|
|
287
|
+
* 0.6.x control-plane token. When server-ce has `SERVER_CE_CONTROL_TOKEN` set,
|
|
288
|
+
* older non-registration routes may require it. Current server-ce returns a
|
|
289
|
+
* per-instance access token during registration and the agent uses that token
|
|
290
|
+
* for heartbeat/deregister; this control token is a compatibility fallback
|
|
291
|
+
* (see xtrape-server-ce#12).
|
|
292
|
+
*/
|
|
293
|
+
controlToken?: string;
|
|
286
294
|
/** injectable fetch (tests); defaults to global fetch */
|
|
287
295
|
fetchImpl?: typeof fetch;
|
|
288
296
|
}
|
|
297
|
+
interface CeServiceRegistrationResult {
|
|
298
|
+
instanceId: string;
|
|
299
|
+
accessToken?: string;
|
|
300
|
+
}
|
|
289
301
|
type CeMessageReply = string | {
|
|
290
302
|
content: string;
|
|
291
303
|
messageType?: ConversationMessage["messageType"];
|
|
@@ -301,16 +313,22 @@ declare class CeServiceAgent {
|
|
|
301
313
|
private readonly address;
|
|
302
314
|
private readonly tenant;
|
|
303
315
|
private readonly registrationToken?;
|
|
316
|
+
private readonly controlToken?;
|
|
304
317
|
private readonly fetchImpl;
|
|
318
|
+
private accessToken?;
|
|
305
319
|
private health;
|
|
306
320
|
private hbTimer?;
|
|
307
321
|
constructor(opts: CeServiceAgentOptions);
|
|
322
|
+
/**
|
|
323
|
+
* Header carrying the per-instance access token returned by registration.
|
|
324
|
+
* Falls back to the transitional control token for compatibility with older
|
|
325
|
+
* server-ce deployments that did not mint per-instance access tokens yet.
|
|
326
|
+
*/
|
|
327
|
+
private lifecycleAuthHeaders;
|
|
308
328
|
/** The ServiceInstance this agent reports on register/heartbeat. */
|
|
309
329
|
instance(): ServiceInstance;
|
|
310
330
|
/** POST /v1/instances { manifest, instance } (sends the registration token if set). */
|
|
311
|
-
register(): Promise<
|
|
312
|
-
instanceId: string;
|
|
313
|
-
}>;
|
|
331
|
+
register(): Promise<CeServiceRegistrationResult>;
|
|
314
332
|
/**
|
|
315
333
|
* Lifecycle convenience: register, then start periodic heartbeats. Returns a
|
|
316
334
|
* `stop()` that stops heartbeats and best-effort deregisters — so a service
|
|
@@ -339,7 +357,7 @@ declare class CeServiceAgent {
|
|
|
339
357
|
|
|
340
358
|
/**
|
|
341
359
|
* One-call bootstrap for an `xtrape-service`: start an HTTP listener that serves
|
|
342
|
-
* `POST /messages
|
|
360
|
+
* `POST /messages`, action endpoints (and `GET /health`), register the instance to xtrape-server-ce,
|
|
343
361
|
* and run heartbeats. Returns a handle whose `close()` stops heartbeats,
|
|
344
362
|
* deregisters, and closes the listener.
|
|
345
363
|
*
|
|
@@ -352,8 +370,25 @@ interface ServeCeServiceOptions {
|
|
|
352
370
|
manifest: ServiceManifest;
|
|
353
371
|
/** business handler invoked per inbound ConversationMessage */
|
|
354
372
|
handler: CeMessageHandler;
|
|
373
|
+
/** Optional CE action handlers exposed over GET /actions, POST /actions/:name/prepare, and POST /actions/:name/execute. */
|
|
374
|
+
actions?: CeServiceActionDefinition[];
|
|
355
375
|
/** shared registration token, if server-ce requires one */
|
|
356
376
|
registrationToken?: string;
|
|
377
|
+
/**
|
|
378
|
+
* 0.6.x control-plane token forwarded to server-ce on heartbeat/deregister
|
|
379
|
+
* only as a compatibility fallback. Current server-ce returns a per-instance
|
|
380
|
+
* access token during registration and the SDK uses that first.
|
|
381
|
+
*/
|
|
382
|
+
controlToken?: string;
|
|
383
|
+
/**
|
|
384
|
+
* Optional inbound callback token. When set, `POST /messages` and action
|
|
385
|
+
* endpoints (`GET /actions`, `POST /actions/:name/prepare`,
|
|
386
|
+
* `POST /actions/:name/execute`) must include
|
|
387
|
+
* `x-xtrape-service-callback-token: <callbackToken>` (or
|
|
388
|
+
* `Authorization: Bearer <callbackToken>`); otherwise the request is rejected
|
|
389
|
+
* with 401. Lets a service require authenticated callbacks from server-ce.
|
|
390
|
+
*/
|
|
391
|
+
callbackToken?: string;
|
|
357
392
|
/** listen port; 0 picks an ephemeral port (default 0) */
|
|
358
393
|
port?: number;
|
|
359
394
|
/** listen host (default 0.0.0.0) */
|
|
@@ -372,6 +407,36 @@ interface ServeCeServiceOptions {
|
|
|
372
407
|
/** injectable fetch (tests); defaults to global fetch */
|
|
373
408
|
fetchImpl?: typeof fetch;
|
|
374
409
|
}
|
|
410
|
+
/**
|
|
411
|
+
* What a two-phase action's prepare step returns to the operator UI:
|
|
412
|
+
* `initialPayload` seeds the execute form, `currentState` is contextual
|
|
413
|
+
* data to display (e.g. the accounts/balances the decision is based on),
|
|
414
|
+
* and `inputSchema` optionally overrides the static schema with live options.
|
|
415
|
+
*/
|
|
416
|
+
interface CeActionPrepareResult {
|
|
417
|
+
/** Longer operator-facing explanation shown on the execution screen. */
|
|
418
|
+
longDescription?: string;
|
|
419
|
+
initialPayload?: Record<string, unknown>;
|
|
420
|
+
currentState?: Record<string, unknown>;
|
|
421
|
+
inputSchema?: Record<string, unknown>;
|
|
422
|
+
}
|
|
423
|
+
interface CeServiceActionDefinition {
|
|
424
|
+
name: string;
|
|
425
|
+
label: string;
|
|
426
|
+
description?: string;
|
|
427
|
+
/** Operator-facing grouping shown by Panel (capsule ActionDefinition heritage), e.g. "diagnostics". */
|
|
428
|
+
category?: string;
|
|
429
|
+
/** Display order within the category; lower sorts first. */
|
|
430
|
+
order?: number;
|
|
431
|
+
inputSchema?: Record<string, unknown>;
|
|
432
|
+
/**
|
|
433
|
+
* Optional prepare step. When present the action is two-phase: server-ce
|
|
434
|
+
* calls this before the operator fills the form to fetch live defaults and
|
|
435
|
+
* current state. Advertised as `requiresPrepare: true` in the action list.
|
|
436
|
+
*/
|
|
437
|
+
prepare?(input?: unknown): CeActionPrepareResult | Promise<CeActionPrepareResult>;
|
|
438
|
+
execute(input: unknown): unknown | Promise<unknown>;
|
|
439
|
+
}
|
|
375
440
|
interface ServeCeServiceHandle {
|
|
376
441
|
agent: CeServiceAgent;
|
|
377
442
|
/** advertised address registered with server-ce */
|
|
@@ -383,4 +448,4 @@ interface ServeCeServiceHandle {
|
|
|
383
448
|
}
|
|
384
449
|
declare function serveCeService(opts: ServeCeServiceOptions): Promise<ServeCeServiceHandle>;
|
|
385
450
|
|
|
386
|
-
export { type ActionHandler, type ActionPrepareHandler, AgentApiClient, AgentApiError, AgentAuthError, type AgentLogLevel, type AgentLogRecord, type AgentMode, BusDepthExceededError, BusDisabledError, BusRateLimitedError, CapsuleAgent, type CapsuleAgentOptions, type CeMessageHandler, type CeMessageReply, CeServiceAgent, type CeServiceAgentOptions, type ConfigProvider, type ConsoleLogger, FileTokenStore, type HealthProvider, type LegacyServiceAgentOptions, NetworkError, type PublishBusEventInput, type PublishBusEventResult, type RegisteredAction, RegistrationError, type ServeCeServiceHandle, type ServeCeServiceOptions, type ServiceSnapshot, type StructuredLogger, type TokenStore, type WorkerAgentOptions, type WorkerDefinition, type WorkerSnapshot, CapsuleAgent as XtrapeAgent, type XtrapeAgentOptions, serveCeService };
|
|
451
|
+
export { type ActionHandler, type ActionPrepareHandler, AgentApiClient, AgentApiError, AgentAuthError, type AgentLogLevel, type AgentLogRecord, type AgentMode, BusDepthExceededError, BusDisabledError, BusRateLimitedError, CapsuleAgent, type CapsuleAgentOptions, type CeActionPrepareResult, type CeMessageHandler, type CeMessageReply, type CeServiceActionDefinition, CeServiceAgent, type CeServiceAgentOptions, type ConfigProvider, type ConsoleLogger, FileTokenStore, type HealthProvider, type LegacyServiceAgentOptions, NetworkError, type PublishBusEventInput, type PublishBusEventResult, type RegisteredAction, RegistrationError, type ServeCeServiceHandle, type ServeCeServiceOptions, type ServiceSnapshot, type StructuredLogger, type TokenStore, type WorkerAgentOptions, type WorkerDefinition, type WorkerSnapshot, CapsuleAgent as XtrapeAgent, type XtrapeAgentOptions, serveCeService };
|
package/dist/index.d.ts
CHANGED
|
@@ -283,9 +283,21 @@ interface CeServiceAgentOptions {
|
|
|
283
283
|
* `x-xtrape-registration-token` header on register.
|
|
284
284
|
*/
|
|
285
285
|
registrationToken?: string;
|
|
286
|
+
/**
|
|
287
|
+
* 0.6.x control-plane token. When server-ce has `SERVER_CE_CONTROL_TOKEN` set,
|
|
288
|
+
* older non-registration routes may require it. Current server-ce returns a
|
|
289
|
+
* per-instance access token during registration and the agent uses that token
|
|
290
|
+
* for heartbeat/deregister; this control token is a compatibility fallback
|
|
291
|
+
* (see xtrape-server-ce#12).
|
|
292
|
+
*/
|
|
293
|
+
controlToken?: string;
|
|
286
294
|
/** injectable fetch (tests); defaults to global fetch */
|
|
287
295
|
fetchImpl?: typeof fetch;
|
|
288
296
|
}
|
|
297
|
+
interface CeServiceRegistrationResult {
|
|
298
|
+
instanceId: string;
|
|
299
|
+
accessToken?: string;
|
|
300
|
+
}
|
|
289
301
|
type CeMessageReply = string | {
|
|
290
302
|
content: string;
|
|
291
303
|
messageType?: ConversationMessage["messageType"];
|
|
@@ -301,16 +313,22 @@ declare class CeServiceAgent {
|
|
|
301
313
|
private readonly address;
|
|
302
314
|
private readonly tenant;
|
|
303
315
|
private readonly registrationToken?;
|
|
316
|
+
private readonly controlToken?;
|
|
304
317
|
private readonly fetchImpl;
|
|
318
|
+
private accessToken?;
|
|
305
319
|
private health;
|
|
306
320
|
private hbTimer?;
|
|
307
321
|
constructor(opts: CeServiceAgentOptions);
|
|
322
|
+
/**
|
|
323
|
+
* Header carrying the per-instance access token returned by registration.
|
|
324
|
+
* Falls back to the transitional control token for compatibility with older
|
|
325
|
+
* server-ce deployments that did not mint per-instance access tokens yet.
|
|
326
|
+
*/
|
|
327
|
+
private lifecycleAuthHeaders;
|
|
308
328
|
/** The ServiceInstance this agent reports on register/heartbeat. */
|
|
309
329
|
instance(): ServiceInstance;
|
|
310
330
|
/** POST /v1/instances { manifest, instance } (sends the registration token if set). */
|
|
311
|
-
register(): Promise<
|
|
312
|
-
instanceId: string;
|
|
313
|
-
}>;
|
|
331
|
+
register(): Promise<CeServiceRegistrationResult>;
|
|
314
332
|
/**
|
|
315
333
|
* Lifecycle convenience: register, then start periodic heartbeats. Returns a
|
|
316
334
|
* `stop()` that stops heartbeats and best-effort deregisters — so a service
|
|
@@ -339,7 +357,7 @@ declare class CeServiceAgent {
|
|
|
339
357
|
|
|
340
358
|
/**
|
|
341
359
|
* One-call bootstrap for an `xtrape-service`: start an HTTP listener that serves
|
|
342
|
-
* `POST /messages
|
|
360
|
+
* `POST /messages`, action endpoints (and `GET /health`), register the instance to xtrape-server-ce,
|
|
343
361
|
* and run heartbeats. Returns a handle whose `close()` stops heartbeats,
|
|
344
362
|
* deregisters, and closes the listener.
|
|
345
363
|
*
|
|
@@ -352,8 +370,25 @@ interface ServeCeServiceOptions {
|
|
|
352
370
|
manifest: ServiceManifest;
|
|
353
371
|
/** business handler invoked per inbound ConversationMessage */
|
|
354
372
|
handler: CeMessageHandler;
|
|
373
|
+
/** Optional CE action handlers exposed over GET /actions, POST /actions/:name/prepare, and POST /actions/:name/execute. */
|
|
374
|
+
actions?: CeServiceActionDefinition[];
|
|
355
375
|
/** shared registration token, if server-ce requires one */
|
|
356
376
|
registrationToken?: string;
|
|
377
|
+
/**
|
|
378
|
+
* 0.6.x control-plane token forwarded to server-ce on heartbeat/deregister
|
|
379
|
+
* only as a compatibility fallback. Current server-ce returns a per-instance
|
|
380
|
+
* access token during registration and the SDK uses that first.
|
|
381
|
+
*/
|
|
382
|
+
controlToken?: string;
|
|
383
|
+
/**
|
|
384
|
+
* Optional inbound callback token. When set, `POST /messages` and action
|
|
385
|
+
* endpoints (`GET /actions`, `POST /actions/:name/prepare`,
|
|
386
|
+
* `POST /actions/:name/execute`) must include
|
|
387
|
+
* `x-xtrape-service-callback-token: <callbackToken>` (or
|
|
388
|
+
* `Authorization: Bearer <callbackToken>`); otherwise the request is rejected
|
|
389
|
+
* with 401. Lets a service require authenticated callbacks from server-ce.
|
|
390
|
+
*/
|
|
391
|
+
callbackToken?: string;
|
|
357
392
|
/** listen port; 0 picks an ephemeral port (default 0) */
|
|
358
393
|
port?: number;
|
|
359
394
|
/** listen host (default 0.0.0.0) */
|
|
@@ -372,6 +407,36 @@ interface ServeCeServiceOptions {
|
|
|
372
407
|
/** injectable fetch (tests); defaults to global fetch */
|
|
373
408
|
fetchImpl?: typeof fetch;
|
|
374
409
|
}
|
|
410
|
+
/**
|
|
411
|
+
* What a two-phase action's prepare step returns to the operator UI:
|
|
412
|
+
* `initialPayload` seeds the execute form, `currentState` is contextual
|
|
413
|
+
* data to display (e.g. the accounts/balances the decision is based on),
|
|
414
|
+
* and `inputSchema` optionally overrides the static schema with live options.
|
|
415
|
+
*/
|
|
416
|
+
interface CeActionPrepareResult {
|
|
417
|
+
/** Longer operator-facing explanation shown on the execution screen. */
|
|
418
|
+
longDescription?: string;
|
|
419
|
+
initialPayload?: Record<string, unknown>;
|
|
420
|
+
currentState?: Record<string, unknown>;
|
|
421
|
+
inputSchema?: Record<string, unknown>;
|
|
422
|
+
}
|
|
423
|
+
interface CeServiceActionDefinition {
|
|
424
|
+
name: string;
|
|
425
|
+
label: string;
|
|
426
|
+
description?: string;
|
|
427
|
+
/** Operator-facing grouping shown by Panel (capsule ActionDefinition heritage), e.g. "diagnostics". */
|
|
428
|
+
category?: string;
|
|
429
|
+
/** Display order within the category; lower sorts first. */
|
|
430
|
+
order?: number;
|
|
431
|
+
inputSchema?: Record<string, unknown>;
|
|
432
|
+
/**
|
|
433
|
+
* Optional prepare step. When present the action is two-phase: server-ce
|
|
434
|
+
* calls this before the operator fills the form to fetch live defaults and
|
|
435
|
+
* current state. Advertised as `requiresPrepare: true` in the action list.
|
|
436
|
+
*/
|
|
437
|
+
prepare?(input?: unknown): CeActionPrepareResult | Promise<CeActionPrepareResult>;
|
|
438
|
+
execute(input: unknown): unknown | Promise<unknown>;
|
|
439
|
+
}
|
|
375
440
|
interface ServeCeServiceHandle {
|
|
376
441
|
agent: CeServiceAgent;
|
|
377
442
|
/** advertised address registered with server-ce */
|
|
@@ -383,4 +448,4 @@ interface ServeCeServiceHandle {
|
|
|
383
448
|
}
|
|
384
449
|
declare function serveCeService(opts: ServeCeServiceOptions): Promise<ServeCeServiceHandle>;
|
|
385
450
|
|
|
386
|
-
export { type ActionHandler, type ActionPrepareHandler, AgentApiClient, AgentApiError, AgentAuthError, type AgentLogLevel, type AgentLogRecord, type AgentMode, BusDepthExceededError, BusDisabledError, BusRateLimitedError, CapsuleAgent, type CapsuleAgentOptions, type CeMessageHandler, type CeMessageReply, CeServiceAgent, type CeServiceAgentOptions, type ConfigProvider, type ConsoleLogger, FileTokenStore, type HealthProvider, type LegacyServiceAgentOptions, NetworkError, type PublishBusEventInput, type PublishBusEventResult, type RegisteredAction, RegistrationError, type ServeCeServiceHandle, type ServeCeServiceOptions, type ServiceSnapshot, type StructuredLogger, type TokenStore, type WorkerAgentOptions, type WorkerDefinition, type WorkerSnapshot, CapsuleAgent as XtrapeAgent, type XtrapeAgentOptions, serveCeService };
|
|
451
|
+
export { type ActionHandler, type ActionPrepareHandler, AgentApiClient, AgentApiError, AgentAuthError, type AgentLogLevel, type AgentLogRecord, type AgentMode, BusDepthExceededError, BusDisabledError, BusRateLimitedError, CapsuleAgent, type CapsuleAgentOptions, type CeActionPrepareResult, type CeMessageHandler, type CeMessageReply, type CeServiceActionDefinition, CeServiceAgent, type CeServiceAgentOptions, type ConfigProvider, type ConsoleLogger, FileTokenStore, type HealthProvider, type LegacyServiceAgentOptions, NetworkError, type PublishBusEventInput, type PublishBusEventResult, type RegisteredAction, RegistrationError, type ServeCeServiceHandle, type ServeCeServiceOptions, type ServiceSnapshot, type StructuredLogger, type TokenStore, type WorkerAgentOptions, type WorkerDefinition, type WorkerSnapshot, CapsuleAgent as XtrapeAgent, type XtrapeAgentOptions, serveCeService };
|
package/dist/index.js
CHANGED
|
@@ -412,7 +412,9 @@ var CeServiceAgent = class {
|
|
|
412
412
|
address;
|
|
413
413
|
tenant;
|
|
414
414
|
registrationToken;
|
|
415
|
+
controlToken;
|
|
415
416
|
fetchImpl;
|
|
417
|
+
accessToken;
|
|
416
418
|
health = "HEALTHY";
|
|
417
419
|
hbTimer;
|
|
418
420
|
constructor(opts) {
|
|
@@ -423,8 +425,18 @@ var CeServiceAgent = class {
|
|
|
423
425
|
this.serviceId = opts.manifest.service.id;
|
|
424
426
|
this.instanceId = opts.instanceId ?? `${this.serviceId}-${randomUUID().slice(0, 8)}`;
|
|
425
427
|
this.registrationToken = opts.registrationToken;
|
|
428
|
+
this.controlToken = opts.controlToken;
|
|
426
429
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
427
430
|
}
|
|
431
|
+
/**
|
|
432
|
+
* Header carrying the per-instance access token returned by registration.
|
|
433
|
+
* Falls back to the transitional control token for compatibility with older
|
|
434
|
+
* server-ce deployments that did not mint per-instance access tokens yet.
|
|
435
|
+
*/
|
|
436
|
+
lifecycleAuthHeaders() {
|
|
437
|
+
if (this.accessToken) return { "x-xtrape-access-token": this.accessToken };
|
|
438
|
+
return this.controlToken ? { "x-xtrape-control-token": this.controlToken } : void 0;
|
|
439
|
+
}
|
|
428
440
|
/** The ServiceInstance this agent reports on register/heartbeat. */
|
|
429
441
|
instance() {
|
|
430
442
|
return ServiceInstanceSchema.parse({
|
|
@@ -440,8 +452,12 @@ var CeServiceAgent = class {
|
|
|
440
452
|
/** POST /v1/instances { manifest, instance } (sends the registration token if set). */
|
|
441
453
|
async register() {
|
|
442
454
|
const headers = this.registrationToken ? { "x-xtrape-registration-token": this.registrationToken } : void 0;
|
|
443
|
-
await this.post("/v1/instances", { manifest: this.manifest, instance: this.instance() }, headers);
|
|
444
|
-
|
|
455
|
+
const response = await this.post("/v1/instances", { manifest: this.manifest, instance: this.instance() }, headers);
|
|
456
|
+
const accessToken = typeof response === "object" && response !== null && "accessToken" in response ? response.accessToken : void 0;
|
|
457
|
+
if (typeof accessToken === "string" && accessToken.length > 0) {
|
|
458
|
+
this.accessToken = accessToken;
|
|
459
|
+
}
|
|
460
|
+
return { instanceId: this.instanceId, ...this.accessToken ? { accessToken: this.accessToken } : {} };
|
|
445
461
|
}
|
|
446
462
|
/**
|
|
447
463
|
* Lifecycle convenience: register, then start periodic heartbeats. Returns a
|
|
@@ -471,7 +487,7 @@ var CeServiceAgent = class {
|
|
|
471
487
|
health: this.health,
|
|
472
488
|
...metricsSummary ? { metricsSummary } : {}
|
|
473
489
|
};
|
|
474
|
-
await this.post(`/v1/instances/${encodeURIComponent(this.instanceId)}/heartbeat`, hb);
|
|
490
|
+
await this.post(`/v1/instances/${encodeURIComponent(this.instanceId)}/heartbeat`, hb, this.lifecycleAuthHeaders());
|
|
475
491
|
}
|
|
476
492
|
/** Start a periodic heartbeat; returns a stop function. Timer is unref'd. */
|
|
477
493
|
startHeartbeat(intervalMs = 1e4, metrics) {
|
|
@@ -491,7 +507,10 @@ var CeServiceAgent = class {
|
|
|
491
507
|
}
|
|
492
508
|
/** DELETE /v1/instances/{id} — graceful deregister. */
|
|
493
509
|
async deregister() {
|
|
494
|
-
await this.fetchJson(`/v1/instances/${encodeURIComponent(this.instanceId)}`, {
|
|
510
|
+
await this.fetchJson(`/v1/instances/${encodeURIComponent(this.instanceId)}`, {
|
|
511
|
+
method: "DELETE",
|
|
512
|
+
...this.lifecycleAuthHeaders() ? { headers: this.lifecycleAuthHeaders() } : {}
|
|
513
|
+
});
|
|
495
514
|
}
|
|
496
515
|
/**
|
|
497
516
|
* Validate an inbound message, run the handler, and build the reply message
|
|
@@ -563,6 +582,26 @@ function readBody(req) {
|
|
|
563
582
|
function isLocalOnlyHost(host) {
|
|
564
583
|
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
565
584
|
}
|
|
585
|
+
function extractCallbackToken(req) {
|
|
586
|
+
const raw = req.headers["x-xtrape-service-callback-token"];
|
|
587
|
+
const header = Array.isArray(raw) ? raw[0] : raw;
|
|
588
|
+
if (typeof header === "string" && header.length > 0) return header;
|
|
589
|
+
const auth = req.headers["authorization"];
|
|
590
|
+
if (typeof auth === "string" && auth.startsWith("Bearer ")) {
|
|
591
|
+
const token = auth.slice("Bearer ".length).trim();
|
|
592
|
+
return token.length > 0 ? token : void 0;
|
|
593
|
+
}
|
|
594
|
+
return void 0;
|
|
595
|
+
}
|
|
596
|
+
function requireCallbackAuthorized(req, res, callbackToken) {
|
|
597
|
+
if (!callbackToken || extractCallbackToken(req) === callbackToken) return true;
|
|
598
|
+
send(res, 401, {
|
|
599
|
+
code: "UNAUTHORIZED",
|
|
600
|
+
message: "service callback token is missing or invalid",
|
|
601
|
+
retryable: false
|
|
602
|
+
});
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
566
605
|
function defaultLocalAddress(host, port) {
|
|
567
606
|
if (host === "::1") return `http://[::1]:${port}`;
|
|
568
607
|
return `http://${host}:${port}`;
|
|
@@ -590,6 +629,10 @@ function sendMessageError(res, error) {
|
|
|
590
629
|
const message = error instanceof Error ? error.message : "message handler failed";
|
|
591
630
|
send(res, 500, { code: "HANDLER_FAILED", message, retryable: true });
|
|
592
631
|
}
|
|
632
|
+
function sendHandlerError(res, error) {
|
|
633
|
+
const message = error instanceof Error ? error.message : "action handler failed";
|
|
634
|
+
send(res, 500, { code: "ACTION_FAILED", message, retryable: false });
|
|
635
|
+
}
|
|
593
636
|
async function serveCeService(opts) {
|
|
594
637
|
const host = opts.host ?? "0.0.0.0";
|
|
595
638
|
let agent;
|
|
@@ -605,6 +648,7 @@ async function serveCeService(opts) {
|
|
|
605
648
|
send(res, 503, { code: "NOT_READY", message: "service not registered yet", retryable: true });
|
|
606
649
|
return;
|
|
607
650
|
}
|
|
651
|
+
if (!requireCallbackAuthorized(req, res, opts.callbackToken)) return;
|
|
608
652
|
const raw = await readBody(req);
|
|
609
653
|
let payload;
|
|
610
654
|
try {
|
|
@@ -621,6 +665,83 @@ async function serveCeService(opts) {
|
|
|
621
665
|
send(res, 200, reply);
|
|
622
666
|
return;
|
|
623
667
|
}
|
|
668
|
+
if (req.method === "GET" && req.url === "/actions") {
|
|
669
|
+
if (!requireCallbackAuthorized(req, res, opts.callbackToken)) return;
|
|
670
|
+
send(
|
|
671
|
+
res,
|
|
672
|
+
200,
|
|
673
|
+
(opts.actions ?? []).map((action) => ({
|
|
674
|
+
name: action.name,
|
|
675
|
+
label: action.label,
|
|
676
|
+
description: action.description,
|
|
677
|
+
category: action.category,
|
|
678
|
+
order: action.order,
|
|
679
|
+
requiresPrepare: typeof action.prepare === "function" ? true : void 0,
|
|
680
|
+
inputSchema: action.inputSchema
|
|
681
|
+
}))
|
|
682
|
+
);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const prepareMatch = req.url?.match(/^\/actions\/([^/]+)\/prepare$/);
|
|
686
|
+
if (req.method === "POST" && prepareMatch) {
|
|
687
|
+
if (!requireCallbackAuthorized(req, res, opts.callbackToken)) return;
|
|
688
|
+
const name = decodeURIComponent(prepareMatch[1]);
|
|
689
|
+
const action = (opts.actions ?? []).find((candidate) => candidate.name === name);
|
|
690
|
+
if (!action) {
|
|
691
|
+
send(res, 404, { code: "ACTION_NOT_FOUND", message: `action ${name} not found`, retryable: false });
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (typeof action.prepare !== "function") {
|
|
695
|
+
send(res, 400, { code: "ACTION_NOT_PREPARABLE", message: `action ${name} has no prepare step`, retryable: false });
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const raw = await readBody(req);
|
|
699
|
+
let payload;
|
|
700
|
+
try {
|
|
701
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
702
|
+
} catch (error) {
|
|
703
|
+
send(res, 400, {
|
|
704
|
+
code: "VALIDATION_FAILED",
|
|
705
|
+
message: error instanceof Error ? error.message : "invalid JSON request body",
|
|
706
|
+
retryable: false
|
|
707
|
+
});
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
try {
|
|
711
|
+
send(res, 200, await action.prepare(payload) ?? {});
|
|
712
|
+
} catch (error) {
|
|
713
|
+
sendHandlerError(res, error);
|
|
714
|
+
}
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const executeMatch = req.url?.match(/^\/actions\/([^/]+)\/execute$/);
|
|
718
|
+
if (req.method === "POST" && executeMatch) {
|
|
719
|
+
if (!requireCallbackAuthorized(req, res, opts.callbackToken)) return;
|
|
720
|
+
const name = decodeURIComponent(executeMatch[1]);
|
|
721
|
+
const action = (opts.actions ?? []).find((candidate) => candidate.name === name);
|
|
722
|
+
if (!action) {
|
|
723
|
+
send(res, 404, { code: "ACTION_NOT_FOUND", message: `action ${name} not found`, retryable: false });
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const raw = await readBody(req);
|
|
727
|
+
let payload;
|
|
728
|
+
try {
|
|
729
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
730
|
+
} catch (error) {
|
|
731
|
+
send(res, 400, {
|
|
732
|
+
code: "VALIDATION_FAILED",
|
|
733
|
+
message: error instanceof Error ? error.message : "invalid JSON request body",
|
|
734
|
+
retryable: false
|
|
735
|
+
});
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
send(res, 200, await action.execute(payload));
|
|
740
|
+
} catch (error) {
|
|
741
|
+
sendHandlerError(res, error);
|
|
742
|
+
}
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
624
745
|
send(res, 404, { code: "NOT_FOUND", message: "not found", retryable: false });
|
|
625
746
|
} catch (e) {
|
|
626
747
|
sendMessageError(res, e);
|
|
@@ -644,6 +765,7 @@ async function serveCeService(opts) {
|
|
|
644
765
|
address,
|
|
645
766
|
instanceId: opts.instanceId,
|
|
646
767
|
registrationToken: opts.registrationToken,
|
|
768
|
+
controlToken: opts.controlToken,
|
|
647
769
|
fetchImpl: opts.fetchImpl
|
|
648
770
|
});
|
|
649
771
|
await agent.register();
|