ask for the URL first, and offer what tea already knows

The form asked for a URL and a token together, on a fresh instance, while the
machine in front of it often had both on disk already — somebody had run `tea
login` by hand.

Two changes.

One thing at a time: until the platform knows where Gitea is, a token cannot be
checked, because there is nothing to check it against. So the owner names the
instance first, and only then does the page ask for — or discover — a token.
That step now probes /api/v1/version unauthenticated, since demanding a token
to validate a URL refuses a good URL for the wrong reason and sends whoever
typed it looking for a credential problem.

And it reads tea's own config when the platform has no instance yet, offering
the URL it finds. A machine that already has a working login should not be
asked to retype what it can see.

Members skip the first step entirely — the instance is the owner's.
This commit is contained in:
officer
2026-08-19 10:25:22 +00:00
parent 862512d83a
commit d4efc52447
4 changed files with 102 additions and 23 deletions
+15 -4
View File
@@ -16,9 +16,10 @@ import {
invalidateAllGiteaConfigs,
normalizeBase,
probe,
reachable,
UnreadableCredentials,
} from './upstream';
import { readTeaLogin, writeTeaLogin, osUserFor, testTeaLogin } from './tea-config';
import { readTeaLogin, readAnyTeaLogin, writeTeaLogin, osUserFor, testTeaLogin } from './tea-config';
import { API_URL } from '@@/officer-url.mjs';
// The officer-gitea sidecar. Owns the whole Gitea contract: the instance URL and the personal access
@@ -136,7 +137,10 @@ async function connectionState(userId: number): Promise<Response> {
//
// Read rather than asked for: on a machine where the owner and their members already ran `tea login`
// by hand, the settings page should say "connected" instead of demanding something they have.
const tea = instanceUrl ? await readTeaLogin(userId, instanceUrl) : null;
// With an instance known, ask about THAT one. Without, offer whatever tea already has — a machine
// where somebody ran `tea login` by hand knows the URL and holds a working token, and asking them to
// retype it is asking for something the box can already see.
const tea = instanceUrl ? await readTeaLogin(userId, instanceUrl) : await readAnyTeaLogin(userId);
const osUser = await osUserFor(userId);
return Response.json({
@@ -146,7 +150,8 @@ async function connectionState(userId: number): Promise<Response> {
instanceConfigured: !!instanceUrl,
isOwner: await isOwner(userId),
// What tea already knows, so the page can say who you are without a round trip to Gitea.
tea: tea ? { name: tea.name, user: tea.user, hasToken: tea.hasToken } : null,
// `url` included so the form can offer it when the platform has no instance yet.
tea: tea ? { name: tea.name, url: tea.url, user: tea.user, hasToken: tea.hasToken } : null,
// Null means never provisioned an OS account, so there is nowhere to write a config. The page says
// so rather than failing on save.
canStoreToken: !!osUser || (await isOwner(userId)),
@@ -199,8 +204,14 @@ async function handleConfig(req: Request, userId: number): Promise<Response> {
// fails with the reason. Storing first and discovering later makes every downstream screen fail
// mysteriously instead. For a member this also proves their token is valid against the shared
// instance, which is the only thing that can be wrong on their side.
// Setting the INSTANCE is its own step, and it must not demand a token: the owner names where Gitea
// is first, and only then does the page ask for — or discover — a token for it. Probing with one
// when there is none would refuse a perfectly good URL for the wrong reason.
const current = token ? null : await getGiteaConfig(userId);
const result = await probe({ id: existing?.id ?? 0, base, token: token ?? current?.token ?? null });
const candidate = token ?? current?.token ?? null;
const result = candidate
? await probe({ id: existing?.id ?? 0, base, token: candidate })
: await reachable(base);
if (!result.ok) return bad(result.error ?? 'could not reach the instance');
// THE TOKEN GOES TO `tea`, NOT THE DATABASE.
+36 -10
View File
@@ -84,18 +84,20 @@ export async function osUserFor(userId: number): Promise<string | null> {
* The TOKEN ITSELF IS NOT RETURNED. Callers want to know whether one exists and who it belongs to; the
* value never needs to leave this file, and returning it would put it in a response body.
*/
export async function readTeaLogin(userId: number, instanceUrl: string): Promise<TeaLogin | null> {
async function rawConfig(userId: number): Promise<string | null> {
const osUser = await osUserFor(userId);
const raw = isOwnerUser(osUser)
? await (async () => {
try {
return readFileSync(configPathFor(homedir()), 'utf8');
} catch {
return null;
}
})()
: await asUser(osUser, ['cat', configPathFor(join('/home', osUser!))]);
if (isOwnerUser(osUser)) {
try {
return readFileSync(configPathFor(homedir()), 'utf8');
} catch {
return null;
}
}
return asUser(osUser, ['cat', configPathFor(join('/home', osUser!))]);
}
export async function readTeaLogin(userId: number, instanceUrl: string): Promise<TeaLogin | null> {
const raw = await rawConfig(userId);
if (!raw) return null;
// One block per login. Matched loosely on purpose — the file is hand-editable and its key order is not
@@ -115,6 +117,30 @@ export async function readTeaLogin(userId: number, instanceUrl: string): Promise
return null;
}
/**
* Whatever login `tea` already has, when the platform does not yet know the instance.
*
* A machine where somebody ran `tea login` by hand already knows the URL and holds a working token. Not
* offering that back means asking them to retype something the box in front of them can see.
*
* Returns the FIRST login, which for a config with one is the only one, and for a config with several is
* the one `tea` itself defaults to — the same answer the agent would get.
*/
export async function readAnyTeaLogin(userId: number): Promise<TeaLogin | null> {
const raw = await rawConfig(userId);
if (!raw) return null;
const block = raw.split(/^\s*-\s+name:/m)[1];
if (!block) return null;
const url = block.match(/^\s*url:\s*(\S+)/m)?.[1]?.replace(/\/$/, '') ?? '';
if (!url) return null;
return {
name: block.split('\n')[0]?.trim() ?? '',
url,
user: block.match(/^\s*user:\s*(\S+)/m)?.[1] ?? null,
hasToken: /^\s*token:\s*\S+/m.test(block),
};
}
/**
* Write a login for this instance, as that user.
*
+21
View File
@@ -113,6 +113,27 @@ export type ProbeResult = { ok: boolean; version: string | null; user: string |
* 200 → both good
* Verified against the live instance: unauthenticated /version → 403, /version with a junk token → 401.
*/
/**
* Is there a Gitea at this URL at all — no token involved.
*
* Naming the instance is its own step: the owner says where Gitea is, and only then does anyone ask for
* a token for it. Probing with a token that does not exist yet would refuse a perfectly good URL for the
* wrong reason, and send whoever typed it looking for a problem with their credentials.
*
* `/api/v1/version` answers unauthenticated on every Gitea, so a 200 means "this is a Gitea and it is
* up", which is exactly what this step needs to establish.
*/
export async function reachable(base: string): Promise<ProbeResult> {
try {
const res = await fetch(`${base}/api/v1/version`, { signal: AbortSignal.timeout(8000) });
if (!res.ok) return { ok: false, version: null, user: null, error: `the server answered ${res.status}` };
const body = (await res.json().catch(() => null)) as { version?: string } | null;
return { ok: true, version: body?.version ?? null, user: null };
} catch {
return { ok: false, version: null, user: null, error: 'could not reach that URL from the server' };
}
}
export async function probe(config: GiteaConfig): Promise<ProbeResult> {
if (!config.token) return { ok: false, version: null, user: null, error: 'no access token stored' };
+30 -9
View File
@@ -20,6 +20,8 @@ export type ConnectionState = {
url?: string | null;
user?: string | null;
isOwner?: boolean;
/** Whether the OWNER has set the instance URL. Members inherit it and never set one. */
instanceConfigured?: boolean;
/**
* A token is stored but cannot be decrypted.
*
@@ -30,7 +32,7 @@ export type ConnectionState = {
*/
unreadable?: boolean;
/** What `tea` already has for this instance. Null when it has no login for it. */
tea?: { name: string; user: string | null; hasToken: boolean } | null;
tea?: { name: string; url?: string; user: string | null; hasToken: boolean } | null;
/** False when this account has no OS user, so there is nowhere to write a config. */
canStoreToken?: boolean;
};
@@ -43,7 +45,10 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
// Loopback is the right default and worth putting in front of someone: the sidecar fetches
// SERVER-SIDE, so `localhost` means the machine Officer runs on. It needs no TLS and never leaves the
// box — where the public URL would take the same traffic out and back.
const [url, setUrl] = useState(state.url ?? 'http://localhost:3000');
// Seeded from what tea already knows before falling back to a guess. On a machine where somebody ran
// `tea login` by hand, the URL is already on disk and asking for it again is asking for something the
// box can see.
const [url, setUrl] = useState(state.url ?? state.tea?.url ?? 'http://localhost:3000');
const [token, setToken] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -63,6 +68,13 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
}
};
// ONE THING AT A TIME. Until the platform knows where Gitea is, a token cannot be checked — there is
// nothing to check it against — and once the URL is known, tea may already hold a working token, in
// which case the second step never appears at all.
//
// Members skip step one entirely: the instance is the owner's.
const needsUrl = state.isOwner !== false && !state.instanceConfigured;
const save = async () => {
setBusy(true);
setError(null);
@@ -86,13 +98,15 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
<p className="mt-1 text-sm text-muted-foreground">
{state.unreadable
? "The stored token can no longer be read — this server's encryption key changed. Nothing else is lost; paste it again."
: state.isOwner === false
? 'Your own access token. The instance is already set.'
: 'Where Gitea runs, and a token for it.'}
: needsUrl
? 'Where Gitea runs. Officer reaches it from the server, so localhost means the machine Officer runs on.'
: state.isOwner === false
? 'Your own access token. The instance is already set.'
: 'Your own access token for this instance.'}
</p>
</div>
{state.isOwner !== false && (
{needsUrl && (
<label className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">Instance URL</span>
<Input value={url} onChange={(ev) => setUrl(ev.target.value)} placeholder="http://localhost:3000" />
@@ -103,15 +117,19 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
</label>
)}
{/* Not shown while the instance is still unknown: a token cannot be validated without one, and
tea may already hold one for whatever URL is entered above. */}
{needsUrl ? null : (
<label className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">Access token</span>
<Input
type="password"
value={token}
onChange={(ev) => setToken(ev.target.value)}
placeholder={state.configured && !state.unreadable ? '•••••••• (unchanged)' : 'Settings → Applications in Gitea'}
placeholder={state.tea?.hasToken && !state.unreadable ? '•••••••• (unchanged)' : 'Settings → Applications in Gitea'}
/>
</label>
)}
{/* What tea already has, read from the account's own config. On a machine where someone ran
`tea login` by hand, this says so instead of demanding a token they already gave. */}
@@ -139,9 +157,12 @@ export const GiteaConnection = ({ state, onSaved }: Props) => {
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex gap-2">
<Button onClick={save} disabled={busy || ((!state.configured || state.unreadable) && !token)}>
<Button
onClick={save}
disabled={busy || (needsUrl ? !url.trim() : !state.tea?.hasToken && !token)}
>
{busy ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
{state.configured ? 'Save' : 'Connect'}
{needsUrl ? 'Continue' : state.configured ? 'Save' : 'Connect'}
</Button>
{/* Only offered when there is something to test. */}
{state.tea?.hasToken ? (