diff --git a/src/agent.ts b/src/agent.ts index ccde8dd..e2a0f22 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -84,10 +84,10 @@ export const ACCOUNT_ROUTES: ReadonlySet = new Set(['agent', 'auth', 'me export async function agentHeaders(api: SessionApi, method: string, path: string, rawBody: string, scope: AgentScope = {}): Promise> { // 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] ?? '')) { diff --git a/src/commands/domain.ts b/src/commands/domain.ts index f58c7eb..b2e38fb 100644 --- a/src/commands/domain.ts +++ b/src/commands/domain.ts @@ -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)) @@ -167,15 +180,7 @@ const domainPath = (orgId: string, domainName: string): string => */ export async function domainDelegate(domainName: string, opts: RecordsOpts, deps?: DomainDeps): Promise { 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 @@ -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)}`) } } @@ -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 @@ -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 { 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 @@ -437,9 +436,7 @@ export async function zoneRecords(domainName: string, opts: RecordsOpts, deps?: */ export async function zoneRelease(domainName: string, opts: RecordsOpts, deps?: DomainDeps): Promise { 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 } diff --git a/src/commands/github.ts b/src/commands/github.ts index bf84d15..ac09581 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -97,6 +97,13 @@ const sleepSeconds = (s: number) => new Promise((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. @@ -121,10 +128,7 @@ export async function authorizeTerminal(api: ApiClient, wait: (s: number) => Pro try { answer = await api.request('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) { @@ -183,8 +187,7 @@ export async function findCallerRepo(api: ApiClient, ref: RepoRef, authorize: ty try { mine = await api.request('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) diff --git a/src/commands/postgres.ts b/src/commands/postgres.ts index 63fad0b..d328586 100644 --- a/src/commands/postgres.ts +++ b/src/commands/postgres.ts @@ -319,8 +319,7 @@ export async function resolveDbUrl( service: string | undefined, json?: boolean, ): Promise { - 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 @@ -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 }, 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) } diff --git a/src/commands/ssh-config.ts b/src/commands/ssh-config.ts index 0df540d..bacbb0d 100644 --- a/src/commands/ssh-config.ts +++ b/src/commands/ssh-config.ts @@ -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 } diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index 38d72ad..7e26e1f 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -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`: @@ -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 @@ -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 @@ -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)