@creeperhost/modlens-mcp 1.6.23 → 1.6.26

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 (38) hide show
  1. package/README.md +14 -6
  2. package/RUNTIME.md +30 -25
  3. package/dist/hosted-oauth.d.ts +5 -0
  4. package/dist/hosted-oauth.d.ts.map +1 -1
  5. package/dist/hosted-oauth.js +156 -50
  6. package/dist/hosted-oauth.js.map +1 -1
  7. package/dist/hosted-tool-catalog.d.ts +11 -0
  8. package/dist/hosted-tool-catalog.d.ts.map +1 -0
  9. package/dist/hosted-tool-catalog.js +48 -0
  10. package/dist/hosted-tool-catalog.js.map +1 -0
  11. package/dist/private-network.d.ts +4 -0
  12. package/dist/private-network.d.ts.map +1 -0
  13. package/dist/private-network.js +17 -0
  14. package/dist/private-network.js.map +1 -0
  15. package/dist/runtime/companion.d.ts +3 -3
  16. package/dist/runtime/guidance.d.ts +1 -1
  17. package/dist/runtime/guidance.js +1 -1
  18. package/dist/runtime/guidance.js.map +1 -1
  19. package/dist/runtime/hub.d.ts +2 -1
  20. package/dist/runtime/hub.d.ts.map +1 -1
  21. package/dist/runtime/hub.js +22 -12
  22. package/dist/runtime/hub.js.map +1 -1
  23. package/dist/runtime/modlens-agent.jar +0 -0
  24. package/dist/runtime/requests.d.ts +4 -4
  25. package/dist/runtime/requests.d.ts.map +1 -1
  26. package/dist/runtime/requests.js +5 -4
  27. package/dist/runtime/requests.js.map +1 -1
  28. package/dist/server.js +29 -10
  29. package/dist/server.js.map +1 -1
  30. package/package.json +4 -1
  31. package/scripts/build-runtime-agent.mjs +63 -3
  32. package/scripts/gradle/modlens-runtime.init.gradle +1 -1
  33. package/scripts/test-hosted-oauth.mjs +75 -12
  34. package/scripts/test-hosted-policy.mjs +26 -0
  35. package/scripts/test-package.mjs +4 -0
  36. package/scripts/test-runtime-glfw.mjs +81 -0
  37. package/scripts/test-runtime-legacy.mjs +71 -0
  38. package/scripts/test-runtime-lwjgl2-real.mjs +59 -0
package/README.md CHANGED
@@ -6,10 +6,11 @@ Store mod metadata, class indexes, mixin targets, AT/AW entries, and decompiled
6
6
 
7
7
  ## Optional live development client
8
8
 
9
- The `runtime` MCP tool can prepare and launch a **Minecraft 26.3 / Java 25** dev
10
- client, detect IntelliJ launches, monitor JVM failures and memory pressure, and
11
- control input without desktop automation. It supports normal, visible watch-only,
12
- and hidden windows. Ask the AI to call `runtime` with `action:"help"` or set it up
9
+ The `runtime` MCP tool can prepare and launch a Minecraft development client,
10
+ detect IntelliJ launches, monitor JVM failures and memory pressure, capture
11
+ screenshots, and control game input. It uses SDL on Minecraft 26.3, GLFW on
12
+ 1.13–1.21, and LWJGL2 on 1.7.10–1.12.2. Ask the AI
13
+ to call `runtime` with `action:"help"` or set it up
13
14
  for your mod project. With remote MCP, Codex runs the local `--runtime` helper;
14
15
  with local stdio MCP, the tools execute directly. No second MCP connection is needed.
15
16
  See [runtime setup, examples, and compatibility limits](RUNTIME.md).
@@ -1067,12 +1068,17 @@ node dist/cli.js check-updates 2
1067
1068
 
1068
1069
  ## Hosted access limits
1069
1070
 
1070
- HTTP MCP (`MCP_PORT`) enables hosted limits by default; local stdio retains full access. Public HTTP returns Minecraft class locations, members, references, mixin and version analysis, and source metadata, without Minecraft source text or bytecode. `source_info` reports cached line counts; `search_code` reports matching files and lines (`0` when no exact line is available). Line numbers may differ from a client's local decompilation.
1071
+ HTTP MCP (`MCP_PORT`) enables hosted limits by default; local stdio retains full access. Direct HTTP connections from RFC 1918 IPv4 peers (`10/8`, `172.16/12`, `192.168/16`) also get full tool access by default, including whole-mod and whole-Minecraft decompilation, without gateway or OAuth authentication. Set `MODLENS_RFC1918_BYPASS=0` to require normal hosted authentication and limits for these clients. Loopback and IPv6 addresses do not match this bypass.
1071
1072
 
1072
- The first hosted Minecraft request for a version queues private full-version decompilation and indexing. Public `get_source` prioritizes that class and returns preparation status instead of source; `index_status` reports full-version progress. Bulk commands, exports, raw JAR reads, host paths, filesystem administration, and KubeJS directory access remain local-only.
1073
+ The check uses the TCP peer address, including IPv4-mapped IPv6 addresses, and ignores claimed client IP headers. Requests carrying `Forwarded`, `X-Forwarded-For`, `X-Real-IP`, or ModLens gateway identity headers follow normal hosted access. If a reverse proxy reaches the server from an RFC 1918 address, configure it to send a forwarding header for **every** request or disable the bypass; otherwise its public clients may appear to be private peers. Private clients must connect directly to use this allowance.
1074
+
1075
+ Public HTTP returns Minecraft class locations, members, references, mixin and version analysis, and source metadata, without Minecraft source text or bytecode. `source_info` reports cached line counts; `search_code` reports matching files and lines (`0` when no exact line is available). Line numbers may differ from a client's local decompilation.
1076
+
1077
+ The first hosted Minecraft request for a version queues private full-version decompilation and indexing. Public `get_source` prioritizes that class and returns preparation status instead of source; `index_status` reports full-version progress. Bulk commands, exports, raw JAR reads, host paths, filesystem administration, and KubeJS directory access remain unavailable to public hosted clients.
1073
1078
 
1074
1079
  | Setting | Default | Scope |
1075
1080
  | --- | --- | --- |
1081
+ | `MODLENS_RFC1918_BYPASS` | enabled | Set to `0` to disable private-peer full access |
1076
1082
  | `MODLENS_HOSTED_SOURCE_LINES` | 200 | Source/bytecode lines per response, shared across search snippets |
