@ducking-mind/proxy-app-cli 0.1.0 → 0.1.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.
@@ -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,134 @@ 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
+ // Do not silently reuse an already signed-in browser account when no
318
+ // developer credential is available. The login page will require the
319
+ // user to choose the creator account explicitly.
320
+ if (!developerToken && !explicitToken) requiresFreshLogin = true
321
+ checkActive()
322
+ if (developerToken) {
323
+ try {
324
+ await wait(registerWithDiagnostics(developerToken, controller.signal))
325
+ checkActive()
326
+ developmentReady = true
327
+ return
328
+ } catch (error) {
329
+ // A cached developer account can be valid for the platform while
330
+ // still belonging to a different app owner. Registration reports
331
+ // that case as 403/404, the same way it reports an expired
332
+ // developer session as 401. Drop only the cached credential and
333
+ // offer one fresh developer login; explicit management tokens are
334
+ // caller supplied and must remain an actionable hard failure.
335
+ const registrationNeedsFreshLogin = error?.code === 'APP_LOGIN_SESSION_EXPIRED'
336
+ || (error?.stage === 'app_registration' && [401, 403, 404].includes(error.statusCode))
337
+ if (!registrationNeedsFreshLogin || explicitToken) throw error
338
+ await wait(Promise.resolve(developerCredentialStore?.clear()))
339
+ requiresFreshLogin = true
340
+ }
341
+ }
342
+ checkActive()
343
+ if (!onAuthorizationRequired) throw relayError('APP_DEVELOPER_LOGIN_REQUIRED', '请先通过 proxy-app dev 完成开发者授权。', 'development_preflight', 401)
344
+ timer = setTimeout(() => cancel(relayError('APP_DEVELOPER_LOGIN_TIMEOUT', '开发者授权等待超时,请重新运行 proxy-app dev。', 'development_preflight', 408)), Math.max(1, Math.min(timeoutMs, 10 * 60 * 1000)))
345
+ checkActive()
346
+ setupState = randomUUID()
347
+ setupIssuedAt = Date.now()
348
+ const receivedToken = new Promise((resolve) => { submitSetupToken = resolve })
349
+ const query = new URLSearchParams({ proxy_app_auth: '1', proxy_app_state: setupState, proxy_app_relay: relayURL })
350
+ if (requiresFreshLogin) query.set('proxy_app_force_login', '1')
351
+ const details = { login_url: portalOrigin + '/#/login?' + query, auth_state: setupState, relay_url: relayURL, force_login: query.has('proxy_app_force_login') }
352
+ await wait(Promise.resolve(onAuthorizationRequired(details)))
353
+ developerToken = await wait(receivedToken)
354
+ checkActive()
355
+ if (!developerToken) throw relayError('APP_LOGIN_TOKEN_INVALID', '平台未返回有效的开发者授权,请重新运行 proxy-app dev。', 'development_preflight', 400)
356
+ await wait(registerWithDiagnostics(developerToken, controller.signal))
357
+ checkActive()
358
+ // Runtime user login never reads or clears this store.
359
+ await wait(Promise.resolve(developerCredentialStore?.save(developerToken)).catch(() => undefined))
360
+ checkActive()
361
+ developmentReady = true
362
+ } catch (error) {
363
+ // Preserve structured diagnostic fields, but never print arbitrary
364
+ // upstream text or credentials in the terminal/authorization page.
365
+ if (error?.name === 'AbortError') throw error
366
+ throw Object.assign(new Error(visibleErrorMessage(error)), {
367
+ code: error?.code, stage: error?.stage, statusCode: error?.statusCode,
368
+ requestId: error?.requestId, upstreamCode: error?.upstreamCode,
369
+ currentAccount: error?.currentAccount, platformAPI: error?.platformAPI,
370
+ })
371
+ } finally {
372
+ clearTimeout(timer)
373
+ signal?.removeEventListener('abort', abort)
374
+ setupState = ''
375
+ setupIssuedAt = 0
376
+ submitSetupToken = null
377
+ cancelSetup = null
378
+ }
379
+ })()
380
+ developmentPromise = pending
381
+ pending.catch(() => { if (developmentPromise === pending) developmentPromise = null })
382
+ return pending
383
+ }
210
384
 
