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/README.md +82 -17
- package/cordis.patch.yml +7 -1
- package/index.js +1179 -30
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +5 -4
- package/scripts/login-test.mjs +272 -0
- package/scripts/smoke.mjs +18 -0
- package/scripts/verify-package.mjs +1 -0
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,
|
|
6
|
-
* operations
|
|
7
|
-
*
|
|
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
|
|
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 ===
|
|
265
|
-
? ' The
|
|
266
|
-
: response.status ===
|
|
267
|
-
?
|
|
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
|
|
1236
|
-
const
|
|
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
|
}
|