@valbuild/next 0.116.0 → 0.117.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.
@@ -3,8 +3,10 @@ import { _ as _slicedToArray } from '../../dist/slicedToArray-aa291011.esm.js';
3
3
  import { _ as _asyncToGenerator, a as _regenerator, V as VERSION } from '../../dist/version-4d7b692c.esm.js';
4
4
  import { _ as _objectSpread2 } from '../../dist/objectSpread2-60d1bd93.esm.js';
5
5
  import { Internal } from '@valbuild/core';
6
- import { createValApiRouter, createValServer, createValTools, initHandlerOptions } from '@valbuild/server';
6
+ import { createValApiRouter, createValServer, authorIdFromVerifiedSubject, VAL_SCOPE_READ, createValTools, initHandlerOptions, VAL_SCOPE_WRITE } from '@valbuild/server';
7
7
  import { NextResponse } from 'next/server';
8
+ import { _ as _typeof } from '../../dist/typeof-a1531d8f.esm.js';
9
+ import { createPublicKey, verify } from 'node:crypto';
8
10
  import '../../dist/unsupportedIterableToArray-5baabfdc.esm.js';
9
11
  import '../../dist/defineProperty-cca5affa.esm.js';
10
12
 
@@ -116,6 +118,578 @@ function initValServer(valModules, config, nextConfig) {
116
118
  };
117
119
  }
118
120
 
