akanjs 3.0.0-alpha.100 → 3.0.0-alpha.101

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.
Files changed (44) hide show
  1. package/common/TrustedProxy.ts +6 -1
  2. package/constant/cascadePaths.ts +20 -20
  3. package/constant/fieldInfo.ts +22 -16
  4. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  5. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  6. package/package.json +1 -1
  7. package/server/akanAppHeaders.ts +1 -1
  8. package/server/akanOption.ts +7 -6
  9. package/server/akanServer.ts +1 -1
  10. package/server/di/utils.ts +1 -1
  11. package/server/index.ts +1 -0
  12. package/server/mcp/McpAuth.ts +63 -19
  13. package/server/mcp/McpDispatcher.ts +4 -3
  14. package/server/mcp/McpRouter.ts +22 -1
  15. package/server/oauth/OAuthAuthorize.ts +84 -0
  16. package/server/oauth/OAuthClientIdMetadata.ts +98 -0
  17. package/server/oauth/OAuthErrors.ts +77 -0
  18. package/server/oauth/OAuthMetadata.ts +56 -0
  19. package/server/oauth/OAuthPkce.ts +29 -0
  20. package/server/oauth/OAuthRedirect.ts +57 -0
  21. package/server/oauth/OAuthRegistration.ts +86 -0
  22. package/server/oauth/OAuthToken.ts +112 -0
  23. package/server/oauth/index.ts +9 -0
  24. package/server/oauth/oauthTypes.ts +79 -0
  25. package/server/resolver/CascadeRunner.ts +1 -1
  26. package/signal/signalContext.ts +3 -1
  27. package/types/common/TrustedProxy.d.ts +5 -0
  28. package/types/constant/cascadePaths.d.ts +4 -2
  29. package/types/constant/fieldInfo.d.ts +15 -8
  30. package/types/server/akanOption.d.ts +4 -3
  31. package/types/server/di/utils.d.ts +1 -1
  32. package/types/server/index.d.ts +1 -0
  33. package/types/server/mcp/McpAuth.d.ts +28 -5
  34. package/types/server/oauth/OAuthAuthorize.d.ts +26 -0
  35. package/types/server/oauth/OAuthClientIdMetadata.d.ts +35 -0
  36. package/types/server/oauth/OAuthErrors.d.ts +40 -0
  37. package/types/server/oauth/OAuthMetadata.d.ts +35 -0
  38. package/types/server/oauth/OAuthPkce.d.ts +9 -0
  39. package/types/server/oauth/OAuthRedirect.d.ts +20 -0
  40. package/types/server/oauth/OAuthRegistration.d.ts +21 -0
  41. package/types/server/oauth/OAuthToken.d.ts +30 -0
  42. package/types/server/oauth/index.d.ts +9 -0
  43. package/types/server/oauth/oauthTypes.d.ts +49 -0
  44. package/types/server/oauth.d.ts +1 -0
