@n24q02m/hull 0.4.0 → 0.5.0-beta.1

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 (169) hide show
  1. package/README.md +47 -16
  2. package/build/cli/build-cli.d.ts +5 -6
  3. package/build/cli/build-cli.d.ts.map +1 -1
  4. package/build/cli/build-cli.js +4 -61
  5. package/build/cli/build-cli.js.map +1 -1
  6. package/build/crypto/index.d.ts +1 -1
  7. package/build/crypto/index.d.ts.map +1 -1
  8. package/build/crypto/index.js +1 -1
  9. package/build/crypto/index.js.map +1 -1
  10. package/build/crypto/kdf.d.ts +0 -13
  11. package/build/crypto/kdf.d.ts.map +1 -1
  12. package/build/crypto/kdf.js +3 -18
  13. package/build/crypto/kdf.js.map +1 -1
  14. package/build/index.d.ts +1 -11
  15. package/build/index.d.ts.map +1 -1
  16. package/build/index.js +4 -16
  17. package/build/index.js.map +1 -1
  18. package/build/storage/account-registry.d.ts +53 -0
  19. package/build/storage/account-registry.d.ts.map +1 -0
  20. package/build/storage/account-registry.js +148 -0
  21. package/build/storage/account-registry.js.map +1 -0
  22. package/build/storage/backends.d.ts +0 -26
  23. package/build/storage/backends.d.ts.map +1 -1
  24. package/build/storage/backends.js +0 -65
  25. package/build/storage/backends.js.map +1 -1
  26. package/build/storage/config-file.d.ts +2 -19
  27. package/build/storage/config-file.d.ts.map +1 -1
  28. package/build/storage/config-file.js +2 -23
  29. package/build/storage/config-file.js.map +1 -1
  30. package/build/storage/credential-store.d.ts +1 -9
  31. package/build/storage/credential-store.d.ts.map +1 -1
  32. package/build/storage/credential-store.js +4 -16
  33. package/build/storage/credential-store.js.map +1 -1
  34. package/build/storage/data-dir.d.ts +2 -2
  35. package/build/storage/data-dir.js +2 -2
  36. package/build/storage/encryption.d.ts.map +1 -1
  37. package/build/storage/encryption.js +4 -0
  38. package/build/storage/encryption.js.map +1 -1
  39. package/build/storage/index.d.ts +5 -3
  40. package/build/storage/index.d.ts.map +1 -1
  41. package/build/storage/index.js +5 -3
  42. package/build/storage/index.js.map +1 -1
  43. package/build/storage/key-pool-crypto.d.ts +44 -0
  44. package/build/storage/key-pool-crypto.d.ts.map +1 -0
  45. package/build/storage/key-pool-crypto.js +132 -0
  46. package/build/storage/key-pool-crypto.js.map +1 -0
  47. package/build/storage/key-pool-store.d.ts +134 -0
  48. package/build/storage/key-pool-store.d.ts.map +1 -0
  49. package/build/storage/key-pool-store.js +470 -0
  50. package/build/storage/key-pool-store.js.map +1 -0
  51. package/build/storage/key-pool.d.ts +172 -0
  52. package/build/storage/key-pool.d.ts.map +1 -0
  53. package/build/storage/key-pool.js +374 -0
  54. package/build/storage/key-pool.js.map +1 -0
  55. package/build/storage/resolver.js +1 -1
  56. package/build/storage/resolver.js.map +1 -1
  57. package/build/token-auth/index.d.ts +1 -1
  58. package/build/token-auth/index.js +1 -1
  59. package/build/transport/index.d.ts +1 -2
  60. package/build/transport/index.d.ts.map +1 -1
  61. package/build/transport/index.js +1 -2
  62. package/build/transport/index.js.map +1 -1
  63. package/build/transport/local-server.d.ts +12 -128
  64. package/build/transport/local-server.d.ts.map +1 -1
  65. package/build/transport/local-server.js +20 -262
  66. package/build/transport/local-server.js.map +1 -1
  67. package/build/transport/streamable-http.d.ts +6 -7
  68. package/build/transport/streamable-http.d.ts.map +1 -1
  69. package/build/transport/streamable-http.js +6 -6
  70. package/build/transport/streamable-http.js.map +1 -1
  71. package/build/util/browser.d.ts.map +1 -0
  72. package/build/{relay → util}/browser.js +2 -2
  73. package/build/util/browser.js.map +1 -0
  74. package/package.json +4 -21
  75. package/build/auth/credential-form.d.ts +0 -163
  76. package/build/auth/credential-form.d.ts.map +0 -1
  77. package/build/auth/credential-form.js +0 -1869
  78. package/build/auth/credential-form.js.map +0 -1
  79. package/build/auth/delegated-oauth-app.d.ts +0 -90
  80. package/build/auth/delegated-oauth-app.d.ts.map +0 -1
  81. package/build/auth/delegated-oauth-app.js +0 -881
  82. package/build/auth/delegated-oauth-app.js.map +0 -1
  83. package/build/auth/index.d.ts +0 -12
  84. package/build/auth/index.d.ts.map +0 -1
  85. package/build/auth/index.js +0 -12
  86. package/build/auth/index.js.map +0 -1
  87. package/build/auth/local-oauth-app.d.ts +0 -152
  88. package/build/auth/local-oauth-app.d.ts.map +0 -1
  89. package/build/auth/local-oauth-app.js +0 -975
  90. package/build/auth/local-oauth-app.js.map +0 -1
  91. package/build/auth/relay-login.d.ts +0 -46
  92. package/build/auth/relay-login.d.ts.map +0 -1
  93. package/build/auth/relay-login.js +0 -197
  94. package/build/auth/relay-login.js.map +0 -1
  95. package/build/auth/router.d.ts +0 -18
  96. package/build/auth/router.d.ts.map +0 -1
  97. package/build/auth/router.js +0 -62
  98. package/build/auth/router.js.map +0 -1
  99. package/build/auth/session-store.d.ts +0 -49
  100. package/build/auth/session-store.d.ts.map +0 -1
  101. package/build/auth/session-store.js +0 -94
  102. package/build/auth/session-store.js.map +0 -1
  103. package/build/auth/stable-sub.d.ts +0 -27
  104. package/build/auth/stable-sub.d.ts.map +0 -1
  105. package/build/auth/stable-sub.js +0 -38
  106. package/build/auth/stable-sub.js.map +0 -1
  107. package/build/auth/well-known.d.ts +0 -4
  108. package/build/auth/well-known.d.ts.map +0 -1
  109. package/build/auth/well-known.js +0 -26
  110. package/build/auth/well-known.js.map +0 -1
  111. package/build/oauth/cache.d.ts +0 -22
  112. package/build/oauth/cache.d.ts.map +0 -1
  113. package/build/oauth/cache.js +0 -24
  114. package/build/oauth/cache.js.map +0 -1
  115. package/build/oauth/index.d.ts +0 -6
  116. package/build/oauth/index.d.ts.map +0 -1
  117. package/build/oauth/index.js +0 -6
  118. package/build/oauth/index.js.map +0 -1
  119. package/build/oauth/jwt-issuer.d.ts +0 -87
  120. package/build/oauth/jwt-issuer.d.ts.map +0 -1
  121. package/build/oauth/jwt-issuer.js +0 -205
  122. package/build/oauth/jwt-issuer.js.map +0 -1
  123. package/build/oauth/pkce.d.ts +0 -10
  124. package/build/oauth/pkce.d.ts.map +0 -1
  125. package/build/oauth/pkce.js +0 -34
  126. package/build/oauth/pkce.js.map +0 -1
  127. package/build/oauth/provider.d.ts +0 -52
  128. package/build/oauth/provider.d.ts.map +0 -1
  129. package/build/oauth/provider.js +0 -98
  130. package/build/oauth/provider.js.map +0 -1
  131. package/build/oauth/user-store.d.ts +0 -25
  132. package/build/oauth/user-store.d.ts.map +0 -1
  133. package/build/oauth/user-store.js +0 -85
  134. package/build/oauth/user-store.js.map +0 -1
  135. package/build/relay/browser.d.ts.map +0 -1
  136. package/build/relay/browser.js.map +0 -1
  137. package/build/relay/client.d.ts +0 -36
  138. package/build/relay/client.d.ts.map +0 -1
  139. package/build/relay/client.js +0 -135
  140. package/build/relay/client.js.map +0 -1
  141. package/build/relay/session.d.ts +0 -12
  142. package/build/relay/session.d.ts.map +0 -1
  143. package/build/relay/session.js +0 -55
  144. package/build/relay/session.js.map +0 -1
  145. package/build/relay/tool-helpers.d.ts +0 -85
  146. package/build/relay/tool-helpers.d.ts.map +0 -1
  147. package/build/relay/tool-helpers.js +0 -103
  148. package/build/relay/tool-helpers.js.map +0 -1
  149. package/build/relay/wordlist.d.ts +0 -2
  150. package/build/relay/wordlist.d.ts.map +0 -1
  151. package/build/relay/wordlist.js +0 -7782
  152. package/build/relay/wordlist.js.map +0 -1
  153. package/build/schema/types.d.ts +0 -67
  154. package/build/schema/types.d.ts.map +0 -1
  155. package/build/schema/types.js +0 -2
  156. package/build/schema/types.js.map +0 -1
  157. package/build/storage/mode.d.ts +0 -23
  158. package/build/storage/mode.d.ts.map +0 -1
  159. package/build/storage/mode.js +0 -46
  160. package/build/storage/mode.js.map +0 -1
  161. package/build/storage/session-lock.d.ts +0 -19
  162. package/build/storage/session-lock.d.ts.map +0 -1
  163. package/build/storage/session-lock.js +0 -105
  164. package/build/storage/session-lock.js.map +0 -1
  165. package/build/transport/oauth-middleware.d.ts +0 -45
  166. package/build/transport/oauth-middleware.d.ts.map +0 -1
  167. package/build/transport/oauth-middleware.js +0 -86
  168. package/build/transport/oauth-middleware.js.map +0 -1
  169. /package/build/{relay → util}/browser.d.ts +0 -0
