@plaud-ai/mcp 0.3.9 → 0.3.11

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,23 +1,24 @@
1
1
  import {
2
2
  normalizeMcpHost,
3
3
  registerTools
4
- } from "./chunk-FSNFNJXH.js";
4
+ } from "./chunk-O6BKY23H.js";
5
5
  import {
6
- PlaudClient
7
- } from "./chunk-5NWKLF3V.js";
6
+ PlaudClient,
7
+ classifyError
8
+ } from "./chunk-VUPXBO2J.js";
8
9
  import {
9
10
  logger
10
- } from "./chunk-NPCCDRWQ.js";
11
+ } from "./chunk-OEZV5MA4.js";
11
12
  import {
12
13
  httpRequestDuration,
13
14
  httpRequestsInProgress,
14
15
  oauthTokenRefresh
15
- } from "./chunk-RUFCT6DQ.js";
16
+ } from "./chunk-DIPROABB.js";
16
17
 
17
18
  // src/http/server.ts
18
19
  import express from "express";
19
20
  import { createServer } from "http";
20
- import { randomUUID as randomUUID3 } from "crypto";
21
+ import { randomUUID as randomUUID2 } from "crypto";
21
22
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
23
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
23
24
  import { createOAuthMetadata, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
@@ -25,12 +26,13 @@ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middlew
25
26
  import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
26
27
 
27
28
  // src/http/oauth-provider.ts
28
- import { createCipheriv, createDecipheriv, createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
29
+ import { createCipheriv, createDecipheriv, createHash as createHash2, createHmac, randomBytes, timingSafeEqual } from "crypto";
29
30
  import { ProxyOAuthServerProvider } from "@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js";
30
31
  import {
31
32
  InvalidGrantError,
32
33
  InvalidTokenError,
33
- ServerError as McpServerError
34
+ ServerError as McpServerError,
35
+ TooManyRequestsError
34
36
  } from "@modelcontextprotocol/sdk/server/auth/errors.js";
35
37
 
36
38
  // src/http/cimd.ts
@@ -266,6 +268,249 @@ function validateMetadata(parsed, expectedClientIdUrl) {
266
268
  };
267
269
  }
268
270
 
271
+ // src/http/token-verifier.ts
272
+ import { createHash } from "crypto";
273
+ function decodeJwtClaims(token) {
274
+ const parts = token.split(".");
275
+ if (parts.length !== 3) return null;
276
+ const payload = parts[1];
277
+ if (!payload) return null;
278
+ try {
279
+ const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
280
+ if (!parsed || typeof parsed !== "object") return null;
281
+ return parsed;
282
+ } catch {
283
+ return null;
284
+ }
285
+ }
286
+ function sha256(value) {
287
+ return createHash("sha256").update(value).digest("base64url");
288
+ }
289
+ var Lru = class {
290
+ constructor(max) {
291
+ this.max = max;
292
+ }
293
+ max;
294
+ map = /* @__PURE__ */ new Map();
295
+ get(key) {
296
+ const value = this.map.get(key);
297
+ if (value === void 0) return void 0;
298
+ this.map.delete(key);
299
+ this.map.set(key, value);
300
+ return value;
301
+ }
302
+ set(key, value) {
303
+ if (this.map.has(key)) {
304
+ this.map.delete(key);
305
+ } else if (this.map.size >= this.max) {
306
+ const oldest = this.map.keys().next().value;
307
+ if (oldest !== void 0) this.map.delete(oldest);
308
+ }
309
+ this.map.set(key, value);
310
+ }
311
+ delete(key) {
312
+ this.map.delete(key);
313
+ }
314
+ get size() {
315
+ return this.map.size;
316
+ }
317
+ };
318
+ var TokenBucket = class {
319
+ constructor(capacity, refillPerSec, nowMs) {
320
+ this.capacity = capacity;
321
+ this.refillPerSec = refillPerSec;
322
+ this.tokens = capacity;
323
+ this.lastRefillMs = nowMs;
324
+ }
325
+ capacity;
326
+ refillPerSec;
327
+ tokens;
328
+ lastRefillMs;
329
+ take(nowMs) {
330
+ const elapsedSec = (nowMs - this.lastRefillMs) / 1e3;
331
+ if (elapsedSec > 0) {
332
+ this.tokens = Math.min(this.capacity, this.tokens + elapsedSec * this.refillPerSec);
333
+ this.lastRefillMs = nowMs;
334
+ }
335
+ if (this.tokens >= 1) {
336
+ this.tokens -= 1;
337
+ return true;
338
+ }
339
+ return false;
340
+ }
341
+ };
342
+ var DEFAULTS = {
343
+ cacheMaxEntries: 5e3,
344
+ positiveTtlMs: 6e4,
345
+ negativeTtlMs: 3e4,
346
+ strangerBurst: 40,
347
+ strangerRatePerSec: 20,
348
+ knownBurst: 10,
349
+ knownRatePerSec: 5,
350
+ knownSubMaxEntries: 2e4
351
+ };
352
+ var TokenVerifier = class {
353
+ cache;
354
+ subBuckets;
355
+ strangerBucket;
356
+ opts;
357
+ constructor(options) {
358
+ const now = options.now ?? (() => Date.now());
359
+ this.opts = {
360
+ verifyUpstream: options.verifyUpstream,
361
+ cacheEnabled: options.cacheEnabled ?? true,
362
+ cacheMaxEntries: options.cacheMaxEntries ?? DEFAULTS.cacheMaxEntries,
363
+ positiveTtlMs: options.positiveTtlMs ?? DEFAULTS.positiveTtlMs,
364
+ negativeTtlMs: options.negativeTtlMs ?? DEFAULTS.negativeTtlMs,
365
+ strangerBurst: options.strangerBurst ?? DEFAULTS.strangerBurst,
366
+ strangerRatePerSec: options.strangerRatePerSec ?? DEFAULTS.strangerRatePerSec,
367
+ knownBurst: options.knownBurst ?? DEFAULTS.knownBurst,
368
+ knownRatePerSec: options.knownRatePerSec ?? DEFAULTS.knownRatePerSec,
369
+ knownSubMaxEntries: options.knownSubMaxEntries ?? DEFAULTS.knownSubMaxEntries,
370
+ now
371
+ };
372
+ this.cache = new Lru(this.opts.cacheMaxEntries);
373
+ this.subBuckets = new Lru(this.opts.knownSubMaxEntries);
374
+ this.strangerBucket = new TokenBucket(
375
+ this.opts.strangerBurst,
376
+ this.opts.strangerRatePerSec,
377
+ now()
378
+ );
379
+ }
380
+ /**
381
+ * Seeds the cache with a token that was just minted upstream (OAuth code
382
+ * exchange or refresh).
383
+ *
384
+ * This is what keeps normal users out of the rate-limit tier entirely: their
385
+ * first request after logging in or rotating a token is already a cache hit,
386
+ * so it never draws from the budget that unrecognised tokens consume.
387
+ *
388
+ * clientId is taken from the JWT `sub` rather than the upstream user.id: we
389
+ * have not called upstream at this point and do not need to, since the token
390
+ * came straight from the token endpoint. Downstream only uses clientId for
391
+ * logging, and server.ts derives ctxUserId from `sub` first anyway.
392
+ */
393
+ prewarm(token) {
394
+ if (!this.opts.cacheEnabled) return;
395
+ const claims = decodeJwtClaims(token);
396
+ if (!claims) return;
397
+ const sub = typeof claims.sub === "string" ? claims.sub : void 0;
398
+ const exp = typeof claims.exp === "number" ? claims.exp : void 0;
399
+ if (!sub) return;
400
+ const nowMs = this.opts.now();
401
+ const expiresAt = exp ?? Math.floor(nowMs / 1e3) + 3600;
402
+ this.putPositive(sha256(token), sub, expiresAt, nowMs);
403
+ this.noteKnownSub(sub, nowMs);
404
+ logger.info({ event: "token_prewarmed", cache_size: this.cache.size });
405
+ }
406
+ async verify(token) {
407
+ const nowMs = this.opts.now();
408
+ const nowSec = Math.floor(nowMs / 1e3);
409
+ const claims = decodeJwtClaims(token);
410
+ if (!claims) {
411
+ logger.warn({ event: "token_rejected_local", reason: "malformed" });
412
+ return { ok: false, kind: "malformed" };
413
+ }
414
+ const sub = typeof claims.sub === "string" && claims.sub ? claims.sub : void 0;
415
+ const exp = typeof claims.exp === "number" ? claims.exp : void 0;
416
+ if (exp !== void 0 && exp <= nowSec) {
417
+ logger.warn({ event: "token_rejected_local", reason: "expired" });
418
+ return { ok: false, kind: "expired" };
419
+ }
420
+ const key = sha256(token);
421
+ if (this.opts.cacheEnabled) {
422
+ const hit = this.cache.get(key);
423
+ if (hit && hit.cacheExpiresAtMs > nowMs) {
424
+ return hit.valid ? { ok: true, clientId: hit.clientId, expiresAt: hit.expiresAt, source: "cache" } : { ok: false, kind: "invalid" };
425
+ }
426
+ if (hit) this.cache.delete(key);
427
+ }
428
+ const looksKnown = sub !== void 0 && sub.startsWith("client_user_");
429
+ const bucket = sub !== void 0 && looksKnown ? this.subBuckets.get(sub) : void 0;
430
+ const allowed = bucket ? bucket.take(nowMs) : this.strangerBucket.take(nowMs);
431
+ if (!allowed) {
432
+ logger.warn({
433
+ event: "token_verify_rate_limited",
434
+ scope: bucket ? "known_sub" : "stranger"
435
+ });
436
+ return { ok: false, kind: "rate_limited" };
437
+ }
438
+ const verdict = await this.opts.verifyUpstream(token);
439
+ if (verdict.kind === "valid") {
440
+ const expiresAt = exp ?? nowSec + 3600;
441
+ if (this.opts.cacheEnabled) this.putPositive(key, verdict.clientId, expiresAt, nowMs);
442
+ if (sub !== void 0) this.noteKnownSub(sub, nowMs);
443
+ logger.info({ event: "token_verified", client_id: verdict.clientId, expires_at: expiresAt });
444
+ return { ok: true, clientId: verdict.clientId, expiresAt, source: "upstream" };
445
+ }
446
+ if (verdict.kind === "invalid") {
447
+ if (this.opts.cacheEnabled) {
448
+ this.cache.set(key, {
449
+ valid: false,
450
+ clientId: "",
451
+ expiresAt: 0,
452
+ cacheExpiresAtMs: nowMs + this.opts.negativeTtlMs
453
+ });
454
+ }
455
+ logger.warn({ event: "token_verify_failed", reason: "invalid" });
456
+ return { ok: false, kind: "invalid" };
457
+ }
458
+ if (verdict.kind === "rate_limited") {
459
+ logger.warn({ event: "token_verify_upstream_rate_limited" });
460
+ return { ok: false, kind: "rate_limited" };
461
+ }
462
+ logger.error({ event: "token_verify_unavailable", reason: verdict.reason });
463
+ return { ok: false, kind: "unavailable" };
464
+ }
465
+ /** For /health and metrics — counts only, no tokens or user identifiers. */
466
+ stats() {
467
+ return {
468
+ cacheSize: this.cache.size,
469
+ knownSubs: this.subBuckets.size,
470
+ cacheEnabled: this.opts.cacheEnabled
471
+ };
472
+ }
473
+ putPositive(key, clientId, expiresAt, nowMs) {
474
+ const remainingMs = expiresAt * 1e3 - nowMs;
475
+ const ttlMs = Math.min(this.opts.positiveTtlMs, Math.max(0, remainingMs));
476
+ if (ttlMs <= 0) return;
477
+ this.cache.set(key, { valid: true, clientId, expiresAt, cacheExpiresAtMs: nowMs + ttlMs });
478
+ }
479
+ noteKnownSub(sub, nowMs) {
480
+ if (this.subBuckets.get(sub) === void 0) {
481
+ this.subBuckets.set(
482
+ sub,
483
+ new TokenBucket(this.opts.knownBurst, this.opts.knownRatePerSec, nowMs)
484
+ );
485
+ }
486
+ }
487
+ };
488
+ function tokenVerifierOptionsFromEnv() {
489
+ const num = (name, fallback) => {
490
+ const raw = process.env[name];
491
+ if (raw === void 0 || raw === "") return fallback;
492
+ const parsed = Number(raw);
493
+ if (!Number.isFinite(parsed) || parsed <= 0) {
494
+ logger.warn({ event: "token_verifier_bad_env", name, value: raw, using: fallback });
495
+ return fallback;
496
+ }
497
+ return parsed;
498
+ };
499
+ return {
500
+ cacheEnabled: !["0", "false", "no", "off"].includes(
501
+ (process.env["PLAUD_TOKEN_CACHE_ENABLED"] ?? "").toLowerCase()
502
+ ),
503
+ cacheMaxEntries: num("PLAUD_TOKEN_CACHE_MAX", DEFAULTS.cacheMaxEntries),
504
+ positiveTtlMs: num("PLAUD_TOKEN_CACHE_TTL_MS", DEFAULTS.positiveTtlMs),
505
+ negativeTtlMs: num("PLAUD_TOKEN_NEGATIVE_TTL_MS", DEFAULTS.negativeTtlMs),
506
+ strangerBurst: num("PLAUD_TOKEN_VERIFY_BURST", DEFAULTS.strangerBurst),
507
+ strangerRatePerSec: num("PLAUD_TOKEN_VERIFY_RATE", DEFAULTS.strangerRatePerSec),
508
+ knownBurst: num("PLAUD_TOKEN_KNOWN_BURST", DEFAULTS.knownBurst),
509
+ knownRatePerSec: num("PLAUD_TOKEN_KNOWN_RATE", DEFAULTS.knownRatePerSec),
510
+ knownSubMaxEntries: num("PLAUD_TOKEN_KNOWN_SUB_MAX", DEFAULTS.knownSubMaxEntries)
511
+ };
512
+ }
513
+
269
514
  // src/http/oauth-provider.ts
270
515
  function subFromJwt(token) {
271
516
  if (!token) return void 0;
@@ -276,6 +521,10 @@ function subFromJwt(token) {
276
521
  return void 0;
277
522
  }
278
523
  }
524
+ function isUpstreamRateLimited(err) {
525
+ const msg = err instanceof Error ? err.message : String(err ?? "");
526
+ return /\bAPI error: 429\b/.test(msg);
527
+ }
279
528
  var AUTO_RECOVERED_CLIENT_NAME = "auto-recovered client";
280
529
  var REDIRECT_HOST_CLIENT_NAMES = {
281
530
  "chatgpt.com": "chatgpt",
@@ -301,6 +550,8 @@ var DEFAULT_TOKEN_URL = "https://platform.plaud.ai/developer/api/oauth/third-par
301
550
  var DEFAULT_REFRESH_URL = "https://platform.plaud.ai/developer/api/oauth/third-party/access-token/refresh";
302
551
  var STATELESS_CODE_PREFIX = "pc1_";
303
552
  var AUTHORIZATION_CODE_TTL_MS = 10 * 60 * 1e3;
553
+ var STATELESS_STATE_PREFIX = "st1_";
554
+ var AUTHORIZATION_STATE_TTL_MS = 30 * 60 * 1e3;
304
555
  function urlParts(value) {
305
556
  if (!value || !URL.canParse(value)) {
306
557
  return { host: null, path: null };
@@ -318,57 +569,74 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
318
569
  _debugOAuthLogs;
319
570
  _cimdLoader;
320
571
  _tracker;
572
+ /** Local pre-checks + cache + tiered rate limit + upstream call, all in one
573
+ * place. See token-verifier.ts. */
574
+ _verifier;
321
575
  _registeredClients = /* @__PURE__ */ new Map();
322
576
  // HMAC secret used to sign DCR-issued client_ids so we can recover them
323
577
  // across container restarts without persistent storage. See verifyAndRecover.
324
578
  _clientIdSecret;
325
- // internalState client redirect, original client state, and resource indicator
326
- // We generate our own state to track the pending flow regardless of whether the client sent one.
327
- _pendingStates = /* @__PURE__ */ new Map();
579
+ // NOTE: there is deliberately no in-process store for pending authorizations.
580
+ // This class used to keep `_pendingStates: Map<state, {clientRedirectUri, …}>`,
581
+ // which meant /authorize and /auth/callback had to be served by the same
582
+ // process — the callback looked the state up and hard-failed with 400 if it
583
+ // wasn't there. That single map is what pinned the deployment to one replica
584
+ // (and it was never swept, so abandoned authorizations accumulated for the
585
+ // lifetime of the pod). The state parameter is now a sealed, self-contained
586
+ // envelope instead; see encodeAuthorizationState.
587
+ //
588
+ // Everything else here was already replica-safe: client_ids are HMAC-signed
589
+ // and recoverable (verifyAndRecover), and authorization codes are sealed the
590
+ // same way. This was the last piece holding it back.
328
591
  constructor(options) {
329
592
  const authUrl = options.authUrl ?? "https://web.plaud.ai/platform/oauth";
330
593
  const tokenUrl = options.tokenUrl ?? DEFAULT_TOKEN_URL;
331
594
  const refreshUrl = options.refreshUrl ?? DEFAULT_REFRESH_URL;
332
595
  const apiBase = options.apiBase ?? "https://platform.plaud.ai/developer/api";
596
+ const holder = {};
597
+ const verifyUpstream = async (token) => {
598
+ const client = new PlaudClient({
599
+ clientId: options.clientId,
600
+ clientSecret: "",
601
+ redirectUri: "",
602
+ apiBase,
603
+ staticToken: token
604
+ });
605
+ try {
606
+ const user = await client.getCurrentUser();
607
+ return { kind: "valid", clientId: String(user.id ?? "unknown") };
608
+ } catch (err) {
609
+ const type = classifyError(err);
610
+ if (type === "auth") return { kind: "invalid" };
611
+ if (isUpstreamRateLimited(err)) return { kind: "rate_limited" };
612
+ return { kind: "unavailable", reason: type };
613
+ }
614
+ };
333
615
  super({
334
616
  endpoints: {
335
617
  authorizationUrl: authUrl,
336
618
  tokenUrl
337
619
  },
338
620
  verifyAccessToken: async (token) => {
339
- const client = new PlaudClient({
340
- clientId: options.clientId,
341
- clientSecret: "",
342
- redirectUri: "",
343
- apiBase,
344
- staticToken: token
345
- });
346
- try {
347
- const user = await client.getCurrentUser();
348
- let expiresAt;
349
- try {
350
- const payload = JSON.parse(
351
- Buffer.from(token.split(".")[1], "base64url").toString()
352
- );
353
- expiresAt = typeof payload.exp === "number" ? payload.exp : Math.floor(Date.now() / 1e3) + 3600;
354
- } catch {
355
- expiresAt = Math.floor(Date.now() / 1e3) + 3600;
356
- }
357
- const authInfo = {
358
- token,
359
- clientId: String(user.id ?? "unknown"),
360
- scopes: [],
361
- expiresAt
362
- };
363
- logger.info({ event: "token_verified", client_id: authInfo.clientId, expires_at: expiresAt });
364
- return authInfo;
365
- } catch (err) {
366
- logger.warn({ event: "token_verify_failed", error: String(err) });
367
- throw new InvalidTokenError("Invalid or expired token");
621
+ const verifier = holder.verifier;
622
+ if (!verifier) throw new McpServerError("Token verifier not initialised");
623
+ const outcome = await verifier.verify(token);
624
+ if (outcome.ok) {
625
+ return { token, clientId: outcome.clientId, scopes: [], expiresAt: outcome.expiresAt };
626
+ }
627
+ switch (outcome.kind) {
628
+ case "unavailable":
629
+ throw new McpServerError("Upstream token verification temporarily unavailable");
630
+ case "rate_limited":
631
+ throw new TooManyRequestsError("Token verification rate limited");
632
+ default:
633
+ throw new InvalidTokenError("Invalid or expired token");
368
634
  }
369
635
  },
370
636
  getClient: async (id) => this._registeredClients.get(id)
371
637
  });
638
+ holder.verifier = new TokenVerifier({ ...tokenVerifierOptionsFromEnv(), verifyUpstream });
639
+ this._verifier = holder.verifier;
372
640
  this._plaudClientId = options.clientId;
373
641
  this._plaudClientSecret = options.clientSecret ?? "";
374
642
  this._plaudTokenUrl = tokenUrl;
@@ -390,6 +658,10 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
390
658
  }
391
659
  this.skipLocalPkceValidation = true;
392
660
  }
661
+ /** For /health and metrics — counts only, no tokens or user identifiers. */
662
+ get verifierStats() {
663
+ return this._verifier.stats();
664
+ }
393
665
  // Override clientsStore. registerClient signs the issued client_id with HMAC
394
666
  // so verifyAndRecover can later resurrect it across container restarts.
395
667
  get clientsStore() {
@@ -495,49 +767,116 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
495
767
  if (actual.length !== expected.length) return false;
496
768
  return timingSafeEqual(actual, expected);
497
769
  }
498
- authorizationCodeKey() {
499
- return createHmac("sha256", this._clientIdSecret).update("plaud-mcp-authorization-code").digest();
770
+ // Key is derived per purpose so a value sealed for one hop can never be
771
+ // unsealed as the other — an authorization code presented as a state (or vice
772
+ // versa) fails the GCM tag check rather than decoding into the wrong shape.
773
+ envelopeKey(purpose) {
774
+ return createHmac("sha256", this._clientIdSecret).update(purpose).digest();
500
775
  }
501
- encodeAuthorizationCode(payload) {
776
+ // AES-256-GCM envelope: prefix + base64url(iv[12] | tag[16] | ciphertext).
777
+ // Self-contained, so any pod can open what any other pod sealed — that is what
778
+ // lets us run more than one replica without a shared store.
779
+ seal(purpose, prefix, payload, ttlMs) {
502
780
  const iv = randomBytes(12);
503
- const cipher = createCipheriv("aes-256-gcm", this.authorizationCodeKey(), iv);
504
- const plaintext = Buffer.from(JSON.stringify({
505
- upstreamCode: payload.upstreamCode,
506
- upstreamState: payload.upstreamState,
507
- resource: payload.resource,
508
- expiresAt: Date.now() + AUTHORIZATION_CODE_TTL_MS
509
- }), "utf8");
781
+ const cipher = createCipheriv("aes-256-gcm", this.envelopeKey(purpose), iv);
782
+ const plaintext = Buffer.from(
783
+ JSON.stringify({ ...payload, expiresAt: Date.now() + ttlMs }),
784
+ "utf8"
785
+ );
510
786
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
511
787
  const tag = cipher.getAuthTag();
512
- return `${STATELESS_CODE_PREFIX}${Buffer.concat([iv, tag, ciphertext]).toString("base64url")}`;
788
+ return `${prefix}${Buffer.concat([iv, tag, ciphertext]).toString("base64url")}`;
513
789
  }
514
- decodeAuthorizationCode(code) {
515
- if (!code.startsWith(STATELESS_CODE_PREFIX)) return null;
790
+ // Returns null for anything that isn't ours, has been tampered with, or has
791
+ // expired. Callers still validate the payload's own shape.
792
+ unseal(purpose, prefix, value) {
793
+ if (typeof value !== "string" || !value.startsWith(prefix)) return null;
516
794
  try {
517
- const encoded = code.slice(STATELESS_CODE_PREFIX.length);
518
- const data = Buffer.from(encoded, "base64url");
795
+ const data = Buffer.from(value.slice(prefix.length), "base64url");
519
796
  if (data.length <= 28) return null;
520
- const iv = data.subarray(0, 12);
521
- const tag = data.subarray(12, 28);
522
- const ciphertext = data.subarray(28);
523
- const decipher = createDecipheriv("aes-256-gcm", this.authorizationCodeKey(), iv);
524
- decipher.setAuthTag(tag);
525
- const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
797
+ const decipher = createDecipheriv("aes-256-gcm", this.envelopeKey(purpose), data.subarray(0, 12));
798
+ decipher.setAuthTag(data.subarray(12, 28));
799
+ const plaintext = Buffer.concat([
800
+ decipher.update(data.subarray(28)),
801
+ decipher.final()
802
+ ]).toString("utf8");
526
803
  const payload = JSON.parse(plaintext);
527
- if (typeof payload.upstreamCode !== "string" || typeof payload.upstreamState !== "string" || payload.resource !== void 0 && typeof payload.resource !== "string" || typeof payload.expiresAt !== "number") {
804
+ if (typeof payload["expiresAt"] !== "number" || Date.now() > payload["expiresAt"]) {
528
805
  return null;
529
806
  }
530
- if (Date.now() > payload.expiresAt) {
531
- return null;
532
- }
533
- return {
807
+ return payload;
808
+ } catch {
809
+ return null;
810
+ }
811
+ }
812
+ encodeAuthorizationCode(payload) {
813
+ return this.seal(
814
+ "plaud-mcp-authorization-code",
815
+ STATELESS_CODE_PREFIX,
816
+ {
534
817
  upstreamCode: payload.upstreamCode,
535
818
  upstreamState: payload.upstreamState,
536
819
  resource: payload.resource
537
- };
538
- } catch {
820
+ },
821
+ AUTHORIZATION_CODE_TTL_MS
822
+ );
823
+ }
824
+ decodeAuthorizationCode(code) {
825
+ const payload = this.unseal(
826
+ "plaud-mcp-authorization-code",
827
+ STATELESS_CODE_PREFIX,
828
+ code
829
+ );
830
+ if (!payload) return null;
831
+ if (typeof payload.upstreamCode !== "string" || typeof payload.upstreamState !== "string" || payload.resource !== void 0 && typeof payload.resource !== "string") {
539
832
  return null;
540
833
  }
834
+ return {
835
+ upstreamCode: payload.upstreamCode,
836
+ upstreamState: payload.upstreamState,
837
+ resource: payload.resource
838
+ };
839
+ }
840
+ // The authorize→callback hop. Everything the callback needs travels inside the
841
+ // state parameter itself, so the pod that handles the callback need not be the
842
+ // pod that started the flow. This is what replaced the in-process
843
+ // _pendingStates map — see the class docstring for why that map capped us at
844
+ // one replica.
845
+ encodeAuthorizationState(payload) {
846
+ return this.seal(
847
+ "plaud-mcp-authorization-state",
848
+ STATELESS_STATE_PREFIX,
849
+ {
850
+ clientRedirectUri: payload.clientRedirectUri,
851
+ originalState: payload.originalState,
852
+ resource: payload.resource
853
+ },
854
+ AUTHORIZATION_STATE_TTL_MS
855
+ );
856
+ }
857
+ // Stable short handle for correlating authorize and callback log lines.
858
+ // Not a secret and not reversible — purely a join key for reading logs.
859
+ stateHandle(state) {
860
+ if (typeof state !== "string") return "non-string";
861
+ return createHash2("sha256").update(state).digest("base64url").slice(0, 12);
862
+ }
863
+ decodeAuthorizationState(state) {
864
+ const payload = this.unseal(
865
+ "plaud-mcp-authorization-state",
866
+ STATELESS_STATE_PREFIX,
867
+ state
868
+ );
869
+ if (!payload) return null;
870
+ if (typeof payload.clientRedirectUri !== "string" || !URL.canParse(payload.clientRedirectUri)) {
871
+ return null;
872
+ }
873
+ if (payload.originalState !== void 0 && typeof payload.originalState !== "string") return null;
874
+ if (payload.resource !== void 0 && typeof payload.resource !== "string") return null;
875
+ return {
876
+ clientRedirectUri: payload.clientRedirectUri,
877
+ originalState: payload.originalState,
878
+ resource: payload.resource
879
+ };
541
880
  }
542
881
  // Recover a client_id that the registry doesn't know about. Directory clients
543
882
  // (OpenAI Apps, Claude directory) cache the client_id they got from /register;
@@ -601,8 +940,7 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
601
940
  * Store the client's original redirect_uri keyed by state so we can forward after Plaud calls back.
602
941
  */
603
942
  async authorize(_client, params, res) {
604
- const internalState = randomUUID();
605
- this._pendingStates.set(internalState, {
943
+ const internalState = this.encodeAuthorizationState({
606
944
  clientRedirectUri: params.redirectUri,
607
945
  originalState: params.state,
608
946
  resource: params.resource?.href
@@ -610,7 +948,10 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
610
948
  const redirectParts = urlParts(params.redirectUri);
611
949
  logger.info({
612
950
  event: "oauth_authorize_start",
613
- internal_state: internalState,
951
+ // Short digest, not the sealed blob — it is 300+ chars and would bloat
952
+ // every line. Same value appears on the matching callback, so the two
953
+ // still correlate.
954
+ state_handle: this.stateHandle(internalState),
614
955
  redirect_uri: params.redirectUri,
615
956
  ...this._debugOAuthLogs ? {
616
957
  redirect_uri_host: redirectParts.host,
@@ -618,7 +959,7 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
618
959
  has_original_state: !!params.state,
619
960
  has_resource: !!params.resource,
620
961
  resource: params.resource?.href ?? null,
621
- pending_states_count: this._pendingStates.size
962
+ state_len: internalState.length
622
963
  } : {}
623
964
  });
624
965
  const targetUrl = new URL(this._endpoints.authorizationUrl);
@@ -644,17 +985,25 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
644
985
  * Called when Plaud redirects to our /auth/callback (CALLBACK_PATH in
645
986
  * http/server.ts). NOT /oauth/callback — this comment said so until 2026-08-19
646
987
  * and a downstream client registration was configured from it by mistake.
647
- * Looks up the original client redirect_uri and forwards the code+state to it.
988
+ * Opens the sealed state to recover the client's redirect_uri and forwards the
989
+ * code+state to it. No lookup — the state is the storage, which is what allows
990
+ * this to be served by a pod that never saw the /authorize request.
648
991
  */
649
992
  handleCallback(code, state, res) {
650
- const pending = this._pendingStates.get(state);
993
+ const pending = this.decodeAuthorizationState(state);
651
994
  if (!pending) {
652
- logger.warn({ event: "oauth_callback_unknown_state", state });
995
+ logger.warn({
996
+ event: "oauth_callback_unknown_state",
997
+ state_handle: this.stateHandle(state),
998
+ // typeof-guarded: this is the one log line reached by a request whose
999
+ // parameter shape we do not control.
1000
+ state_len: typeof state === "string" ? state.length : null,
1001
+ sealed_shape: typeof state === "string" && state.startsWith(STATELESS_STATE_PREFIX)
1002
+ });
653
1003
  res.status(400).send("Unknown state \u2014 authorization request not found");
654
1004
  return;
655
1005
  }
656
- this._pendingStates.delete(state);
657
- logger.info({ event: "oauth_callback_received", internal_state: state });
1006
+ logger.info({ event: "oauth_callback_received", state_handle: this.stateHandle(state) });
658
1007
  const pendingCode = {
659
1008
  upstreamCode: code,
660
1009
  upstreamState: state,
@@ -667,10 +1016,12 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
667
1016
  target.searchParams.set("state", pending.originalState);
668
1017
  }
669
1018
  const redirectParts = urlParts(pending.clientRedirectUri);
1019
+ const isCustomScheme = !/^https?:$/.test(new URL(target.toString()).protocol);
670
1020
  logger.info({
671
1021
  event: "oauth_callback_redirect",
672
- internal_state: state,
1022
+ state_handle: this.stateHandle(state),
673
1023
  redirect_uri: pending.clientRedirectUri,
1024
+ custom_scheme: isCustomScheme,
674
1025
  has_original_state: !!pending.originalState,
675
1026
  original_state_len: pending.originalState?.length ?? 0,
676
1027
  upstream_code_len: code.length,
@@ -685,7 +1036,37 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
685
1036
  redirect_has_state: target.searchParams.has("state")
686
1037
  } : {}
687
1038
  });
688
- res.redirect(target.toString());
1039
+ if (!isCustomScheme) {
1040
+ res.redirect(target.toString());
1041
+ return;
1042
+ }
1043
+ res.status(200).type("html").send(this.appHandoffPage(target.toString()));
1044
+ }
1045
+ // Landing page for native-app callbacks. The URL goes into a data attribute
1046
+ // rather than inline JS so nothing from `clientRedirectUri` can be parsed as
1047
+ // script; it is only ever assigned to location.href.
1048
+ appHandoffPage(redirectUrl) {
1049
+ const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1050
+ const href = esc(redirectUrl);
1051
+ return `<!doctype html>
1052
+ <html lang="zh-CN"><head><meta charset="utf-8">
1053
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1054
+ <title>\u6388\u6743\u6210\u529F \xB7 Plaud</title></head>
1055
+ <body style="font-family:system-ui,-apple-system,sans-serif;margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#fafafa;color:#111">
1056
+ <main id="p" data-href="${href}" style="text-align:center;padding:2rem;max-width:28rem">
1057
+ <div style="font-size:2.5rem;line-height:1">\u2713</div>
1058
+ <h1 style="font-size:1.25rem;margin:.75rem 0 .5rem">\u6388\u6743\u6210\u529F</h1>
1059
+ <p style="color:#666;margin:0 0 1.5rem">\u6B63\u5728\u8FD4\u56DE\u5E94\u7528\uFF0C\u53EF\u4EE5\u5173\u95ED\u6B64\u9875\u9762\u3002</p>
1060
+ <p style="font-size:.875rem;color:#888;margin:0">
1061
+ \u6CA1\u6709\u81EA\u52A8\u8DF3\u8F6C\uFF1F<a id="m" href="#" style="color:#0066cc">\u70B9\u6B64\u624B\u52A8\u6253\u5F00</a>
1062
+ </p>
1063
+ </main>
1064
+ <script>
1065
+ var u = document.getElementById("p").dataset.href;
1066
+ document.getElementById("m").href = u;
1067
+ location.href = u;
1068
+ </script>
1069
+ </body></html>`;
689
1070
  }
690
1071
  // Override to use PKCE public-client flow — no Basic auth, client_id sent in body.
691
1072
  async exchangeAuthorizationCode(client, authorizationCode, codeVerifier, _redirectUri, resource) {
@@ -762,6 +1143,7 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
762
1143
  throw new McpServerError("Token endpoint returned non-JSON response");
763
1144
  }
764
1145
  logger.info({ event: "oauth_token_exchange_ok", has_refresh_token: !!data.refresh_token });
1146
+ this._verifier.prewarm(data.access_token);
765
1147
  const authorizedUserId = subFromJwt(data.access_token);
766
1148
  this._tracker?.track({
767
1149
  name: "auth.oauth_callback_success",
@@ -834,6 +1216,7 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
834
1216
  throw new McpServerError("Refresh endpoint returned non-JSON response");
835
1217
  }
836
1218
  oauthTokenRefresh.inc({ result: "success" });
1219
+ this._verifier.prewarm(data.access_token);
837
1220
  this._tracker?.track({
838
1221
  name: "auth.token_refresh_success",
839
1222
  actorType: "user",
@@ -856,7 +1239,7 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
856
1239
  };
857
1240
 
858
1241
  // src/telemetry-server/tracker.ts
859
- import { randomUUID as randomUUID2 } from "crypto";
1242
+ import { randomUUID } from "crypto";
860
1243
 
861
1244
  // src/telemetry-server/transport.ts
862
1245
  import { PostHog } from "posthog-node";
@@ -1018,7 +1401,7 @@ var WarehouseTracker = class {
1018
1401
  service_version: this.common.serviceVersion,
1019
1402
  ...this.common.buildId ? { build_id: this.common.buildId } : {},
1020
1403
  // timing / tracing
1021
- event_id: randomUUID2(),
1404
+ event_id: randomUUID(),
1022
1405
  ...input.requestId ? { request_id: input.requestId } : {},
1023
1406
  // (trace_id/span_id omitted — no OpenTelemetry; spec forbids fabricating)
1024
1407
  // non-user actors: don't create a PostHog person
@@ -1283,8 +1666,8 @@ function startHttpServer() {
1283
1666
  common: {
1284
1667
  serviceName: process.env.PLAUD_WAREHOUSE_SERVICE_NAME ?? "plaudmcp",
1285
1668
  // matches §1.3 key label `plaudmcp:TRACKING_KEY_PLAUDMCP`; confirm exact string with David
1286
- serviceVersion: "0.3.9",
1287
- buildId: "ee2ff6d",
1669
+ serviceVersion: "0.3.11",
1670
+ buildId: "516b086",
1288
1671
  // mcp tsup TODO: inject git short SHA (like CLI)
1289
1672
  region: process.env.PLAUD_REGION ?? "US",
1290
1673
  env: process.env.PLAUD_ENV ?? process.env.NODE_ENV ?? "development"
@@ -1374,7 +1757,10 @@ function startHttpServer() {
1374
1757
  server_url: process.env.PLAUD_SERVER_URL ?? "(default:localhost)",
1375
1758
  oauth_debug_logs: OAUTH_DEBUG_LOGS,
1376
1759
  cimd_enabled: CIMD_ENABLED
1377
- }
1760
+ },
1761
+ // Size of the token-verification cache, to confirm it is actually in
1762
+ // effect. Counts only — no tokens or user identifiers.
1763
+ token_verifier: provider.verifierStats
1378
1764
  });
1379
1765
  });
1380
1766
  app.get("/", (_req, res) => {
@@ -1382,8 +1768,8 @@ function startHttpServer() {
1382
1768
  });
1383
1769
  if (process.env.PLAUD_CALLBACK_URL) {
1384
1770
  app.get(CALLBACK_PATH, (req, res) => {
1385
- const code = req.query["code"];
1386
- const state = req.query["state"];
1771
+ const code = queryValue(req.query["code"]);
1772
+ const state = queryValue(req.query["state"]);
1387
1773
  if (!code || !state) {
1388
1774
  logger.warn({ event: "oauth_callback_invalid", code: !!code, state: !!state });
1389
1775
  res.status(400).send("Missing code or state");
@@ -1393,7 +1779,7 @@ function startHttpServer() {
1393
1779
  });
1394
1780
  }
1395
1781
  app.use((req, res, next) => {
1396
- const reqId = req.headers["x-request-id"] ?? randomUUID3();
1782
+ const reqId = req.headers["x-request-id"] ?? randomUUID2();
1397
1783
  const startMs = Date.now();
1398
1784
  res.locals["reqId"] = reqId;
1399
1785
  res.on("finish", () => {
@@ -1545,7 +1931,7 @@ function startHttpServer() {
1545
1931
  requireBearerAuth({ verifier: provider, resourceMetadataUrl: protectedResourceMetadataUrl }),
1546
1932
  async (req, res) => {
1547
1933
  const token = req.auth.token;
1548
- const reqId = res.locals["reqId"] ?? randomUUID3();
1934
+ const reqId = res.locals["reqId"] ?? randomUUID2();
1549
1935
  const reqLog = logger.child({ req_id: reqId });
1550
1936
  const startMs = Date.now();
1551
1937
  reqLog.info({ event: "mcp_request_start", client_id: req.auth.clientId });
@@ -1556,7 +1942,7 @@ function startHttpServer() {
1556
1942
  apiBase,
1557
1943
  staticToken: token
1558
1944
  });
1559
- const mcpServer = new McpServer({ name: "plaud", version: "0.3.9" });
1945
+ const mcpServer = new McpServer({ name: "plaud", version: "0.3.11" });
1560
1946
  registerTools(mcpServer, client, warehouseToolHooks);
1561
1947
  const transport = new StreamableHTTPServerTransport({
1562
1948
  sessionIdGenerator: void 0,
@@ -1594,8 +1980,8 @@ function startHttpServer() {
1594
1980
  if (!process.env.PLAUD_CALLBACK_URL) {
1595
1981
  const callbackApp = express();
1596
1982
  callbackApp.get(CALLBACK_PATH, (req, res) => {
1597
- const code = req.query["code"];
1598
- const state = req.query["state"];
1983
+ const code = queryValue(req.query["code"]);
1984
+ const state = queryValue(req.query["state"]);
1599
1985
  if (!code || !state) {
1600
1986
  logger.warn({ event: "oauth_callback_invalid", code: !!code, state: !!state });
1601
1987
  res.status(400).send("Missing code or state");