@@ -51,9 +51,14 @@ export class TrustedProxy {
51
51
  /**
52
52
  * The caller's address as recorded by a proxy, but only when the peer is one. Falls back to the peer itself,
53
53
  * which is the right answer for a process nothing is proxying, and `null` when there is no peer either.
54
+ *
55
+ * `null` and `undefined` mean different things here. `null` is what `Server.requestIP` answers for a socket
56
+ * that has no address — a unix socket, which is how a gateway reaches its children — and only a process on
57
+ * this machine can open one, so the headers it wrote are a local proxy's and are believed. `undefined` is
58
+ * "nobody asked": no resolver was registered, and a header from an unknown peer is a header the client wrote.
54
59
  */
55
60
  static clientAddress(headers: Headers, peerAddress: string | null | undefined): string | null {
56
- if (TrustedProxy.isTrusted(peerAddress)) {
61
+ if (peerAddress === null || TrustedProxy.isTrusted(peerAddress)) {
57
62
  const realIp = headers.get("x-real-ip")?.trim();
58
63
  if (realIp) return normalizeIpAddress(realIp);
59
64
  const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
@@ -6,8 +6,10 @@ import type { ConstantModelRef } from "./via";
6
6
  * Which end of a relation goes away with the other. `removeRef` removes what the field points at when this
7
7
  * document is removed; `removeWith` removes this document when what the field points at is removed. The two
8
8
  * read identically on a relation field, so the value has to name the direction — a mistake here is a data loss.
9
+ * `removeWithAny` is `removeWith` over an owner that is unknowable at build time, and it names the whole
10
+ * decision in one value so the widening cannot be declared apart from the direction it widens.
9
11
  */
10
- export const cascadeActions = ["removeRef", "removeWith"] as const;
12
+ export const cascadeActions = ["removeRef", "removeWith", "removeWithAny"] as const;
11
13
  export type CascadeAction = (typeof cascadeActions)[number];
12
14
 
13
15
  /** How a `removeWith` field names the owner whose removal takes this document with it. */
@@ -21,7 +23,7 @@ export interface CascadeWithPath {
21
23
  readonly typeKey: string | null;
22
24
  /** The refNames `typeKey` may hold. Empty unless the field is polymorphic over an enum. */
23
25
  readonly typeValues: readonly string[];
24
- /** Set by `polymorphic: "any"`: `typeKey` is free-form, so the owner is whatever refName the row holds. */
26
+ /** Set by `cascade: "removeWithAny"`: `typeKey` is free-form, so the owner is whatever refName the row holds. */
25
27
  readonly anyOwner: boolean;
26
28
  }
27
29
 
@@ -35,7 +37,6 @@ export class CascadePaths {
35
37
 
36
38
  collect(fieldMap: FieldObject) {
37
39
  for (const [key, field] of Object.entries(fieldMap)) {
38
- this.#assertPolymorphicIsWiredUp(key, field);
39
40
  if (!field.cascade) continue;
40
41
  this.#assertKnownAction(key, field.cascade);
41
42
  if (field.cascade === "removeRef") this.removeRef.set(key, this.#readOwnedRelation(key, field));
@@ -44,16 +45,6 @@ export class CascadePaths {
44
45
  return this;
45
46
  }
46
47
 
47
- /** `polymorphic` widens one thing and nothing else, so anywhere else it is a declaration that does nothing. */
48
- #assertPolymorphicIsWiredUp(key: string, field: ConstantField) {
49
- if (!field.polymorphic) return;
50
- if (field.cascade === "removeWith" && field.refPath) return;
51
- throw new Error(
52
- `Field "${key}" declares polymorphic: "${field.polymorphic}", which only widens a ` +
53
- `cascade: "removeWith" field that names its owner type with refPath`,
54
- );
55
- }
56
-
57
48
  #assertKnownAction(key: string, action: CascadeAction) {
58
49
 
59
50
  if (!cascadeActions.includes(action)) {
@@ -74,7 +65,15 @@ export class CascadePaths {
74
65
 
75
66
  if (field.arrDepth > 0) throw new Error(`Cascade field "${key}" is an array and names more than one owner`);
76
67
  if (field.isMap) throw new Error(`Cascade field "${key}" is a Map and names no owner`);
77
- if (field.refPath) return this.#readPolymorphicOwner(key, field, fieldMap);
68
+ const anyOwner = field.cascade === "removeWithAny";
69
+ if (field.refPath) return this.#readPolymorphicOwner(key, field, fieldMap, anyOwner);
70
+
71
+ if (anyOwner) {
72
+ throw new Error(
73
+ `Cascade field "${key}" declares cascade: "removeWithAny" and must name the field holding the owner's ` +
74
+ `refName with refPath: "<typeField>"`,
75
+ );
76
+ }
78
77
  if (field.ref) {
79
78
  this.#assertHoldsId(key, field);
80
79
  return { key, modelRef: null, refName: field.ref, typeKey: null, typeValues: [], anyOwner: false };
@@ -88,18 +87,18 @@ export class CascadePaths {
88
87
  );
89
88
  }
90
89
 
91
- #readPolymorphicOwner(key: string, field: ConstantField, fieldMap: FieldObject): CascadeWithPath {
90
+ #readPolymorphicOwner(key: string, field: ConstantField, fieldMap: FieldObject, anyOwner: boolean): CascadeWithPath {
92
91
  if (field.ref) throw new Error(`Cascade field "${key}" declares both ref and refPath; keep one`);
93
92
  this.#assertHoldsId(key, field);
94
93
  const typeKey = field.refPath as string;
95
94
  const typeField = fieldMap[typeKey];
96
95
  if (!typeField) throw new Error(`Cascade field "${key}" declares refPath: "${typeKey}", which is not a field`);
97
- if (field.polymorphic === "any") return this.#readAnyOwner(key, typeKey, typeField);
96
+ if (anyOwner) return this.#readAnyOwner(key, typeKey, typeField);
98
97
 
99
98
  if (!typeField.enum) {
100
99
  throw new Error(
101
100
  `Cascade field "${key}" declares refPath: "${typeKey}", which must be an enumOf(...) naming the owner ` +
102
- `refNames it may hold; declare polymorphic: "any" to pay for a sweep on every removal instead`,
101
+ `refNames it may hold; declare cascade: "removeWithAny" to pay for a sweep on every removal instead`,
103
102
  );
104
103
  }
105
104
  const typeValues = typeField.enum.values.map((value) => String(value));
@@ -110,14 +109,15 @@ export class CascadePaths {
110
109
 
111
110
  if (typeField.enum) {
112
111
  throw new Error(
113
- `Cascade field "${key}" declares polymorphic: "any" and a refPath naming an enumOf(...); keep one`,
112
+ `Cascade field "${key}" declares cascade: "removeWithAny" and a refPath naming an enumOf(...), which ` +
113
+ `already names its owners; use cascade: "removeWith"`,
114
114
  );
115
115
  }
116
116
 
117
117
  if (this.#primitiveNameOf(typeField) !== "String") {
118
118
  throw new Error(
119
- `Cascade field "${key}" declares polymorphic: "any", so refPath: "${typeKey}" must be a String field ` +
120
- `holding the owner's refName`,
119
+ `Cascade field "${key}" declares cascade: "removeWithAny", so refPath: "${typeKey}" must be a String ` +
120
+ `field holding the owner's refName`,
121
121
  );
122
122
  }
123
123
  return { key, modelRef: null, refName: null, typeKey, typeValues: [], anyOwner: true };
@@ -106,11 +106,6 @@ export interface ConstantFieldProps<
106
106
  validate?: (value: FieldValue, model: any) => boolean;
107
107
  text?: TextFieldRole;
108
108
  cascade?: CascadeAction;
109
- /**
110
- * Widens a `cascade: "removeWith"` field whose `refPath` names a free-form `String` instead of an `enumOf`:
111
- * the owner is whatever refName the row happens to hold, found by sweeping at removal time.
112
- */
113
- polymorphic?: "any";
114
109
  /**
115
110
  * Renders on the page, never reaches an agent. Stripped wherever a value is masked for an AI caller — the
116
111
  * in-page agent's reads and every MCP result — and left untouched everywhere else, so a `File`'s blur
@@ -209,7 +204,6 @@ interface ConstantFieldBuildProps<
209
204
  validate?: (value: FieldValue, model: any) => boolean;
210
205
  text?: TextFieldRole;
211
206
  cascade?: CascadeAction;
212
- polymorphic?: "any";
213
207
  visual: boolean;
214
208
  modelRef: ConstantModelRef;
215
209
  arrDepth: number;
@@ -335,7 +329,6 @@ export class ConstantField<
335
329
  readonly validate?: (value: FieldValue, model: any) => boolean;
336
330
  readonly text?: TextFieldRole;
337
331
  readonly cascade?: CascadeAction;
338
- readonly polymorphic?: "any";
339
332
  readonly visual: boolean;
340
333
  readonly modelRef: ConstantModelRef;
341
334
  readonly arrDepth: number;
@@ -369,7 +362,6 @@ export class ConstantField<
369
362
  this.validate = props.validate;
370
363
  this.text = props.text;
371
364
  this.cascade = props.cascade;
372
- this.polymorphic = props.polymorphic;
373
365
  this.visual = props.visual;
374
366
  this.modelRef = props.modelRef;
375
367
  this.arrDepth = props.arrDepth;
@@ -450,7 +442,6 @@ export class ConstantField<
450
442
  validate: option.validate,
451
443
  text: option.text,
452
444
  cascade: option.cascade,
453
- polymorphic: option.polymorphic,
454
445
  visual: option.visual ?? false,
455
446
  modelRef,
456
447
  arrDepth: arrDepth,
@@ -498,7 +489,6 @@ export class ConstantField<
498
489
  validate: this.validate,
499
490
  text: this.text,
500
491
  cascade: this.cascade,
501
- polymorphic: this.polymorphic,
502
492
  visual: this.visual,
503
493
  modelRef: this.modelRef,
504
494
  arrDepth: this.arrDepth,
@@ -523,19 +513,35 @@ export interface FieldObject {
523
513
  */
524
514
  type WithoutTextRole<Option> = Option extends unknown ? Omit<Option, "text"> : never;
525
515
 
516
+ /**
517
+ * A wildcard owner is read off the row at removal time, so `removeWithAny` without a `refPath` names nothing to
518
+ * read it from — and the price of that action (no cascade in the app removes in one query) is one nobody should
519
+ * pay for a declaration that cascades nothing. Pairing the two in the option type refuses it at the call site;
520
+ * `CascadePaths` keeps the same refusal for the macro and bundled paths that reach it without a typecheck.
521
+ */
522
+ type CascadeOption =
523
+ | { cascade?: "removeRef" | "removeWith"; refPath?: string }
524
+ | { cascade: "removeWithAny"; refPath: string };
525
+
526
+ type WithCascadePair<Option> = Omit<Option, "cascade" | "refPath"> & CascadeOption;
527
+
526
528
  type FieldOption<
527
529
  Value extends ConstantFieldTypeInput,
528
530
  MapValue = Value extends MapConstructor ? typeof PrimitiveScalar : never,
529
531
  Metadata extends { [key: string]: any } = { [key: string]: any },
530
532
  _FieldToValue = FieldToValue<Value, MapValue> | null | undefined,
531
533
  > =
532
- | Omit<
533
- ConstantFieldProps<ConstantFieldKind, _FieldToValue, MapValue, Metadata>,
534
- "enum" | "meta" | "nullable" | "fieldType" | "select"
534
+ | WithCascadePair<
535
+ Omit<
536
+ ConstantFieldProps<ConstantFieldKind, _FieldToValue, MapValue, Metadata>,
537
+ "enum" | "meta" | "nullable" | "fieldType" | "select"
538
+ >
535
539
  >
536
- | Omit<
537
- ConstantFieldProps<ConstantFieldKind, SingleValue<_FieldToValue>, MapValue, Metadata>,
538
- "enum" | "meta" | "nullable" | "fieldType" | "select"
540
+ | WithCascadePair<
541
+ Omit<
542
+ ConstantFieldProps<ConstantFieldKind, SingleValue<_FieldToValue>, MapValue, Metadata>,
543
+ "enum" | "meta" | "nullable" | "fieldType" | "select"
544
+ >
539
545
  >[];
540
546
 
541
547
  export type PlainTypeToFieldType<PlainType> = PlainType extends [infer First, ...infer Rest]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.100",
3
+ "version": "3.0.0-alpha.101",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -23,7 +23,7 @@ export function makeAkanChildProxyHeaders(req: Request, childIdx: number, peer?:
23
23
  for (const key of HOP_BY_HOP_HEADERS) headers.delete(key);
24
24
  const forwardedFor = headers.get("x-forwarded-for");
25
25
 
26
- const clientAddress = TrustedProxy.clientAddress(headers, peer?.address);
26
+ const clientAddress = TrustedProxy.clientAddress(headers, peer === null ? null : peer?.address);
27
27
  const host = headers.get("host");
28
28
 
29
29
  if (clientAddress) {
@@ -20,7 +20,7 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
20
20
  readonly #adaptorOverrides: AdaptorOverride[] = [];
21
21
  readonly #webProxies: WebProxyRegistration[] = [];
22
22
  readonly #getLlms: ((env: Env) => LlmOption)[] = [];
23
- #mcp: boolean | McpServerOption | undefined;
23
+ #getMcp: ((env: Env) => boolean | McpServerOption) | undefined;
24
24
  #agentAccess: GuardCls | GuardCls[] | null | undefined;
25
25
  #crossSite: CrossSiteOption | undefined;
26
26
  constructor() {
@@ -48,10 +48,11 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
48
48
  /**
49
49
  * MCP server settings for the app mounting this option, merged over the `AKAN_MCP_*` environment and under an
50
50
  * option the server is constructed with. The app's own `option.ts` is the last lib the server reads, so it wins
51
- * over every library it depends on.
51
+ * over every library it depends on. The function form receives the server env, for a setting derived from it —
52
+ * a token verifier built on the app's signing secret, the issuer an authorization server publishes under.
52
53
  */
53
- setMcp(mcp: boolean | McpServerOption = true) {
54
- this.#mcp = mcp;
54
+ setMcp(mcpOrFn: boolean | McpServerOption | ((env: Env) => boolean | McpServerOption) = true) {
55
+ this.#getMcp = typeof mcpOrFn === "function" ? mcpOrFn : () => mcpOrFn;
55
56
  return this;
56
57
  }
57
58
  /**
@@ -91,8 +92,8 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
91
92
  getWebProxies(): WebProxyRegistration[] {
92
93
  return this.#webProxies;
93
94
  }
94
- getMcp(): boolean | McpServerOption | undefined {
95
- return this.#mcp;
95
+ getMcp(env: Env): boolean | McpServerOption | undefined {
96
+ return this.#getMcp?.(env);
96
97
  }
97
98
  getAgentAccess(): GuardCls | GuardCls[] | null | undefined {
98
99
  return this.#agentAccess;
@@ -195,7 +195,7 @@ export class AkanServer {
195
195
  this.openapi = options?.openapi ?? this.openapi;
196
196
 
197
197
  libs.forEach((lib) => {
198
- const mcp = lib.option.getMcp();
198
+ const mcp = lib.option.getMcp(this.env);
199
199
  if (mcp !== undefined) this.setMcp(mcp);
200
200
  const agentAccess = lib.option.getAgentAccess();
201
201
  if (agentAccess !== undefined) AgentRelayAccess.use(agentAccess);
@@ -72,7 +72,7 @@ export const getModuleDependencyRefNames = (mod: DatabaseModule | ServiceModule)
72
72
  * The modules a cascade edge forces this one to be mounted with: a `removeRef` target and a monomorphic
73
73
  * `removeWith` owner both fail `CascadeRunner.seal` when they are absent, so they are boot dependencies the
74
74
  * inject graph cannot see. A polymorphic owner is exempt — an enum list spans optional modules by design, and
75
- * `polymorphic: "any"` names no module at all.
75
+ * `cascade: "removeWithAny"` names no module at all.
76
76
  */
77
77
  export const getModuleCascadeRefNames = (mod: DatabaseModule | ServiceModule) => {
78
78
  const dependencies = new Set<string>();
package/server/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./console";
7
7
  export * from "./decorators";
8
8
  export * from "./devtools";
9
9
  export type { ChangeBatch, ChangeKind } from "./hmr/changeBatch";
10
+ export * from "./oauth";
10
11
  export * from "./processMetricsCollector";
11
12
  export * from "./proxy";
12
13
 
@@ -1,12 +1,23 @@
1
+ import type { PromiseOrObject } from "akanjs/base";
1
2
  import type { HttpRoutes } from "../types";
2
3
 
3
4
  export interface McpAuthOption {
4
- /** Where a client may obtain a token, published in the metadata document for it to discover. */
5
+ /**
6
+ * Where a client may obtain a token, published in the metadata document for it to discover. Naming one also
7
+ * makes a credential mandatory: an MCP client begins its OAuth flow only on a 401, so a server that let an
8
+ * anonymous `initialize` succeed would leave it connected to the anonymous shelf and never asking.
9
+ */
5
10
  authorizationServers?: string[];
6
11
  /** Scopes every call must carry. Declaring any turns `insufficient_scope` enforcement on. */
7
12
  scopes?: string[];
8
13
  /** Overrides the resource identifier when the public URL differs from what the request reports. */
9
14
  resource?: string;
15
+ /**
16
+ * Verifies a bearer token's signature and returns its claims, or `null` for one this server did not mint. The
17
+ * framework holds no signing key, so the module that issues tokens supplies this through `option.setMcp`; without
18
+ * it the claims are read unverified and a forged token degrades to an anonymous caller instead of being refused.
19
+ */
20
+ verify?: (token: string) => PromiseOrObject<Record<string, unknown> | null>;
10
21
  }
11
22
 
12
23
  export interface McpAuthProps extends McpAuthOption {
@@ -48,34 +59,65 @@ export class McpAuth {
48
59
  };
49
60
  }
50
61
 
62
+ /**
63
+ * A request that carried no credential at all gets a challenge with no `error` code — RFC 6750 §3.1 reserves
64
+ * the code for a credential that was presented and found wanting — so a client reads it as "authenticate",
65
+ * not as "your token is bad".
66
+ */
51
67
  unauthorized(req: Request, failure?: McpAuthFailure) {
52
- const { status, error, description } = failure ?? {
53
- status: 401 as const,
54
- error: "invalid_token" as const,
55
- description: "Authentication is required to use this MCP server.",
56
- };
68
+ const status = failure?.status ?? 401;
69
+ const description = failure?.description ?? "Authentication is required to use this MCP server.";
57
70
  return Response.json(
58
- { error, error_description: description },
59
- { status, headers: { "WWW-Authenticate": this.#challenge(req, error, description) } },
71
+ { ...(failure ? { error: failure.error } : {}), error_description: description },
72
+ { status, headers: { "WWW-Authenticate": this.#challenge(req, failure) } },
60
73
  );
61
74
  }
62
75
 
63
76
  /**
64
77
  * Refuses a bearer token that is already provably unusable, before the signal pipeline sees it.
65
78
  *
66
- * The claims are read **without verifying the signature**, which is sound only because every branch below can
67
- * deny and none can grant: a forged token still has to pass the app's own middleware afterwards, so the worst
68
- * a crafted payload achieves is refusing itself. What this buys is the failure mode it removes — an expired or
79
+ * With a `verify` hook the token is checked end to end and one that fails is refused outright. Without one the
80
+ * claims are read **without verifying the signature**, which is sound only because every branch below can deny
81
+ * and none can grant: a forged token still has to pass the app's own middleware afterwards, so the worst a
82
+ * crafted payload achieves is refusing itself. What this buys is the failure mode it removes — an expired or
69
83
  * foreign token otherwise degrades to an anonymous caller, and the agent is told a tool does not exist rather
70
84
  * than that it needs to authenticate.
71
85
  */
72
- reject(req: Request): Response | null {
73
- const claims = McpAuth.#claims(req);
74
- if (!claims) return null;
86
+ async reject(req: Request): Promise<Response | null> {
87
+ const token = McpAuth.#bearer(req);
88
+ if (!token) return null;
89
+ const claims = this.#props.verify ? await this.#verified(token) : McpAuth.#claims(token);
90
+ if (!claims) {
91
+ if (!this.#props.verify) return null;
92
+ return this.unauthorized(req, {
93
+ status: 401,
94
+ error: "invalid_token",
95
+ description: "The access token could not be verified.",
96
+ });
97
+ }
75
98
  const failure = this.#failureOf(claims, req);
76
99
  return failure ? this.unauthorized(req, failure) : null;
77
100
  }
78
101
 
102
+ /**
103
+ * The 401 a credential-less request gets once an authorization server is named. A server naming none keeps
104
+ * anonymous access, which is what a public catalogue wants; the public tools of a server naming one are still
105
+ * reachable, with the token the challenge sends the client off to obtain.
106
+ */
107
+ challengeAnonymous(req: Request): Response | null {
108
+ if (!this.#props.authorizationServers?.length || McpAuth.#bearer(req)) return null;
109
+ return this.unauthorized(req);
110
+ }
111
+
112
+ /** A verifier that throws has said no: the token is refused, never let through as an anonymous caller. */
113
+ async #verified(token: string): Promise<Record<string, unknown> | null> {
114
+ try {
115
+ return (await this.#props.verify?.(token)) ?? null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
79
121
  #failureOf(claims: Record<string, unknown>, req: Request): McpAuthFailure | null {
80
122
  const { exp, aud } = claims;
81
123
  if (typeof exp === "number" && exp * 1000 <= Date.now())
@@ -123,14 +165,13 @@ export class McpAuth {
123
165
  );
124
166
  }
125
167
 
126
- #challenge(req: Request, error: string, description: string) {
168
+ #challenge(req: Request, failure?: McpAuthFailure) {
127
169
  const scopes = this.#props.scopes ?? [];
128
170
  return [
129
171
  "Bearer",
130
172
  [
131
173
  `resource_metadata="${new URL(McpAuth.wellKnownPath + this.#props.path, McpAuth.origin(req)).href}"`,
132
- `error="${error}"`,
133
- `error_description="${description}"`,
174
+ ...(failure ? [`error="${failure.error}"`, `error_description="${failure.description}"`] : []),
134
175
  ...(scopes.length ? [`scope="${scopes.join(" ")}"`] : []),
135
176
  ].join(", "),
136
177
  ].join(" ");
@@ -161,9 +202,12 @@ export class McpAuth {
161
202
  return `${forwarded("x-forwarded-proto") ?? url.protocol.replace(":", "")}://${host}`;
162
203
  }
163
204
 
164
- static #claims(req: Request): Record<string, unknown> | null {
205
+ static #bearer(req: Request): string | null {
165
206
  const [scheme, token] = (req.headers.get("authorization") ?? "").split(" ");
166
- if (scheme !== "Bearer" || !token) return null;
207
+ return scheme === "Bearer" && token ? token : null;
208
+ }
209
+
210
+ static #claims(token: string): Record<string, unknown> | null {
167
211
  const segments = token.split(".");
168
212
 
169
213
  if (segments.length !== 3) return null;
@@ -1,5 +1,5 @@
1
1
  import { type BackendEnv, ENDPOINT_META } from "akanjs/base";
2
- import { Logger } from "akanjs/common";
2
+ import { interpolateTranslation, Logger } from "akanjs/common";
3
3
  import { ConstantRegistry, mask } from "akanjs/constant";
4
4
  import { DictionaryLookup } from "akanjs/dictionary";
5
5
  import { NoDocumentError } from "akanjs/document";
@@ -203,13 +203,14 @@ export class McpDispatcher {
203
203
 
204
204
  if (error instanceof NoDocumentError) return `No ${refName} found for the arguments given.`;
205
205
  if (status && status < 500) {
206
+
207
+ if (status === 401 || status === 403) return "You are not permitted to perform this action.";
206
208
  const raw = error instanceof Error ? error.message : String(error);
207
209
 
208
210
  this.#lookup ??= new DictionaryLookup(this.#props.language);
209
211
  const text = this.#lookup.text(raw);
210
- if (text) return text;
211
212
 
212
- if (status === 401 || status === 403) return "You are not permitted to perform this action.";
213
+ if (text) return interpolateTranslation(text, (error as { data?: Record<string, unknown> }).data);
213
214
  return raw;
214
215
  }
215
216
 
@@ -158,6 +158,7 @@ export class McpRouter {
158
158
  McpRouter.logger.warn(
159
159
  "MCP is enabled but published nothing. Every candidate was refused; see the reasons below.",
160
160
  );
161
+ this.#reportUnverifiable(tools.map((tool) => tool.name));
161
162
  for (const { key, reason } of refusals) McpRouter.logger.verbose(`MCP did not expose "${key}": ${reason}`);
162
163
  for (const { key, reason } of undescribed)
163
164
  McpRouter.logger.warn(`MCP exposed "${key}" with no description: ${reason}`);
@@ -169,6 +170,24 @@ export class McpRouter {
169
170
  }
170
171
  }
171
172
 
173
+ /**
174
+ * A guarded tool is only reachable with a credential, and this server can neither issue one nor check one unless
175
+ * something configured `auth`. Left silent, the shelf lists tools no client can ever call and a forged token
176
+ * reads as anonymous — so it is said once at boot, and said louder anywhere but a developer's machine.
177
+ */
178
+ #reportUnverifiable(toolNames: string[]) {
179
+ const { verify, authorizationServers } = this.#props.auth ?? {};
180
+ if (verify || authorizationServers?.length) return;
181
+ const document = this.#getDocument();
182
+ const guarded = toolNames.filter((name) =>
183
+ (document.findTool(name)?.endpoint.guards ?? []).some((guard) => guard !== "Public"),
184
+ ).length;
185
+ if (!guarded) return;
186
+ const line = `MCP publishes ${guarded} guarded tool(s) but no authorization server is configured: a client cannot obtain a token for them, and a bearer token's signature is not checked here. Set \`auth.verify\` and \`auth.authorizationServers\` through option.setMcp (a module that issues tokens does this), or AKAN_MCP_AUTH_SERVERS.`;
187
+ if (getEnv().operationMode === "local") McpRouter.logger.warn(line);
188
+ else McpRouter.logger.error(line);
189
+ }
190
+
172
191
  static #kb(bytes: number) {
173
192
  return bytes < 1024 ? `${bytes}B` : `${Math.round(bytes / 1024)}KB`;
174
193
  }
@@ -196,7 +215,9 @@ export class McpRouter {
196
215
 
197
216
  async #post(req: Request) {
198
217
  if (!this.#originAllowed(req)) return new Response("Forbidden", { status: 403 });
199
- const rejected = this.#auth.reject(req);
218
+
219
+ req.headers.delete("cookie");
220
+ const rejected = this.#auth.challengeAnonymous(req) ?? (await this.#auth.reject(req));
200
221
  if (rejected) return rejected;
201
222
  let body: McpJsonRpcRequest;
202
223
  try {
@@ -0,0 +1,84 @@
1
+ import { OAuthErrors } from "./OAuthErrors";
2
+ import { OAuthPkce } from "./OAuthPkce";
3
+ import { OAuthRedirect } from "./OAuthRedirect";
4
+ import type { OAuthAuthorizeErrorCode, OAuthAuthorizeParams, OAuthClientRecord } from "./oauthTypes";
5
+
6
+ export interface OAuthAuthorizeContext {
7
+ /** The client the request's `client_id` resolved to, however it was registered; `null` when it resolved to nothing. */
8
+ client: OAuthClientRecord | null;
9
+ issuer: string;
10
+ /** The one resource this server issues tokens for — the MCP endpoint's canonical URL. */
11
+ resource: string;
12
+ }
13
+
14
+ export type OAuthAuthorizeResult = { ok: true; params: OAuthAuthorizeParams } | { ok: false; response: Response };
15
+
16
+ export class OAuthAuthorize {
17
+ /**
18
+ * Validates in the order RFC 6749 §4.1.2.1 prescribes: the client and its redirect URI first, answered with a page
19
+ * because nothing may be redirected to an unverified destination; everything after that is answered through the
20
+ * redirect URI the client registered, with `state` and `iss` so the client can match and trust the answer.
21
+ */
22
+ static parse(url: URL, { client, issuer, resource }: OAuthAuthorizeContext): OAuthAuthorizeResult {
23
+ const param = (name: string) => url.searchParams.get(name) ?? undefined;
24
+ const clientId = param("client_id");
25
+ if (!clientId) return OAuthAuthorize.#page("Missing client", "The authorization request names no client_id.");
26
+ if (!client) return OAuthAuthorize.#page("Unknown client", `No client is registered as "${clientId}".`);
27
+
28
+ const redirectUri =
29
+ param("redirect_uri") ?? (client.redirectUris.length === 1 ? client.redirectUris[0] : undefined);
30
+ if (!redirectUri)
31
+ return OAuthAuthorize.#page(
32
+ "Missing redirect URI",
33
+ "The client registered several redirect URIs and named none.",
34
+ );
35
+ if (!OAuthRedirect.matches(client.redirectUris, redirectUri))
36
+ return OAuthAuthorize.#page(
37
+ "Redirect URI not registered",
38
+ `"${redirectUri}" is not a redirect URI registered for this client.`,
39
+ );
40
+ const state = param("state");
41
+ const fail = (error: OAuthAuthorizeErrorCode, description: string): OAuthAuthorizeResult => ({
42
+ ok: false,
43
+ response: OAuthErrors.redirect(redirectUri, { error, description, state, iss: issuer }),
44
+ });
45
+ if (param("response_type") !== "code")
46
+ return fail("unsupported_response_type", "Only response_type=code is supported.");
47
+ if (!client.grantTypes.includes("authorization_code"))
48
+ return fail("unauthorized_client", "This client is not registered for the authorization_code grant.");
49
+ const codeChallenge = param("code_challenge");
50
+ const method = param("code_challenge_method");
51
+
52
+ if (method !== undefined && method !== OAuthPkce.method)
53
+ return fail("invalid_request", `code_challenge_method "${method}" is not supported; use ${OAuthPkce.method}.`);
54
+ if (!OAuthPkce.isChallenge(codeChallenge)) return fail("invalid_request", "A PKCE code_challenge is required.");
55
+ if (method === undefined)
56
+ return fail("invalid_request", `code_challenge_method is required and must be ${OAuthPkce.method}.`);
57
+ const requested = param("resource");
58
+ if (requested !== undefined && !OAuthAuthorize.sameResource(requested, resource))
59
+ return fail("invalid_target", `This server issues tokens for ${resource} only.`);
60
+ return {
61
+ ok: true,
62
+ params: { clientId, redirectUri, codeChallenge, state, resource, scope: param("scope") },
63
+ };
64
+ }
65
+
66
+ /** RFC 8707 §2 compares resource identifiers as URIs, so a trailing slash a client added is not another server. */
67
+ static sameResource(a: string, b: string): boolean {
68
+ return OAuthAuthorize.#canonical(a) === OAuthAuthorize.#canonical(b);
69
+ }
70
+
71
+ static #canonical(resource: string): string {
72
+ try {
73
+ const url = new URL(resource);
74
+ url.hash = "";
75
+ return url.href.replace(/\/$/, "");
76
+ } catch {
77
+ return resource;
78
+ }
79
+ }
80
+
81
+ static #page(title: string, detail: string): OAuthAuthorizeResult {
82
+ return { ok: false, response: OAuthErrors.page(400, title, detail) };
83
+ }
84
+ }