1077
1083
  | `MODLENS_HOSTED_RESPONSE_BYTES` | 131072 (128 KiB) | Serialized text content per tool response |
1078
1084
  | `MODLENS_HOSTED_DAILY_BYTES` | 5242880 (5 MiB) | Delivered tool content per account per UTC day |
@@ -1100,6 +1106,8 @@ npx -y @creeperhost/modlens-mcp --local-mod --request-file local-request.json
1100
1106
 
1101
1107
  For built-in OAuth sign-in, set `MODLENS_HOSTED_AUTH=oauth` and configure `MODLENS_OAUTH_PUBLIC_URL` (the public `/mcp` URL), `MODLENS_OAUTH_ISSUER`, `MODLENS_OAUTH_CLIENT_ID`, `MODLENS_OAUTH_SCOPES`, and a stable base64-encoded 32-byte `MODLENS_OAUTH_STORAGE_KEY`. Set `MODLENS_OAUTH_PROFILE_URL` unless provider discovery supplies a userinfo endpoint. The provider must support authorization code with S256 PKCE and refresh tokens for persistent sign-in. Register `<public origin>/oauth/upstream/callback` as the provider client's redirect URI. `MODLENS_OAUTH_CLIENT_SECRET` is optional. `MODLENS_OAUTH_SUBJECT_FIELD` defaults to `sub`; optional `MODLENS_OAUTH_REQUIRED_FIELD` and `MODLENS_OAUTH_REQUIRED_VALUE` restrict access using a profile field. The OAuth routes and well-known metadata must be reachable through the public HTTPS origin. OAuth mode does not enable hosted Minecraft source.
1102
1108
 
1109
+ Dynamic client registration accepts the optional `client_name` metadata field. The consent page displays this self-reported name alongside the registered callback origin. Existing clients must register again to supply a name; clients without one appear as "Unnamed application".
1110
+
1103
1111
  OAuth clients, short-lived authorization state, grants, and tokens are stored in the configured database. A multi-replica deployment must point every replica at the same persistent database and use the same `MODLENS_OAUTH_STORAGE_KEY`; the embedded SQLite default is suitable only for a single replica. Otherwise, a browser redirect can land on a replica that cannot see the authorization created by the previous request and fail with `invalid_grant`.
1104
1112
 
1105
1113
  The server writes structured OAuth lifecycle events to stderr with a short `flow` identifier. Failures also include an `incident` reference shown on browser-facing error pages, so one report can be matched to its server log. These events include the stage, route, status, error code, hashed client reference, and redirect origin where relevant; authorization codes, state values, PKCE material, tokens, provider profiles, and subjects are never logged.
package/RUNTIME.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # Optional live Minecraft development runtime
2
2
 
3
- ModLens can launch or detect a **Minecraft Java 26.3 / Java 25** development client,
4
- observe JVM health and failures, and send inputs directly through LWJGL's SDL3
5
- bindings. Desktop automation and an IntelliJ plugin are not required. This is an
6
- initial 26.3 adapter, not a claim of compatibility with older versions or every modpack.
3
+ ModLens can launch or detect a Minecraft development client and observe JVM
4
+ health and failures. One Java agent JAR supports Minecraft 1.7.10 and newer.
5
+ It hooks SDL on 26.3, GLFW on 1.13–1.21, and LWJGL2 on 1.7.10–1.12.2 for
6
+ input and screenshots. JFR diagnostics are available when the game runs on
7
+ Java 11 or newer.
8
+ Desktop automation and an IntelliJ plugin are not required.
7
9
 
8
10
  ## Let the AI set it up
9
11
 
@@ -18,6 +20,12 @@ The always-discoverable `runtime` MCP tool describes this workflow. The AI calls
18
20
  {"action":"setup","projectDir":"F:/Git/my-mod","mcVersion":"26.3","mode":"observe","gradleTask":"runClient"}
19
21
  ```
20
22
 
23
+ For an older client, specify its version. For example:
24
+
25
+ ```json
26
+ {"action":"setup","projectDir":"F:/Git/my-old-mod","mcVersion":"1.7.10","mode":"observe","gradleTask":"runClient"}
27
+ ```
28
+
21
29
  With local stdio, that tool call executes directly. With remote MCP, it returns
22
30
  `executed:false` and a **local execution plan**. Codex writes the supplied JSON
23
31
  request to a local file and invokes the version-matched helper through its local
@@ -38,8 +46,8 @@ node /path/to/modlens-mcp/dist/launcher.js --runtime --request-file runtime-requ
38
46
 
39
47
  The helper automatically starts an authenticated loopback companion. It stays
40
48
  running between commands, detects configured IntelliJ launches, and collects
41
- events while Codex is doing other work. It requires Node.js and the same Minecraft
42
- JDK as the direct MCP workflow; it does not bootstrap a source database or modify
49
+ events while Codex is doing other work. It requires Node.js and a JDK supported by
50
+ the Minecraft client (Java 8 or newer); it does not bootstrap a source database or modify
43
51
  MCP configuration. There is no second local MCP connection, Cloudflare dependency,
44
52
  public runtime endpoint, or automatic upload of diagnostics to remote ModLens.
45
53
  Codex must have local terminal/file access on the Minecraft PC; a cloud-only shell
@@ -75,9 +83,9 @@ For multi-project builds, select the actual client task, e.g. `:fabric:runClient
75
83
  The task must extend Gradle `JavaExec`. A generated init script adds the agent to
76
84
  that task only. Custom launch plugins which don't expose a JavaExec task get an
77
85
  actionable error. Setup also returns `vmOptions` for the actual game JVM in an
78
- existing Application run configuration. Add every entry, including
79
- `-XX:StackShadowPages=32`: the 26.3 client needs this setting independently of the
80
- agent. The generated Gradle/IntelliJ launch supplies it automatically, only to the
86
+ existing Application run configuration. For 26.3, add every entry, including
87
+ `-XX:StackShadowPages=32`: that client needs this setting independently of the
88
+ agent. Older versions receive only the agent VM option. The generated Gradle/IntelliJ launch supplies the required options, only to the
81
89
  selected client task. **Do not put these options on IntelliJ itself or the Gradle
82
90
  daemon.** Quote each whole VM option if it contains spaces. The singular `vmOption`