211
385
  const refresh = async (force = false, generation = state.authGeneration) => {
212
386
  assertAuthentication(generation)
@@ -216,7 +390,9 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
216
390
  const pending = portalJSON('/portal/user/apps/' + encodeURIComponent(appID) + '/sessions', {
217
391
  method: 'POST',
218
392
  headers: { 'Content-Type': 'application/json' },
219
- body: JSON.stringify({ version, runtime_protocol: legacyBridge ? 1 : 2 }),
393
+ // App Sessions are always issued for the Gateway data plane. The old
394
+ // runtime_protocol=2 and Bridge transport are not valid app transports.
395
+ body: JSON.stringify({ version, runtime_protocol: 1 }),
220
396
  }, 'app_session', refreshToken).then(async (raw) => {
221
397
  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
398
  const next = { ...raw, gateway_url: normalizeGatewayURL(raw.gateway_url) }
@@ -235,13 +411,46 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
235
411
  return pending
236
412
  }
237
413
 
414
+ const restoreAuthentication = async () => {
415
+ if (!developmentReady) throw developmentRequired()
416
+ if (state.login) return state.login
417
+ if (state.session) return state.session
418
+ if (state.restore) return state.restore
419
+ const generation = state.authGeneration
420
+ const pending = (async () => {
421
+ if (!state.cacheLoaded) {
422
+ const token = await credentialStore?.load()
423
+ assertAuthentication(generation)
424
+ state.cacheLoaded = true
425
+ state.portalToken = typeof token === 'string' ? token : ''
426
+ }
427
+ if (!state.portalToken) return null
428
+ // Preparation already registered this process's manifest and entry URL.
429
+ // Restoring a runtime user must never mutate the developer's app version.
430
+ return await refresh(false, generation)
431
+ })().catch(async (error) => {
432
+ if (state.authGeneration !== generation || state.closed) throw error
433
+ if (['APP_LOGIN_SESSION_EXPIRED', 'APP_LOGIN_NOT_ALLOWED'].includes(error?.code)) {
434
+ await clearAuthentication(true)
435
+ return null
436
+ }
437
+ // Network or session failures are retryable; retain the saved
438
+ // authorization instead of converting every failure into a login prompt.
439
+ throw error
440
+ }).finally(() => {
441
+ if (state.restore === pending) state.restore = null
442
+ })
443
+ state.restore = pending
444
+ return pending
445
+ }
446
+
238
447
  const loginDetails = () => {
239
448
  if (!state.authState || Date.now() - state.authStateIssuedAt > 10 * 60 * 1000) {
240
449
  state.authState = randomUUID()
241
450
  state.authStateIssuedAt = Date.now()
242
451
  }
243
452
  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 }
453
+ return { login_url: portalOrigin + '/#/login?' + query, auth_state: state.authState, relay_url: relayURL, force_login: state.forceLogin }
245
454
  }
246
455
 
247
456
  const requireLogin = (response, reason) => jsonResponse(response, 401, {
@@ -272,26 +481,55 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
272
481
  },
273
482
  }
274
483
  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>'
484
+ // Leave a failed callback open so the SDK can reuse the user-opened popup
485
+ // for one fresh login. Closing it here would trigger popup blockers.
486
+ 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
487
  }
277
488
 
