@ducking-mind/proxy-app-cli 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -13
- package/agent-contract.mjs +13 -6
- package/backend-container.mjs +199 -0
- package/local-dev-credentials.mjs +5 -5
- package/local-dev-relay.mjs +130 -524
- package/package.json +4 -2
- package/platform-settings.mjs +1 -0
- package/proxy-app.mjs +376 -49
- package/runtime-sources.mjs +83 -0
package/local-dev-relay.mjs
CHANGED
|
@@ -1,78 +1,36 @@
|
|
|
1
1
|
import { createServer } from 'node:http'
|
|
2
2
|
import { randomUUID } from 'node:crypto'
|
|
3
|
-
import { Readable } from 'node:stream'
|
|
4
3
|
|
|
5
|
-
const HOP_BY_HOP_HEADERS = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade'])
|
|
6
|
-
const SENSITIVE_HEADERS = new Set(['host', 'authorization', 'cookie', 'origin', 'referer', 'content-length', 'x-proxy-app-relay'])
|
|
7
4
|
const PLATFORM_AUTH_TIMEOUT_MS = 30 * 1000
|
|
8
|
-
const validAccountScope = (value) => typeof value === 'string' && /^acs_[a-f0-9]{64}$/.test(value)
|
|
9
|
-
const accountScopeUnavailable = () => relayError('APP_ACCOUNT_SCOPE_UNAVAILABLE', '暂时无法确认账户信息,请重新登录后再试;如果仍无法进入,请联系平台管理员。', 'account_scope', 502)
|
|
10
5
|
|
|
11
|
-
function
|
|
12
|
-
const headers = new Headers()
|
|
13
|
-
for (const [name, value] of Object.entries(request.headers)) {
|
|
14
|
-
const lower = name.toLowerCase()
|
|
15
|
-
if (HOP_BY_HOP_HEADERS.has(lower) || SENSITIVE_HEADERS.has(lower)) continue
|
|
16
|
-
if (Array.isArray(value)) headers.set(name, value.join(', '))
|
|
17
|
-
else if (value !== undefined) headers.set(name, value)
|
|
18
|
-
}
|
|
19
|
-
return headers
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function copyResponseHeaders(response, target) {
|
|
23
|
-
response.headers.forEach((value, name) => {
|
|
24
|
-
const lower = name.toLowerCase()
|
|
25
|
-
if (!HOP_BY_HOP_HEADERS.has(lower) && !['content-encoding', 'content-length', 'set-cookie'].includes(lower)) target.setHeader(name, value)
|
|
26
|
-
})
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function readBody(request, limit = 16 * 1024 * 1024) {
|
|
6
|
+
async function readBody(request, limit = 16 * 1024) {
|
|
30
7
|
const chunks = []
|
|
31
|
-
let
|
|
8
|
+
let length = 0
|
|
32
9
|
for await (const chunk of request) {
|
|
33
|
-
|
|
34
|
-
if (
|
|
10
|
+
length += chunk.length
|
|
11
|
+
if (length > limit) throw Object.assign(new Error('request body is too large'), { statusCode: 413 })
|
|
35
12
|
chunks.push(chunk)
|
|
36
13
|
}
|
|
37
14
|
return Buffer.concat(chunks)
|
|
38
15
|
}
|
|
39
16
|
|
|
40
|
-
async function
|
|
41
|
-
const text =
|
|
42
|
-
if (!text.trim()) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
17
|
+
async function readResponse(response) {
|
|
18
|
+
const text = await response.text()
|
|
19
|
+
if (!text.trim()) return { text, payload: null, invalidJSON: true }
|
|
20
|
+
try {
|
|
21
|
+
const payload = JSON.parse(text)
|
|
22
|
+
return {
|
|
23
|
+
text,
|
|
24
|
+
payload,
|
|
25
|
+
invalidJSON: !payload || typeof payload !== 'object' || Array.isArray(payload),
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
return { text, payload: null, invalidJSON: true }
|
|
49
29
|
}
|
|
50
30
|
}
|
|
51
31
|
|
|
52
|
-
function unwrap(
|
|
53
|
-
return
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function errorMessage(payload, fallback) {
|
|
57
|
-
const error = typeof payload?.error === 'string' ? payload.error : payload?.error?.message
|
|
58
|
-
return error || payload?.message || fallback
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function errorCode(payload) {
|
|
62
|
-
const error = payload?.error
|
|
63
|
-
const code = typeof error === 'object' && error ? error.code : undefined
|
|
64
|
-
return String(code || payload?.code || '').trim()
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function retryableSessionExpiry(response) {
|
|
68
|
-
if (response?.status !== 401) return false
|
|
69
|
-
const payload = await readJSON(response.clone())
|
|
70
|
-
const error = payload?.error
|
|
71
|
-
const reason = typeof error === 'object' && error ? error.auth_reason : undefined
|
|
72
|
-
const executionStarted = typeof error === 'object' && error && Object.prototype.hasOwnProperty.call(error, 'execution_started')
|
|
73
|
-
? error.execution_started
|
|
74
|
-
: payload?.execution_started
|
|
75
|
-
return reason === 'APP_SESSION_EXPIRED' && executionStarted === false
|
|
32
|
+
function unwrap(value) {
|
|
33
|
+
return value && Object.prototype.hasOwnProperty.call(value, 'data') ? value.data : value
|
|
76
34
|
}
|
|
77
35
|
|
|
78
36
|
function relayError(code, message, stage, statusCode = 502, cause) {
|
|
@@ -84,45 +42,37 @@ function relayError(code, message, stage, statusCode = 502, cause) {
|
|
|
84
42
|
return error
|
|
85
43
|
}
|
|
86
44
|
|
|
45
|
+
function errorCode(payload) {
|
|
46
|
+
const error = payload?.error
|
|
47
|
+
return String((error && typeof error === 'object' ? error.code : '') || payload?.code || '').trim()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function errorMessage(payload, fallback) {
|
|
51
|
+
const error = typeof payload?.error === 'string' ? payload.error : payload?.error?.message
|
|
52
|
+
return error || payload?.message || fallback
|
|
53
|
+
}
|
|
54
|
+
|
|
87
55
|
function visibleErrorMessage(error) {
|
|
88
56
|
switch (error?.code) {
|
|
89
57
|
case 'APP_PLATFORM_UNAVAILABLE': return '无法连接平台,请确认平台服务已启动或网络可用后重试。'
|
|
90
58
|
case 'APP_PLATFORM_TIMEOUT': return '平台响应超时,请稍后重试。'
|
|
91
|
-
case 'APP_PLATFORM_INVALID_RESPONSE': return
|
|
92
|
-
case 'APP_LOGIN_SESSION_EXPIRED': return '
|
|
93
|
-
case 'APP_LOGIN_NOT_ALLOWED': return '当前账户无权使用此应用,请切换账户后重试。'
|
|
59
|
+
case 'APP_PLATFORM_INVALID_RESPONSE': return `平台返回了非 JSON 或格式无效的响应(HTTP ${error?.statusCode || 502}),请检查平台 API 地址、路由及服务配置。`
|
|
60
|
+
case 'APP_LOGIN_SESSION_EXPIRED': return '开发者授权已过期,请重新运行 proxy-app dev。'
|
|
94
61
|
case 'APP_APP_REGISTRATION_FAILED':
|
|
95
|
-
if (
|
|
96
|
-
if ([403, 404].includes(error?.statusCode)) {
|
|
62
|
+
if ([401, 403, 404].includes(error?.statusCode)) {
|
|
97
63
|
const account = error?.currentAccount ? ` 当前登录账户:${error.currentAccount}。` : ''
|
|
98
64
|
const platform = error?.platformAPI ? ` 平台 API 地址:${error.platformAPI}。` : ''
|
|
99
|
-
const reason = error.statusCode === 403 ? '当前账户无权开发此应用' : '该平台上没有当前账户拥有的同 slug 应用'
|
|
100
|
-
return `${reason}。${account}${platform}
|
|
65
|
+
const reason = error.statusCode === 403 ? '当前账户无权开发此应用' : error.statusCode === 404 ? '该平台上没有当前账户拥有的同 slug 应用' : '开发者授权已失效'
|
|
66
|
+
return `${reason}。${account}${platform}请使用应用创建者账户登录,并确认 manifest.json 的 id 与平台应用 slug 完全一致。`
|
|
101
67
|
}
|
|
102
|
-
if (error?.statusCode === 400)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
return '应用开发登记失败(HTTP ' + error?.statusCode + '),请根据请求编号检查平台日志。'
|
|
107
|
-
case 'APP_SESSION_CREATE_FAILED': return '用户 App Session 创建失败,请稍后重试。'
|
|
108
|
-
case 'APP_SESSION_INVALID': return '平台返回的用户 App Session 无效,请稍后重试。'
|
|
109
|
-
default: return String(error?.message || '平台登录失败,请重试。').slice(0, 240)
|
|
68
|
+
if (error?.statusCode === 400) return '应用开发登记参数不符合平台要求,请检查 manifest.json 的 id、version、权限和能力声明。'
|
|
69
|
+
return `应用开发登记失败(HTTP ${error?.statusCode || 502}),请根据请求编号检查平台日志。`
|
|
70
|
+
default: return String(error?.message || '平台开发授权失败,请重试。').slice(0, 240)
|
|
110
71
|
}
|
|
111
72
|
}
|
|
112
73
|
|
|
113
74
|
function htmlEscape(value) {
|
|
114
|
-
return String(value).replace(/[&<>"']/g,
|
|
115
|
-
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
116
|
-
}[character]))
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function safeScriptJSON(value) {
|
|
120
|
-
return JSON.stringify(value)
|
|
121
|
-
.replace(/</g, '\\u003c')
|
|
122
|
-
.replace(/>/g, '\\u003e')
|
|
123
|
-
.replace(/&/g, '\\u0026')
|
|
124
|
-
.replace(/\u2028/g, '\\u2028')
|
|
125
|
-
.replace(/\u2029/g, '\\u2029')
|
|
75
|
+
return String(value).replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character])
|
|
126
76
|
}
|
|
127
77
|
|
|
128
78
|
function safeURL(value, label) {
|
|
@@ -134,139 +84,87 @@ function safeURL(value, label) {
|
|
|
134
84
|
return parsed.origin + parsed.pathname.replace(/\/$/, '')
|
|
135
85
|
}
|
|
136
86
|
|
|
137
|
-
function
|
|
138
|
-
const normalized = safeURL(value, 'platform returned gateway URL')
|
|
139
|
-
const url = new URL(normalized)
|
|
140
|
-
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '')
|
|
141
|
-
const loopback = hostname === 'localhost' || hostname === '::1' || /^127(?:\.\d{1,3}){3}$/.test(hostname)
|
|
142
|
-
const pathStart = value.indexOf('/', value.indexOf('://') + 3)
|
|
143
|
-
const rawPath = pathStart < 0 ? '/' : value.slice(pathStart)
|
|
144
|
-
if (value !== value.trim() || value.includes('\\') || rawPath !== new URL(value).pathname ||
|
|
145
|
-
(url.pathname !== '/' && url.pathname !== '/v1') ||
|
|
146
|
-
/(?:^|[.-])(?:admin|admin-api|admin-go|codex-admin-api|portal|www)(?:[.-]|$)/.test(hostname) ||
|
|
147
|
-
(url.protocol === 'http:' && !loopback)) {
|
|
148
|
-
throw relayError('APP_GATEWAY_URL_INVALID', 'App Session must use the Gateway /v1 URL', 'app_session')
|
|
149
|
-
}
|
|
150
|
-
return url.origin + '/v1'
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function jsonResponse(response, status, payload) {
|
|
87
|
+
function htmlResponse(response, status, title, description, link = '') {
|
|
154
88
|
response.statusCode = status
|
|
155
|
-
response.setHeader('Content-Type', '
|
|
89
|
+
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
|
156
90
|
response.setHeader('Cache-Control', 'no-store')
|
|
157
|
-
response.
|
|
91
|
+
response.setHeader('Referrer-Policy', 'no-referrer')
|
|
92
|
+
response.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'")
|
|
93
|
+
const anchor = link ? `<a href="${htmlEscape(link)}" rel="noreferrer">打开本地应用</a>` : ''
|
|
94
|
+
response.end(`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${htmlEscape(title)}</title><style>html{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#111;color:#eee;font:15px/1.8 system-ui,sans-serif}main{box-sizing:border-box;width:min(440px,calc(100% - 40px));padding:32px;border:1px solid #383838;border-radius:18px;background:#222}small,p{color:#aaa}h1{font-size:24px;line-height:1.4}p{overflow-wrap:anywhere}a{display:block;margin-top:24px;padding:10px;text-align:center;background:#f1f1f1;color:#202020;border-radius:9px;text-decoration:none}</style><main><small>Proxy App · 开发环境初始化</small><h1>${htmlEscape(title)}</h1><p>${htmlEscape(description)}</p>${anchor}</main></html>`)
|
|
158
95
|
}
|
|
159
96
|
|
|
160
97
|
/**
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* runtime requests or reach the app. Both credentials may use separate OS stores.
|
|
98
|
+
* Handles developer authorization and draft registration only. Runtime user
|
|
99
|
+
* sessions are created by the Portal in the browser and never enter this
|
|
100
|
+
* process or its credential store.
|
|
165
101
|
*/
|
|
166
|
-
export async function createLocalDevelopmentRelay({ baseURL, portalURL = '',
|
|
167
|
-
if (!baseURL || !
|
|
168
|
-
const
|
|
169
|
-
const portalOrigin = safeURL(portalURL ||
|
|
170
|
-
const state = {
|
|
171
|
-
let credentialQueue = Promise.resolve()
|
|
172
|
-
const updateCredentials = (operation) => {
|
|
173
|
-
// Serialize writes so an in-flight save cannot resurrect a logged-out account.
|
|
174
|
-
credentialQueue = credentialQueue.then(operation).catch(() => undefined)
|
|
175
|
-
return credentialQueue
|
|
176
|
-
}
|
|
102
|
+
export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', appID, appSlug, version, manifest, functionFiles = [], entryURL, developerCredentialStore = null }) {
|
|
103
|
+
if (!baseURL || !appID || !appSlug || !version || !entryURL) throw new Error('local development relay configuration is incomplete')
|
|
104
|
+
const platformAPI = safeURL(baseURL, 'platform API URL')
|
|
105
|
+
const portalOrigin = safeURL(portalURL || platformAPI, 'platform Portal URL')
|
|
106
|
+
const state = { closed: false }
|
|
177
107
|
let server
|
|
178
108
|
let relayURL = ''
|
|
179
109
|
let developmentReady = false
|
|
180
110
|
let developmentPromise = null
|
|
111
|
+
let developmentSessionGrant = ''
|
|
181
112
|
let setupState = ''
|
|
182
113
|
let setupIssuedAt = 0
|
|
183
114
|
let submitSetupToken = null
|
|
184
115
|
let cancelSetup = null
|
|
185
116
|
|
|
186
|
-
const portalJSON = async (path, init
|
|
187
|
-
|
|
188
|
-
if (!token) {
|
|
189
|
-
throw relayError('APP_LOGIN_REQUIRED', 'platform user login is required', stage, 401)
|
|
190
|
-
}
|
|
117
|
+
const portalJSON = async (path, init, stage, token) => {
|
|
118
|
+
if (!token) throw relayError('APP_LOGIN_REQUIRED', 'developer login is required', stage, 401)
|
|
191
119
|
const controller = new AbortController()
|
|
192
120
|
const timeout = setTimeout(() => controller.abort(), PLATFORM_AUTH_TIMEOUT_MS)
|
|
193
121
|
let response
|
|
194
|
-
let
|
|
122
|
+
let result
|
|
195
123
|
try {
|
|
196
|
-
response = await fetch(
|
|
124
|
+
response = await fetch(platformAPI + path, {
|
|
197
125
|
...init,
|
|
198
|
-
signal: init
|
|
199
|
-
headers: { Authorization: 'Bearer ' + token, ...(init
|
|
126
|
+
signal: init?.signal ? AbortSignal.any([controller.signal, init.signal]) : controller.signal,
|
|
127
|
+
headers: { Authorization: 'Bearer ' + token, ...(init?.headers || {}) },
|
|
200
128
|
})
|
|
201
|
-
|
|
202
|
-
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new SyntaxError('platform response is not an object')
|
|
129
|
+
result = await readResponse(response)
|
|
203
130
|
} catch (error) {
|
|
204
|
-
if (init
|
|
131
|
+
if (init?.signal?.aborted) throw init.signal.reason
|
|
205
132
|
if (controller.signal.aborted || error?.name === 'AbortError') throw relayError('APP_PLATFORM_TIMEOUT', 'platform request timed out', stage, 504, error)
|
|
206
|
-
if (error instanceof SyntaxError) throw relayError('APP_PLATFORM_INVALID_RESPONSE', 'platform response is invalid', stage, 502, error)
|
|
207
133
|
throw relayError('APP_PLATFORM_UNAVAILABLE', 'platform request could not be reached', stage, 502, error)
|
|
208
134
|
} finally {
|
|
209
135
|
clearTimeout(timeout)
|
|
210
136
|
}
|
|
137
|
+
const payload = result.payload
|
|
138
|
+
const requestId = response.headers.get('x-request-id') || response.headers.get('x-trace-id') || ''
|
|
139
|
+
if (result.invalidJSON) {
|
|
140
|
+
const failure = relayError('APP_PLATFORM_INVALID_RESPONSE', `platform returned a non-JSON response (HTTP ${response.status})`, stage, response.status)
|
|
141
|
+
failure.requestId = requestId
|
|
142
|
+
throw failure
|
|
143
|
+
}
|
|
211
144
|
if (!response.ok) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
if (response.status === 401) throw relayError('APP_LOGIN_SESSION_EXPIRED', 'platform user session expired', stage, 401)
|
|
218
|
-
if (response.status === 403 && stage !== 'app_registration') throw relayError('APP_LOGIN_NOT_ALLOWED', 'platform user is not allowed to use this app', stage, 403)
|
|
219
|
-
const code = stage === 'app_registration' ? 'APP_APP_REGISTRATION_FAILED' : stage === 'app_session' ? 'APP_SESSION_CREATE_FAILED' : 'APP_PLATFORM_REQUEST_FAILED'
|
|
220
|
-
const failure = relayError(code, errorMessage(payload, 'platform request failed: HTTP ' + response.status), stage, response.status)
|
|
221
|
-
failure.requestId = response.headers.get('x-request-id') || ''
|
|
222
|
-
failure.upstreamCode = errorCode(payload)
|
|
145
|
+
const code = errorCode(payload)
|
|
146
|
+
const failure = relayError(response.status === 401 ? 'APP_LOGIN_SESSION_EXPIRED' : stage === 'app_registration' ? 'APP_APP_REGISTRATION_FAILED' : code || 'APP_PLATFORM_REQUEST_FAILED', errorMessage(payload, 'platform request failed: HTTP ' + response.status), stage, response.status)
|
|
147
|
+
failure.requestId = requestId
|
|
148
|
+
failure.upstreamCode = code
|
|
223
149
|
throw failure
|
|
224
150
|
}
|
|
225
151
|
return unwrap(payload)
|
|
226
152
|
}
|
|
227
153
|
|
|
228
|
-
const revoke = async (session, authToken) => {
|
|
229
|
-
const id = session?.session_id
|
|
230
|
-
if (!id || state.revoked.has(id) || !(authToken === undefined ? state.portalToken : authToken)) return
|
|
231
|
-
state.revoked.add(id)
|
|
232
|
-
await portalJSON('/portal/user/apps/' + encodeURIComponent(appID) + '/sessions/' + encodeURIComponent(id), { method: 'DELETE' }, 'platform', authToken).catch(() => undefined)
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const clearAuthentication = async (forceLogin = false) => {
|
|
236
|
-
const current = state.session
|
|
237
|
-
const token = state.portalToken
|
|
238
|
-
state.authGeneration += 1
|
|
239
|
-
state.session = null
|
|
240
|
-
state.portalToken = ''
|
|
241
|
-
state.refresh = null
|
|
242
|
-
state.login = null
|
|
243
|
-
state.restore = null
|
|
244
|
-
state.cacheLoaded = true
|
|
245
|
-
state.forceLogin = forceLogin
|
|
246
|
-
state.authState = ''
|
|
247
|
-
state.authStateIssuedAt = 0
|
|
248
|
-
await Promise.all([revoke(current, token), updateCredentials(() => credentialStore?.clear())])
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const assertAuthentication = (generation) => {
|
|
252
|
-
if (state.closed) throw new Error('local development relay is closed')
|
|
253
|
-
if (generation !== state.authGeneration) throw relayError('APP_AUTH_CHANGED', 'local development authentication changed', 'app_session', 409)
|
|
254
|
-
}
|
|
255
|
-
|
|
256
154
|
const register = async (token, signal) => {
|
|
257
155
|
const details = await portalJSON('/portal/user/app-workspace/by-slug/' + encodeURIComponent(appSlug) + '/local-development', {
|
|
258
|
-
method: 'POST',
|
|
259
|
-
signal,
|
|
156
|
+
method: 'POST', signal,
|
|
260
157
|
headers: { 'Content-Type': 'application/json' },
|
|
261
|
-
body: JSON.stringify({ entry_url: entryURL, manifest }),
|
|
158
|
+
body: JSON.stringify({ entry_url: entryURL, manifest, ...(functionFiles.length ? { function_files: functionFiles } : {}) }),
|
|
262
159
|
}, 'app_registration', token)
|
|
263
160
|
if (!details || typeof details.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(details.id)) {
|
|
264
161
|
throw relayError('APP_PLATFORM_INVALID_RESPONSE', 'platform did not return the registered application identity', 'app_registration', 502)
|
|
265
162
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
163
|
+
if (typeof details.local_development_grant !== 'string' || details.local_development_grant.length < 32 || details.local_development_grant.length > 8192) {
|
|
164
|
+
throw relayError('APP_PLATFORM_INVALID_RESPONSE', 'platform did not return a local preview grant', 'app_registration', 502)
|
|
165
|
+
}
|
|
269
166
|
appID = details.id
|
|
167
|
+
developmentSessionGrant = details.local_development_grant
|
|
270
168
|
return details
|
|
271
169
|
}
|
|
272
170
|
|
|
@@ -275,10 +173,7 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
275
173
|
return await register(token, signal)
|
|
276
174
|
} catch (error) {
|
|
277
175
|
if (error?.stage === 'app_registration' && [403, 404].includes(error.statusCode)) {
|
|
278
|
-
error.platformAPI =
|
|
279
|
-
// The profile endpoint confirms which account the accepted token
|
|
280
|
-
// represents. Show only that account's own email to make duplicate
|
|
281
|
-
// usernames and near-identical addresses distinguishable.
|
|
176
|
+
error.platformAPI = platformAPI
|
|
282
177
|
const profile = await portalJSON('/portal/user/profile', {}, 'account_scope', token).catch(() => null)
|
|
283
178
|
const email = typeof profile?.email === 'string' ? profile.email.trim() : ''
|
|
284
179
|
if (email && email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) error.currentAccount = email
|
|
@@ -287,11 +182,14 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
287
182
|
}
|
|
288
183
|
}
|
|
289
184
|
|
|
290
|
-
const
|
|
291
|
-
|
|
185
|
+
const developmentHTML = error => {
|
|
186
|
+
const title = error ? '开发环境检查失败' : '开发环境已就绪'
|
|
187
|
+
const description = error ? visibleErrorMessage(error) : '应用登记与开发权限检查已通过,本地应用正在启动。'
|
|
188
|
+
return { title, description }
|
|
189
|
+
}
|
|
292
190
|
|
|
293
191
|
const prepareDevelopment = ({ token = '', onAuthorizationRequired, signal, forceLogin = false, timeoutMs = 10 * 60 * 1000 } = {}) => {
|
|
294
|
-
if (state.closed || signal?.aborted) return Promise.reject(
|
|
192
|
+
if (state.closed || signal?.aborted) return Promise.reject(Object.assign(new Error('开发环境初始化已取消。'), { name: 'AbortError' }))
|
|
295
193
|
if (developmentReady) return Promise.resolve()
|
|
296
194
|
if (developmentPromise) return developmentPromise
|
|
297
195
|
const pending = (async () => {
|
|
@@ -299,26 +197,23 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
299
197
|
let rejectCancellation
|
|
300
198
|
const controller = new AbortController()
|
|
301
199
|
const cancellation = new Promise((_, reject) => { rejectCancellation = reject })
|
|
302
|
-
// Install the handler immediately, including while reading the OS store.
|
|
303
200
|
cancellation.catch(() => undefined)
|
|
304
|
-
const cancel =
|
|
305
|
-
const abort = () => cancel(
|
|
201
|
+
const cancel = error => { controller.abort(error); rejectCancellation(error) }
|
|
202
|
+
const abort = () => cancel(Object.assign(new Error('开发环境初始化已取消。'), { name: 'AbortError' }))
|
|
306
203
|
cancelSetup = abort
|
|
307
204
|
signal?.addEventListener('abort', abort, { once: true })
|
|
308
|
-
const checkActive = () => {
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
const wait = (operation) => Promise.race([operation, cancellation])
|
|
205
|
+
const checkActive = () => { if (state.closed || signal?.aborted) abort() }
|
|
206
|
+
const wait = operation => Promise.race([operation, cancellation])
|
|
312
207
|
try {
|
|
313
208
|
const explicitToken = !forceLogin && typeof token === 'string' ? token.trim() : ''
|
|
314
209
|
let requiresFreshLogin = forceLogin
|
|
315
210
|
let developerToken = explicitToken
|
|
316
211
|
if (!developerToken && !forceLogin) developerToken = await wait(Promise.resolve(developerCredentialStore?.load()))
|
|
317
|
-
// A
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
if (!developerToken && !explicitToken) requiresFreshLogin =
|
|
212
|
+
// A Portal can already have a valid browser login for a different
|
|
213
|
+
// account than the developer. When the CLI has no explicit or cached
|
|
214
|
+
// credential, require the user to choose the developer account rather
|
|
215
|
+
// than silently accepting the Portal's persisted browser session.
|
|
216
|
+
if (!developerToken && !explicitToken) requiresFreshLogin = true
|
|
322
217
|
checkActive()
|
|
323
218
|
if (developerToken) {
|
|
324
219
|
try {
|
|
@@ -327,15 +222,8 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
327
222
|
developmentReady = true
|
|
328
223
|
return
|
|
329
224
|
} catch (error) {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
// that case as 403/404, the same way it reports an expired
|
|
333
|
-
// developer session as 401. Drop only the cached credential and
|
|
334
|
-
// offer one fresh developer login; explicit management tokens are
|
|
335
|
-
// caller supplied and must remain an actionable hard failure.
|
|
336
|
-
const registrationNeedsFreshLogin = error?.code === 'APP_LOGIN_SESSION_EXPIRED'
|
|
337
|
-
|| (error?.stage === 'app_registration' && [401, 403, 404].includes(error.statusCode))
|
|
338
|
-
if (!registrationNeedsFreshLogin || explicitToken) throw error
|
|
225
|
+
const retry = error?.code === 'APP_LOGIN_SESSION_EXPIRED' || error?.stage === 'app_registration' && [401, 403, 404].includes(error.statusCode)
|
|
226
|
+
if (!retry || explicitToken) throw error
|
|
339
227
|
await wait(Promise.resolve(developerCredentialStore?.clear()))
|
|
340
228
|
requiresFreshLogin = true
|
|
341
229
|
}
|
|
@@ -343,26 +231,21 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
343
231
|
checkActive()
|
|
344
232
|
if (!onAuthorizationRequired) throw relayError('APP_DEVELOPER_LOGIN_REQUIRED', '请先通过 proxy-app dev 完成开发者授权。', 'development_preflight', 401)
|
|
345
233
|
timer = setTimeout(() => cancel(relayError('APP_DEVELOPER_LOGIN_TIMEOUT', '开发者授权等待超时,请重新运行 proxy-app dev。', 'development_preflight', 408)), Math.max(1, Math.min(timeoutMs, 10 * 60 * 1000)))
|
|
346
|
-
checkActive()
|
|
347
234
|
setupState = randomUUID()
|
|
348
235
|
setupIssuedAt = Date.now()
|
|
349
|
-
const receivedToken = new Promise(
|
|
236
|
+
const receivedToken = new Promise(resolve => { submitSetupToken = resolve })
|
|
350
237
|
const query = new URLSearchParams({ proxy_app_auth: '1', proxy_app_state: setupState, proxy_app_relay: relayURL })
|
|
351
238
|
if (requiresFreshLogin) query.set('proxy_app_force_login', '1')
|
|
352
|
-
|
|
353
|
-
await wait(Promise.resolve(onAuthorizationRequired(details)))
|
|
239
|
+
await wait(Promise.resolve(onAuthorizationRequired({ login_url: portalOrigin + '/#/login?' + query, auth_state: setupState, relay_url: relayURL, force_login: requiresFreshLogin })))
|
|
354
240
|
developerToken = await wait(receivedToken)
|
|
355
241
|
checkActive()
|
|
356
242
|
if (!developerToken) throw relayError('APP_LOGIN_TOKEN_INVALID', '平台未返回有效的开发者授权,请重新运行 proxy-app dev。', 'development_preflight', 400)
|
|
357
243
|
await wait(registerWithDiagnostics(developerToken, controller.signal))
|
|
358
244
|
checkActive()
|
|
359
|
-
// Runtime user login never reads or clears this store.
|
|
360
245
|
await wait(Promise.resolve(developerCredentialStore?.save(developerToken)).catch(() => undefined))
|
|
361
246
|
checkActive()
|
|
362
247
|
developmentReady = true
|
|
363
248
|
} catch (error) {
|
|
364
|
-
// Preserve structured diagnostic fields, but never print arbitrary
|
|
365
|
-
// upstream text or credentials in the terminal/authorization page.
|
|
366
249
|
if (error?.name === 'AbortError') throw error
|
|
367
250
|
throw Object.assign(new Error(visibleErrorMessage(error)), {
|
|
368
251
|
code: error?.code, stage: error?.stage, statusCode: error?.statusCode,
|
|
@@ -383,340 +266,63 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
383
266
|
return pending
|
|
384
267
|
}
|
|
385
268
|
|
|
386
|
-
const refresh = async (force = false, generation = state.authGeneration) => {
|
|
387
|
-
assertAuthentication(generation)
|
|
388
|
-
if (state.refresh) return state.refresh
|
|
389
|
-
const refreshToken = state.portalToken
|
|
390
|
-
const previous = force ? state.session : null
|
|
391
|
-
const pending = portalJSON('/portal/user/apps/' + encodeURIComponent(appID) + '/sessions', {
|
|
392
|
-
method: 'POST',
|
|
393
|
-
headers: { 'Content-Type': 'application/json' },
|
|
394
|
-
// App Sessions are always issued for the Gateway data plane. The old
|
|
395
|
-
// runtime_protocol=2 and Bridge transport are not valid app transports.
|
|
396
|
-
body: JSON.stringify({ version, runtime_protocol: 1 }),
|
|
397
|
-
}, 'app_session', refreshToken).then(async (raw) => {
|
|
398
|
-
if (!raw || typeof raw.access_token !== 'string' || typeof raw.gateway_url !== 'string') throw relayError('APP_SESSION_INVALID', 'platform returned an invalid App Session', 'app_session', 502)
|
|
399
|
-
const next = { ...raw, gateway_url: normalizeGatewayURL(raw.gateway_url) }
|
|
400
|
-
if (state.closed || state.authGeneration !== generation || state.portalToken !== refreshToken) {
|
|
401
|
-
await revoke(next, refreshToken)
|
|
402
|
-
assertAuthentication(generation)
|
|
403
|
-
throw relayError('APP_AUTH_CHANGED', 'local development authentication changed', 'app_session', 409)
|
|
404
|
-
}
|
|
405
|
-
state.session = next
|
|
406
|
-
if (previous && previous.session_id !== next.session_id) await revoke(previous, refreshToken)
|
|
407
|
-
return next
|
|
408
|
-
}).finally(() => {
|
|
409
|
-
if (state.refresh === pending) state.refresh = null
|
|
410
|
-
})
|
|
411
|
-
state.refresh = pending
|
|
412
|
-
return pending
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
const restoreAuthentication = async () => {
|
|
416
|
-
if (!developmentReady) throw developmentRequired()
|
|
417
|
-
if (state.login) return state.login
|
|
418
|
-
if (state.session) return state.session
|
|
419
|
-
if (state.restore) return state.restore
|
|
420
|
-
const generation = state.authGeneration
|
|
421
|
-
const pending = (async () => {
|
|
422
|
-
if (!state.cacheLoaded) {
|
|
423
|
-
const token = await credentialStore?.load()
|
|
424
|
-
assertAuthentication(generation)
|
|
425
|
-
state.cacheLoaded = true
|
|
426
|
-
state.portalToken = typeof token === 'string' ? token : ''
|
|
427
|
-
}
|
|
428
|
-
if (!state.portalToken) return null
|
|
429
|
-
// Preparation already registered this process's manifest and entry URL.
|
|
430
|
-
// Restoring a runtime user must never mutate the developer's app version.
|
|
431
|
-
return await refresh(false, generation)
|
|
432
|
-
})().catch(async (error) => {
|
|
433
|
-
if (state.authGeneration !== generation || state.closed) throw error
|
|
434
|
-
if (['APP_LOGIN_SESSION_EXPIRED', 'APP_LOGIN_NOT_ALLOWED'].includes(error?.code)) {
|
|
435
|
-
await clearAuthentication(true)
|
|
436
|
-
return null
|
|
437
|
-
}
|
|
438
|
-
// Network or session failures are retryable; retain the saved
|
|
439
|
-
// authorization instead of converting every failure into a login prompt.
|
|
440
|
-
throw error
|
|
441
|
-
}).finally(() => {
|
|
442
|
-
if (state.restore === pending) state.restore = null
|
|
443
|
-
})
|
|
444
|
-
state.restore = pending
|
|
445
|
-
return pending
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
const loginDetails = () => {
|
|
449
|
-
if (!state.authState || Date.now() - state.authStateIssuedAt > 10 * 60 * 1000) {
|
|
450
|
-
state.authState = randomUUID()
|
|
451
|
-
state.authStateIssuedAt = Date.now()
|
|
452
|
-
}
|
|
453
|
-
const query = new URLSearchParams({ proxy_app_auth: '1', proxy_app_state: state.authState, proxy_app_relay: relayURL })
|
|
454
|
-
return { login_url: portalOrigin + '/#/login?' + query, auth_state: state.authState, relay_url: relayURL, force_login: state.forceLogin }
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const requireLogin = (response, reason) => jsonResponse(response, 401, {
|
|
458
|
-
code: 'APP_LOGIN_REQUIRED',
|
|
459
|
-
error: {
|
|
460
|
-
code: 'APP_LOGIN_REQUIRED',
|
|
461
|
-
message: reason ? visibleErrorMessage(reason) + ' 请重新登录后继续。' : '请登录平台账户后继续',
|
|
462
|
-
},
|
|
463
|
-
...loginDetails(),
|
|
464
|
-
})
|
|
465
|
-
|
|
466
|
-
const callbackHTML = (stateValue, type, error) => {
|
|
467
|
-
const origin = new URL(entryURL).origin
|
|
468
|
-
const isSuccess = type === 'login-complete'
|
|
469
|
-
const message = isSuccess
|
|
470
|
-
? { protocol: 'proxy-app/auth/v1', type, state: stateValue }
|
|
471
|
-
: {
|
|
472
|
-
protocol: 'proxy-app/auth/v1',
|
|
473
|
-
type: 'login-failed',
|
|
474
|
-
state: stateValue,
|
|
475
|
-
error: {
|
|
476
|
-
code: error?.code || 'APP_LOGIN_FAILED',
|
|
477
|
-
message: visibleErrorMessage(error),
|
|
478
|
-
stage: error?.stage || 'login',
|
|
479
|
-
status: error?.statusCode,
|
|
480
|
-
request_id: error?.requestId || undefined,
|
|
481
|
-
upstream_code: error?.upstreamCode || undefined,
|
|
482
|
-
},
|
|
483
|
-
}
|
|
484
|
-
const title = isSuccess ? '登录成功,正在返回应用...' : visibleErrorMessage(error)
|
|
485
|
-
// Leave a failed callback open so the SDK can reuse the user-opened popup
|
|
486
|
-
// for one fresh login. Closing it here would trigger popup blockers.
|
|
487
|
-
return '<!doctype html><meta charset="utf-8"><title>平台登录</title><meta name="viewport" content="width=device-width,initial-scale=1"><p>' + htmlEscape(title) + '</p><script>const m=' + safeScriptJSON(message) + ';if(window.opener){window.opener.postMessage(m,' + safeScriptJSON(origin) + ');' + (isSuccess ? 'setTimeout(()=>window.close(),120)' : '') + '}</script>'
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const developmentHTML = (error) => {
|
|
491
|
-
const title = error ? '开发环境检查失败' : '开发环境已就绪'
|
|
492
|
-
const description = error ? visibleErrorMessage(error) : '应用登记与开发权限检查已通过,终端正在启动本地应用。进入应用后可登录需要测试的用户账户。'
|
|
493
|
-
const diagnostic = error
|
|
494
|
-
? '<p>开发者登录页面:' + htmlEscape(portalOrigin) + '</p><p>平台 API 地址:' + htmlEscape(portal) + '</p><p>应用 slug:' + htmlEscape(appSlug) + '</p>' + (error.requestId ? '<p>请求编号:' + htmlEscape(error.requestId) + '</p>' : '') + '<p>请核对页面显示的完整账户;平台应用要求由创建者账户开发。</p>'
|
|
495
|
-
: '<a href="' + htmlEscape(entryURL) + '" rel="noreferrer">打开本地应用</a>'
|
|
496
|
-
return '<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' + title + '</title><style>html{color-scheme:dark}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#111;color:#eee;font:15px/1.8 system-ui,sans-serif}main{box-sizing:border-box;width:min(440px,calc(100% - 40px));padding:32px;border:1px solid #383838;border-radius:18px;background:#222}small,p{color:#aaa}h1{font-size:24px;line-height:1.4}p{overflow-wrap:anywhere}a{display:block;margin-top:24px;padding:10px;text-align:center;background:#f1f1f1;color:#202020;border-radius:9px;text-decoration:none}a:focus-visible{outline:2px solid #fff;outline-offset:4px}</style><main><small>Proxy App · 开发环境初始化</small><h1>' + title + '</h1><p>' + htmlEscape(description) + '</p>' + diagnostic + '</main></html>'
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
const forward = async (target, request, rawBody, token, appVersion, stage) => {
|
|
500
|
-
const headers = copyRequestHeaders(request)
|
|
501
|
-
if (token) headers.set('Authorization', 'Bearer ' + token)
|
|
502
|
-
if (appVersion) headers.set('X-App-Version', appVersion)
|
|
503
|
-
const readOnly = ['GET', 'HEAD'].includes(request.method || '')
|
|
504
|
-
try {
|
|
505
|
-
return await fetch(target, {
|
|
506
|
-
method: request.method,
|
|
507
|
-
headers,
|
|
508
|
-
body: readOnly ? undefined : rawBody,
|
|
509
|
-
redirect: 'manual',
|
|
510
|
-
})
|
|
511
|
-
} catch (cause) {
|
|
512
|
-
const gateway = stage === 'gateway'
|
|
513
|
-
const message = gateway
|
|
514
|
-
? '无法连接模型网关,请确认网关服务已启动,并检查平台下发的网关地址及网络连接。'
|
|
515
|
-
: '无法连接平台服务,请确认平台服务已启动,并检查平台 API 地址及网络连接。'
|
|
516
|
-
const failure = relayError(gateway ? 'APP_GATEWAY_UNAVAILABLE' : 'APP_PLATFORM_UNAVAILABLE',
|
|
517
|
-
message + (readOnly ? '恢复连接后可重试查询。' : '本次请求结果尚未确认,请先检查任务或操作状态,避免重复提交。'), stage, 502, cause)
|
|
518
|
-
// A transport failure may occur after an upstream mutation was accepted.
|
|
519
|
-
// Never retry here or claim that execution definitely did not start.
|
|
520
|
-
if (!readOnly) failure.executionUnknown = true
|
|
521
|
-
throw failure
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
269
|
const close = async () => {
|
|
526
270
|
if (state.closed) return
|
|
527
271
|
state.closed = true
|
|
528
272
|
cancelSetup?.()
|
|
529
273
|
await developmentPromise?.catch(() => undefined)
|
|
530
|
-
|
|
531
|
-
await state.refresh?.catch(() => undefined)
|
|
532
|
-
await state.login?.catch(() => undefined)
|
|
533
|
-
await credentialQueue
|
|
534
|
-
const current = state.session
|
|
535
|
-
state.session = null
|
|
536
|
-
await revoke(current)
|
|
537
|
-
if (server?.listening) await new Promise((resolve) => server.close(resolve))
|
|
274
|
+
if (server?.listening) await new Promise(resolve => server.close(resolve))
|
|
538
275
|
}
|
|
539
276
|
|
|
540
277
|
try {
|
|
541
278
|
server = createServer(async (request, response) => {
|
|
542
279
|
try {
|
|
543
|
-
if (state.closed) throw new Error('local
|
|
280
|
+
if (state.closed) throw Object.assign(new Error('local developer relay is closed'), { statusCode: 503 })
|
|
544
281
|
const parsed = new URL(request.url || '/', 'http://127.0.0.1')
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const authPrefix = '/__platform/auth'
|
|
549
|
-
const authPath = parsed.pathname.startsWith(authPrefix + '/') ? parsed.pathname.slice(authPrefix.length) : parsed.pathname
|
|
550
|
-
const callback = authPath === '/auth/callback' && request.method === 'POST'
|
|
551
|
-
if (!callback && request.headers['x-proxy-app-relay'] !== relaySecret) {
|
|
552
|
-
jsonResponse(response, 403, { code: 'LOCAL_DEV_RELAY_UNAUTHORIZED', error: 'local development relay authentication failed' })
|
|
282
|
+
if (request.method === 'GET' && parsed.pathname === '/health') {
|
|
283
|
+
response.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
|
|
284
|
+
response.end(JSON.stringify({ ok: true, development_ready: developmentReady, app_id: appID, app_slug: appSlug, app_version: version }))
|
|
553
285
|
return
|
|
554
286
|
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const receivedState = form.get('state') || ''
|
|
559
|
-
if (setupState && receivedState === setupState && Date.now() - setupIssuedAt <= 10 * 60 * 1000 && submitSetupToken) {
|
|
560
|
-
// This callback completes CLI preparation, not an app user login.
|
|
561
|
-
// Consume its state before awaiting I/O to prevent replay.
|
|
562
|
-
setupState = ''
|
|
563
|
-
const submit = submitSetupToken
|
|
564
|
-
submitSetupToken = null
|
|
565
|
-
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
|
566
|
-
response.setHeader('Cache-Control', 'no-store')
|
|
567
|
-
response.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'")
|
|
568
|
-
submit(String(form.get('access_token') || '').trim())
|
|
569
|
-
try {
|
|
570
|
-
await developmentPromise
|
|
571
|
-
response.end(developmentHTML())
|
|
572
|
-
} catch (error) {
|
|
573
|
-
response.statusCode = error?.statusCode || 503
|
|
574
|
-
response.end(developmentHTML(error))
|
|
575
|
-
}
|
|
576
|
-
return
|
|
577
|
-
}
|
|
578
|
-
const valid = receivedState && receivedState === state.authState && Date.now() - state.authStateIssuedAt <= 10 * 60 * 1000
|
|
579
|
-
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
|
580
|
-
response.setHeader('Cache-Control', 'no-store')
|
|
581
|
-
if (!valid) {
|
|
582
|
-
response.statusCode = 400
|
|
583
|
-
response.end(callbackHTML(receivedState, 'login-failed', relayError('APP_LOGIN_STATE_INVALID', 'login request expired', 'state_validation', 400)))
|
|
584
|
-
return
|
|
585
|
-
}
|
|
586
|
-
if (!developmentReady) throw developmentRequired()
|
|
587
|
-
state.authState = ''
|
|
588
|
-
state.authStateIssuedAt = 0
|
|
589
|
-
const previous = state.session
|
|
590
|
-
const previousToken = state.portalToken
|
|
591
|
-
const generation = ++state.authGeneration
|
|
592
|
-
const token = String(form.get('access_token') || '').trim()
|
|
593
|
-
state.session = null
|
|
594
|
-
state.portalToken = token
|
|
595
|
-
state.refresh = null
|
|
596
|
-
state.restore = null
|
|
597
|
-
state.cacheLoaded = true
|
|
598
|
-
const pending = (async () => {
|
|
599
|
-
await Promise.all([revoke(previous, previousToken), updateCredentials(() => credentialStore?.clear())])
|
|
600
|
-
assertAuthentication(generation)
|
|
601
|
-
if (!token) throw relayError('APP_LOGIN_TOKEN_INVALID', 'platform returned an invalid user session token', 'token_validation', 400)
|
|
602
|
-
const session = await refresh(true, generation)
|
|
603
|
-
assertAuthentication(generation)
|
|
604
|
-
await updateCredentials(() => {
|
|
605
|
-
if (!state.closed && state.authGeneration === generation) return credentialStore?.save(token)
|
|
606
|
-
})
|
|
607
|
-
assertAuthentication(generation)
|
|
608
|
-
state.forceLogin = false
|
|
609
|
-
return session
|
|
610
|
-
})()
|
|
611
|
-
state.login = pending
|
|
612
|
-
try {
|
|
613
|
-
await pending
|
|
614
|
-
assertAuthentication(generation)
|
|
615
|
-
response.end(callbackHTML(receivedState, 'login-complete'))
|
|
616
|
-
} catch (error) {
|
|
617
|
-
if (!state.closed && state.authGeneration === generation) await clearAuthentication(['APP_LOGIN_SESSION_EXPIRED', 'APP_LOGIN_NOT_ALLOWED'].includes(error?.code))
|
|
618
|
-
response.statusCode = error?.statusCode || 502
|
|
619
|
-
response.end(callbackHTML(receivedState, 'login-failed', error))
|
|
620
|
-
} finally {
|
|
621
|
-
if (state.login === pending) state.login = null
|
|
622
|
-
}
|
|
287
|
+
if (request.method !== 'POST' || parsed.pathname !== '/auth/callback' || request.socket.remoteAddress && !request.socket.remoteAddress.includes('127.0.0.1') && request.socket.remoteAddress !== '::1' && request.socket.remoteAddress !== '::ffff:127.0.0.1') {
|
|
288
|
+
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
289
|
+
response.end('not found')
|
|
623
290
|
return
|
|
624
291
|
}
|
|
625
|
-
|
|
626
|
-
if (
|
|
627
|
-
|
|
628
|
-
if (parsed.searchParams.get('force_login') === '1') await clearAuthentication(true)
|
|
629
|
-
else await restoreAuthentication()
|
|
630
|
-
jsonResponse(response, 200, { data: { authenticated: Boolean(state.session), ...loginDetails() } })
|
|
292
|
+
const contentType = String(request.headers['content-type'] || '').split(';')[0].trim().toLowerCase()
|
|
293
|
+
if (contentType !== 'application/x-www-form-urlencoded') {
|
|
294
|
+
htmlResponse(response, 415, '授权失败', '开发者授权请求格式无效。')
|
|
631
295
|
return
|
|
632
296
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
297
|
+
const form = new URLSearchParams((await readBody(request)).toString('utf8'))
|
|
298
|
+
const receivedState = form.get('state') || ''
|
|
299
|
+
const token = form.get('access_token') || ''
|
|
300
|
+
const valid = setupState && receivedState === setupState && Date.now() - setupIssuedAt <= 10 * 60 * 1000 && submitSetupToken
|
|
301
|
+
if (!valid || !token || token.length > 8192 || form.getAll('state').length !== 1 || form.getAll('access_token').length !== 1) {
|
|
302
|
+
htmlResponse(response, 400, '授权失败', '开发者授权已失效,请重新运行 proxy-app dev。')
|
|
636
303
|
return
|
|
637
304
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
return
|
|
651
|
-
}
|
|
652
|
-
// The profile bridge is account metadata, not a Gateway model route.
|
|
653
|
-
// Resolve it with the same platform credential used to mint the App
|
|
654
|
-
// Session so local development shows the signed-in user consistently.
|
|
655
|
-
if (parsed.pathname === '/v1/profile' || parsed.pathname === '/auth/profile') {
|
|
656
|
-
const current = await restoreAuthentication()
|
|
657
|
-
if (!current) { requireLogin(response); return }
|
|
658
|
-
const profile = await portalJSON('/portal/user/profile', {}, 'account_scope', state.portalToken)
|
|
659
|
-
const safeProfile = profile && typeof profile === 'object' ? {
|
|
660
|
-
...(typeof profile.id === 'string' ? { id: profile.id } : {}),
|
|
661
|
-
...(typeof profile.email === 'string' ? { email: profile.email } : {}),
|
|
662
|
-
...(typeof profile.username === 'string' ? { username: profile.username } : {}),
|
|
663
|
-
...(typeof profile.name === 'string' ? { name: profile.name } : {}),
|
|
664
|
-
...(typeof profile.avatar_url === 'string' ? { avatar_url: profile.avatar_url } : {}),
|
|
665
|
-
} : {}
|
|
666
|
-
jsonResponse(response, 200, { data: safeProfile })
|
|
667
|
-
return
|
|
668
|
-
}
|
|
669
|
-
const gatewayRequest = parsed.pathname === '/v1' || parsed.pathname.startsWith('/v1/')
|
|
670
|
-
if (gatewayRequest) {
|
|
671
|
-
await restoreAuthentication()
|
|
672
|
-
if (!state.session) { requireLogin(response); return }
|
|
673
|
-
const prefix = '/v1'
|
|
674
|
-
const suffix = parsed.pathname.slice(prefix.length) || '/'
|
|
675
|
-
const target = (session) => session.gateway_url + suffix + parsed.search
|
|
676
|
-
const stage = 'gateway'
|
|
677
|
-
const generation = state.authGeneration
|
|
678
|
-
let current = state.session
|
|
679
|
-
let upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version, stage)
|
|
680
|
-
if (await retryableSessionExpiry(upstream)) {
|
|
681
|
-
await upstream.arrayBuffer().catch(() => undefined)
|
|
682
|
-
try {
|
|
683
|
-
assertAuthentication(generation)
|
|
684
|
-
current = state.session && state.session.access_token !== current.access_token ? state.session : await refresh(true, generation)
|
|
685
|
-
} catch (error) {
|
|
686
|
-
assertAuthentication(generation)
|
|
687
|
-
if (error?.code === 'APP_LOGIN_SESSION_EXPIRED' || error?.code === 'APP_LOGIN_NOT_ALLOWED') {
|
|
688
|
-
await clearAuthentication(true)
|
|
689
|
-
requireLogin(response, error)
|
|
690
|
-
return
|
|
691
|
-
}
|
|
692
|
-
throw error
|
|
693
|
-
}
|
|
694
|
-
assertAuthentication(generation)
|
|
695
|
-
upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version, stage)
|
|
696
|
-
}
|
|
697
|
-
if (state.authGeneration !== generation) {
|
|
698
|
-
await upstream.body?.cancel().catch(() => undefined)
|
|
699
|
-
assertAuthentication(generation)
|
|
700
|
-
}
|
|
701
|
-
response.statusCode = upstream.status
|
|
702
|
-
copyResponseHeaders(upstream, response)
|
|
703
|
-
if (upstream.body) Readable.fromWeb(upstream.body).pipe(response); else response.end()
|
|
704
|
-
return
|
|
305
|
+
setupState = ''
|
|
306
|
+
setupIssuedAt = 0
|
|
307
|
+
const submit = submitSetupToken
|
|
308
|
+
submitSetupToken = null
|
|
309
|
+
submit(token.trim())
|
|
310
|
+
try {
|
|
311
|
+
await developmentPromise
|
|
312
|
+
const result = developmentHTML()
|
|
313
|
+
htmlResponse(response, 200, result.title, result.description, entryURL)
|
|
314
|
+
} catch (error) {
|
|
315
|
+
const result = developmentHTML(error)
|
|
316
|
+
htmlResponse(response, error?.statusCode || 503, result.title, result.description)
|
|
705
317
|
}
|
|
706
|
-
jsonResponse(response, 404, { code: 'LOCAL_DEV_ROUTE_NOT_FOUND', error: 'local development route not found' })
|
|
707
318
|
} catch (error) {
|
|
708
|
-
const
|
|
709
|
-
|
|
710
|
-
code: error?.code || 'LOCAL_DEV_RELAY_ERROR', error: message, message,
|
|
711
|
-
stage: error?.stage,
|
|
712
|
-
execution_unknown: error?.executionUnknown || undefined,
|
|
713
|
-
})
|
|
319
|
+
const result = developmentHTML(error)
|
|
320
|
+
htmlResponse(response, error?.statusCode || 502, result.title, result.description)
|
|
714
321
|
}
|
|
715
322
|
})
|
|
716
323
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
|
|
717
|
-
|
|
718
|
-
relayURL
|
|
719
|
-
return { relayURL, prepareDevelopment, close }
|
|
324
|
+
relayURL = 'http://127.0.0.1:' + server.address().port
|
|
325
|
+
return { relayURL, prepareDevelopment, getDevelopmentSessionGrant: () => developmentSessionGrant, close }
|
|
720
326
|
} catch (error) {
|
|
721
327
|
await close()
|
|
722
328
|
throw error
|