83
91
  field remains available for callers that only need the agent argument.
@@ -94,7 +102,7 @@ include the compiled agent; end users do not need to compile it.
94
102
  | --- | --- | --- |
95
103
  | `interactive` | Visible game window | Human controls; MCP input rejected |
96
104
  | `observe` | Visible game window | MCP controls; physical game input filtered |
97
- | `hidden` | Hidden game window | MCP controls; physical game input filtered |
105
+ | `hidden` | Hidden game window (SDL/GLFW) | MCP controls; physical game input filtered |
98
106
 
99
107
  Switch a connected client with:
100
108
 
@@ -105,7 +113,8 @@ Switch a connected client with:
105
113
  Observe mode uses the actual game window as a watch-only display. OS window
106
114
  management, including closing it, remains available. Hidden mode still uses a
107
115
  graphics device and desktop/display environment; it is not a GPU-free server.
108
- Input interception covers the normal SDL paths. Mods using another native input
116
+ LWJGL2 clients support `interactive` and `observe`; their Java display API does
117
+ not support hidden mode. Input interception covers the normal LWJGL paths. Mods using another native input
109
118
  path need a separate adapter. This is a development convenience, not an OS security
110
119
  boundary. Switching to interactive returns control; click the game to resume mouse
111
120
  capture normally.
