drupal-mcp-connector 2.12.0 → 2.13.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.
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Relay northbound edge (#232, #242, #244, #247, #250, #253) — DEV-294 AC4,
3
- * DEV-122 isolation, DEV-124 tenant routing, DEV-123 actor mapping, DEV-125
4
- * policy digest and W&L-operated bundle promotion.
2
+ * Relay northbound edge (#232, #242, #244, #247, #250, #253, #256) — the
3
+ * hosted-edge slice (#232), tenant isolation (#242), tenant routing (#244),
4
+ * actor mapping (#247), policy digest (#250) and W&L-operated bundle
5
+ * promotion (#253), attributable usage, quotas, and abuse signals (#256).
5
6
  *
6
7
  * Terminates northbound MCP over the OAuth resource server and fans requests
7
8
  * down outbound tenant-agent channels. The edge proposes; the tenant-side
@@ -22,11 +23,16 @@
22
23
  * `agentId`; channel records may bind `sites`. A second unscoped agent
23
24
  * or overlapping site claim is denied at hello. Fan-down selects the
24
25
  * unique bound agent from server-owned grants. Single unscoped agent
25
- * remains the DEV-294 compatibility path.
26
+ * remains the #232 single-agent compatibility path.
26
27
  * - Stateless MCP 2026-07-28 northbound: sessionful traffic is refused and
27
28
  * no `Mcp-Session-Id` crosses in either direction.
28
29
  * - Revocation is per-request with no grace window, for both credential
29
30
  * kinds (northbound principal, agent channel).
31
+ * - Metering is optional and fail-closed. With a `usage` ledger every
32
+ * decision and receipt is recorded against the grant-resolved tenant and
33
+ * the validated principal; with `auth.quotas` a tenant or principal
34
+ * without a row, an exhausted window, or a locked principal is refused
35
+ * with zero frames. `GET /usage` serves one tenant partition only.
30
36
  */
31
37
 
32
38
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
@@ -36,7 +42,11 @@ import { createServer as createHttpsServer } from "node:https";
36
42
  import { createServer as createNetServer } from "node:net";
37
43
  import { createServer as createTlsServer } from "node:tls";
38
44
  import { createLocalRelay } from "../contracts/relay.js";
39
- import { createInboundHttpsAuth, SPOOFABLE_IDENTITY_HEADERS } from "../http-auth.js";
45
+ import {
46
+ createInboundHttpsAuth,
47
+ formatWwwAuthenticate,
48
+ SPOOFABLE_IDENTITY_HEADERS,
49
+ } from "../http-auth.js";
40
50
  import { createLegacySessionHandler, createMcpRequestHandler } from "../http-handler.js";
41
51
  import { isWriteLikeCall } from "../operations.js";
42
52
  import {
@@ -46,6 +56,13 @@ import {
46
56
  resolveEligiblePromotion,
47
57
  } from "../policy-promotion.js";
48
58
  import { DIAGNOSTIC_TOOLS, resolveActor, resolveGrantedSites, resolvePolicy } from "../principal.js";
59
+ import {
60
+ attributedTenant,
61
+ createQuotaGate,
62
+ readUsage,
63
+ reconcileUsage,
64
+ usagePrincipalKey,
65
+ } from "../usage.js";
49
66
  import {
50
67
  attachFramer,
51
68
  createRequestBroker,
@@ -57,7 +74,7 @@ import {
57
74
  export const EDGE_MCP_PROTOCOL = "2026-07-28";
58
75
 
59
76
  /**
60
- * Revocation bound restated from the DEV-293 lab for both credential kinds.
77
+ * Revocation bound restated from the outbound-relay lab for both credential kinds.
61
78
  * The next request after revoke is denied; an in-flight request may finish.
62
79
  */
63
80
  export const EDGE_REVOCATION_BOUND = Object.freeze({
@@ -93,9 +110,19 @@ const SITE_CREDENTIAL_KEYS = Object.freeze([
93
110
 
94
111
  const DEFAULT_FAN_DOWN_TIMEOUT_MS = 10_000;
95
112
 
113
+ /** Post-authentication refusals that count toward a principal's abuse lock. */
114
+ const ABUSE_SIGNAL_ERRORS = new Set(["not_entitled", "quota_exceeded"]);
115
+
116
+ /**
117
+ * Refusal scopes that describe something other than the caller's own
118
+ * behaviour — a shared tenant window, or a table the operator misconfigured.
119
+ * They never feed a principal's abuse lock.
120
+ */
121
+ const SHARED_SCOPES = new Set(["tenant", "config"]);
122
+
96
123
  /**
97
124
  * Normalize a channel-record `sites` list. Empty / missing means unscoped
98
- * (legal only as the sole connected agent — the DEV-294 compatibility path).
125
+ * (legal only as the sole connected agent — the #232 compatibility path).
99
126
  *
100
127
  * @param {unknown} sites
101
128
  * @returns {string[]}
@@ -201,7 +228,7 @@ export function selectTenantSession({
201
228
  * Resolve tenant and target from server-owned grants. Caller `tenant` is a
202
229
  * confirming hint inside the grant, never authority. When `tenantGrants` is
203
230
  * omitted, tenant is derived from the unique agent covering the site grant
204
- * (DEV-122 / DEV-294 compatibility).
231
+ * (#242 / #232 compatibility).
205
232
  *
206
233
  * @param {object} params
207
234
  * @param {object|null} params.identity
@@ -458,11 +485,16 @@ function assertBindAllowed(role, host, hasTls) {
458
485
  }
459
486
  }
460
487
 
461
- function jsonResponse(res, status, body) {
462
- res.writeHead(status, { "content-type": "application/json" })
488
+ function jsonResponse(res, status, body, headers = {}) {
489
+ res.writeHead(status, { ...headers, "content-type": "application/json" })
463
490
  .end(JSON.stringify(body));
464
491
  }
465
492
 
493
+ function byteLength(value) {
494
+ if (value === null || value === undefined) return 0;
495
+ return Buffer.byteLength(typeof value === "string" ? value : JSON.stringify(value), "utf8");
496
+ }
497
+
466
498
  /**
467
499
  * Start the relay edge: an authenticated northbound MCP listener and the
468
500
  * agent channel listener the tenant dials into.
@@ -489,6 +521,14 @@ function jsonResponse(res, status, body) {
489
521
  * @param {number} [options.agentPort] Agent channel port (0 = ephemeral).
490
522
  * @param {{cert: string|Buffer, key: string|Buffer}} [options.tls]
491
523
  * @param {boolean} [options.allowHttpLoopback] Permit plain listeners on loopback.
524
+ * @param {object|null} [options.quotas] Optional `auth.quotas` (tenant /
525
+ * principal windows plus an abuse lock). When present, a tenant or
526
+ * principal without a row is refused; exhausted windows and locked
527
+ * principals are refused with `Retry-After`. Zero frames on any refusal.
528
+ * @param {?object} [options.usage] Optional usage ledger (usage.js). When
529
+ * present, every decision and receipt is recorded and `GET /usage` serves
530
+ * the caller's tenant partition. Omit to record nothing (404 on /usage).
531
+ * @param {() => number} [options.now] Clock for quotas and cost signals.
492
532
  * @param {?object} [options.rateLimiter] Optional rate limiter (rate-limit.js).
493
533
  * @param {number} [options.fanDownTimeoutMs]
494
534
  * @param {typeof fetch} [options.fetchFn] Issuer discovery/JWKS fetch.
@@ -511,6 +551,9 @@ export async function startEdge({
511
551
  agentPort = 0,
512
552
  tls = null,
513
553
  allowHttpLoopback = false,
554
+ quotas = null,
555
+ usage = null,
556
+ now = () => Date.now(),
514
557
  rateLimiter = null,
515
558
  fanDownTimeoutMs = DEFAULT_FAN_DOWN_TIMEOUT_MS,
516
559
  fetchFn = fetch,
@@ -541,6 +584,39 @@ export async function startEdge({
541
584
  ? promotions
542
585
  : null;
543
586
  const promoRequired = promotionsRequired(promotionTable);
587
+ const quotaGate = createQuotaGate({ quotas, now });
588
+ if (quotaGate.invalid) {
589
+ throw new EdgeStartupError(
590
+ `Relay edge refuses to start: auth.quotas is not readable at "${quotaGate.reason}". `
591
+ + "A quota table that cannot be read authorizes nobody; fix the row or remove the table.",
592
+ );
593
+ }
594
+ const usageLedger = usage === null || usage === undefined ? null : usage;
595
+ if (usageLedger !== null && (
596
+ typeof usageLedger !== "object"
597
+ || typeof usageLedger.record !== "function"
598
+ || typeof usageLedger.query !== "function"
599
+ || typeof usageLedger.stats !== "function"
600
+ )) {
601
+ throw new EdgeStartupError(
602
+ "Relay edge usage ledger must expose record(), query(), and stats() "
603
+ + "(see createUsageLedger in usage.js), or be omitted.",
604
+ );
605
+ }
606
+
607
+ /**
608
+ * Record without ever changing the verdict: a metering failure is logged
609
+ * (without the error detail) and the request proceeds as decided.
610
+ */
611
+ function meter(entry) {
612
+ if (!usageLedger) return null;
613
+ try {
614
+ return usageLedger.record(entry) ?? null;
615
+ } catch {
616
+ console.error("[drupal-mcp-edge] usage record failed; the decision stands and is unmetered.");
617
+ return null;
618
+ }
619
+ }
544
620
  if (typeof channelCredentials?.lookup !== "function") {
545
621
  throw new EdgeStartupError(
546
622
  "Relay edge requires an agent channel credential store; without one no "
@@ -637,7 +713,24 @@ export async function startEdge({
637
713
  return;
638
714
  }
639
715
  if (frame.type === "mcp-response") {
640
- broker.settle(frame, { owner: agentId });
716
+ const settled = broker.settle(frame, { owner: agentId });
717
+ if (!settled && usageLedger) {
718
+ // A response nobody is waiting for: late, repeated, fabricated, or
719
+ // injected from another tenant's tunnel. Recorded against the
720
+ // sending tunnel so reconciliation and abuse signals can see it.
721
+ meter({
722
+ phase: "receipt",
723
+ requestId: typeof frame.id === "string" && frame.id ? frame.id : null,
724
+ decisionId: null,
725
+ tenant: agentId,
726
+ principalKey: null,
727
+ outcome: "unknown",
728
+ reason: "unmatched_receipt",
729
+ status: Number.isInteger(frame.status) ? frame.status : null,
730
+ bytesOut: byteLength(frame.body),
731
+ durationMs: null,
732
+ });
733
+ }
641
734
  return;
642
735
  }
643
736
  // Any other frame type on the agent channel is a protocol violation.
@@ -661,11 +754,57 @@ export async function startEdge({
661
754
  return;
662
755
  }
663
756
 
757
+ // Attribution context (#256): who this is, which tenant the grant names,
758
+ // and what it cost. Stamped on every decision, allow or deny. Caller
759
+ // fields never reach it — the identity object and grant tables do.
760
+ const startedAt = now();
761
+ const isCall = body?.method === "tools/call";
762
+ const toolName = isCall && typeof body?.params?.name === "string" && body.params.name.trim()
763
+ ? body.params.name.trim()
764
+ : null;
765
+ const principalKey = usagePrincipalKey(identity);
766
+ const principal = Object.freeze({
767
+ clientId: identity.clientId ?? null,
768
+ sub: identity.sub ?? null,
769
+ });
770
+ const bytesIn = byteLength(body);
771
+ let usageTenant = attributedTenant(identity, tenantGrantTable);
772
+ let policyDigest = null;
773
+
774
+ function refuse(status, error, {
775
+ scope = null, exposeScope = true, retryAfterSec = 0, extra = {},
776
+ } = {}) {
777
+ if (ABUSE_SIGNAL_ERRORS.has(error) && !SHARED_SCOPES.has(scope)) {
778
+ quotaGate.noteDenial(principalKey);
779
+ }
780
+ meter({
781
+ phase: "decision",
782
+ decision: "deny",
783
+ reason: error,
784
+ scope,
785
+ requestId: null,
786
+ tenant: usageTenant,
787
+ principal,
788
+ principalKey,
789
+ method: body?.method ?? null,
790
+ tool: toolName,
791
+ policyDigest,
792
+ units: 1,
793
+ bytesIn,
794
+ });
795
+ jsonResponse(
796
+ res,
797
+ status,
798
+ { error, ...(scope && exposeScope ? { scope } : {}), ...extra },
799
+ retryAfterSec > 0 ? { "Retry-After": String(retryAfterSec) } : {},
800
+ );
801
+ }
802
+
664
803
  // Entitlement at the seam, before anything about the tenant is revealed:
665
804
  // an unlisted client learns nothing, not even whether an agent exists.
666
805
  const granted = resolveGrantedSites(identity, catalog, grantTable);
667
806
  if (!granted.length) {
668
- jsonResponse(res, 403, { error: "not_entitled" });
807
+ refuse(403, "not_entitled");
669
808
  return;
670
809
  }
671
810
  const args = body?.params?.arguments ?? {};
@@ -673,34 +812,31 @@ export async function startEdge({
673
812
  const siteArgs = { ...args };
674
813
  delete siteArgs.tenant;
675
814
  let targetName = null;
676
- if (body?.method === "tools/call") {
815
+ if (isCall) {
677
816
  try {
678
817
  targetName = targetRelay.resolve(identity, siteArgs).name;
679
818
  } catch {
680
- jsonResponse(res, 403, { error: "not_entitled" });
819
+ refuse(403, "not_entitled");
681
820
  return;
682
821
  }
683
822
  }
684
823
 
685
824
  const mapped = resolveActor({ identity, actors: actorTable });
686
825
  const boundPolicy = resolvePolicy({ identity, policies: policyTable });
687
- const isCall = body?.method === "tools/call";
688
- const toolName = isCall && typeof body?.params?.name === "string" && body.params.name.trim()
689
- ? body.params.name.trim()
690
- : null;
826
+ policyDigest = boundPolicy.policy ?? null;
691
827
  if (mapped.required && mapped.reason && isCall && (!toolName || isWriteLikeCall(toolName, args))) {
692
- jsonResponse(res, 403, { error: "not_entitled" });
828
+ refuse(403, "not_entitled");
693
829
  return;
694
830
  }
695
831
  const policyCall = isCall && (!toolName || !DIAGNOSTIC_TOOLS.has(toolName));
696
832
  if (
697
833
  boundPolicy.required && boundPolicy.reason && policyCall
698
834
  ) {
699
- jsonResponse(res, 403, { error: "not_entitled" });
835
+ refuse(403, "not_entitled");
700
836
  return;
701
837
  }
702
838
  if (promoRequired && policyCall && !boundPolicy.policy) {
703
- jsonResponse(res, 403, { error: "not_entitled" });
839
+ refuse(403, "not_entitled");
704
840
  return;
705
841
  }
706
842
 
@@ -712,6 +848,30 @@ export async function startEdge({
712
848
  targetName,
713
849
  sessions: [...sessions.values()],
714
850
  });
851
+ if (selected.tenant) usageTenant = selected.tenant;
852
+ if (!selected.session && selected.reason === "not_entitled") {
853
+ refuse(403, "not_entitled");
854
+ return;
855
+ }
856
+ if (!selected.session && !selected.tenant) {
857
+ // Site-derived path with no agent: there is no tenant to meter against,
858
+ // so this is an outage answer, not a quota or abuse signal.
859
+ refuse(503, "no_agent");
860
+ return;
861
+ }
862
+ // Quota boundary (#256): the tenant is now grant-resolved. Every request
863
+ // reaching this line counts; a tenant or principal without a row, an
864
+ // exhausted window, or a locked principal is refused with zero frames.
865
+ const verdict = quotaGate.check({ tenant: selected.tenant, principalKey });
866
+ if (!verdict.allowed) {
867
+ const unassigned = verdict.reason === "not_entitled";
868
+ refuse(unassigned ? 403 : 429, verdict.reason, {
869
+ scope: verdict.scope,
870
+ exposeScope: !unassigned,
871
+ retryAfterSec: verdict.retryAfterSec,
872
+ });
873
+ return;
874
+ }
715
875
  if (promoRequired && policyCall) {
716
876
  const promo = resolveEligiblePromotion({
717
877
  digest: boundPolicy.policy,
@@ -719,25 +879,22 @@ export async function startEdge({
719
879
  });
720
880
  const attested = selected.session?.attestedDigests;
721
881
  if (!promo.eligible || !attested || !attested.has(boundPolicy.policy)) {
722
- jsonResponse(res, 403, { error: "not_entitled" });
882
+ refuse(403, "not_entitled");
723
883
  return;
724
884
  }
725
885
  }
726
886
  if (!selected.session) {
727
- const entitled = selected.reason === "not_entitled";
728
- jsonResponse(res, entitled ? 403 : 503, {
729
- error: entitled ? "not_entitled" : "no_agent",
730
- });
887
+ refuse(503, "no_agent");
731
888
  return;
732
889
  }
733
890
  const record = channelCredentials.lookup(selected.session.token);
734
891
  if (!record || record.revoked) {
735
- jsonResponse(res, 403, { error: "revoked", bound: EDGE_REVOCATION_BOUND.name });
892
+ refuse(403, "revoked", { extra: { bound: EDGE_REVOCATION_BOUND.name } });
736
893
  return;
737
894
  }
738
895
  if (siteBindingKey(record.sites) !== siteBindingKey(selected.session.sites)) {
739
896
  selected.session.socket.destroy();
740
- jsonResponse(res, 503, { error: "no_agent" });
897
+ refuse(503, "no_agent");
741
898
  return;
742
899
  }
743
900
 
@@ -769,14 +926,55 @@ export async function startEdge({
769
926
  });
770
927
  if (!wrote) {
771
928
  broker.settle({ id, status: 503 }, { owner: selected.session.agentId });
772
- jsonResponse(res, 503, { error: "no_agent" });
929
+ refuse(503, "no_agent");
773
930
  return;
774
931
  }
932
+ const decisionRecord = meter({
933
+ phase: "decision",
934
+ decision: "allow",
935
+ reason: null,
936
+ requestId: id,
937
+ tenant: selected.tenant,
938
+ principal,
939
+ principalKey,
940
+ method: body?.method ?? null,
941
+ tool: toolName,
942
+ target: selected.target ?? null,
943
+ actor: mapped.actor ?? null,
944
+ policyDigest,
945
+ units: 1,
946
+ bytesIn,
947
+ });
948
+
949
+ function receipt(fields) {
950
+ meter({
951
+ phase: "receipt",
952
+ requestId: id,
953
+ decisionId: decisionRecord?.decisionId ?? null,
954
+ tenant: selected.tenant,
955
+ principalKey,
956
+ durationMs: Math.max(0, now() - startedAt),
957
+ ...fields,
958
+ });
959
+ }
775
960
 
776
961
  let result;
777
962
  try {
778
963
  result = await waited;
779
964
  } catch {
965
+ // The frame crossed; no settled response came back. The tenant may
966
+ // have executed it — reconciliation names this chain uncertain.
967
+ receipt({ outcome: "unknown", reason: "fan_down_failed", status: null, bytesOut: 0 });
968
+ jsonResponse(res, 502, { error: "fan_down_failed" });
969
+ return;
970
+ }
971
+ // The agent frame is unvalidated input. A status the northbound
972
+ // listener cannot emit, or a header it refuses, is a failed receipt and
973
+ // a 502 — never an "ok" receipt for a response nobody received.
974
+ const status = result.status;
975
+ const bytesOut = byteLength(result.body);
976
+ if (!Number.isInteger(status) || status < 100 || status > 999) {
977
+ receipt({ outcome: "failed", reason: "invalid_status", status: null, bytesOut });
780
978
  jsonResponse(res, 502, { error: "fan_down_failed" });
781
979
  return;
782
980
  }
@@ -784,8 +982,86 @@ export async function startEdge({
784
982
  Object.entries(forwardHeaders(result.headers ?? {}))
785
983
  .filter(([name]) => String(name).toLowerCase() !== "mcp-session-id"),
786
984
  );
787
- res.writeHead(result.status || 200, headers);
985
+ try {
986
+ res.writeHead(status, headers);
987
+ } catch {
988
+ receipt({ outcome: "failed", reason: "relay_write_failed", status, bytesOut });
989
+ if (!res.headersSent) {
990
+ for (const name of res.getHeaderNames()) res.removeHeader(name);
991
+ jsonResponse(res, 502, { error: "fan_down_failed" });
992
+ } else {
993
+ res.destroy();
994
+ }
995
+ return;
996
+ }
788
997
  res.end(result.body ?? "");
998
+ receipt({ outcome: status >= 500 ? "failed" : "ok", reason: null, status, bytesOut });
999
+ }
1000
+
1001
+ /**
1002
+ * `GET /usage` — the caller's own tenant partition (#256). Authenticated
1003
+ * on the same resource server as `/mcp`; the tenant comes from
1004
+ * `auth.tenantGrants`, a `tenant` query value is a confirming hint, and
1005
+ * any other tenant is `not_entitled` with no records. 404 without a ledger.
1006
+ */
1007
+ async function serveUsage(req, res) {
1008
+ if (!usageLedger) {
1009
+ res.writeHead(404).end("Not found");
1010
+ return;
1011
+ }
1012
+ if (req.method !== "GET") {
1013
+ res.writeHead(405, { Allow: "GET" }).end("Method Not Allowed");
1014
+ return;
1015
+ }
1016
+ if (rateLimiter) {
1017
+ const verdict = rateLimiter.check(req.socket?.remoteAddress || "unknown");
1018
+ if (!verdict.allowed) {
1019
+ res.writeHead(429, { "Retry-After": String(verdict.retryAfterSec) }).end("Too Many Requests");
1020
+ return;
1021
+ }
1022
+ }
1023
+ let auth;
1024
+ try {
1025
+ auth = await inbound.authenticate(req);
1026
+ } catch {
1027
+ res.writeHead(401, {
1028
+ "WWW-Authenticate": formatWwwAuthenticate({
1029
+ error: "invalid_token",
1030
+ errorDescription: "Token validation failed",
1031
+ }),
1032
+ }).end("Unauthorized");
1033
+ return;
1034
+ }
1035
+ if (!auth.ok) {
1036
+ res.writeHead(auth.status, auth.headers).end(auth.body);
1037
+ return;
1038
+ }
1039
+ try {
1040
+ const query = new URL(String(req.url || "/usage"), "http://edge.invalid").searchParams;
1041
+ const read = readUsage({
1042
+ identity: auth.identity,
1043
+ tenantGrants: tenantGrantTable,
1044
+ tenant: query.get("tenant"),
1045
+ principalKey: query.get("principal"),
1046
+ ledger: usageLedger,
1047
+ });
1048
+ if (!read.ok) {
1049
+ jsonResponse(res, 403, { error: "not_entitled" });
1050
+ return;
1051
+ }
1052
+ jsonResponse(res, 200, {
1053
+ tenant: read.tenant,
1054
+ records: read.records,
1055
+ reconciliation: reconcileUsage(read.records, { dropped: usageLedger.stats().dropped }),
1056
+ });
1057
+ } catch {
1058
+ console.error("[drupal-mcp-edge] usage read failed.");
1059
+ if (res.headersSent) {
1060
+ res.destroy();
1061
+ return;
1062
+ }
1063
+ res.writeHead(500).end("Internal Server Error");
1064
+ }
789
1065
  }
790
1066
 
791
1067
  const requestHandler = createMcpRequestHandler({
@@ -804,9 +1080,16 @@ export async function startEdge({
804
1080
  rateLimiter,
805
1081
  });
806
1082
 
1083
+ function northbound(req, res) {
1084
+ if (String(req.url || "").split("?")[0] === "/usage") {
1085
+ void serveUsage(req, res);
1086
+ return;
1087
+ }
1088
+ void requestHandler(req, res);
1089
+ }
807
1090
  const northServer = hasTls
808
- ? createHttpsServer(tls, (req, res) => { void requestHandler(req, res); })
809
- : createHttpServer((req, res) => { void requestHandler(req, res); });
1091
+ ? createHttpsServer(tls, northbound)
1092
+ : createHttpServer(northbound);
810
1093
 
811
1094
  const channelAddr = await listen(channelServer, agentBindHost, agentPort, "edge-agent-channel");
812
1095
  const northAddr = await listen(northServer, bindHost, port, "edge-northbound");
@@ -2,7 +2,7 @@
2
2
  * Relay frame codec (#232).
3
3
  *
4
4
  * Length-prefixed JSON frames for the edge/agent channel, promoted from the
5
- * DEV-293 lab harness (`lab/outbound-relay/harness.js`). Mechanism only — no
5
+ * outbound-relay lab harness (`lab/outbound-relay/harness.js`). Mechanism only — no
6
6
  * authentication, entitlement, or header policy lives here.
7
7
  *
8
8
  * Wire format: 4-byte big-endian payload length, then UTF-8 JSON. A frame