mandala-computer-mcp 0.8.0 → 0.9.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/README.md +143 -32
- package/dist/api.d.ts +6 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +13 -3
- package/dist/api.js.map +1 -1
- package/dist/cli.d.ts +5 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +28 -9
- package/dist/cli.js.map +1 -1
- package/dist/credentials.d.ts +55 -0
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +104 -2
- package/dist/credentials.js.map +1 -1
- package/dist/errors.d.ts +25 -8
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +62 -34
- package/dist/errors.js.map +1 -1
- package/dist/format.d.ts +9 -0
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +17 -1
- package/dist/format.js.map +1 -1
- package/dist/http.d.ts +33 -7
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +344 -61
- package/dist/http.js.map +1 -1
- package/dist/paths.d.ts +7 -5
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +17 -7
- package/dist/paths.js.map +1 -1
- package/dist/secret-store.d.ts +2 -1
- package/dist/secret-store.d.ts.map +1 -1
- package/dist/secret-store.js +6 -1
- package/dist/secret-store.js.map +1 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/session.d.ts +24 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +6 -0
- package/dist/session.js.map +1 -1
- package/dist/stdio.d.ts.map +1 -1
- package/dist/stdio.js +9 -1
- package/dist/stdio.js.map +1 -1
- package/dist/tool-filters.d.ts +1 -1
- package/dist/tool-filters.d.ts.map +1 -1
- package/dist/tool-filters.js +3 -0
- package/dist/tool-filters.js.map +1 -1
- package/dist/tools/account.d.ts.map +1 -1
- package/dist/tools/account.js +80 -6
- package/dist/tools/account.js.map +1 -1
- package/dist/tools/chat.d.ts.map +1 -1
- package/dist/tools/chat.js +14 -7
- package/dist/tools/chat.js.map +1 -1
- package/dist/tools/computers.d.ts.map +1 -1
- package/dist/tools/computers.js +62 -4
- package/dist/tools/computers.js.map +1 -1
- package/dist/tools/guest.d.ts.map +1 -1
- package/dist/tools/guest.js +7 -1
- package/dist/tools/guest.js.map +1 -1
- package/dist/tools/input.d.ts.map +1 -1
- package/dist/tools/input.js +98 -48
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/secrets.d.ts.map +1 -1
- package/dist/tools/secrets.js +76 -19
- package/dist/tools/secrets.js.map +1 -1
- package/dist/version.d.ts +8 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/package.json +1 -1
package/dist/http.js
CHANGED
|
@@ -78,7 +78,9 @@ export const DEFAULT_MAX_SESSIONS = 256;
|
|
|
78
78
|
*
|
|
79
79
|
* A suspended account's bearer is admitted (OPL-5437: it is how its holder
|
|
80
80
|
* reaches `whoami` and learns it is suspended), and one session is all that
|
|
81
|
-
* needs. Every other call it could make is refused by the platform.
|
|
81
|
+
* needs. Every other call it could make is refused by the platform. One
|
|
82
|
+
* session for the ACCOUNT, however many keys or tokens it holds (OPL-5452):
|
|
83
|
+
* see `standing` in {@link runHttp}.
|
|
82
84
|
*/
|
|
83
85
|
const DEFAULT_MAX_SESSIONS_PER_BEARER = 16;
|
|
84
86
|
const MAX_SESSIONS_SUSPENDED = 1;
|
|
@@ -231,6 +233,32 @@ export async function runHttp(cfg) {
|
|
|
231
233
|
const checkTtl = cfg.bearerCheckTtlMs ?? DEFAULT_BEARER_CHECK_TTL_MS;
|
|
232
234
|
const accepted = new Map();
|
|
233
235
|
let checksInFlight = 0;
|
|
236
|
+
const standing = new Map();
|
|
237
|
+
let probeSeq = 0;
|
|
238
|
+
/** Whether the latest answer about this account says it is suspended. */
|
|
239
|
+
const accountSuspended = (account) => standing.get(account)?.suspended === true;
|
|
240
|
+
/** Take a probe's answer for its account, unless a newer-started one answered already. */
|
|
241
|
+
const noteStanding = (account, suspended, seq) => {
|
|
242
|
+
const known = standing.get(account);
|
|
243
|
+
if (known && known.seq >= seq)
|
|
244
|
+
return;
|
|
245
|
+
// Re-inserted, so Map order is the order answers were taken in.
|
|
246
|
+
standing.delete(account);
|
|
247
|
+
if (standing.size >= MAX_ACCEPTED_BEARERS) {
|
|
248
|
+
// An account holding no session needs no answer kept: a later request
|
|
249
|
+
// or initialize for it finds none and asks again.
|
|
250
|
+
const holding = new Set();
|
|
251
|
+
for (const live of sessions.values())
|
|
252
|
+
holding.add(live.account);
|
|
253
|
+
for (const k of standing.keys())
|
|
254
|
+
if (!holding.has(k))
|
|
255
|
+
standing.delete(k);
|
|
256
|
+
if (standing.size >= MAX_ACCEPTED_BEARERS) {
|
|
257
|
+
standing.delete(standing.keys().next().value);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
standing.set(account, { suspended, seq });
|
|
261
|
+
};
|
|
234
262
|
/** The platform's acceptance of this bearer, while the cache window lasts. */
|
|
235
263
|
const acceptance = (key) => {
|
|
236
264
|
const entry = accepted.get(digest(key).toString('hex'));
|
|
@@ -241,35 +269,46 @@ export async function runHttp(cfg) {
|
|
|
241
269
|
/**
|
|
242
270
|
* `ok`: the probe answered 2xx, and only that — the one answer that says the
|
|
243
271
|
* platform authenticated this credential AND let it act. `suspended`: the
|
|
244
|
-
* same 2xx, for an account whose
|
|
245
|
-
* as `ok` is, and held to one session. `refused`: a 401.
|
|
272
|
+
* same 2xx, for an account whose standing says `status: "suspended"` —
|
|
273
|
+
* admitted as `ok` is, and held to one session in all. `refused`: a 401.
|
|
246
274
|
* `unknown`: anything else — a 403 (a lost membership, say; a suspended
|
|
247
275
|
* account is answered 2xx on whoami and admitted), a 404 (the route moved), a 429, a 5xx, a timeout, or this
|
|
248
276
|
* server already asking about as many bearers as it will at once. Only `ok`
|
|
249
|
-
* and `suspended` are cached
|
|
250
|
-
*
|
|
277
|
+
* and `suspended` are cached: the account key with the acceptance, the
|
|
278
|
+
* standing per account. `account` is the account key the bearer counts against (see
|
|
251
279
|
* {@link accountKey}); it is set whenever the verdict is `ok` or
|
|
252
280
|
* `suspended`, and returned rather than read back from the cache, which a
|
|
253
|
-
* zero TTL or a full cache may already have let go of.
|
|
281
|
+
* zero TTL or a full cache may already have let go of. `workspace` is the
|
|
282
|
+
* workspace key (see {@link workspaceKey}), returned the same way, when
|
|
283
|
+
* whoami named a workspace for an account it named (OPL-5453).
|
|
254
284
|
*/
|
|
255
285
|
// `onStart` runs only when a probe really starts — never for a cached answer
|
|
256
286
|
// or a full cap — and synchronously, before the first await, so a caller's
|
|
257
287
|
// bookkeeping and its check against it cannot interleave with another's.
|
|
288
|
+
//
|
|
289
|
+
// `ok` and `suspended` are the account's STANDING when this returns, which
|
|
290
|
+
// a newer-started probe of another of its bearers may have set, not what
|
|
291
|
+
// this bearer's own probe (or cached acceptance) said (OPL-5452).
|
|
258
292
|
const checkBearer = async (key, onStart) => {
|
|
259
293
|
const id = digest(key).toString('hex');
|
|
260
294
|
const cached = acceptance(key);
|
|
261
|
-
|
|
295
|
+
// Believed only while its account has a standing to read (see above).
|
|
296
|
+
if (cached && standing.has(cached.account)) {
|
|
262
297
|
return {
|
|
263
|
-
verdict: cached.
|
|
264
|
-
account:
|
|
298
|
+
verdict: accountSuspended(cached.account) ? 'suspended' : 'ok',
|
|
299
|
+
account: cached.account,
|
|
300
|
+
workspace: cached.workspace,
|
|
265
301
|
};
|
|
266
302
|
}
|
|
267
303
|
if (checksInFlight >= MAX_BEARER_CHECKS_IN_FLIGHT)
|
|
268
304
|
return { verdict: 'unknown' };
|
|
269
305
|
checksInFlight++;
|
|
306
|
+
// Numbered as it starts, before the first await: see `standing`.
|
|
307
|
+
const seq = ++probeSeq;
|
|
270
308
|
onStart?.();
|
|
271
309
|
let suspended;
|
|
272
310
|
let confirmed;
|
|
311
|
+
let workspaceId;
|
|
273
312
|
try {
|
|
274
313
|
const api = new Api(key, cfg.baseUrl, AbortSignal.timeout(BEARER_CHECK_TIMEOUT_MS), {
|
|
275
314
|
serviceSecret,
|
|
@@ -287,6 +326,7 @@ export async function runHttp(cfg) {
|
|
|
287
326
|
const who = await api.json('GET', WHOAMI);
|
|
288
327
|
suspended = isSuspended(who);
|
|
289
328
|
confirmed = accountIdOf(who);
|
|
329
|
+
workspaceId = workspaceIdOf(who);
|
|
290
330
|
}
|
|
291
331
|
catch (err) {
|
|
292
332
|
return { verdict: err instanceof APIError && err.status === 401 ? 'refused' : 'unknown' };
|
|
@@ -304,8 +344,11 @@ export async function runHttp(cfg) {
|
|
|
304
344
|
accepted.delete(accepted.keys().next().value);
|
|
305
345
|
}
|
|
306
346
|
}
|
|
307
|
-
|
|
308
|
-
|
|
347
|
+
const account = accountKey(id, confirmed);
|
|
348
|
+
const workspace = workspaceKey(account, workspaceId);
|
|
349
|
+
accepted.set(id, { until: now + checkTtl, account, workspace });
|
|
350
|
+
noteStanding(account, suspended, seq);
|
|
351
|
+
return { verdict: accountSuspended(account) ? 'suspended' : 'ok', account, workspace };
|
|
309
352
|
};
|
|
310
353
|
// Refused initializes per source, in fixed one-minute windows. Kept apart
|
|
311
354
|
// from the session cap so a flood of invented tokens is turned away before
|
|
@@ -404,13 +447,32 @@ export async function runHttp(cfg) {
|
|
|
404
447
|
// number of bearers, so a burst spread across its keys would otherwise all
|
|
405
448
|
// count the same sessions and all pass the account ceiling.
|
|
406
449
|
const pendingByAccount = new Map();
|
|
407
|
-
// The ceiling no account may pass, live plus pending:
|
|
408
|
-
// default
|
|
409
|
-
//
|
|
450
|
+
// The ceiling no account may pass, live plus pending: a quarter of the pool
|
|
451
|
+
// by default (OPL-5454). Half let two accounts take the whole of it between
|
|
452
|
+
// them, however many others were waiting. Normalised as the per-bearer cap
|
|
453
|
+
// is, and clamped to the pool.
|
|
410
454
|
const perAccount = cfg.maxSessionsPerAccount;
|
|
411
455
|
const maxPerAccount = typeof perAccount === 'number' && perAccount >= 1
|
|
412
456
|
? Math.min(Math.floor(perAccount), maxSessions)
|
|
413
|
-
: Math.max(1, Math.floor(maxSessions /
|
|
457
|
+
: Math.max(1, Math.floor(maxSessions / 4));
|
|
458
|
+
// The same reservations per workspace (OPL-5453), keyed by workspace key and
|
|
459
|
+
// kept only for workspace-scoped bearers, for the same reason again.
|
|
460
|
+
const pendingByWorkspace = new Map();
|
|
461
|
+
// The sub-ceiling no workspace may pass under its account's: a quarter of
|
|
462
|
+
// the account's ceiling by default, the EFFECTIVE one, so raising only the
|
|
463
|
+
// account ceiling raises this with it. Normalised likewise, and clamped to
|
|
464
|
+
// the account ceiling, which holds a workspace whatever this says.
|
|
465
|
+
const perWorkspace = cfg.maxSessionsPerWorkspace;
|
|
466
|
+
const maxPerWorkspace = typeof perWorkspace === 'number' && perWorkspace >= 1
|
|
467
|
+
? Math.min(Math.floor(perWorkspace), maxPerAccount)
|
|
468
|
+
: Math.max(1, Math.floor(maxPerAccount / 4));
|
|
469
|
+
// The last quarter of the pool, kept for accounts holding less than their
|
|
470
|
+
// share of the rest (OPL-5454; see the initialize's admission). None in a
|
|
471
|
+
// pool under 4. Not configurable. The share is taken of the UNRESERVED part:
|
|
472
|
+
// of the whole pool, a lone account would always hold less than its share
|
|
473
|
+
// and could take the reserve too, which would then protect nobody.
|
|
474
|
+
const poolReserve = Math.floor(maxSessions / 4);
|
|
475
|
+
const unreserved = maxSessions - poolReserve;
|
|
414
476
|
// Two parsers, chosen by whether this server has already checked who is
|
|
415
477
|
// asking.
|
|
416
478
|
//
|
|
@@ -703,9 +765,23 @@ export async function runHttp(cfg) {
|
|
|
703
765
|
return counted;
|
|
704
766
|
};
|
|
705
767
|
/**
|
|
706
|
-
* The sessions
|
|
707
|
-
*
|
|
708
|
-
*
|
|
768
|
+
* The live sessions counted against this workspace key (OPL-5453; see
|
|
769
|
+
* {@link workspaceKey}), whichever bearer opened them, refused ones
|
|
770
|
+
* included for the reason {@link sessionsOfAccount} gives.
|
|
771
|
+
*/
|
|
772
|
+
const sessionsOfWorkspace = (workspace) => {
|
|
773
|
+
const counted = [];
|
|
774
|
+
for (const entry of sessions)
|
|
775
|
+
if (entry[1].workspace === workspace)
|
|
776
|
+
counted.push(entry);
|
|
777
|
+
return counted;
|
|
778
|
+
};
|
|
779
|
+
/**
|
|
780
|
+
* The sessions to close so that one more fits under this bearer's cap, its
|
|
781
|
+
* account's ceiling (OPL-5447) and, for a workspace-scoped bearer, its
|
|
782
|
+
* workspace's sub-ceiling (OPL-5453), or which of them it cannot fit under:
|
|
783
|
+
* `bearer`, then `workspace`, then `account`, when more than one cannot be
|
|
784
|
+
* met.
|
|
709
785
|
*
|
|
710
786
|
* Only idle sessions (`active === 0`, what the sweeper closes) are
|
|
711
787
|
* candidates, and of two kinds. The bearer's own, which make room under
|
|
@@ -716,43 +792,119 @@ export async function runHttp(cfg) {
|
|
|
716
792
|
* account's other keys out until the sweep. Refused ones go first, then the
|
|
717
793
|
* least recently seen. A live session of another bearer is never a
|
|
718
794
|
* candidate, and neither is anything of another account; an own session
|
|
719
|
-
* counts toward the ceiling's need only if it counts against this account
|
|
795
|
+
* counts toward the ceiling's need only if it counts against this account,
|
|
796
|
+
* and any candidate toward the workspace's only if it counts against this
|
|
797
|
+
* workspace. A candidate is taken only if it meets a need that is still
|
|
798
|
+
* unmet.
|
|
799
|
+
*
|
|
800
|
+
* `suspended` (hosted mode, the account's standing, OPL-5452) lowers both
|
|
801
|
+
* limits to one, and makes every idle session of the account a candidate,
|
|
802
|
+
* whichever bearer opened it: a suspended account holds one session in all,
|
|
803
|
+
* and the bearer asking now (a refreshed token, say) may take it over while
|
|
804
|
+
* it is idle. One with a request in flight is still never a candidate. The
|
|
805
|
+
* workspace sub-ceiling is not applied then: the account's one session is
|
|
806
|
+
* below any sub-ceiling, and applying it could close a second session that
|
|
807
|
+
* the account's one-session limit does not need closed.
|
|
720
808
|
*
|
|
721
|
-
* `
|
|
722
|
-
*
|
|
723
|
-
*
|
|
809
|
+
* `workspace` is the requester's workspace key, undefined when its bearer
|
|
810
|
+
* is not workspace-scoped. `pendingBearer`/`pendingWorkspace`/
|
|
811
|
+
* `pendingAccount` are initializes admitted and not yet landed; at landing
|
|
812
|
+
* all are 0 (see `onsessioninitialized`). The result names sessions, so
|
|
813
|
+
* nothing that outlives the call may keep it.
|
|
724
814
|
*/
|
|
725
|
-
const planRoom = (keyDigest, account,
|
|
815
|
+
const planRoom = (keyDigest, account, workspace, suspended, pendingBearer, pendingWorkspace, pendingAccount) => {
|
|
816
|
+
const bearerCap = suspended ? MAX_SESSIONS_SUSPENDED : maxPerBearer;
|
|
817
|
+
const accountCap = suspended ? MAX_SESSIONS_SUSPENDED : maxPerAccount;
|
|
726
818
|
const own = sessionsOf(keyDigest);
|
|
727
819
|
const counted = sessionsOfAccount(account);
|
|
728
820
|
let bearerNeed = own.length + pendingBearer - bearerCap + 1;
|
|
729
|
-
let accountNeed = counted.length + pendingAccount -
|
|
821
|
+
let accountNeed = counted.length + pendingAccount - accountCap + 1;
|
|
822
|
+
let workspaceNeed = workspace !== undefined && !suspended
|
|
823
|
+
? sessionsOfWorkspace(workspace).length + pendingWorkspace - maxPerWorkspace + 1
|
|
824
|
+
: 0;
|
|
730
825
|
const victims = [];
|
|
731
|
-
if (bearerNeed <= 0 && accountNeed <= 0)
|
|
826
|
+
if (bearerNeed <= 0 && accountNeed <= 0 && workspaceNeed <= 0)
|
|
732
827
|
return { victims };
|
|
733
828
|
const mine = new Set(own.map(([id]) => id));
|
|
734
|
-
const candidates = [
|
|
829
|
+
const candidates = [
|
|
830
|
+
...own,
|
|
831
|
+
...counted.filter(([id, live]) => !mine.has(id) && (live.refused || suspended)),
|
|
832
|
+
]
|
|
735
833
|
.filter(([, live]) => live.active === 0)
|
|
736
|
-
.sort(
|
|
834
|
+
.sort(closingOrder);
|
|
737
835
|
for (const entry of candidates) {
|
|
738
|
-
if (bearerNeed <= 0 && accountNeed <= 0)
|
|
836
|
+
if (bearerNeed <= 0 && accountNeed <= 0 && workspaceNeed <= 0)
|
|
739
837
|
break;
|
|
740
838
|
const ofBearer = mine.has(entry[0]);
|
|
741
839
|
const ofAccount = entry[1].account === account;
|
|
742
|
-
|
|
840
|
+
const ofWorkspace = workspace !== undefined && entry[1].workspace === workspace;
|
|
841
|
+
if (!(ofBearer && bearerNeed > 0) &&
|
|
842
|
+
!(ofAccount && accountNeed > 0) &&
|
|
843
|
+
!(ofWorkspace && workspaceNeed > 0)) {
|
|
743
844
|
continue;
|
|
845
|
+
}
|
|
744
846
|
victims.push(entry);
|
|
745
847
|
if (ofBearer)
|
|
746
848
|
bearerNeed--;
|
|
747
849
|
if (ofAccount)
|
|
748
850
|
accountNeed--;
|
|
851
|
+
if (ofWorkspace)
|
|
852
|
+
workspaceNeed--;
|
|
749
853
|
}
|
|
750
854
|
if (bearerNeed > 0)
|
|
751
855
|
return { full: 'bearer', holds: own.length };
|
|
856
|
+
if (workspaceNeed > 0)
|
|
857
|
+
return { full: 'workspace' };
|
|
752
858
|
if (accountNeed > 0)
|
|
753
859
|
return { full: 'account' };
|
|
754
860
|
return { victims };
|
|
755
861
|
};
|
|
862
|
+
/**
|
|
863
|
+
* Hold a suspended account to one session in all (OPL-5452): close its idle
|
|
864
|
+
* sessions, whichever of its bearers opened them, in {@link closingOrder},
|
|
865
|
+
* until it holds one or none idle is left. Never `keep` (the session being
|
|
866
|
+
* served), and never one with a request in flight, which goes once it is
|
|
867
|
+
* idle, at the next of these. Does nothing unless the account's standing
|
|
868
|
+
* says suspended, so a self-hosted server, which has none, is untouched.
|
|
869
|
+
*
|
|
870
|
+
* A request whose body is still arriving has not reached its session yet,
|
|
871
|
+
* so its session can look idle here; for a suspended account that costs
|
|
872
|
+
* nothing, since the platform refuses every call it could make but whoami.
|
|
873
|
+
*/
|
|
874
|
+
const enforceAccountSuspension = (account, keep) => {
|
|
875
|
+
if (!accountSuspended(account))
|
|
876
|
+
return;
|
|
877
|
+
const counted = sessionsOfAccount(account);
|
|
878
|
+
const excess = counted.length - MAX_SESSIONS_SUSPENDED;
|
|
879
|
+
if (excess <= 0)
|
|
880
|
+
return;
|
|
881
|
+
const idle = counted.filter(([id, live]) => id !== keep && live.active === 0);
|
|
882
|
+
idle.sort(closingOrder);
|
|
883
|
+
for (const [id, gone] of idle.slice(0, excess)) {
|
|
884
|
+
sessions.delete(id);
|
|
885
|
+
void gone.transport.close().catch(() => { });
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
/**
|
|
889
|
+
* {@link enforceAccountSuspension} for a session served under its own
|
|
890
|
+
* bearer, whose account the platform may have named only since the session
|
|
891
|
+
* was admitted: `bearerAccount` is what the bearer counts against NOW (its
|
|
892
|
+
* probe's or cached acceptance's account). A session is counted against the
|
|
893
|
+
* account key it was admitted under and is never re-keyed, so one admitted
|
|
894
|
+
* while whoami named no account sits in a `bearer:` bucket that holds no
|
|
895
|
+
* standing. When the bearer's account now differs from that and is
|
|
896
|
+
* suspended, the bearer's own idle sessions go down to one as well, on the
|
|
897
|
+
* account's standing as it is now: a newer-started probe that found the
|
|
898
|
+
* account active leaves them be.
|
|
899
|
+
*/
|
|
900
|
+
const enforceSuspensionFor = (live, bearerAccount, keep) => {
|
|
901
|
+
if (bearerAccount !== undefined &&
|
|
902
|
+
bearerAccount !== live.account &&
|
|
903
|
+
accountSuspended(bearerAccount)) {
|
|
904
|
+
trimIdle(live.keyDigest, MAX_SESSIONS_SUSPENDED, keep);
|
|
905
|
+
}
|
|
906
|
+
enforceAccountSuspension(live.account, keep);
|
|
907
|
+
};
|
|
756
908
|
/**
|
|
757
909
|
* Close this bearer's idle sessions, least recently seen first, until it
|
|
758
910
|
* holds no more than `cap` or none idle is left. Never `keep`, and never one
|
|
@@ -953,28 +1105,39 @@ export async function runHttp(cfg) {
|
|
|
953
1105
|
live.active--;
|
|
954
1106
|
};
|
|
955
1107
|
if (challenge && live.refused)
|
|
956
|
-
return challenged(res,
|
|
1108
|
+
return challenged(res, refusedMessage(key), rpcId(req));
|
|
957
1109
|
// Checked before a request is dispatched, while the answer can still
|
|
958
1110
|
// be a 401 whatever the call goes on to do. A platform that cannot say
|
|
959
1111
|
// is not a refusal: the call goes ahead, and a real refusal during it
|
|
960
1112
|
// still reaches the client through the held answer or the mark.
|
|
961
1113
|
const request = carriesRequest(req.body);
|
|
962
|
-
const
|
|
1114
|
+
const checked = challenge && request ? await checkBearer(key) : undefined;
|
|
1115
|
+
const verdict = checked?.verdict;
|
|
963
1116
|
if (verdict === 'refused') {
|
|
964
1117
|
live.refused = true;
|
|
965
|
-
return challenged(res,
|
|
1118
|
+
return challenged(res, refusedMessage(key), rpcId(req));
|
|
1119
|
+
}
|
|
1120
|
+
// An account suspended after its bearers opened sessions kept every
|
|
1121
|
+
// one of them, while an initialize on it was held to one (OPL-5448).
|
|
1122
|
+
// Its other idle sessions go now, whichever of its bearers opened
|
|
1123
|
+
// them (OPL-5452), down to this one: it is pinned above, and one busy
|
|
1124
|
+
// elsewhere is skipped and closed at a later one of these once idle.
|
|
1125
|
+
// What decides is the account's standing as it is now, not the
|
|
1126
|
+
// verdict above: a newer-started probe of another bearer may have
|
|
1127
|
+
// found the account reinstated while this one awaited its own.
|
|
1128
|
+
// A request has just had its bearer's standing confirmed (or found it
|
|
1129
|
+
// cached). Anything else — a notification, a response — asks the
|
|
1130
|
+
// platform nothing, so it acts on the account's standing only while
|
|
1131
|
+
// its bearer's acceptance is cached: the standing is then no older
|
|
1132
|
+
// than that, and never an answer from long before a reinstatement.
|
|
1133
|
+
const confirmed = request ? verdict === 'ok' || verdict === 'suspended' : isAccepted(key);
|
|
1134
|
+
if (challenge && confirmed) {
|
|
1135
|
+
enforceSuspensionFor(live, request ? checked?.account : acceptance(key)?.account, sessionId);
|
|
966
1136
|
}
|
|
967
|
-
// An account suspended after its bearer opened sessions kept every one
|
|
968
|
-
// of them, up to the 16 an active bearer may hold, while an initialize
|
|
969
|
-
// on it was held to one (OPL-5448). Its other idle sessions go now,
|
|
970
|
-
// down to that one; this session is pinned above and one busy
|
|
971
|
-
// elsewhere is skipped, and closed on a later request once it is idle.
|
|
972
|
-
if (verdict === 'suspended')
|
|
973
|
-
trimIdle(live.keyDigest, MAX_SESSIONS_SUSPENDED, sessionId);
|
|
974
1137
|
const lease = res.locals.largeBodyLease;
|
|
975
1138
|
const id = rpcId(req);
|
|
976
1139
|
const held = challenge
|
|
977
|
-
? holdAnswer(res, () => challenged(res,
|
|
1140
|
+
? holdAnswer(res, () => challenged(res, refusedMessage(key), id))
|
|
978
1141
|
: undefined;
|
|
979
1142
|
return await heldAnswer.run(held, () => requestBodyLease.run(lease, () => serving(live, () => live.transport.handleRequest(req, res, req.body),
|
|
980
1143
|
// A request keeps the session as before: it was just put to
|
|
@@ -1018,8 +1181,8 @@ export async function runHttp(cfg) {
|
|
|
1018
1181
|
return rpcError(res, 403, -32000, refused, rpcId(req));
|
|
1019
1182
|
// Hosted: no session for a bearer the platform does not accept. Before the
|
|
1020
1183
|
// cap and the reservation, so a refused bearer never holds a slot.
|
|
1021
|
-
let suspended = false;
|
|
1022
1184
|
let checkedAccount;
|
|
1185
|
+
let checkedWorkspace;
|
|
1023
1186
|
if (challenge) {
|
|
1024
1187
|
// A spent budget does not close the address. Many clients can share one
|
|
1025
1188
|
// (a NAT, an office), and one bad neighbour must not lock out the rest:
|
|
@@ -1040,7 +1203,7 @@ export async function runHttp(cfg) {
|
|
|
1040
1203
|
// The interval is spent only by a probe that starts: a request turned
|
|
1041
1204
|
// away because the process-wide cap is full reached nobody, and must not
|
|
1042
1205
|
// make the next client at this address wait out an interval for it.
|
|
1043
|
-
const { verdict, account } = await checkBearer(key, spent
|
|
1206
|
+
const { verdict, account, workspace } = await checkBearer(key, spent
|
|
1044
1207
|
? () => {
|
|
1045
1208
|
const entry = failures.get(sourceOf(req));
|
|
1046
1209
|
if (entry)
|
|
@@ -1049,15 +1212,15 @@ export async function runHttp(cfg) {
|
|
|
1049
1212
|
: undefined);
|
|
1050
1213
|
if (verdict === 'refused') {
|
|
1051
1214
|
noteFailure(req);
|
|
1052
|
-
return challenged(res,
|
|
1215
|
+
return challenged(res, refusedMessage(key), rpcId(req));
|
|
1053
1216
|
}
|
|
1054
1217
|
// Not an initialize, it is a 400 whatever the platform could say about
|
|
1055
1218
|
// the key short of refusing it.
|
|
1056
1219
|
if (verdict === 'unknown' && initialize) {
|
|
1057
1220
|
return unavailable(res, 'The platform could not confirm this token just now. Retry shortly.', rpcId(req));
|
|
1058
1221
|
}
|
|
1059
|
-
suspended = verdict === 'suspended';
|
|
1060
1222
|
checkedAccount = account;
|
|
1223
|
+
checkedWorkspace = workspace;
|
|
1061
1224
|
}
|
|
1062
1225
|
if (!initialize) {
|
|
1063
1226
|
return badRequest(res, 'No session id, and this is not an initialize request.', rpcId(req));
|
|
@@ -1073,11 +1236,20 @@ export async function runHttp(cfg) {
|
|
|
1073
1236
|
// Hosted, the account the platform confirmed for this bearer (or the
|
|
1074
1237
|
// bearer itself, when whoami named none); self-hosted, the bearer.
|
|
1075
1238
|
const account = checkedAccount ?? accountKey(keyId, undefined);
|
|
1239
|
+
// Hosted, the workspace key when the platform named a workspace for the
|
|
1240
|
+
// bearer's account (OPL-5453); otherwise none, and no sub-ceiling.
|
|
1241
|
+
const workspace = checkedAccount !== undefined ? checkedWorkspace : undefined;
|
|
1242
|
+
// The account's standing, read after the check above (OPL-5452): what the
|
|
1243
|
+
// newest-started probe of ANY of its bearers said, not only this one's.
|
|
1244
|
+
// Self-hosted there is none, and nothing is suspended.
|
|
1245
|
+
const suspended = accountSuspended(account);
|
|
1076
1246
|
const bearerCap = suspended ? MAX_SESSIONS_SUSPENDED : maxPerBearer;
|
|
1077
|
-
// A bearer can hold more than its
|
|
1078
|
-
// account suspended after it opened sessions
|
|
1079
|
-
//
|
|
1080
|
-
// what is left to count, and to name in a
|
|
1247
|
+
// A bearer, or a suspended account, can hold more than its limit only when
|
|
1248
|
+
// the limit fell under it: an account suspended after it opened sessions
|
|
1249
|
+
// (OPL-5448, OPL-5452). Those over it are closed first if idle, whatever
|
|
1250
|
+
// becomes of this initialize, so what is left to count, and to name in a
|
|
1251
|
+
// 429, is what it really holds.
|
|
1252
|
+
enforceAccountSuspension(account);
|
|
1081
1253
|
trimIdle(keyDigest, bearerCap);
|
|
1082
1254
|
const tooMany = (message) => {
|
|
1083
1255
|
res.set('Retry-After', String(BEARER_FULL_RETRY_AFTER_S));
|
|
@@ -1088,20 +1260,28 @@ export async function runHttp(cfg) {
|
|
|
1088
1260
|
// binds: its own cap or its account's ceiling. Never another bearer's live
|
|
1089
1261
|
// session, and never one with a request in flight: past the ceiling an
|
|
1090
1262
|
// initialize is refused, since the account's holder is the one who knows
|
|
1091
|
-
// which of its clients can spare a session.
|
|
1092
|
-
// account's whose bearer the platform
|
|
1093
|
-
//
|
|
1263
|
+
// which of its clients can spare a session. Two exceptions (see
|
|
1264
|
+
// `planRoom`): an idle session of the account's whose bearer the platform
|
|
1265
|
+
// refused, and, while the account is suspended, any idle session of the
|
|
1266
|
+
// account's, since it holds one in all (OPL-5452). A client whose old
|
|
1267
|
+
// session is gone gets 404 for it and
|
|
1094
1268
|
// initializes again, as MCP clients do. Decided here, so a caller with
|
|
1095
1269
|
// nothing idle to give back is refused at once; carried out only once the
|
|
1096
1270
|
// new session exists (in `onsessioninitialized`), so an initialize the SDK
|
|
1097
1271
|
// then refuses (406, 415, 400) or that throws on the way does not also
|
|
1098
|
-
// cost the caller a working session. Only the number is kept past here
|
|
1272
|
+
// cost the caller a working session. Only the number is kept past here,
|
|
1273
|
+
// and, for the pool reserve's count below, the ids, which do not leave
|
|
1274
|
+
// this synchronous block.
|
|
1099
1275
|
let planned = 0;
|
|
1276
|
+
let plannedIds;
|
|
1100
1277
|
{
|
|
1101
|
-
const room = planRoom(keyDigest, account,
|
|
1278
|
+
const room = planRoom(keyDigest, account, workspace, suspended, pendingByDigest.get(keyId) ?? 0, workspace !== undefined ? (pendingByWorkspace.get(workspace) ?? 0) : 0, pendingByAccount.get(account) ?? 0);
|
|
1102
1279
|
if ('full' in room) {
|
|
1103
|
-
if (room.full === 'account')
|
|
1104
|
-
return tooMany(accountFullMessage(account, maxPerAccount));
|
|
1280
|
+
if (room.full === 'account') {
|
|
1281
|
+
return tooMany(suspended ? SUSPENDED_ACCOUNT_FULL : accountFullMessage(account, maxPerAccount));
|
|
1282
|
+
}
|
|
1283
|
+
if (room.full === 'workspace')
|
|
1284
|
+
return tooMany(workspaceFullMessage(maxPerWorkspace));
|
|
1105
1285
|
// Over the cap after the trim means every session over it is busy,
|
|
1106
1286
|
// and closing one would not be enough: the client can only wait.
|
|
1107
1287
|
return tooMany(room.holds > bearerCap
|
|
@@ -1109,6 +1289,7 @@ export async function runHttp(cfg) {
|
|
|
1109
1289
|
: `This token already holds its maximum of ${bearerCap} ${bearerCap === 1 ? 'session' : 'sessions'} on this server. Close one (DELETE /mcp) or retry shortly.`);
|
|
1110
1290
|
}
|
|
1111
1291
|
planned = room.victims.length;
|
|
1292
|
+
plannedIds = new Set(room.victims.map(([id]) => id));
|
|
1112
1293
|
}
|
|
1113
1294
|
// Swept sessions free their slot on the timer; this is the backstop for the
|
|
1114
1295
|
// case the timer cannot help with, which is arrivals faster than the TTL.
|
|
@@ -1120,16 +1301,44 @@ export async function runHttp(cfg) {
|
|
|
1120
1301
|
res.set('Retry-After', String(POOL_FULL_RETRY_AFTER_S));
|
|
1121
1302
|
return unavailable(res, `This server is holding its maximum of ${maxSessions} sessions. Retry shortly.`, rpcId(req));
|
|
1122
1303
|
}
|
|
1304
|
+
// The last quarter of the pool is kept for accounts holding less than
|
|
1305
|
+
// their share (OPL-5454): without it the ceilings alone let four accounts
|
|
1306
|
+
// take every session between them, and the fifth was refused until one
|
|
1307
|
+
// gave some back. Counted as the check above counts, pending included, so
|
|
1308
|
+
// a burst cannot all read the same count. The share is of the unreserved
|
|
1309
|
+
// part, divided among the accounts holding or opening sessions, this one
|
|
1310
|
+
// included. Checked at admission only, as the pool is; nothing is closed
|
|
1311
|
+
// for it.
|
|
1312
|
+
const inUse = sessions.size - planned + pending;
|
|
1313
|
+
if (poolReserve > 0 && inUse >= unreserved) {
|
|
1314
|
+
const holders = new Set([account, ...pendingByAccount.keys()]);
|
|
1315
|
+
let held = pendingByAccount.get(account) ?? 0;
|
|
1316
|
+
for (const [id, live] of sessions) {
|
|
1317
|
+
if (plannedIds.has(id))
|
|
1318
|
+
continue;
|
|
1319
|
+
holders.add(live.account);
|
|
1320
|
+
if (live.account === account)
|
|
1321
|
+
held++;
|
|
1322
|
+
}
|
|
1323
|
+
const fairShare = Math.max(1, Math.floor(unreserved / holders.size));
|
|
1324
|
+
if (held >= fairShare) {
|
|
1325
|
+
res.set('Retry-After', String(POOL_FULL_RETRY_AFTER_S));
|
|
1326
|
+
return unavailable(res, `This server keeps its last ${poolReserve} sessions for accounts holding less than their share of it, and this account holds its share. Retry shortly.`, rpcId(req));
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1123
1329
|
pending++;
|
|
1124
1330
|
pendingByDigest.set(keyId, (pendingByDigest.get(keyId) ?? 0) + 1);
|
|
1125
1331
|
pendingByAccount.set(account, (pendingByAccount.get(account) ?? 0) + 1);
|
|
1332
|
+
if (workspace !== undefined) {
|
|
1333
|
+
pendingByWorkspace.set(workspace, (pendingByWorkspace.get(workspace) ?? 0) + 1);
|
|
1334
|
+
}
|
|
1126
1335
|
// Released exactly once, whether the initialize lands in the map or throws
|
|
1127
1336
|
// on the way there. A reservation that leaked on the failure path would
|
|
1128
1337
|
// ratchet the cap down until the process restarted — every later initialize
|
|
1129
1338
|
// refused with 503 for the life of the process, which is the denial of
|
|
1130
|
-
// service the counter was added to prevent, self-inflicted. The per-bearer
|
|
1131
|
-
// and per-
|
|
1132
|
-
// account.
|
|
1339
|
+
// service the counter was added to prevent, self-inflicted. The per-bearer,
|
|
1340
|
+
// per-account and per-workspace counts leaking would do the same to that
|
|
1341
|
+
// bearer, account and workspace.
|
|
1133
1342
|
let reserved = true;
|
|
1134
1343
|
const release = () => {
|
|
1135
1344
|
if (reserved) {
|
|
@@ -1145,6 +1354,13 @@ export async function runHttp(cfg) {
|
|
|
1145
1354
|
pendingByAccount.set(account, accountLeft);
|
|
1146
1355
|
else
|
|
1147
1356
|
pendingByAccount.delete(account);
|
|
1357
|
+
if (workspace !== undefined) {
|
|
1358
|
+
const workspaceLeft = (pendingByWorkspace.get(workspace) ?? 1) - 1;
|
|
1359
|
+
if (workspaceLeft > 0)
|
|
1360
|
+
pendingByWorkspace.set(workspace, workspaceLeft);
|
|
1361
|
+
else
|
|
1362
|
+
pendingByWorkspace.delete(workspace);
|
|
1363
|
+
}
|
|
1148
1364
|
}
|
|
1149
1365
|
};
|
|
1150
1366
|
// Everything from here to the map write is inside the reservation,
|
|
@@ -1186,13 +1402,16 @@ export async function runHttp(cfg) {
|
|
|
1186
1402
|
// on its own. Only a race reaches this: a request landing on a
|
|
1187
1403
|
// planned session between the check above and this callback.
|
|
1188
1404
|
//
|
|
1189
|
-
// The account's ceiling is re-checked here too (OPL-5447),
|
|
1405
|
+
// The account's ceiling is re-checked here too (OPL-5447), and the
|
|
1406
|
+
// workspace's sub-ceiling (OPL-5453), against
|
|
1190
1407
|
// the map alone. Initializes still pending were each admitted
|
|
1191
1408
|
// counting this one's reservation, and the room they will make is
|
|
1192
1409
|
// still in the map, so counting them again here would drop a session
|
|
1193
1410
|
// that fits; what keeps a burst under the ceiling is the pending
|
|
1194
1411
|
// count at admission. Every landing leaves the map within both.
|
|
1195
|
-
|
|
1412
|
+
// The account's standing is read again: a probe that answered in
|
|
1413
|
+
// between is the newer word on whether it is suspended.
|
|
1414
|
+
const room = planRoom(keyDigest, account, workspace, accountSuspended(account), 0, 0, 0);
|
|
1196
1415
|
if ('full' in room) {
|
|
1197
1416
|
dropped = true;
|
|
1198
1417
|
release();
|
|
@@ -1207,6 +1426,7 @@ export async function runHttp(cfg) {
|
|
|
1207
1426
|
transport: t,
|
|
1208
1427
|
keyDigest,
|
|
1209
1428
|
account,
|
|
1429
|
+
workspace,
|
|
1210
1430
|
lastSeen: Date.now(),
|
|
1211
1431
|
// Initialize is already in flight when the session first becomes
|
|
1212
1432
|
// visible to the sweeper. Count it until handleRequest settles.
|
|
@@ -1325,7 +1545,13 @@ export async function runHttp(cfg) {
|
|
|
1325
1545
|
// The stream is refused on a token the platform has already refused; a
|
|
1326
1546
|
// DELETE is still honoured, since the digest shows it is the holder.
|
|
1327
1547
|
if (challenge && live.refused && req.method === 'GET')
|
|
1328
|
-
return challenged(res,
|
|
1548
|
+
return challenged(res, refusedMessage(key));
|
|
1549
|
+
// A suspended account is held to one session on its stream too, as on a
|
|
1550
|
+
// notification: the stream asks the platform nothing, so only while this
|
|
1551
|
+
// bearer's acceptance is cached (OPL-5452). This session is the one kept.
|
|
1552
|
+
if (challenge && req.method !== 'DELETE' && isAccepted(key)) {
|
|
1553
|
+
enforceSuspensionFor(live, acceptance(key)?.account, sessionId);
|
|
1554
|
+
}
|
|
1329
1555
|
// The GET is the notification stream and is only noted; anything else
|
|
1330
1556
|
// here (a DELETE, or a HEAD, which Express routes to the GET handler with
|
|
1331
1557
|
// the method unchanged) is held for while the SDK handles it. See
|
|
@@ -1514,6 +1740,21 @@ function accountIdOf(who) {
|
|
|
1514
1740
|
const id = account.id;
|
|
1515
1741
|
return typeof id === 'string' && id.length > 0 && id.length <= 256 ? id : undefined;
|
|
1516
1742
|
}
|
|
1743
|
+
/**
|
|
1744
|
+
* The workspace id a whoami record names for a workspace-scoped bearer
|
|
1745
|
+
* (OPL-5453): `workspace.id`, a non-empty string of at most 256 characters.
|
|
1746
|
+
* Anything else, `workspace: null` (a key of the account as a whole)
|
|
1747
|
+
* included, is no workspace, and the bearer is held by no sub-ceiling.
|
|
1748
|
+
*/
|
|
1749
|
+
function workspaceIdOf(who) {
|
|
1750
|
+
if (typeof who !== 'object' || who === null)
|
|
1751
|
+
return undefined;
|
|
1752
|
+
const workspace = who.workspace;
|
|
1753
|
+
if (typeof workspace !== 'object' || workspace === null)
|
|
1754
|
+
return undefined;
|
|
1755
|
+
const id = workspace.id;
|
|
1756
|
+
return typeof id === 'string' && id.length > 0 && id.length <= 256 ? id : undefined;
|
|
1757
|
+
}
|
|
1517
1758
|
/**
|
|
1518
1759
|
* What a session counts against for the account ceiling (OPL-5447).
|
|
1519
1760
|
*
|
|
@@ -1526,6 +1767,29 @@ function accountIdOf(who) {
|
|
|
1526
1767
|
function accountKey(keyId, confirmed) {
|
|
1527
1768
|
return confirmed !== undefined ? `account:${confirmed}` : `bearer:${keyId}`;
|
|
1528
1769
|
}
|
|
1770
|
+
/**
|
|
1771
|
+
* The 429 for a suspended account with nothing idle to give up (OPL-5452):
|
|
1772
|
+
* its session is serving a request, or another initialize of its is in
|
|
1773
|
+
* flight. An idle one would have been closed to make room.
|
|
1774
|
+
*/
|
|
1775
|
+
const SUSPENDED_ACCOUNT_FULL = 'This account is suspended, so it may hold only 1 session on this server across all its tokens, and none it holds is idle. Retry once its session finishes its request, or close it (DELETE /mcp).';
|
|
1776
|
+
/**
|
|
1777
|
+
* The workspace key a workspace-scoped bearer counts against for the workspace
|
|
1778
|
+
* sub-ceiling (OPL-5453), or undefined when there is none: its account key and
|
|
1779
|
+
* the workspace id together, so the same workspace id under two accounts can
|
|
1780
|
+
* never share a count. Only an account the platform confirmed (`account:`)
|
|
1781
|
+
* has workspaces; a `bearer:` account never does.
|
|
1782
|
+
*/
|
|
1783
|
+
function workspaceKey(account, workspaceId) {
|
|
1784
|
+
return account.startsWith('account:') && workspaceId !== undefined
|
|
1785
|
+
? `${account}/workspace:${workspaceId}`
|
|
1786
|
+
: undefined;
|
|
1787
|
+
}
|
|
1788
|
+
/** The 429 for a workspace at its sub-ceiling. It names the limit and nothing else. */
|
|
1789
|
+
function workspaceFullMessage(cap) {
|
|
1790
|
+
const noun = cap === 1 ? 'session' : 'sessions';
|
|
1791
|
+
return `This workspace has reached its maximum of ${cap} ${noun} on this server, across all its tokens. Close one (DELETE /mcp) or retry shortly.`;
|
|
1792
|
+
}
|
|
1529
1793
|
/** The 429 for an account at its ceiling. It names the limit and nothing else. */
|
|
1530
1794
|
function accountFullMessage(account, cap) {
|
|
1531
1795
|
const noun = cap === 1 ? 'session' : 'sessions';
|
|
@@ -1534,6 +1798,14 @@ function accountFullMessage(account, cap) {
|
|
|
1534
1798
|
: `This token has reached the per-account maximum of ${cap} ${noun} on this server, as an account of its own. Close one (DELETE /mcp) or retry shortly.`;
|
|
1535
1799
|
}
|
|
1536
1800
|
const always = () => true;
|
|
1801
|
+
/**
|
|
1802
|
+
* The order idle sessions are closed in to make room: those whose bearer the
|
|
1803
|
+
* platform has refused first, since nothing more is served on them, then the
|
|
1804
|
+
* least recently seen.
|
|
1805
|
+
*/
|
|
1806
|
+
function closingOrder(a, b) {
|
|
1807
|
+
return Number(b[1].refused) - Number(a[1].refused) || a[1].lastSeen - b[1].lastSeen;
|
|
1808
|
+
}
|
|
1537
1809
|
/** Whether a POST body holds a JSON-RPC request, which is what opens a stream. */
|
|
1538
1810
|
function carriesRequest(body) {
|
|
1539
1811
|
const isRequest = (m) => typeof m === 'object' && m !== null && 'method' in m && 'id' in m;
|
|
@@ -1541,6 +1813,17 @@ function carriesRequest(body) {
|
|
|
1541
1813
|
}
|
|
1542
1814
|
const NO_TOKEN = 'Authorization required. Authorize this client with Mandala (OAuth), or send a Mandala API key: Authorization: Bearer …';
|
|
1543
1815
|
const TOKEN_REFUSED = 'The platform no longer accepts this access token. Refresh it, or authorize again.';
|
|
1816
|
+
const API_KEY_REFUSED = 'The platform does not accept this API key. Check it, or create a new one in Settings → Credentials.';
|
|
1817
|
+
/**
|
|
1818
|
+
* What a refused bearer is told. A `com_…` API key was never an OAuth access
|
|
1819
|
+
* token and cannot be refreshed, so it is not told to refresh (OPL-5464); any
|
|
1820
|
+
* other bearer is an access token and keeps the OAuth wording. Only the message
|
|
1821
|
+
* text differs — the 401 and its challenge are the same — and the bearer itself
|
|
1822
|
+
* is never part of it.
|
|
1823
|
+
*/
|
|
1824
|
+
function refusedMessage(key) {
|
|
1825
|
+
return key?.startsWith('com_') ? API_KEY_REFUSED : TOKEN_REFUSED;
|
|
1826
|
+
}
|
|
1544
1827
|
/**
|
|
1545
1828
|
* Hold a response's status line until its first byte of body (hosted mode).
|
|
1546
1829
|
*
|