dsh-plugin-discourse 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -2,13 +2,16 @@
2
2
  * Discourse administration and agent tools for the DeepSeek Harness.
3
3
  *
4
4
  * One Host plugin registers model-facing tools over the Discourse REST API:
5
- * reading topics and users, authoring posts, and running moderator/administrator
6
- * operations. Every request carries the configured API key, and `readOnly`
7
- * turns the plugin into an inspection-only surface.
5
+ * reading topics and users, authoring posts, running moderator/administrator
6
+ * operations, and signing in through the browser with a scoped user API key.
7
+ * Every request carries either the stored user session or the configured API
8
+ * key, and `readOnly` turns the plugin into an inspection-only surface.
8
9
  *
9
10
  * @module dsh-plugin-discourse
10
11
  */
11
12
 
13
+ import { constants as cryptoConstants, generateKeyPairSync, privateDecrypt, randomBytes } from 'node:crypto'
14
+
12
15
  /** Loader row id used by this bundle. */
13
16
  export const name = 'discourse'
14
17
 
@@ -27,6 +30,16 @@ const MAX_LIMIT = 50
27
30
  const MAX_BODY = 700
28
31
  /** Hours a resolved category list stays trustworthy. */
29
32
  const CATEGORY_TTL_MS = 5 * 60 * 1000
33
+ /** Credential record holding the browser-authorized user session. */
34
+ const SESSION_KEY = 'discourse/session'
35
+ /** Scopes requested by the browser sign-in when the config names none. */
36
+ const DEFAULT_LOGIN_SCOPES = 'read,write,session_info'
37
+ /** Application name shown on the forum authorization page. */
38
+ const DEFAULT_LOGIN_APPLICATION = 'DeepSeek Harness'
39
+ /** Client id Discourse stores with the authorized user API key. */
40
+ const DEFAULT_LOGIN_CLIENT_ID = 'dsh-plugin-discourse'
41
+ /** Browser sign-in deadline when the config names none (Discourse's device code lives 10 minutes). */
42
+ const DEFAULT_LOGIN_TIMEOUT_MS = 10 * 60 * 1000
30
43
 
31
44
  /** Per-tool output schema: an open object, because each tool projects its own shape. */
32
45
  const OBJECT_OUTPUT = { type: 'object', additionalProperties: true }