@@ -130,7 +139,7 @@ capture normally.
130
139
  ```
131
140
 
132
141
  Key names include A–Z, 0–9, SPACE, ENTER, ESCAPE, TAB, arrows, modifiers and F1–F12.
133
- Numeric strings outside single digits represent SDL scancodes. Mouse buttons are
142
+ Numeric strings outside single digits represent backend-specific key codes. Mouse buttons are
134
143
  1=left, 2=middle, 3=right. Coordinates are **window pixels**, not scaled GUI units;
135
144
  relative movements are deltas. Text sends text-input events independently of keys.
136
145
  `scroll` accepts `x` and `y`. Inputs are delivered to the event loop; they do not
@@ -142,8 +151,8 @@ been unreachable for five seconds (as soon as the game event loop runs). Use
142
151
  automatically after uncertain delivery. A timeout means execution is unknown;
143
152
  inspect state before retrying an action.
144
153
 
145
- Screenshots use 26.3's own renderer-independent screenshot API, after
146
- `state.observation.gameLoaded` becomes true. PNGs are returned as MCP image content
154
+ On 26.3, screenshots use Minecraft's screenshot API after
155
+ `state.observation.gameLoaded` becomes true. GLFW and LWJGL2 clients capture the OpenGL framebuffer. PNGs are returned as MCP image content
147
156
  by `artifact` over local stdio MCP. The CLI returns the local PNG path for Codex's
148
157
  image viewer instead of base64 in terminal text. `threads` writes a thread dump including detected deadlock IDs;
149
158
  `recording` writes the recent JFR recording. Files live under
@@ -152,8 +161,8 @@ contain application data; delete old session directories when finished.
152
161
 
153
162
  ## Monitoring and failure semantics
154
163
 
155
- The agent samples heap/non-heap usage, thread counts and GC counters every second,
156
- keeps a bounded two-minute/16 MiB JFR recording, captures uncaught exceptions while
164
+ The agent samples heap/non-heap usage, thread counts and GC counters every second.
165
+ On Java 11+, it keeps a bounded two-minute/16 MiB JFR recording. It captures uncaught exceptions while
157
166
  chaining the previous handler, and hooks the game's fatal crash reporting path.
158
167
  It does not intercept every logged/caught exception or exceptions consumed by a
159
168
  custom per-thread handler. The optional Minecraft crash hook covers the normal
@@ -203,15 +212,11 @@ not guarantee an idle Codex task wakes up; host scheduling is separate from MCP.
203
212
  runtime actions on the Minecraft PC using the same request schema and dispatcher
204
213
  as local stdio MCP. HTTP handlers never execute user-supplied local paths.
205
214
  - The agent connects only to authenticated loopback HTTP; no browser control routes.
206
- - SDL events, keyboard state and mouse state are kept consistent. Input/window code
207
- lives in `SdlAdapter`; optional game APIs live in `Minecraft263`.
208
- - Exact transformation descriptors live in `Transformer`. It uses Java 25's
209
- standard Class-File API and an isolated bootstrap bridge, with no ASM/Gson or
210
- native agent dependencies to collide with mod loaders.
211
- - Setup accepts `minecraftHooks:false` to isolate compatibility problems. SDL
212
- controls and JVM diagnostics remain available; screenshots and structured game
213
- state require the Minecraft adapter. A GLFW backend belongs to a future backport:
214
- vanilla 26.3 uses SDL3.
215
+ - SDL events, keyboard state and mouse state are kept consistent by `SdlAdapter`;
216
+ optional 26.3 game APIs live in `Minecraft263`. GLFW and LWJGL2 input use
217
+ `LegacyInput` and a relocated ASM transformer.
218
+ - Setup accepts `minecraftHooks:false` for the optional 26.3 game hooks. SDL
219
+ control and JVM diagnostics remain available.
215
220
  - Capability presence and validation are distinct. Tests cover selected environments;
216
221
  Fabric/NeoForge launchers and rendering replacements still require their own runs.
217
222
 
@@ -11,10 +11,13 @@ export declare class HostedOAuth {
11
11
  private config;
12
12
  private database;
13
13
  private ready?;
14
+ private readonly instance;
14
15
  private requestCounts;
16
+ private refreshes;
15
17
  private constructor();
16
18
  private audit;
17
19
  private clientRef;
20
+ private grantRef;
18
21
  logError(method: string | undefined, route: string, error: HostedOAuthError): string;
19
22
  static create(env?: NodeJS.ProcessEnv, database?: () => Promise<Database>): Promise<HostedOAuth>;
20
23
  private db;
@@ -25,6 +28,7 @@ export declare class HostedOAuth {
25
28
  private getObject;
26
29
  private takeObject;
27
30
  private client;
31
+ private clientName;
28
32
  private upstreamToken;
29
33
  private profile;
30
34
  private callbackUrl;
@@ -35,6 +39,7 @@ export declare class HostedOAuth {
35
39
  authenticate(req: IncomingMessage): Promise<string>;
36
40
  private revoke;
37
41
  private issue;
42
+ private refresh;
38
43
  private limit;
39
44
  handle(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean>;
40
45
  private metadataUrlPath;
@@ -1 +1 @@
1
- {"version":3,"file":"hosted-oauth.d.ts","sourceRoot":"","sources":["../src/hosted-oauth.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AACjE,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAEhC,KAAK,QAAQ,GAAG,OAAO,CAAC,UAAU,CAAC,OAAO,KAAK,CAAC,CAAC,CAAC;AAclD,qBAAa,gBAAiB,SAAQ,KAAK;IACH,MAAM;IAAe,IAAI;IAA6B,MAAM,CAAC,EAAE,MAAM;gBAA7F,OAAO,EAAE,MAAM,EAAS,MAAM,SAAM,EAAS,IAAI,SAAoB,EAAS,MAAM,CAAC,EAAE,MAAM,YAAA;CAC5G;AAuHD,qBAAa,WAAW;IAGA,OAAO,CAAC,MAAM;IAAc,OAAO,CAAC,QAAQ;IAFhE,OAAO,CAAC,KAAK,CAAC,CAAgB;IAC9B,OAAO,CAAC,aAAa,CAAwD;IAC7E,OAAO;IAEP,OAAO,CAAC,KAAK;IAIb,OAAO,CAAC,SAAS;IAEjB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,GAAG,MAAM;WAOvE,MAAM,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,EAAE,QAAQ,GAAE,MAAM,OAAO,CAAC,QAAQ,CAAS,GAAG,OAAO,CAAC,WAAW,CAAC;YAmC5G,EAAE;IAkBhB,OAAO,CAAC,IAAI;IAOZ,OAAO,CAAC,IAAI;YAOE,SAAS;YAST,WAAW;YAOX,SAAS;YAOT,UAAU;YAWV,MAAM;YAQN,aAAa;YAiBb,OAAO;IA6BrB,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,SAAS;IACjB,OAAO,CAAC,WAAW;IAInB,SAAS,CAAC,GAAG,EAAE,cAAc,GAAG,IAAI;IAIpC,YAAY,CAAC,GAAG,EAAE,cAAc,EAAE,KAAK,EAAE,gBAAgB,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI;IAa7E,YAAY,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,CAAC;YA4B3C,MAAM;YAMN,KAAK;IASnB,OAAO,CAAC,KAAK;IAYP,MAAM,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,EAAE,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,OAAO,CAAC;IAmOnF,OAAO,CAAC,eAAe;CAC1B"}
1
+ {"version":3,"file":"hosted-oauth.d.ts","sourceRoot":"","sources":["../src/hosted-oauth.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AACjE,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAEhC,KAAK,QAAQ,GAAG,OAAO,CAAC,UAAU,CAAC,OAAO,KAAK,CAAC,CAAC,CAAC;AAclD,qBAAa,gBAAiB,SAAQ,KAAK;IACH,MAAM;IAAe,IAAI;IAA6B,MAAM,CAAC,EAAE,MAAM;gBAA7F,OAAO,EAAE,MAAM,EAAS,MAAM,SAAM,EAAS,IAAI,SAAoB,EAAS,MAAM,CAAC,EAAE,MAAM,YAAA;CAC5G;AA6JD,qBAAa,WAAW;IAKA,OAAO,CAAC,MAAM;IAAc,OAAO,CAAC,QAAQ;IAJhE,OAAO,CAAC,KAAK,CAAC,CAAgB;IAC9B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkC;IAC3D,OAAO,CAAC,aAAa,CAAwD;IAC7E,OAAO,CAAC,SAAS,CAAuD;IACxE,OAAO;IAEP,OAAO,CAAC,KAAK;IAIb,OAAO,CAAC,SAAS;IACjB,OAAO,CAAC,QAAQ;IAEhB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,GAAG,MAAM;WAOvE,MAAM,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,EAAE,QAAQ,GAAE,MAAM,OAAO,CAAC,QAAQ,CAAS,GAAG,OAAO,CAAC,WAAW,CAAC;YAqC5G,EAAE;IAoBhB,OAAO,CAAC,IAAI;IAOZ,OAAO,CAAC,IAAI;YAOE,SAAS;YAST,WAAW;YAOX,SAAS;YAOT,UAAU;YAWV,MAAM;YAQN,UAAU;YAOV,aAAa;YAsBb,OAAO;IAiCrB,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,SAAS;IACjB,OAAO,CAAC,WAAW;IAInB,SAAS,CAAC,GAAG,EAAE,cAAc,GAAG,IAAI;IAIpC,YAAY,CAAC,GAAG,EAAE,cAAc,EAAE,KAAK,EAAE,gBAAgB,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI;IAa7E,YAAY,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,CAAC;YA6B3C,MAAM;YAON,KAAK;YASL,OAAO;IAiDrB,OAAO,CAAC,KAAK;IAYP,MAAM,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,EAAE,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,OAAO,CAAC;IA8NnF,OAAO,CAAC,eAAe;CAC1B"}
@@ -22,12 +22,13 @@ const redirect = (res, url) => {
22
22
  res.writeHead(302, { Location: url.toString(), "Cache-Control": "no-store" });
23
23
  res.end();
24
24
  };
25
+ const htmlCsp = (formAction = "'self'") => `default-src 'none'; style-src 'unsafe-inline'; form-action ${formAction}; base-uri 'none'; frame-ancestors 'none'`;
25
26
  const htmlHeaders = {
26
27
  "Content-Type": "text/html; charset=utf-8",
27
28
  "Cache-Control": "no-store",
28
29
  "Referrer-Policy": "no-referrer",
29
30
  "X-Content-Type-Options": "nosniff",
30
- "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
31
+ "Content-Security-Policy": htmlCsp(),
31
32
  };
32
33
  const escapeHtml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
33
34
  .replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
@@ -51,7 +52,7 @@ main{width:100%;display:flex;flex:1;flex-direction:column;align-items:center;jus
51
52
  .brand{display:flex;align-items:center;justify-content:center;margin:0 auto 22px;color:white}.brand-copy{text-align:center;line-height:1}.brand-copy strong{display:block;font-size:21px;font-weight:750;letter-spacing:.13em}.brand-copy small{display:block;margin-top:7px;color:#ffffff73;font-size:10px;font-weight:650;letter-spacing:.31em}
52
53
  .card{width:min(638px,calc(100vw - 34px));border:1px solid var(--line);outline:1px solid var(--line-dark);border-radius:10px;overflow:hidden;background:linear-gradient(180deg,var(--panel-top),var(--panel-bottom));box-shadow:0 0 25px rgba(0,0,0,.2)}
53
54
  .content{padding:32px;text-align:left}.eyebrow{display:block;margin:0 0 7px;color:${tone === "error" ? "var(--error)" : "#72d96e"};font-size:10px;font-weight:700;letter-spacing:.17em}.content h1{margin:0;color:var(--text);font-family:"Centra No 2","Segoe UI Variable","Segoe UI",sans-serif;font-size:30px;line-height:36px;letter-spacing:-.03em;font-weight:500}.lead,.content>p{margin:9px 0 0;color:#ffffffb3;font-size:14px;line-height:1.6}
54
- .app{display:flex;align-items:center;gap:12px;margin:20px 0;padding:12px;background:var(--panel-deep);border:1px solid var(--line);border-radius:6px}.app-icon{display:flex;align-items:center;justify-content:center;width:36px;height:36px;flex:0 0 auto;border-radius:50%;background:#06c20020;color:#8cdd88;font:600 12px/1 Consolas,monospace}.app-copy{min-width:0}.app-origin{display:block;overflow:hidden;text-overflow:ellipsis;color:#f0f0f5;font:600 13px/1.45 Consolas,monospace;white-space:nowrap}.app-label{display:block;color:var(--muted);font-size:11px}
55
+ .app{display:flex;align-items:center;gap:12px;margin:20px 0;padding:12px;background:var(--panel-deep);border:1px solid var(--line);border-radius:6px}.app-icon{display:flex;align-items:center;justify-content:center;width:36px;height:36px;flex:0 0 auto;border-radius:50%;background:#06c20020;color:#8cdd88;font:600 12px/1 Consolas,monospace}.app-copy{min-width:0}.app-name{display:block;color:#f0f0f5;font-size:14px}.app-origin{display:block;overflow:hidden;text-overflow:ellipsis;color:#d7dbe0;font:500 12px/1.45 Consolas,monospace;white-space:nowrap}.app-label{display:block;color:var(--muted);font-size:11px}
55
56
  .permissions{margin:20px 0;padding-left:22px;color:#d7dbe0;font-size:13px;line-height:1.65}.permissions li+li{margin-top:9px}.permissions li::marker{color:#48c544}
56
57
  form{margin:0}.actions{display:grid;grid-template-columns:1fr 1.6fr;gap:12px;margin-top:24px}button{display:inline-flex;min-height:48px;align-items:center;justify-content:center;appearance:none;border:1px solid transparent;border-radius:4px;padding:10px 16px;color:white;font:500 14px/1 "Segoe UI Variable","Segoe UI",sans-serif;cursor:pointer;transition:box-shadow .18s ease,background .18s ease}button:focus-visible,a:focus-visible{outline:2px solid var(--focus);outline-offset:3px}.primary{background:linear-gradient(to left,var(--green),var(--green-dark))}.primary:hover{box-shadow:0 0 20px #42d80854}.secondary{background:#242b33;border-color:#343b44}.secondary:hover{background:#2d353e}
57
58
  .note{margin:12px 0 0!important;color:#ffffff73!important;font-size:11px!important}.error-code{display:block;margin-top:20px;padding:12px;border:1px solid #d1005650;border-radius:4px;background:#d1005615;color:var(--error);font:12px/1.55 Consolas,monospace;overflow-wrap:anywhere}
@@ -131,19 +132,63 @@ function challenge(verifier) {
131
132
  throw new HostedOAuthError("Invalid PKCE verifier", 400, "invalid_grant");
132
133
  return createHash("sha256").update(verifier).digest("base64url");
133
134
  }
135
+ async function safeResponseExcerpt(response) {
136
+ let value;
137
+ try {
138
+ value = await response.clone().text();
139
+ }
140
+ catch {
141
+ return "[unreadable response body]";
142
+ }
143
+ return value.slice(0, 2048)
144
+ .replace(/Bearer\s+[^\s"'<]+/gi, "Bearer [redacted]")
145
+ .replace(/(access_token|refresh_token|id_token|client_secret|authorization|assertion|password|code|state)(["']?\s*[:=]\s*["']?)[^"',\s<}&]+/gi, "$1$2[redacted]");
146
+ }
147
+ async function providerMetadata(url) {
148
+ const attempts = 4;
149
+ for (let attempt = 1; attempt <= attempts; attempt++) {
150
+ let response;
151
+ try {
152
+ response = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(10000) });
153
+ }
154
+ catch (error) {
155
+ if (attempt === attempts)
156
+ throw new Error(`OAuth provider metadata request failed for ${url}`, { cause: error });
157
+ console.error(`[modlens] oauth ${JSON.stringify({ event: "provider_metadata_retry", endpoint: url.toString(),
158
+ attempt, reason: error instanceof Error ? error.name : "request_error" })}`);
159
+ await new Promise(resolve => setTimeout(resolve, 500 * 2 ** (attempt - 1)));
160
+ continue;
161
+ }
162
+ console.error(`[modlens] oauth ${JSON.stringify({ event: "provider_metadata_response", endpoint: url.toString(),
163
+ attempt, status: response.status, contentType: response.headers.get("content-type") ?? "",
164
+ ...(response.ok ? {} : { response: await safeResponseExcerpt(response) }) })}`);
165
+ if (response.ok || response.status === 404 || (response.status !== 429 && response.status < 500))
166
+ return response;
167
+ if (attempt === attempts)
168
+ return response;
169
+ console.error(`[modlens] oauth ${JSON.stringify({ event: "provider_metadata_retry", endpoint: url.toString(),
170
+ attempt, status: response.status })}`);
171
+ await response.body?.cancel();
172
+ await new Promise(resolve => setTimeout(resolve, 500 * 2 ** (attempt - 1)));
173
+ }
174
+ throw new Error("OAuth provider metadata retry loop ended unexpectedly");
175
+ }
134
176
  export class HostedOAuth {
135
177
  config;
136
178
  database;
137
179
  ready;
180
+ instance = randomBytes(6).toString("hex");
138
181
  requestCounts = new Map();
182
+ refreshes = new Map();
139
183
  constructor(config, database) {
140
184
  this.config = config;
141
185
  this.database = database;
142
186
  }
143
187
  audit(event, fields = {}) {
144
- console.error(`[modlens] oauth ${JSON.stringify({ event, ...fields })}`);
188
+ console.error(`[modlens] oauth ${JSON.stringify({ event, instance: this.instance, ...fields })}`);
145
189
  }
146
190
  clientRef(clientId) { return sha(clientId).slice(0, 12); }
191
+ grantRef(grantId) { return sha(grantId).slice(0, 12); }
147
192
  logError(method, route, error) {
148
193
  const incident = randomBytes(6).toString("hex");
149
194
  this.audit("request_failed", { incident, flow: error.flowId, method: method ?? "UNKNOWN", route,
@@ -169,12 +214,14 @@ export class HostedOAuth {
169
214
  if (!!requiredField !== (env.MODLENS_OAUTH_REQUIRED_VALUE !== undefined))
170
215
  throw new Error("MODLENS_OAUTH_REQUIRED_FIELD and MODLENS_OAUTH_REQUIRED_VALUE must be configured together");
171
216
  const path = new URL(issuer).pathname.replace(/\/$/, "");
172
- const metadataUrl = new URL(`/.well-known/oauth-authorization-server${path === "/" ? "" : path}`, issuer);
173
- let response = await fetch(metadataUrl, { redirect: "error", signal: AbortSignal.timeout(10000) });
174
- if (response.status === 404)
175
- response = await fetch(new URL(".well-known/openid-configuration", issuer + "/"), { redirect: "error", signal: AbortSignal.timeout(10000) });
217
+ let metadataUrl = new URL(`/.well-known/oauth-authorization-server${path === "/" ? "" : path}`, issuer);
218
+ let response = await providerMetadata(metadataUrl);
219
+ if (response.status === 404) {
220
+ metadataUrl = new URL(".well-known/openid-configuration", issuer + "/");
221
+ response = await providerMetadata(metadataUrl);
222
+ }
176
223
  if (!response.ok)
177
- throw new Error("OAuth provider metadata unavailable");
224
+ throw new Error(`OAuth provider metadata unavailable: HTTP ${response.status} from ${metadataUrl}`);
178
225
  const metadata = await response.json();
179
226
  if (metadata.issuer !== issuer)
180
227
  throw new Error("OAuth provider issuer mismatch");
@@ -193,6 +240,8 @@ export class HostedOAuth {
193
240
  this.ready = (async () => {
194
241
  await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_clients (
195
242
  id TEXT PRIMARY KEY, redirect_uris TEXT NOT NULL, created BIGINT NOT NULL)`);