278
- const forward = (target, request, rawBody, token, appVersion) => {
489
+ const developmentHTML = (error) => {
490
+ const title = error ? '开发环境检查失败' : '开发环境已就绪'
491
+ const description = error ? visibleErrorMessage(error) : '应用登记与开发权限检查已通过,终端正在启动本地应用。进入应用后可登录需要测试的用户账户。'
492
+ const diagnostic = error
493
+ ? '<p>开发者登录页面:' + htmlEscape(portalOrigin) + '</p><p>平台 API 地址:' + htmlEscape(portal) + '</p><p>应用 slug:' + htmlEscape(appSlug) + '</p>' + (error.requestId ? '<p>请求编号:' + htmlEscape(error.requestId) + '</p>' : '') + '<p>请核对页面显示的完整账户;平台应用要求由创建者账户开发。</p>'
494
+ : '<a href="' + htmlEscape(entryURL) + '" rel="noreferrer">打开本地应用</a>'
495
+ 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>'
496
+ }
497
+
498
+ const forward = async (target, request, rawBody, token, appVersion, stage) => {
279
499
  const headers = copyRequestHeaders(request)
280
500
  if (token) headers.set('Authorization', 'Bearer ' + token)
281
501
  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
- })
502
+ const readOnly = ['GET', 'HEAD'].includes(request.method || '')
503
+ try {
504
+ return await fetch(target, {
505
+ method: request.method,
506
+ headers,
507
+ body: readOnly ? undefined : rawBody,
508
+ redirect: 'manual',
509
+ })
510
+ } catch (cause) {
511
+ const gateway = stage === 'gateway'
512
+ const message = gateway
513
+ ? '无法连接模型网关,请确认网关服务已启动,并检查平台下发的网关地址及网络连接。'
514
+ : '无法连接平台服务,请确认平台服务已启动,并检查平台 API 地址及网络连接。'
515
+ const failure = relayError(gateway ? 'APP_GATEWAY_UNAVAILABLE' : 'APP_PLATFORM_UNAVAILABLE',
516
+ message + (readOnly ? '恢复连接后可重试查询。' : '本次请求结果尚未确认,请先检查任务或操作状态,避免重复提交。'), stage, 502, cause)
517
+ // A transport failure may occur after an upstream mutation was accepted.
518
+ // Never retry here or claim that execution definitely did not start.
519
+ if (!readOnly) failure.executionUnknown = true
520
+ throw failure
521
+ }
288
522
  }
289
523
 
