@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.
- package/LICENSE +21 -0
- package/README.md +53 -3
- package/README.zh-CN.md +51 -0
- package/REFERENCE.md +49 -0
- package/assets/market-icon.png +0 -0
- package/cordis.patch.yml +6 -0
- package/docs/COMPATIBILITY.md +17 -0
- package/docs/DATA_MIGRATION.md +26 -0
- package/docs/INSTALL_V1_2.md +22 -0
- package/docs/PUBLISHING.md +26 -0
- package/docs/RELEASE_CHECKLIST.md +23 -0
- package/docs/RELEASE_NOTES.md +38 -0
- package/docs/UPGRADE_POLICY.md +26 -0
- package/docs/V1_SCOPE.md +33 -0
- package/docs/screenshots/account-browser.png +0 -0
- package/docs/screenshots/account-dark-browser.png +0 -0
- package/docs/screenshots/global-desktop.png +0 -0
- package/docs/screenshots/overview-desktop.png +0 -0
- package/docs/screenshots/product-release-20261031.png +0 -0
- package/docs/screenshots/v1.2-overview.png +0 -0
- package/docs/v1.1/PRESET_MATRIX.md +28 -0
- package/docs/v1.1/PROTOTYPE_DIFF.md +15 -0
- package/docs/v1.1/TEST_REPORT.md +20 -0
- package/docs/v1.1/UPGRADE.md +10 -0
- package/docs/verification/V1_REAL_NAPCAT_2026-10-27.md +43 -0
- package/lib/client.js +380 -0
- package/package.json +91 -6
- package/preset.patch.yml +103 -0
- package/screenshots.json +6 -0
- package/scripts/build-client.mjs +52 -0
- package/scripts/registry-guard.mjs +12 -0
- package/scripts/release-audit.mjs +59 -0
- package/scripts/symlink-security.mjs +8 -0
- package/src/account-profile.js +223 -0
- package/src/admin-http.js +361 -0
- package/src/audio.js +428 -0
- package/src/capabilities.js +785 -0
- package/src/client/factory.template.js +82 -0
- package/src/client/logic.js +290 -0
- package/src/config.js +48 -0
- package/src/console/console.css +28 -0
- package/src/console/console.js +538 -0
- package/src/console/index.html +67 -0
- package/src/core.js +892 -0
- package/src/files.js +230 -0
- package/src/index.js +867 -0
- package/src/lease.js +42 -0
- package/src/media.js +246 -0
- package/src/napcat-action-names.js +186 -0
- package/src/native-asr.js +112 -0
- package/src/onebot.js +378 -0
- package/src/outbound-media.js +309 -0
- package/src/owner-workspace.js +394 -0
- package/src/permissions.js +250 -0
- package/src/preset-guard.mjs +579 -0
- package/src/qzone-provider.js +628 -0
- package/src/session-provenance.js +74 -0
- package/src/setup.js +255 -0
- package/src/storage.js +580 -0
- 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
|
+
}
|