From 89baf6b5df8a445e94f33fb06393bf92dda38cbe Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Wed, 21 Oct 2026 17:58:15 -0400 Subject: [PATCH 1/3] fix(host): explain the channel service's 401 like the directory's Every team-service refusal now goes through refusal(), which hands a 401 to rejection(): a wrong ACE_SECRET, a tailnet mismatch, or a service without TAILNET. Creating, moving, deleting, and backing up hosted channels said only "The hosting service refused (HTTP 401)". Refs #541 --- apps/host/src/client.ts | 14 -------------- apps/host/src/compute.ts | 3 +-- apps/host/src/directory.ts | 4 ++-- apps/host/src/http.ts | 19 ++++++++++++++++++- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/apps/host/src/client.ts b/apps/host/src/client.ts index 7af15ac4..7817cbdf 100644 --- a/apps/host/src/client.ts +++ b/apps/host/src/client.ts @@ -76,20 +76,6 @@ export async function serviceAuth(secret: string): Promise { - await response.body?.cancel(); - const served = response.headers.get("ace-tailnet"); - const domain = await tailnet.domain(); - if (!served) { - return `${service} names no tailnet: it predates tailnet-scoped services or lacks its TAILNET setting. Check its URL against your team's current services.`; - } - if (domain && served !== domain) { - return `${service} serves the ${served} tailnet, but this Mac is on ${domain}. Switch tailnets, or use services deployed for this one.`; - } - return `${service} rejected this host's ACE_SECRET. Ask whoever set it up for the team's secret.`; -} - export async function hostedSocket(base: string, path: string): Promise { const socket = new WebSocket(`${base.replace(/^http/, "ws")}${path}`, { headers: await hostedAuth(), diff --git a/apps/host/src/compute.ts b/apps/host/src/compute.ts index e5318136..3af6d6ec 100644 --- a/apps/host/src/compute.ts +++ b/apps/host/src/compute.ts @@ -8,7 +8,7 @@ import { realpathSync } from "node:fs"; import type { ModelRef } from "@ace/channel/protocol"; import * as catalog from "./catalog"; -import { hostedAuth, rejection } from "./client"; +import { hostedAuth } from "./client"; import { project as repoOf } from "./github"; import { refusal } from "./http"; import { project } from "./manage"; @@ -30,7 +30,6 @@ async function call(url: string, init: RequestInit = {}): Promise { cause: error, }); } - if (response.status === 401) throw new Error(await rejection(response, "Cloud compute")); if (!response.ok) throw new Error(await refusal(response, "Cloud compute")); return await response.json() as T; } diff --git a/apps/host/src/directory.ts b/apps/host/src/directory.ts index e8a49bca..56d3e2e7 100644 --- a/apps/host/src/directory.ts +++ b/apps/host/src/directory.ts @@ -8,8 +8,8 @@ import { join } from "node:path"; import type { Lifecycle, Share } from "@ace/directory/protocol"; import * as catalog from "./catalog"; -import { hostedAuth, rejection, serviceAuth } from "./client"; -import { refusal } from "./http"; +import { hostedAuth, serviceAuth } from "./client"; +import { refusal, rejection } from "./http"; import { failure, log } from "./log"; import type { Listing } from "./protocol"; diff --git a/apps/host/src/http.ts b/apps/host/src/http.ts index 0cee47dc..5a7a465e 100644 --- a/apps/host/src/http.ts +++ b/apps/host/src/http.ts @@ -1,4 +1,5 @@ import { log } from "./log"; +import * as tailnet from "./tailnet"; /** Longer bodies are pages, not messages a service wrote for people. */ const SHORT = 200; @@ -6,9 +7,11 @@ const SHORT = 200; /** * One line for a service's failed answer: the status, plus the service's own message when it wrote * a short plain-text or JSON one. Error pages from whatever fronts the service (a Cloudflare - * exception page, a proxy) go to the host log instead, so the app never shows raw HTML. + * exception page, a proxy) go to the host log instead, so the app never shows raw HTML. Every + * caller is a team service, so a 401 says why the team's credential was refused. */ export async function refusal(response: Response, service: string): Promise { + if (response.status === 401) return rejection(response, service); const body = await response.text().catch(() => ""); log("warn", "http.refused", { url: response.url, status: response.status, body }); const text = said(response.headers.get("content-type") || "", body); @@ -30,3 +33,17 @@ function said(type: string, body: string): string { } return text.length <= SHORT && !/[\r\n]/.test(text) ? text : ""; } + +/** Why a team service refused this host, naming both tailnets when they differ. */ +export async function rejection(response: Response, service: string): Promise { + await response.body?.cancel(); + const served = response.headers.get("ace-tailnet"); + const domain = await tailnet.domain(); + if (!served) { + return `${service} names no tailnet: it predates tailnet-scoped services or lacks its TAILNET setting. Check its URL against your team's current services.`; + } + if (domain && served !== domain) { + return `${service} serves the ${served} tailnet, but this Mac is on ${domain}. Switch tailnets, or use services deployed for this one.`; + } + return `${service} rejected this host's ACE_SECRET. Ask whoever set it up for the team's secret.`; +} From 0b29e8cc880e4058e0e339b7e16d49ccf3bf6079 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Wed, 21 Oct 2026 17:58:23 -0400 Subject: [PATCH 2/3] chore: confirm the refusal note after explaining 401s --- ...Z4C2JDB2YZ2JBTW2WPT34T-service-error-bodies-reach-t.md | 5 +++-- ...G7HY7DJTK2JSQ9EE9QW7M7-only-team-services-answers-m.md | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 .lane/memory/apps/host/src/http.ts/01M5G7HY7DJTK2JSQ9EE9QW7M7-only-team-services-answers-m.md diff --git a/.lane/memory/apps/host/src/http.ts/01M4Z4C2JDB2YZ2JBTW2WPT34T-service-error-bodies-reach-t.md b/.lane/memory/apps/host/src/http.ts/01M4Z4C2JDB2YZ2JBTW2WPT34T-service-error-bodies-reach-t.md index 59d8ba86..9539ef61 100644 --- a/.lane/memory/apps/host/src/http.ts/01M4Z4C2JDB2YZ2JBTW2WPT34T-service-error-bodies-reach-t.md +++ b/.lane/memory/apps/host/src/http.ts/01M4Z4C2JDB2YZ2JBTW2WPT34T-service-error-bodies-reach-t.md @@ -4,8 +4,9 @@ anchor: function refusal created: 2026-10-15T06:35:39Z norm: '1' sig: 78f3b0d364527f79 -body_hash: c9ba4b0665155aa0 -raw_hash: 579f783033937f07 +body_hash: 58340303b3254b16 +raw_hash: 95b03b2c5b254c98 +vouched: 2026-10-21T21:58:19Z lines: 11-20 --- diff --git a/.lane/memory/apps/host/src/http.ts/01M5G7HY7DJTK2JSQ9EE9QW7M7-only-team-services-answers-m.md b/.lane/memory/apps/host/src/http.ts/01M5G7HY7DJTK2JSQ9EE9QW7M7-only-team-services-answers-m.md new file mode 100644 index 00000000..e75fb05c --- /dev/null +++ b/.lane/memory/apps/host/src/http.ts/01M5G7HY7DJTK2JSQ9EE9QW7M7-only-team-services-answers-m.md @@ -0,0 +1,8 @@ +--- +id: 01M5G7HY7DJTK2JSQ9EE9QW7M7 +anchor: function refusal +created: 2026-10-21T21:58:23Z +norm: '1' +--- + +only team services' answers may go through refusal: a 401 is explained as the team's ACE_SECRET or tailnet being refused From 94fa75380658eb901b2dc65f0d0ea388164c8c42 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Wed, 21 Oct 2026 17:58:24 -0400 Subject: [PATCH 3/3] lane: sync hosted-401 memory --- ...01M5G7HY7DJTK2JSQ9EE9QW7M7-only-team-services-answers-m.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.lane/memory/apps/host/src/http.ts/01M5G7HY7DJTK2JSQ9EE9QW7M7-only-team-services-answers-m.md b/.lane/memory/apps/host/src/http.ts/01M5G7HY7DJTK2JSQ9EE9QW7M7-only-team-services-answers-m.md index e75fb05c..a28791c0 100644 --- a/.lane/memory/apps/host/src/http.ts/01M5G7HY7DJTK2JSQ9EE9QW7M7-only-team-services-answers-m.md +++ b/.lane/memory/apps/host/src/http.ts/01M5G7HY7DJTK2JSQ9EE9QW7M7-only-team-services-answers-m.md @@ -3,6 +3,10 @@ id: 01M5G7HY7DJTK2JSQ9EE9QW7M7 anchor: function refusal created: 2026-10-21T21:58:23Z norm: '1' +sig: 78f3b0d364527f79 +body_hash: 58340303b3254b16 +raw_hash: 95b03b2c5b254c98 +lines: 13-23 --- only team services' answers may go through refusal: a 401 is explained as the team's ACE_SECRET or tailnet being refused