@fg-lab/amadeus-qq 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +55 -53
  3. package/README.zh-CN.md +51 -51
  4. package/REFERENCE.md +49 -49
  5. package/cordis.patch.yml +6 -6
  6. package/docs/DATA_MIGRATION.md +26 -26
  7. package/docs/INSTALL_V1_2.md +22 -22
  8. package/docs/PUBLISHING.md +28 -26
  9. package/docs/RELEASE_CHECKLIST.md +23 -23
  10. package/docs/RELEASE_NOTES.md +38 -38
  11. package/docs/UPGRADE_POLICY.md +26 -26
  12. package/docs/verification/IMAGE_READ_FIX_2026-11-07.md +22 -0
  13. package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
  14. package/lib/client.js +372 -372
  15. package/package.json +94 -91
  16. package/preset.patch.yml +63 -0
  17. package/scripts/symlink-security.mjs +1 -1
  18. package/src/account-profile.js +223 -223
  19. package/src/admin-http.js +361 -361
  20. package/src/audio.js +428 -428
  21. package/src/capabilities.js +785 -785
  22. package/src/client/factory.template.js +82 -82
  23. package/src/client/logic.js +290 -290
  24. package/src/config.js +2 -0
  25. package/src/console/console.css +28 -28
  26. package/src/console/console.js +3 -0
  27. package/src/console/index.html +67 -67
  28. package/src/core.js +21 -4
  29. package/src/files.js +230 -230
  30. package/src/index.js +135 -8
  31. package/src/lease.js +42 -42
  32. package/src/media.js +66 -3
  33. package/src/napcat-action-names.js +186 -186
  34. package/src/native-asr.js +112 -112
  35. package/src/onebot.js +378 -378
  36. package/src/outbound-media.js +309 -309
  37. package/src/owner-workspace.js +394 -394
  38. package/src/permissions.js +20 -0
  39. package/src/preset-guard.mjs +16 -2
  40. package/src/qzone-provider.js +628 -628
  41. package/src/session-provenance.js +74 -74
  42. package/src/setup.js +255 -255
  43. package/src/web-console.js +9 -9
