dsh-plugin-discourse 1.0.1 → 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,10 +170,12 @@ 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 : {}
@@ -185,6 +200,19 @@ function readConfig(raw) {
185
200
  const maxItems = typeof source.maxItems === 'number' && Number.isFinite(source.maxItems)
186
201
  ? Math.min(MAX_LIMIT, Math.max(1, Math.round(source.maxItems)))
187
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
188
216
  return {
189
217
  baseUrl,
190
218
  apiKey,
@@ -192,6 +220,111 @@ function readConfig(raw) {
192
220
  readOnly: source.readOnly === true,
193
221
  timeoutMs,
194
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,
195
328
  }
196
329
  }
197
330
 
@@ -202,16 +335,68 @@ function readConfig(raw) {
202
335
  /**
203
336
  * Build the request client shared by every tool of one activation.
204
337
  * @param {ReturnType<typeof readConfig>} config - resolved configuration.
338
+ * @param {() => any} [getCredentials] - resolves the credential service on demand.
205
339
  * @returns {{ call: Function, writable: Function, categories: Function, resolveCategory: Function, config: object }} the client.
206
340
  */
207
- function createClient(config) {
341
+ function createClient(config, getCredentials = () => undefined) {
208
342
  const cache = { at: 0, entries: [] }
209
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
+
210
395
  /**
211
396
  * Issue one Discourse request and decode its JSON body.
212
397
  * @param {string} method - HTTP method.
213
398
  * @param {string} path - absolute path such as `/latest.json`.
214
- * @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.
215
400
  * @returns {Promise<any>} the decoded body, or text for non-JSON responses.
216
401
  */
217
402
  async function call(method, path, options = {}) {
@@ -224,11 +409,7 @@ function createClient(config) {
224
409
  const suffix = search.size > 0 ? `?${search.toString()}` : ''
225
410
  const url = `${config.baseUrl}${path}${suffix}`
226
411
 
227
- const headers = { Accept: 'application/json' }
228
- if (config.apiKey.length > 0) {
229
- headers['Api-Key'] = config.apiKey
230
- headers['Api-Username'] = config.apiUsername
231
- }
412
+ const headers = { Accept: 'application/json', ...(await authHeaders(options)) }
232
413
  if (options.body !== undefined) headers['Content-Type'] = 'application/json'
233
414
 
234
415
  const deadline = AbortSignal.timeout(config.timeoutMs)
@@ -251,6 +432,7 @@ function createClient(config) {
251
432
  fail(`Discourse request failed: ${method} ${path} (${reason})`)
252
433
  }
253
434
 
435
+ const usedSession = headers['User-Api-Key'] !== undefined
254
436
  const text = await response.text()
255
437
  let payload
256
438
  try {
@@ -261,11 +443,17 @@ function createClient(config) {
261
443
 
262
444
  if (!response.ok) {
263
445
  const detail = describeError(payload)
264
- const hint = response.status === 403
265
- ? ' The API key needs an admin-scoped key for this endpoint.'
266
- : response.status === 404
267
- ? ' Check that the forum URL and the target id are correct.'
268
- : ''
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
+ : ''
269
457
  fail(`Discourse ${method} ${path} failed (HTTP ${response.status}): ${detail}${hint}`)
270
458
  }
271
459
  return payload
@@ -338,6 +526,880 @@ function createClient(config) {
338
526
  return { call, writable, categories, resolveCategory, config }
339
527
  }
340
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
+
341
1403
  // ---------------------------------------------------------------------------
342
1404
  // Projections
343
1405
  // ---------------------------------------------------------------------------
@@ -421,9 +1483,10 @@ function projectUser(user, summary) {
421
1483
  /**
422
1484
  * Build every model-facing tool for one client.
423
1485
  * @param {ReturnType<typeof createClient>} client - the request client.
1486
+ * @param {ReturnType<typeof createLogin>} login - the browser sign-in controller.
424
1487
  * @returns {Array<Record<string, any>>} registry-ready tool definitions.
425
1488
  */
426
- function buildTools(client) {
1489
+ function buildTools(client, login) {
427
1490
  const tools = []
428
1491
  const limit = (value) => integer(value, client.config.maxItems, MAX_LIMIT)
429
1492
  const page = (value) => integer(value, 1, 10000)
@@ -851,7 +1914,7 @@ function buildTools(client) {
851
1914
  },
852
1915
  async execute(args, exec) {
853
1916
  const status = typeof args?.status === 'string' ? args.status : 'pending'
854
- 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 })
855
1918
  const rows = Array.isArray(payload?.reviewables) ? payload.reviewables : Array.isArray(payload) ? payload : []
856
1919
  const reviewables = rows.slice(0, limit(args?.limit)).map((item) => ({
857
1920
  id: item?.id,
@@ -899,6 +1962,7 @@ function buildTools(client) {
899
1962
  await client.call('DELETE', `/posts/${postId}.json`, {
900
1963
  query: permanent ? { force_destroy: 'true' } : {},
901
1964
  signal: exec?.signal,
1965
+ privileged: true,
902
1966
  })
903
1967
  return { postId, permanent, deleted: true }
904
1968
  },
@@ -924,7 +1988,7 @@ function buildTools(client) {
924
1988
  async execute(args, exec) {
925
1989
  client.writable('restoring a post')
926
1990
  const postId = requiredInteger(args?.postId, 'postId')
927
- 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 })
928
1992
  return { postId, restored: true }
929
1993
  },
930
1994
  render(_args, value) {
@@ -956,7 +2020,7 @@ function buildTools(client) {
956
2020
  const status = typeof args?.status === 'string' ? args.status : 'active'
957
2021
  if (status !== 'all') query.status = status
958
2022
  if (optionalString(args?.query) !== undefined) query.query = optionalString(args.query)
959
- 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 })
960
2024
  const rows = Array.isArray(payload) ? payload : payload?.users ?? []
961
2025
  const users = (Array.isArray(rows) ? rows : []).slice(0, integer(args?.limit, 25, 100)).map((user) => ({
962
2026
  id: user?.id,
@@ -1011,21 +2075,21 @@ function buildTools(client) {
1011
2075
  client.writable('moderating a user')
1012
2076
  const username = requiredString(args?.username, 'username').replace(/^@/, '')
1013
2077
  const action = requiredString(args?.action, 'action')
1014
- const userId = await resolveUserId(client, username, exec?.signal)
2078
+ const userId = await resolveUserId(client, username, exec?.signal, { privileged: true })
1015
2079
  if (action === 'suspend' || action === 'silence') {
1016
2080
  const until = requiredString(args?.until, 'until')
1017
2081
  const body = action === 'suspend' ? { suspend_until: until } : { silenced_till: until }
1018
2082
  if (optionalString(args?.reason) !== undefined) body.reason = optionalString(args.reason)
1019
2083
  if (optionalString(args?.message) !== undefined) body.message = optionalString(args.message)
1020
- 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 })
1021
2085
  return { userId, username, action, until }
1022
2086
  }
1023
2087
  if (action === 'activate') {
1024
- 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 })
1025
2089
  return { userId, username, action }
1026
2090
  }
1027
2091
  const lifted = action === 'unsuspend' ? 'suspend' : 'silence'
1028
- 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 })
1029
2093
  return { userId, username, action }
1030
2094
  },
1031
2095
  render(_args, value) {
@@ -1061,10 +2125,11 @@ function buildTools(client) {
1061
2125
  await client.call('PUT', `/admin/site_settings/${encodeURIComponent(name)}.json`, {
1062
2126
  body: { [name]: coerceSettingValue(args.value) },
1063
2127
  signal: exec?.signal,
2128
+ privileged: true,
1064
2129
  })
1065
2130
  return { updated: true, name, value: args.value }
1066
2131
  }
1067
- 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 })
1068
2133
  const rows = Array.isArray(payload) ? payload : payload?.site_settings ?? []
1069
2134
  const filter = optionalString(args?.filter)?.toLowerCase()
1070
2135
  const name = optionalString(args?.name)?.toLowerCase()
@@ -1120,6 +2185,7 @@ function buildTools(client) {
1120
2185
  const payload = await client.call('GET', `/admin/reports/${report}.json`, {
1121
2186
  query: { start_date: isoDay(start), end_date: isoDay(end) },
1122
2187
  signal: exec?.signal,
2188
+ privileged: true,
1123
2189
  })
1124
2190
  const body = payload?.report ?? payload ?? {}
1125
2191
  const points = Array.isArray(body?.data) ? body.data.slice(-days) : []
@@ -1142,6 +2208,82 @@ function buildTools(client) {
1142
2208
  },
1143
2209
  })
1144
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
+
1145
2287
  return tools
1146
2288
  }
1147
2289
 
@@ -1180,11 +2322,12 @@ function extractTopicId(input) {
1180
2322
  * @param {ReturnType<typeof createClient>} client - the request client.
1181
2323
  * @param {string} username - forum username.
1182
2324
  * @param {AbortSignal} [signal] - caller cancellation.
2325
+ * @param {{ privileged?: boolean }} [options] - staff/admin request intent.
1183
2326
  * @returns {Promise<number>} the user id.
1184
2327
  */
1185
- async function resolveUserId(client, username, signal) {
2328
+ async function resolveUserId(client, username, signal, options = {}) {
1186
2329
  if (/^\d+$/.test(username)) return Number(username)
1187
- 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 })
1188
2331
  const id = payload?.user?.id ?? payload?.id
1189
2332
  if (typeof id !== 'number') fail(`could not resolve @${username} to a user id on ${client.config.baseUrl}`)
1190
2333
  return id
@@ -1232,9 +2375,15 @@ function isoDay(date) {
1232
2375
  * @returns {void}
1233
2376
  */
1234
2377
  export function apply(ctx, config) {
1235
- const client = createClient(readConfig(config))
1236
- 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)
1237
2382
  for (const tool of tools) {
1238
2383
  ctx.effect(() => ctx.tools.register(tool), `discourse:${tool.name}`)
1239
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')
1240
2389
  }