@fg-lab/amadeus-qq 0.0.0-stage → 1.2.1

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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -3
  3. package/README.zh-CN.md +51 -0
  4. package/REFERENCE.md +49 -0
  5. package/assets/market-icon.png +0 -0
  6. package/cordis.patch.yml +6 -0
  7. package/docs/COMPATIBILITY.md +17 -0
  8. package/docs/DATA_MIGRATION.md +26 -0
  9. package/docs/INSTALL_V1_2.md +22 -0
  10. package/docs/PUBLISHING.md +26 -0
  11. package/docs/RELEASE_CHECKLIST.md +23 -0
  12. package/docs/RELEASE_NOTES.md +38 -0
  13. package/docs/UPGRADE_POLICY.md +26 -0
  14. package/docs/V1_SCOPE.md +33 -0
  15. package/docs/screenshots/account-browser.png +0 -0
  16. package/docs/screenshots/account-dark-browser.png +0 -0
  17. package/docs/screenshots/global-desktop.png +0 -0
  18. package/docs/screenshots/overview-desktop.png +0 -0
  19. package/docs/screenshots/product-release-20261031.png +0 -0
  20. package/docs/screenshots/v1.2-overview.png +0 -0
  21. package/docs/v1.1/PRESET_MATRIX.md +28 -0
  22. package/docs/v1.1/PROTOTYPE_DIFF.md +15 -0
  23. package/docs/v1.1/TEST_REPORT.md +20 -0
  24. package/docs/v1.1/UPGRADE.md +10 -0
  25. package/docs/verification/V1_REAL_NAPCAT_2026-10-27.md +43 -0
  26. package/lib/client.js +380 -0
  27. package/package.json +91 -6
  28. package/preset.patch.yml +103 -0
  29. package/screenshots.json +6 -0
  30. package/scripts/build-client.mjs +52 -0
  31. package/scripts/registry-guard.mjs +12 -0
  32. package/scripts/release-audit.mjs +59 -0
  33. package/scripts/symlink-security.mjs +8 -0
  34. package/src/account-profile.js +223 -0
  35. package/src/admin-http.js +361 -0
  36. package/src/audio.js +428 -0
  37. package/src/capabilities.js +785 -0
  38. package/src/client/factory.template.js +82 -0
  39. package/src/client/logic.js +290 -0
  40. package/src/config.js +48 -0
  41. package/src/console/console.css +28 -0
  42. package/src/console/console.js +538 -0
  43. package/src/console/index.html +67 -0
  44. package/src/core.js +892 -0
  45. package/src/files.js +230 -0
  46. package/src/index.js +867 -0
  47. package/src/lease.js +42 -0
  48. package/src/media.js +246 -0
  49. package/src/napcat-action-names.js +186 -0
  50. package/src/native-asr.js +112 -0
  51. package/src/onebot.js +378 -0
  52. package/src/outbound-media.js +309 -0
  53. package/src/owner-workspace.js +394 -0
  54. package/src/permissions.js +250 -0
  55. package/src/preset-guard.mjs +579 -0
  56. package/src/qzone-provider.js +628 -0
  57. package/src/session-provenance.js +74 -0
  58. package/src/setup.js +255 -0
  59. package/src/storage.js +580 -0
  60. package/src/web-console.js +9 -0
