@ducking-mind/proxy-app-cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,8 @@ import { Readable } from 'node:stream'
5
5
  const HOP_BY_HOP_HEADERS = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade'])
6
6
  const SENSITIVE_HEADERS = new Set(['host', 'authorization', 'cookie', 'origin', 'referer', 'content-length', 'x-proxy-app-relay'])
7
7
  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)
8
10
 
9
11
  function copyRequestHeaders(request) {
10
12
  const headers = new Headers()
@@ -35,10 +37,16 @@ async function readBody(request, limit = 16 * 1024 * 1024) {
35
37
  return Buffer.concat(chunks)
36
38
  }
37
39
 
38
- async function readJSON(response) {
39
- const text = await response.text().catch(() => '')
40
- if (!text.trim()) return {}
41
- try { return JSON.parse(text) } catch { return { message: text } }
40
+ async function readJSON(response, strictBody = false) {
41
+ const text = strictBody ? await response.text() : await response.text().catch(() => '')
42
+ if (!text.trim()) {
43
+ if (strictBody) throw new SyntaxError('empty platform response')
44
+ return {}
45
+ }
46
+ try { return JSON.parse(text) } catch (error) {
47
+ if (strictBody) throw error
48
+ return { message: text }
49
+ }
42
50
  }
43
51
 
44
52
  function unwrap(payload) {
@@ -56,7 +64,7 @@ function errorCode(payload) {
56
64
  return String(code || payload?.code || '').trim()
57
65
  }
58
66
 
59
- async function retryableSessionExpiry(response, runtimeRequest) {
67
+ async function retryableSessionExpiry(response) {
60
68
  if (response?.status !== 401) return false
61
69
  const payload = await readJSON(response.clone())
62
70
  const error = payload?.error
@@ -64,7 +72,7 @@ async function retryableSessionExpiry(response, runtimeRequest) {
64
72
  const executionStarted = typeof error === 'object' && error && Object.prototype.hasOwnProperty.call(error, 'execution_started')
65
73
  ? error.execution_started
66
74
  : payload?.execution_started
67
- return (runtimeRequest ? errorCode(payload) : reason) === 'APP_SESSION_EXPIRED' && executionStarted === false
75
+ return reason === 'APP_SESSION_EXPIRED' && executionStarted === false
68
76
  }
69
77
 
70
78
  function relayError(code, message, stage, statusCode = 502, cause) {
@@ -80,12 +88,21 @@ function visibleErrorMessage(error) {
80
88
  switch (error?.code) {
81
89
  case 'APP_PLATFORM_UNAVAILABLE': return '无法连接平台,请确认平台服务已启动或网络可用后重试。'
82
90
  case 'APP_PLATFORM_TIMEOUT': return '平台响应超时,请稍后重试。'
91
+ case 'APP_PLATFORM_INVALID_RESPONSE': return '平台返回了无效响应,请检查平台 API 地址及服务配置。'
83
92
  case 'APP_LOGIN_SESSION_EXPIRED': return '平台登录态已失效,请重新登录。'
84
93
  case 'APP_LOGIN_NOT_ALLOWED': return '当前账户无权使用此应用,请切换账户后重试。'
85
94
  case 'APP_APP_REGISTRATION_FAILED':
86
- if (error?.upstreamCode === 'APP_LOCAL_MANIFEST_CONFLICT' || error?.message === 'local development version already exists with a different manifest; bump manifest.version') return '平台登录已完成,但本地 manifest 与已上传或发布的同版本内容不同。请更新 manifest.version 后重启 proxy-app dev。'
87
- if (error?.statusCode === 403) return '当前登录账户无权开发此应用,请使用创建该应用的账户登录。'
88
- if (error?.statusCode === 404) return '应用不存在,或当前账户不是应用创建者。请检查 .proxy-app.json 中的应用绑定。'
95
+ if (error?.upstreamCode === 'APP_LOCAL_MANIFEST_CONFLICT' || error?.message === 'local development version already exists with a different manifest; bump manifest.version') return '本地 manifest 与已上传或发布的同版本内容不同。请更新 manifest.version 后重启 proxy-app dev。'
96
+ if ([403, 404].includes(error?.statusCode)) {
97
+ const account = error?.currentAccount ? ` 当前登录账户:${error.currentAccount}。` : ''
98
+ const platform = error?.platformAPI ? ` 平台 API 地址:${error.platformAPI}。` : ''
99
+ const reason = error.statusCode === 403 ? '当前账户无权开发此应用' : '该平台上没有当前账户拥有的同 slug 应用'
100
+ return `${reason}。${account}${platform}请使用应用创建时的完整账户登录,并确认 manifest.json 的 id 与平台应用 slug 完全一致。`
101
+ }
102
+ if (error?.statusCode === 400) {
103
+ const code = error?.upstreamCode ? `(${error.upstreamCode})` : ''
104
+ return '应用开发登记参数不符合平台要求(HTTP 400)' + code + '。请检查 manifest.json 的 id、version、entry、权限和能力声明。'
105
+ }
89
106
  return '应用开发登记失败(HTTP ' + error?.statusCode + '),请根据请求编号检查平台日志。'
90
107
  case 'APP_SESSION_CREATE_FAILED': return '用户 App Session 创建失败,请稍后重试。'
91
108
  case 'APP_SESSION_INVALID': return '平台返回的用户 App Session 无效,请稍后重试。'
@@ -119,7 +136,18 @@ function safeURL(value, label) {
119
136
 
120
137
  function normalizeGatewayURL(value) {
121
138
  const normalized = safeURL(value, 'platform returned gateway URL')
122
- return normalized.endsWith('/v1') ? normalized : normalized + '/v1'
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'
123
151
  }
124
152
 
125
153
  function jsonResponse(response, status, payload) {
@@ -130,19 +158,30 @@ function jsonResponse(response, status, payload) {
130
158
  }
131
159
 
132
160
  /**
133
- * Real local development relay. It stores only the current signed-in user's
134
- * Portal token in memory and gives the app an App Session, never the Portal
135
- * token. The token is obtained through the platform login callback, not from
136
- * the developer's shell environment, so model calls belong to the user who
137
- * opened the local app.
161
+ * Development preparation and runtime user login are separate operations.
162
+ * prepareDevelopment registers the manifest before the CLI starts the app.
163
+ * Only runtime login creates App Sessions; developer credentials never fund
164
+ * runtime requests or reach the app. Both credentials may use separate OS stores.
138
165
  */
139
- export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', relaySecret, appID, appSlug, version, manifest, entryURL, legacyBridge = false }) {
166
+ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', relaySecret, appID, appSlug, version, manifest, entryURL, credentialStore = null, developerCredentialStore = null }) {
140
167
  if (!baseURL || !relaySecret || !appID || !appSlug || !version || !entryURL) throw new Error('local development relay configuration is incomplete')
141
168
  const portal = safeURL(baseURL, 'platform API URL')
142
169
  const portalOrigin = safeURL(portalURL || portal, 'platform Portal URL')
143
- const state = { portalToken: '', session: null, refresh: null, login: null, authGeneration: 0, authState: '', authStateIssuedAt: 0, closed: false, revoked: new Set() }
170
+ const state = { portalToken: '', session: null, refresh: null, login: null, restore: null, cacheLoaded: false, forceLogin: false, authGeneration: 0, authState: '', authStateIssuedAt: 0, closed: false, revoked: new Set() }
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
+ }
144
177
  let server
145
178
  let relayURL = ''
179
+ let developmentReady = false
180
+ let developmentPromise = null
181
+ let setupState = ''
182
+ let setupIssuedAt = 0
183
+ let submitSetupToken = null
184
+ let cancelSetup = null
146
185
 
147
186
  const portalJSON = async (path, init = {}, stage = 'platform', authToken) => {
148
187
  const token = authToken === undefined ? state.portalToken : authToken
@@ -152,22 +191,31 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
152
191
  const controller = new AbortController()
153
192
  const timeout = setTimeout(() => controller.abort(), PLATFORM_AUTH_TIMEOUT_MS)
154
193
  let response
194
+ let payload
155
195
  try {
156
196
  response = await fetch(portal + path, {
157
197
  ...init,
158
- signal: controller.signal,
198
+ signal: init.signal ? AbortSignal.any([controller.signal, init.signal]) : controller.signal,
159
199
  headers: { Authorization: 'Bearer ' + token, ...(init.headers || {}) },
160
200
  })
201
+ payload = await readJSON(response, true)
202
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new SyntaxError('platform response is not an object')
161
203
  } catch (error) {
162
- clearTimeout(timeout)
163
- if (error?.name === 'AbortError') throw relayError('APP_PLATFORM_TIMEOUT', 'platform request timed out', stage, 504, error)
204
+ if (init.signal?.aborted) throw init.signal.reason
205
+ 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)
164
207
  throw relayError('APP_PLATFORM_UNAVAILABLE', 'platform request could not be reached', stage, 502, error)
208
+ } finally {
209
+ clearTimeout(timeout)
165
210
  }
166
- clearTimeout(timeout)
167
- const payload = await readJSON(response)
168
211
  if (!response.ok) {
212
+ if (stage === 'account_scope') {
213
+ const failure = relayError(errorCode(payload) || 'APP_PLATFORM_REQUEST_FAILED', errorMessage(payload, 'platform request failed: HTTP ' + response.status), stage, response.status)
214
+ failure.requestId = response.headers.get('x-request-id') || ''
215
+ throw failure
216
+ }
169
217
  if (response.status === 401) throw relayError('APP_LOGIN_SESSION_EXPIRED', 'platform user session expired', stage, 401)
170
- if (response.status === 403) throw relayError('APP_LOGIN_NOT_ALLOWED', 'platform user is not allowed to use this app', stage, 403)
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)
171
219
  const code = stage === 'app_registration' ? 'APP_APP_REGISTRATION_FAILED' : stage === 'app_session' ? 'APP_SESSION_CREATE_FAILED' : 'APP_PLATFORM_REQUEST_FAILED'
172
220
  const failure = relayError(code, errorMessage(payload, 'platform request failed: HTTP ' + response.status), stage, response.status)
173
221
  failure.requestId = response.headers.get('x-request-id') || ''
@@ -184,7 +232,7 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
184
232
  await portalJSON('/portal/user/apps/' + encodeURIComponent(appID) + '/sessions/' + encodeURIComponent(id), { method: 'DELETE' }, 'platform', authToken).catch(() => undefined)
185
233
  }
186
234
 
187
- const clearAuthentication = async () => {
235
+ const clearAuthentication = async (forceLogin = false) => {
188
236
  const current = state.session
189
237
  const token = state.portalToken
190
238
  state.authGeneration += 1
@@ -192,9 +240,12 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
192
240
  state.portalToken = ''
193
241
  state.refresh = null
194
242
  state.login = null
243
+ state.restore = null
244
+ state.cacheLoaded = true
245
+ state.forceLogin = forceLogin
195
246
  state.authState = ''
196
247
  state.authStateIssuedAt = 0
197
- await revoke(current, token)
248
+ await Promise.all([revoke(current, token), updateCredentials(() => credentialStore?.clear())])
198
249
  }
199
250
 
200
251
  const assertAuthentication = (generation) => {
@@ -202,11 +253,135 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
202
253
  if (generation !== state.authGeneration) throw relayError('APP_AUTH_CHANGED', 'local development authentication changed', 'app_session', 409)
203
254
  }
204
255
 
205
- const register = (token) => portalJSON('/portal/user/app-workspace/' + encodeURIComponent(appID) + '/local-development', {
206
- method: 'POST',
207
- headers: { 'Content-Type': 'application/json' },
208
- body: JSON.stringify({ entry_url: entryURL, manifest }),
209
- }, 'app_registration', token)
256
+ const register = async (token, signal) => {
257
+ const details = await portalJSON('/portal/user/app-workspace/by-slug/' + encodeURIComponent(appSlug) + '/local-development', {
258
+ method: 'POST',
259
+ signal,
260
+ headers: { 'Content-Type': 'application/json' },
261
+ body: JSON.stringify({ entry_url: entryURL, manifest }),
262
+ }, 'app_registration', token)
263
+ if (!details || typeof details.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(details.id)) {
264
+ throw relayError('APP_PLATFORM_INVALID_RESPONSE', 'platform did not return the registered application identity', 'app_registration', 502)
265
+ }
266
+ // The public slug locates the owner-authorized app. All subsequent
267
+ // session/revocation calls use the server-resolved durable identity, not
268
+ // the slug or an obsolete local sidecar. No repair step is required.
269
+ appID = details.id
270
+ return details
271
+ }
272
+
273
+ const registerWithDiagnostics = async (token, signal) => {
274
+ try {
275
+ return await register(token, signal)
276
+ } catch (error) {
277
+ if (error?.stage === 'app_registration' && [403, 404].includes(error.statusCode)) {
278
+ error.platformAPI = portal
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.
282
+ const profile = await portalJSON('/portal/user/profile', {}, 'account_scope', token).catch(() => null)
283
+ const email = typeof profile?.email === 'string' ? profile.email.trim() : ''
284
+ if (email && email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) error.currentAccount = email
285
+ }
286
+ throw error
287
+ }
288
+ }
289
+
290
+ const developmentRequired = () => relayError('APP_DEVELOPMENT_NOT_READY', '开发环境尚未就绪,请先在终端完成 proxy-app dev 的开发授权与应用登记。', 'development_preflight', 503)
291
+ const abortPreparation = () => Object.assign(new Error('开发环境初始化已取消。'), { name: 'AbortError' })
292
+
293
+ const prepareDevelopment = ({ token = '', onAuthorizationRequired, signal, forceLogin = false, timeoutMs = 10 * 60 * 1000 } = {}) => {
294
+ if (state.closed || signal?.aborted) return Promise.reject(abortPreparation())
295
+ if (developmentReady) return Promise.resolve()
296
+ if (developmentPromise) return developmentPromise
297
+ const pending = (async () => {
298
+ let timer
299
+ let rejectCancellation
300
+ const controller = new AbortController()
301
+ const cancellation = new Promise((_, reject) => { rejectCancellation = reject })
302
+ // Install the handler immediately, including while reading the OS store.
303
+ cancellation.catch(() => undefined)
304
+ const cancel = (error) => { controller.abort(error); rejectCancellation(error) }
305
+ const abort = () => cancel(abortPreparation())
306
+ cancelSetup = abort
307
+ signal?.addEventListener('abort', abort, { once: true })
308
+ const checkActive = () => {
309
+ if (state.closed || signal?.aborted) throw abortPreparation()
310
+ }
311
+ const wait = (operation) => Promise.race([operation, cancellation])
312
+ try {
313
+ const explicitToken = !forceLogin && typeof token === 'string' ? token.trim() : ''
314
+ let requiresFreshLogin = forceLogin
315
+ let developerToken = explicitToken
316
+ if (!developerToken && !forceLogin) developerToken = await wait(Promise.resolve(developerCredentialStore?.load()))
317
+ // A missing developer cache does not mean that the Portal session is
318
+ // invalid. Let the Portal reuse its current signed-in account; the
319
+ // registration endpoint still enforces that it is the app creator.
320
+ // Force account selection only when replacing a rejected credential.
321
+ if (!developerToken && !explicitToken) requiresFreshLogin = false
322
+ checkActive()
323
+ if (developerToken) {
324
+ try {
325
+ await wait(registerWithDiagnostics(developerToken, controller.signal))
326
+ checkActive()
327
+ developmentReady = true
328
+ return
329
+ } catch (error) {
330
+ // A cached developer account can be valid for the platform while
331
+ // still belonging to a different app owner. Registration reports
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
339
+ await wait(Promise.resolve(developerCredentialStore?.clear()))
340
+ requiresFreshLogin = true
341
+ }
342
+ }
343
+ checkActive()
344
+ if (!onAuthorizationRequired) throw relayError('APP_DEVELOPER_LOGIN_REQUIRED', '请先通过 proxy-app dev 完成开发者授权。', 'development_preflight', 401)
345
+ 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
+ setupState = randomUUID()
348
+ setupIssuedAt = Date.now()
349
+ const receivedToken = new Promise((resolve) => { submitSetupToken = resolve })
350
+ const query = new URLSearchParams({ proxy_app_auth: '1', proxy_app_state: setupState, proxy_app_relay: relayURL })
351
+ if (requiresFreshLogin) query.set('proxy_app_force_login', '1')
352
+ const details = { login_url: portalOrigin + '/#/login?' + query, auth_state: setupState, relay_url: relayURL, force_login: query.has('proxy_app_force_login') }
353
+ await wait(Promise.resolve(onAuthorizationRequired(details)))
354
+ developerToken = await wait(receivedToken)
355
+ checkActive()
356
+ if (!developerToken) throw relayError('APP_LOGIN_TOKEN_INVALID', '平台未返回有效的开发者授权,请重新运行 proxy-app dev。', 'development_preflight', 400)
357
+ await wait(registerWithDiagnostics(developerToken, controller.signal))
358
+ checkActive()
359
+ // Runtime user login never reads or clears this store.
360
+ await wait(Promise.resolve(developerCredentialStore?.save(developerToken)).catch(() => undefined))
361
+ checkActive()
362
+ developmentReady = true
363
+ } catch (error) {
364
+ // Preserve structured diagnostic fields, but never print arbitrary
365
+ // upstream text or credentials in the terminal/authorization page.
366
+ if (error?.name === 'AbortError') throw error
367
+ throw Object.assign(new Error(visibleErrorMessage(error)), {
368
+ code: error?.code, stage: error?.stage, statusCode: error?.statusCode,
369
+ requestId: error?.requestId, upstreamCode: error?.upstreamCode,
370
+ currentAccount: error?.currentAccount, platformAPI: error?.platformAPI,
371
+ })
372
+ } finally {
373
+ clearTimeout(timer)
374
+ signal?.removeEventListener('abort', abort)
375
+ setupState = ''
376
+ setupIssuedAt = 0
377
+ submitSetupToken = null
378
+ cancelSetup = null
379
+ }
380
+ })()
381
+ developmentPromise = pending
382
+ pending.catch(() => { if (developmentPromise === pending) developmentPromise = null })
383
+ return pending
384
+ }
210
385
 
211
386
  const refresh = async (force = false, generation = state.authGeneration) => {
212
387
  assertAuthentication(generation)
@@ -216,7 +391,9 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
216
391
  const pending = portalJSON('/portal/user/apps/' + encodeURIComponent(appID) + '/sessions', {
217
392
  method: 'POST',
218
393
  headers: { 'Content-Type': 'application/json' },
219
- body: JSON.stringify({ version, runtime_protocol: legacyBridge ? 1 : 2 }),
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 }),
220
397
  }, 'app_session', refreshToken).then(async (raw) => {
221
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)
222
399
  const next = { ...raw, gateway_url: normalizeGatewayURL(raw.gateway_url) }
@@ -235,13 +412,46 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
235
412
  return pending
236
413
  }
237
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
+
238
448
  const loginDetails = () => {
239
449
  if (!state.authState || Date.now() - state.authStateIssuedAt > 10 * 60 * 1000) {
240
450
  state.authState = randomUUID()
241
451
  state.authStateIssuedAt = Date.now()
242
452
  }
243
453
  const query = new URLSearchParams({ proxy_app_auth: '1', proxy_app_state: state.authState, proxy_app_relay: relayURL })
244
- return { login_url: portalOrigin + '/#/login?' + query, auth_state: state.authState, relay_url: relayURL }
454
+ return { login_url: portalOrigin + '/#/login?' + query, auth_state: state.authState, relay_url: relayURL, force_login: state.forceLogin }
245
455
  }
246
456
 
247
457
  const requireLogin = (response, reason) => jsonResponse(response, 401, {
@@ -272,26 +482,55 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
272
482
  },
273
483
  }
274
484
  const title = isSuccess ? '登录成功,正在返回应用...' : visibleErrorMessage(error)
275
- 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) + ');setTimeout(()=>window.close(),120)}</script>'
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>'
276
488
  }
277
489
 
278
- const forward = (target, request, rawBody, token, appVersion) => {
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) => {
279
500
  const headers = copyRequestHeaders(request)
280
501
  if (token) headers.set('Authorization', 'Bearer ' + token)
281
502
  if (appVersion) headers.set('X-App-Version', appVersion)
282
- return fetch(target, {
283
- method: request.method,
284
- headers,
285
- body: ['GET', 'HEAD'].includes(request.method || '') ? undefined : rawBody,
286
- redirect: 'manual',
287
- })
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
+ }
288
523
  }
289
524
 
290
525
  const close = async () => {
291
526
  if (state.closed) return
292
527
  state.closed = true
528
+ cancelSetup?.()
529
+ await developmentPromise?.catch(() => undefined)
530
+ await state.restore?.catch(() => undefined)
293
531
  await state.refresh?.catch(() => undefined)
294
532
  await state.login?.catch(() => undefined)
533
+ await credentialQueue
295
534
  const current = state.session
296
535
  state.session = null
297
536
  await revoke(current)
@@ -303,8 +542,11 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
303
542
  try {
304
543
  if (state.closed) throw new Error('local development relay is closed')
305
544
  const parsed = new URL(request.url || '/', 'http://127.0.0.1')
306
- const runtimePrefix = '/api/app-runtime/v1'
307
- const authPath = parsed.pathname.startsWith(runtimePrefix + '/auth/') ? parsed.pathname.slice(runtimePrefix.length) : parsed.pathname
545
+ // `/auth` is the relay's local control plane. It is exposed to app
546
+ // pages as `/__platform/auth` by the Vite proxy, while the relay
547
+ // itself never accepts an Admin App Runtime path.
548
+ const authPrefix = '/__platform/auth'
549
+ const authPath = parsed.pathname.startsWith(authPrefix + '/') ? parsed.pathname.slice(authPrefix.length) : parsed.pathname
308
550
  const callback = authPath === '/auth/callback' && request.method === 'POST'
309
551
  if (!callback && request.headers['x-proxy-app-relay'] !== relaySecret) {
310
552
  jsonResponse(response, 403, { code: 'LOCAL_DEV_RELAY_UNAUTHORIZED', error: 'local development relay authentication failed' })
@@ -314,6 +556,25 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
314
556
  if (callback) {
315
557
  const form = new URLSearchParams(rawBody.toString('utf8'))
316
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
+ }
317
578
  const valid = receivedState && receivedState === state.authState && Date.now() - state.authStateIssuedAt <= 10 * 60 * 1000
318
579
  response.setHeader('Content-Type', 'text/html; charset=utf-8')
319
580
  response.setHeader('Cache-Control', 'no-store')
@@ -322,6 +583,7 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
322
583
  response.end(callbackHTML(receivedState, 'login-failed', relayError('APP_LOGIN_STATE_INVALID', 'login request expired', 'state_validation', 400)))
323
584
  return
324
585
  }
586
+ if (!developmentReady) throw developmentRequired()
325
587
  state.authState = ''
326
588
  state.authStateIssuedAt = 0
327
589
  const previous = state.session
@@ -331,13 +593,20 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
331
593
  state.session = null
332
594
  state.portalToken = token
333
595
  state.refresh = null
596
+ state.restore = null
597
+ state.cacheLoaded = true
334
598
  const pending = (async () => {
335
- await revoke(previous, previousToken)
599
+ await Promise.all([revoke(previous, previousToken), updateCredentials(() => credentialStore?.clear())])
336
600
  assertAuthentication(generation)
337
601
  if (!token) throw relayError('APP_LOGIN_TOKEN_INVALID', 'platform returned an invalid user session token', 'token_validation', 400)
338
- await register(token)
602
+ const session = await refresh(true, generation)
339
603
  assertAuthentication(generation)
340
- return refresh(true, 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
341
610
  })()
342
611
  state.login = pending
343
612
  try {
@@ -345,7 +614,7 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
345
614
  assertAuthentication(generation)
346
615
  response.end(callbackHTML(receivedState, 'login-complete'))
347
616
  } catch (error) {
348
- if (state.authGeneration === generation) await clearAuthentication()
617
+ if (!state.closed && state.authGeneration === generation) await clearAuthentication(['APP_LOGIN_SESSION_EXPIRED', 'APP_LOGIN_NOT_ALLOWED'].includes(error?.code))
349
618
  response.statusCode = error?.statusCode || 502
350
619
  response.end(callbackHTML(receivedState, 'login-failed', error))
351
620
  } finally {
@@ -353,50 +622,62 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
353
622
  }
354
623
  return
355
624
  }
356
- if (parsed.pathname === '/health') { jsonResponse(response, 200, { ok: true, app_id: appID, app_slug: appSlug, app_version: version, runtime_protocol: legacyBridge ? 1 : 2, authenticated: Boolean(state.session) }); return }
357
- if (authPath === '/auth/start') { jsonResponse(response, 200, { data: { authenticated: Boolean(state.session), ...loginDetails() } }); return }
358
- if (authPath === '/auth/logout' && request.method === 'POST') {
625
+ if (parsed.pathname === '/health') { jsonResponse(response, 200, { ok: true, development_ready: developmentReady, app_id: appID, app_slug: appSlug, app_version: version, runtime_protocol: 1, authenticated: Boolean(state.session) }); return }
626
+ if (!developmentReady) throw developmentRequired()
627
+ if (authPath === '/start' || authPath === '/auth/start') {
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() } })
631
+ return
632
+ }
633
+ if ((authPath === '/logout' || authPath === '/auth/logout') && request.method === 'POST') {
359
634
  await clearAuthentication()
360
635
  jsonResponse(response, 200, { data: { success: true } })
361
636
  return
362
637
  }
363
- if (authPath === '/auth/status') { jsonResponse(response, 200, { data: { authenticated: Boolean(state.session), app_id: appID, app_version: version } }); return }
364
- if (parsed.pathname === '/bridge') {
365
- if (!legacyBridge) {
366
- jsonResponse(response, 404, { error: { code: 'APP_LEGACY_BRIDGE_DISABLED', message: 'Legacy Bridge is disabled; use the HTTP runtime or start proxy-app dev with --legacy-bridge for an old application.' } })
367
- return
638
+ if (authPath === '/status' || authPath === '/auth/status') {
639
+ const current = await restoreAuthentication()
640
+ if (current && !validAccountScope(current.account_scope)) {
641
+ // Account scope is returned by the Gateway App Session contract.
642
+ // If an older cached session lacks it, obtain one replacement
643
+ // session through the authenticated control-plane session API;
644
+ // never query the retired Admin App Runtime endpoint.
645
+ const generation = state.authGeneration
646
+ await refresh(true, generation)
647
+ if (!validAccountScope(state.session?.account_scope)) throw accountScopeUnavailable()
368
648
  }
369
- if (!state.session) { requireLogin(response); return }
370
- let bridgeRequest
371
- try { bridgeRequest = JSON.parse(rawBody.toString('utf8') || '{}') } catch { jsonResponse(response, 400, { code: 'APP_BRIDGE_INVALID_REQUEST', error: 'invalid bridge request' }); return }
372
- bridgeRequest.appId = appSlug
373
- const generation = state.authGeneration
374
- const upstream = await forward(portal + '/portal/user/apps/' + encodeURIComponent(appID) + '/bridge', request, Buffer.from(JSON.stringify(bridgeRequest)), state.portalToken)
375
- if (state.authGeneration !== generation) {
376
- await upstream.body?.cancel().catch(() => undefined)
377
- assertAuthentication(generation)
378
- }
379
- if (upstream.status === 401) {
380
- await clearAuthentication()
381
- requireLogin(response, relayError('APP_LOGIN_SESSION_EXPIRED', 'platform user session expired', 'bridge', 401))
382
- return
383
- }
384
- response.statusCode = upstream.status
385
- copyResponseHeaders(upstream, response)
386
- if (upstream.body) Readable.fromWeb(upstream.body).pipe(response); else response.end()
649
+ jsonResponse(response, 200, { data: { authenticated: Boolean(state.session), app_id: appID, app_version: version, ...(state.session?.account_scope ? { account_scope: state.session.account_scope } : {}) } })
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 })
387
667
  return
388
668
  }
389
- const runtimeRequest = parsed.pathname === runtimePrefix || parsed.pathname.startsWith(runtimePrefix + '/')
390
669
  const gatewayRequest = parsed.pathname === '/v1' || parsed.pathname.startsWith('/v1/')
391
- if (runtimeRequest || gatewayRequest) {
670
+ if (gatewayRequest) {
671
+ await restoreAuthentication()
392
672
  if (!state.session) { requireLogin(response); return }
393
- const prefix = runtimeRequest ? runtimePrefix : '/v1'
673
+ const prefix = '/v1'
394
674
  const suffix = parsed.pathname.slice(prefix.length) || '/'
395
- const target = (session) => (runtimeRequest ? portal + '/app-runtime/v1' : session.gateway_url) + suffix + parsed.search
675
+ const target = (session) => session.gateway_url + suffix + parsed.search
676
+ const stage = 'gateway'
396
677
  const generation = state.authGeneration
397
678
  let current = state.session
398
- let upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version)
399
- if (await retryableSessionExpiry(upstream, runtimeRequest)) {
679
+ let upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version, stage)
680
+ if (await retryableSessionExpiry(upstream)) {
400
681
  await upstream.arrayBuffer().catch(() => undefined)
401
682
  try {
402
683
  assertAuthentication(generation)
@@ -404,14 +685,14 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
404
685
  } catch (error) {
405
686
  assertAuthentication(generation)
406
687
  if (error?.code === 'APP_LOGIN_SESSION_EXPIRED' || error?.code === 'APP_LOGIN_NOT_ALLOWED') {
407
- await clearAuthentication()
688
+ await clearAuthentication(true)
408
689
  requireLogin(response, error)
409
690
  return
410
691
  }
411
692
  throw error
412
693
  }
413
694
  assertAuthentication(generation)
414
- upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version)
695
+ upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version, stage)
415
696
  }
416
697
  if (state.authGeneration !== generation) {
417
698
  await upstream.body?.cancel().catch(() => undefined)
@@ -424,13 +705,18 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
424
705
  }
425
706
  jsonResponse(response, 404, { code: 'LOCAL_DEV_ROUTE_NOT_FOUND', error: 'local development route not found' })
426
707
  } catch (error) {
427
- jsonResponse(response, error?.statusCode || (error?.message === 'local development relay is closed' ? 503 : 502), { code: error?.code || 'LOCAL_DEV_RELAY_ERROR', error: error instanceof Error ? error.message : String(error) })
708
+ const message = error instanceof Error ? error.message : String(error)
709
+ jsonResponse(response, error?.statusCode || (error?.message === 'local development relay is closed' ? 503 : 502), {
710
+ code: error?.code || 'LOCAL_DEV_RELAY_ERROR', error: message, message,
711
+ stage: error?.stage,
712
+ execution_unknown: error?.executionUnknown || undefined,
713
+ })
428
714
  }
429
715
  })
430
716
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
431
717
  const address = server.address()
432
718
  relayURL = 'http://127.0.0.1:' + address.port
433
- return { relayURL, close }
719
+ return { relayURL, prepareDevelopment, close }
434
720
  } catch (error) {
435
721
  await close()
436
722
  throw error