Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -110,12 +114,31 @@ type DeviceStart = {

export type DevicePoster = (path: string, body: Record<string, unknown>) => Promise<any>

/** 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<void>((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<void> = sleepSeconds): Promise<string> {
export async function deviceGrant(post: DevicePoster, wait: (s: number) => Promise<void> = sleepSeconds, target?: DeviceTarget): Promise<string> {
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.
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -31,11 +37,13 @@ export const ENVS: Record<EnvName, EnvHosts> = {
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',
},
}
Expand Down
73 changes: 72 additions & 1 deletion test/device-login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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')
})
})
9 changes: 9 additions & 0 deletions test/env-switch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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./-]+)?$/)
Expand Down
Loading