243
+ await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_client_metadata (
244
+ id TEXT PRIMARY KEY, client_name TEXT NOT NULL)`);
196
245
  await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_objects (
197
246
  id TEXT PRIMARY KEY, kind TEXT NOT NULL, payload TEXT NOT NULL, expires BIGINT NOT NULL)`);
198
247
  await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_grants (
@@ -252,6 +301,11 @@ export class HostedOAuth {
252
301
  throw new HostedOAuthError("Unknown OAuth client", 400, "invalid_client");
253
302
  return JSON.parse(rows[0].redirect_uris);
254
303
  }
304
+ async clientName(clientId) {
305
+ const db = await this.db();
306
+ const rows = await db.$queryRawUnsafe(`SELECT client_name FROM hosted_oauth_client_metadata WHERE id=$1`, clientId);
307
+ return rows[0]?.client_name;
308
+ }
255
309
  async upstreamToken(values) {
256
310
  const headers = { "Content-Type": "application/x-www-form-urlencoded" };
257
311
  values.set("client_id", this.config.clientId);
@@ -265,8 +319,12 @@ export class HostedOAuth {
265
319
  catch {
266
320
  throw new HostedOAuthError("Provider token endpoint unavailable", 503, "temporarily_unavailable");
267
321
  }
268
- if (!response.ok)
322
+ if (!response.ok) {
323
+ this.audit("provider_token_response", { endpoint: this.config.tokenUrl, grantType: values.get("grant_type") ?? "unknown",
324
+ status: response.status, contentType: response.headers.get("content-type") ?? "",
325
+ response: await safeResponseExcerpt(response) });
269
326
  throw new HostedOAuthError("Provider token exchange failed", response.status >= 500 ? 503 : 401, "invalid_grant");
327
+ }
270
328
  let data;
271
329
  try {
272
330
  data = await response.json();
@@ -288,8 +346,11 @@ export class HostedOAuth {
288
346
  catch {
289
347
  throw new HostedOAuthError("Provider profile unavailable", 503, "temporarily_unavailable");
290
348
  }
291
- if (!response.ok)
349
+ if (!response.ok) {
350
+ this.audit("provider_profile_response", { endpoint: this.config.profileUrl, status: response.status,
351
+ contentType: response.headers.get("content-type") ?? "", bodyLogged: false });
292
352
  throw new HostedOAuthError("Provider profile unavailable", response.status === 401 || response.status === 403 ? 401 : 503, response.status === 401 || response.status === 403 ? "invalid_token" : "temporarily_unavailable");
353
+ }
293
354
  let data;
294
355
  try {
295
356
  data = await response.json();
@@ -356,21 +417,22 @@ export class HostedOAuth {
356
417
  try {
357
418
  const profile = await this.profile(this.open(grant.upstream_access));
358
419
  if (!profile.allowed || profile.subject !== grant.subject) {
359
- await this.revoke(grant.id);
420
+ await this.revoke(grant.id, "profile_access_denied");
360
421
  throw new HostedOAuthError("Account access denied", 403, "access_denied");
361
422
  }
362
423
  }
363
424
  catch (error) {
364
425
  if (error instanceof HostedOAuthError && error.status === 401)
365
- await this.revoke(grant.id);
426
+ await this.revoke(grant.id, "profile_unauthorized");
366
427
  throw error;
367
428
  }
368
429
  return sha(this.config.issuer + "\0" + grant.subject);
369
430
  }
370
- async revoke(id) {
431
+ async revoke(id, reason) {
371
432
  const db = await this.db();
372
433
  await db.$executeRawUnsafe(`DELETE FROM hosted_oauth_access WHERE grant_id=$1`, id);
373
434
  await db.$executeRawUnsafe(`DELETE FROM hosted_oauth_grants WHERE id=$1`, id);
435
+ this.audit("grant_revoked", { grant: this.grantRef(id), reason });
374
436
  }
375
437
  async issue(grant) {
376
438
  const rawAccess = token("mla_");
@@ -379,6 +441,57 @@ export class HostedOAuth {
379
441
  await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_access (token_hash,grant_id,expires) VALUES ($1,$2,$3)`, sha(rawAccess), grant.id, now() + expires);
380
442
  return { access_token: rawAccess, token_type: "Bearer", expires_in: expires, scope: "modlens" };
381
443
  }
444
+ async refresh(clientId, raw) {
445
+ const db = await this.db();
446
+ const rows = await db.$queryRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=NULL
447
+ WHERE refresh_hash=$1 AND client_id=$2 AND refresh_expires>$3 RETURNING *`, sha(raw), clientId, now());
448
+ if (rows.length !== 1 || !rows[0].upstream_refresh) {
449
+ this.audit("refresh_rejected", { client: this.clientRef(clientId), refreshRef: sha(raw).slice(0, 12),
450
+ reason: rows.length === 1 ? "no_upstream_refresh" : "not_current_or_missing" });
451
+ throw new HostedOAuthError("Invalid refresh token", 400, "invalid_grant");
452
+ }
453
+ const grant = rows[0];
454
+ let stage = "provider_token";
455
+ try {
456
+ const oldUpstreamRefresh = grant.upstream_refresh;
457
+ const upstream = await this.upstreamToken(new URLSearchParams({ grant_type: "refresh_token",
458
+ refresh_token: this.open(oldUpstreamRefresh) }));
459
+ grant.upstream_access = this.seal(upstream.access_token);
460
+ grant.upstream_refresh = this.seal(upstream.refresh_token ?? this.open(oldUpstreamRefresh));
461
+ grant.upstream_expires = now() + Math.max(1, Math.min(86400, Number(upstream.expires_in) || 300));
462
+ stage = "grant_update";
463
+ await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET upstream_access=$2,upstream_refresh=$3,
464
+ upstream_expires=$4 WHERE id=$1`, grant.id, grant.upstream_access, grant.upstream_refresh, grant.upstream_expires);
465
+ stage = "profile";
466
+ const profile = await this.profile(upstream.access_token);
467
+ if (profile.subject !== grant.subject || !profile.allowed)
468
+ throw new HostedOAuthError("Account access denied", 403, "access_denied");
469
+ const next = token("mlr_");
470
+ grant.refresh_hash = sha(next);
471
+ stage = "rotation";
472
+ await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=$2 WHERE id=$1`, grant.id, grant.refresh_hash);
473
+ stage = "access_issue";
474
+ const access = await this.issue(grant);
475
+ const response = { ...access, refresh_token: next };
476
+ this.audit("refresh_issued", { client: this.clientRef(clientId), grant: this.grantRef(grant.id),
477
+ previousRefreshRef: sha(raw).slice(0, 12), refreshRef: sha(next).slice(0, 12),
478
+ accessExpiresIn: Number(access.expires_in) });
479
+ return response;
480
+ }
481
+ catch (error) {
482
+ const retryable = error instanceof HostedOAuthError && error.status === 503;
483
+ this.audit("refresh_failed", { client: this.clientRef(clientId), grant: this.grantRef(grant.id),
484
+ refreshRef: sha(raw).slice(0, 12), stage, retryable,
485
+ status: error instanceof HostedOAuthError ? error.status : undefined,
486
+ code: error instanceof HostedOAuthError ? error.code : undefined });
487
+ if (retryable) {
488
+ await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=$2 WHERE id=$1 AND refresh_hash IS NULL`, grant.id, sha(raw));
489
+ }
490
+ else
491
+ await this.revoke(grant.id, "refresh_failed");
492
+ throw error;
493
+ }
494
+ }
382
495
  limit(req, route, maximum) {
383
496
  const minute = Math.floor(now() / 60);
384
497
  const key = `${route}:${req.socket.remoteAddress ?? "unknown"}`;
@@ -427,12 +540,19 @@ export class HostedOAuth {
427
540
  !redirects.every(value => typeof value === "string" && callbackSafe(value)) ||
428
541
  (registration.token_endpoint_auth_method && registration.token_endpoint_auth_method !== "none"))
429
542
  throw new HostedOAuthError("Invalid client registration");
543
+ const clientName = registration.client_name;
544
+ if (clientName !== undefined && (typeof clientName !== "string" || !clientName.trim() ||
545
+ clientName.length > 120 || /[\x00-\x1f\x7f]/.test(clientName)))
546
+ throw new HostedOAuthError("Invalid client name");
430
547
  const id = token("mlc_");
431
548
  const db = await this.db();
432
549
  await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_clients (id,redirect_uris,created) VALUES ($1,$2,$3)`, id, JSON.stringify(redirects), now());
550
+ if (clientName !== undefined)
551
+ await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_client_metadata (id,client_name) VALUES ($1,$2)`, id, clientName);
433
552
  this.audit("client_registered", { client: this.clientRef(id), redirects: redirects.length });
434
553
  json(res, 201, { client_id: id, redirect_uris: redirects, grant_types: ["authorization_code", "refresh_token"],
435
- response_types: ["code"], token_endpoint_auth_method: "none" });
554
+ response_types: ["code"], token_endpoint_auth_method: "none",
555
+ ...(clientName === undefined ? {} : { client_name: clientName }) });
436
556
  return true;
437
557
  }
438
558
  if (route === "/oauth/authorize" && req.method === "GET") {
@@ -493,13 +613,16 @@ export class HostedOAuth {
493
613
  const approval = await this.putObject("approval", { ...state, upstream, subject: profile.subject }, 600);
494
614
  this.audit("consent_presented", { flow: state.flowId, client: this.clientRef(state.clientId),
495
615
  redirectOrigin: callback.origin });
616
+ const clientName = await this.clientName(state.clientId);
496
617
  const destination = escapeHtml(callback.origin);
497
618
  const content = `<h1>Allow access?</h1><p>The application below wants to connect to your hosted ModLens account.</p>`
498
- + `<div class="app"><span class="app-icon" aria-hidden="true">&gt;_</span><span class="app-copy"><strong class="app-origin">${destination}</strong><small class="app-label">Requesting application</small></span></div>`
619
+ + `<div class="app"><span class="app-icon" aria-hidden="true">&gt;_</span><span class="app-copy"><strong class="app-name">${clientName ? escapeHtml(clientName) : "Unnamed application"}</strong><small class="app-label">${clientName ? "Name supplied by application" : "No application name supplied"}</small><span class="app-origin">Callback: ${destination}</span></span></div>`
499
620
  + `<ul class="permissions"><li>Run hosted ModLens tools on your behalf</li><li>Use your hosted account allowance</li></ul>`
500
621
  + `<form method="post" action="/oauth/approve"><input type="hidden" name="approval" value="${escapeHtml(approval)}"><div class="actions"><button class="secondary" name="decision" value="deny">Cancel</button><button class="primary" name="decision" value="allow">Allow access</button></div></form>`
501
622
  + `<p class="note">Only continue if you started this request in the app.</p>`;
502
- res.writeHead(200, htmlHeaders);
623
+ // Browsers may apply form-action to the redirect after approval as well as the POST.
624
+ res.writeHead(200, { ...htmlHeaders,
625
+ "Content-Security-Policy": htmlCsp(`'self' ${callback.origin}`) });
503
626
  res.end(page("Connect", content));
504
627
  return true;
505
628
  }
@@ -562,8 +685,11 @@ export class HostedOAuth {
562
685
  const db = await this.db();
563
686
  await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_grants (id,client_id,subject,resource,upstream_access,upstream_refresh,
564
687
  upstream_expires,refresh_hash,refresh_expires) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, grant.id, grant.client_id, grant.subject, grant.resource, grant.upstream_access, grant.upstream_refresh, grant.upstream_expires, grant.refresh_hash, grant.refresh_expires);
565
- const response = { ...await this.issue(grant), ...(refresh ? { refresh_token: refresh } : {}) };
566
- this.audit("token_issued", { flow: code.flowId, client: this.clientRef(clientId), refresh: !!refresh });
688
+ const access = await this.issue(grant);
689
+ const response = { ...access, ...(refresh ? { refresh_token: refresh } : {}) };
690
+ this.audit("token_issued", { flow: code.flowId, client: this.clientRef(clientId), refresh: !!refresh,
691
+ grant: this.grantRef(grant.id), accessExpiresIn: Number(access.expires_in),
692
+ ...(refresh ? { refreshRef: sha(refresh).slice(0, 12) } : {}) });
567
693
  json(res, 200, response);
568
694
  return true;
569
695
  }
@@ -573,37 +699,17 @@ export class HostedOAuth {
573
699
  if (params.has("scope") && params.get("scope") !== "modlens")
574
700
  throw new HostedOAuthError("Unsupported scope", 400, "invalid_scope");
575
701
  const raw = required(params, "refresh_token");
576
- const db = await this.db();
577
- const rows = await db.$queryRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=NULL
578
- WHERE refresh_hash=$1 AND client_id=$2 AND refresh_expires>$3 RETURNING *`, sha(raw), clientId, now());
579
- if (rows.length !== 1 || !rows[0].upstream_refresh)
580
- throw new HostedOAuthError("Invalid refresh token", 400, "invalid_grant");
581
- const grant = rows[0];
582
- try {
583
- const oldUpstreamRefresh = grant.upstream_refresh;
584
- const upstream = await this.upstreamToken(new URLSearchParams({ grant_type: "refresh_token",
585
- refresh_token: this.open(oldUpstreamRefresh) }));
586
- grant.upstream_access = this.seal(upstream.access_token);
587
- grant.upstream_refresh = this.seal(upstream.refresh_token ?? this.open(oldUpstreamRefresh));
588
- grant.upstream_expires = now() + Math.max(1, Math.min(86400, Number(upstream.expires_in) || 300));
589
- await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET upstream_access=$2,upstream_refresh=$3,
590
- upstream_expires=$4 WHERE id=$1`, grant.id, grant.upstream_access, grant.upstream_refresh, grant.upstream_expires);
591
- const profile = await this.profile(upstream.access_token);
592
- if (profile.subject !== grant.subject || !profile.allowed)
593
- throw new HostedOAuthError("Account access denied", 403, "access_denied");
594
- const next = token("mlr_");
595
- grant.refresh_hash = sha(next);
596
- await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=$2 WHERE id=$1`, grant.id, grant.refresh_hash);
597
- json(res, 200, { ...await this.issue(grant), refresh_token: next });
598
- }
599
- catch (error) {
600
- if (error instanceof HostedOAuthError && error.status === 503) {
601
- await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=$2 WHERE id=$1 AND refresh_hash IS NULL`, grant.id, sha(raw));
602
- }
603
- else
604
- await this.revoke(grant.id);
605
- throw error;
702
+ const key = `${clientId}:${sha(raw)}`;
703
+ let pending = this.refreshes.get(key);
704
+ if (!pending) {
705
+ this.audit("refresh_started", { client: this.clientRef(clientId), refreshRef: sha(raw).slice(0, 12) });
706
+ pending = this.refresh(clientId, raw);
707
+ this.refreshes.set(key, pending);
708
+ void pending.finally(() => this.refreshes.delete(key)).catch(() => { });
606
709
  }
710
+ else
711
+ this.audit("refresh_joined", { client: this.clientRef(clientId), refreshRef: sha(raw).slice(0, 12) });
712
+ json(res, 200, await pending);
607
713
  return true;
608
714
  }
609
715
  throw new HostedOAuthError("Unsupported grant type", 400, "unsupported_grant_type");
@@ -616,12 +722,12 @@ export class HostedOAuth {
616
722
  const db = await this.db();
617
723
  const rows = await db.$queryRawUnsafe(`SELECT id FROM hosted_oauth_grants WHERE client_id=$1 AND refresh_hash=$2`, clientId, sha(raw));
618
724
  if (rows.length)
619
- await this.revoke(rows[0].id);
725
+ await this.revoke(rows[0].id, "client_request");
620
726
  else {
621
727
  const access = await db.$queryRawUnsafe(`SELECT g.id FROM hosted_oauth_access a JOIN hosted_oauth_grants g ON g.id=a.grant_id
622
728
  WHERE g.client_id=$1 AND a.token_hash=$2`, clientId, sha(raw));
623
729
  if (access.length)
624
- await this.revoke(access[0].id);
730
+ await this.revoke(access[0].id, "client_request");
625
731
  }
626
732
  json(res, 200, {});
627
733
  return true;