uniweb 0.12.34 → 0.12.36

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.
@@ -35,6 +35,11 @@ export function getAuthDir() {
35
35
  return join(homedir(), '.uniweb')
36
36
  }
37
37
 
38
+ /** Normalize a backend URL to a bare origin (for stamping on the session). */
39
+ function normOrigin(u) {
40
+ try { return new URL(u).origin } catch { return u }
41
+ }
42
+
38
43
  /** True when a stored session's `expiresAt` is in the past (absent → never expires). */
39
44
  export function isExpired(auth) {
40
45
  if (!auth?.expiresAt) return false
@@ -128,7 +133,7 @@ export async function loginToRegistry({ apiBase, username, password } = {}) {
128
133
  // the shared isExpired() works unchanged. Tolerant of the pre-#2 flat body
129
134
  // (no `account` key) — token + expiry still resolve, identity is just skipped.
130
135
  const account = data.account || {}
131
- const record = { token: data.token }
136
+ const record = { token: data.token, origin: normOrigin(apiBase) }
132
137
  if (data.expires_at) record.expiresAt = data.expires_at
133
138
  if (account.uuid) record.uuid = account.uuid
134
139
  if (account.username) record.username = account.username
@@ -244,7 +249,7 @@ async function loginViaTokenPaste({ apiBase, nonInteractive }) {
244
249
  }, { onCancel: () => { console.log('\nLogin cancelled.'); process.exit(0) } })
245
250
  if (!token) process.exit(1)
246
251
  const account = await fetchMe({ apiBase, token }) // throws if the token is invalid
247
- const record = { token }
252
+ const record = { token, origin: normOrigin(apiBase) }
248
253
  if (account?.uuid) record.uuid = account.uuid
249
254
  if (account?.username) record.username = account.username
250
255
  if (account?.handle) record.handle = account.handle
@@ -265,35 +270,50 @@ async function openBrowser(url) {
265
270
  }
266
271
  }
267
272
 