@@ -1,975 +0,0 @@
1
- /**
2
- * Local OAuth 2.1 Authorization Server as an HTTP request handler.
3
- *
4
- * Provides a self-hosted Authorization Server for single-user MCP servers.
5
- * Implements the OAuth 2.1 PKCE flow with credential collection via a
6
- * browser-rendered form.
7
- *
8
- * Routes:
9
- * - GET / -- Auto-bootstrap PKCE then redirect to /authorize
10
- * - GET /authorize -- Render credential form
11
- * - POST /authorize -- Save credentials, return auth code
12
- * - POST /otp -- Submit multi-step credential (OTP / 2FA password)
13
- * - POST /token -- Exchange auth code + PKCE verifier for JWT
14
- * - GET /setup-status -- Poll background setup completion
15
- * - GET /callback-done -- Friendly "tab can be closed" page after PKCE callback
16
- * - GET /.well-known/oauth-authorization-server -- RFC 8414 metadata
17
- * - GET /.well-known/oauth-protected-resource -- RFC 9728 metadata
18
- *
19
- * The /mcp endpoint is NOT included -- it is mounted by the transport layer.
20
- *
21
- * This is a TypeScript port of core-py's ``local_oauth_app.py``. Behavior,
22
- * protocol, and TTL constants are kept identical for cross-language parity.
23
- */
24
- import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
25
- import { JWTIssuer } from '../oauth/jwt-issuer.js';
26
- import { markSetupComplete as markConfigSetupComplete } from '../storage/credential-store.js';
27
- import { isOAuthField, isSecretField, renderCredentialForm } from './credential-form.js';
28
- import { configureRelayLogin, createRelayLoginMiddleware, loginGetHandler, loginPostHandler } from './relay-login.js';
29
- import { createRouter, htmlResponse, jsonResponse, parseFormBody, parseJsonBody } from './router.js';
30
- import { deriveStableSub } from './stable-sub.js';
31
- import { authorizationServerMetadata, protectedResourceMetadata } from './well-known.js';
32
- // Auth codes and PKCE sessions expire after 10 minutes.
33
- const AUTH_CODE_TTL_S = 600;
34
- const SESSION_TTL_S = 600;
35
- // Multi-step auth (OTP / 2FA password) constraints.
36
- const OTP_TIMEOUT_S = 300;
37
- const OTP_MAX_ATTEMPTS = 5;
38
- /**
39
- * Verify PKCE S256: ``base64url(sha256(code_verifier)) == code_challenge``,
40
- * using a timing-safe comparison to prevent timing attacks.
41
- */
42
- function s256Verify(codeVerifier, codeChallenge) {
43
- const computed = createHash('sha256').update(codeVerifier, 'ascii').digest('base64url');
44
- const computedBuf = Buffer.from(computed, 'ascii');
45
- const challengeBuf = Buffer.from(codeChallenge, 'ascii');
46
- const isLengthEqual = computedBuf.length === challengeBuf.length;
47
- const compareBuf = isLengthEqual ? challengeBuf : computedBuf;
48
- try {
49
- return timingSafeEqual(computedBuf, compareBuf) && isLengthEqual;
50
- }
51
- catch {
52
- return false;
53
- }
54
- }
55
- /** Prune entries older than ``ttlMs`` milliseconds from an in-memory store. */
56
- function pruneExpired(store, ttlMs) {
57
- const now = Date.now();
58
- for (const [key, value] of store) {
59
- if (now - value.createdAt > ttlMs)
60
- store.delete(key);
61
- }
62
- }
63
- /**
64
- * Derive the public base URL of this request (protocol + host, no trailing slash).
65
- *
66
- * Resolution order:
67
- * 1. ``PUBLIC_URL`` env var -- trusted, explicit. This is the remote-deploy
68
- * convention (oci-vm-prod) where the container sits behind CF Tunnel ->
69
- * Caddy (HTTP internal) but is served to clients over HTTPS. Without this,
70
- * OAuth 2.1 metadata would leak ``http://`` as the issuer and strict
71
- * clients reject the discovery document.
72
- * 2. ``X-Forwarded-Proto`` header (first value) + Host header -- for reverse
73
- * proxies that forward the original scheme.
74
- * 3. Socket ``encrypted`` flag -- TLS-terminated at this process.
75
- * 4. ``http://<host>`` fallback -- plain local dev.
76
- */
77
- function getBaseUrl(req) {
78
- const publicUrl = process.env.PUBLIC_URL;
79
- if (publicUrl !== undefined && publicUrl.length > 0) {
80
- return publicUrl.replace(/\/+$/, '');
81
- }
82
- const host = req.headers.host ?? 'localhost';
83
- const encrypted = req.socket.encrypted === true;
84
- const forwardedProto = req.headers['x-forwarded-proto'];
85
- let protocol = encrypted ? 'https' : 'http';
86
- if (typeof forwardedProto === 'string' && forwardedProto.length > 0) {
87
- const commaIdx = forwardedProto.indexOf(',');
88
- protocol = (commaIdx !== -1 ? forwardedProto.substring(0, commaIdx) : forwardedProto).trim();
89
- }
90
- return `${protocol}://${host}`;
91
- }
92
- /**
93
- * Create OAuth 2.1 Authorization Server HTTP handler.
94
- *
95
- * Returns a handler compatible with ``http.createServer`` along with the
96
- * ``JWTIssuer`` (for the transport layer to verify Bearer tokens) and a
97
- * ``markSetupComplete`` function for background setup callbacks (e.g. GDrive
98
- * device code flow).
99
- */
100
- export async function createLocalOAuthApp(options) {
101
- const signingSecret = process.env.MCP_JWT_SIGNING_SECRET ?? process.env.CREDENTIAL_SECRET ?? null;
102
- const jwtIssuer = options.jwtIssuer ?? new JWTIssuer(options.serverName, undefined, signingSecret);
103
- await jwtIssuer.init();
104
- // In-memory stores keyed by nonce / auth_code. Each entry has a ``createdAt``
105
- // for TTL expiry.
106
- const pendingSessions = new Map();
107
- const authCodes = new Map();
108
- // Server-side prefill keyed by OAuth ``state`` (the PKCE state token chosen
109
- // by the client BEFORE the GET /authorize redirect). The E2E driver POSTs
110
- // skret-derived form values here so the URL it announces to the user does
111
- // NOT contain credential bytes. Without this, ``?prefill_TELEGRAM_PHONE=
112
- // %2B84...`` would land in browser history, server access logs, screenshots
113
- // and HTTP referrer headers — every place a URL leaks.
114
- const pendingPrefills = new Map();
115
- const PREFILL_TTL_S = 300;
116
- // Single-user local mode: one pending multi-step session at a time.
117
- let pendingStep = null;
118
- const setupStatus = { gdrive: 'idle' };
119
- // Edge auth password gate. When ``MCP_RELAY_PASSWORD`` is set, /authorize
120
- // GET + POST + /authorize/prefill are fronted by a cookie-session
121
- // middleware. Empty password disables the gate (single-user dev). See
122
- // spec ``2026-05-01-stdio-pure-http-multiuser §4.2.1``. Configured here
123
- // (not module-scope) so multi-app test setups don't leak state across
124
- // apps; the relay-login module itself keeps a single shared password
125
- // string, intentional because in deploy each container hosts one app.
126
- const relayPassword = process.env.MCP_RELAY_PASSWORD ?? '';
127
- configureRelayLogin(relayPassword);
128
- const relayMw = createRelayLoginMiddleware({ password: relayPassword });
129
- function markPendingStep(sub) {
130
- pendingStep = { active: true, createdAt: Date.now(), attempts: 0, sub };
131
- }
132
- function clearPendingStep() {
133
- pendingStep = null;
134
- }
135
- // ------------------------------------------------------------------
136
- // Route handlers
137
- // ------------------------------------------------------------------
138
- async function authorizeGet(req, res) {
139
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
140
- const params = url.searchParams;
141
- const clientId = params.get('client_id');
142
- const redirectUri = params.get('redirect_uri');
143
- const state = params.get('state');
144
- const codeChallenge = params.get('code_challenge');
145
- const codeChallengeMethod = params.get('code_challenge_method') ?? 'S256';
146
- if (!clientId || !redirectUri || !state || !codeChallenge) {
147
- jsonResponse(res, 400, {
148
- error: 'invalid_request',
149
- error_description: 'Missing required parameters'
150
- });
151
- return;
152
- }
153
- // Resolve prefill values for the form. Two channels, in priority order:
154
- // 1. Server-side store keyed by ``state`` — written by the E2E driver
155
- // via POST /authorize/prefill BEFORE the user opens the URL. This is
156
- // the safe channel; nothing leaves the server boundary.
157
- // 2. URL query string ``?prefill_<KEY>=<VALUE>`` — legacy fallback for
158
- // callers that have not migrated yet. Deprecated; emitted to
159
- // ``X-Prefill-Source: url`` so callers can find them via access logs.
160
- // Renderers receive the flat ``{KEY: VALUE}`` map and emit ``value="..."``
161
- // on matching inputs.
162
- const prefill = {};
163
- pruneExpired(pendingPrefills, PREFILL_TTL_S * 1000);
164
- const stored = pendingPrefills.get(state);
165
- if (stored) {
166
- // Explicit per-key copy (not Object.assign / spread) — values landed
167
- // here via authorizePrefill which coerces every value through String()
168
- // and rejects empty strings, but we restate the contract here so static
169
- // analyzers don't flag the merge as a mass-assignment sink.
170
- // nosemgrep: javascript.express.security.express-data-exfiltration.express-data-exfiltration
171
- for (const [k, v] of Object.entries(stored.data)) {
172
- prefill[k] = String(v);
173
- }
174
- pendingPrefills.delete(state);
175
- }
176
- else {
177
- params.forEach((value, key) => {
178
- if (key.startsWith('prefill_')) {
179
- prefill[key.slice('prefill_'.length)] = value;
180
- }
181
- });
182
- }
183
- const nonce = randomBytes(32).toString('base64url');
184
- // Generate a per-authorize-request subject here (not at /token time) so the
185
- // credential save callback and the eventual JWT share the same ``sub``. If
186
- // this were derived at /token, concurrent authorize requests would collide
187
- // on a static 'local-user' subject and leak credentials across users.
188
- const sub = randomBytes(16).toString('base64url');
189
- pendingSessions.set(nonce, {
190
- clientId,
191
- redirectUri,
192
- state,
193
- codeChallenge,
194
- codeChallengeMethod,
195
- createdAt: Date.now(),
196
- sub
197
- });
198
- pruneExpired(pendingSessions, SESSION_TTL_S * 1000);
199
- const base = getBaseUrl(req);
200
- const submitUrl = `${base}/authorize?nonce=${nonce}`;
201
- const html = options.customCredentialFormHtml !== undefined
202
- ? options.customCredentialFormHtml(options.relaySchema, { submitUrl, prefill })
203
- : renderCredentialForm(options.relaySchema, {
204
- submitUrl,
205
- prefill,
206
- includeUsernameField: options.stableSubEnabled === true
207
- });
208
- htmlResponse(res, 200, html);
209
- }
210
- async function authorizePost(req, res) {
211
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
212
- const nonce = url.searchParams.get('nonce');
213
- if (!nonce || !pendingSessions.has(nonce)) {
214
- jsonResponse(res, 400, {
215
- error: 'invalid_request',
216
- error_description: 'Invalid or expired nonce'
217
- });
218
- return;
219
- }
220
- const session = pendingSessions.get(nonce);
221
- pendingSessions.delete(nonce);
222
- if (Date.now() - session.createdAt > SESSION_TTL_S * 1000) {
223
- jsonResponse(res, 400, {
224
- error: 'invalid_request',
225
- error_description: 'Session expired'
226
- });
227
- return;
228
- }
229
- let credentials;
230
- try {
231
- credentials = await parseJsonBody(req);
232
- }
233
- catch {
234
- jsonResponse(res, 400, {
235
- error: 'invalid_request',
236
- error_description: 'Invalid JSON body'
237
- });
238
- return;
239
- }
240
- // Reserved partition key: remove it so it is never persisted as a provider
241
- // credential. When stable-sub is enabled and a username was supplied,
242
- // override the random per-authorize sub with a STABLE one derived from it
243
- // (same username -> same per-sub bucket across re-auth and devices).
244
- // Blank username or disabled flag -> keep the random sub (unchanged).
245
- const submittedUsername = String(credentials.__sub_username ?? '').trim();
246
- delete credentials.__sub_username;
247
- if (options.stableSubEnabled === true && submittedUsername) {
248
- session.sub = deriveStableSub(submittedUsername, options.serverName, process.env.CREDENTIAL_SECRET);
249
- }
250
- // Reset stale completion markers from previous authorize submits.
251
- // setupStatus is module-scoped, so a key flipped to "complete" by a
252
- // prior background poll (e.g. Outlook device code finished on the
253
- // first attempt) would otherwise persist into the next form submit.
254
- // The frontend renders a fresh oauth_device_code UI and starts
255
- // polling /setup-status, which returns the stale "complete" within
256
- // a few seconds and triggers a premature redirect — the user sees
257
- // the device code flash on screen and then the "setup complete"
258
- // banner before they ever open microsoft.com/link. Reset all keys
259
- // back to "idle" here so each submit starts from a clean state.
260
- for (const key of Object.keys(setupStatus)) {
261
- setupStatus[key] = 'idle';
262
- }
263
- // Save credentials via callback. Callback may return a dict with
264
- // next_step info (e.g. GDrive OAuth device code to show in the form).
265
- // The per-authorize ``sub`` is threaded through so consumers persist
266
- // credentials keyed by subject — subsequently the JWT issued at /token
267
- // will carry this same sub, letting tool handlers load the correct
268
- // credential set via AsyncLocalStorage.
269
- let nextStep = null;
270
- if (options.onCredentialsSaved !== undefined) {
271
- try {
272
- const result = await options.onCredentialsSaved(credentials, { sub: session.sub });
273
- if (result !== null && result !== undefined && typeof result === 'object') {
274
- nextStep = result;
275
- }
276
- }
277
- catch {
278
- jsonResponse(res, 500, {
279
- error: 'server_error',
280
- error_description: 'Failed to save credentials'
281
- });
282
- return;
283
- }
284
- }
285
- // Issue #682: a failing save callback must not yield an auth code. The
286
- // response used to carry both ``redirect_url`` (with a usable code) and
287
- // ``next_step: {type: 'error'}``, so a client reading only the redirect
288
- // exchanged the code for a token bound to credentials that were never
289
- // persisted. Short-circuit here, before the code is minted. Other
290
- // next_step types are legitimate continuations of a successful save and
291
- // keep the redirect.
292
- if (nextStep !== null && nextStep.type === 'error') {
293
- const errText = typeof nextStep.text === 'string' ? nextStep.text : 'credential save failed';
294
- jsonResponse(res, 400, { ok: false, error: errText });
295
- return;
296
- }
297
- // Mark the persistent ``_setup_complete`` flag once the user submits
298
- // successfully. Multi-step flows (OTP / 2FA) defer marking until the
299
- // final step in ``otpHandler`` — see core-py parity. Best-effort: any
300
- // storage error is logged via warning rather than failing the response.
301
- const isMultiStep = nextStep !== null && (nextStep.type === 'otp_required' || nextStep.type === 'password_required');
302
- if (!isMultiStep) {
303
- try {
304
- await markConfigSetupComplete(options.serverName);
305
- }
306
- catch (err) {
307
- console.warn('Failed to mark _setup_complete=true for %s: %s', options.serverName, err instanceof Error ? err.message : String(err));
308
- }
309
- }
310
- // Generate auth code. Carry ``sub`` so /token can issue JWT with the
311
- // same subject the credentials were saved under.
312
- const authCode = randomBytes(32).toString('base64url');
313
- authCodes.set(authCode, {
314
- codeChallenge: session.codeChallenge,
315
- codeChallengeMethod: session.codeChallengeMethod,
316
- createdAt: Date.now(),
317
- sub: session.sub
318
- });
319
- pruneExpired(authCodes, AUTH_CODE_TTL_S * 1000);
320
- // RFC 9207 / SEP-2468: carry ``iss`` (this AS's issuer) in the
321
- // authorization response so the client can verify the response came from
322
- // the AS it started the flow with (mix-up defence).
323
- const base = getBaseUrl(req);
324
- const separator = session.redirectUri.includes('?') ? '&' : '?';
325
- const redirectUrl = `${session.redirectUri}${separator}code=${authCode}&state=${session.state}` + `&iss=${encodeURIComponent(base)}`;
326
- const body = { ok: true, redirect_url: redirectUrl };
327
- if (nextStep !== null) {
328
- body.next_step = nextStep;
329
- // If next_step requires additional input (OTP or 2FA password),
330
- // activate pending step session so /otp endpoint accepts input.
331
- // Capture the authorize-session sub so /otp can thread the correct
332
- // SubjectContext into onStepSubmitted (the browser POSTs to /otp
333
- // with step data only — no sub in body).
334
- const stepType = nextStep.type;
335
- if (stepType === 'otp_required' || stepType === 'password_required') {
336
- markPendingStep(session.sub);
337
- }
338
- }
339
- jsonResponse(res, 200, body);
340
- }
341
- async function authorize(req, res) {
342
- if (req.method === 'GET') {
343
- await authorizeGet(req, res);
344
- return;
345
- }
346
- await authorizePost(req, res);
347
- }
348
- async function authorizePrefill(req, res) {
349
- // Driver-only side-channel: store form prefill values keyed by the OAuth
350
- // ``state`` token chosen by the client. ``GET /authorize?state=<X>`` then
351
- // hydrates the form on render — credentials never appear in the URL.
352
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
353
- const state = url.searchParams.get('state');
354
- if (!state) {
355
- jsonResponse(res, 400, { error: 'invalid_request', error_description: 'Missing state' });
356
- return;
357
- }
358
- let body;
359
- try {
360
- body = await parseJsonBody(req);
361
- }
362
- catch {
363
- jsonResponse(res, 400, { error: 'invalid_request', error_description: 'Body must be JSON object' });
364
- return;
365
- }
366
- if (!body || typeof body !== 'object' || Array.isArray(body)) {
367
- jsonResponse(res, 400, { error: 'invalid_request', error_description: 'Body must be JSON object' });
368
- return;
369
- }
370
- // Coerce all values to string + drop empties so blank ``value=""`` attrs
371
- // don't shadow the placeholder text in the rendered form.
372
- const data = {};
373
- for (const [k, v] of Object.entries(body)) {
374
- if (v != null && String(v).length > 0) {
375
- data[k] = String(v);
376
- }
377
- }
378
- pendingPrefills.set(state, { data, createdAt: Date.now() });
379
- pruneExpired(pendingPrefills, PREFILL_TTL_S * 1000);
380
- jsonResponse(res, 204, {});
381
- }
382
- /**
383
- * Build and write the standard /token success body for ``sub``.
384
- *
385
- * Issues a fresh access token AND a fresh refresh token. The refresh token
386
- * lets long-running MCP clients renew the 1h access token without re-running
387
- * the browser PKCE flow (issue #261). ``scope`` advertises ``offline_access``
388
- * so clients know a refresh token was granted.
389
- */
390
- async function issueTokenResponse(res, sub) {
391
- const accessToken = await jwtIssuer.issueAccessToken(sub);
392
- const refreshToken = await jwtIssuer.issueRefreshToken(sub);
393
- jsonResponse(res, 200, {
394
- access_token: accessToken,
395
- token_type: 'Bearer',
396
- expires_in: 3600,
397
- refresh_token: refreshToken,
398
- scope: 'offline_access'
399
- });
400
- }
401
- /**
402
- * Handle ``grant_type=refresh_token`` (RFC 6749 §6) statelessly. Verifies the
403
- * presented refresh token's signature / iss / aud / exp / ``typ`` via the JWT
404
- * issuer (no server-side store), extracts ``sub``, and issues a NEW access
405
- * token AND a NEW refresh token (rotation). The refresh token is
406
- * self-contained, so rotation here is stateless: the old refresh token simply
407
- * expires on its own 30-day clock; clients replace it with the rotated one.
408
- */
409
- async function handleRefreshToken(res, form) {
410
- const refreshToken = form.refresh_token;
411
- if (!refreshToken) {
412
- jsonResponse(res, 400, {
413
- error: 'invalid_request',
414
- error_description: 'Missing refresh_token'
415
- });
416
- return;
417
- }
418
- let sub;
419
- try {
420
- const claims = await jwtIssuer.verifyRefreshToken(refreshToken);
421
- sub = claims.sub;
422
- }
423
- catch {
424
- jsonResponse(res, 400, { error: 'invalid_grant' });
425
- return;
426
- }
427
- await issueTokenResponse(res, sub);
428
- }
429
- async function token(req, res) {
430
- let form;
431
- try {
432
- form = await parseFormBody(req);
433
- }
434
- catch {
435
- jsonResponse(res, 400, { error: 'invalid_request' });
436
- return;
437
- }
438
- const grantType = form.grant_type;
439
- if (grantType === 'refresh_token') {
440
- await handleRefreshToken(res, form);
441
- return;
442
- }
443
- if (grantType !== 'authorization_code') {
444
- jsonResponse(res, 400, { error: 'unsupported_grant_type' });
445
- return;
446
- }
447
- const code = form.code;
448
- const codeVerifier = form.code_verifier;
449
- if (!code || !codeVerifier) {
450
- jsonResponse(res, 400, {
451
- error: 'invalid_request',
452
- error_description: 'Missing code or code_verifier'
453
- });
454
- return;
455
- }
456
- const entry = authCodes.get(code);
457
- if (entry === undefined) {
458
- jsonResponse(res, 400, { error: 'invalid_grant' });
459
- return;
460
- }
461
- authCodes.delete(code);
462
- if (Date.now() - entry.createdAt > AUTH_CODE_TTL_S * 1000) {
463
- jsonResponse(res, 400, { error: 'invalid_grant' });
464
- return;
465
- }
466
- if (entry.codeChallengeMethod !== 'S256') {
467
- jsonResponse(res, 400, {
468
- error: 'invalid_request',
469
- error_description: 'Only S256 is supported'
470
- });
471
- return;
472
- }
473
- if (!s256Verify(codeVerifier, entry.codeChallenge)) {
474
- jsonResponse(res, 400, { error: 'invalid_grant' });
475
- return;
476
- }
477
- // Issue JWT with the subject bound to this authorize session. Historically
478
- // this was the static string 'local-user' — that collapsed every concurrent
479
- // browser into one subject and made credential isolation impossible. The
480
- // new flow mints a fresh UUID in authorizeGet (PendingSession.sub), carries
481
- // it through onCredentialsSaved, and issues it here so the Bearer returned
482
- // to the client scopes future /mcp calls to this user's credentials.
483
- await issueTokenResponse(res, entry.sub);
484
- }
485
- async function otpHandler(req, res) {
486
- // 1. Active session check.
487
- if (pendingStep === null || !pendingStep.active) {
488
- jsonResponse(res, 400, {
489
- error: 'invalid_request',
490
- error_description: 'No active step session'
491
- });
492
- return;
493
- }
494
- // 2. Timeout check.
495
- if (Date.now() - pendingStep.createdAt > OTP_TIMEOUT_S * 1000) {
496
- clearPendingStep();
497
- jsonResponse(res, 400, {
498
- error: 'invalid_request',
499
- error_description: 'Step session expired'
500
- });
501
- return;
502
- }
503
- // 3. Parse JSON body BEFORE incrementing attempts. Malformed input
504
- // must not consume the user's retry quota nor clear the session.
505
- let stepData;
506
- try {
507
- stepData = await parseJsonBody(req);
508
- }
509
- catch {
510
- jsonResponse(res, 400, {
511
- error: 'invalid_request',
512
- error_description: 'Invalid JSON body'
513
- });
514
- return;
515
- }
516
- // 4. Increment attempts counter (count every valid-JSON submit).
517
- pendingStep.attempts += 1;
518
- // 5. Attempt limit check.
519
- if (pendingStep.attempts > OTP_MAX_ATTEMPTS) {
520
- clearPendingStep();
521
- jsonResponse(res, 400, {
522
- error: 'invalid_request',
523
- error_description: 'Too many attempts'
524
- });
525
- return;
526
- }
527
- // 6. Dispatch to step callback with the authorize-session sub so
528
- // consumers (telegram Telethon multi-user) can route to the correct
529
- // per-user state.
530
- const stepContext = { sub: pendingStep.sub };
531
- const stepSub = pendingStep.sub;
532
- let nextStep = null;
533
- if (options.onStepSubmitted !== undefined) {
534
- try {
535
- const result = await options.onStepSubmitted(stepData, stepContext);
536
- if (result !== null && result !== undefined && typeof result === 'object') {
537
- nextStep = result;
538
- }
539
- }
540
- catch {
541
- jsonResponse(res, 500, {
542
- error: 'server_error',
543
- error_description: 'Failed to process step input'
544
- });
545
- return;
546
- }
547
- }
548
- // Error from callback: keep pending session, allow retry.
549
- if (nextStep !== null && nextStep.type === 'error') {
550
- const errText = typeof nextStep.text === 'string' ? nextStep.text : 'Invalid input';
551
- jsonResponse(res, 200, { ok: false, error: errText });
552
- return;
553
- }
554
- // Chain to next step: reset counters so the new step gets its own quota.
555
- // Preserve the original sub so the whole multi-step chain belongs to the
556
- // same user.
557
- if (nextStep !== null && (nextStep.type === 'otp_required' || nextStep.type === 'password_required')) {
558
- markPendingStep(stepSub);
559
- jsonResponse(res, 200, { ok: true, next_step: nextStep });
560
- return;
561
- }
562
- // Completion (callback returned null / undefined or unknown dict type).
563
- // Mark persistent _setup_complete flag now that the multi-step chain has
564
- // finished — single-step counterpart lives in authorizePost.
565
- try {
566
- await markConfigSetupComplete(options.serverName);
567
- }
568
- catch (err) {
569
- console.warn('Failed to mark _setup_complete=true for %s: %s', options.serverName, err instanceof Error ? err.message : String(err));
570
- }
571
- clearPendingStep();
572
- jsonResponse(res, 200, { ok: true });
573
- }
574
- async function setupStatusHandler(_req, res) {
575
- jsonResponse(res, 200, setupStatus);
576
- }
577
- async function wellKnownAs(req, res) {
578
- const base = getBaseUrl(req);
579
- jsonResponse(res, 200, authorizationServerMetadata(base));
580
- }
581
- async function wellKnownPr(req, res) {
582
- const base = getBaseUrl(req);
583
- jsonResponse(res, 200, protectedResourceMetadata(base, [base]));
584
- }
585
- async function wellKnownJwks(_req, res) {
586
- jsonResponse(res, 200, await jwtIssuer.getJwks());
587
- }
588
- /**
589
- * RFC 7591 Dynamic Client Registration.
590
- *
591
- * Local OAuth server uses a fixed public client id (`local-browser`).
592
- * DCR is echo-style — mirror the client's submitted metadata back with
593
- * the fixed id so MCP clients (Python SDK OAuthClientProvider, etc.)
594
- * can bootstrap OAuth without failing at the registration step.
595
- */
596
- async function registerHandler(req, res) {
597
- let body = {};
598
- try {
599
- const raw = await parseJsonBody(req);
600
- body = raw;
601
- }
602
- catch {
603
- // fall through with empty body
604
- }
605
- const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
606
- const grantTypes = Array.isArray(body.grant_types) ? body.grant_types : ['authorization_code'];
607
- const responseTypes = Array.isArray(body.response_types) ? body.response_types : ['code'];
608
- const clientName = typeof body.client_name === 'string' ? body.client_name : 'mcp-client';
609
- jsonResponse(res, 201, {
610
- client_id: 'local-browser',
611
- client_name: clientName,
612
- redirect_uris: redirectUris,
613
- grant_types: grantTypes,
614
- response_types: responseTypes,
615
- token_endpoint_auth_method: 'none'
616
- });
617
- }
618
- function markSetupComplete(key = 'gdrive') {
619
- setupStatus[key] = 'complete';
620
- }
621
- /**
622
- * Mark a background setup step as failed. The status becomes
623
- * ``"error:<message>"`` so the frontend poll handler can surface the
624
- * message and stop spinning. Whitespace is collapsed to keep the value
625
- * single-line (the frontend inlines it verbatim).
626
- */
627
- function markSetupFailed(key = 'gdrive', error = 'unknown error') {
628
- // Replace multiple array allocations (.split.filter.join) with regex replace for faster whitespace collapsing
629
- const collapsed = String(error).replace(/\s+/g, ' ').trim();
630
- const message = collapsed.length > 0 ? collapsed : 'unknown error';
631
- setupStatus[key] = `error:${message}`;
632
- }
633
- /**
634
- * GET / -- auto-generate PKCE and redirect to /authorize.
635
- *
636
- * The ``/authorize`` endpoint requires 4 PKCE parameters; users arriving
637
- * from a log line / bookmark have no way to construct them. Bootstrap a
638
- * default ``local-browser`` client here: generate random state + S256
639
- * challenge, redirect to ``/authorize``, and on success return to
640
- * ``/callback-done`` for a friendly close message. Keeps the one-URL UX
641
- * ("open http://... in browser") working without exposing the raw OAuth
642
- * machinery to end users.
643
- */
644
- async function rootHandler(req, res) {
645
- const base = getBaseUrl(req);
646
- const codeVerifier = randomBytes(64).toString('base64url');
647
- const codeChallenge = createHash('sha256').update(codeVerifier, 'ascii').digest('base64url');
648
- const state = randomBytes(16).toString('base64url');
649
- const params = new URLSearchParams({
650
- client_id: 'local-browser',
651
- redirect_uri: `${base}/callback-done`,
652
- state,
653
- code_challenge: codeChallenge,
654
- code_challenge_method: 'S256'
655
- });
656
- res.writeHead(302, { Location: `/authorize?${params.toString()}` });
657
- res.end();
658
- }
659
- /**
660
- * GET /callback-done -- terminal "tab can be closed" landing page.
661
- *
662
- * Users redirected from the bootstrap flow land here on success. Exists
663
- * purely so the bare URL doesn't 404 after the PKCE redirect completes.
664
- */
665
- async function callbackDoneHandler(_req, res) {
666
- const html = "<!DOCTYPE html><html><head><meta charset='utf-8'>" +
667
- '<title>Setup complete</title>' +
668
- '<style>body{font-family:-apple-system,Segoe UI,sans-serif;' +
669
- 'background:#111;color:#eee;display:flex;align-items:center;' +
670
- 'justify-content:center;height:100vh;margin:0}' +
671
- '.box{text-align:center;padding:2rem;border:1px solid #333;' +
672
- 'border-radius:8px;background:#1a1a1a}' +
673
- 'h1{color:#34c759;margin:0 0 0.5rem}p{color:#aaa;margin:0}' +
674
- '</style></head><body><div class="box">' +
675
- '<h1>Setup complete</h1>' +
676
- '<p>You can close this tab.</p>' +
677
- '</div></body></html>';
678
- htmlResponse(res, 200, html);
679
- }
680
- // ------------------------------------------------------------------
681
- // Relay password gate adapters.
682
- //
683
- // The relay-login module exposes Express-style handlers (``req.body``,
684
- // ``res.status().send()`` etc) so the same logic can be unit-tested
685
- // without a live HTTP server. Here we adapt them onto the project's
686
- // ``IncomingMessage`` / ``ServerResponse`` router. Each adapter:
687
- // 1. Parses ``Cookie`` into ``req.cookies``,
688
- // 2. Mounts ``res.status / res.send / res.cookie / res.redirect /
689
- // res.header / res.set`` on top of ``res.writeHead`` / ``res.end``,
690
- // 3. Lets the relay-login handler do the work.
691
- //
692
- // Only ``/authorize`` GET + POST + ``/authorize/prefill`` go through
693
- // ``relayMw``; ``/token``, ``/register``, ``/otp``, ``/setup-status``,
694
- // ``/.well-known/*``, ``/`` and ``/callback-done`` are intentionally NOT
695
- // gated — they're machine endpoints (DCR / token exchange / metadata) or
696
- // auto-bootstrap helpers that must remain accessible without the cookie.
697
- // ------------------------------------------------------------------
698
- function parseCookies(req) {
699
- const header = req.headers.cookie;
700
- if (!header)
701
- return {};
702
- const out = {};
703
- // Use a single-pass loop instead of `split(';')` to avoid array
704
- // allocations and intermediate string objects in this hot path.
705
- let pos = 0;
706
- while (pos < header.length) {
707
- let nextSemi = header.indexOf(';', pos);
708
- if (nextSemi < 0)
709
- nextSemi = header.length;
710
- const eq = header.indexOf('=', pos);
711
- if (eq >= 0 && eq < nextSemi) {
712
- const k = header.substring(pos, eq).trim();
713
- const v = header.substring(eq + 1, nextSemi).trim();
714
- if (k.length > 0)
715
- out[k] = decodeURIComponent(v);
716
- }
717
- pos = nextSemi + 1;
718
- }
719
- return out;
720
- }
721
- function clientIp(req) {
722
- const fwd = req.headers['x-forwarded-for'];
723
- if (typeof fwd === 'string' && fwd.length > 0) {
724
- const idx = fwd.indexOf(',');
725
- return (idx !== -1 ? fwd.substring(0, idx) : fwd).trim();
726
- }
727
- return req.socket.remoteAddress ?? 'unknown';
728
- }
729
- function buildResAdapter(res) {
730
- let statusCode = 200;
731
- const headers = {};
732
- let completed = false;
733
- const setHeader = (name, value) => {
734
- headers[name] = value;
735
- };
736
- const adapter = {
737
- status: (code) => {
738
- statusCode = Number(code);
739
- return adapter;
740
- },
741
- header: (name, value) => {
742
- setHeader(String(name), String(value));
743
- return adapter;
744
- },
745
- set: (name, value) => {
746
- setHeader(String(name), String(value));
747
- return adapter;
748
- },
749
- cookie: (name, value, options) => {
750
- const opts = (options ?? {});
751
- const parts = [`${String(name)}=${String(value)}`];
752
- if (opts.maxAge !== undefined) {
753
- parts.push(`Max-Age=${Math.floor(Number(opts.maxAge) / 1000)}`);
754
- }
755
- if (opts.httpOnly === true)
756
- parts.push('HttpOnly');
757
- if (opts.secure === true)
758
- parts.push('Secure');
759
- if (typeof opts.sameSite === 'string') {
760
- // Capitalise SameSite values (lax → Lax, strict → Strict).
761
- const v = opts.sameSite;
762
- parts.push(`SameSite=${v.charAt(0).toUpperCase() + v.slice(1)}`);
763
- }
764
- parts.push('Path=/');
765
- const existing = headers['Set-Cookie'];
766
- const next = parts.join('; ');
767
- headers['Set-Cookie'] = existing !== undefined ? `${existing}, ${next}` : next;
768
- return adapter;
769
- },
770
- send: (body) => {
771
- if (completed)
772
- return adapter;
773
- completed = true;
774
- if (!('Content-Type' in headers)) {
775
- headers['Content-Type'] = 'text/html; charset=utf-8';
776
- }
777
- // Anti-clickjacking for HTML responses
778
- const ctype = headers['Content-Type'];
779
- if (ctype && ctype.toLowerCase().includes('text/html')) {
780
- headers['X-Frame-Options'] = 'DENY';
781
- }
782
- res.writeHead(statusCode, headers);
783
- res.end(body === undefined ? '' : String(body));
784
- return adapter;
785
- },
786
- redirect: (url) => {
787
- if (completed)
788
- return adapter;
789
- completed = true;
790
- headers.Location = String(url);
791
- res.writeHead(302, headers);
792
- res.end();
793
- return adapter;
794
- }
795
- };
796
- return { adapter, isComplete: () => completed };
797
- }
798
- /**
799
- * Wrap a router handler with the relay-login middleware. If the cookie
800
- * gate redirects, the inner handler is never called; otherwise the inner
801
- * handler runs against the original ``req`` / ``res``.
802
- */
803
- function withRelayGate(inner) {
804
- return async (req, res) => {
805
- if (!relayPassword) {
806
- await inner(req, res);
807
- return;
808
- }
809
- const cookies = parseCookies(req);
810
- const expressReq = {
811
- cookies,
812
- originalUrl: req.url ?? '/'
813
- };
814
- const { adapter, isComplete } = buildResAdapter(res);
815
- let proceed = false;
816
- await relayMw(expressReq, adapter, () => {
817
- proceed = true;
818
- });
819
- if (proceed && !isComplete()) {
820
- await inner(req, res);
821
- }
822
- };
823
- }
824
- /**
825
- * Adapter: bind ``loginGetHandler`` / ``loginPostHandler`` (Express-style)
826
- * onto IncomingMessage / ServerResponse routes.
827
- */
828
- async function loginGetRoute(req, res) {
829
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
830
- const expressReq = { query: { next: url.searchParams.get('next') ?? '/authorize' } };
831
- const { adapter } = buildResAdapter(res);
832
- await loginGetHandler(expressReq, adapter);
833
- }
834
- async function loginPostRoute(req, res) {
835
- let body = {};
836
- try {
837
- body = await parseFormBody(req);
838
- }
839
- catch {
840
- res.writeHead(400, { 'Content-Type': 'application/json' });
841
- res.end(JSON.stringify({ error: 'invalid_request' }));
842
- return;
843
- }
844
- const expressReq = { body, ip: clientIp(req) };
845
- const { adapter } = buildResAdapter(res);
846
- await loginPostHandler(expressReq, adapter);
847
- }
848
- const handler = createRouter([
849
- { method: 'GET', path: '/', handler: rootHandler },
850
- { method: 'GET', path: '/login', handler: loginGetRoute },
851
- { method: 'POST', path: '/login', handler: loginPostRoute },
852
- { method: 'GET', path: '/authorize', handler: withRelayGate(authorize) },
853
- { method: 'POST', path: '/authorize', handler: withRelayGate(authorize) },
854
- { method: 'POST', path: '/authorize/prefill', handler: withRelayGate(authorizePrefill) },
855
- { method: 'POST', path: '/token', handler: token },
856
- { method: 'POST', path: '/register', handler: registerHandler },
857
- { method: 'POST', path: '/otp', handler: otpHandler },
858
- { method: 'GET', path: '/setup-status', handler: setupStatusHandler },
859
- { method: 'GET', path: '/callback-done', handler: callbackDoneHandler },
860
- { method: 'GET', path: '/.well-known/oauth-authorization-server', handler: wellKnownAs },
861
- { method: 'GET', path: '/.well-known/oauth-protected-resource', handler: wellKnownPr },
862
- { method: 'GET', path: '/.well-known/jwks.json', handler: wellKnownJwks }
863
- ]);
864
- return { handler, jwtIssuer, markSetupComplete, markSetupFailed };
865
- }
866
- // ---------------------------------------------------------------------------
867
- // D7 — Pre-fill renderer + form-submission merger.
868
- //
869
- // These helpers mirror the core-py exports of the same names. They are
870
- // intentionally NOT wired into the default ``authorizeGet`` / ``authorizePost``
871
- // above — that path stays on ``renderCredentialForm`` for legacy parity. New
872
- // consumers (transparent-bridge Wave 1) compose ``renderField`` per field for
873
- // secret-aware rendering, then call ``mergeSubmission`` to merge the POST body
874
- // with their stored ``config.enc`` so a missing secret in the body preserves
875
- // the previously stored value instead of clearing it.
876
- // ---------------------------------------------------------------------------
877
- const SECRET_PLACEHOLDER = '••••••••(configured)';
878
- function escapeHtml(s) {
879
- return s
880
- .replace(/&/g, '&amp;')
881
- .replace(/</g, '&lt;')
882
- .replace(/>/g, '&gt;')
883
- .replace(/"/g, '&quot;')
884
- .replace(/'/g, '&#39;');
885
- }
886
- /**
887
- * Resolve the canonical config key for a relay field.
888
- *
889
- * Two schema styles coexist in the codebase: D7 ``RelayConfigField`` uses
890
- * ``name``; legacy ``ConfigField`` (consumed by ``renderCredentialForm``)
891
- * uses ``key``. ``renderField`` and ``mergeSubmission`` accept both so a
892
- * consumer can mix or migrate without rewriting field dicts. ``name`` wins
893
- * when both are present.
894
- */
895
- function fieldName(field) {
896
- const raw = field;
897
- const name = typeof raw.name === 'string' ? raw.name : '';
898
- if (name.length > 0)
899
- return name;
900
- const key = typeof raw.key === 'string' ? raw.key : '';
901
- return key;
902
- }
903
- /**
904
- * Render an HTML ``<label>+<input>`` for a single relay field.
905
- *
906
- * Rules per D7:
907
- * - oauth_field: render Re-authorize button (no plaintext exposed)
908
- * - secret + value present: placeholder, empty input, "Replace" checkbox
909
- * - secret + no value: empty input with field label as placeholder
910
- * - non-secret: pre-fill with ``currentValue`` as input ``value`` attr
911
- */
912
- export function renderField(field, currentValue) {
913
- const name = fieldName(field);
914
- const label = field.label ?? name;
915
- const fieldType = field.type ?? 'text';
916
- if (isOAuthField(field)) {
917
- const status = currentValue ? 'Connected' : 'Not connected';
918
- return (`<div class="field oauth-field"><label>${escapeHtml(label)}</label> ` +
919
- `<span class="oauth-status">${status}</span> ` +
920
- `<button type="button" class="oauth-reauth" data-field="${escapeHtml(name)}" aria-label="Re-authorize ${escapeHtml(label)}">` +
921
- `Re-authorize</button></div>`);
922
- }
923
- if (isSecretField(field)) {
924
- if (currentValue) {
925
- return (`<div class="field secret-field"><label for="field-${escapeHtml(name)}">${escapeHtml(label)}</label> ` +
926
- `<input id="field-${escapeHtml(name)}" type="password" name="${escapeHtml(name)}" value="" ` +
927
- `placeholder="${escapeHtml(SECRET_PLACEHOLDER)}" ` +
928
- `data-secret-configured="true"> ` +
929
- `<label for="replace-${escapeHtml(name)}" class="replace-toggle"><input id="replace-${escapeHtml(name)}" type="checkbox" ` +
930
- `name="__replace_${escapeHtml(name)}" value="1"> Replace this credential</label>` +
931
- `</div>`);
932
- }
933
- return (`<div class="field secret-field"><label for="field-${escapeHtml(name)}">${escapeHtml(label)}</label> ` +
934
- `<input id="field-${escapeHtml(name)}" type="password" name="${escapeHtml(name)}" value="" ` +
935
- `placeholder="${escapeHtml(label)}"></div>`);
936
- }
937
- const valueAttr = currentValue !== null && currentValue !== undefined ? escapeHtml(String(currentValue)) : '';
938
- return (`<div class="field"><label for="field-${escapeHtml(name)}">${escapeHtml(label)}</label> ` +
939
- `<input id="field-${escapeHtml(name)}" type="${escapeHtml(fieldType)}" name="${escapeHtml(name)}" ` +
940
- `value="${valueAttr}"></div>`);
941
- }
942
- /**
943
- * Merge a form submission into the current config per D7 rules.
944
- *
945
- * Behavior:
946
- * - Empty secret -> preserve the existing value (avoid clearing on a
947
- * re-submit where the user did not retype the credential).
948
- * - Non-empty secret -> replace.
949
- * - Non-secret -> always replace (including with an empty string, so the
950
- * user can intentionally blank a field).
951
- * - oauth_field -> ignored here; OAuth fields are managed by their own
952
- * Re-authorize flow, never by raw form input.
953
- */
954
- export function mergeSubmission(current, submitted, schemaFields) {
955
- const result = { ...current };
956
- for (const field of schemaFields) {
957
- const name = fieldName(field);
958
- if (!name)
959
- continue;
960
- if (isOAuthField(field))
961
- continue;
962
- const newValue = submitted[name] ?? '';
963
- if (isSecretField(field)) {
964
- if (newValue === '' || newValue === null || newValue === undefined) {
965
- continue;
966
- }
967
- result[name] = newValue;
968
- }
969
- else {
970
- result[name] = newValue;
971
- }
972
- }
973
- return result;
974
- }
975
- //# sourceMappingURL=local-oauth-app.js.map