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/locale/en.json
CHANGED
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-discourse",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Discourse administration and agent tools for the DeepSeek Harness:
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Discourse administration and agent tools for the DeepSeek Harness: 17 tools for reading, posting, moderating, administering, and signing in to a Discourse forum through the browser.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AkarinLiu",
|
|
7
7
|
"type": "module",
|
|
@@ -31,10 +31,11 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"test": "node scripts/smoke.mjs",
|
|
34
|
+
"test": "node scripts/smoke.mjs && node scripts/login-test.mjs",
|
|
35
|
+
"test:login": "node scripts/login-test.mjs",
|
|
35
36
|
"verify": "node scripts/verify-package.mjs",
|
|
36
37
|
"doctor": "node scripts/doctor.mjs",
|
|
37
|
-
"prepack": "node scripts/smoke.mjs"
|
|
38
|
+
"prepack": "node scripts/smoke.mjs && node scripts/login-test.mjs"
|
|
38
39
|
},
|
|
39
40
|
"exports": {
|
|
40
41
|
".": "./index.js",
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Offline end-to-end test for the browser sign-in tool.
|
|
4
|
+
*
|
|
5
|
+
* A stub credential store, a stub authorization seam, and a stub forum exercise
|
|
6
|
+
* the real `apply()` path: start the classic authorization page, complete it
|
|
7
|
+
* with an encrypted payload, read the signed-in status, cancel a pending
|
|
8
|
+
* attempt, and sign out; then repeat through the device-code path. The test
|
|
9
|
+
* never reaches the network and never prints the user API key.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { constants, publicEncrypt } from 'node:crypto'
|
|
13
|
+
import { apply } from '../index.js'
|
|
14
|
+
|
|
15
|
+
// The plugin also reads these from the environment; keep the test deterministic.
|
|
16
|
+
delete process.env.DISCOURSE_API_KEY
|
|
17
|
+
delete process.env.DISCOURSE_API_USERNAME
|
|
18
|
+
|
|
19
|
+
const problems = []
|
|
20
|
+
const store = new Map()
|
|
21
|
+
const flows = new Map()
|
|
22
|
+
const requests = []
|
|
23
|
+
let activeController
|
|
24
|
+
let deviceSupported = false
|
|
25
|
+
let deviceAuth
|
|
26
|
+
let devicePolls = 0
|
|
27
|
+
const DEVICE_KEY = 'device-key-456'
|
|
28
|
+
|
|
29
|
+
/** Build a JSON response without a network. */
|
|
30
|
+
function json(body, status = 200) {
|
|
31
|
+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const credentials = {
|
|
35
|
+
async readRecord(key) {
|
|
36
|
+
return store.get(key)
|
|
37
|
+
},
|
|
38
|
+
async describeRecord(key) {
|
|
39
|
+
const record = store.get(key)
|
|
40
|
+
return { configured: record !== undefined, kind: record?.kind, writable: true }
|
|
41
|
+
},
|
|
42
|
+
async modifyRecord(key, mutate) {
|
|
43
|
+
const next = await mutate(store.get(key))
|
|
44
|
+
if (next === undefined) return store.get(key)
|
|
45
|
+
store.set(key, next)
|
|
46
|
+
return next
|
|
47
|
+
},
|
|
48
|
+
async deleteRecord(key) {
|
|
49
|
+
store.delete(key)
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const authorization = {
|
|
54
|
+
registerFlow(flow) {
|
|
55
|
+
flows.set(flow.key, flow)
|
|
56
|
+
return () => {
|
|
57
|
+
flows.delete(flow.key)
|
|
58
|
+
this.cancel(flow.key)
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
cancel() {
|
|
62
|
+
activeController?.abort()
|
|
63
|
+
},
|
|
64
|
+
async begin({ key, signal, interaction }) {
|
|
65
|
+
const flow = flows.get(key)
|
|
66
|
+
if (flow === undefined) {
|
|
67
|
+
const error = new Error(`no authorization flow for ${key}`)
|
|
68
|
+
error.code = 'NO_FLOW'
|
|
69
|
+
throw error
|
|
70
|
+
}
|
|
71
|
+
activeController = new AbortController()
|
|
72
|
+
const combined = signal ?? activeController.signal
|
|
73
|
+
const session = {
|
|
74
|
+
method: flow.methods[0].id,
|
|
75
|
+
signal: combined,
|
|
76
|
+
notify: (notice) => interaction.notify(notice),
|
|
77
|
+
prompt: (question) => interaction.prompt(question),
|
|
78
|
+
commit: async (record) => {
|
|
79
|
+
await credentials.modifyRecord(key, () => Promise.resolve(record))
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
await flow.run(session)
|
|
84
|
+
return { status: 'authorized' }
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (combined.aborted) return { status: 'cancelled' }
|
|
87
|
+
throw error
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
93
|
+
const url = new URL(String(input))
|
|
94
|
+
const headers = new Headers(init.headers)
|
|
95
|
+
requests.push({ path: url.pathname, method: init.method ?? 'GET', headers })
|
|
96
|
+
if (url.pathname === '/user-api-key/device.json') {
|
|
97
|
+
if (!deviceSupported) {
|
|
98
|
+
// Pretend this is a forum older than the device authorization flow.
|
|
99
|
+
return json({ errors: ['not found'] }, 404)
|
|
100
|
+
}
|
|
101
|
+
const body = JSON.parse(init.body)
|
|
102
|
+
deviceAuth = { publicKey: body.public_key, nonce: body.nonce }
|
|
103
|
+
devicePolls = 0
|
|
104
|
+
return json({
|
|
105
|
+
device_code: 'a'.repeat(64),
|
|
106
|
+
user_code: 'ABCD-EFGH',
|
|
107
|
+
verification_uri: 'https://forum.test/user-api-key/activate',
|
|
108
|
+
verification_uri_with_request: 'https://forum.test/user-api-key/activate?request=abcd1234',
|
|
109
|
+
expires_in: 600,
|
|
110
|
+
interval: 1,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
if (url.pathname === '/user-api-key/device/poll.json') {
|
|
114
|
+
if (!deviceSupported) return json({ errors: ['not found'] }, 404)
|
|
115
|
+
devicePolls += 1
|
|
116
|
+
if (devicePolls === 1) return json({ status: 'authorization_pending' })
|
|
117
|
+
const plaintext = JSON.stringify({ key: DEVICE_KEY, nonce: deviceAuth.nonce, push: false, api: 4 })
|
|
118
|
+
const payload = publicEncrypt(
|
|
119
|
+
{ key: deviceAuth.publicKey, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha1' },
|
|
120
|
+
Buffer.from(plaintext, 'utf8'),
|
|
121
|
+
).toString('base64')
|
|
122
|
+
return json({ status: 'authorized', payload })
|
|
123
|
+
}
|
|
124
|
+
if (url.pathname === '/session/current.json') {
|
|
125
|
+
const key = headers.get('user-api-key')
|
|
126
|
+
if (key === 'user-key-123') return json({ current_user: { id: 7, username: 'tester' } })
|
|
127
|
+
if (key === DEVICE_KEY) return json({ current_user: { id: 8, username: 'device-tester' } })
|
|
128
|
+
return json({ errors: ['bad key'] }, 403)
|
|
129
|
+
}
|
|
130
|
+
if (url.pathname === '/user-api-key/revoke.json') {
|
|
131
|
+
const key = headers.get('user-api-key')
|
|
132
|
+
if (key === 'user-key-123' || key === DEVICE_KEY) return json({ success: 'OK' })
|
|
133
|
+
return json({ errors: ['bad key'] }, 403)
|
|
134
|
+
}
|
|
135
|
+
return json({ errors: [`unexpected ${url.pathname}`] }, 500)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const registry = new Map()
|
|
139
|
+
const ctx = {
|
|
140
|
+
tools: {
|
|
141
|
+
register(tool) {
|
|
142
|
+
registry.set(tool.name, tool)
|
|
143
|
+
return () => registry.delete(tool.name)
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
effect(callback) {
|
|
147
|
+
const dispose = callback()
|
|
148
|
+
return () => {
|
|
149
|
+
if (typeof dispose === 'function') dispose()
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
get(name) {
|
|
153
|
+
if (name === 'credentials') return credentials
|
|
154
|
+
if (name === 'authorization') return authorization
|
|
155
|
+
return undefined
|
|
156
|
+
},
|
|
157
|
+
inject(_deps, callback) {
|
|
158
|
+
return callback(ctx)
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
apply(ctx, {
|
|
163
|
+
baseUrl: 'https://forum.test',
|
|
164
|
+
apiKey: '',
|
|
165
|
+
authMode: 'auto',
|
|
166
|
+
loginScopes: 'read,write,session_info',
|
|
167
|
+
loginTimeoutMs: 60000,
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const login = registry.get('discourse_login')
|
|
171
|
+
if (login === undefined) problems.push('discourse_login was not registered')
|
|
172
|
+
|
|
173
|
+
if (problems.length === 0) {
|
|
174
|
+
const started = await login.execute({ action: 'start' }, {})
|
|
175
|
+
if (started.signedIn !== false) problems.push('start should report signed out')
|
|
176
|
+
if (started.attempt?.status !== 'pending') problems.push(`start should report a pending attempt, got ${started.attempt?.status}`)
|
|
177
|
+
if (started.attempt?.mode !== 'manual') problems.push(`classic fallback should use manual mode, got ${started.attempt?.mode}`)
|
|
178
|
+
if (typeof started.attempt?.url !== 'string') problems.push('start did not return an authorization URL')
|
|
179
|
+
|
|
180
|
+
const startView = login.output.render({ action: 'start' }, started).map((block) => block.text).join('\n')
|
|
181
|
+
if (!startView.includes(started.attempt.url)) problems.push('start render did not include the authorization URL')
|
|
182
|
+
if (!startView.includes('discourse_login')) problems.push('start render did not explain the next status call')
|
|
183
|
+
|
|
184
|
+
const url = new URL(started.attempt.url)
|
|
185
|
+
if (!url.pathname.endsWith('/user-api-key/new')) problems.push(`unexpected authorization path ${url.pathname}`)
|
|
186
|
+
if (url.searchParams.get('client_id') !== 'dsh-plugin-discourse') problems.push('authorization URL lost the client id')
|
|
187
|
+
if (url.searchParams.has('padding')) problems.push('classic fallback should let the forum use its default padding')
|
|
188
|
+
const publicKey = url.searchParams.get('public_key')
|
|
189
|
+
const nonce = url.searchParams.get('nonce')
|
|
190
|
+
if (typeof publicKey !== 'string' || !publicKey.includes('BEGIN PUBLIC KEY')) problems.push('authorization URL has no PEM public key')
|
|
191
|
+
if (typeof nonce !== 'string' || nonce.length < 32) problems.push('authorization URL has no nonce')
|
|
192
|
+
|
|
193
|
+
const plaintext = JSON.stringify({ key: 'user-key-123', nonce, push: false, api: 4 })
|
|
194
|
+
// The classic page omits `padding`, so Discourse encrypts with PKCS#1 there;
|
|
195
|
+
// the device path uses OAEP. Both decryption branches are covered here.
|
|
196
|
+
const payload = publicEncrypt(
|
|
197
|
+
{ key: publicKey, padding: constants.RSA_PKCS1_PADDING },
|
|
198
|
+
Buffer.from(plaintext, 'utf8'),
|
|
199
|
+
).toString('base64')
|
|
200
|
+
|
|
201
|
+
const completed = await login.execute({ action: 'complete', payload }, {})
|
|
202
|
+
if (completed.signedIn !== true) problems.push('complete should report signed in')
|
|
203
|
+
if (completed.session?.username !== 'tester') problems.push(`complete should read the account, got ${JSON.stringify(completed.session)}`)
|
|
204
|
+
if (completed.session?.scopes?.join(',') !== 'read,write,session_info') problems.push('session scopes did not round-trip')
|
|
205
|
+
if (JSON.stringify(completed).includes('user-key-123')) problems.push('complete leaked the user API key')
|
|
206
|
+
|
|
207
|
+
const status = await login.execute({ action: 'status' }, {})
|
|
208
|
+
if (status.signedIn !== true) problems.push('status should report signed in after complete')
|
|
209
|
+
if (status.activeSource !== 'user-session') problems.push(`status source should be user-session, got ${status.activeSource}`)
|
|
210
|
+
if (JSON.stringify(status).includes('user-key-123')) problems.push('status leaked the user API key')
|
|
211
|
+
|
|
212
|
+
const record = store.get('discourse/session')
|
|
213
|
+
if (record?.kind !== 'grant') problems.push('the session record is not a grant')
|
|
214
|
+
if (record?.payload?.baseUrl !== 'https://forum.test') problems.push('the session record has the wrong forum')
|
|
215
|
+
if (record?.payload?.clientId !== 'dsh-plugin-discourse') problems.push('the session record has the wrong client id')
|
|
216
|
+
|
|
217
|
+
const rendered = login.output.render({ action: 'status' }, status).map((block) => block.text).join('\n')
|
|
218
|
+
if (!rendered.includes('@tester')) problems.push(`render did not name the account: ${rendered}`)
|
|
219
|
+
if (rendered.includes('user-key-123')) problems.push('render leaked the user API key')
|
|
220
|
+
|
|
221
|
+
const signedOut = await login.execute({ action: 'logout' }, {})
|
|
222
|
+
if (signedOut.signedOut !== true) problems.push('logout should report signed out')
|
|
223
|
+
if (signedOut.revoked !== true) problems.push('logout should revoke the forum key')
|
|
224
|
+
if (store.size !== 0) problems.push('logout did not delete the local session record')
|
|
225
|
+
|
|
226
|
+
const after = await login.execute({ action: 'status' }, {})
|
|
227
|
+
if (after.signedIn !== false) problems.push('status should report signed out after logout')
|
|
228
|
+
|
|
229
|
+
const revoke = requests.find((request) => request.path === '/user-api-key/revoke.json')
|
|
230
|
+
if (revoke === undefined) problems.push('logout did not call the revoke endpoint')
|
|
231
|
+
else if (revoke.headers.get('user-api-key') !== 'user-key-123') problems.push('logout did not authenticate with the user API key')
|
|
232
|
+
|
|
233
|
+
const restarted = await login.execute({ action: 'start', mode: 'manual' }, {})
|
|
234
|
+
if (restarted.attempt?.status !== 'pending') problems.push('manual restart should be pending before cancel')
|
|
235
|
+
const cancelled = await login.execute({ action: 'cancel' }, {})
|
|
236
|
+
if (cancelled.cancelled !== true) problems.push('cancel should report that an attempt was cancelled')
|
|
237
|
+
if (cancelled.signedIn !== false) problems.push('cancel should leave the session signed out')
|
|
238
|
+
if (cancelled.attempt !== undefined) problems.push('cancel should clear the pending attempt')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The device flow is the preferred path on a current forum: request a code,
|
|
242
|
+
// poll while pending, then accept the encrypted payload.
|
|
243
|
+
if (problems.length === 0) {
|
|
244
|
+
deviceSupported = true
|
|
245
|
+
const started = await login.execute({ action: 'start', mode: 'device' }, {})
|
|
246
|
+
if (started.signedIn !== false) problems.push('device start should report signed out')
|
|
247
|
+
if (started.attempt?.status !== 'pending') problems.push(`device start should report pending, got ${started.attempt?.status}`)
|
|
248
|
+
if (started.attempt?.mode !== 'device') problems.push(`device start should report device mode, got ${started.attempt?.mode}`)
|
|
249
|
+
if (started.attempt?.userCode !== 'ABCD-EFGH') problems.push(`device start lost the user code: ${started.attempt?.userCode}`)
|
|
250
|
+
if (!String(started.attempt?.url).includes('/user-api-key/activate')) problems.push('device start did not return the activation URL')
|
|
251
|
+
|
|
252
|
+
const waited = await login.execute({ action: 'status', waitSeconds: 10 }, {})
|
|
253
|
+
if (waited.signedIn !== true) problems.push('device status wait should report signed in')
|
|
254
|
+
if (waited.session?.username !== 'device-tester') problems.push(`device sign-in should read the account, got ${JSON.stringify(waited.session)}`)
|
|
255
|
+
if (JSON.stringify(waited).includes(DEVICE_KEY)) problems.push('device status leaked the user API key')
|
|
256
|
+
if (devicePolls < 2) problems.push(`device flow should poll at least twice, polled ${devicePolls}`)
|
|
257
|
+
|
|
258
|
+
const record = store.get('discourse/session')
|
|
259
|
+
if (record?.payload?.source !== 'device') problems.push(`device session source should be device, got ${record?.payload?.source}`)
|
|
260
|
+
|
|
261
|
+
const signedOut = await login.execute({ action: 'logout' }, {})
|
|
262
|
+
if (signedOut.revoked !== true) problems.push('device logout should revoke the forum key')
|
|
263
|
+
if (store.size !== 0) problems.push('device logout did not delete the local session record')
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (problems.length > 0) {
|
|
267
|
+
console.error('browser login test failed:')
|
|
268
|
+
for (const problem of problems) console.error(` - ${problem}`)
|
|
269
|
+
process.exit(1)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
console.log('ok: browser login starts, completes, reports, and revokes offline')
|
package/scripts/smoke.mjs
CHANGED
|
@@ -30,6 +30,12 @@ const ctx = {
|
|
|
30
30
|
if (typeof dispose === 'function') dispose()
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
|
+
get() {
|
|
34
|
+
return undefined
|
|
35
|
+
},
|
|
36
|
+
inject(_deps, callback) {
|
|
37
|
+
return callback(ctx)
|
|
38
|
+
},
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
apply(ctx, { baseUrl, readOnly: true, maxItems: 5 })
|
|
@@ -51,6 +57,7 @@ const expected = [
|
|
|
51
57
|
'discourse_moderate_user',
|
|
52
58
|
'discourse_site_settings',
|
|
53
59
|
'discourse_admin_stats',
|
|
60
|
+
'discourse_login',
|
|
54
61
|
]
|
|
55
62
|
|
|
56
63
|
for (const name of expected) {
|
|
@@ -74,6 +81,17 @@ for (const [name, tool] of registry) {
|
|
|
74
81
|
}
|
|
75
82
|
}
|
|
76
83
|
|
|
84
|
+
// The login tool must report a signed-out state without the credential store,
|
|
85
|
+
// and it must not expose a key it never stored.
|
|
86
|
+
try {
|
|
87
|
+
const value = await registry.get('discourse_login').execute({ action: 'status' }, {})
|
|
88
|
+
if (value?.signedIn !== false) problems.push('discourse_login status should report signed out offline')
|
|
89
|
+
if (value?.storageAvailable !== false) problems.push('discourse_login status should report missing credential storage offline')
|
|
90
|
+
if (JSON.stringify(value).includes('"key"')) problems.push('discourse_login status leaked a key field')
|
|
91
|
+
} catch (error) {
|
|
92
|
+
problems.push(`discourse_login status failed offline: ${error.message}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
77
95
|
// A malformed forum URL must fail at activation with a usable diagnostic,
|
|
78
96
|
// before any tool is registered.
|
|
79
97
|
try {
|