@creeperhost/modlens-mcp 1.6.24 → 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.
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).
@@ -1105,6 +1106,8 @@ npx -y @creeperhost/modlens-mcp --local-mod --request-file local-request.json
1105
1106
 
1106
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.
1107
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
+
1108
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`.
1109
1112
 
1110
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;AA2JD,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;YAqC5G,EAAE;IAkBhB,OAAO,CAAC,IAAI;IAOZ,OAAO,CAAC,IAAI;YAOE,SAAS;YAST,WAAW;YAOX,SAAS;YAOT,UAAU;YAWV,MAAM;YAQN,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;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}
@@ -176,15 +177,18 @@ export class HostedOAuth {
176
177
  config;
177
178
  database;
178
179
  ready;
180
+ instance = randomBytes(6).toString("hex");
179
181
  requestCounts = new Map();
182
+ refreshes = new Map();
180
183
  constructor(config, database) {
181
184
  this.config = config;
182
185
  this.database = database;
183
186
  }
184
187
  audit(event, fields = {}) {
185
- console.error(`[modlens] oauth ${JSON.stringify({ event, ...fields })}`);
188
+ console.error(`[modlens] oauth ${JSON.stringify({ event, instance: this.instance, ...fields })}`);
186
189
  }
187
190
  clientRef(clientId) { return sha(clientId).slice(0, 12); }
191
+ grantRef(grantId) { return sha(grantId).slice(0, 12); }
188
192
  logError(method, route, error) {
189
193
  const incident = randomBytes(6).toString("hex");
190
194
  this.audit("request_failed", { incident, flow: error.flowId, method: method ?? "UNKNOWN", route,
@@ -236,6 +240,8 @@ export class HostedOAuth {
236
240
  this.ready = (async () => {
237
241
  await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_clients (
238
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)`);
239
245
  await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_objects (
240
246
  id TEXT PRIMARY KEY, kind TEXT NOT NULL, payload TEXT NOT NULL, expires BIGINT NOT NULL)`);
241
247
  await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_grants (
@@ -295,6 +301,11 @@ export class HostedOAuth {
295
301
  throw new HostedOAuthError("Unknown OAuth client", 400, "invalid_client");
296
302
  return JSON.parse(rows[0].redirect_uris);
297
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
+ }
298
309
  async upstreamToken(values) {
299
310
  const headers = { "Content-Type": "application/x-www-form-urlencoded" };
300
311
  values.set("client_id", this.config.clientId);
@@ -406,21 +417,22 @@ export class HostedOAuth {
406
417
  try {
407
418
  const profile = await this.profile(this.open(grant.upstream_access));
408
419
  if (!profile.allowed || profile.subject !== grant.subject) {
409
- await this.revoke(grant.id);
420
+ await this.revoke(grant.id, "profile_access_denied");
410
421
  throw new HostedOAuthError("Account access denied", 403, "access_denied");
411
422
  }
412
423
  }
413
424
  catch (error) {
414
425
  if (error instanceof HostedOAuthError && error.status === 401)
415
- await this.revoke(grant.id);
426
+ await this.revoke(grant.id, "profile_unauthorized");
416
427
  throw error;
417
428
  }
418
429
  return sha(this.config.issuer + "\0" + grant.subject);
419
430
  }
420
- async revoke(id) {
431
+ async revoke(id, reason) {
421
432
  const db = await this.db();
422
433
  await db.$executeRawUnsafe(`DELETE FROM hosted_oauth_access WHERE grant_id=$1`, id);
423
434
  await db.$executeRawUnsafe(`DELETE FROM hosted_oauth_grants WHERE id=$1`, id);
435
+ this.audit("grant_revoked", { grant: this.grantRef(id), reason });
424
436
  }
425
437
  async issue(grant) {
426
438
  const rawAccess = token("mla_");
@@ -429,6 +441,57 @@ export class HostedOAuth {
429
441
  await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_access (token_hash,grant_id,expires) VALUES ($1,$2,$3)`, sha(rawAccess), grant.id, now() + expires);
430
442
  return { access_token: rawAccess, token_type: "Bearer", expires_in: expires, scope: "modlens" };
431
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
+ }
432
495
  limit(req, route, maximum) {
433
496
  const minute = Math.floor(now() / 60);
434
497
  const key = `${route}:${req.socket.remoteAddress ?? "unknown"}`;
@@ -477,12 +540,19 @@ export class HostedOAuth {
477
540
  !redirects.every(value => typeof value === "string" && callbackSafe(value)) ||
478
541
  (registration.token_endpoint_auth_method && registration.token_endpoint_auth_method !== "none"))
479
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");
480
547
  const id = token("mlc_");
481
548
  const db = await this.db();
482
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);
483
552
  this.audit("client_registered", { client: this.clientRef(id), redirects: redirects.length });
484
553
  json(res, 201, { client_id: id, redirect_uris: redirects, grant_types: ["authorization_code", "refresh_token"],
485
- response_types: ["code"], token_endpoint_auth_method: "none" });
554
+ response_types: ["code"], token_endpoint_auth_method: "none",
555
+ ...(clientName === undefined ? {} : { client_name: clientName }) });
486
556
  return true;
