diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 521216b..dc9a53f 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,7 +1,7 @@ import { createServer } from 'node:http' import { randomBytes } from 'node:crypto' import { ApiClient, ApiError, linkedProject } from '../api.js' -import { ENVS, ENV_NAMES, envForApiUrl, isEnvName } from '../env.js' +import { ENVS, ENV_NAMES, envForApiUrl, isEnvName, type EnvName } from '../env.js' import { info, die, printJson, promptPassword, openUrl } from '../util.js' /** --api-url and --env both set the target host; --api-url wins (more specific), matching the @@ -58,7 +58,11 @@ export async function loginDevice(opts: { apiUrl?: string; env?: string }): Prom const api = await ApiClient.load() const target = targetApiUrl(opts) if (target) api.setApiUrl(target) - const token = await deviceGrant((path, body) => api.request('POST', path, body, { auth: false })) + const token = await deviceGrant( + (path, body) => api.request('POST', path, body, { auth: false }), + undefined, + { env: envForApiUrl(api.apiUrl), apiUrl: api.apiUrl }, + ) api.setSession({ accessToken: token, refreshToken: token }) const me = await api.request<{ user: { id: string; email: string | null; name: string | null } }>('GET', '/me') api.setSession({ accessToken: token, refreshToken: token }, me.user) @@ -110,12 +114,31 @@ type DeviceStart = { export type DevicePoster = (path: string, body: Record) => Promise +/** Where the login is headed — names the environment in the prompt and anchors the approval + * link. `env` is null for a custom/self-hosted apiUrl (deliberate choice, left alone). */ +export type DeviceTarget = { env: EnvName | null; apiUrl: string } + const sleepSeconds = (s: number) => new Promise((r) => setTimeout(r, s * 1000)) +/** The device-approval link to show the user. verification_uri is built by the PLATFORM from its + * own INSTA_CONSOLE_URL, so a misconfigured deployment mints links to a host that serves no + * /device page at all (live-caught on staging, #134: the landing site — the link passed Vercel + * SSO and 404'd). For a known environment the CLI knows the console host itself, so re-anchor + * the link there: path, query (the user_code) and fragment stay the server's, only the origin moves — on + * a correctly configured platform this is a no-op. A custom/self-hosted host (env null) is the + * user's deliberate choice and is never rewritten. */ +export function deviceVerificationUrl(raw: string, env: EnvName | null): string { + if (!env) return raw + let url: URL + try { url = new URL(raw) } catch { return raw } // unparseable — show the server's string as-is + const consoleOrigin = new URL(ENVS[env].console).origin + return url.origin === consoleOrigin ? raw : `${consoleOrigin}${url.pathname}${url.search}${url.hash}` +} + // Drives the device grant against the platform's Better Auth mount (/api/auth/device*) and // returns the approved session token. Injectable poster + wait keep this testable without a // network or real timers. Poll errors arrive as ApiError with the OAuth error code as message. -export async function deviceGrant(post: DevicePoster, wait: (s: number) => Promise = sleepSeconds): Promise { +export async function deviceGrant(post: DevicePoster, wait: (s: number) => Promise = sleepSeconds, target?: DeviceTarget): Promise { const start = (await post('/api/auth/device/code', { client_id: 'insta-cli' })) as DeviceStart // A missing/garbage expires_in must fail loudly here — carried into the deadline arithmetic it // becomes NaN, every `Date.now() < deadline` is false, and login dies as a bogus instant expiry. @@ -126,8 +149,11 @@ export async function deviceGrant(post: DevicePoster, wait: (s: number) => Promi throw new Error('malformed device authorization response (missing expires_in) — is the platform up to date?') } const lifetime = Math.min(expiresIn, 3600) // no device code sensibly outlives an hour - info('to log in, open this link in a browser on any device:') - info(` ${start.verification_uri_complete ?? start.verification_uri}`) + // Name the deployment in the prompt ("to log in to STAGING …"): approving a device code + // against the wrong environment is exactly the mistake worth making loud (see status()). + const where = target ? (target.env?.toUpperCase() ?? target.apiUrl) : null + info(`to log in${where ? ` to ${where}` : ''}, open this link in a browser on any device:`) + info(` ${deviceVerificationUrl(start.verification_uri_complete ?? start.verification_uri, target?.env ?? null)}`) info(`and check it shows this code: ${start.user_code}`) info(`waiting for approval… (expires in ${Math.round(lifetime / 60)}m, ctrl-c to abort)`) // Absent OR non-finite interval = the RFC 8628 §3.2 default 5s: NaN would fire the timer diff --git a/src/env.ts b/src/env.ts index 21cc9f1..92e1bcd 100644 --- a/src/env.ts +++ b/src/env.ts @@ -15,6 +15,12 @@ export type EnvName = 'prod' | 'staging' export type EnvHosts = { api: string mcp: string + // The console (web UI) origin for this environment — where a human approves things in a + // browser. The device-login verification link is re-anchored onto this host (see + // deviceVerificationUrl in commands/auth.ts): the platform builds that link from its own + // INSTA_CONSOLE_URL, and a misconfigured deployment mints links to a host that serves no + // /device page at all (live-caught on staging: the landing site, insta-cli#134). + console: string // The agent-skill source passed to `npx skills add`, as `owner/repo` or `owner/repo#ref`. // Staging pins the integration branch so a staging install gets the skill text that documents // the staging control plane, rather than whatever is published on the default branch. @@ -31,11 +37,13 @@ export const ENVS: Record = { prod: { api: 'https://api.instacloud.com', mcp: 'https://mcp.instacloud.com/mcp', + console: 'https://console.instacloud.com', skills: 'InsForge/insta-skills', }, staging: { api: 'https://api.staging.instacloud.com', mcp: 'https://mcp.staging.instacloud.com/mcp', + console: 'https://console.staging.instacloud.com', skills: 'InsForge/insta-skills#devel', }, } diff --git a/test/device-login.test.ts b/test/device-login.test.ts index d7ebcef..22122e3 100644 --- a/test/device-login.test.ts +++ b/test/device-login.test.ts @@ -3,8 +3,9 @@ // the loop's protocol behavior: pending -> retry, slow_down -> back off, denial/expiry -> clear // errors, approval -> the session token comes back. import { describe, expect, it } from 'vitest' -import { deviceGrant, type DevicePoster } from '../src/commands/auth.js' +import { deviceGrant, deviceVerificationUrl, type DevicePoster } from '../src/commands/auth.js' import { ApiError } from '../src/api.js' +import { ENVS } from '../src/env.js' const START = { device_code: 'dev-123', user_code: 'ABCD1234', @@ -138,4 +139,74 @@ describe('deviceGrant', () => { expect(lines.join('')).toContain('https://console.test/device') expect(lines.join('')).not.toContain('undefined') }) + + // #134: the staging platform minted its approval link on a host with no /device page (the + // landing site — the link passed Vercel SSO and 404'd). For a known environment the CLI + // re-anchors the link onto that environment's console host, and names the deployment in the + // prompt so a code can't be skimmed into approval against the wrong one. + it('labels the environment and re-anchors a wrong-host approval link (staging, #134)', async () => { + const lines: string[] = [] + const write = process.stdout.write.bind(process.stdout) + process.stdout.write = ((s: string) => { lines.push(String(s)); return true }) as typeof process.stdout.write + try { + const wrongHost = { + ...START, + verification_uri: 'https://staging.instacloud.com/device', + verification_uri_complete: 'https://staging.instacloud.com/device?user_code=ABCD1234', + } + const { post, wait } = fakeFlow(['token:sess-s'], wrongHost) + const target = { env: 'staging' as const, apiUrl: ENVS.staging.api } + await expect(deviceGrant(post, wait, target)).resolves.toBe('sess-s') + } finally { + process.stdout.write = write + } + const out = lines.join('') + expect(out).toContain('to log in to STAGING') + expect(out).toContain(`${ENVS.staging.console}/device?user_code=ABCD1234`) + expect(out).not.toContain('https://staging.instacloud.com/device') // the 404 host, gone + }) + + // A custom/self-hosted apiUrl has no environment name — the prompt names the host instead, + // and the server's link is trusted as-is (rewriting a deliberate custom target would break it). + it('labels a custom host by its apiUrl and leaves the link untouched', async () => { + const lines: string[] = [] + const write = process.stdout.write.bind(process.stdout) + process.stdout.write = ((s: string) => { lines.push(String(s)); return true }) as typeof process.stdout.write + try { + const { post, wait } = fakeFlow(['token:sess-c']) + await expect(deviceGrant(post, wait, { env: null, apiUrl: 'https://insta.local:7130' })).resolves.toBe('sess-c') + } finally { + process.stdout.write = write + } + const out = lines.join('') + expect(out).toContain('to log in to https://insta.local:7130') + expect(out).toContain('https://console.test/device?user_code=ABCD1234') + }) +}) + +// The link rewrite itself: origin-only, path + query + fragment preserved, and inert everywhere it must be. +describe('deviceVerificationUrl', () => { + it('re-anchors a wrong-host link onto the environment console, keeping path and user_code', () => { + expect(deviceVerificationUrl('https://staging.instacloud.com/device?user_code=AB12-CD34', 'staging')) + .toBe(`${ENVS.staging.console}/device?user_code=AB12-CD34`) + }) + + it('keeps a fragment through the rewrite', () => { + expect(deviceVerificationUrl('https://staging.instacloud.com/device?user_code=AB12#approve', 'staging')) + .toBe(`${ENVS.staging.console}/device?user_code=AB12#approve`) + }) + + it('is a no-op when the platform already points at the environment console', () => { + const good = `${ENVS.prod.console}/device?user_code=AB12` + expect(deviceVerificationUrl(good, 'prod')).toBe(good) + }) + + it('never rewrites for a custom/self-hosted target (env null)', () => { + expect(deviceVerificationUrl('https://insta.local:7130/device?user_code=AB12', null)) + .toBe('https://insta.local:7130/device?user_code=AB12') + }) + + it('shows an unparseable server string as-is rather than dropping the link', () => { + expect(deviceVerificationUrl('not a url', 'staging')).toBe('not a url') + }) }) diff --git a/test/env-switch.test.ts b/test/env-switch.test.ts index 6565a1a..70aed93 100644 --- a/test/env-switch.test.ts +++ b/test/env-switch.test.ts @@ -35,6 +35,15 @@ describe('env table', () => { expect(ENVS.prod.mcp).not.toContain('staging') }) + // The console host anchors the device-login approval link (#134): each environment must name + // its own, and they are pinned because the working hosts were verified by probe — prod's + // console serves /device, and staging's console (not the staging landing site) is the host + // that serves it there. + it('gives every environment its own console host for device-login approval', () => { + expect(ENVS.prod.console).toBe('https://console.instacloud.com') + expect(ENVS.staging.console).toBe('https://console.staging.instacloud.com') + }) + it('gives every environment a skill source', () => { for (const name of ENV_NAMES) { expect(ENVS[name].skills).toMatch(/^[\w.-]+\/[\w.-]+(#[\w./-]+)?$/)