package/src/admin-http.js CHANGED
@@ -1,361 +1,361 @@
1
- /**
2
- * Native management HTTP surface for per-Scene QQ permissions.
3
- *
4
- * Every request must be same-origin, pass the DSH Connection owner admission,
5
- * and POST requests must carry the `x-amadeus-qq: 1` marker. Loopback alone is
6
- * never authorization: the DSH `connection.admit(req)` result is the only
7
- * source of the owner principal. Model tools cannot reach this surface.
8
- */
9
-
10
- import { sceneIdentity } from './storage.js'
11
- import { BEHAVIOR_PRESETS, CAPABILITY_CATALOG, MODE_IDS, validateScenePermissions } from './permissions.js'
12
- import { ownerWorkspaceStatus, providerStatus } from './owner-workspace.js'
13
- import { CONSOLE_CSS, CONSOLE_HTML, CONSOLE_JS } from './web-console.js'
14
-
15
- export const ADMIN_PREFIX = '/api/amadeus-qq'
16
- const MAX_BODY_BYTES = 64 * 1024
17
-
18
- class RequestError extends Error {
19
- constructor(status, message) {
20
- super(message)
21
- this.status = status
22
- }
23
- }
24
-
25
- /**
26
- * @param {Record<string, unknown>} req - Node request.
27
- * @returns {boolean} whether Host/Origin state permits a same-origin request.
28
- */
29
- export function sameOrigin(req) {
30
- const host = String(req?.headers?.host ?? '')
31
- if (!host) return false
32
- if (req?.headers?.['sec-fetch-site'] === 'cross-site') return false
33
- const origin = req?.headers?.origin
34
- if (origin === undefined) return true
35
- try {
36
- const parsed = new URL(String(origin))
37
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
38
- return parsed.host === host
39
- } catch {
40
- return false
41
- }
42
- }
43
-
44
- /**
45
- * @param {import('node:http').ServerResponse} res - response.
46
- * @param {number} status - HTTP status.
47
- * @param {Record<string, unknown>} payload - JSON body.
48
- */
49
- function send(res, status, payload) {
50
- const body = JSON.stringify(payload)
51
- res.writeHead(status, {
52
- 'content-type': 'application/json; charset=utf-8',
53
- 'cache-control': 'no-store',
54
- 'x-content-type-options': 'nosniff',
55
- 'referrer-policy': 'no-referrer',
56
- 'content-security-policy': "default-src 'none'",
57
- 'content-length': Buffer.byteLength(body),
58
- })
59
- res.end(body)
60
- }
61
-
62
- function sendConsoleAsset(res, contentType, body) {
63
- res.writeHead(200, {
64
- 'content-type': contentType,
65
- 'cache-control': 'no-store',
66
- 'x-content-type-options': 'nosniff',
67
- 'x-frame-options': 'DENY',
68
- 'referrer-policy': 'no-referrer',
69
- 'content-security-policy': [
70
- "default-src 'none'",
71
- "script-src 'self'",
72
- "style-src 'self'",
73
- "connect-src 'self'",
74
- "img-src 'none'",
75
- "object-src 'none'",
76
- "base-uri 'none'",
77
- "form-action 'none'",
78
- "frame-ancestors 'none'",
79
- ].join('; '),
80
- 'content-length': Buffer.byteLength(body),
81
- })
82
- res.end(body)
83
- }
84
-
85
- /**
86
- * Fixed, non-reflective management failure. The client uses `code` to decide
87
- * whether the page must become read-only, so codes are part of the contract.
88
- * @param {import('node:http').ServerResponse} res
89
- * @param {number} status
90
- * @param {string} code fixed machine code.
91
- * @param {string} message fixed human message; never exception/scene content.
92
- */
93
- function fail(res, status, code, message) {
94
- return send(res, status, { ok: false, code, error: message })
95
- }
96
-
97
- /**
98
- * @param {Record<string, unknown>} connection - DSH Connection service.
99
- * @param {Record<string, unknown>} req - incoming request.
100
- * @returns {{ok: true}|{ok: false, status: number, message: string}} decision.
101
- */
102
- export function authorizeRequest(connection, req) {
103
- if (!connection || typeof connection.admit !== 'function') {
104
- return { ok: false, status: 503, code: 'QQ_MANAGEMENT_UNAVAILABLE', message: 'native QQ management requires the DSH Connection service' }
105
- }
106
- let admission
107
- try {
108
- admission = connection.admit(req)
109
- } catch {
110
- return { ok: false, status: 403, code: 'QQ_AUTH_REQUIRED', message: 'request authentication failed' }
111
- }
112
- if (!admission || admission.rejection !== undefined) {
113
- const rejection = Number(admission?.rejection)
114
- const status = Number.isInteger(rejection) && rejection >= 400 && rejection <= 599 ? rejection : 403
115
- return { ok: false, status, code: 'QQ_AUTH_REQUIRED', message: 'DSH authentication is required' }
116
- }
117
- if (admission?.peer?.principal?.kind !== 'owner') {
118
- return { ok: false, status: 403, code: 'QQ_AUTH_REQUIRED', message: 'only the authenticated owner may manage QQ Scenes' }
119
- }
120
- return { ok: true }
121
- }
122
-
123
- /** @param {import('node:http').IncomingMessage} req - request. @returns {Promise<Record<string, unknown>>} parsed JSON object. */
124
- async function readBody(req) {
125
- const contentType = String(req?.headers?.['content-type'] ?? '').toLowerCase()
126
- if (!contentType.startsWith('application/json')) throw new RequestError(400, 'POST requests require application/json')
127
- let size = 0
128
- const chunks = []
129
- for await (const chunk of req) {
130
- size += chunk.length
131
- if (size > MAX_BODY_BYTES) throw new RequestError(413, 'request body is too large')
132
- chunks.push(chunk)
133
- }
134
- if (size === 0) throw new RequestError(400, 'request body is required')
135
- let parsed
136
- try {
137
- parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
138
- } catch {
139
- throw new RequestError(400, 'request body must be valid JSON')
140
- }
141
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
142
- throw new RequestError(400, 'request body must be a JSON object')
143
- }
144
- return parsed
145
- }
146
-
147
- /** @param {unknown} object @param {string[]} keys @param {string} label */
148
- function exactKeys(object, keys, label) {
149
- if (object === null || typeof object !== 'object' || Array.isArray(object)) {
150
- throw new RequestError(400, `${label} must be an object`)
151
- }
152
- const actual = Object.keys(object).sort()
153
- const expected = [...keys].sort()
154
- if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
155
- throw new RequestError(400, `${label} has unexpected or missing fields`)
156
- }
157
- }
158
-
159
- /** @param {Record<string, unknown>} body */
160
- function parsePermissionRequest(body) {
161
- exactKeys(body, ['scene', 'access', 'expectedRevision'], 'request')
162
- exactKeys(body.scene, ['botId', 'kind', 'peerId'], 'scene')
163
- if (body.access === null || typeof body.access !== 'object' || Array.isArray(body.access) ||
164
- Object.keys(body.access).some(key => !['mode', 'overrides', 'behavior'].includes(key))) {
165
- throw new RequestError(400, 'access has unexpected fields')
166
- }
167
- let scene
168
- try {
169
- scene = sceneIdentity(body.scene)
170
- } catch {
171
- throw new RequestError(400, 'scene is not a valid QQ Scene identity')
172
- }
173
- let access
174
- try {
175
- access = validateScenePermissions(body.access, { allowRevision: false })
176
- } catch {
177
- throw new RequestError(400, 'access is not a valid Scene permission request')
178
- }
179
- if (!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0) {
180
- throw new RequestError(400, 'expectedRevision must be a non-negative integer')
181
- }
182
- return { scene, access, expectedRevision: body.expectedRevision }
183
- }
184
-
185
- /** @param {Record<string, unknown>} body */
186
- function parseResolutionRequest(body) {
187
- exactKeys(body, ['scene', 'actionId', 'outcome'], 'request')
188
- exactKeys(body.scene, ['botId', 'kind', 'peerId'], 'scene')
189
- let scene
190
- try {
191
- scene = sceneIdentity(body.scene)
192
- } catch {
193
- throw new RequestError(400, 'scene is not a valid QQ Scene identity')
194
- }
195
- if (typeof body.actionId !== 'string' || !/^[\w-]{1,100}$/.test(body.actionId)) {
196
- throw new RequestError(400, 'actionId is invalid')
197
- }
198
- if (body.outcome !== 'failed') throw new RequestError(400, 'outcome must be failed')
199
- return { scene, actionId: body.actionId, outcome: body.outcome }
200
- }
201
-
202
- /** @param {import('node:http').ServerResponse} res @param {unknown} error */
203
- function sendFailure(res, error) {
204
- if (error?.code === 'QQ_PERMISSION_CONFLICT') {
205
- return send(res, 409, {
206
- ok: false,
207
- code: 'QQ_PERMISSION_CONFLICT',
208
- error: 'Scene permissions changed; reload before retrying',
209
- currentRevision: error.currentRevision,
210
- })
211
- }
212
- if (error?.code === 'QQ_OWNER_AGENT_FORBIDDEN') {
213
- return fail(res, 403, 'QQ_OWNER_AGENT_FORBIDDEN', 'owner-agent is limited to a configured owner private Scene')
214
- }
215
- if (error?.code === 'QQ_DELIVERY_IN_FLIGHT') {
216
- return fail(res, 409, 'QQ_DELIVERY_IN_FLIGHT', 'an in-flight send cannot be reconciled as failed')
217
- }
218
- if (error?.code === 'QQ_DELIVERY_NOT_UNKNOWN') {
219
- return fail(res, 409, 'QQ_DELIVERY_NOT_UNKNOWN', 'only a settled unknown delivery can be reconciled as failed')
220
- }
221
- if (error?.code === 'QQ_DELIVERY_NOT_FOUND') {
222
- return fail(res, 404, 'QQ_DELIVERY_NOT_FOUND', 'unknown delivery not found')
223
- }
224
- if (error?.code === 'QQ_SCENE_UNAUTHORIZED') {
225
- return fail(res, 403, 'QQ_SCENE_UNAUTHORIZED', 'Scene is outside the configured allowlist')
226
- }
227
- if (error?.message === 'QQ_SETUP_CONFLICT') return fail(res, 409, 'QQ_SETUP_CONFLICT', 'QQ settings changed; reload before retrying')
228
- if (error?.message === 'QQ_SETUP_UNAVAILABLE' || error?.message === 'QQ_WORKSPACE_UNAVAILABLE') {
229
- return fail(res, 503, 'QQ_SETUP_UNAVAILABLE', 'DSH settings or Workspace service is unavailable')
230
- }
231
- if (error?.message?.startsWith('QQ_HANDOFF_BUSY')) {
232
- return fail(res, 503, 'QQ_HANDOFF_BUSY', 'QQ is paused, but an earlier platform action has not settled; retry after checking diagnostics')
233
- }
234
- if (error instanceof RequestError) return fail(res, error.status, 'QQ_REQUEST_INVALID', error.message)
235
- if (error instanceof TypeError) return fail(res, 400, 'QQ_REQUEST_INVALID', 'invalid Scene or permission request')
236
- const message = String(error?.message ?? '')
237
- if (message.startsWith('QQ_UNAUTHORIZED')) {
238
- return fail(res, 403, 'QQ_SCENE_UNAUTHORIZED', 'Scene is outside the configured allowlist')
239
- }
240
- if (message === 'QQ_SESSION_CANCEL_UNAVAILABLE') {
241
- return fail(res, 503, 'QQ_SESSION_CANCEL_UNAVAILABLE', 'the previous QQ Session could not be cancelled safely')
242
- }
243
- if (message === 'QQ_SESSION_CANCEL_FAILED') {
244
- return fail(res, 503, 'QQ_SESSION_CANCEL_FAILED', 'the previous QQ Session could not be cancelled safely')
245
- }
246
- return fail(res, 500, 'QQ_MANAGEMENT_FAILED', 'QQ management request failed')
247
- }
248
-
249
- /**
250
- * Create the native management handler.
251
- * @param {import('./core.js').QQCore} core - QQ decision core.
252
- * @param {Record<string, unknown>|undefined} connection - DSH Connection service.
253
- * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => Promise<void>} handler.
254
- */
255
- export function createAdminHandler(core, connection, setup = null) {
256
- if (core && (typeof core.listScenePermissions !== 'function' || typeof core.setScenePermissions !== 'function')) {
257
- throw new TypeError('createAdminHandler requires a QQCore')
258
- }
259
- return async function adminHandler(req, res) {
260
- const url = new URL(String(req?.url ?? '/'), 'http://localhost')
261
- if (!url.pathname.startsWith(ADMIN_PREFIX)) {
262
- return fail(res, 404, 'QQ_ENDPOINT_UNKNOWN', 'not found')
263
- }
264
- if (!sameOrigin(req)) return fail(res, 403, 'QQ_AUTH_REQUIRED', 'same-origin request required')
265
- if (req.method === 'POST' && req.headers?.['x-amadeus-qq'] !== '1') {
266
- return fail(res, 403, 'QQ_AUTH_REQUIRED', 'missing QQ management request marker')
267
- }
268
- const auth = authorizeRequest(connection, req)
269
- if (!auth.ok) return send(res, auth.status, { ok: false, code: auth.code, error: auth.message })
270
-
271
- const path = url.pathname.slice(ADMIN_PREFIX.length) || '/'
272
- if (req.method === 'GET' && path === '/setup') {
273
- if (!setup) return fail(res, 503, 'QQ_SETUP_UNAVAILABLE', 'DSH settings service is unavailable')
274
- try { return send(res, 200, { ok: true, data: setup.status() }) }
275
- catch (error) { return sendFailure(res, error) }
276
- }
277
- if (req.method === 'POST' && ['/setup', '/handoff', '/scenes/allowlist', '/scenes/pause', '/account'].includes(path)) {
278
- if (!setup) return fail(res, 503, 'QQ_SETUP_UNAVAILABLE', 'DSH settings service is unavailable')
279
- let body
280
- try { body = await readBody(req) } catch (error) { return sendFailure(res, error) }
281
- try {
282
- const data = path === '/setup' ? await setup.save(body, () => core?.pauseForHandoff?.())
283
- : path === '/handoff' ? await setup.setConnection(body, () => core?.pauseForHandoff?.())
284
- : path === '/scenes/allowlist' ? await setup.setScene(body)
285
- : path === '/scenes/pause' ? await setup.pauseScene(body) : await setup.saveAccount(body)
286
- return send(res, 200, { ok: true, data })
287
- } catch (error) { return sendFailure(res, error) }
288
- }
289
- if (req.method === 'GET') {
290
- if (path === '/console') {
291
- return sendConsoleAsset(res, 'text/html; charset=utf-8', CONSOLE_HTML)
292
- }
293
- if (path === '/console.js') {
294
- return sendConsoleAsset(res, 'text/javascript; charset=utf-8', CONSOLE_JS)
295
- }
296
- if (path === '/console.css') {
297
- return sendConsoleAsset(res, 'text/css; charset=utf-8', CONSOLE_CSS)
298
- }
299
- if (path !== '/scenes') return fail(res, 404, 'QQ_ENDPOINT_UNKNOWN', 'unknown QQ management endpoint')
300
- if (!core) return send(res, 200, { ok: true, data: { scenes: [], health: { enabled: false, transport: 'disabled' }, modes: [...MODE_IDS], behaviorPresets: BEHAVIOR_PRESETS, catalog: CAPABILITY_CATALOG, ownerWorkspace: ownerWorkspaceStatus(), providers: providerStatus() } })
301
- try {
302
- return send(res, 200, {
303
- ok: true,
304
- data: {
305
- scenes: core.listScenePermissions(),
306
- health: core.health(),
307
- modes: [...MODE_IDS],
308
- behaviorPresets: BEHAVIOR_PRESETS,
309
- catalog: CAPABILITY_CATALOG,
310
- ownerWorkspace: ownerWorkspaceStatus(),
311
- providers: providerStatus(),
312
- },
313
- })
314
- } catch {
315
- return fail(res, 500, 'QQ_MANAGEMENT_FAILED', 'QQ management request failed')
316
- }
317
- }
318
- if (req.method !== 'POST') return fail(res, 405, 'QQ_METHOD_NOT_ALLOWED', 'method not allowed')
319
- if (path !== '/scenes/permissions' && path !== '/deliveries/resolve') {
320
- return fail(res, 404, 'QQ_ENDPOINT_UNKNOWN', 'unknown QQ management endpoint')
321
- }
322
- if (!core) return fail(res, 503, 'QQ_MANAGEMENT_UNAVAILABLE', 'QQ connector is not enabled')
323
- let body
324
- try {
325
- body = await readBody(req)
326
- } catch (error) {
327
- return sendFailure(res, error)
328
- }
329
- if (path === '/deliveries/resolve') {
330
- let resolution
331
- try {
332
- resolution = parseResolutionRequest(body)
333
- } catch (error) {
334
- return sendFailure(res, error)
335
- }
336
- try {
337
- if (typeof core.resolveDelivery !== 'function') {
338
- return fail(res, 503, 'QQ_MANAGEMENT_UNAVAILABLE', 'QQ delivery reconciliation is unavailable')
339
- }
340
- await core.resolveDelivery(resolution.scene, resolution.actionId, resolution.outcome)
341
- return send(res, 200, { ok: true, data: { status: 'failed' } })
342
- } catch (error) {
343
- return sendFailure(res, error)
344
- }
345
- }
346
- let parsed
347
- try {
348
- parsed = parsePermissionRequest(body)
349
- } catch (error) {
350
- return sendFailure(res, error)
351
- }
352
- try {
353
- const permissions = await core.setScenePermissions(parsed.scene, parsed.access, {
354
- expectedRevision: parsed.expectedRevision,
355
- })
356
- return send(res, 200, { ok: true, data: { permissions } })
357
- } catch (error) {
358
- return sendFailure(res, error)
359
- }
360
- }
361
- }
1
+ /**
2
+ * Native management HTTP surface for per-Scene QQ permissions.
3
+ *
4
+ * Every request must be same-origin, pass the DSH Connection owner admission,
5
+ * and POST requests must carry the `x-amadeus-qq: 1` marker. Loopback alone is
6
+ * never authorization: the DSH `connection.admit(req)` result is the only
7
+ * source of the owner principal. Model tools cannot reach this surface.
8
+ */
9
+
10
+ import { sceneIdentity } from './storage.js'
11
+ import { BEHAVIOR_PRESETS, CAPABILITY_CATALOG, MODE_IDS, validateScenePermissions } from './permissions.js'
12
+ import { ownerWorkspaceStatus, providerStatus } from './owner-workspace.js'
13
+ import { CONSOLE_CSS, CONSOLE_HTML, CONSOLE_JS } from './web-console.js'
14
+
15
+ export const ADMIN_PREFIX = '/api/amadeus-qq'
16
+ const MAX_BODY_BYTES = 64 * 1024
17
+
18
+ class RequestError extends Error {
19
+ constructor(status, message) {
20
+ super(message)
21
+ this.status = status
22
+ }
23
+ }
24
+
25
+ /**
26
+ * @param {Record<string, unknown>} req - Node request.
27
+ * @returns {boolean} whether Host/Origin state permits a same-origin request.
28
+ */
29
+ export function sameOrigin(req) {
30
+ const host = String(req?.headers?.host ?? '')
31
+ if (!host) return false
32
+ if (req?.headers?.['sec-fetch-site'] === 'cross-site') return false
33
+ const origin = req?.headers?.origin
34
+ if (origin === undefined) return true
35
+ try {
36
+ const parsed = new URL(String(origin))
37
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
38
+ return parsed.host === host
39
+ } catch {
40
+ return false
41
+ }
42
+ }
43
+
44
+ /**
45
+ * @param {import('node:http').ServerResponse} res - response.
46
+ * @param {number} status - HTTP status.
47
+ * @param {Record<string, unknown>} payload - JSON body.
48
+ */
49
+ function send(res, status, payload) {
50
+ const body = JSON.stringify(payload)
51
+ res.writeHead(status, {
52
+ 'content-type': 'application/json; charset=utf-8',
53
+ 'cache-control': 'no-store',
54
+ 'x-content-type-options': 'nosniff',
55
+ 'referrer-policy': 'no-referrer',
56
+ 'content-security-policy': "default-src 'none'",
57
+ 'content-length': Buffer.byteLength(body),
58
+ })
59
+ res.end(body)
60
+ }
61
+
62
+ function sendConsoleAsset(res, contentType, body) {
63
+ res.writeHead(200, {
64
+ 'content-type': contentType,
65
+ 'cache-control': 'no-store',
66
+ 'x-content-type-options': 'nosniff',
67
+ 'x-frame-options': 'DENY',
68
+ 'referrer-policy': 'no-referrer',
69
+ 'content-security-policy': [
70
+ "default-src 'none'",
71
+ "script-src 'self'",
72
+ "style-src 'self'",
73
+ "connect-src 'self'",
74
+ "img-src 'none'",
75
+ "object-src 'none'",
76
+ "base-uri 'none'",
77
+ "form-action 'none'",
78
+ "frame-ancestors 'none'",
79
+ ].join('; '),
80
+ 'content-length': Buffer.byteLength(body),
81
+ })
82
+ res.end(body)
83
+ }
84
+
85
+ /**
86
+ * Fixed, non-reflective management failure. The client uses `code` to decide
87
+ * whether the page must become read-only, so codes are part of the contract.
88
+ * @param {import('node:http').ServerResponse} res
89
+ * @param {number} status
90
+ * @param {string} code fixed machine code.
91
+ * @param {string} message fixed human message; never exception/scene content.
92
+ */
93
+ function fail(res, status, code, message) {
94
+ return send(res, status, { ok: false, code, error: message })
95
+ }
96
+
97
+ /**
98
+ * @param {Record<string, unknown>} connection - DSH Connection service.
99
+ * @param {Record<string, unknown>} req - incoming request.
100
+ * @returns {{ok: true}|{ok: false, status: number, message: string}} decision.
101
+ */
102
+ export function authorizeRequest(connection, req) {
103
+ if (!connection || typeof connection.admit !== 'function') {
104
+ return { ok: false, status: 503, code: 'QQ_MANAGEMENT_UNAVAILABLE', message: 'native QQ management requires the DSH Connection service' }
105
+ }
106
+ let admission
107
+ try {
108
+ admission = connection.admit(req)
109
+ } catch {
110
+ return { ok: false, status: 403, code: 'QQ_AUTH_REQUIRED', message: 'request authentication failed' }
111
+ }
112
+ if (!admission || admission.rejection !== undefined) {
113
+ const rejection = Number(admission?.rejection)
114
+ const status = Number.isInteger(rejection) && rejection >= 400 && rejection <= 599 ? rejection : 403
115
+ return { ok: false, status, code: 'QQ_AUTH_REQUIRED', message: 'DSH authentication is required' }
116
+ }
117
+ if (admission?.peer?.principal?.kind !== 'owner') {
118
+ return { ok: false, status: 403, code: 'QQ_AUTH_REQUIRED', message: 'only the authenticated owner may manage QQ Scenes' }
119
+ }
120
+ return { ok: true }
121
+ }
122
+
123
+ /** @param {import('node:http').IncomingMessage} req - request. @returns {Promise<Record<string, unknown>>} parsed JSON object. */
124
+ async function readBody(req) {
125
+ const contentType = String(req?.headers?.['content-type'] ?? '').toLowerCase()
126
+ if (!contentType.startsWith('application/json')) throw new RequestError(400, 'POST requests require application/json')
127
+ let size = 0
128
+ const chunks = []
129
+ for await (const chunk of req) {
130
+ size += chunk.length
131
+ if (size > MAX_BODY_BYTES) throw new RequestError(413, 'request body is too large')
132
+ chunks.push(chunk)
133
+ }
134
+ if (size === 0) throw new RequestError(400, 'request body is required')
135
+ let parsed
136
+ try {
137
+ parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
138
+ } catch {
139
+ throw new RequestError(400, 'request body must be valid JSON')
140
+ }
141
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
142
+ throw new RequestError(400, 'request body must be a JSON object')
143
+ }
144
+ return parsed
145
+ }
146
+
147
+ /** @param {unknown} object @param {string[]} keys @param {string} label */
148
+ function exactKeys(object, keys, label) {
149
+ if (object === null || typeof object !== 'object' || Array.isArray(object)) {
150
+ throw new RequestError(400, `${label} must be an object`)
151
+ }
152
+ const actual = Object.keys(object).sort()
153
+ const expected = [...keys].sort()
154
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
155
+ throw new RequestError(400, `${label} has unexpected or missing fields`)
156
+ }
157
+ }
158
+
159
+ /** @param {Record<string, unknown>} body */
160
+ function parsePermissionRequest(body) {
161
+ exactKeys(body, ['scene', 'access', 'expectedRevision'], 'request')
162
+ exactKeys(body.scene, ['botId', 'kind', 'peerId'], 'scene')
163
+ if (body.access === null || typeof body.access !== 'object' || Array.isArray(body.access) ||
164
+ Object.keys(body.access).some(key => !['mode', 'overrides', 'behavior'].includes(key))) {
165
+ throw new RequestError(400, 'access has unexpected fields')
166
+ }
167
+ let scene
168
+ try {
169
+ scene = sceneIdentity(body.scene)
170
+ } catch {
171
+ throw new RequestError(400, 'scene is not a valid QQ Scene identity')
172
+ }
173
+ let access
174
+ try {
175
+ access = validateScenePermissions(body.access, { allowRevision: false })
176
+ } catch {
177
+ throw new RequestError(400, 'access is not a valid Scene permission request')
178
+ }
179
+ if (!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0) {
180
+ throw new RequestError(400, 'expectedRevision must be a non-negative integer')
181
+ }
182
+ return { scene, access, expectedRevision: body.expectedRevision }
183
+ }
184
+
185
+ /** @param {Record<string, unknown>} body */
186
+ function parseResolutionRequest(body) {
187
+ exactKeys(body, ['scene', 'actionId', 'outcome'], 'request')
188
+ exactKeys(body.scene, ['botId', 'kind', 'peerId'], 'scene')
189
+ let scene
190
+ try {
191
+ scene = sceneIdentity(body.scene)
192
+ } catch {
193
+ throw new RequestError(400, 'scene is not a valid QQ Scene identity')
194
+ }
195
+ if (typeof body.actionId !== 'string' || !/^[\w-]{1,100}$/.test(body.actionId)) {
196
+ throw new RequestError(400, 'actionId is invalid')
197
+ }
198
+ if (body.outcome !== 'failed') throw new RequestError(400, 'outcome must be failed')
199
+ return { scene, actionId: body.actionId, outcome: body.outcome }
200
+ }
201
+
202
+ /** @param {import('node:http').ServerResponse} res @param {unknown} error */
203
+ function sendFailure(res, error) {
204
+ if (error?.code === 'QQ_PERMISSION_CONFLICT') {
205
+ return send(res, 409, {
206
+ ok: false,
207
+ code: 'QQ_PERMISSION_CONFLICT',
208
+ error: 'Scene permissions changed; reload before retrying',
209
+ currentRevision: error.currentRevision,
210
+ })
211
+ }
212
+ if (error?.code === 'QQ_OWNER_AGENT_FORBIDDEN') {
213
+ return fail(res, 403, 'QQ_OWNER_AGENT_FORBIDDEN', 'owner-agent is limited to a configured owner private Scene')
214
+ }
215
+ if (error?.code === 'QQ_DELIVERY_IN_FLIGHT') {
216
+ return fail(res, 409, 'QQ_DELIVERY_IN_FLIGHT', 'an in-flight send cannot be reconciled as failed')
217
+ }
218
+ if (error?.code === 'QQ_DELIVERY_NOT_UNKNOWN') {
219
+ return fail(res, 409, 'QQ_DELIVERY_NOT_UNKNOWN', 'only a settled unknown delivery can be reconciled as failed')
220
+ }
221
+ if (error?.code === 'QQ_DELIVERY_NOT_FOUND') {
222
+ return fail(res, 404, 'QQ_DELIVERY_NOT_FOUND', 'unknown delivery not found')
223
+ }
224
+ if (error?.code === 'QQ_SCENE_UNAUTHORIZED') {
225
+ return fail(res, 403, 'QQ_SCENE_UNAUTHORIZED', 'Scene is outside the configured allowlist')
226
+ }
227
+ if (error?.message === 'QQ_SETUP_CONFLICT') return fail(res, 409, 'QQ_SETUP_CONFLICT', 'QQ settings changed; reload before retrying')
228
+ if (error?.message === 'QQ_SETUP_UNAVAILABLE' || error?.message === 'QQ_WORKSPACE_UNAVAILABLE') {
229
+ return fail(res, 503, 'QQ_SETUP_UNAVAILABLE', 'DSH settings or Workspace service is unavailable')
230
+ }
231
+ if (error?.message?.startsWith('QQ_HANDOFF_BUSY')) {
232
+ return fail(res, 503, 'QQ_HANDOFF_BUSY', 'QQ is paused, but an earlier platform action has not settled; retry after checking diagnostics')
233
+ }
234
+ if (error instanceof RequestError) return fail(res, error.status, 'QQ_REQUEST_INVALID', error.message)
235
+ if (error instanceof TypeError) return fail(res, 400, 'QQ_REQUEST_INVALID', 'invalid Scene or permission request')
236
+ const message = String(error?.message ?? '')
237
+ if (message.startsWith('QQ_UNAUTHORIZED')) {
238
+ return fail(res, 403, 'QQ_SCENE_UNAUTHORIZED', 'Scene is outside the configured allowlist')
239
+ }
240
+ if (message === 'QQ_SESSION_CANCEL_UNAVAILABLE') {
241
+ return fail(res, 503, 'QQ_SESSION_CANCEL_UNAVAILABLE', 'the previous QQ Session could not be cancelled safely')
242
+ }
243
+ if (message === 'QQ_SESSION_CANCEL_FAILED') {
244
+ return fail(res, 503, 'QQ_SESSION_CANCEL_FAILED', 'the previous QQ Session could not be cancelled safely')
245
+ }
246
+ return fail(res, 500, 'QQ_MANAGEMENT_FAILED', 'QQ management request failed')
247
+ }
248
+
249
+ /**
250
+ * Create the native management handler.
251
+ * @param {import('./core.js').QQCore} core - QQ decision core.
252
+ * @param {Record<string, unknown>|undefined} connection - DSH Connection service.
253
+ * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => Promise<void>} handler.
254
+ */
255
+ export function createAdminHandler(core, connection, setup = null) {
256
+ if (core && (typeof core.listScenePermissions !== 'function' || typeof core.setScenePermissions !== 'function')) {
257
+ throw new TypeError('createAdminHandler requires a QQCore')
258
+ }
259
+ return async function adminHandler(req, res) {
260
+ const url = new URL(String(req?.url ?? '/'), 'http://localhost')
261
+ if (!url.pathname.startsWith(ADMIN_PREFIX)) {
262
+ return fail(res, 404, 'QQ_ENDPOINT_UNKNOWN', 'not found')
263
+ }
264
+ if (!sameOrigin(req)) return fail(res, 403, 'QQ_AUTH_REQUIRED', 'same-origin request required')
265
+ if (req.method === 'POST' && req.headers?.['x-amadeus-qq'] !== '1') {
266
+ return fail(res, 403, 'QQ_AUTH_REQUIRED', 'missing QQ management request marker')
267
+ }
268
+ const auth = authorizeRequest(connection, req)
269
+ if (!auth.ok) return send(res, auth.status, { ok: false, code: auth.code, error: auth.message })
270
+
271
+ const path = url.pathname.slice(ADMIN_PREFIX.length) || '/'
272
+ if (req.method === 'GET' && path === '/setup') {
273
+ if (!setup) return fail(res, 503, 'QQ_SETUP_UNAVAILABLE', 'DSH settings service is unavailable')
274
+ try { return send(res, 200, { ok: true, data: setup.status() }) }
275
+ catch (error) { return sendFailure(res, error) }
276
+ }
277
+ if (req.method === 'POST' && ['/setup', '/handoff', '/scenes/allowlist', '/scenes/pause', '/account'].includes(path)) {
278
+ if (!setup) return fail(res, 503, 'QQ_SETUP_UNAVAILABLE', 'DSH settings service is unavailable')
279
+ let body
280
+ try { body = await readBody(req) } catch (error) { return sendFailure(res, error) }
281
+ try {
282
+ const data = path === '/setup' ? await setup.save(body, () => core?.pauseForHandoff?.())
283
+ : path === '/handoff' ? await setup.setConnection(body, () => core?.pauseForHandoff?.())
284
+ : path === '/scenes/allowlist' ? await setup.setScene(body)
285
+ : path === '/scenes/pause' ? await setup.pauseScene(body) : await setup.saveAccount(body)
286
+ return send(res, 200, { ok: true, data })
287
+ } catch (error) { return sendFailure(res, error) }
288
+ }
289
+ if (req.method === 'GET') {
290
+ if (path === '/console') {
291
+ return sendConsoleAsset(res, 'text/html; charset=utf-8', CONSOLE_HTML)
292
+ }
293
+ if (path === '/console.js') {
294
+ return sendConsoleAsset(res, 'text/javascript; charset=utf-8', CONSOLE_JS)
295
+ }
296
+ if (path === '/console.css') {
297
+ return sendConsoleAsset(res, 'text/css; charset=utf-8', CONSOLE_CSS)
298
+ }
299
+ if (path !== '/scenes') return fail(res, 404, 'QQ_ENDPOINT_UNKNOWN', 'unknown QQ management endpoint')
300
+ if (!core) return send(res, 200, { ok: true, data: { scenes: [], health: { enabled: false, transport: 'disabled' }, modes: [...MODE_IDS], behaviorPresets: BEHAVIOR_PRESETS, catalog: CAPABILITY_CATALOG, ownerWorkspace: ownerWorkspaceStatus(), providers: providerStatus() } })
301
+ try {
302
+ return send(res, 200, {
303
+ ok: true,
304
+ data: {
305
+ scenes: core.listScenePermissions(),
306
+ health: core.health(),
307
+ modes: [...MODE_IDS],
308
+ behaviorPresets: BEHAVIOR_PRESETS,
309
+ catalog: CAPABILITY_CATALOG,
310
+ ownerWorkspace: ownerWorkspaceStatus(),
311
+ providers: providerStatus(),
312
+ },
313
+ })
314
+ } catch {
315
+ return fail(res, 500, 'QQ_MANAGEMENT_FAILED', 'QQ management request failed')
316
+ }
317
+ }
318
+ if (req.method !== 'POST') return fail(res, 405, 'QQ_METHOD_NOT_ALLOWED', 'method not allowed')
319
+ if (path !== '/scenes/permissions' && path !== '/deliveries/resolve') {
320
+ return fail(res, 404, 'QQ_ENDPOINT_UNKNOWN', 'unknown QQ management endpoint')
321
+ }
322
+ if (!core) return fail(res, 503, 'QQ_MANAGEMENT_UNAVAILABLE', 'QQ connector is not enabled')
323
+ let body
324
+ try {
325
+ body = await readBody(req)
326
+ } catch (error) {
327
+ return sendFailure(res, error)
328
+ }
329
+ if (path === '/deliveries/resolve') {
330
+ let resolution
331
+ try {
332
+ resolution = parseResolutionRequest(body)
333
+ } catch (error) {
334
+ return sendFailure(res, error)
335
+ }
336
+ try {
337
+ if (typeof core.resolveDelivery !== 'function') {
338
+ return fail(res, 503, 'QQ_MANAGEMENT_UNAVAILABLE', 'QQ delivery reconciliation is unavailable')
339
+ }
340
+ await core.resolveDelivery(resolution.scene, resolution.actionId, resolution.outcome)
341
+ return send(res, 200, { ok: true, data: { status: 'failed' } })
342
+ } catch (error) {
343
+ return sendFailure(res, error)
344
+ }
345
+ }
346
+ let parsed
347
+ try {
348
+ parsed = parsePermissionRequest(body)
349
+ } catch (error) {
350
+ return sendFailure(res, error)
351
+ }
352
+ try {
353
+ const permissions = await core.setScenePermissions(parsed.scene, parsed.access, {
354
+ expectedRevision: parsed.expectedRevision,
355
+ })
356
+ return send(res, 200, { ok: true, data: { permissions } })
357
+ } catch (error) {
358
+ return sendFailure(res, error)
359
+ }
360
+ }
361
+ }