Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ export const ACCOUNT_ROUTES: ReadonlySet<string> = new Set(['agent', 'auth', 'me
export async function agentHeaders(api: SessionApi, method: string, path: string, rawBody: string, scope: AgentScope = {}): Promise<Record<string, string>> {
// A key minted by agent auth is already an agent principal on the platform and may not enroll a session.
if (!mode || api.agentCredential) return {}
if (canonicalTarget(path) === '/agent/sessions' && method === 'POST') return {
const target = canonicalTarget(path)
if (target === '/agent/sessions' && method === 'POST') return {
'Insta-Actor-Type': 'agent', 'Insta-Agent-Source': mode.source, 'Insta-Agent-Client': mode.client,
}
const target = canonicalTarget(path)
const match = target.match(/^\/projects\/([^/?]+)/)
const projectId = scope.projectId ?? (match ? decodeURIComponent(match[1]!) : undefined)
if (!projectId && !ACCOUNT_ROUTES.has(target.split('/')[1]?.split('?')[0] ?? '')) {
Expand Down
41 changes: 19 additions & 22 deletions src/commands/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ async function orgDeps(opts: { org?: string }, deps?: DomainDeps): Promise<{ api
return { api: await ApiClient.load(), orgId: await resolveOrgId(opts) }
}

// The org mutations still sign for a PROJECT in agent mode: `domain.delegate` and `zone.delegate`
// are read at the session project, and a bootstrap session names none, so the platform refuses it.
// A user token needs no project, so the link stays optional and the verb keeps working from an
// unlinked directory. The link only counts when it belongs to the org being mutated: under `--org`
// naming ANOTHER org, signing with this directory's project would evaluate authorization against
// the wrong project's policy, so the call goes projectless and the platform's org-administration
// path judges it instead.
async function signingScope(orgId: string, deps?: DomainDeps): Promise<{ projectId: string } | undefined> {
const link = deps?.project ?? (await readProject()) ?? undefined
const projectId = link && link.orgId === orgId ? link.projectId : undefined
return projectId ? { projectId } : undefined
}

export function searchLines(results: Quote[]): string[] {
if (!results.length) return ['no results']
const w = Math.max(...results.map((r) => r.domainName.length))
Expand Down Expand Up @@ -167,15 +180,7 @@ const domainPath = (orgId: string, domainName: string): string =>
*/
export async function domainDelegate(domainName: string, opts: RecordsOpts, deps?: DomainDeps): Promise<void> {
const { api, orgId } = await orgDeps(opts, deps)
// The org route still signs for a PROJECT in agent mode — `domain.delegate` is read there, the
// same precedent `buy` documents above — but a user token needs none, so the link stays optional
// and the verb keeps working from an unlinked directory. The link only counts when it belongs to
// the org being mutated: under `--org` naming ANOTHER org, signing with this directory's project
// would evaluate authorization against the wrong project's policy, so the call goes projectless
// and the platform's org-administration path judges it instead.
const link = deps?.project ?? (await readProject()) ?? undefined
const projectId = link && link.orgId === orgId ? link.projectId : undefined
const res = await api.rawRequest('POST', `${domainPath(orgId, domainName)}/delegate`, undefined, projectId ? { projectId } : undefined)
const res = await api.rawRequest('POST', `${domainPath(orgId, domainName)}/delegate`, undefined, await signingScope(orgId, deps))
if (handleApproval(res, opts.json)) return
if (opts.json) return printJson(res.body)
const d = res.body as Purchased
Expand All @@ -187,7 +192,7 @@ export async function domainDelegate(domainName: string, opts: RecordsOpts, deps
// attach` line domainLines just printed, which IS the remedy there (attach works as usual
// under managed custody).
if (!d.hostnames.length || d.hostnames.some((h) => h.state !== 'failed')) {
info(`hostnames re-verify on the managed zone by themselves — watch: insta domain status ${d.domainName}${opts.org ? ` --org ${opts.org}` : ''}`)
info(`hostnames re-verify on the managed zone by themselves — watch: insta domain status ${d.domainName}${orgArgOf(opts)}`)
}
}

Expand Down Expand Up @@ -374,6 +379,8 @@ export function zoneRecordLines(records: ZoneRecord[]): string[] {
` ${r.type.padEnd(typeW)} ${r.host.padEnd(hostW)} ${r.answer.padEnd(answerW)}${r.ttl !== undefined ? ` ttl ${r.ttl}` : ''}${r.priority !== undefined ? ` prio ${r.priority}` : ''}${r.proxied ? ' (proxied)' : ''}`)
}

const orgArgOf = (opts: RecordsOpts): string => (opts.org ? ` --org ${opts.org}` : '')

/**
* Delegate a bring-your-own domain: the platform builds a managed zone for it and answers the two
* nameservers to set at the domain's own registrar. From then on every attach publishes its records
Expand All @@ -384,17 +391,9 @@ export function zoneRecordLines(records: ZoneRecord[]): string[] {
* can drop mail is never done implicitly. 202 approval_required in agent mode (zone.delegate); org
* admin either way.
*/
const orgArgOf = (opts: RecordsOpts): string => (opts.org ? ` --org ${opts.org}` : '')

export async function zoneDelegate(domainName: string, opts: RecordsOpts, deps?: DomainDeps): Promise<void> {
const { api, orgId } = await orgDeps(opts, deps)
// Same precedent as `domain delegate`: the org route signs for the linked project in agent mode
// (zone.delegate is read at the session project), but only when the link belongs to the org
// being mutated — under `--org` naming another org the call goes projectless and the platform's
// org-administration path judges it.
const link = deps?.project ?? (await readProject()) ?? undefined
const projectId = link && link.orgId === orgId ? link.projectId : undefined
const res = await api.rawRequest('POST', zonePath(orgId), { domainName }, projectId ? { projectId } : undefined)
const res = await api.rawRequest('POST', zonePath(orgId), { domainName }, await signingScope(orgId, deps))
if (handleApproval(res, opts.json)) return
if (opts.json) return printJson(res.body)
const z = res.body as OrgZone
Expand Down Expand Up @@ -437,9 +436,7 @@ export async function zoneRecords(domainName: string, opts: RecordsOpts, deps?:
*/
export async function zoneRelease(domainName: string, opts: RecordsOpts, deps?: DomainDeps): Promise<void> {
const { api, orgId } = await orgDeps(opts, deps)
const link = deps?.project ?? (await readProject()) ?? undefined
const projectId = link && link.orgId === orgId ? link.projectId : undefined
const res = await api.rawRequest('DELETE', zonePath(orgId, domainName), undefined, projectId ? { projectId } : undefined)
const res = await api.rawRequest('DELETE', zonePath(orgId, domainName), undefined, await signingScope(orgId, deps))
if (handleApproval(res, opts.json)) return
if (opts.json) return printJson(res.body)
const r = res.body as { domainName: string; released: boolean }
Expand Down
15 changes: 9 additions & 6 deletions src/commands/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ const sleepSeconds = (s: number) => new Promise<void>((r) => setTimeout(r, s * 1
// Node fires a timer of ~1ms for anything it cannot represent — a huge delay overflows, a negative one
// is clamped — so both ends are pinned or the wait becomes a hot poll of GitHub through us.
const pollDelay = (s: number) => Math.min(Math.max(s, 1), 60)
// The next poll interval after a failed poll. A dropped link, or the per-IP limiter this cadence
// already tripped on the login flow, must not end an authorization the person may be one click from
// finishing; any other platform refusal does.
const backOff = (e: unknown, interval: number): number => {
if (e instanceof ApiError && e.status !== 429) throw e
return pollDelay(interval + 5)
}

// GitHub shows the person a code to type; the platform holds the device code and finishes the exchange,
// so nothing secret passes through the CLI.
Expand All @@ -121,10 +128,7 @@ export async function authorizeTerminal(api: ApiClient, wait: (s: number) => Pro
try {
answer = await api.request<GitHubDevicePoll>('POST', '/me/github/device/poll', { state: start.state })
} catch (e) {
// A dropped link, or the per-IP limiter this cadence already tripped on the login flow, must not
// end an authorization the person may be one click from finishing.
if (e instanceof ApiError && e.status !== 429) throw e
interval = pollDelay(interval + 5)
interval = backOff(e, interval)
continue
}
if (!answer.pending) {
Expand Down Expand Up @@ -183,8 +187,7 @@ export async function findCallerRepo(api: ApiClient, ref: RepoRef, authorize: ty
try {
mine = await api.request<GitHubView>('GET', '/me/github/repos', undefined, { signal: AbortSignal.timeout(remaining) })
} catch (e) {
if (e instanceof ApiError && e.status !== 429) throw e
interval = pollDelay(interval + 5)
interval = backOff(e, interval)
continue
}
const ready = lookup(mine.repos)
Expand Down
5 changes: 2 additions & 3 deletions src/commands/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,7 @@ export async function resolveDbUrl(
service: string | undefined,
json?: boolean,
): Promise<DbUrlResolution | null> {
const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`)
const svc = resolveSoleService(services as Array<{ id: string; type: string; name: string }>, 'postgres', service)
const svc = await resolvePostgres(api, projectId, branch, service)
const res = await api.rawRequest('GET', `/projects/${projectId}/services/${svc.id}/credentials`)
if (handleApproval(res, json)) return null
const url = res.body?.credentials?.DATABASE_URL
Expand Down Expand Up @@ -473,7 +472,7 @@ export function accessErrorMessage(e: ApiError, ctx: { service: string; branch?:
return `${ctx.what} failed (${e.status}): ${e.message}`
}

async function resolvePostgres(api: ApiClient, projectId: string, branch: string | undefined, service: string | undefined) {
async function resolvePostgres(api: { request: (m: string, p: string) => Promise<any> }, projectId: string, branch: string | undefined, service: string | undefined) {
const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`)
return resolveSoleService(services as Array<{ id: string; type: string; name: string }>, 'postgres', service)
}
Expand Down
6 changes: 3 additions & 3 deletions src/commands/ssh-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,11 +678,11 @@ export function upsertCertAuthority(existing: string, hostPattern: string, caKey
export function planCertAuthority(existing: string, hostPattern: string, caKey: string): CertAuthorityPlan {
const key = caKey.trim()
const lines = existing.split('\n')
const removed: string[] = [], removedAt: number[] = []
const removed: string[] = [], removedAt: number[] = [], keptLines: string[] = []
lines.forEach((l, i) => {
if (isSupersededAnchor(l, hostPattern, key)) { removed.push(l); removedAt.push(i) }
if (isSupersededAnchor(l, hostPattern, key)) { removed.push(l); removedAt.push(i) } else keptLines.push(l)
})
const kept = lines.filter((l) => !isSupersededAnchor(l, hostPattern, key)).join('\n')
const kept = keptLines.join('\n')
const base = kept === '' ? '' : kept.endsWith('\n') ? kept : kept + '\n'
const line = renderCertAuthority(hostPattern, key)
return { next: base + line, line: line.trimEnd(), removed, removedAt }
Expand Down
14 changes: 10 additions & 4 deletions src/commands/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ export function writeCache(c: CheckCache): void {
writeFileSync(cachePath(), JSON.stringify(c))
}

// Record a freshly observed `latest` as checked `now`, keeping whatever else the cache holds (lastAutoAt).
function recordLatest(latest: string, now: number): CheckCache {
const cache: CheckCache = { ...(readCache() ?? { checkedAt: 0, latest }), checkedAt: now, latest }
writeCache(cache)
return cache
}

// May this cached answer stand in for a live registry call? An entry that is old, malformed, or
// provably behind the build we are running must not suppress the check — that is exactly how a
// wrong `latest` pins an install to an old version indefinitely. Note the deliberate `>= 0`:
Expand Down Expand Up @@ -278,7 +285,7 @@ export async function upgrade(current: string, deps: UpgradeDeps = {}): Promise<

if (latest) {
// Record what we just learned so the start-up nudge can't keep quoting a stale answer.
writeCache({ ...(readCache() ?? { checkedAt: 0, latest }), checkedAt: now, latest })
recordLatest(latest, now)
if (cmpSemver(latest, current) <= 0) {
say(`✓ insta ${current} is already the latest release — nothing to upgrade`)
return
Expand Down Expand Up @@ -319,7 +326,7 @@ export async function upgrade(current: string, deps: UpgradeDeps = {}): Promise<
say(`insta install finished, but the installed version could not be read — run \`insta --version\` to confirm`)
return
}
writeCache({ ...(readCache() ?? { checkedAt: 0, latest: observed }), checkedAt: now, latest: observed })
recordLatest(observed, now)
if (cmpSemver(observed, current) > 0) {
say(`✓ insta upgraded ${current} → ${observed}`)
return
Expand Down Expand Up @@ -351,8 +358,7 @@ export async function backgroundCheck(current: string, deps: CheckDeps = {}): Pr
const latest = await resolveLatest(deps.fetchImpl ?? fetch)
if (!latest) return 'none' // offline / registry down — try again next TTL
const now = deps.now ?? Date.now()
const cache: CheckCache = { ...(readCache() ?? { checkedAt: 0, latest }), checkedAt: now, latest }
writeCache(cache)
const cache = recordLatest(latest, now)

const channel = deps.channel ?? detectChannel()
const action = decideAction(cache, current, deps.auto ?? autoEnabled(), channel, now)
Expand Down
Loading