487
557
  }
488
558
  if (route === "/oauth/authorize" && req.method === "GET") {
@@ -543,13 +613,16 @@ export class HostedOAuth {
543
613
  const approval = await this.putObject("approval", { ...state, upstream, subject: profile.subject }, 600);
544
614
  this.audit("consent_presented", { flow: state.flowId, client: this.clientRef(state.clientId),
545
615
  redirectOrigin: callback.origin });
616
+ const clientName = await this.clientName(state.clientId);
546
617
  const destination = escapeHtml(callback.origin);
547
618
  const content = `<h1>Allow access?</h1><p>The application below wants to connect to your hosted ModLens account.</p>`
548
- + `<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>`
549
620
  + `<ul class="permissions"><li>Run hosted ModLens tools on your behalf</li><li>Use your hosted account allowance</li></ul>`
550
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>`
551
622
  + `<p class="note">Only continue if you started this request in the app.</p>`;
552
- 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}`) });
553
626
  res.end(page("Connect", content));
554
627
  return true;
555
628
  }
@@ -612,8 +685,11 @@ export class HostedOAuth {
612
685
  const db = await this.db();
613
686
  await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_grants (id,client_id,subject,resource,upstream_access,upstream_refresh,
614
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);
615
- const response = { ...await this.issue(grant), ...(refresh ? { refresh_token: refresh } : {}) };
616
- 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) } : {}) });
617
693
  json(res, 200, response);
618
694
  return true;
619
695
  }
@@ -623,37 +699,17 @@ export class HostedOAuth {
623
699
  if (params.has("scope") && params.get("scope") !== "modlens")
624
700
  throw new HostedOAuthError("Unsupported scope", 400, "invalid_scope");
625
701
  const raw = required(params, "refresh_token");
626
- const db = await this.db();
627
- const rows = await db.$queryRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=NULL
628
- WHERE refresh_hash=$1 AND client_id=$2 AND refresh_expires>$3 RETURNING *`, sha(raw), clientId, now());
629
- if (rows.length !== 1 || !rows[0].upstream_refresh)
630
- throw new HostedOAuthError("Invalid refresh token", 400, "invalid_grant");
631
- const grant = rows[0];
632
- try {
633
- const oldUpstreamRefresh = grant.upstream_refresh;
634
- const upstream = await this.upstreamToken(new URLSearchParams({ grant_type: "refresh_token",
635
- refresh_token: this.open(oldUpstreamRefresh) }));
636
- grant.upstream_access = this.seal(upstream.access_token);
637
- grant.upstream_refresh = this.seal(upstream.refresh_token ?? this.open(oldUpstreamRefresh));
638
- grant.upstream_expires = now() + Math.max(1, Math.min(86400, Number(upstream.expires_in) || 300));
639
- await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET upstream_access=$2,upstream_refresh=$3,
640
- upstream_expires=$4 WHERE id=$1`, grant.id, grant.upstream_access, grant.upstream_refresh, grant.upstream_expires);
641
- const profile = await this.profile(upstream.access_token);
642
- if (profile.subject !== grant.subject || !profile.allowed)
643
- throw new HostedOAuthError("Account access denied", 403, "access_denied");
644
- const next = token("mlr_");
645
- grant.refresh_hash = sha(next);
646
- await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=$2 WHERE id=$1`, grant.id, grant.refresh_hash);
647
- json(res, 200, { ...await this.issue(grant), refresh_token: next });
648
- }
649
- catch (error) {
650
- if (error instanceof HostedOAuthError && error.status === 503) {
651
- await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=$2 WHERE id=$1 AND refresh_hash IS NULL`, grant.id, sha(raw));
652
- }
653
- else
654
- await this.revoke(grant.id);
655
- 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(() => { });
656
709
  }
710
+ else
711
+ this.audit("refresh_joined", { client: this.clientRef(clientId), refreshRef: sha(raw).slice(0, 12) });
712
+ json(res, 200, await pending);
657
713
  return true;
658
714
  }
659
715
  throw new HostedOAuthError("Unsupported grant type", 400, "unsupported_grant_type");
@@ -666,12 +722,12 @@ export class HostedOAuth {
666
722
  const db = await this.db();
667
723
  const rows = await db.$queryRawUnsafe(`SELECT id FROM hosted_oauth_grants WHERE client_id=$1 AND refresh_hash=$2`, clientId, sha(raw));
668
724
  if (rows.length)
669
- await this.revoke(rows[0].id);
725
+ await this.revoke(rows[0].id, "client_request");
670
726
  else {
671
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
672
728
  WHERE g.client_id=$1 AND a.token_hash=$2`, clientId, sha(raw));
673
729
  if (access.length)
674
- await this.revoke(access[0].id);
730
+ await this.revoke(access[0].id, "client_request");
675
731
  }
676
732
  json(res, 200, {});
677
733
  return true;