290
524
  const close = async () => {
291
525
  if (state.closed) return
292
526
  state.closed = true
527
+ cancelSetup?.()
528
+ await developmentPromise?.catch(() => undefined)
529
+ await state.restore?.catch(() => undefined)
293
530
  await state.refresh?.catch(() => undefined)
294
531
  await state.login?.catch(() => undefined)
532
+ await credentialQueue
295
533
  const current = state.session
296
534
  state.session = null
297
535
  await revoke(current)
@@ -303,8 +541,11 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
303
541
  try {
304
542
  if (state.closed) throw new Error('local development relay is closed')
305
543
  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
544
+ // `/auth` is the relay's local control plane. It is exposed to app
545
+ // pages as `/__platform/auth` by the Vite proxy, while the relay
546
+ // itself never accepts an Admin App Runtime path.
547
+ const authPrefix = '/__platform/auth'
548
+ const authPath = parsed.pathname.startsWith(authPrefix + '/') ? parsed.pathname.slice(authPrefix.length) : parsed.pathname
308
549
  const callback = authPath === '/auth/callback' && request.method === 'POST'
309
550
  if (!callback && request.headers['x-proxy-app-relay'] !== relaySecret) {
310
551
  jsonResponse(response, 403, { code: 'LOCAL_DEV_RELAY_UNAUTHORIZED', error: 'local development relay authentication failed' })
@@ -314,6 +555,25 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
314
555
  if (callback) {
315
556
  const form = new URLSearchParams(rawBody.toString('utf8'))
316
557
  const receivedState = form.get('state') || ''
558
+ if (setupState && receivedState === setupState && Date.now() - setupIssuedAt <= 10 * 60 * 1000 && submitSetupToken) {
559
+ // This callback completes CLI preparation, not an app user login.
560
+ // Consume its state before awaiting I/O to prevent replay.
561
+ setupState = ''
562
+ const submit = submitSetupToken
563
+ submitSetupToken = null
564
+ response.setHeader('Content-Type', 'text/html; charset=utf-8')
565
+ response.setHeader('Cache-Control', 'no-store')
566
+ response.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'")
567
+ submit(String(form.get('access_token') || '').trim())
568
+ try {
569
+ await developmentPromise
570
+ response.end(developmentHTML())
571
+ } catch (error) {
572
+ response.statusCode = error?.statusCode || 503
573
+ response.end(developmentHTML(error))
574
+ }
575
+ return
576
+ }
317
577
  const valid = receivedState && receivedState === state.authState && Date.now() - state.authStateIssuedAt <= 10 * 60 * 1000
318
578
  response.setHeader('Content-Type', 'text/html; charset=utf-8')
319
579
  response.setHeader('Cache-Control', 'no-store')
@@ -322,6 +582,7 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
322
582
  response.end(callbackHTML(receivedState, 'login-failed', relayError('APP_LOGIN_STATE_INVALID', 'login request expired', 'state_validation', 400)))
323
583
  return
324
584
  }
585
+ if (!developmentReady) throw developmentRequired()
325
586
  state.authState = ''
326
587
  state.authStateIssuedAt = 0
327
588
  const previous = state.session
@@ -331,13 +592,20 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
331
592
  state.session = null
332
593
  state.portalToken = token
333
594
  state.refresh = null
595
+ state.restore = null
596
+ state.cacheLoaded = true
334
597
  const pending = (async () => {
335
- await revoke(previous, previousToken)
598
+ await Promise.all([revoke(previous, previousToken), updateCredentials(() => credentialStore?.clear())])
336
599
  assertAuthentication(generation)
337
600
  if (!token) throw relayError('APP_LOGIN_TOKEN_INVALID', 'platform returned an invalid user session token', 'token_validation', 400)
338
- await register(token)
601
+ const session = await refresh(true, generation)
339
602
  assertAuthentication(generation)
340
- return refresh(true, generation)
603
+ await updateCredentials(() => {
604
+ if (!state.closed && state.authGeneration === generation) return credentialStore?.save(token)
605
+ })
606
+ assertAuthentication(generation)
607
+ state.forceLogin = false
608
+ return session
341
609
  })()
342
610
  state.login = pending
343
611
  try {
@@ -345,7 +613,7 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
345
613
  assertAuthentication(generation)
346
614
  response.end(callbackHTML(receivedState, 'login-complete'))
347
615
  } catch (error) {
348
- if (state.authGeneration === generation) await clearAuthentication()
616
+ if (!state.closed && state.authGeneration === generation) await clearAuthentication(['APP_LOGIN_SESSION_EXPIRED', 'APP_LOGIN_NOT_ALLOWED'].includes(error?.code))
349
617
  response.statusCode = error?.statusCode || 502
350
618
  response.end(callbackHTML(receivedState, 'login-failed', error))
351
619
  } finally {
@@ -353,50 +621,62 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
353
621
  }
354
622
  return
355
623
  }
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') {
624
+ 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 }
625
+ if (!developmentReady) throw developmentRequired()
626
+ if (authPath === '/start' || authPath === '/auth/start') {
627
+ if (parsed.searchParams.get('force_login') === '1') await clearAuthentication(true)
628
+ else await restoreAuthentication()
629
+ jsonResponse(response, 200, { data: { authenticated: Boolean(state.session), ...loginDetails() } })
630
+ return
631
+ }
632
+ if ((authPath === '/logout' || authPath === '/auth/logout') && request.method === 'POST') {
359
633
  await clearAuthentication()
360
634
  jsonResponse(response, 200, { data: { success: true } })
361
635
  return
362
636
  }
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
637
+ if (authPath === '/status' || authPath === '/auth/status') {
638
+ const current = await restoreAuthentication()
639
+ if (current && !validAccountScope(current.account_scope)) {
640
+ // Account scope is returned by the Gateway App Session contract.
641
+ // If an older cached session lacks it, obtain one replacement
642
+ // session through the authenticated control-plane session API;
643
+ // never query the retired Admin App Runtime endpoint.
644
+ const generation = state.authGeneration
645
+ await refresh(true, generation)
646
+ if (!validAccountScope(state.session?.account_scope)) throw accountScopeUnavailable()
368
647
  }
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()
648
+ jsonResponse(response, 200, { data: { authenticated: Boolean(state.session), app_id: appID, app_version: version, ...(state.session?.account_scope ? { account_scope: state.session.account_scope } : {}) } })
649
+ return
650
+ }
651
+ // The profile bridge is account metadata, not a Gateway model route.
652
+ // Resolve it with the same platform credential used to mint the App
653
+ // Session so local development shows the signed-in user consistently.
654
+ if (parsed.pathname === '/v1/profile' || parsed.pathname === '/auth/profile') {
655
+ const current = await restoreAuthentication()
656
+ if (!current) { requireLogin(response); return }
657
+ const profile = await portalJSON('/portal/user/profile', {}, 'account_scope', state.portalToken)
658
+ const safeProfile = profile && typeof profile === 'object' ? {
659
+ ...(typeof profile.id === 'string' ? { id: profile.id } : {}),
660
+ ...(typeof profile.email === 'string' ? { email: profile.email } : {}),
661
+ ...(typeof profile.username === 'string' ? { username: profile.username } : {}),
662
+ ...(typeof profile.name === 'string' ? { name: profile.name } : {}),
663
+ ...(typeof profile.avatar_url === 'string' ? { avatar_url: profile.avatar_url } : {}),
664
+ } : {}
665
+ jsonResponse(response, 200, { data: safeProfile })
387
666
  return
388
667
  }
389
- const runtimeRequest = parsed.pathname === runtimePrefix || parsed.pathname.startsWith(runtimePrefix + '/')
390
668
  const gatewayRequest = parsed.pathname === '/v1' || parsed.pathname.startsWith('/v1/')
391
- if (runtimeRequest || gatewayRequest) {
669
+ if (gatewayRequest) {
670
+ await restoreAuthentication()
392
671
  if (!state.session) { requireLogin(response); return }
393
- const prefix = runtimeRequest ? runtimePrefix : '/v1'
672
+ const prefix = '/v1'
394
673
  const suffix = parsed.pathname.slice(prefix.length) || '/'
395
- const target = (session) => (runtimeRequest ? portal + '/app-runtime/v1' : session.gateway_url) + suffix + parsed.search
674
+ const target = (session) => session.gateway_url + suffix + parsed.search
675
+ const stage = 'gateway'
396
676
  const generation = state.authGeneration
397
677
  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)) {
678
+ let upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version, stage)
679
+ if (await retryableSessionExpiry(upstream)) {
400
680
  await upstream.arrayBuffer().catch(() => undefined)
401
681
  try {
402
682
  assertAuthentication(generation)
@@ -404,14 +684,14 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
404
684
  } catch (error) {
405
685
  assertAuthentication(generation)
406
686
  if (error?.code === 'APP_LOGIN_SESSION_EXPIRED' || error?.code === 'APP_LOGIN_NOT_ALLOWED') {
407
- await clearAuthentication()
687
+ await clearAuthentication(true)
408
688
  requireLogin(response, error)
409
689
  return
410
690
  }
411
691
  throw error
412
692
  }
413
693
  assertAuthentication(generation)
414
- upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version)
694
+ upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version, stage)
415
695
  }
416
696
  if (state.authGeneration !== generation) {
417
697
  await upstream.body?.cancel().catch(() => undefined)
@@ -424,13 +704,18 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
424
704
  }
425
705
  jsonResponse(response, 404, { code: 'LOCAL_DEV_ROUTE_NOT_FOUND', error: 'local development route not found' })
426
706
  } 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) })
707
+ const message = error instanceof Error ? error.message : String(error)
708
+ jsonResponse(response, error?.statusCode || (error?.message === 'local development relay is closed' ? 503 : 502), {
709
+ code: error?.code || 'LOCAL_DEV_RELAY_ERROR', error: message, message,
710
+ stage: error?.stage,
711
+ execution_unknown: error?.executionUnknown || undefined,
712
+ })
428
713
  }
429
714
  })
430
715
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
431
716
  const address = server.address()
432
717
  relayURL = 'http://127.0.0.1:' + address.port
433
- return { relayURL, close }
718
+ return { relayURL, prepareDevelopment, close }
434
719
  } catch (error) {
435
720
  await close()
436
721
  throw error