121
+ /**
122
+ * Verifying an OAuth access token, which is the whole of what makes this app a
123
+ * resource server rather than a relay.
124
+ *
125
+ * The token is issued by Val's authorization server and presented by an MCP
126
+ * client. This app holds no signing key for it and cannot mint one — it fetches
127
+ * the issuer's *public* keys and checks a signature. That asymmetry is the
128
+ * point: a verified `sub` is a fact about the token rather than a claim by
129
+ * whoever sent it, which is what lets the tools attribute a patch to that
130
+ * profile at all.
131
+ *
132
+ * ## Why this is not `jose`
133
+ *
134
+ * `jose` was the first choice and was rejected on a fact rather than a
135
+ * preference: version 6 is ESM-only (`"type": "module"`, no CJS export). This
136
+ * package is built by preconstruct and `require`d by Next.js server code, so an
137
+ * ESM-only dependency here is a runtime failure in consumers' apps, not a build
138
+ * inconvenience. Adding it would also put a dependency in every install of
139
+ * `@valbuild/next` for one function.
140
+ *
141
+ * The actual cryptography is still not hand-rolled — `node:crypto` does the
142
+ * ECDSA and the JWK import. What is written here is the JWS envelope and the
143
+ * claim checks, and the rules that keep that safe are worth stating because
144
+ * this repository has already shipped the counterexample (`decodeJwt`: `exp`
145
+ * never checked, a non-constant-time compare, verification skippable):
146
+ *
147
+ * - **`alg` is pinned**, not read from the token. The header is only consulted
148
+ * for `kid`. A verifier that honours the token's own `alg` can be handed
149
+ * `HS256` and will treat the *published* public key as a shared secret.
150
+ * - **Nothing is read from the payload before the signature verifies.** Claims
151
+ * from an unverified token are attacker input.
152
+ * - **Keys come only from the configured issuer's JWKS**, never from the token.
153
+ * - **ECDSA JWS signatures are raw `r||s`** (RFC 7518), not DER, which is what
154
+ * `dsaEncoding: "ieee-p1363"` below is for. Omit it and every valid signature
155
+ * is rejected — or worse, a future change makes it accept the wrong thing.
156
+ */
157
+
158
+ var DEFAULT_CLOCK_TOLERANCE_SECONDS = 60;
159
+ /** How long a fetched key set is reused before it is fetched again. */
160
+ var JWKS_TTL_MS = 5 * 60 * 1000;
161
+ /**
162
+ * How long to wait before re-fetching after a failure.
163
+ *
164
+ * Shorter than the success TTL so a key rotation recovers quickly, but not zero:
165
+ * an unreachable issuer must not turn every tool call into another request to
166
+ * it.
167
+ */
168
+ var JWKS_ERROR_TTL_MS = 30 * 1000;
169
+ /**
170
+ * One cache per issuer, and it has to outlive the request or it is not a cache:
171
+ * a fetch per tool call would put a network round trip in front of every read.
172
+ */
173
+ var jwksCache = new Map();
174
+ /** Concurrent misses share one fetch rather than starting several. */
175
+ var inFlight = new Map();
176
+ function jwksUrl(issuer) {
177
+ // Not discovered from the issuer's metadata document, deliberately:
178
+ // discovery would mean one more request on the hot path and one more thing
179
+ // that can be pointed elsewhere. The location is fixed by convention and by
180
+ // Val's own authorization server.
181
+ return new URL("/.well-known/jwks.json", issuer).toString();
182
+ }
183
+ function loadJwks(_x, _x2) {
184
+ return _loadJwks.apply(this, arguments);
185
+ }
186
+ function _loadJwks() {
187
+ _loadJwks = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee2(config, nowMs) {
188
+ var _config$fetchImpl;
189
+ var url, cached, age, ttl, existing, fetchImpl, pending, entry;
190
+ return _regenerator().w(function (_context2) {
191
+ while (1) switch (_context2.p = _context2.n) {
192
+ case 0:
193
+ url = jwksUrl(config.issuer);
194
+ cached = jwksCache.get(url);
195
+ if (!cached) {
196
+ _context2.n = 1;
197
+ break;
198
+ }
199
+ age = cached.status === "keys" ? nowMs - cached.fetchedAtMs : nowMs - cached.failedAtMs;
200
+ ttl = cached.status === "keys" ? JWKS_TTL_MS : JWKS_ERROR_TTL_MS;
201
+ if (!(age < ttl)) {
202
+ _context2.n = 1;
203
+ break;
204
+ }
205
+ return _context2.a(2, cached);
206
+ case 1:
207
+ existing = inFlight.get(url);
208
+ if (!existing) {
209
+ _context2.n = 2;
210
+ break;
211
+ }
212
+ return _context2.a(2, existing);
213
+ case 2:
214
+ fetchImpl = (_config$fetchImpl = config.fetchImpl) !== null && _config$fetchImpl !== void 0 ? _config$fetchImpl : fetch;
215
+ pending = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee() {
216
+ var res, body, keys;
217
+ return _regenerator().w(function (_context) {
218
+ while (1) switch (_context.p = _context.n) {
219
+ case 0:
220
+ _context.p = 0;
221
+ _context.n = 1;
222
+ return fetchImpl(url, {
223
+ headers: {
224
+ accept: "application/json"
225
+ }
226
+ });
227
+ case 1:
228
+ res = _context.v;
229
+ if (res.ok) {
230
+ _context.n = 2;
231
+ break;
232
+ }
233
+ return _context.a(2, {
234
+ status: "error",
235
+ failedAtMs: nowMs
236
+ });
237
+ case 2:
238
+ _context.n = 3;
239
+ return res.json();
240
+ case 3:
241
+ body = _context.v;
242
+ keys = readKeys(body);
243
+ if (!(keys === null)) {
244
+ _context.n = 4;
245
+ break;
246
+ }
247
+ return _context.a(2, {
248
+ status: "error",
249
+ failedAtMs: nowMs
250
+ });
251
+ case 4:
252
+ return _context.a(2, {
253
+ status: "keys",
254
+ keys: keys,
255
+ fetchedAtMs: nowMs
256
+ });
257
+ case 5:
258
+ _context.p = 5;
259
+ _context.v;
260
+ return _context.a(2, {
261
+ status: "error",
262
+ failedAtMs: nowMs
263
+ });
264
+ }
265
+ }, _callee, null, [[0, 5]]);
266
+ }))();
267
+ inFlight.set(url, pending);
268
+ _context2.p = 3;
269
+ _context2.n = 4;
270
+ return pending;
271
+ case 4:
272
+ entry = _context2.v;
273
+ jwksCache.set(url, entry);
274
+ return _context2.a(2, entry);
275
+ case 5:
276
+ _context2.p = 5;
277
+ inFlight["delete"](url);
278
+ return _context2.f(5);
279
+ case 6:
280
+ return _context2.a(2);
281
+ }
282
+ }, _callee2, null, [[3,, 5, 6]]);
283
+ }));
284
+ return _loadJwks.apply(this, arguments);
285
+ }
286
+ function readKeys(body) {
287
+ if (_typeof(body) !== "object" || body === null || !("keys" in body)) {
288
+ return null;
289
+ }
290
+ // `in` narrows the property into the type, so no assertion is needed to read
291
+ // it — and the `Array.isArray` below is what actually establishes the shape.
292
+ var keys = body.keys;
293
+ if (!Array.isArray(keys)) {
294
+ return null;
295
+ }
296
+ return keys.filter(function (key) {
297
+ return _typeof(key) === "object" && key !== null;
298
+ });
299
+ }
300
+
301
+ /**
302
+ * Read `Authorization: Bearer …`.
303
+ *
304
+ * Exported because the refusal needs to know whether a token was presented at
305
+ * all: RFC 6750 distinguishes "no credential" — a bare `401`, which is an
306
+ * invitation to authenticate — from "a bad credential", and a client that gets
307
+ * the second when it deserved the first will not start the authorization flow.
308
+ */
309
+ function readBearerToken(request) {
310
+ var _match$;
311
+ var header = request.headers.get("authorization");
312
+ if (!header) {
313
+ return null;
314
+ }
315
+ var match = /^Bearer\s+(.+)$/i.exec(header.trim());
316
+ var token = match === null || match === void 0 || (_match$ = match[1]) === null || _match$ === void 0 ? void 0 : _match$.trim();
317
+ return token ? token : null;
318
+ }
319
+ function verifyValAccessToken(_x3, _x4) {
320
+ return _verifyValAccessToken.apply(this, arguments);
321
+ }
322
+ function _verifyValAccessToken() {
323
+ _verifyValAccessToken = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee3(request, config) {
324
+ var _config$clockToleranc;
325
+ var token, parts, _parts, encodedHeader, encodedPayload, encodedSignature, header, kid, nowMs, jwks, candidates, signature, signedData, verified, payload, tolerance, nowSeconds, subject, scopes;
326
+ return _regenerator().w(function (_context3) {
327
+ while (1) switch (_context3.n) {
328
+ case 0:
329
+ token = readBearerToken(request);
330
+ if (!(token === null)) {
331
+ _context3.n = 1;
332
+ break;
333
+ }
334
+ return _context3.a(2, {
335
+ status: "refused",
336
+ error: "invalid_request",
337
+ description: "This Val MCP endpoint needs an access token. Authorize with the Val authorization server and present it as `Authorization: Bearer`."
338
+ });
339
+ case 1:
340
+ parts = token.split(".");
341
+ if (!(parts.length !== 3)) {
342
+ _context3.n = 2;
343
+ break;
344
+ }
345
+ return _context3.a(2, invalidToken("The access token is not a JWS."));
346
+ case 2:
347
+ _parts = _slicedToArray(parts, 3), encodedHeader = _parts[0], encodedPayload = _parts[1], encodedSignature = _parts[2];
348
+ header = decodeJsonSegment(encodedHeader);
349
+ if (!(header === null)) {
350
+ _context3.n = 3;
351
+ break;
352
+ }
353
+ return _context3.a(2, invalidToken("The access token's header could not be read."));
354
+ case 3:
355
+ if (!(header.alg !== "ES256")) {
356
+ _context3.n = 4;
357
+ break;
358
+ }
359
+ return _context3.a(2, invalidToken("The access token is not signed with ES256, which is the only algorithm this server accepts."));
360
+ case 4:
361
+ kid = typeof header.kid === "string" ? header.kid : null;
362
+ nowMs = Date.now();
363
+ _context3.n = 5;
364
+ return loadJwks(config, nowMs);
365
+ case 5:
366
+ jwks = _context3.v;
367
+ if (!(jwks.status === "error")) {
368
+ _context3.n = 6;
369
+ break;
370
+ }
371
+ return _context3.a(2, invalidToken("The access token could not be verified because the Val authorization server's keys could not be fetched. This may be temporary."));
372
+ case 6:
373
+ candidates = jwks.keys.filter(function (key) {
374
+ return isVerifyingP256Key(key, kid);
375
+ });
376
+ if (!(candidates.length === 0)) {
377
+ _context3.n = 7;
378
+ break;
379
+ }
380
+ return _context3.a(2, invalidToken("The access token was signed with a key the Val authorization server does not publish."));
381
+ case 7:
382
+ signature = decodeBase64Url(encodedSignature);
383
+ if (!(signature === null)) {
384
+ _context3.n = 8;
385
+ break;
386
+ }
387
+ return _context3.a(2, invalidToken("The access token's signature could not be read."));
388
+ case 8:
389
+ signedData = Buffer.from("".concat(encodedHeader, ".").concat(encodedPayload), "ascii"); // Every published key is tried when the token names no `kid`, so a rotation
390
+ // that has not yet propagated to clients still verifies. With a `kid` the
391
+ // filter above leaves one.
392
+ verified = candidates.some(function (key) {
393
+ return verifyWithJwk(key, signedData, signature);
394
+ });
395
+ if (verified) {
396
+ _context3.n = 9;
397
+ break;
398
+ }
399
+ return _context3.a(2, invalidToken("The access token's signature could not be verified."));
400
+ case 9:
401
+ // Only now: everything below reads the payload, and before this line it was
402
+ // attacker input.
403
+ payload = decodeJsonSegment(encodedPayload);
404
+ if (!(payload === null)) {
405
+ _context3.n = 10;
406
+ break;
407
+ }
408
+ return _context3.a(2, invalidToken("The access token's payload could not be read."));
409
+ case 10:
410
+ tolerance = (_config$clockToleranc = config.clockToleranceSeconds) !== null && _config$clockToleranc !== void 0 ? _config$clockToleranc : DEFAULT_CLOCK_TOLERANCE_SECONDS;
411
+ nowSeconds = Math.floor(nowMs / 1000);
412
+ if (!(typeof payload.exp !== "number")) {
413
+ _context3.n = 11;
414
+ break;
415
+ }
416
+ return _context3.a(2, invalidToken("The access token has no expiry."));
417
+ case 11:
418
+ if (!(payload.exp + tolerance <= nowSeconds)) {
419
+ _context3.n = 12;
420
+ break;
421
+ }
422
+ return _context3.a(2, invalidToken("The access token has expired. Refresh it and try again."));
423
+ case 12:
424
+ if (!(typeof payload.nbf === "number" && payload.nbf - tolerance > nowSeconds)) {
425
+ _context3.n = 13;
426
+ break;
427
+ }
428
+ return _context3.a(2, invalidToken("The access token is not valid yet."));
429
+ case 13:
430
+ if (!(payload.iss !== config.issuer)) {
431
+ _context3.n = 14;
432
+ break;
433
+ }
434
+ return _context3.a(2, invalidToken("The access token was not issued by this server's authorization server (iss claim)."));
435
+ case 14:
436
+ if (audienceMatches(payload.aud, config.resource)) {
437
+ _context3.n = 15;
438
+ break;
439
+ }
440
+ return _context3.a(2, invalidToken("The access token is not valid for this server (aud claim)."));
441
+ case 15:
442
+ subject = payload.sub;
443
+ if (!(typeof subject !== "string" || subject.length === 0)) {
444
+ _context3.n = 16;
445
+ break;
446
+ }
447
+ return _context3.a(2, invalidToken("The access token has no subject."));
448
+ case 16:
449
+ scopes = readScopes(payload.scope);
450
+ if (scopes.includes(VAL_SCOPE_READ)) {
451
+ _context3.n = 17;
452
+ break;
453
+ }
454
+ return _context3.a(2, {
455
+ status: "refused",
456
+ error: "insufficient_scope",
457
+ description: "The access token does not have the ".concat(VAL_SCOPE_READ, " scope, so it cannot read any content.")
458
+ });
459
+ case 17:
460
+ return _context3.a(2, {
461
+ status: "ok",
462
+ auth: {
463
+ type: "verified-profile",
464
+ profileId: authorIdFromVerifiedSubject(subject),
465
+ scopes: scopes
466
+ }
467
+ });
468
+ }
469
+ }, _callee3);
470
+ }));
471
+ return _verifyValAccessToken.apply(this, arguments);
472
+ }
473
+ function invalidToken(description) {
474
+ // Described by class, never by echoing the token or a raw error: a
475
+ // verification failure message is a place credentials leak into logs.
476
+ return {
477
+ status: "refused",
478
+ error: "invalid_token",
479
+ description: description
480
+ };
481
+ }
482
+
483
+ /**
484
+ * `aud` is a string or an array of strings (RFC 7519 section 4.1.3).
485
+ *
486
+ * A match on any member is a match, which is the spec's own rule — a token may
487
+ * legitimately be addressed to several resources.
488
+ */
489
+ function audienceMatches(claim, resource) {
490
+ if (typeof claim === "string") {
491
+ return claim === resource;
492
+ }
493
+ if (Array.isArray(claim)) {
494
+ return claim.some(function (entry) {
495
+ return entry === resource;
496
+ });
497
+ }
498
+ return false;
499
+ }
500
+
501
+ /**
502
+ * `scope` is a space-delimited string (RFC 6749 section 3.3).
503
+ *
504
+ * Anything else is read as no scopes rather than coerced. A token whose scope
505
+ * claim is the wrong shape is a token we do not understand, and understanding
506
+ * it generously is how a write gets authorized by an array someone sent.
507
+ */
508
+ function readScopes(claim) {
509
+ if (typeof claim !== "string") {
510
+ return [];
511
+ }
512
+ return claim.split(" ").filter(function (scope) {
513
+ return scope.length > 0;
514
+ });
515
+ }
516
+ function isVerifyingP256Key(key, kid) {
517
+ if (key.kty !== "EC" || key.crv !== "P-256") {
518
+ return false;
519
+ }
520
+ if (typeof key.x !== "string" || typeof key.y !== "string") {
521
+ return false;
522
+ }
523
+ // A key published for encryption is not a key to verify signatures with, and
524
+ // an `alg` that disagrees with what we verify is a key meant for something
525
+ // else.
526
+ if (key.use !== undefined && key.use !== "sig") {
527
+ return false;
528
+ }
529
+ if (key.alg !== undefined && key.alg !== "ES256") {
530
+ return false;
531
+ }
532
+ if (kid !== null && typeof key.kid === "string" && key.kid !== kid) {
533
+ return false;
534
+ }
535
+ return true;
536
+ }
537
+ function verifyWithJwk(key, signedData, signature) {
538
+ try {
539
+ var publicKey = createPublicKey({
540
+ key: {
541
+ kty: "EC",
542
+ crv: "P-256",
543
+ x: String(key.x),
544
+ y: String(key.y)
545
+ },
546
+ format: "jwk"
547
+ });
548
+ return verify("sha256", signedData,
549
+ // `ieee-p1363` because a JWS ECDSA signature is the raw `r||s` pair, while
550
+ // node defaults to DER for EC keys. Getting this wrong rejects every
551
+ // valid signature.
552
+ {
553
+ key: publicKey,
554
+ dsaEncoding: "ieee-p1363"
555
+ }, signature);
556
+ } catch (_unused) {
557
+ // A malformed key in an otherwise good key set should not take down
558
+ // verification against the other keys.
559
+ return false;
560
+ }
561
+ }
562
+ function decodeBase64Url(segment) {
563
+ if (segment === undefined || !/^[A-Za-z0-9_-]*$/.test(segment)) {
564
+ return null;
565
+ }
566
+ try {
567
+ return Buffer.from(segment, "base64url");
568
+ } catch (_unused2) {
569
+ return null;
570
+ }
571
+ }
572
+ function decodeJsonSegment(segment) {
573
+ var decoded = decodeBase64Url(segment);
574
+ if (decoded === null) {
575
+ return null;
576
+ }
577
+ try {
578
+ var parsed = JSON.parse(decoded.toString("utf8"));
579
+ if (_typeof(parsed) !== "object" || parsed === null || Array.isArray(parsed)) {
580
+ return null;
581
+ }
582
+ return _objectSpread2({}, parsed);
583
+ } catch (_unused3) {
584
+ return null;
585
+ }
586
+ }
587
+
588
+ /**
589
+ * The one document an MCP client needs before it can authorize: RFC 9728
590
+ * Protected Resource Metadata, served by the *resource* server.
591
+ *
592
+ * This is how a client discovers where to authorize. It asks the resource — this
593
+ * app — and the resource names its authorization server. Which is why this
594
+ * belongs here and the RFC 8414 *authorization server* metadata does not: that
595
+ * document lives at the issuer, describes the issuer's own endpoints, and is
596
+ * served by the issuer. An app serving a copy would be asserting the issuer's
597
+ * configuration on its behalf, and would be wrong the moment the issuer changed
598
+ * anything.
599
+ *
600
+ * The flow, so the split reads as a whole:
601
+ *
602
+ * 1. client → `{app}/api/mcp` with no token → `401` naming this document
603
+ * 2. client → `{app}/.well-known/oauth-protected-resource` → the issuer
604
+ * 3. client → `{issuer}/.well-known/oauth-authorization-server` → endpoints
605
+ * 4. client → issuer's `/authorize`, then `/token`
606
+ * 5. client → `{app}/api/mcp` with the token
607
+ */
608
+
609
+ var CORS_HEADERS = {
610
+ // The document is public and contains no secrets — it exists to be read by
611
+ // clients whose origin we cannot know in advance, so `*` is the correct value
612
+ // rather than a lazy one. Note there is no `Access-Control-Allow-Credentials`:
613
+ // with it, `*` would be rejected by browsers, and this document is never
614
+ // fetched with credentials.
615
+ "Access-Control-Allow-Origin": "*",
616
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
617
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, MCP-Protocol-Version",
618
+ "Access-Control-Max-Age": "3600"
619
+ };
620
+ function createValMcpMetadata(oauth, scopesSupported) {
621
+ var document = {
622
+ // The resource identifier, which MUST be the value clients send as
623
+ // `resource` and the value that arrives back in `aud`. Same string as the
624
+ // audience this app verifies against — one value, so the two cannot drift.
625
+ resource: oauth.resource,
626
+ authorization_servers: [oauth.issuer],
627
+ scopes_supported: scopesSupported,
628
+ bearer_methods_supported: ["header"]
629
+ };
630
+ var body = JSON.stringify(document);
631
+ return {
632
+ GET: function GET() {
633
+ return new Response(body, {
634
+ status: 200,
635
+ headers: _objectSpread2({
636
+ "Content-Type": "application/json",
637
+ // Cacheable: it changes only when the app is reconfigured, and a
638
+ // client that re-reads it on every authorization costs a round trip
639
+ // for nothing.
640
+ "Cache-Control": "public, max-age=3600"
641
+ }, CORS_HEADERS)
642
+ });
643
+ },
644
+ OPTIONS: function OPTIONS() {
645
+ return new Response(null, {
646
+ status: 204,
647
+ headers: CORS_HEADERS
648
+ });
649
+ }
650
+ };
651
+ }
652
+
653
+ /**
654
+ * The `WWW-Authenticate` value for a refusal (RFC 6750 section 3, RFC 9728
655
+ * section 5.1).
656
+ *
657
+ * `resource_metadata` is the load-bearing parameter: it is how a client that has
658
+ * never seen this server learns where to authorize. A `401` without it is a dead
659
+ * end — the client knows it needs a token and has no way to find out from where.
660
+ */
661
+ function wwwAuthenticate(oauth, scopesSupported, refusal) {
662
+ var metadataUrl = new URL("/.well-known/oauth-protected-resource", oauth.resource).toString();
663
+ var params = ["resource_metadata=\"".concat(metadataUrl, "\""), "scope=\"".concat(scopesSupported.join(" "), "\"")];
664
+ if (refusal) {
665
+ params.push("error=\"".concat(refusal.error, "\""));
666
+ params.push("error_description=\"".concat(headerSafe(refusal.description), "\""));
667
+ }
668
+ return "Bearer ".concat(params.join(", "));
669
+ }
670
+
671
+ /**
672
+ * Make a string safe to put inside a quoted header parameter.
673
+ *
674
+ * Three classes go, and the third is the one that matters most:
675
+ *
676
+ * - a **quote** would close the parameter early;
677
+ * - a **backslash** would start an escape the rest of the value does not
678
+ * finish;
679
+ * - a **CR or LF** would end the header line, which is response splitting — an
680
+ * attacker-influenced description could inject a header of their own, or a
681
+ * whole second response.
682
+ *
683
+ * The descriptions passed here today are all literals from this package and
684
+ * contain none of it. That is a property of today's callers rather than of the
685
+ * type, and this function exists so it stays true when a future one interpolates
686
+ * something from a request.
687
+ */
688
+ function headerSafe(value) {
689
+ // eslint-disable-next-line no-control-regex -- the point is to remove them
690
+ return value.replace(/["\\]/g, "").replace(/[\u0000-\u001f\u007f]/g, " ");
691
+ }
692
+
119
693
  /**
120
694
  * Val's tools over MCP, and the two checks that have to happen before a request
121
695
  * gets to them.
@@ -170,7 +744,10 @@ function initValMcp(valModules, config, opts) {
170
744
  setupPromise["catch"](function () {
171
745
  // handled per request
172
746
  });
747
+ var oauth = opts === null || opts === void 0 ? void 0 : opts.oauth;
748
+ var scopesSupported = [VAL_SCOPE_READ, VAL_SCOPE_WRITE];
173
749
  return {
750
+ valMcpMetadata: oauth ? createValMcpMetadata(oauth, scopesSupported) : null,
174
751
  valMcpTools: function valMcpTools() {
175
752
  return _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee2() {
176
753
  return _regenerator().w(function (_context2) {
@@ -186,7 +763,7 @@ function initValMcp(valModules, config, opts) {
186
763
  },
187
764
  valMcpAuthorize: function valMcpAuthorize(request) {
188
765
  return _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee3() {
189
- var setup, refusal, pat, _t;
766
+ var setup, refusal, sessionId, verified, status, pat, _t;
190
767
  return _regenerator().w(function (_context3) {
191
768
  while (1) switch (_context3.p = _context3.n) {
192
769
  case 0:
@@ -229,21 +806,66 @@ function initValMcp(valModules, config, opts) {
229
806
  response: refusal
230
807
  });
231
808
  case 5:
809
+ // Not the MCP session id, in either branch below. Val's patch `sessionId`
810
+ // names a Val AI session, and putting an unrelated id in it would claim a
811
+ // relationship that does not exist.
812
+ sessionId = null;
813
+ if (!oauth) {
814
+ _context3.n = 8;
815
+ break;
816
+ }
817
+ _context3.n = 6;
818
+ return verifyValAccessToken(request, oauth);
819
+ case 6:
820
+ verified = _context3.v;
821
+ if (!(verified.status === "refused")) {
822
+ _context3.n = 7;
823
+ break;
824
+ }
825
+ // 401 for a missing or bad token, 403 once the token is good but does
826
+ // not carry the scope: RFC 6750 section 3.1, and the distinction is
827
+ // what tells a client whether to authorize again or to give up.
828
+ status = verified.error === "insufficient_scope" ? 403 : 401;
829
+ return _context3.a(2, {
830
+ status: "refused",
831
+ response: new Response(JSON.stringify({
832
+ error: verified.error,
833
+ error_description: verified.description
834
+ }), {
835
+ status: status,
836
+ headers: {
837
+ "Content-Type": "application/json",
838
+ "WWW-Authenticate": wwwAuthenticate(oauth, scopesSupported, {
839
+ error: verified.error,
840
+ description: verified.description
841
+ })
842
+ }
843
+ })
844
+ });
845
+ case 7:
846
+ return _context3.a(2, {
847
+ status: "ok",
848
+ tools: setup.tools,
849
+ ctx: {
850
+ auth: verified.auth,
851
+ sessionId: sessionId
852
+ }
853
+ });
854
+ case 8:
232
855
  pat = readBearerToken(request);
233
856
  return _context3.a(2, {
234
857
  status: "ok",
235
858
  tools: setup.tools,
236
859
  ctx: {
237
- // Passed through unverified, deliberately: this app is not the
238
- // authority on what a token may do, and the registry sends it to the
860
+ // Passed through unverified, deliberately: without an `oauth` config
861
+ // this app has no key to check anything against, so it is not the
862
+ // authority on what the token may do and the registry sends it to the
239
863
  // backend that is. See `docs/plans/mcp.md` D.2.
240
864
  auth: pat === null ? null : {
865
+ type: "pat",
241
866
  pat: pat
242
867
  },
243
- // Not the MCP session id. Val's patch `sessionId` names a Val AI
244
- // session, and putting an unrelated id in it would claim a
245
- // relationship that does not exist.
246
- sessionId: null
868
+ sessionId: sessionId
247
869
  }
248
870
  });
249
871
  }
@@ -354,16 +976,6 @@ function hostnameOf(host) {
354
976
  var colon = host.indexOf(":");
355
977
  return colon === -1 ? host : host.slice(0, colon);
356
978
  }
357
- function readBearerToken(request) {
358
- var _match$;
359
- var header = request.headers.get("authorization");
360
- if (!header) {
361
- return null;
362
- }
363
- var match = /^Bearer\s+(.+)$/i.exec(header.trim());
364
- var token = match === null || match === void 0 || (_match$ = match[1]) === null || _match$ === void 0 ? void 0 : _match$.trim();
365
- return token ? token : null;
366
- }
367
979
  function jsonResponse(status, body) {
368
980
  return new Response(JSON.stringify(body), {
369
981
  status: status,