drupal-mcp-connector 2.10.1 → 2.12.0
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/CHANGELOG.md +66 -0
- package/bin/drupal-mcp-edge.js +17 -3
- package/config/config.example.json +12 -0
- package/package.json +1 -1
- package/src/lib/backends/jsonapi.js +32 -3
- package/src/lib/config.js +64 -0
- package/src/lib/http-auth.js +5 -1
- package/src/lib/policy-enforcement.js +268 -0
- package/src/lib/policy-promotion.js +130 -0
- package/src/lib/principal.js +159 -4
- package/src/lib/relay/agent.js +44 -0
- package/src/lib/relay/edge.js +426 -37
- package/src/lib/relay/frames.js +37 -9
- package/src/tools/site.js +1 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W&L-operated dual-control promotion ledger (#253 / DEV-125).
|
|
3
|
+
*
|
|
4
|
+
* The edge never mints a Sentinel HMAC seal. A promotion is an already-sealed
|
|
5
|
+
* portable document plus two distinct operator ids. Eligibility is the gate
|
|
6
|
+
* before the tenant agent may receive the artifact. Caller-supplied documents
|
|
7
|
+
* are never read here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const POLICY_DIGEST = /^[0-9a-f]{64}$/i;
|
|
11
|
+
|
|
12
|
+
export const SEAL_PREFIX = "hmac-sha256:";
|
|
13
|
+
|
|
14
|
+
function tableHasKeys(table) {
|
|
15
|
+
return Object.keys(table).some((key) => {
|
|
16
|
+
const id = key.trim();
|
|
17
|
+
return id && !id.startsWith("_");
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function uniqueApprovals(raw) {
|
|
22
|
+
if (!Array.isArray(raw)) return [];
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const value of raw) {
|
|
26
|
+
const id = String(value).trim();
|
|
27
|
+
if (!id || id.startsWith("_") || seen.has(id)) continue;
|
|
28
|
+
seen.add(id);
|
|
29
|
+
out.push(id);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function portableDocument(raw) {
|
|
35
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
36
|
+
const bag = new Map(Object.entries(raw));
|
|
37
|
+
const digest = typeof bag.get("digest") === "string"
|
|
38
|
+
? bag.get("digest").trim().toLowerCase()
|
|
39
|
+
: "";
|
|
40
|
+
const seal = typeof bag.get("seal") === "string" ? bag.get("seal").trim() : "";
|
|
41
|
+
if (!POLICY_DIGEST.test(digest)) return null;
|
|
42
|
+
if (!seal.startsWith(SEAL_PREFIX) || seal.length <= SEAL_PREFIX.length) return null;
|
|
43
|
+
bag.set("digest", digest);
|
|
44
|
+
bag.set("seal", seal);
|
|
45
|
+
return Object.freeze(Object.fromEntries(bag));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Normalize `auth.promotions` (digest → sealed document + approvals).
|
|
50
|
+
* Comment keys and malformed rows are dropped. Duplicate operator ids in
|
|
51
|
+
* `approvals` count once. A row is eligible only with two distinct operators
|
|
52
|
+
* and a document whose claimed digest matches the map key.
|
|
53
|
+
*
|
|
54
|
+
* @param {object|null} promotions
|
|
55
|
+
* @returns {object|null}
|
|
56
|
+
*/
|
|
57
|
+
export function normalizePromotions(promotions) {
|
|
58
|
+
if (!promotions || typeof promotions !== "object" || Array.isArray(promotions)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const entries = [];
|
|
62
|
+
for (const [rawKey, value] of Object.entries(promotions)) {
|
|
63
|
+
const key = rawKey.trim().toLowerCase();
|
|
64
|
+
if (!key || key.startsWith("_") || !POLICY_DIGEST.test(key)) continue;
|
|
65
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
66
|
+
const bag = new Map(Object.entries(value));
|
|
67
|
+
const document = portableDocument(bag.get("document"));
|
|
68
|
+
if (!document || document.digest !== key) continue;
|
|
69
|
+
const approvals = uniqueApprovals(bag.get("approvals"));
|
|
70
|
+
entries.push([key, Object.freeze({
|
|
71
|
+
digest: key,
|
|
72
|
+
document,
|
|
73
|
+
approvals: Object.freeze(approvals),
|
|
74
|
+
eligible: approvals.length >= 2,
|
|
75
|
+
})]);
|
|
76
|
+
}
|
|
77
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether a promotions table is in force (fail-closed even when every row
|
|
82
|
+
* is ineligible). Comment-only objects are not in force.
|
|
83
|
+
*
|
|
84
|
+
* @param {object|null} promotions
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
*/
|
|
87
|
+
export function promotionsRequired(promotions) {
|
|
88
|
+
if (!promotions || typeof promotions !== "object" || Array.isArray(promotions)) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return tableHasKeys(promotions);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Eligible sealed documents ready to fan down.
|
|
96
|
+
*
|
|
97
|
+
* @param {object|null} promotions
|
|
98
|
+
* @returns {Array<{digest: string, document: object, approvals: string[]}>}
|
|
99
|
+
*/
|
|
100
|
+
export function eligiblePromotions(promotions) {
|
|
101
|
+
const table = normalizePromotions(promotions);
|
|
102
|
+
if (!table) return [];
|
|
103
|
+
return Object.values(table).filter((row) => row.eligible);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Look up one digest in the ledger.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} params
|
|
110
|
+
* @param {string|null} [params.digest]
|
|
111
|
+
* @param {object|null} [params.promotions]
|
|
112
|
+
* @returns {{document: object|null, eligible: boolean, reason: "not_entitled"|null}}
|
|
113
|
+
*/
|
|
114
|
+
export function resolveEligiblePromotion({ digest = null, promotions = null } = {}) {
|
|
115
|
+
const required = promotionsRequired(promotions);
|
|
116
|
+
const table = normalizePromotions(promotions);
|
|
117
|
+
if (!table) {
|
|
118
|
+
return {
|
|
119
|
+
document: null,
|
|
120
|
+
eligible: false,
|
|
121
|
+
reason: required ? "not_entitled" : null,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const key = typeof digest === "string" ? digest.trim().toLowerCase() : "";
|
|
125
|
+
const record = key ? new Map(Object.entries(table)).get(key) : null;
|
|
126
|
+
if (!record || !record.eligible) {
|
|
127
|
+
return { document: null, eligible: false, reason: "not_entitled" };
|
|
128
|
+
}
|
|
129
|
+
return { document: record.document, eligible: true, reason: null };
|
|
130
|
+
}
|
package/src/lib/principal.js
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* inbound principal and keep the existing site + source-governance filter
|
|
8
8
|
* so a local operator is not hollowed out.
|
|
9
9
|
*
|
|
10
|
-
* Caller-supplied site, environment, tenant, target,
|
|
11
|
-
* hints. They never become authority.
|
|
12
|
-
* not a wildcard.
|
|
10
|
+
* Caller-supplied site, environment, tenant, target, actor, delegator,
|
|
11
|
+
* policy, digest, or scope fields are hints. They never become authority.
|
|
12
|
+
* Empty inbound scopes are no grants, not a wildcard.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
@@ -22,6 +22,16 @@ const identityStore = new AsyncLocalStorage();
|
|
|
22
22
|
/** Caller fields that look like a target but are never authority. */
|
|
23
23
|
export const TARGET_HINT_KEYS = Object.freeze(["site", "environment", "tenant", "target"]);
|
|
24
24
|
|
|
25
|
+
const ACTOR_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
26
|
+
const POLICY_DIGEST = /^[0-9a-f]{64}$/i;
|
|
27
|
+
|
|
28
|
+
function tableHasKeys(table) {
|
|
29
|
+
return Object.keys(table).some((key) => {
|
|
30
|
+
const id = key.trim();
|
|
31
|
+
return id && !id.startsWith("_");
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
/** Always discoverable; they are how an operator sees a denial. */
|
|
26
36
|
export const DIAGNOSTIC_TOOLS = new Set([
|
|
27
37
|
"drupal_list_sites",
|
|
@@ -104,6 +114,147 @@ export function principalHasScope(identity, scope) {
|
|
|
104
114
|
return (identity?.scopes ?? []).includes(scope);
|
|
105
115
|
}
|
|
106
116
|
|
|
117
|
+
function grantNameList(values) {
|
|
118
|
+
if (!Array.isArray(values)) return [];
|
|
119
|
+
return [...new Set(
|
|
120
|
+
values.map((value) => String(value).trim()).filter((value) => value && !value.startsWith("_")),
|
|
121
|
+
)];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Normalize `auth.actors` (principal key → Drupal user UUID).
|
|
126
|
+
* Keys are inbound `sub` or `azp`/`clientId`. Values may be a UUID string
|
|
127
|
+
* or `{ uuid, delegators?, revoked? }`. Comment keys are dropped.
|
|
128
|
+
*
|
|
129
|
+
* @param {object|null} actors
|
|
130
|
+
* @returns {object|null}
|
|
131
|
+
*/
|
|
132
|
+
export function normalizeActors(actors) {
|
|
133
|
+
if (!actors || typeof actors !== "object" || Array.isArray(actors)) return null;
|
|
134
|
+
const entries = [];
|
|
135
|
+
for (const [rawKey, value] of Object.entries(actors)) {
|
|
136
|
+
const key = rawKey.trim();
|
|
137
|
+
if (!key || key.startsWith("_")) continue;
|
|
138
|
+
let uuid = "";
|
|
139
|
+
let revoked = false;
|
|
140
|
+
let delegators = [];
|
|
141
|
+
if (typeof value === "string") {
|
|
142
|
+
uuid = value.trim();
|
|
143
|
+
} else if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
144
|
+
uuid = typeof value.uuid === "string" ? value.uuid.trim() : "";
|
|
145
|
+
revoked = value.revoked === true;
|
|
146
|
+
delegators = grantNameList(value.delegators);
|
|
147
|
+
} else {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!uuid || !ACTOR_UUID.test(uuid)) continue;
|
|
151
|
+
entries.push([key, Object.freeze({ uuid, revoked, delegators: Object.freeze(delegators) })]);
|
|
152
|
+
}
|
|
153
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Resolve the Drupal actor for this principal from server-owned grants.
|
|
158
|
+
* Caller `identity.actor` is ignored. JWT `act.sub` is a confirming hint
|
|
159
|
+
* inside `delegators`, never authority.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} params
|
|
162
|
+
* @param {object|null} params.identity
|
|
163
|
+
* @param {object|null} [params.actors]
|
|
164
|
+
* @returns {{actor: string|null, delegator: string|null, required: boolean, reason: "not_entitled"|null}}
|
|
165
|
+
*/
|
|
166
|
+
export function resolveActor({ identity = null, actors = null } = {}) {
|
|
167
|
+
if (!actors || typeof actors !== "object" || Array.isArray(actors)) {
|
|
168
|
+
return { actor: null, delegator: null, required: false, reason: null };
|
|
169
|
+
}
|
|
170
|
+
const hasKeys = tableHasKeys(actors);
|
|
171
|
+
const table = normalizeActors(actors);
|
|
172
|
+
if (!table) {
|
|
173
|
+
return {
|
|
174
|
+
actor: null,
|
|
175
|
+
delegator: null,
|
|
176
|
+
required: hasKeys,
|
|
177
|
+
reason: hasKeys ? "not_entitled" : null,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const lookup = new Map(Object.entries(table));
|
|
181
|
+
const record = (identity?.sub && lookup.get(identity.sub))
|
|
182
|
+
|| (identity?.clientId && lookup.get(identity.clientId))
|
|
183
|
+
|| null;
|
|
184
|
+
if (!record || record.revoked) {
|
|
185
|
+
return { actor: null, delegator: null, required: true, reason: "not_entitled" };
|
|
186
|
+
}
|
|
187
|
+
const actSub = typeof identity?.actSub === "string" && identity.actSub.trim()
|
|
188
|
+
? identity.actSub.trim()
|
|
189
|
+
: null;
|
|
190
|
+
if (actSub && !record.delegators.includes(actSub)) {
|
|
191
|
+
return { actor: null, delegator: null, required: true, reason: "not_entitled" };
|
|
192
|
+
}
|
|
193
|
+
return { actor: record.uuid, delegator: actSub, required: true, reason: null };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Normalize `auth.policies` (principal key → SHA-256 digest).
|
|
198
|
+
* Keys are inbound `sub` or `azp`/`clientId`. Values may be a digest string
|
|
199
|
+
* or `{ digest, revoked? }`. Comment keys are dropped.
|
|
200
|
+
*
|
|
201
|
+
* @param {object|null} policies
|
|
202
|
+
* @returns {object|null}
|
|
203
|
+
*/
|
|
204
|
+
export function normalizePolicies(policies) {
|
|
205
|
+
if (!policies || typeof policies !== "object" || Array.isArray(policies)) return null;
|
|
206
|
+
const entries = [];
|
|
207
|
+
for (const [rawKey, value] of Object.entries(policies)) {
|
|
208
|
+
const key = rawKey.trim();
|
|
209
|
+
if (!key || key.startsWith("_")) continue;
|
|
210
|
+
let digest = "";
|
|
211
|
+
let revoked = false;
|
|
212
|
+
if (typeof value === "string") {
|
|
213
|
+
digest = value.trim().toLowerCase();
|
|
214
|
+
} else if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
215
|
+
digest = typeof value.digest === "string" ? value.digest.trim().toLowerCase() : "";
|
|
216
|
+
revoked = value.revoked === true;
|
|
217
|
+
} else {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (!digest || !POLICY_DIGEST.test(digest)) continue;
|
|
221
|
+
entries.push([key, Object.freeze({ digest, revoked })]);
|
|
222
|
+
}
|
|
223
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Resolve the expected policy digest for this principal from server-owned grants.
|
|
228
|
+
* Caller `identity.policy` is ignored.
|
|
229
|
+
*
|
|
230
|
+
* @param {object} params
|
|
231
|
+
* @param {object|null} params.identity
|
|
232
|
+
* @param {object|null} [params.policies]
|
|
233
|
+
* @returns {{policy: string|null, required: boolean, reason: "not_entitled"|null}}
|
|
234
|
+
*/
|
|
235
|
+
export function resolvePolicy({ identity = null, policies = null } = {}) {
|
|
236
|
+
if (!policies || typeof policies !== "object" || Array.isArray(policies)) {
|
|
237
|
+
return { policy: null, required: false, reason: null };
|
|
238
|
+
}
|
|
239
|
+
const hasKeys = tableHasKeys(policies);
|
|
240
|
+
const table = normalizePolicies(policies);
|
|
241
|
+
if (!table) {
|
|
242
|
+
return {
|
|
243
|
+
policy: null,
|
|
244
|
+
required: hasKeys,
|
|
245
|
+
reason: hasKeys ? "not_entitled" : null,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const lookup = new Map(Object.entries(table));
|
|
249
|
+
const record = (identity?.sub && lookup.get(identity.sub))
|
|
250
|
+
|| (identity?.clientId && lookup.get(identity.clientId))
|
|
251
|
+
|| null;
|
|
252
|
+
if (!record || record.revoked) {
|
|
253
|
+
return { policy: null, required: true, reason: "not_entitled" };
|
|
254
|
+
}
|
|
255
|
+
return { policy: record.digest, required: true, reason: null };
|
|
256
|
+
}
|
|
257
|
+
|
|
107
258
|
/**
|
|
108
259
|
* Site names this principal may address. Unknown names in a grant are dropped.
|
|
109
260
|
*
|
|
@@ -240,7 +391,7 @@ export function describeTarget(site, source) {
|
|
|
240
391
|
* @param {Array<object>} resolvable
|
|
241
392
|
* @param {string[]} configuredNames
|
|
242
393
|
* @param {object|null} [grants]
|
|
243
|
-
* @returns {{sites: string[], targets: Array<{name: string, baseUrl?: string, source: string}>}}
|
|
394
|
+
* @returns {{sites: string[], targets: Array<{name: string, baseUrl?: string, source: string}>, tenants: Array<{id: string, source: string}>}}
|
|
244
395
|
*/
|
|
245
396
|
export function visibleSiteTargets(identity, resolvable, configuredNames, grants) {
|
|
246
397
|
const granted = identity ? resolveGrantedSites(identity, resolvable, grants) : resolvable;
|
|
@@ -249,12 +400,16 @@ export function visibleSiteTargets(identity, resolvable, configuredNames, grants
|
|
|
249
400
|
: [...configuredNames];
|
|
250
401
|
const byName = new Map(granted.map((site) => [site._name, site]));
|
|
251
402
|
const source = identity ? "grant" : "config";
|
|
403
|
+
const tenantId = identity && typeof identity.tenant === "string" && identity.tenant
|
|
404
|
+
? identity.tenant
|
|
405
|
+
: null;
|
|
252
406
|
return {
|
|
253
407
|
sites: names,
|
|
254
408
|
targets: names.map((name) => {
|
|
255
409
|
const site = byName.get(name);
|
|
256
410
|
return site ? describeTarget(site, source) : { name, source };
|
|
257
411
|
}),
|
|
412
|
+
tenants: tenantId ? [{ id: tenantId, source: "grant" }] : [],
|
|
258
413
|
};
|
|
259
414
|
}
|
|
260
415
|
|
package/src/lib/relay/agent.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { connect as netConnect } from "node:net";
|
|
17
17
|
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
18
18
|
import { createConnectorServerFactory } from "../mcp-server.js";
|
|
19
|
+
import { POLICY_DIGEST } from "../policy-promotion.js";
|
|
19
20
|
import { runWithIdentity } from "../principal.js";
|
|
20
21
|
import { attachFramer, forwardHeaders, writeFrame } from "./frames.js";
|
|
21
22
|
|
|
@@ -35,6 +36,9 @@ import { attachFramer, forwardHeaders, writeFrame } from "./frames.js";
|
|
|
35
36
|
* @param {?() => void} [options.onChannelClose] Called when an established
|
|
36
37
|
* channel is lost (not on deliberate `drop`/`close`), so an entry point
|
|
37
38
|
* can fail loudly instead of idling disconnected.
|
|
39
|
+
* @param {?{activate: Function}} [options.policyEnforcement] Local Sentinel
|
|
40
|
+
* hook. `activate(document)` verifies, activates, and attests. Missing
|
|
41
|
+
* hook fail-closes a `policy-bundle` frame (no mint on this process).
|
|
38
42
|
* @param {?{recordConnect: Function}} [options.ledger]
|
|
39
43
|
* @returns {object}
|
|
40
44
|
*/
|
|
@@ -45,6 +49,7 @@ export function createRelayAgent({
|
|
|
45
49
|
surface,
|
|
46
50
|
connectFn = netConnect,
|
|
47
51
|
onChannelClose = null,
|
|
52
|
+
policyEnforcement = null,
|
|
48
53
|
ledger = null,
|
|
49
54
|
}) {
|
|
50
55
|
if (!host || !port) {
|
|
@@ -63,6 +68,41 @@ export function createRelayAgent({
|
|
|
63
68
|
let socket = null;
|
|
64
69
|
let agentInfo = null;
|
|
65
70
|
|
|
71
|
+
async function presentBundle(activeSocket, frame) {
|
|
72
|
+
let ok = false;
|
|
73
|
+
let digest = "";
|
|
74
|
+
let reason = "no_enforcement";
|
|
75
|
+
try {
|
|
76
|
+
if (policyEnforcement && typeof policyEnforcement.activate === "function") {
|
|
77
|
+
const result = await policyEnforcement.activate(frame.document);
|
|
78
|
+
const claimed = typeof result?.digest === "string"
|
|
79
|
+
? result.digest.trim().toLowerCase()
|
|
80
|
+
: "";
|
|
81
|
+
ok = result?.ok === true && POLICY_DIGEST.test(claimed);
|
|
82
|
+
digest = ok ? claimed : "";
|
|
83
|
+
reason = ok
|
|
84
|
+
? ""
|
|
85
|
+
: (typeof result?.reason === "string" && result.reason.trim()
|
|
86
|
+
? result.reason.trim().slice(0, 64)
|
|
87
|
+
: "unverified");
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
ok = false;
|
|
91
|
+
digest = "";
|
|
92
|
+
reason = "activate_failed";
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
writeFrame(activeSocket, {
|
|
96
|
+
type: "policy-bundle-ack",
|
|
97
|
+
ok,
|
|
98
|
+
digest,
|
|
99
|
+
...(ok ? {} : { reason }),
|
|
100
|
+
});
|
|
101
|
+
} catch {
|
|
102
|
+
activeSocket.destroy();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
66
106
|
async function serveFrame(activeSocket, frame) {
|
|
67
107
|
if (frame.type !== "mcp-request") return;
|
|
68
108
|
if (!frame.identity || typeof frame.identity !== "object" || Array.isArray(frame.identity)) {
|
|
@@ -138,6 +178,10 @@ export function createRelayAgent({
|
|
|
138
178
|
resolve({ ok: false, reason: frame.reason });
|
|
139
179
|
return;
|
|
140
180
|
}
|
|
181
|
+
if (frame.type === "policy-bundle") {
|
|
182
|
+
void presentBundle(next, frame);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
141
185
|
void serveFrame(next, frame);
|
|
142
186
|
});
|
|
143
187
|
next.on("error", reject);
|