@xtrape/capsule-agent-node 0.6.0 → 0.6.2

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 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`, `GET /health`),
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), and runs heartbeats so a service does not copy-paste lifecycle code:
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",
@@ -79,6 +88,38 @@ const svc = await serveCeService({
79
88
  process.on("SIGTERM", () => void svc.close()); // stop heartbeat + deregister + close
80
89
  ```
81
90
 
91
+ ### CE service actions
92
+
93
+ Pass `actions` to expose operator-triggerable operations over `GET /actions`,
94
+ `POST /actions/:name/prepare`, and `POST /actions/:name/execute`:
95
+
96
+ ```ts
97
+ const svc = await serveCeService({
98
+ // ...as above
99
+ actions: [
100
+ {
101
+ name: "relogin",
102
+ label: "Re-login account",
103
+ // Optional two-phase prepare: fetch live defaults/current state before
104
+ // the operator fills the form (advertised as requiresPrepare: true).
105
+ prepare: async () => ({ initialPayload: { accountId: "acc-1" } }),
106
+ execute: async (input) => ({ ok: true }),
107
+ },
108
+ {
109
+ name: "full-sync",
110
+ label: "Full re-sync",
111
+ // Long-running action (server-ce#41 Stage B): when the pushed execute
112
+ // carries x-xtrape-command-id, the SDK replies 202 RUNNING immediately,
113
+ // runs execute in the background, and reports the outcome to server-ce
114
+ // with the per-instance access token. Against an older server-ce
115
+ // (no header) the action falls back to synchronous execution.
116
+ async: true,
117
+ execute: async (input) => runFullSync(input),
118
+ },
119
+ ],
120
+ });
121
+ ```
122
+
82
123
  ### Lower-level agent
83
124
 
84
125
  When the service already runs its own HTTP server, use `CeServiceAgent` directly:
@@ -89,6 +130,7 @@ import { CeServiceAgent } from "@xtrape/capsule-agent-node";
89
130
  const agent = new CeServiceAgent({
90
131
  serverUrl: "http://localhost:3000",
91
132
  registrationToken: process.env.SERVER_CE_REGISTRATION_TOKEN,
133
+ controlToken: process.env.SERVER_CE_CONTROL_TOKEN, // compatibility fallback only
92
134
  address: "http://127.0.0.1:8080",
93
135
  manifest: /* ServiceManifest */ undefined as never,
94
136
  });
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
- return { instanceId: this.instanceId };
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,31 @@ 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)}`, { method: "DELETE" });
544
+ await this.fetchJson(`/v1/instances/${encodeURIComponent(this.instanceId)}`, {
545
+ method: "DELETE",
546
+ ...this.lifecycleAuthHeaders() ? { headers: this.lifecycleAuthHeaders() } : {}
547
+ });
548
+ }
549
+ /**
550
+ * Report the outcome of an async action (server-ce#41 Stage B):
551
+ * POST /v1/instances/{instanceId}/commands/{commandId}/result using the
552
+ * per-instance access token. `outcome.result` carries the action payload for
553
+ * COMPLETED; errorCode/errorMessage describe a FAILED run.
554
+ */
555
+ async reportCommandResult(commandId, outcome) {
556
+ const body = {
557
+ contractVersion: import_capsule_contracts_node.CE_CONTRACT_VERSION,
558
+ commandId,
559
+ status: outcome.status,
560
+ ...outcome.result !== void 0 ? { result: outcome.result } : {},
561
+ ...outcome.errorCode ? { errorCode: outcome.errorCode } : {},
562
+ ...outcome.errorMessage ? { errorMessage: outcome.errorMessage } : {}
563
+ };
564
+ await this.post(
565
+ `/v1/instances/${encodeURIComponent(this.instanceId)}/commands/${encodeURIComponent(commandId)}/result`,
566
+ body,
567
+ this.lifecycleAuthHeaders()
568
+ );
529
569
  }
530
570
  /**
531
571
  * Validate an inbound message, run the handler, and build the reply message
@@ -582,6 +622,7 @@ var CeServiceAgent = class {
582
622
 
583
623
  // src/ce/serve.ts
584
624
  var import_node_http = require("http");
625
+ var import_capsule_contracts_node2 = require("@xtrape/capsule-contracts-node");
585
626
  function send(res, status, body) {
586
627
  res.writeHead(status, { "content-type": "application/json" });
587
628
  res.end(JSON.stringify(body));
@@ -597,6 +638,26 @@ function readBody(req) {
597
638
  function isLocalOnlyHost(host) {
598
639
  return host === "127.0.0.1" || host === "localhost" || host === "::1";
599
640
  }
641
+ function extractCallbackToken(req) {
642
+ const raw = req.headers["x-xtrape-service-callback-token"];
643
+ const header = Array.isArray(raw) ? raw[0] : raw;
644
+ if (typeof header === "string" && header.length > 0) return header;
645
+ const auth = req.headers["authorization"];
646
+ if (typeof auth === "string" && auth.startsWith("Bearer ")) {
647
+ const token = auth.slice("Bearer ".length).trim();
648
+ return token.length > 0 ? token : void 0;
649
+ }
650
+ return void 0;
651
+ }
652
+ function requireCallbackAuthorized(req, res, callbackToken) {
653
+ if (!callbackToken || extractCallbackToken(req) === callbackToken) return true;
654
+ send(res, 401, {
655
+ code: "UNAUTHORIZED",
656
+ message: "service callback token is missing or invalid",
657
+ retryable: false
658
+ });
659
+ return false;
660
+ }
600
661
  function defaultLocalAddress(host, port) {
601
662
  if (host === "::1") return `http://[::1]:${port}`;