@@ -0,0 +1,59 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { readFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+
5
+ const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
6
+ const requirements = {
7
+ '@fg-lab/amadeus-qq': ['src/index.js', 'lib/client.js', 'src/console/index.html', 'src/console/console.js', 'src/console/console.css', 'cordis.patch.yml', 'preset.patch.yml'],
8
+ '@fg-lab/amadeus-memory': ['lib/index.js', 'lib/client.js', 'cordis.patch.yml', 'runtime-manifest.json'],
9
+ '@fg-lab/amadeus-life': ['src/index.js', 'lib/client.js', 'cordis.patch.yml', 'icon.svg', 'locale/zh.json', 'locale/en.json'],
10
+ '@fg-lab/amadeus-state': ['src/index.js', 'lib/client.js', 'cordis.patch.yml', 'icon.svg', 'locale/zh.json', 'locale/en.json'],
11
+ '@fg-lab/amadeus-persona': ['src/index.js', 'src/client.js', 'src/console/index.html', 'src/console/console.js', 'src/console/console.css', 'cordis.patch.yml']
12
+ }
13
+ for (const required of Object.values(requirements)) required.push('assets/market-icon.png', 'screenshots.json', 'docs/INSTALL_V1_2.md')
14
+ if (pkg.license === 'MIT') requirements[pkg.name]?.push('LICENSE')
15
+ const expectedId = pkg.name === '@fg-lab/amadeus-memory' ? 'dsh-mneme' : pkg.name.replace('@fg-lab/', '')
16
+ const errors = []
17
+ const warnings = []
18
+ if (!requirements[pkg.name]) errors.push(`unexpected package name: ${pkg.name}`)
19
+ if (pkg.scripts?.prepare || pkg.scripts?.postinstall || pkg.scripts?.install) errors.push('install-time script is forbidden for the prebuilt package')
20
+ if (!pkg.dsh?.bundle?.patch || !pkg.dsh?.client) errors.push('missing DSH bundle patch or client manifest')
21
+ if (!pkg.engines?.dsh) errors.push('missing tested engines.dsh range')
22
+ if (!readFileSync('cordis.patch.yml', 'utf8').includes(`id: ${expectedId}`)) errors.push(`patch does not preserve plugin id ${expectedId}`)
23
+ if (pkg.private === true) warnings.push('private:true prevents public npm publish')
24
+ if (!pkg.license || pkg.license === 'UNLICENSED') warnings.push('public distribution license needs owner review')
25
+
26
+ let manifest
27
+ try {
28
+ const raw = execSync('npm pack --dry-run --json --ignore-scripts', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
29
+ const parsed = JSON.parse(raw)
30
+ manifest = Array.isArray(parsed) ? parsed[0] : parsed[pkg.name] ?? Object.values(parsed)[0]
31
+ } catch (error) {
32
+ errors.push(`npm pack failed: ${error.stderr?.toString().trim() || error.message}`)
33
+ }
34
+
35
+ if (manifest) {
36
+ const names = new Set(manifest.files.map(file => file.path.replaceAll('\\', '/')))
37
+ for (const path of requirements[pkg.name] ?? []) if (!names.has(path)) errors.push(`missing runtime asset: ${path}`)
38
+ try {
39
+ const screenshots = JSON.parse(readFileSync('screenshots.json', 'utf8'))
40
+ if (!Array.isArray(screenshots) || screenshots.length < 1 || screenshots.length > 8) errors.push('screenshots.json must list 1–8 images')
41
+ else for (const path of screenshots) {
42
+ if (typeof path !== 'string' || path.startsWith('/') || path.includes('..') || !/\.(png|jpe?g|webp)$/i.test(path)) errors.push(`invalid screenshot path: ${path}`)
43
+ else if (!names.has(path)) errors.push(`screenshot is absent from package: ${path}`)
44
+ }
45
+ } catch (error) { errors.push(`invalid screenshots.json: ${error.message}`) }
46
+ const forbiddenName = /(^|\/)(?:node_modules|private|profiles|logs|\.git|\.env[^/]*|\.npmrc|credentials(?:\.json)?|secrets?)(?:\/|$)|^runtime\/|\.(?:db|sqlite(?:-wal|-shm)?|log|key|pem)$/i
47
+ const secret = /gh[pousr]_[A-Za-z0-9_]{30,}|(?:sk-|sk_)[A-Za-z0-9]{32,}|-----BEGIN (?:RSA|OPENSSH|EC) PRIVATE KEY-----|DEEPSEEK_API_KEY\s*[:=]\s*['"]?[A-Za-z0-9_-]{16,}/
48
+ for (const path of names) {
49
+ if (forbiddenName.test(path)) errors.push(`forbidden package path: ${path}`)
50
+ if (path === 'package.json') continue
51
+ const file = readFileSync(resolve(path))
52
+ if (file.length <= 2_000_000 && !file.includes(0) && secret.test(file.toString('utf8'))) errors.push(`possible credential in package file: ${path}`)
53
+ }
54
+ console.log(JSON.stringify({ name: manifest.name, version: manifest.version, files: names.size, unpackedBytes: manifest.unpackedSize, warnings }, null, 2))
55
+ }
56
+ if (errors.length) {
57
+ for (const error of errors) console.error(`ERROR ${error}`)
58
+ process.exitCode = 1
59
+ }
@@ -0,0 +1,8 @@
1
+ import {spawnSync} from 'node:child_process';
2
+ if(process.platform!=='linux')throw Error('Run this release gate on Linux; Windows skips do not satisfy it');
3
+ const files=['tests/audio-read.test.mjs','tests/media-read.test.mjs','tests/file-read.test.mjs','tests/outbound-media.test.mjs'];
4
+ const result=spawnSync(process.execPath,['--test','--test-name-pattern=symlink',...files],{encoding:'utf8'});
5
+ process.stdout.write(result.stdout??'');process.stderr.write(result.stderr??'');
6
+ const cases=(result.stdout??'').split(/\r?\n/).filter(line=>/^(?:not )?ok \d+ - .*symlink/i.test(line));
7
+ if(result.status!==0||cases.length!==4||cases.some(line=>!line.startsWith('ok ')||/# SKIP|# TODO/.test(line)))throw Error('All four symlink security cases must execute and pass without skips');
8
+ console.log('RELEASE_GATE: 4 symlink safety tests executed and passed');
@@ -0,0 +1,223 @@
1
+ import { createHash } from 'node:crypto'
2
+ import path from 'node:path'
3
+ import { snapshotControlledImage } from './outbound-media.js'
4
+
5
+ const STATUS_VALUES = new Set([10, 30, 40, 50, 60, 70])
6
+ const MAX_SIGNATURE = 200
7
+ const MAX_NICKNAME = 36
8
+
9
+ function profileError(code) {
10
+ const error = new Error(code)
11
+ error.code = code
12
+ return error
13
+ }
14
+
15
+ function cleanText(value, max, code) {
16
+ if (typeof value !== 'string') throw profileError(code)
17
+ const text = value.replace(/[\u0000-\u001f\u007f]/g, '').trim()
18
+ if (text.length === 0 || text.length > max) throw profileError(code)
19
+ return text
20
+ }
21
+
22
+ function decimalId(value) {
23
+ const id = typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : value
24
+ if (typeof id !== 'string' || !/^[1-9]\d{0,19}$/.test(id)) throw profileError('QQ_PROFILE_RESPONSE_INVALID')
25
+ return id
26
+ }
27
+
28
+ function responseInvalid() {
29
+ return profileError('QQ_PROFILE_RESPONSE_INVALID')
30
+ }
31
+
32
+ function profileFingerprint(scene, action, payload) {
33
+ return createHash('sha256')
34
+ .update(JSON.stringify({
35
+ account: scene.botId,
36
+ scene: scene.peerId,
37
+ action,
38
+ payload,
39
+ }))
40
+ .digest('hex')
41
+ }
42
+
43
+ export function createAccountProfile({ core, transport, authorize, ledger, sendRoot, tempRoot }) {
44
+ if (typeof authorize !== 'function') throw profileError('QQ_PROFILE_UNAUTHORIZED')
45
+ if (!transport || typeof transport.action !== 'function') throw profileError('QQ_PROFILE_UNAVAILABLE')
46
+
47
+ async function check(sessionId, action, write) {
48
+ let scene
49
+ try {
50
+ scene = core.authorizedScene(sessionId)
51
+ } catch {
52
+ throw profileError('QQ_PROFILE_UNAUTHORIZED')
53
+ }
54
+ if (write && scene.kind !== 'private') throw profileError('QQ_PROFILE_OWNER_REQUIRED')
55
+ let allowed = false
56
+ try {
57
+ allowed = await authorize({ action, write, sessionId, scene }) === true
58
+ } catch {
59
+ allowed = false
60
+ }
61
+ if (!allowed) throw profileError('QQ_PROFILE_UNAUTHORIZED')
62
+ return scene
63
+ }
64
+
65
+ async function call(action, params, write) {
66
+ let data
67
+ try {
68
+ data = await transport.action(action, params, 10_000)
69
+ } catch (error) {
70
+ if (!write) throw profileError('QQ_PROFILE_UNAVAILABLE')
71
+ if (error?.code === 'ONEBOT_REJECTED') throw profileError('QQ_PROFILE_REJECTED')
72
+ throw profileError('QQ_PROFILE_UNKNOWN')
73
+ }
74
+ if (data === null || data === undefined) {
75
+ if (write) return {}
76
+ throw responseInvalid()
77
+ }
78
+ if (typeof data !== 'object') throw responseInvalid()
79
+ return data
80
+ }
81
+
82
+ async function loginInfo(sessionId, write = false) {
83
+ const data = await call('get_login_info', {}, write)
84
+ return { userId: decimalId(data.user_id), nickname: typeof data.nickname === 'string' ? data.nickname.slice(0, MAX_NICKNAME) : '' }
85
+ }
86
+
87
+ async function strangerInfo(sessionId, userId, write = false) {
88
+ const data = await call('get_stranger_info', { user_id: userId, no_cache: true }, write)
89
+ return {
90
+ longNick: typeof data.long_nick === 'string' ? data.long_nick.slice(0, MAX_SIGNATURE) : '',
91
+ status: Number.isFinite(Number(data.status)) ? Number(data.status) : null,
92
+ }
93
+ }
94
+
95
+ async function userStatus(userId, write = false) {
96
+ const data = await call('nc_get_user_status', { user_id: userId }, write)
97
+ const status = Number(data.status)
98
+ const extStatus = Number(data.ext_status)
99
+ if (!Number.isSafeInteger(status) || !Number.isSafeInteger(extStatus)) throw responseInvalid()
100
+ return { status, extStatus }
101
+ }
102
+
103
+ async function readProfileInternal(sessionId, write = false) {
104
+ const login = await loginInfo(sessionId, write)
105
+ const stranger = await strangerInfo(sessionId, login.userId, write)
106
+ const online = await userStatus(login.userId, write)
107
+ return {
108
+ userId: login.userId,
109
+ nickname: login.nickname,
110
+ signature: stranger.longNick,
111
+ status: online.status,
112
+ extStatus: online.extStatus,
113
+ }
114
+ }
115
+
116
+ async function writeWithLedger(sessionId, action, payload, actionId, verify) {
117
+ const scene = await check(sessionId, action, true)
118
+ if (!ledger || typeof ledger.run !== 'function') throw profileError('QQ_PROFILE_LEDGER_REQUIRED')
119
+ if (typeof actionId !== 'string' || !/^[\w-]{1,100}$/.test(actionId)) throw profileError('QQ_PROFILE_ACTION_ID_INVALID')
120
+ const fingerprint = profileFingerprint(scene, action, payload)
121
+ return ledger.run({
122
+ actionId,
123
+ fingerprint,
124
+ action,
125
+ sessionId,
126
+ sceneKey: `${scene.botId}:${scene.kind}:${scene.peerId}`,
127
+ execute: async () => {
128
+ await call(action, payload, true)
129
+ try {
130
+ await check(sessionId, action, true)
131
+ } catch {
132
+ // The platform write already happened; do not report a clean failure.
133
+ throw profileError('QQ_PROFILE_UNKNOWN')
134
+ }
135
+ let profile
136
+ try {
137
+ profile = await readProfileInternal(sessionId, true)
138
+ if (!(await verify(profile))) throw profileError('QQ_PROFILE_UNKNOWN')
139
+ } catch (error) {
140
+ if (error?.code === 'QQ_PROFILE_UNKNOWN') throw error
141
+ throw profileError('QQ_PROFILE_UNKNOWN')
142
+ }
143
+ try {
144
+ await check(sessionId, action, true)
145
+ } catch {
146
+ throw profileError('QQ_PROFILE_UNKNOWN')
147
+ }
148
+ return profile
149
+ },
150
+ })
151
+ }
152
+
153
+ return Object.freeze({
154
+ async readProfile({ sessionId }) {
155
+ await check(sessionId, 'get_login_info', false)
156
+ const profile = await readProfileInternal(sessionId)
157
+ await check(sessionId, 'get_login_info', false)
158
+ return profile
159
+ },
160
+
161
+ async setSignature({ sessionId, signature, actionId }) {
162
+ const value = cleanText(signature, MAX_SIGNATURE, 'QQ_PROFILE_SIGNATURE_INVALID')
163
+ return writeWithLedger(sessionId, 'set_self_longnick', { longNick: value }, actionId,
164
+ profile => profile.signature === value)
165
+ },
166
+
167
+ async setNickname({ sessionId, nickname, actionId }) {
168
+ const value = cleanText(nickname, MAX_NICKNAME, 'QQ_PROFILE_NICKNAME_INVALID')
169
+ return writeWithLedger(sessionId, 'set_qq_profile', { nickname: value }, actionId,
170
+ profile => profile.nickname === value)
171
+ },
172
+
173
+ async setOnlineStatus({ sessionId, status, extStatus, batteryStatus, actionId }) {
174
+ if (!Number.isSafeInteger(status) || !STATUS_VALUES.has(status)) throw profileError('QQ_PROFILE_STATUS_INVALID')
175
+ if (!Number.isSafeInteger(extStatus) || extStatus < 0 || extStatus > 9999) throw profileError('QQ_PROFILE_STATUS_INVALID')
176
+ if (!Number.isSafeInteger(batteryStatus) || batteryStatus < 0 || batteryStatus > 100) throw profileError('QQ_PROFILE_STATUS_INVALID')
177
+ return writeWithLedger(sessionId, 'set_online_status', {
178
+ status, ext_status: extStatus, battery_status: batteryStatus,
179
+ }, actionId, profile => profile.status === status && profile.extStatus === extStatus)
180
+ },
181
+
182
+ async setAvatar({ sessionId, path: relativePath, actionId }) {
183
+ if (typeof sendRoot !== 'string' || !path.isAbsolute(sendRoot)) {
184
+ throw profileError('QQ_PROFILE_AVATAR_ROOT_INVALID')
185
+ }
186
+ const scene = await check(sessionId, 'set_qq_avatar', true)
187
+ if (!ledger || typeof ledger.run !== 'function') throw profileError('QQ_PROFILE_LEDGER_REQUIRED')
188
+ if (typeof actionId !== 'string' || !/^[\w-]{1,100}$/.test(actionId)) throw profileError('QQ_PROFILE_ACTION_ID_INVALID')
189
+
190
+ let snapshot
191
+ try {
192
+ snapshot = await snapshotControlledImage({ sendRoot, relativePath, tempRoot })
193
+ } catch {
194
+ throw profileError('QQ_PROFILE_AVATAR_INVALID')
195
+ }
196
+ const fingerprint = profileFingerprint(scene, 'set_qq_avatar', {
197
+ sha256: snapshot.sha256,
198
+ mediaType: snapshot.mediaType,
199
+ })
200
+ try {
201
+ return await ledger.run({
202
+ actionId,
203
+ fingerprint,
204
+ action: 'set_qq_avatar',
205
+ sessionId,
206
+ sceneKey: `${scene.botId}:${scene.kind}:${scene.peerId}`,
207
+ execute: async () => {
208
+ await call('set_qq_avatar', { file: snapshot.path }, true)
209
+ try {
210
+ await check(sessionId, 'set_qq_avatar', true)
211
+ } catch {
212
+ // The platform accepted the write; a later revocation is unknown.
213
+ throw profileError('QQ_PROFILE_UNKNOWN')
214
+ }
215
+ return { status: 'confirmed', mediaType: snapshot.mediaType, bytes: snapshot.bytes }
216
+ },
217
+ })
218
+ } finally {
219
+ await snapshot.cleanup()
220
+ }
221
+ },
222
+ })
223
+ }
@@ -0,0 +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
+ }