@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.
- package/LICENSE +21 -21
- package/README.md +55 -53
- package/README.zh-CN.md +51 -51
- package/REFERENCE.md +49 -49
- package/cordis.patch.yml +6 -6
- package/docs/DATA_MIGRATION.md +26 -26
- package/docs/INSTALL_V1_2.md +22 -22
- package/docs/PUBLISHING.md +28 -26
- package/docs/RELEASE_CHECKLIST.md +23 -23
- package/docs/RELEASE_NOTES.md +38 -38
- package/docs/UPGRADE_POLICY.md +26 -26
- package/docs/verification/IMAGE_READ_FIX_2026-11-07.md +22 -0
- package/docs/verification/LIFE_QQ_BOUNDARY_2026-11-07.md +20 -0
- package/lib/client.js +372 -372
- package/package.json +94 -91
- package/preset.patch.yml +63 -0
- package/scripts/symlink-security.mjs +1 -1
- package/src/account-profile.js +223 -223
- package/src/admin-http.js +361 -361
- package/src/audio.js +428 -428
- package/src/capabilities.js +785 -785
- package/src/client/factory.template.js +82 -82
- package/src/client/logic.js +290 -290
- package/src/config.js +2 -0
- package/src/console/console.css +28 -28
- package/src/console/console.js +3 -0
- package/src/console/index.html +67 -67
- package/src/core.js +21 -4
- package/src/files.js +230 -230
- package/src/index.js +135 -8
- package/src/lease.js +42 -42
- package/src/media.js +66 -3
- package/src/napcat-action-names.js +186 -186
- package/src/native-asr.js +112 -112
- package/src/onebot.js +378 -378
- package/src/outbound-media.js +309 -309
- package/src/owner-workspace.js +394 -394
- package/src/permissions.js +20 -0
- package/src/preset-guard.mjs +16 -2
- package/src/qzone-provider.js +628 -628
- package/src/session-provenance.js +74 -74
- package/src/setup.js +255 -255
- 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
|
+
}
|