602
663
  return `http://${host}:${port}`;
@@ -624,6 +685,10 @@ function sendMessageError(res, error) {
624
685
  const message = error instanceof Error ? error.message : "message handler failed";
625
686
  send(res, 500, { code: "HANDLER_FAILED", message, retryable: true });
626
687
  }
688
+ function sendHandlerError(res, error) {
689
+ const message = error instanceof Error ? error.message : "action handler failed";
690
+ send(res, 500, { code: "ACTION_FAILED", message, retryable: false });
691
+ }
627
692
  async function serveCeService(opts) {
628
693
  const host = opts.host ?? "0.0.0.0";
629
694
  let agent;
@@ -639,6 +704,7 @@ async function serveCeService(opts) {
639
704
  send(res, 503, { code: "NOT_READY", message: "service not registered yet", retryable: true });
640
705
  return;
641
706
  }
707
+ if (!requireCallbackAuthorized(req, res, opts.callbackToken)) return;
642
708
  const raw = await readBody(req);
643
709
  let payload;
644
710
  try {
@@ -655,6 +721,111 @@ async function serveCeService(opts) {
655
721
  send(res, 200, reply);
656
722
  return;
657
723
  }
724
+ if (req.method === "GET" && req.url === "/actions") {
725
+ if (!requireCallbackAuthorized(req, res, opts.callbackToken)) return;
726
+ send(
727
+ res,
728
+ 200,
729
+ (opts.actions ?? []).map((action) => ({
730
+ name: action.name,
731
+ label: action.label,
732
+ description: action.description,
733
+ category: action.category,
734
+ order: action.order,
735
+ requiresPrepare: typeof action.prepare === "function" ? true : void 0,
736
+ async: action.async === true ? true : void 0,
737
+ inputSchema: action.inputSchema
738
+ }))
739
+ );
740
+ return;
741
+ }
742
+ const prepareMatch = req.url?.match(/^\/actions\/([^/]+)\/prepare$/);
743
+ if (req.method === "POST" && prepareMatch) {
744
+ if (!requireCallbackAuthorized(req, res, opts.callbackToken)) return;
745
+ const name = decodeURIComponent(prepareMatch[1]);
746
+ const action = (opts.actions ?? []).find((candidate) => candidate.name === name);
747
+ if (!action) {
748
+ send(res, 404, { code: "ACTION_NOT_FOUND", message: `action ${name} not found`, retryable: false });
749
+ return;
750
+ }
751
+ if (typeof action.prepare !== "function") {
752
+ send(res, 400, { code: "ACTION_NOT_PREPARABLE", message: `action ${name} has no prepare step`, retryable: false });
753
+ return;
754
+ }
755
+ const raw = await readBody(req);
756
+ let payload;
757
+ try {
758
+ payload = raw ? JSON.parse(raw) : {};
759
+ } catch (error) {
760
+ send(res, 400, {
761
+ code: "VALIDATION_FAILED",
762
+ message: error instanceof Error ? error.message : "invalid JSON request body",
763
+ retryable: false
764
+ });
765
+ return;
766
+ }
767
+ try {
768
+ send(res, 200, await action.prepare(payload) ?? {});
769
+ } catch (error) {
770
+ sendHandlerError(res, error);
771
+ }
772
+ return;
773
+ }
774
+ const executeMatch = req.url?.match(/^\/actions\/([^/]+)\/execute$/);
775
+ if (req.method === "POST" && executeMatch) {
776
+ if (!requireCallbackAuthorized(req, res, opts.callbackToken)) return;
777
+ const name = decodeURIComponent(executeMatch[1]);
778
+ const action = (opts.actions ?? []).find((candidate) => candidate.name === name);
779
+ if (!action) {
780
+ send(res, 404, { code: "ACTION_NOT_FOUND", message: `action ${name} not found`, retryable: false });
781
+ return;
782
+ }
783
+ const raw = await readBody(req);
784
+ let payload;
785
+ try {
786
+ payload = raw ? JSON.parse(raw) : {};
787
+ } catch (error) {
788
+ send(res, 400, {
789
+ code: "VALIDATION_FAILED",
790
+ message: error instanceof Error ? error.message : "invalid JSON request body",
791
+ retryable: false
792
+ });
793
+ return;
794
+ }
795
+ const commandIdRaw = req.headers["x-xtrape-command-id"];
796
+ const commandId = Array.isArray(commandIdRaw) ? commandIdRaw[0] : commandIdRaw;
797
+ if (action.async === true && typeof commandId === "string" && commandId.length > 0 && agent) {
798
+ const reporter = agent;
799
+ send(res, 202, { contractVersion: import_capsule_contracts_node2.CE_CONTRACT_VERSION, status: "RUNNING", commandId });
800
+ void (async () => {
801
+ let outcome;
802
+ try {
803
+ const result = await action.execute(payload);
804
+ outcome = {
805
+ status: "COMPLETED",
806
+ ...result !== void 0 && result !== null && typeof result === "object" && !Array.isArray(result) ? { result } : result !== void 0 ? { result: { value: result } } : {}
807
+ };
808
+ } catch (error) {
809
+ outcome = {
810
+ status: "FAILED",
811
+ errorCode: "ACTION_FAILED",
812
+ errorMessage: error instanceof Error ? error.message : "action handler failed"
813
+ };
814
+ }
815
+ try {
816
+ await reporter.reportCommandResult(commandId, outcome);
817
+ } catch {
818
+ }
819
+ })();
820
+ return;
821
+ }
822
+ try {
823
+ send(res, 200, await action.execute(payload));
824
+ } catch (error) {
825
+ sendHandlerError(res, error);
826
+ }
827
+ return;
828
+ }
658
829
  send(res, 404, { code: "NOT_FOUND", message: "not found", retryable: false });
659
830
  } catch (e) {
660
831
  sendMessageError(res, e);
@@ -678,6 +849,7 @@ async function serveCeService(opts) {
678
849
  address,
679
850
  instanceId: opts.instanceId,
680
851
  registrationToken: opts.registrationToken,
852
+ controlToken: opts.controlToken,
681
853
  fetchImpl: opts.fetchImpl
682
854
  });
683
855
  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
@@ -328,6 +346,18 @@ declare class CeServiceAgent {
328
346
  stopHeartbeat(): void;
329
347
  /** DELETE /v1/instances/{id} — graceful deregister. */
330
348
  deregister(): Promise<void>;
349
+ /**
350
+ * Report the outcome of an async action (server-ce#41 Stage B):
351
+ * POST /v1/instances/{instanceId}/commands/{commandId}/result using the
352
+ * per-instance access token. `outcome.result` carries the action payload for
353
+ * COMPLETED; errorCode/errorMessage describe a FAILED run.
354
+ */
355
+ reportCommandResult(commandId: string, outcome: {
356
+ status: "COMPLETED" | "FAILED";
357
+ result?: Record<string, unknown>;
358
+ errorCode?: string;
359
+ errorMessage?: string;
360
+ }): Promise<void>;
331
361
  /**
332
362
  * Validate an inbound message, run the handler, and build the reply message
333
363
  * (senderType=SERVICE, targeted back at the sender, with replyToMessageId).
@@ -339,7 +369,7 @@ declare class CeServiceAgent {
339
369
 
340
370
  /**
341
371
  * One-call bootstrap for an `xtrape-service`: start an HTTP listener that serves
342
- * `POST /messages` (and `GET /health`), register the instance to xtrape-server-ce,
372
+ * `POST /messages`, action endpoints (and `GET /health`), register the instance to xtrape-server-ce,
343
373
  * and run heartbeats. Returns a handle whose `close()` stops heartbeats,
344
374
  * deregisters, and closes the listener.
345
375
  *
@@ -352,8 +382,25 @@ interface ServeCeServiceOptions {
352
382
  manifest: ServiceManifest;
353
383
  /** business handler invoked per inbound ConversationMessage */
354
384
  handler: CeMessageHandler;
385
+ /** Optional CE action handlers exposed over GET /actions, POST /actions/:name/prepare, and POST /actions/:name/execute. */
386
+ actions?: CeServiceActionDefinition[];
355
387
  /** shared registration token, if server-ce requires one */
356
388
  registrationToken?: string;
389
+ /**
390
+ * 0.6.x control-plane token forwarded to server-ce on heartbeat/deregister
391
+ * only as a compatibility fallback. Current server-ce returns a per-instance
392
+ * access token during registration and the SDK uses that first.
393
+ */
394
+ controlToken?: string;
395
+ /**
396
+ * Optional inbound callback token. When set, `POST /messages` and action
397
+ * endpoints (`GET /actions`, `POST /actions/:name/prepare`,
398
+ * `POST /actions/:name/execute`) must include
399
+ * `x-xtrape-service-callback-token: <callbackToken>` (or
400
+ * `Authorization: Bearer <callbackToken>`); otherwise the request is rejected
401
+ * with 401. Lets a service require authenticated callbacks from server-ce.
402
+ */
403
+ callbackToken?: string;
357
404
  /** listen port; 0 picks an ephemeral port (default 0) */
358
405
  port?: number;
359
406
  /** listen host (default 0.0.0.0) */
@@ -372,6 +419,45 @@ interface ServeCeServiceOptions {
372
419
  /** injectable fetch (tests); defaults to global fetch */
373
420
  fetchImpl?: typeof fetch;
374
421
  }
422
+ /**
423
+ * What a two-phase action's prepare step returns to the operator UI:
424
+ * `initialPayload` seeds the execute form, `currentState` is contextual
425
+ * data to display (e.g. the accounts/balances the decision is based on),
426
+ * and `inputSchema` optionally overrides the static schema with live options.
427
+ */
428
+ interface CeActionPrepareResult {
429
+ /** Longer operator-facing explanation shown on the execution screen. */
430
+ longDescription?: string;
431
+ initialPayload?: Record<string, unknown>;
432
+ currentState?: Record<string, unknown>;
433
+ inputSchema?: Record<string, unknown>;
434
+ }
435
+ interface CeServiceActionDefinition {
436
+ name: string;
437
+ label: string;
438
+ description?: string;
439
+ /** Operator-facing grouping shown by Panel (capsule ActionDefinition heritage), e.g. "diagnostics". */
440
+ category?: string;
441
+ /** Display order within the category; lower sorts first. */
442
+ order?: number;
443
+ inputSchema?: Record<string, unknown>;
444
+ /**
445
+ * Optional prepare step. When present the action is two-phase: server-ce
446
+ * calls this before the operator fills the form to fetch live defaults and
447
+ * current state. Advertised as `requiresPrepare: true` in the action list.
448
+ */
449
+ prepare?(input?: unknown): CeActionPrepareResult | Promise<CeActionPrepareResult>;
450
+ /**
451
+ * Long-running action (server-ce#41 Stage B). When true and the pushed
452
+ * execute carries an `x-xtrape-command-id` header, the listener replies
453
+ * 202 { status: "RUNNING", commandId } immediately, runs `execute` in the
454
+ * background, and reports the outcome via
455
+ * `CeServiceAgent.reportCommandResult`. Without the header (older server-ce)
456
+ * the action falls back to synchronous execution.
457
+ */
458
+ async?: boolean;
459
+ execute(input: unknown): unknown | Promise<unknown>;
460
+ }
375
461
  interface ServeCeServiceHandle {
376
462
  agent: CeServiceAgent;
377
463
  /** advertised address registered with server-ce */
@@ -383,4 +469,4 @@ interface ServeCeServiceHandle {
383
469
  }
384
470
  declare function serveCeService(opts: ServeCeServiceOptions): Promise<ServeCeServiceHandle>;
385
471
 
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 };
472
+ 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
@@ -328,6 +346,18 @@ declare class CeServiceAgent {
328
346
  stopHeartbeat(): void;
329
347
  /** DELETE /v1/instances/{id} — graceful deregister. */
330
348
  deregister(): Promise<void>;
349
+ /**
350
+ * Report the outcome of an async action (server-ce#41 Stage B):
351
+ * POST /v1/instances/{instanceId}/commands/{commandId}/result using the
352
+ * per-instance access token. `outcome.result` carries the action payload for
353
+ * COMPLETED; errorCode/errorMessage describe a FAILED run.
354
+ */
355
+ reportCommandResult(commandId: string, outcome: {
356
+ status: "COMPLETED" | "FAILED";
357
+ result?: Record<string, unknown>;
358
+ errorCode?: string;
359
+ errorMessage?: string;
360
+ }): Promise<void>;
331
361
  /**
332
362
  * Validate an inbound message, run the handler, and build the reply message
333
363
  * (senderType=SERVICE, targeted back at the sender, with replyToMessageId).
@@ -339,7 +369,7 @@ declare class CeServiceAgent {
339
369
 
340
370
  /**
341
371
  * One-call bootstrap for an `xtrape-service`: start an HTTP listener that serves
342
- * `POST /messages` (and `GET /health`), register the instance to xtrape-server-ce,
372
+ * `POST /messages`, action endpoints (and `GET /health`), register the instance to xtrape-server-ce,
343
373
  * and run heartbeats. Returns a handle whose `close()` stops heartbeats,
344
374
  * deregisters, and closes the listener.
345
375
  *
@@ -352,8 +382,25 @@ interface ServeCeServiceOptions {
352
382
  manifest: ServiceManifest;
353
383
  /** business handler invoked per inbound ConversationMessage */
354
384
  handler: CeMessageHandler;
385
+ /** Optional CE action handlers exposed over GET /actions, POST /actions/:name/prepare, and POST /actions/:name/execute. */
386
+ actions?: CeServiceActionDefinition[];
355
387
  /** shared registration token, if server-ce requires one */
356
388
  registrationToken?: string;
389
+ /**
390
+ * 0.6.x control-plane token forwarded to server-ce on heartbeat/deregister
391
+ * only as a compatibility fallback. Current server-ce returns a per-instance
392
+ * access token during registration and the SDK uses that first.
393
+ */
394
+ controlToken?: string;
395
+ /**
396
+ * Optional inbound callback token. When set, `POST /messages` and action
397
+ * endpoints (`GET /actions`, `POST /actions/:name/prepare`,
398
+ * `POST /actions/:name/execute`) must include
399
+ * `x-xtrape-service-callback-token: <callbackToken>` (or
400
+ * `Authorization: Bearer <callbackToken>`); otherwise the request is rejected
401
+ * with 401. Lets a service require authenticated callbacks from server-ce.
402
+ */
403
+ callbackToken?: string;
357
404
  /** listen port; 0 picks an ephemeral port (default 0) */
358
405
  port?: number;
359
406
  /** listen host (default 0.0.0.0) */
@@ -372,6 +419,45 @@ interface ServeCeServiceOptions {
372
419
  /** injectable fetch (tests); defaults to global fetch */
373
420
  fetchImpl?: typeof fetch;
374
421
  }
422
+ /**
423
+ * What a two-phase action's prepare step returns to the operator UI:
424
+ * `initialPayload` seeds the execute form, `currentState` is contextual
425
+ * data to display (e.g. the accounts/balances the decision is based on),
426
+ * and `inputSchema` optionally overrides the static schema with live options.
427
+ */
428
+ interface CeActionPrepareResult {
429
+ /** Longer operator-facing explanation shown on the execution screen. */
430
+ longDescription?: string;
431
+ initialPayload?: Record<string, unknown>;
432
+ currentState?: Record<string, unknown>;
433
+ inputSchema?: Record<string, unknown>;
434
+ }
435
+ interface CeServiceActionDefinition {
436
+ name: string;
437
+ label: string;
438
+ description?: string;
439
+ /** Operator-facing grouping shown by Panel (capsule ActionDefinition heritage), e.g. "diagnostics". */
440
+ category?: string;
441
+ /** Display order within the category; lower sorts first. */
442
+ order?: number;
443
+ inputSchema?: Record<string, unknown>;
444
+ /**
445
+ * Optional prepare step. When present the action is two-phase: server-ce
446
+ * calls this before the operator fills the form to fetch live defaults and
447
+ * current state. Advertised as `requiresPrepare: true` in the action list.
448
+ */
449
+ prepare?(input?: unknown): CeActionPrepareResult | Promise<CeActionPrepareResult>;
450
+ /**
451
+ * Long-running action (server-ce#41 Stage B). When true and the pushed
452
+ * execute carries an `x-xtrape-command-id` header, the listener replies
453
+ * 202 { status: "RUNNING", commandId } immediately, runs `execute` in the
454
+ * background, and reports the outcome via
455
+ * `CeServiceAgent.reportCommandResult`. Without the header (older server-ce)
456
+ * the action falls back to synchronous execution.
457
+ */
458
+ async?: boolean;
459
+ execute(input: unknown): unknown | Promise<unknown>;
460
+ }
375
461
  interface ServeCeServiceHandle {
376
462
  agent: CeServiceAgent;
377
463
  /** advertised address registered with server-ce */
@@ -383,4 +469,4 @@ interface ServeCeServiceHandle {
383
469
  }
384
470
  declare function serveCeService(opts: ServeCeServiceOptions): Promise<ServeCeServiceHandle>;
385
471
 
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 };
472
+ 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
- return { instanceId: this.instanceId };
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,31 @@ 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)}`, { method: "DELETE" });
510
+ await this.fetchJson(`/v1/instances/${encodeURIComponent(this.instanceId)}`, {
511
+ method: "DELETE",
512
+ ...this.lifecycleAuthHeaders() ? { headers: this.lifecycleAuthHeaders() } : {}
513
+ });
514
+ }
515
+ /**
516
+ * Report the outcome of an async action (server-ce#41 Stage B):
517
+ * POST /v1/instances/{instanceId}/commands/{commandId}/result using the
518
+ * per-instance access token. `outcome.result` carries the action payload for
519
+ * COMPLETED; errorCode/errorMessage describe a FAILED run.
520
+ */
521
+ async reportCommandResult(commandId, outcome) {
522
+ const body = {
523
+ contractVersion: CE_CONTRACT_VERSION,
524
+ commandId,
525
+ status: outcome.status,
526
+ ...outcome.result !== void 0 ? { result: outcome.result } : {},
527
+ ...outcome.errorCode ? { errorCode: outcome.errorCode } : {},
528
+ ...outcome.errorMessage ? { errorMessage: outcome.errorMessage } : {}
529
+ };
530
+ await this.post(
531
+ `/v1/instances/${encodeURIComponent(this.instanceId)}/commands/${encodeURIComponent(commandId)}/result`,
532
+ body,
533
+ this.lifecycleAuthHeaders()
534
+ );
495
535
  }
496
536
  /**
497
537
  * Validate an inbound message, run the handler, and build the reply message
@@ -548,6 +588,7 @@ var CeServiceAgent = class {
548
588
 
549
589
  // src/ce/serve.ts
550
590
  import { createServer } from "http";
591
+ import { CE_CONTRACT_VERSION as CE_CONTRACT_VERSION2 } from "@xtrape/capsule-contracts-node";
551
592
  function send(res, status, body) {
552
593
  res.writeHead(status, { "content-type": "application/json" });
553
594
  res.end(JSON.stringify(body));
@@ -563,6 +604,26 @@ function readBody(req) {
563
604
  function isLocalOnlyHost(host) {
564
605
  return host === "127.0.0.1" || host === "localhost" || host === "::1";
565
606
  }
607
+ function extractCallbackToken(req) {
608
+ const raw = req.headers["x-xtrape-service-callback-token"];
609
+ const header = Array.isArray(raw) ? raw[0] : raw;
610
+ if (typeof header === "string" && header.length > 0) return header;
611
+ const auth = req.headers["authorization"];
612
+ if (typeof auth === "string" && auth.startsWith("Bearer ")) {
613
+ const token = auth.slice("Bearer ".length).trim();
614
+ return token.length > 0 ? token : void 0;
615
+ }
616
+ return void 0;
617
+ }
618
+ function requireCallbackAuthorized(req, res, callbackToken) {
619
+ if (!callbackToken || extractCallbackToken(req) === callbackToken) return true;
620
+ send(res, 401, {
621
+ code: "UNAUTHORIZED",
622
+ message: "service callback token is missing or invalid",
623
+ retryable: false
624
+ });
625
+ return false;
626
+ }
566
627
  function defaultLocalAddress(host, port) {
567
628
  if (host === "::1") return `http://[::1]:${port}`;
568
629
  return `http://${host}:${port}`;
@@ -590,6 +651,10 @@ function sendMessageError(res, error) {
590
651
  const message = error instanceof Error ? error.message : "message handler failed";
591
652
  send(res, 500, { code: "HANDLER_FAILED", message, retryable: true });
592
653
  }
654
+ function sendHandlerError(res, error) {
655
+ const message = error instanceof Error ? error.message : "action handler failed";
656
+ send(res, 500, { code: "ACTION_FAILED", message, retryable: false });
657
+ }
593
658
  async function serveCeService(opts) {
594
659
  const host = opts.host ?? "0.0.0.0";
595
660
  let agent;
@@ -605,6 +670,7 @@ async function serveCeService(opts) {
605
670
  send(res, 503, { code: "NOT_READY", message: "service not registered yet", retryable: true });
606
671
  return;
607
672
  }
673
+ if (!requireCallbackAuthorized(req, res, opts.callbackToken)) return;
608
674
  const raw = await readBody(req);
609
675
  let payload;
610
676
  try {
@@ -621,6 +687,111 @@ async function serveCeService(opts) {
621
687
  send(res, 200, reply);
622
688
  return;
623
689
  }
690
+ if (req.method === "GET" && req.url === "/actions") {
691
+ if (!requireCallbackAuthorized(req, res, opts.callbackToken)) return;
692
+ send(
693
+ res,
694
+ 200,
695
+ (opts.actions ?? []).map((action) => ({
696
+ name: action.name,
697
+ label: action.label,
698
+ description: action.description,
699
+ category: action.category,
700
+ order: action.order,
701
+ requiresPrepare: typeof action.prepare === "function" ? true : void 0,
702
+ async: action.async === true ? true : void 0,
703
+ inputSchema: action.inputSchema
704
+ }))
705
+ );
706
+ return;
707
+ }
708
+ const prepareMatch = req.url?.match(/^\/actions\/([^/]+)\/prepare$/);
709
+ if (req.method === "POST" && prepareMatch) {
710
+ if (!requireCallbackAuthorized(req, res, opts.callbackToken)) return;
711
+ const name = decodeURIComponent(prepareMatch[1]);
712
+ const action = (opts.actions ?? []).find((candidate) => candidate.name === name);
713
+ if (!action) {
714
+ send(res, 404, { code: "ACTION_NOT_FOUND", message: `action ${name} not found`, retryable: false });
715
+ return;
716
+ }
717
+ if (typeof action.prepare !== "function") {
718
+ send(res, 400, { code: "ACTION_NOT_PREPARABLE", message: `action ${name} has no prepare step`, retryable: false });
719
+ return;
720
+ }
721
+ const raw = await readBody(req);
722
+ let payload;
723
+ try {
724
+ payload = raw ? JSON.parse(raw) : {};
725
+ } catch (error) {
726
+ send(res, 400, {
727
+ code: "VALIDATION_FAILED",
728
+ message: error instanceof Error ? error.message : "invalid JSON request body",
729
+ retryable: false
730
+ });
731
+ return;
732
+ }
733
+ try {
734
+ send(res, 200, await action.prepare(payload) ?? {});
735
+ } catch (error) {
736
+ sendHandlerError(res, error);
737
+ }
738
+ return;
739
+ }
740
+ const executeMatch = req.url?.match(/^\/actions\/([^/]+)\/execute$/);
741
+ if (req.method === "POST" && executeMatch) {
742
+ if (!requireCallbackAuthorized(req, res, opts.callbackToken)) return;
743
+ const name = decodeURIComponent(executeMatch[1]);
744
+ const action = (opts.actions ?? []).find((candidate) => candidate.name === name);
745
+ if (!action) {
746
+ send(res, 404, { code: "ACTION_NOT_FOUND", message: `action ${name} not found`, retryable: false });
747
+ return;
748
+ }
749
+ const raw = await readBody(req);
750
+ let payload;
751
+ try {
752
+ payload = raw ? JSON.parse(raw) : {};
753
+ } catch (error) {
754
+ send(res, 400, {
755
+ code: "VALIDATION_FAILED",
756
+ message: error instanceof Error ? error.message : "invalid JSON request body",
757
+ retryable: false
758
+ });
759
+ return;
760
+ }
761
+ const commandIdRaw = req.headers["x-xtrape-command-id"];
762
+ const commandId = Array.isArray(commandIdRaw) ? commandIdRaw[0] : commandIdRaw;
763
+ if (action.async === true && typeof commandId === "string" && commandId.length > 0 && agent) {
764
+ const reporter = agent;
765
+ send(res, 202, { contractVersion: CE_CONTRACT_VERSION2, status: "RUNNING", commandId });
766
+ void (async () => {
767
+ let outcome;
768
+ try {
769
+ const result = await action.execute(payload);
770
+ outcome = {
771
+ status: "COMPLETED",
772
+ ...result !== void 0 && result !== null && typeof result === "object" && !Array.isArray(result) ? { result } : result !== void 0 ? { result: { value: result } } : {}
773
+ };
774
+ } catch (error) {
775
+ outcome = {
776
+ status: "FAILED",
777
+ errorCode: "ACTION_FAILED",
778
+ errorMessage: error instanceof Error ? error.message : "action handler failed"
779
+ };
780
+ }
781
+ try {
782
+ await reporter.reportCommandResult(commandId, outcome);
783
+ } catch {
784
+ }
785
+ })();
786
+ return;
787
+ }
788
+ try {
789
+ send(res, 200, await action.execute(payload));
790
+ } catch (error) {
791
+ sendHandlerError(res, error);
792
+ }
793
+ return;
794
+ }
624
795
  send(res, 404, { code: "NOT_FOUND", message: "not found", retryable: false });
625
796
  } catch (e) {
626
797
  sendMessageError(res, e);
@@ -644,6 +815,7 @@ async function serveCeService(opts) {
644
815
  address,
645
816
  instanceId: opts.instanceId,
646
817
  registrationToken: opts.registrationToken,
818
+ controlToken: opts.controlToken,
647
819
  fetchImpl: opts.fetchImpl
648
820
  });
649
821
  await agent.register();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xtrape/capsule-agent-node",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Node.js Embedded Agent SDK for reporting Workers to the Xtrape control plane.",
5
5
  "keywords": [
6
6
  "xtrape",
@@ -44,7 +44,7 @@
44
44
  "lint": "tsc --noEmit"
45
45
  },
46
46
  "dependencies": {
47
- "@xtrape/capsule-contracts-node": "^0.6.0"
47
+ "@xtrape/capsule-contracts-node": "^0.6.1"
48
48
  },
49
49
  "devDependencies": {
50
50
  "tsup": "^8.3.5",