@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 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",
@@ -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
- 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,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)}`, { method: "DELETE" });
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` (and `GET /health`), register the instance to xtrape-server-ce,
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` (and `GET /health`), register the instance to xtrape-server-ce,
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
- 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,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)}`, { method: "DELETE" });
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();
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.1",
4
4
  "description": "Node.js Embedded Agent SDK for reporting Workers to the Xtrape control plane.",
5
5
  "keywords": [
6
6
  "xtrape",