268
- // Browser / social — loopback OAuth against the backend's /cli/authorize.
269
- // The CLI hosts a one-shot 127.0.0.1 server, opens the browser to authorize, and
270
- // the backend (after the Google dance) 302s back to the loopback with the token
271
- // (or an error). state is a CSRF nonce echoed back and verified. The token never
272
- // leaves browser→localhost. Gated by BROWSER_AVAILABLE until the endpoint is live.
273
- async function loginViaBrowser({ apiBase }) {
274
- if (!BROWSER_AVAILABLE) {
275
- throw new Error('browser/social login for the new backend isn’t available yet use --password or --token-paste.')
276
- }
277
- const base = apiBase.replace(/\/$/, '')
278
- const state = randomBytes(16).toString('hex')
279
-
273
+ /**
274
+ * One-shot browser loopback the reusable primitive behind both `uniweb login`
275
+ * (token-in-redirect) and `uniweb publish`'s payment handoff (done-signal).
276
+ *
277
+ * Hosts a one-shot `127.0.0.1` server on an ephemeral port, opens the browser to
278
+ * a URL built from that port, and resolves once the browser is redirected back
279
+ * to `/callback`. The value never leaves browser→localhost. Provider-agnostic:
280
+ * the CALLER supplies the URL to open (given the loopback redirect URI) and a
281
+ * validator that inspects the callback query — so the same tested server serves
282
+ * any "open a page, wait for it to come back" flow.
283
+ *
284
+ * @param {object} o
285
+ * @param {(redirectUri: string) => string} o.buildUrl - the URL to open, given the loopback /callback URI
286
+ * @param {(params: URLSearchParams) => ({ value: any } | { error: string })} o.validate
287
+ * - inspect the callback query; return `{ value }` to succeed or `{ error }` to fail
288
+ * @param {number} [o.timeoutMs=120000]
289
+ * @param {string} [o.openingLabel] - the "opening…" line
290
+ * @param {string} [o.waitingLabel] - the "waiting…" line
291
+ * @param {string} [o.okTitle='Done'] - success page heading
292
+ * @param {string} [o.errTitle='Something went wrong'] - failure page heading
293
+ * @returns {Promise<any>} the validated `value`
294
+ * @throws on validation error, timeout, or a loopback server error
295
+ */
296
+ export async function awaitBrowserCallback({
297
+ buildUrl,
298
+ validate,
299
+ timeoutMs = 120000,
300
+ openingLabel = 'Opening your browser…',
301
+ waitingLabel,
302
+ okTitle = 'Done',
303
+ errTitle = 'Something went wrong',
304
+ } = {}) {
280
305
  const result = await new Promise((resolve) => {
281
306
  const server = createServer((req, res) => {
282
307
  const u = new URL(req.url, 'http://127.0.0.1')
283
308
  if (u.pathname !== '/callback') { res.writeHead(404); res.end('Not found'); return }
284
- const token = u.searchParams.get('token')
285
- const error = u.searchParams.get('error')
286
- const gotState = u.searchParams.get('state')
287
- const failed = !!error || !token || gotState !== state
309
+ const verdict = validate(u.searchParams) || { error: 'no result from the callback.' }
310
+ const failed = !!verdict.error
288
311
  res.writeHead(failed ? 400 : 200, { 'Content-Type': 'text/html' })
289
312
  res.end(`<!doctype html><html><body style="font-family:system-ui,sans-serif;text-align:center;padding:60px">`
290
- + `<h2 style="color:${failed ? '#dc2626' : '#16a34a'}">${failed ? 'Login failed' : 'Login successful'}</h2>`
313
+ + `<h2 style="color:${failed ? '#dc2626' : '#16a34a'}">${failed ? errTitle : okTitle}</h2>`
291
314
  + `<p>You can close this tab and return to your terminal.</p></body></html>`)
292
315
  cleanup()
293
- if (error) resolve({ error })
294
- else if (gotState !== state) resolve({ error: 'state mismatch — please try again.' })
295
- else if (!token) resolve({ error: 'no token returned by the callback.' })
296
- else resolve({ token })
316
+ resolve(failed ? { error: verdict.error } : { value: verdict.value })
297
317
  })
298
318
  let timer
299
319
  function cleanup() { clearTimeout(timer); server.close() }
@@ -301,20 +321,51 @@ async function loginViaBrowser({ apiBase }) {
301
321
  server.listen(0, '127.0.0.1', async () => {
302
322
  const { port } = server.address()
303
323
  const redirectUri = `http://127.0.0.1:${port}/callback`
304
- const authorizeUrl = `${base}/dev/auth/authorize?redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`
305
- console.log('\x1b[36m→\x1b[0m Opening your browser to sign in…')
306
- console.log(` \x1b[2m${authorizeUrl}\x1b[0m`)
307
- const opened = await openBrowser(authorizeUrl)
324
+ const url = buildUrl(redirectUri)
325
+ console.log(`\x1b[36m→\x1b[0m ${openingLabel}`)
326
+ console.log(` \x1b[2m${url}\x1b[0m`)
327
+ const opened = await openBrowser(url)
308
328
  if (!opened) console.log('\x1b[33m⚠\x1b[0m Could not open a browser automatically — open the URL above.')
309
- console.log('\x1b[2mWaiting for sign-in to complete (120s)…\x1b[0m')
329
+ console.log(`\x1b[2m${waitingLabel || `Waiting (${Math.round(timeoutMs / 1000)}s)…`}\x1b[0m`)
310
330
  })
311
- timer = setTimeout(() => { server.close(); resolve({ error: 'timed out waiting for sign-in (120s).' }) }, 120000)
331
+ timer = setTimeout(() => { server.close(); resolve({ error: `timed out (${Math.round(timeoutMs / 1000)}s).` }) }, timeoutMs)
312
332
  })
313
-
314
333
  if (result.error) throw new Error(result.error)
334
+ return result.value
335
+ }
336
+
337
+ // Browser / social — loopback OAuth against the backend's /dev/auth/authorize.
338
+ // The CLI hosts a one-shot 127.0.0.1 server (awaitBrowserCallback), opens the
339
+ // browser to authorize, and the backend (after the Google dance) 302s back to
340
+ // the loopback with the token (or an error). state is a CSRF nonce echoed back
341
+ // and verified. The token never leaves browser→localhost. Gated by
342
+ // BROWSER_AVAILABLE until the endpoint is live.
343
+ async function loginViaBrowser({ apiBase }) {
344
+ if (!BROWSER_AVAILABLE) {
345
+ throw new Error('browser/social login for the new backend isn’t available yet — use --password or --token-paste.')
346
+ }
347
+ const base = apiBase.replace(/\/$/, '')
348
+ const state = randomBytes(16).toString('hex')
349
+
350
+ const token = await awaitBrowserCallback({
351
+ buildUrl: (redirectUri) =>
352
+ `${base}/dev/auth/authorize?redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`,
353
+ validate: (params) => {
354
+ if (params.get('error')) return { error: params.get('error') }
355
+ if (params.get('state') !== state) return { error: 'state mismatch — please try again.' }
356
+ const tok = params.get('token')
357
+ if (!tok) return { error: 'no token returned by the callback.' }
358
+ return { value: tok }
359
+ },
360
+ openingLabel: 'Opening your browser to sign in…',
361
+ waitingLabel: 'Waiting for sign-in to complete (120s)…',
362
+ okTitle: 'Login successful',
363
+ errTitle: 'Login failed',
364
+ })
365
+
315
366
  let account = null
316
- try { account = await fetchMe({ apiBase, token: result.token }) } catch { /* identity optional; token is valid */ }
317
- const record = { token: result.token }
367
+ try { account = await fetchMe({ apiBase, token }) } catch { /* identity optional; token is valid */ }
368
+ const record = { token, origin: normOrigin(apiBase) }
318
369
  if (account?.uuid) record.uuid = account.uuid
319
370
  if (account?.username) record.username = account.username
320
371
  if (account?.handle) record.handle = account.handle
@@ -359,7 +410,7 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
359
410
  console.error(`\x1b[31m✗\x1b[0m Token rejected by ${apiBase}: ${err.message}`)
360
411
  process.exit(1)
361
412
  }
362
- const record = { token: tokenFlag }
413
+ const record = { token: tokenFlag, origin: normOrigin(apiBase) }
363
414
  if (account?.uuid) record.uuid = account.uuid
364
415
  if (account?.username) record.username = account.username
365
416
  if (account?.handle) record.handle = account.handle