@@ -157,22 +170,26 @@ function day(value) {
157
170
  * Normalize the loader row config into one resolved client configuration.
158
171
  *
159
172
  * The API key may come from the row config or from `DISCOURSE_API_KEY`, so a
160
- * shared profile file never needs to carry the secret itself.
173
+ * shared profile file never needs to carry the secret itself. Browser sign-in
174
+ * settings default to a scoped user API key that can read, write, and read the
175
+ * current session; a deployment may narrow the scopes.
161
176
  *
162
177
  * @param {unknown} raw - the loader row config object.
163
- * @returns {{ baseUrl: string, apiKey: string, apiUsername: string, readOnly: boolean, timeoutMs: number, maxItems: number }} the resolved configuration.
178
+ * @returns {{ baseUrl: string, apiKey: string, apiUsername: string, readOnly: boolean, timeoutMs: number, maxItems: number, authMode: 'auto' | 'api-key' | 'user', loginScopes: string[], loginClientId: string, loginApplicationName: string, loginTimeoutMs: number }} the resolved configuration.
164
179
  */
165
180
  function readConfig(raw) {
166
181
  const source = raw !== null && typeof raw === 'object' ? raw : {}
167
182
  const declared = optionalString(source.baseUrl) ?? DEFAULT_BASE_URL
168
- let baseUrl
183
+ let parsed
169
184
  try {
170
- const parsed = new URL(declared)
171
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') fail('unsupported protocol')
172
- baseUrl = `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`
185
+ parsed = new URL(declared)
173
186
  } catch {
174
187
  fail(`invalid config: \`baseUrl\` must be an http(s) URL, got ${JSON.stringify(declared)}`)
175
188
  }
189
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
190
+ fail(`invalid config: \`baseUrl\` must be an http(s) URL, got ${JSON.stringify(declared)}`)
191
+ }
192
+ const baseUrl = `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`
176
193
  const envKey = typeof process !== 'undefined' ? process.env?.DISCOURSE_API_KEY : undefined
177
194
  const envUser = typeof process !== 'undefined' ? process.env?.DISCOURSE_API_USERNAME : undefined
178
195
  const apiKey = optionalString(source.apiKey) ?? optionalString(envKey) ?? ''
@@ -183,6 +200,19 @@ function readConfig(raw) {
183
200
  const maxItems = typeof source.maxItems === 'number' && Number.isFinite(source.maxItems)
184
201
  ? Math.min(MAX_LIMIT, Math.max(1, Math.round(source.maxItems)))
185
202
  : DEFAULT_LIMIT
203
+ const authMode = typeof source.authMode === 'string' ? source.authMode.trim().toLowerCase() : 'auto'
204
+ if (authMode !== 'auto' && authMode !== 'api-key' && authMode !== 'user') {
205
+ fail(`invalid config: \`authMode\` must be "auto", "api-key", or "user", got ${JSON.stringify(source.authMode)}`)
206
+ }
207
+ const loginScopes = readLoginScopes(source.loginScopes)
208
+ const loginClientId = optionalString(source.loginClientId) ?? DEFAULT_LOGIN_CLIENT_ID
209
+ const loginApplicationName = optionalString(source.loginApplicationName) ?? DEFAULT_LOGIN_APPLICATION
210
+ for (const [label, value] of [['loginClientId', loginClientId], ['loginApplicationName', loginApplicationName]]) {
211
+ if (Buffer.byteLength(value, 'utf8') > 200) fail(`invalid config: \`${label}\` must be at most 200 bytes`)
212
+ }
213
+ const loginTimeoutMs = typeof source.loginTimeoutMs === 'number' && Number.isFinite(source.loginTimeoutMs)
214
+ ? Math.min(1800000, Math.max(30000, Math.round(source.loginTimeoutMs)))
215
+ : DEFAULT_LOGIN_TIMEOUT_MS
186
216
  return {
187
217
  baseUrl,
188
218
  apiKey,
@@ -190,6 +220,111 @@ function readConfig(raw) {
190
220
  readOnly: source.readOnly === true,
191
221
  timeoutMs,
192
222
  maxItems,
223
+ authMode,
224
+ loginScopes,
225
+ loginClientId,
226
+ loginApplicationName,
227
+ loginTimeoutMs,
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Normalize the scopes requested by browser sign-in.
233
+ * @param {unknown} value - a comma-separated string or an array of scope names.
234
+ * @returns {string[]} unique lowercase scope names.
235
+ */
236
+ function readLoginScopes(value) {
237
+ const entries = Array.isArray(value) ? value : String(value ?? DEFAULT_LOGIN_SCOPES).split(',')
238
+ const scopes = []
239
+ for (const entry of entries) {
240
+ const scope = String(entry ?? '').trim().toLowerCase()
241
+ if (scope.length === 0) continue
242
+ if (!/^[a-z][a-z0-9_]*$/.test(scope)) fail(`invalid config: \`loginScopes\` contains an invalid scope ${JSON.stringify(entry)}`)
243
+ if (!scopes.includes(scope)) scopes.push(scope)
244
+ }
245
+ if (scopes.length === 0) fail('invalid config: `loginScopes` must name at least one scope')
246
+ if (scopes.length > 20) fail('invalid config: `loginScopes` names more than 20 scopes')
247
+ return scopes
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Stored browser session
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /**
255
+ * Validate one stored credential record and project it into a session.
256
+ *
257
+ * The record is owned by this plugin and addressed as `discourse/session`.
258
+ * A record for another forum, an unsupported version, or one without a key is
259
+ * reported as an error rather than used, so a changed `baseUrl` never sends the
260
+ * wrong forum's credential to a new host.
261
+ *
262
+ * @param {unknown} record - `credentials.readRecord()` result.
263
+ * @param {ReturnType<typeof readConfig>} config - resolved configuration.
264
+ * @returns {{ session?: Record<string, any>, error?: string }} the validated session or a diagnostic.
265
+ */
266
+ function readSessionRecord(record, config) {
267
+ if (record === undefined || record === null) return {}
268
+ if (record.kind !== 'grant' || record.payload === null || typeof record.payload !== 'object') {
269
+ return { error: 'the stored Discourse sign-in is not a grant record' }
270
+ }
271
+ const payload = record.payload
272
+ if (payload.version !== 1) return { error: `the stored Discourse sign-in uses unsupported version ${JSON.stringify(payload.version)}` }
273
+ if (payload.baseUrl !== config.baseUrl) return { error: `the stored Discourse sign-in belongs to ${JSON.stringify(payload.baseUrl)}` }
274
+ if (typeof payload.key !== 'string' || payload.key.length === 0) return { error: 'the stored Discourse sign-in has no user API key' }
275
+ const expiresAt = typeof payload.expiresAt === 'string' && payload.expiresAt.length > 0 ? payload.expiresAt : null
276
+ const expiresAtMs = expiresAt === null ? Number.NaN : Date.parse(expiresAt)
277
+ if (expiresAt !== null && !Number.isFinite(expiresAtMs)) {
278
+ return { error: `the stored Discourse sign-in has an invalid expiry ${JSON.stringify(expiresAt)}` }
279
+ }
280
+ const expired = Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now()
281
+ return {
282
+ session: {
283
+ key: payload.key,
284
+ username: typeof payload.username === 'string' && payload.username.length > 0 ? payload.username : null,
285
+ clientId: typeof payload.clientId === 'string' && payload.clientId.length > 0 ? payload.clientId : config.loginClientId,
286
+ scopes: Array.isArray(payload.scopes) ? payload.scopes.filter((scope) => typeof scope === 'string') : [],
287
+ apiVersion: typeof payload.apiVersion === 'number' ? payload.apiVersion : null,
288
+ source: typeof payload.source === 'string' ? payload.source : null,
289
+ createdAt: typeof payload.createdAt === 'string' ? payload.createdAt : null,
290
+ expiresAt,
291
+ expired,
292
+ },
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Read and validate the stored browser session.
298
+ * @param {any} credentials - the credential service, when the profile provides one.
299
+ * @param {ReturnType<typeof readConfig>} config - resolved configuration.
300
+ * @returns {Promise<{ storageAvailable: boolean, session?: Record<string, any>, error?: string }>} the read result.
301
+ */
302
+ async function readStoredSession(credentials, config) {
303
+ if (credentials === undefined || credentials === null) return { storageAvailable: false }
304
+ let record
305
+ try {
306
+ record = await credentials.readRecord(SESSION_KEY)
307
+ } catch (error) {
308
+ return { storageAvailable: true, error: `could not read the stored Discourse sign-in: ${error instanceof Error ? error.message : String(error)}` }
309
+ }
310
+ return { storageAvailable: true, ...readSessionRecord(record, config) }
311
+ }
312
+
313
+ /**
314
+ * Project a session onto the fields a tool may show.
315
+ * @param {Record<string, any>} session - validated session.
316
+ * @returns {Record<string, unknown>} the public session view, without the key.
317
+ */
318
+ function publicSession(session) {
319
+ return {
320
+ username: session.username,
321
+ clientId: session.clientId,
322
+ scopes: session.scopes,
323
+ apiVersion: session.apiVersion,
324
+ source: session.source,
325
+ createdAt: session.createdAt,
326
+ expiresAt: session.expiresAt,
327
+ expired: session.expired === true,
193
328
  }
194
329
  }
195
330
 
@@ -200,16 +335,68 @@ function readConfig(raw) {
200
335
  /**
201
336
  * Build the request client shared by every tool of one activation.
202
337
  * @param {ReturnType<typeof readConfig>} config - resolved configuration.
338
+ * @param {() => any} [getCredentials] - resolves the credential service on demand.
203
339
  * @returns {{ call: Function, writable: Function, categories: Function, resolveCategory: Function, config: object }} the client.
204
340
  */
205
- function createClient(config) {
341
+ function createClient(config, getCredentials = () => undefined) {
206
342
  const cache = { at: 0, entries: [] }
207
343
 
344
+ /**
345
+ * Read the stored browser session for request authentication.
346
+ * @returns {Promise<Record<string, any> | undefined>} a non-expired session for this forum.
347
+ */
348
+ async function readSession() {
349
+ const credentials = getCredentials()
350
+ if (credentials === undefined || credentials === null) return undefined
351
+ try {
352
+ const record = await credentials.readRecord(SESSION_KEY)
353
+ const projected = readSessionRecord(record, config)
354
+ if (projected.session !== undefined && projected.session.expired !== true) return projected.session
355
+ } catch {
356
+ // A storage failure falls back to the configured API key or anonymous reads.
357
+ }
358
+ return undefined
359
+ }
360
+
361
+ /**
362
+ * Compose request authorization for one operation.
363
+ * @param {{ anonymous?: boolean, privileged?: boolean, userApiKey?: string }} options - caller intent.
364
+ * @returns {Promise<Record<string, string>>} the authorization headers.
365
+ */
366
+ async function authHeaders(options) {
367
+ if (typeof options.userApiKey === 'string' && options.userApiKey.length > 0) {
368
+ return { 'User-Api-Key': options.userApiKey }
369
+ }
370
+ if (options.anonymous === true) return {}
371
+ const apiHeaders = config.apiKey.length > 0
372
+ ? { 'Api-Key': config.apiKey, 'Api-Username': config.apiUsername }
373
+ : {}
374
+ if (config.authMode === 'api-key') return apiHeaders
375
+ const session = await readSession()
376
+ if (config.authMode === 'user') {
377
+ if (session === undefined) {
378
+ fail('Discourse browser sign-in is required by `authMode: user`, but no valid session is stored. Run discourse_login with action "start".')
379
+ }
380
+ return {
381
+ 'User-Api-Key': session.key,
382
+ ...(session.clientId.length > 0 ? { 'User-Api-Client-Id': session.clientId } : {}),
383
+ }
384
+ }
385
+ if (options.privileged === true && config.apiKey.length > 0) return apiHeaders
386
+ if (session !== undefined) {
387
+ return {
388
+ 'User-Api-Key': session.key,
389
+ ...(session.clientId.length > 0 ? { 'User-Api-Client-Id': session.clientId } : {}),
390
+ }
391
+ }
392
+ return apiHeaders
393
+ }
394
+
208
395
  /**
209
396
  * Issue one Discourse request and decode its JSON body.
210
397
  * @param {string} method - HTTP method.
211
398
  * @param {string} path - absolute path such as `/latest.json`.
212
- * @param {{ query?: Record<string, unknown>, body?: unknown, signal?: AbortSignal }} [options] - query values, JSON body, and caller cancellation.
399
+ * @param {{ query?: Record<string, unknown>, body?: unknown, signal?: AbortSignal, anonymous?: boolean, privileged?: boolean, userApiKey?: string }} [options] - query values, JSON body, caller cancellation, and authentication intent.
213
400
  * @returns {Promise<any>} the decoded body, or text for non-JSON responses.
214
401
  */
215
402
  async function call(method, path, options = {}) {
@@ -222,11 +409,7 @@ function createClient(config) {
222
409
  const suffix = search.size > 0 ? `?${search.toString()}` : ''
223
410
  const url = `${config.baseUrl}${path}${suffix}`
224
411
 
225
- const headers = { Accept: 'application/json' }
226
- if (config.apiKey.length > 0) {
227
- headers['Api-Key'] = config.apiKey
228
- headers['Api-Username'] = config.apiUsername
229
- }
412
+ const headers = { Accept: 'application/json', ...(await authHeaders(options)) }
230
413
  if (options.body !== undefined) headers['Content-Type'] = 'application/json'
231
414
 
232
415
  const deadline = AbortSignal.timeout(config.timeoutMs)
@@ -249,6 +432,7 @@ function createClient(config) {
249
432
  fail(`Discourse request failed: ${method} ${path} (${reason})`)
250
433
  }
251
434
 
435
+ const usedSession = headers['User-Api-Key'] !== undefined
252
436
  const text = await response.text()
253
437
  let payload
254
438
  try {
@@ -259,11 +443,17 @@ function createClient(config) {
259
443
 
260
444
  if (!response.ok) {
261
445
  const detail = describeError(payload)
262
- const hint = response.status === 403
263
- ? ' The API key needs an admin-scoped key for this endpoint.'
264
- : response.status === 404
265
- ? ' Check that the forum URL and the target id are correct.'
266
- : ''
446
+ const hint = response.status === 401 && usedSession
447
+ ? ' The stored Discourse session was rejected; run discourse_login with action "start" to sign in again.'
448
+ : response.status === 403
449
+ ? options.anonymous === true
450
+ ? ' The forum rejected anonymous user API key authorization; check that user API keys are enabled and the requested scopes are allowed.'
451
+ : usedSession
452
+ ? ' The signed-in user API key may lack the scope this endpoint needs; staff and admin endpoints require the configured apiKey.'
453
+ : ' The API key needs an admin-scoped key for this endpoint.'
454
+ : response.status === 404
455
+ ? ' Check that the forum URL and the target id are correct.'
456
+ : ''
267
457
  fail(`Discourse ${method} ${path} failed (HTTP ${response.status}): ${detail}${hint}`)
268
458
  }
269
459
  return payload
@@ -336,6 +526,880 @@ function createClient(config) {
336
526
  return { call, writable, categories, resolveCategory, config }
337
527
  }
338
528
 
529
+ // ---------------------------------------------------------------------------
530
+ // Browser authorization login
531
+ // ---------------------------------------------------------------------------
532
+
533
+ /**
534
+ * Locate one service without requiring it at activation.
535
+ * @param {any} context - a Cordis context or the smoke test's stub.
536
+ * @param {string} name - service name.
537
+ * @returns {any} the service, or undefined when the profile does not provide it.
538
+ */
539
+ function getService(context, name) {
540
+ if (context === undefined || context === null) return undefined
541
+ if (typeof context.get === 'function') {
542
+ try {
543
+ return context.get(name)
544
+ } catch {
545
+ return undefined
546
+ }
547
+ }
548
+ return context[name]
549
+ }
550
+
551
+ /**
552
+ * Resolve after a delay, rejecting when the caller's signal is withdrawn.
553
+ * @param {number} ms - delay in milliseconds.
554
+ * @param {AbortSignal} [signal] - attempt cancellation.
555
+ * @returns {Promise<void>} resolves after the delay.
556
+ */
557
+ function sleep(ms, signal) {
558
+ return new Promise((resolve, reject) => {
559
+ if (signal?.aborted === true) {
560
+ reject(new Error('the Discourse sign-in attempt was cancelled'))
561
+ return
562
+ }
563
+ const timer = setTimeout(() => {
564
+ cleanup()
565
+ resolve()
566
+ }, ms)
567
+ const onAbort = () => {
568
+ cleanup()
569
+ reject(new Error('the Discourse sign-in attempt was cancelled'))
570
+ }
571
+ function cleanup() {
572
+ clearTimeout(timer)
573
+ signal?.removeEventListener('abort', onAbort)
574
+ }
575
+ signal?.addEventListener('abort', onAbort, { once: true })
576
+ })
577
+ }
578
+
579
+ /**
580
+ * Race one promise against a deadline without leaving a live timer behind.
581
+ * @param {Promise<any>} promise - the operation to bound.
582
+ * @param {number} ms - deadline in milliseconds.
583
+ * @returns {Promise<any>} the operation's value.
584
+ */
585
+ function withTimeout(promise, ms) {
586
+ return new Promise((resolve, reject) => {
587
+ const timer = setTimeout(() => reject(new Error('timed out waiting for Discourse')), ms)
588
+ Promise.resolve(promise).then(
589
+ (value) => {
590
+ clearTimeout(timer)
591
+ resolve(value)
592
+ },
593
+ (error) => {
594
+ clearTimeout(timer)
595
+ reject(error)
596
+ },
597
+ )
598
+ })
599
+ }
600
+
601
+ /**
602
+ * Race one promise against a deadline, resolving undefined on timeout and
603
+ * clearing the deadline timer when the operation settles first.
604
+ * @param {Promise<any>} promise - the operation to bound.
605
+ * @param {number} ms - deadline in milliseconds.
606
+ * @returns {Promise<any>} the operation's value, or undefined on timeout.
607
+ */
608
+ function raceTimeout(promise, ms) {
609
+ return new Promise((resolve, reject) => {
610
+ const timer = setTimeout(() => resolve(undefined), ms)
611
+ Promise.resolve(promise).then(
612
+ (value) => {
613
+ clearTimeout(timer)
614
+ resolve(value)
615
+ },
616
+ (error) => {
617
+ clearTimeout(timer)
618
+ reject(error)
619
+ },
620
+ )
621
+ })
622
+ }
623
+
624
+ /**
625
+ * Whether a device-authorization request failed because the forum predates it.
626
+ * @param {unknown} error - request failure.
627
+ * @returns {boolean} true when the classic flow should be attempted.
628
+ */
629
+ function isMissingDeviceRoute(error) {
630
+ const message = error instanceof Error ? error.message : String(error)
631
+ return /HTTP (?:404|405|501)\b/.test(message)
632
+ }
633
+
634
+ /**
635
+ * Extract a Discourse encrypted payload from a pasted value.
636
+ *
637
+ * The classic authorization page shows the payload as text; a redirect URL
638
+ * carries it as `?payload=…`, and a copied JSON response carries the same
639
+ * field. All three shapes are accepted, including a whole page whose longest
640
+ * Base64 run is the payload.
641
+ *
642
+ * @param {unknown} input - payload text, JSON, or URL supplied by the user.
643
+ * @returns {string} the Base64 ciphertext.
644
+ */
645
+ function extractEncryptedPayload(input) {
646
+ let text = requiredString(input, 'payload')
647
+ if (/^[{[]/.test(text)) {
648
+ try {
649
+ const parsed = JSON.parse(text)
650
+ if (typeof parsed?.payload === 'string') text = parsed.payload
651
+ } catch {
652
+ // Not JSON; keep looking for a URL or Base64 text.
653
+ }
654
+ }
655
+ const queryMatch = /[?&]payload=([^&\s]+)/.exec(text)
656
+ if (queryMatch !== null) {
657
+ try {
658
+ text = decodeURIComponent(queryMatch[1])
659
+ } catch {
660
+ text = queryMatch[1]
661
+ }
662
+ }
663
+ const compact = text.replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/')
664
+ if (compact.length >= 60 && /^[A-Za-z0-9+/=]+$/.test(compact)) return compact
665
+ const candidates = text.match(/[A-Za-z0-9+/=_-]{60,}/g) ?? []
666
+ if (candidates.length === 0) {
667
+ fail('invalid payload: paste the encrypted payload that Discourse shows after you approve, or the full redirect URL')
668
+ }
669
+ const longest = candidates.reduce((best, candidate) => (candidate.length > best.length ? candidate : best), '')
670
+ return longest.replace(/-/g, '+').replace(/_/g, '/')
671
+ }
672
+
673
+ /**
674
+ * Decrypt one Discourse user API key payload with the attempt's private key.
675
+ * @param {string} privateKey - PEM-encoded private key generated for the attempt.
676
+ * @param {string} payload - Base64 ciphertext from the poll or the clipboard.
677
+ * @returns {Record<string, any>} the decrypted payload with `key` and `nonce`.
678
+ */
679
+ function decryptLoginPayload(privateKey, payload) {
680
+ const normalized = String(payload ?? '').replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/')
681
+ if (normalized.length === 0) fail('invalid payload: the encrypted payload is empty')
682
+ const ciphertext = Buffer.from(normalized, 'base64')
683
+ let lastError
684
+ for (const options of [
685
+ { padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha1' },
686
+ { padding: cryptoConstants.RSA_PKCS1_PADDING },
687
+ ]) {
688
+ try {
689
+ const plaintext = privateDecrypt({ key: privateKey, ...options }, ciphertext).toString('utf8')
690
+ const parsed = JSON.parse(plaintext)
691
+ if (parsed !== null && typeof parsed === 'object' && typeof parsed.key === 'string' && parsed.key.length > 0) return parsed
692
+ lastError = new Error('the decrypted payload carries no user API key')
693
+ } catch (error) {
694
+ lastError = error
695
+ }
696
+ }
697
+ const reason = lastError instanceof Error ? lastError.message : String(lastError)
698
+ fail(`could not decrypt the Discourse authorization payload (${reason}); start the sign-in again`)
699
+ }
700
+
701
+ /**
702
+ * Build the classic user API key authorization URL.
703
+ *
704
+ * The classic page predates the device flow, so it is also the compatibility
705
+ * path for older forums. It deliberately omits `padding`: those forums may not
706
+ * know the parameter and use PKCS#1, while `decryptLoginPayload` accepts either
707
+ * padding mode. The device flow, which targets current forums, always uses OAEP.
708
+ *
709
+ * @param {ReturnType<typeof readConfig>} config - resolved configuration.
710
+ * @param {{ publicKey: string, nonce: string, scopes: string[] }} plan - attempt key material and scopes.
711
+ * @returns {string} the authorization page URL.
712
+ */
713
+ function buildManualLoginUrl(config, plan) {
714
+ const query = new URLSearchParams({
715
+ application_name: config.loginApplicationName,
716
+ client_id: config.loginClientId,
717
+ nonce: plan.nonce,
718
+ scopes: plan.scopes.join(','),
719
+ public_key: plan.publicKey,
720
+ })
721
+ return `${config.baseUrl}/user-api-key/new?${query.toString()}`
722
+ }
723
+
724
+ /**
725
+ * Build the browser sign-in controller for one plugin activation.
726
+ *
727
+ * The controller owns the current attempt, the interaction a tool call sees,
728
+ * and the credential record read by the HTTP client. Discourse's device flow
729
+ * (Auth API v4) is preferred; a forum without it falls back to the classic
730
+ * page whose encrypted payload the user pastes back.
731
+ *
732
+ * @param {any} ctx - plugin context.
733
+ * @param {ReturnType<typeof readConfig>} config - resolved configuration.
734
+ * @param {ReturnType<typeof createClient>} client - request client.
735
+ * @returns {{ attach: Function, start: Function, status: Function, complete: Function, cancel: Function, logout: Function, waitFor: Function, dispose: Function }} the controller.
736
+ */
737
+ function createLogin(ctx, config, client) {
738
+ /** Credential and authorization services, once their provider is mounted. */
739
+ let services
740
+ /** The attempt started by this plugin, including its interaction surface. */
741
+ let attempt
742
+ /** Key material and mode for the attempt currently running through the authorization seam. */
743
+ let activePlan
744
+ let disposed = false
745
+ let attachError
746
+
747
+ const host = (() => {
748
+ try {
749
+ return new URL(config.baseUrl).host
750
+ } catch {
751
+ return config.baseUrl
752
+ }
753
+ })()
754
+ const flowLabel = `Discourse (${host})`
755
+
756
+ /**
757
+ * Offer the sign-in flow to the authorization service.
758
+ * @param {any} scope - context carrying `credentials` and `authorization`.
759
+ * @returns {boolean} whether the flow is registered.
760
+ */
761
+ function attach(scope) {
762
+ if (disposed) return false
763
+ if (services !== undefined) return true
764
+ const credentials = getService(scope, 'credentials')
765
+ const authorization = getService(scope, 'authorization')
766
+ if (credentials === undefined || authorization === undefined) return false
767
+ const flow = {
768
+ key: SESSION_KEY,
769
+ label: flowLabel,
770
+ methods: [{ id: 'browser', label: 'Sign in with browser' }],
771
+ run: (session) => run(session),
772
+ }
773
+ let disposeFlow
774
+ try {
775
+ disposeFlow = authorization.registerFlow(flow)
776
+ } catch (error) {
777
+ attachError = error instanceof Error ? error.message : String(error)
778
+ return false
779
+ }
780
+ const attached = { credentials, authorization }
781
+ services = attached
782
+ const effectHost = typeof scope.effect === 'function' ? scope : ctx
783
+ if (typeof effectHost?.effect === 'function') {
784
+ effectHost.effect(() => () => {
785
+ disposeFlow()
786
+ if (services === attached) services = undefined
787
+ }, 'discourse:authorization flow')
788
+ }
789
+ return true
790
+ }
791
+
792
+ /**
793
+ * Fail unless the credential store and authorization seam are available.
794
+ * @returns {{ credentials: any, authorization: any }} the services.
795
+ */
796
+ function requireServices() {
797
+ if (disposed) fail('the Discourse plugin is shutting down')
798
+ if (services !== undefined) return services
799
+ if (!attach(ctx)) {
800
+ fail(attachError !== undefined
801
+ ? `Discourse browser sign-in is unavailable: ${attachError}`
802
+ : 'Discourse browser sign-in needs the DSH credential store and authorization service. This profile does not provide them.')
803
+ }
804
+ return services
805
+ }
806
+
807
+ /**
808
+ * Generate the per-attempt RSA key pair and nonce.
809
+ * @param {string[]} scopes - scopes to request.
810
+ * @param {'auto' | 'device' | 'manual'} mode - transport preference.
811
+ * @returns {{ publicKey: string, privateKey: string, nonce: string, scopes: string[], mode: string }} the attempt plan.
812
+ */
813
+ function createPlan(scopes, mode) {
814
+ const { publicKey, privateKey } = generateKeyPairSync('rsa', {
815
+ modulusLength: 2048,
816
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
817
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
818
+ })
819
+ return {
820
+ publicKey,
821
+ privateKey,
822
+ nonce: randomBytes(32).toString('hex'),
823
+ scopes: [...scopes],
824
+ mode,
825
+ }
826
+ }
827
+
828
+ /**
829
+ * Read the approving account's username without making sign-in depend on it.
830
+ * @param {string} key - freshly issued user API key.
831
+ * @param {AbortSignal} signal - attempt cancellation.
832
+ * @returns {Promise<Record<string, any> | undefined>} the account, or undefined when the scope cannot read it.
833
+ */
834
+ async function readLoginProfile(key, signal) {
835
+ try {
836
+ const payload = await client.call('GET', '/session/current.json', { userApiKey: key, signal })
837
+ const current = payload?.current_user ?? payload?.user
838
+ return current !== null && typeof current === 'object' ? current : undefined
839
+ } catch (error) {
840
+ // A 401 means the freshly issued key is unusable; do not store it. A
841
+ // missing `session_info` scope (403) only costs the friendly username.
842
+ if (/HTTP 401\b/.test(error instanceof Error ? error.message : String(error))) throw error
843
+ return undefined
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Validate and commit one decrypted payload through the authorization seam.
849
+ * @param {any} session - authorization session.
850
+ * @param {{ nonce: string, scopes: string[] }} plan - attempt plan.
851
+ * @param {Record<string, any>} data - decrypted payload.
852
+ * @param {'device' | 'manual'} source - which browser path produced it.
853
+ * @returns {Promise<void>} resolves after the credential record is stored.
854
+ */
855
+ async function commitSession(session, plan, data, source) {
856
+ if (typeof data.nonce !== 'string' || data.nonce !== plan.nonce) {
857
+ fail('the Discourse authorization payload nonce did not match this attempt; start the sign-in again')
858
+ }
859
+ if (typeof data.key !== 'string' || data.key.length === 0) {
860
+ fail('the Discourse authorization payload carried no user API key')
861
+ }
862
+ const profile = await readLoginProfile(data.key, session.signal)
863
+ await session.commit({
864
+ kind: 'grant',
865
+ payload: {
866
+ version: 1,
867
+ baseUrl: config.baseUrl,
868
+ key: data.key,
869
+ username: typeof profile?.username === 'string' && profile.username.length > 0 ? profile.username : null,
870
+ clientId: config.loginClientId,
871
+ scopes: [...plan.scopes],
872
+ apiVersion: typeof data.api === 'number' ? data.api : null,
873
+ createdAt: new Date().toISOString(),
874
+ expiresAt: typeof data.expires_at === 'string' && data.expires_at.length > 0 ? data.expires_at : null,
875
+ source,
876
+ },
877
+ })
878
+ }
879
+
880
+ /**
881
+ * Run Discourse's device authorization flow: request a code, notify the
882
+ * browser destination, poll, decrypt, and commit.
883
+ * @param {any} session - authorization session.
884
+ * @param {any} plan - attempt plan.
885
+ * @returns {Promise<void>} resolves after sign-in.
886
+ */
887
+ async function runDevice(session, plan) {
888
+ let request
889
+ try {
890
+ request = await client.call('POST', '/user-api-key/device.json', {
891
+ body: {
892
+ nonce: plan.nonce,
893
+ scopes: plan.scopes.join(','),
894
+ client_id: config.loginClientId,
895
+ application_name: config.loginApplicationName,
896
+ public_key: plan.publicKey,
897
+ padding: 'oaep',
898
+ },
899
+ anonymous: true,
900
+ signal: session.signal,
901
+ })
902
+ } catch (error) {
903
+ if (plan.mode === 'auto' && isMissingDeviceRoute(error)) return runManual(session, plan)
904
+ throw error
905
+ }
906
+ if (typeof request?.device_code !== 'string' || request.device_code.length === 0) {
907
+ if (plan.mode === 'auto') return runManual(session, plan)
908
+ fail('Discourse did not return a device authorization code')
909
+ }
910
+ const deviceCode = request.device_code
911
+ const rawUrl = optionalString(request.verification_uri_with_request) ?? optionalString(request.verification_uri)
912
+ if (rawUrl === undefined) fail('Discourse did not return a verification URL for the device authorization code')
913
+ let verificationUrl
914
+ try {
915
+ verificationUrl = new URL(rawUrl, config.baseUrl).href
916
+ } catch {
917
+ fail(`Discourse returned an invalid verification URL: ${JSON.stringify(rawUrl)}`)
918
+ }
919
+ const userCode = optionalString(request.user_code)
920
+ const expiresIn = typeof request.expires_in === 'number' && Number.isFinite(request.expires_in) && request.expires_in > 0
921
+ ? request.expires_in
922
+ : 600
923
+ const interval = Math.max(1, Math.min(30, typeof request.interval === 'number' && Number.isFinite(request.interval) ? request.interval : 5))
924
+ const deadline = Math.min(Date.now() + config.loginTimeoutMs, Date.now() + expiresIn * 1000)
925
+ session.notify({
926
+ message: userCode === undefined
927
+ ? `Open ${verificationUrl} and approve the sign-in for ${host}.`
928
+ : `Open ${verificationUrl} and confirm the code ${userCode} to sign in to ${host}.`,
929
+ url: verificationUrl,
930
+ ...(userCode === undefined ? {} : { code: userCode }),
931
+ })
932
+ let failures = 0
933
+ for (;;) {
934
+ if (Date.now() >= deadline) fail('the Discourse device authorization code expired before it was approved; start the sign-in again')
935
+ await sleep(interval * 1000, session.signal)
936
+ let result
937
+ try {
938
+ result = await client.call('POST', '/user-api-key/device/poll.json', {
939
+ body: { device_code: deviceCode },
940
+ anonymous: true,
941
+ signal: session.signal,
942
+ })
943
+ failures = 0
944
+ } catch (error) {
945
+ if (session.signal.aborted === true) throw error
946
+ if (/HTTP 429\b/.test(error instanceof Error ? error.message : String(error))) {
947
+ await sleep(interval * 2000, session.signal)
948
+ continue
949
+ }
950
+ failures += 1
951
+ if (failures >= 3) throw error
952
+ continue
953
+ }
954
+ const status = result?.status
955
+ if (status === 'authorization_pending') continue
956
+ if (status === 'authorized') {
957
+ if (typeof result?.payload !== 'string' || result.payload.length === 0) {
958
+ fail('Discourse authorized the sign-in but returned no encrypted payload')
959
+ }
960
+ const data = decryptLoginPayload(plan.privateKey, result.payload)
961
+ await commitSession(session, plan, data, 'device')
962
+ return
963
+ }
964
+ if (status === 'access_denied') fail('the Discourse authorization request was denied in the browser')
965
+ if (status === 'expired_token') fail('the Discourse device authorization code expired; start the sign-in again')
966
+ fail(`Discourse returned an unexpected device authorization status ${JSON.stringify(status)}`)
967
+ }
968
+ }
969
+
970
+ /**
971
+ * Run the classic user API key page and finish from the pasted payload.
972
+ * @param {any} session - authorization session.
973
+ * @param {any} plan - attempt plan.
974
+ * @returns {Promise<void>} resolves after sign-in.
975
+ */
976
+ async function runManual(session, plan) {
977
+ const url = buildManualLoginUrl(config, plan)
978
+ session.notify({
979
+ message: `Open ${url}, approve the request, then copy the encrypted payload that Discourse shows and paste it back here.`,
980
+ url,
981
+ })
982
+ const pasted = await session.prompt({
983
+ kind: 'text',
984
+ message: 'Paste the encrypted payload that Discourse showed after you approved the request.',
985
+ })
986
+ const payload = extractEncryptedPayload(pasted)
987
+ const data = decryptLoginPayload(plan.privateKey, payload)
988
+ await commitSession(session, plan, data, 'manual')
989
+ }
990
+
991
+ /**
992
+ * Run one authorization attempt for whichever surface started it.
993
+ * @param {any} session - authorization session supplied by the seam.
994
+ * @returns {Promise<void>} resolves after the credential record is committed.
995
+ */
996
+ async function run(session) {
997
+ if (disposed) fail('the Discourse plugin is shutting down')
998
+ const plan = activePlan ?? createPlan(config.loginScopes, 'auto')
999
+ if (activePlan === undefined) activePlan = plan
1000
+ try {
1001
+ if (plan.mode === 'manual') return await runManual(session, plan)
1002
+ return await runDevice(session, plan)
1003
+ } finally {
1004
+ if (activePlan === plan) activePlan = undefined
1005
+ }
1006
+ }
1007
+
1008
+ /**
1009
+ * Project the active attempt onto safe status fields.
1010
+ * @returns {Record<string, unknown> | undefined} the attempt view.
1011
+ */
1012
+ function publicAttempt() {
1013
+ if (attempt === undefined) return undefined
1014
+ const view = {
1015
+ status: attempt.state,
1016
+ startedAt: attempt.startedAt,
1017
+ expiresAt: attempt.expiresAt,
1018
+ }
1019
+ if (attempt.mode !== undefined) view.mode = attempt.mode
1020
+ if (attempt.url !== undefined) view.url = attempt.url
1021
+ if (attempt.userCode !== undefined) view.userCode = attempt.userCode
1022
+ if (attempt.message !== undefined) view.message = attempt.message
1023
+ if (attempt.prompt !== undefined) view.prompt = attempt.prompt
1024
+ if (attempt.error !== undefined) view.error = attempt.error
1025
+ if (attempt.abortReason === 'timeout' && attempt.state !== 'authorized') view.status = 'expired'
1026
+ return view
1027
+ }
1028
+
1029
+ /**
1030
+ * Build the safe status the tool reports.
1031
+ * @returns {Promise<Record<string, unknown>>} sign-in state, active credential source, and any attempt.
1032
+ */
1033
+ async function status() {
1034
+ const credentials = services?.credentials ?? getService(ctx, 'credentials')
1035
+ const read = await readStoredSession(credentials, config)
1036
+ const session = read.session !== undefined && read.session.expired !== true ? read.session : undefined
1037
+ const apiKeyConfigured = config.apiKey.length > 0
1038
+ let activeSource = 'anonymous'
1039
+ if (config.authMode === 'api-key') activeSource = apiKeyConfigured ? 'api-key' : 'anonymous'
1040
+ else if (config.authMode === 'user') activeSource = session === undefined ? 'none' : 'user-session'
1041
+ else activeSource = session !== undefined ? 'user-session' : apiKeyConfigured ? 'api-key' : 'anonymous'
1042
+ const staffSource = config.authMode === 'user'
1043
+ ? (session === undefined ? 'none' : 'user-session')
1044
+ : config.authMode === 'api-key'
1045
+ ? (apiKeyConfigured ? 'api-key' : 'anonymous')
1046
+ : (session !== undefined && apiKeyConfigured ? 'api-key' : activeSource)
1047
+ return {
1048
+ baseUrl: config.baseUrl,
1049
+ authMode: config.authMode,
1050
+ signedIn: session !== undefined,
1051
+ storageAvailable: read.storageAvailable,
1052
+ apiKeyConfigured,
1053
+ activeSource,
1054
+ staffSource,
1055
+ ...(session === undefined ? {} : { session: publicSession(session) }),
1056
+ ...(read.session?.expired === true ? { sessionExpired: true } : {}),
1057
+ ...(read.error === undefined ? {} : { sessionError: read.error }),
1058
+ ...(attempt === undefined ? {} : { attempt: publicAttempt() }),
1059
+ ...(config.authMode === 'user' && session === undefined
1060
+ ? { warning: 'authMode is "user" but no valid browser session is stored; run discourse_login with action "start".' }
1061
+ : {}),
1062
+ }
1063
+ }
1064
+
1065
+ /**
1066
+ * Reject a pending prompt and release the attempt timer without cancelling
1067
+ * another surface's flow.
1068
+ * @returns {void}
1069
+ */
1070
+ function discardAttempt() {
1071
+ if (attempt === undefined) return
1072
+ if (attempt.timer !== undefined) clearTimeout(attempt.timer)
1073
+ if (typeof attempt.promptReject === 'function') {
1074
+ try {
1075
+ attempt.promptReject(new Error('the Discourse sign-in attempt ended'))
1076
+ } catch {
1077
+ // The prompt was already settled.
1078
+ }
1079
+ }
1080
+ attempt = undefined
1081
+ activePlan = undefined
1082
+ }
1083
+
1084
+ /**
1085
+ * Finish an attempt, keeping its outcome for the next status call.
1086
+ * @param {{ state: string, error?: string }} next - outcome fields.
1087
+ * @returns {void}
1088
+ */
1089
+ function finishAttempt(next) {
1090
+ if (attempt === undefined) return
1091
+ attempt.state = next.state
1092
+ if (next.error !== undefined) attempt.error = next.error
1093
+ if (attempt.timer !== undefined) clearTimeout(attempt.timer)
1094
+ attempt.timer = undefined
1095
+ if (typeof attempt.promptReject === 'function') {
1096
+ try {
1097
+ attempt.promptReject(new Error('the Discourse sign-in attempt ended'))
1098
+ } catch {
1099
+ // The prompt was already settled.
1100
+ }
1101
+ }
1102
+ attempt.promptResolve = undefined
1103
+ attempt.promptReject = undefined
1104
+ activePlan = undefined
1105
+ }
1106
+
1107
+ /**
1108
+ * Cancel the active attempt and the seam's running flow.
1109
+ * @returns {void}
1110
+ */
1111
+ function clearAttempt() {
1112
+ if (attempt === undefined) return
1113
+ const controller = attempt.controller
1114
+ discardAttempt()
1115
+ if (services?.authorization !== undefined) {
1116
+ try {
1117
+ services.authorization.cancel(SESSION_KEY)
1118
+ } catch {
1119
+ // The seam may already have released the key.
1120
+ }
1121
+ }
1122
+ if (controller !== undefined && controller.signal.aborted !== true) {
1123
+ try {
1124
+ controller.abort()
1125
+ } catch {
1126
+ // An already aborted controller is harmless.
1127
+ }
1128
+ }
1129
+ }
1130
+
1131
+ /**
1132
+ * Start a browser sign-in, or report the existing session/attempt.
1133
+ * @param {{ scopes?: string | string[], force?: boolean, mode?: 'auto' | 'device' | 'manual' }} [options] - requested scopes, restart, and transport preference.
1134
+ * @returns {Promise<Record<string, unknown>>} status including the verification URL when pending.
1135
+ */
1136
+ async function start(options = {}) {
1137
+ const { authorization } = requireServices()
1138
+ const mode = options.mode === undefined ? 'auto' : String(options.mode).toLowerCase()
1139
+ if (mode !== 'auto' && mode !== 'device' && mode !== 'manual') fail('invalid arguments: `mode` must be "auto", "device", or "manual"')
1140
+ if (options.force !== true) {
1141
+ const current = await status()
1142
+ if (current.signedIn === true) return { ...current, alreadySignedIn: true }
1143
+ }
1144
+ if (attempt !== undefined && (attempt.state === 'pending' || attempt.state === 'starting')) return status()
1145
+ if (attempt !== undefined) clearAttempt()
1146
+ const scopes = options.scopes === undefined ? config.loginScopes : readLoginScopes(options.scopes)
1147
+ let plan
1148
+ try {
1149
+ plan = createPlan(scopes, mode)
1150
+ } catch (error) {
1151
+ fail(`could not generate the browser sign-in key pair: ${error instanceof Error ? error.message : String(error)}`)
1152
+ }
1153
+ const controller = new AbortController()
1154
+ let resolveStarted
1155
+ const started = new Promise((resolve) => {
1156
+ resolveStarted = resolve
1157
+ })
1158
+ const currentAttempt = {
1159
+ state: 'starting',
1160
+ startedAt: new Date().toISOString(),
1161
+ expiresAt: new Date(Date.now() + config.loginTimeoutMs).toISOString(),
1162
+ controller,
1163
+ timer: undefined,
1164
+ url: undefined,
1165
+ userCode: undefined,
1166
+ message: undefined,
1167
+ mode: mode === 'manual' ? 'manual' : mode === 'device' ? 'device' : undefined,
1168
+ prompt: undefined,
1169
+ promptResolve: undefined,
1170
+ promptReject: undefined,
1171
+ error: undefined,
1172
+ abortReason: undefined,
1173
+ promise: undefined,
1174
+ }
1175
+ attempt = currentAttempt
1176
+ activePlan = plan
1177
+ currentAttempt.timer = setTimeout(() => {
1178
+ currentAttempt.abortReason = 'timeout'
1179
+ controller.abort()
1180
+ }, config.loginTimeoutMs)
1181
+ currentAttempt.timer.unref?.()
1182
+ const interaction = {
1183
+ notify(notice) {
1184
+ if (typeof notice?.url === 'string' && notice.url.length > 0) {
1185
+ currentAttempt.url = notice.url
1186
+ currentAttempt.mode = /\/user-api-key\/activate(?:[?#]|$)/.test(notice.url) ? 'device' : 'manual'
1187
+ }
1188
+ if (typeof notice?.message === 'string') currentAttempt.message = notice.message
1189
+ if (typeof notice?.code === 'string') currentAttempt.userCode = notice.code
1190
+ currentAttempt.state = 'pending'
1191
+ resolveStarted()
1192
+ },
1193
+ prompt(question) {
1194
+ currentAttempt.mode = 'manual'
1195
+ currentAttempt.state = 'pending'
1196
+ if (typeof question?.message === 'string') currentAttempt.prompt = question.message
1197
+ resolveStarted()
1198
+ return new Promise((resolve, reject) => {
1199
+ currentAttempt.promptResolve = resolve
1200
+ currentAttempt.promptReject = reject
1201
+ })
1202
+ },
1203
+ }
1204
+ let beginPromise
1205
+ try {
1206
+ beginPromise = authorization.begin({ key: SESSION_KEY, signal: controller.signal, interaction })
1207
+ } catch (error) {
1208
+ discardAttempt()
1209
+ throw error
1210
+ }
1211
+ currentAttempt.promise = beginPromise
1212
+ const early = beginPromise.then((outcome) => ({ outcome }), (error) => ({ error }))
1213
+ void early.then((result) => {
1214
+ resolveStarted()
1215
+ if (attempt !== currentAttempt) return
1216
+ if (result.error !== undefined) {
1217
+ if (result.error?.code === 'ALREADY_IN_FLIGHT') {
1218
+ discardAttempt()
1219
+ return
1220
+ }
1221
+ finishAttempt({ state: 'failed', error: result.error instanceof Error ? result.error.message : String(result.error) })
1222
+ return
1223
+ }
1224
+ if (result.outcome?.status === 'authorized') {
1225
+ finishAttempt({ state: 'authorized' })
1226
+ } else {
1227
+ finishAttempt({ state: currentAttempt.abortReason === 'timeout' ? 'expired' : 'cancelled' })
1228
+ }
1229
+ })
1230
+ const first = await raceTimeout(Promise.race([started.then(() => 'started'), early]), 15000)
1231
+ if (first !== 'started' && first?.error !== undefined) {
1232
+ if (first.error?.code === 'ALREADY_IN_FLIGHT') {
1233
+ discardAttempt()
1234
+ fail('another Discourse browser sign-in is already in progress; finish or cancel it before starting a new one')
1235
+ }
1236
+ fail(first.error instanceof Error ? first.error.message : String(first.error))
1237
+ }
1238
+ const current = await status()
1239
+ if (current.attempt?.status === 'failed') fail(String(current.attempt.error ?? 'Discourse browser sign-in failed'))
1240
+ if (first?.outcome?.status === 'cancelled') {
1241
+ fail(currentAttempt.abortReason === 'timeout'
1242
+ ? 'Discourse browser sign-in expired before the authorization page was opened'
1243
+ : 'Discourse browser sign-in was cancelled')
1244
+ }
1245
+ return current
1246
+ }
1247
+
1248
+ /**
1249
+ * Finish a classic-flow sign-in with the payload the user copied.
1250
+ * @param {unknown} input - encrypted payload, redirect URL, or page text.
1251
+ * @returns {Promise<Record<string, unknown>>} the signed-in status.
1252
+ */
1253
+ async function complete(input) {
1254
+ requireServices()
1255
+ const payload = extractEncryptedPayload(input)
1256
+ if (attempt === undefined || attempt.state !== 'pending' || typeof attempt.promptResolve !== 'function') {
1257
+ const current = await status()
1258
+ if (current.signedIn === true) return { ...current, alreadySignedIn: true }
1259
+ if (attempt?.mode === 'device') {
1260
+ fail('this Discourse sign-in finishes in the browser; no payload is needed. Run discourse_login with action "status".')
1261
+ }
1262
+ fail('no Discourse browser sign-in is waiting for a payload; run discourse_login with action "start" first')
1263
+ }
1264
+ attempt.promptResolve(payload)
1265
+ attempt.promptResolve = undefined
1266
+ attempt.promptReject = undefined
1267
+ let outcome
1268
+ try {
1269
+ outcome = await withTimeout(attempt.promise, 30000)
1270
+ } catch (error) {
1271
+ fail(error instanceof Error ? error.message : String(error))
1272
+ }
1273
+ if (outcome?.status !== 'authorized') {
1274
+ fail(String(attempt?.error ?? 'Discourse did not authorize the pasted payload'))
1275
+ }
1276
+ const current = await status()
1277
+ if (current.signedIn !== true) fail(String(current.attempt?.error ?? 'Discourse did not store the sign-in'))
1278
+ return current
1279
+ }
1280
+
1281
+ /**
1282
+ * Withdraw a pending browser attempt without touching a stored session.
1283
+ * @returns {Promise<Record<string, unknown>>} the resulting status.
1284
+ */
1285
+ async function cancel() {
1286
+ if (disposed) fail('the Discourse plugin is shutting down')
1287
+ const hadAttempt = attempt !== undefined && (attempt.state === 'pending' || attempt.state === 'starting')
1288
+ let wasInFlight = false
1289
+ if (services?.authorization !== undefined && typeof services.authorization.describe === 'function') {
1290
+ try {
1291
+ wasInFlight = services.authorization.describe(SESSION_KEY)?.inFlight === true
1292
+ } catch {
1293
+ // A service without describe support still accepts cancel below.
1294
+ }
1295
+ }
1296
+ if (attempt !== undefined) {
1297
+ const pending = attempt.promise
1298
+ attempt.abortReason = 'cancelled'
1299
+ clearAttempt()
1300
+ if (pending !== undefined) {
1301
+ try {
1302
+ await withTimeout(pending, 5000)
1303
+ } catch {
1304
+ // The seam releases the key when the withdrawn flow settles.
1305
+ }
1306
+ }
1307
+ } else if (services?.authorization !== undefined) {
1308
+ try {
1309
+ services.authorization.cancel(SESSION_KEY)
1310
+ } catch {
1311
+ // The seam may already have released the key.
1312
+ }
1313
+ }
1314
+ return { ...(await status()), cancelled: hadAttempt || wasInFlight }
1315
+ }
1316
+
1317
+ /**
1318
+ * Revoke the stored user API key when possible and delete the local record.
1319
+ * @returns {Promise<Record<string, unknown>>} the sign-out result.
1320
+ */
1321
+ async function logout() {
1322
+ if (disposed) fail('the Discourse plugin is shutting down')
1323
+ // Stop a running attempt before reading the store: an attempt that already
1324
+ // committed would otherwise recreate the record after this sign-out.
1325
+ const pending = attempt?.promise
1326
+ clearAttempt()
1327
+ if (pending !== undefined) {
1328
+ try {
1329
+ await withTimeout(pending, 10000)
1330
+ } catch {
1331
+ // A withdrawn attempt may never settle; its commit, if admitted, still completes before the store read.
1332
+ }
1333
+ }
1334
+ const credentials = services?.credentials ?? getService(ctx, 'credentials')
1335
+ const read = await readStoredSession(credentials, config)
1336
+ if (read.error !== undefined && read.error.startsWith('the stored Discourse sign-in belongs to')) {
1337
+ fail(`${read.error}; change baseUrl back or remove the record from the credential store`)
1338
+ }
1339
+ let revoked = false
1340
+ let revokeWarning
1341
+ if (read.session !== undefined) {
1342
+ try {
1343
+ const result = await client.call('POST', '/user-api-key/revoke.json', { userApiKey: read.session.key })
1344
+ if (result === null || typeof result !== 'object') throw new Error('the forum returned a non-JSON response to the revoke request')
1345
+ revoked = true
1346
+ } catch (error) {
1347
+ revokeWarning = error instanceof Error ? error.message : String(error)
1348
+ }
1349
+ }
1350
+ if (credentials !== undefined && credentials !== null) {
1351
+ try {
1352
+ await credentials.deleteRecord(SESSION_KEY)
1353
+ } catch (error) {
1354
+ fail(`could not delete the stored Discourse sign-in: ${error instanceof Error ? error.message : String(error)}`)
1355
+ }
1356
+ }
1357
+ return {
1358
+ signedOut: true,
1359
+ hadSession: read.session !== undefined,
1360
+ storageAvailable: read.storageAvailable,
1361
+ revoked,
1362
+ ...(revokeWarning === undefined ? {} : { revokeWarning }),
1363
+ }
1364
+ }
1365
+
1366
+ /**
1367
+ * Wait for a running attempt, bounded by the caller's requested seconds.
1368
+ * @param {number} seconds - wait budget, 0-120.
1369
+ * @returns {Promise<Record<string, unknown>>} the resulting status.
1370
+ */
1371
+ async function waitFor(seconds) {
1372
+ const bounded = Math.max(0, Math.min(120, Math.round(Number(seconds) || 0)))
1373
+ if (bounded === 0 || attempt?.promise === undefined) return status()
1374
+ try {
1375
+ await withTimeout(attempt.promise, bounded * 1000)
1376
+ } catch {
1377
+ // A timeout leaves the attempt running; status reports that.
1378
+ }
1379
+ return status()
1380
+ }
1381
+
1382
+ /**
1383
+ * Release the attempt and service references without touching the stored session.
1384
+ * @returns {void}
1385
+ */
1386
+ function dispose() {
1387
+ disposed = true
1388
+ if (attempt?.controller !== undefined) {
1389
+ attempt.abortReason = 'dispose'
1390
+ try {
1391
+ attempt.controller.abort()
1392
+ } catch {
1393
+ // An already aborted controller is harmless.
1394
+ }
1395
+ }
1396
+ clearAttempt()
1397
+ services = undefined
1398
+ }
1399
+
1400
+ return { attach, start, status, complete, cancel, logout, waitFor, dispose }
1401
+ }
1402
+
339
1403
  // ---------------------------------------------------------------------------
340
1404
  // Projections
341
1405
  // ---------------------------------------------------------------------------
@@ -419,9 +1483,10 @@ function projectUser(user, summary) {
419
1483
  /**
420
1484
  * Build every model-facing tool for one client.
421
1485
  * @param {ReturnType<typeof createClient>} client - the request client.
1486
+ * @param {ReturnType<typeof createLogin>} login - the browser sign-in controller.
422
1487
  * @returns {Array<Record<string, any>>} registry-ready tool definitions.
423
1488
  */
424
- function buildTools(client) {
1489
+ function buildTools(client, login) {
425
1490
  const tools = []
426
1491
  const limit = (value) => integer(value, client.config.maxItems, MAX_LIMIT)
427
1492
  const page = (value) => integer(value, 1, 10000)
@@ -849,7 +1914,7 @@ function buildTools(client) {
849
1914
  },
850
1915
  async execute(args, exec) {
851
1916
  const status = typeof args?.status === 'string' ? args.status : 'pending'
852
- const payload = await client.call('GET', '/review.json', { query: { status }, signal: exec?.signal })
1917
+ const payload = await client.call('GET', '/review.json', { query: { status }, signal: exec?.signal, privileged: true })
853
1918
  const rows = Array.isArray(payload?.reviewables) ? payload.reviewables : Array.isArray(payload) ? payload : []
854
1919
  const reviewables = rows.slice(0, limit(args?.limit)).map((item) => ({
855
1920
  id: item?.id,
@@ -897,6 +1962,7 @@ function buildTools(client) {
897
1962
  await client.call('DELETE', `/posts/${postId}.json`, {
898
1963
  query: permanent ? { force_destroy: 'true' } : {},
899
1964
  signal: exec?.signal,
1965
+ privileged: true,
900
1966
  })
901
1967
  return { postId, permanent, deleted: true }
902
1968
  },
@@ -922,7 +1988,7 @@ function buildTools(client) {
922
1988
  async execute(args, exec) {
923
1989
  client.writable('restoring a post')
924
1990
  const postId = requiredInteger(args?.postId, 'postId')
925
- await client.call('PUT', `/posts/${postId}/recover.json`, { signal: exec?.signal })
1991
+ await client.call('PUT', `/posts/${postId}/recover.json`, { signal: exec?.signal, privileged: true })
926
1992
  return { postId, restored: true }
927
1993
  },
928
1994
  render(_args, value) {
@@ -954,7 +2020,7 @@ function buildTools(client) {
954
2020
  const status = typeof args?.status === 'string' ? args.status : 'active'
955
2021
  if (status !== 'all') query.status = status
956
2022
  if (optionalString(args?.query) !== undefined) query.query = optionalString(args.query)
957
- const payload = await client.call('GET', '/admin/users.json', { query, signal: exec?.signal })
2023
+ const payload = await client.call('GET', '/admin/users.json', { query, signal: exec?.signal, privileged: true })
958
2024
  const rows = Array.isArray(payload) ? payload : payload?.users ?? []
959
2025
  const users = (Array.isArray(rows) ? rows : []).slice(0, integer(args?.limit, 25, 100)).map((user) => ({
960
2026
  id: user?.id,
@@ -1009,21 +2075,21 @@ function buildTools(client) {
1009
2075
  client.writable('moderating a user')
1010
2076
  const username = requiredString(args?.username, 'username').replace(/^@/, '')
1011
2077
  const action = requiredString(args?.action, 'action')
1012
- const userId = await resolveUserId(client, username, exec?.signal)
2078
+ const userId = await resolveUserId(client, username, exec?.signal, { privileged: true })
1013
2079
  if (action === 'suspend' || action === 'silence') {
1014
2080
  const until = requiredString(args?.until, 'until')
1015
2081
  const body = action === 'suspend' ? { suspend_until: until } : { silenced_till: until }
1016
2082
  if (optionalString(args?.reason) !== undefined) body.reason = optionalString(args.reason)
1017
2083
  if (optionalString(args?.message) !== undefined) body.message = optionalString(args.message)
1018
- await client.call('PUT', `/admin/users/${userId}/${action}.json`, { body, signal: exec?.signal })
2084
+ await client.call('PUT', `/admin/users/${userId}/${action}.json`, { body, signal: exec?.signal, privileged: true })
1019
2085
  return { userId, username, action, until }
1020
2086
  }
1021
2087
  if (action === 'activate') {
1022
- await client.call('PUT', `/admin/users/${userId}/activate.json`, { signal: exec?.signal })
2088
+ await client.call('PUT', `/admin/users/${userId}/activate.json`, { signal: exec?.signal, privileged: true })
1023
2089
  return { userId, username, action }
1024
2090
  }
1025
2091
  const lifted = action === 'unsuspend' ? 'suspend' : 'silence'
1026
- await client.call('DELETE', `/admin/users/${userId}/${lifted}.json`, { signal: exec?.signal })
2092
+ await client.call('DELETE', `/admin/users/${userId}/${lifted}.json`, { signal: exec?.signal, privileged: true })
1027
2093
  return { userId, username, action }
1028
2094
  },
1029
2095
  render(_args, value) {
@@ -1059,10 +2125,11 @@ function buildTools(client) {
1059
2125
  await client.call('PUT', `/admin/site_settings/${encodeURIComponent(name)}.json`, {
1060
2126
  body: { [name]: coerceSettingValue(args.value) },
1061
2127
  signal: exec?.signal,
2128
+ privileged: true,
1062
2129
  })
1063
2130
  return { updated: true, name, value: args.value }
1064
2131
  }
1065
- const payload = await client.call('GET', '/admin/site_settings.json', { signal: exec?.signal })
2132
+ const payload = await client.call('GET', '/admin/site_settings.json', { signal: exec?.signal, privileged: true })
1066
2133
  const rows = Array.isArray(payload) ? payload : payload?.site_settings ?? []
1067
2134
  const filter = optionalString(args?.filter)?.toLowerCase()
1068
2135
  const name = optionalString(args?.name)?.toLowerCase()
@@ -1118,6 +2185,7 @@ function buildTools(client) {
1118
2185
  const payload = await client.call('GET', `/admin/reports/${report}.json`, {
1119
2186
  query: { start_date: isoDay(start), end_date: isoDay(end) },
1120
2187
  signal: exec?.signal,
2188
+ privileged: true,
1121
2189
  })
1122
2190
  const body = payload?.report ?? payload ?? {}
1123
2191
  const points = Array.isArray(body?.data) ? body.data.slice(-days) : []
@@ -1140,6 +2208,82 @@ function buildTools(client) {
1140
2208
  },
1141
2209
  })
1142
2210
 
2211
+ add({
2212
+ name: 'discourse_login',
2213
+ description: 'Sign in to Discourse through the user\'s browser and inspect or end the stored session. `start` returns a verification URL and code for the forum authorization page; after the user approves it, call `status` (optionally with `waitSeconds`) to collect the signed-in account. `complete` finishes older forums that display an encrypted payload to paste, `cancel` withdraws a pending attempt, and `logout` revokes the user API key. A signed-in user API key is scoped to that account; with `authMode: auto`, staff and admin tools keep using the configured apiKey.',
2214
+ parameters: {
2215
+ type: 'object',
2216
+ additionalProperties: false,
2217
+ properties: {
2218
+ action: { type: 'string', enum: ['start', 'status', 'complete', 'cancel', 'logout'], description: 'Operation to perform. `start` begins browser authorization, `status` reports the stored session and any pending attempt, `complete` accepts the encrypted payload from an older forum, `cancel` withdraws a pending attempt, and `logout` revokes the session.', default: 'status' },
2219
+ scopes: { type: 'string', description: 'Comma-separated scopes for `start`, overriding `loginScopes`; for example `read,session_info` for a read-only session.' },
2220
+ mode: { type: 'string', enum: ['auto', 'device', 'manual'], description: 'How `start` obtains authorization. `auto` uses Discourse\'s device code and falls back to the classic payload page, `device` requires the device code, and `manual` always uses the classic page.', default: 'auto' },
2221
+ force: { type: 'boolean', description: 'With `start`, authorize a new session even when one is already stored.', default: false },
2222
+ payload: { type: 'string', description: 'With `complete`, the encrypted payload shown by an older Discourse page after approval, or its full redirect URL.' },
2223
+ waitSeconds: { type: 'integer', description: 'With `status`, wait up to this many seconds for a pending browser sign-in before reporting, 0-120.', default: 0 },
2224
+ },
2225
+ },
2226
+ async execute(args, _exec) {
2227
+ const action = typeof args?.action === 'string' ? args.action : 'status'
2228
+ if (action === 'start') return login.start({ scopes: args?.scopes, mode: args?.mode, force: args?.force === true })
2229
+ if (action === 'status') {
2230
+ const wait = typeof args?.waitSeconds === 'number' ? integer(args.waitSeconds, 0, 120, 0) : 0
2231
+ return wait > 0 ? login.waitFor(wait) : login.status()
2232
+ }
2233
+ if (action === 'complete') return login.complete(args?.payload)
2234
+ if (action === 'cancel') return login.cancel()
2235
+ if (action === 'logout') return login.logout()
2236
+ fail('invalid arguments: `action` must be one of start, status, complete, cancel, or logout')
2237
+ },
2238
+ render(_args, value) {
2239
+ if (value.signedOut === true) {
2240
+ const lines = [value.revoked === true ? 'Signed out of Discourse and revoked the stored user API key.' : 'Signed out of Discourse.']
2241
+ if (value.revokeWarning !== undefined) lines.push(`The forum did not confirm revocation: ${value.revokeWarning}`)
2242
+ return [{ type: 'text', text: lines.join('\n') }]
2243
+ }
2244
+ if (value.signedIn === true) {
2245
+ const user = value.session?.username ? `@${value.session.username}` : 'the stored account'
2246
+ const lines = [`Signed in to ${value.baseUrl} as ${user}.`]
2247
+ if (value.cancelled === true) lines.push('Cancelled the pending browser sign-in.')
2248
+ if (Array.isArray(value.session?.scopes) && value.session.scopes.length > 0) lines.push(`Scopes: ${value.session.scopes.join(', ')}`)
2249
+ if (value.session?.expiresAt) lines.push(`Key expires: ${value.session.expiresAt}`)
2250
+ if (value.alreadySignedIn === true) lines.push('A browser sign-in is already stored; pass `force: true` to authorize again.')
2251
+ if (value.activeSource) lines.push(`Active credential: ${value.activeSource}`)
2252
+ if (value.attempt?.status === 'failed') lines.push(`The last sign-in attempt failed: ${value.attempt.error ?? 'unknown error'}`)
2253
+ else if (value.attempt?.status === 'expired') lines.push('The last sign-in attempt expired; start again if you need a fresh key.')
2254
+ return [{ type: 'text', text: lines.join('\n') }]
2255
+ }
2256
+ const lines = [`Not signed in to ${value.baseUrl}.`]
2257
+ if (value.cancelled === true) lines.push('Cancelled the pending browser sign-in.')
2258
+ if (value.apiKeyConfigured === true) lines.push('The configured API key is used when no browser session is stored.')
2259
+ if (value.sessionExpired === true) lines.push('The stored session has expired; start a new sign-in.')
2260
+ if (value.sessionError) lines.push(`Stored session problem: ${value.sessionError}`)
2261
+ if (value.warning) lines.push(value.warning)
2262
+ const attempt = value.attempt
2263
+ if (attempt !== undefined) {
2264
+ if (attempt.status === 'pending' || attempt.status === 'starting') {
2265
+ lines.push('', `Browser sign-in ${attempt.status}.`)
2266
+ if (attempt.mode) lines.push(`Mode: ${attempt.mode}`)
2267
+ if (attempt.url) lines.push(`Open: ${attempt.url}`)
2268
+ if (attempt.userCode) lines.push(`Code: ${attempt.userCode}`)
2269
+ if (attempt.prompt) lines.push(attempt.prompt)
2270
+ if (attempt.expiresAt) lines.push(`Expires: ${attempt.expiresAt}`)
2271
+ lines.push('After approving in the browser, run discourse_login with action "status".')
2272
+ } else if (attempt.status === 'failed') {
2273
+ lines.push('', `Browser sign-in failed: ${attempt.error ?? 'unknown error'}`)
2274
+ } else if (attempt.status === 'expired') {
2275
+ lines.push('', 'Browser sign-in expired; start again.')
2276
+ } else if (attempt.status === 'cancelled') {
2277
+ lines.push('', 'Browser sign-in was cancelled.')
2278
+ }
2279
+ }
2280
+ return [{ type: 'text', text: lines.join('\n') }]
2281
+ },
2282
+ presentCall(args) {
2283
+ return { card: 'generic', title: args?.action === 'start' ? 'Sign in to Discourse' : `Discourse login: ${clip(args?.action ?? 'status', 20)}`, kind: 'other' }
2284
+ },
2285
+ })
2286
+
1143
2287
  return tools
1144
2288
  }
1145
2289
 
@@ -1178,11 +2322,12 @@ function extractTopicId(input) {
1178
2322
  * @param {ReturnType<typeof createClient>} client - the request client.
1179
2323
  * @param {string} username - forum username.
1180
2324
  * @param {AbortSignal} [signal] - caller cancellation.
2325
+ * @param {{ privileged?: boolean }} [options] - staff/admin request intent.
1181
2326
  * @returns {Promise<number>} the user id.
1182
2327
  */
1183
- async function resolveUserId(client, username, signal) {
2328
+ async function resolveUserId(client, username, signal, options = {}) {
1184
2329
  if (/^\d+$/.test(username)) return Number(username)
1185
- const payload = await client.call('GET', `/u/${encodeURIComponent(username)}.json`, { signal })
2330
+ const payload = await client.call('GET', `/u/${encodeURIComponent(username)}.json`, { signal, privileged: options.privileged === true })
1186
2331
  const id = payload?.user?.id ?? payload?.id
1187
2332
  if (typeof id !== 'number') fail(`could not resolve @${username} to a user id on ${client.config.baseUrl}`)
1188
2333
  return id
@@ -1230,9 +2375,15 @@ function isoDay(date) {
1230
2375
  * @returns {void}
1231
2376
  */
1232
2377
  export function apply(ctx, config) {
1233
- const client = createClient(readConfig(config))
1234
- const tools = buildTools(client)
2378
+ const resolved = readConfig(config)
2379
+ const client = createClient(resolved, () => getService(ctx, 'credentials'))
2380
+ const login = createLogin(ctx, resolved, client)
2381
+ const tools = buildTools(client, login)
1235
2382
  for (const tool of tools) {
1236
2383
  ctx.effect(() => ctx.tools.register(tool), `discourse:${tool.name}`)
1237
2384
  }
2385
+ if (login.attach(ctx) !== true && typeof ctx.inject === 'function') {
2386
+ ctx.inject(['credentials', 'authorization'], (scope) => login.attach(scope))
2387
+ }
2388
+ ctx.effect(() => () => login.dispose(), 'discourse:login lifetime')
1238
2389
  }