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
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
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
14 changes: 0 additions & 14 deletions apps/host/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,20 +76,6 @@ export async function serviceAuth(secret: string): Promise<Record<string, string
return { authorization: `Bearer ${await credential(secret, domain)}` };
}

/** Why a team service refused this host, naming both tailnets when they differ. */
export async function rejection(response: Response, service: string): Promise<string> {
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<WebSocket> {
const socket = new WebSocket(`${base.replace(/^http/, "ws")}${path}`, {
headers: await hostedAuth(),
Expand Down
3 changes: 1 addition & 2 deletions apps/host/src/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,7 +30,6 @@ async function call<T>(url: string, init: RequestInit = {}): Promise<T> {
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;
}
Expand Down
4 changes: 2 additions & 2 deletions apps/host/src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
19 changes: 18 additions & 1 deletion apps/host/src/http.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { log } from "./log";
import * as tailnet from "./tailnet";

/** Longer bodies are pages, not messages a service wrote for people. */
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<string> {
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);
Expand All @@ -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<string> {
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.`;
}