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
10 changes: 8 additions & 2 deletions commands/__tests__/team-join.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function fakeJoinRedeemSeams(overrides: Partial<JoinRedeemSeams> = {}): JoinRede
readTeamSecret: async () => null,
forgeLogin: async () => "zaphod",
forgeToken: async () => null,
writeLocalSecret: async () => {},
warn: () => {},
...overrides,
};
Expand Down Expand Up @@ -250,24 +251,29 @@ describe("teamJoin", () => {
home: HOME,
fetch: async (url, init) => {
fetchCalls.push(url);
if (url.endsWith("/peer/join")) return { status: 200, body: "", headers: {} };
if (url.endsWith("/boards")) return { status: 201, body: JSON.stringify({ username: "zaphod", token: "tok-1" }), headers: {} };
return relayFetch()(url, init);
},
exec: () => ({ code: 0, stdout: "", stderr: "" }),
});
const secretWrites: { key: string; value: string }[] = [];
const deps = baseDeps({
probes,
joinRedeemSeams: fakeJoinRedeemSeams({
read: fakeRead({ "mattstack.integrations": { switchboard: { url: "https://sb.test" } } }),
readTeamSecret: async () => "admin-token",
writeLocalSecret: async (key, value) => {
secretWrites.push({ key, value });
},
}),
});

await teamJoin(["--json"], {}, deps);

const body = JSON.parse(deps.lines[0]!);
expect(body.peering).toBe("applied");
expect(fetchCalls).toContain("https://sb.test/peer/join");
expect(fetchCalls).toContain("https://sb.test/boards");
expect(secretWrites).toEqual([{ key: "switchboardToken", value: "tok-1" }]);
});

test("redeem success clears the saved setup intent", async () => {
Expand Down
32 changes: 32 additions & 0 deletions commands/__tests__/team-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,38 @@ describe("teamStatus", () => {
});
});

test("a non-array mattstack.roster value falls back to board.members, matching preferredRoster's rule", async () => {
const deps = clonedDeps({
exec: async () => ({ code: 0, stdout: "2026-08-21T10:00:00+00:00\n", stderr: "" }),
read: {
"board.title": "Acme Team",
"board.members": [{ username: "matt" }],
"mattstack.roster": "corrupted-not-an-array",
},
});

await teamStatus(["--team", SLUG, "--json"], {}, deps);

const body = JSON.parse(deps.lines[0]!);
expect(body.members).toEqual([{ username: "matt" }]);
});

test("members come from mattstack.roster when present; board.members is only the legacy fallback", async () => {
const deps = clonedDeps({
exec: async () => ({ code: 0, stdout: "2026-08-21T10:00:00+00:00\n", stderr: "" }),
read: {
"board.title": "Acme Team",
"board.members": [{ username: "legacy-only" }],
"mattstack.roster": [{ username: "matt" }, { username: "leath1" }],
},
});

await teamStatus(["--team", SLUG, "--json"], {}, deps);

const body = JSON.parse(deps.lines[0]!);
expect(body.members).toEqual([{ username: "matt" }, { username: "leath1" }]);
});

test("--json carries pullOnly through from the daemon's snapshot-status entry, so a member can see why nothing pushes", async () => {
const deps = clonedDeps({
exec: async () => ({ code: 0, stdout: "2026-08-21T10:00:00+00:00\n", stderr: "" }),
Expand Down
9 changes: 5 additions & 4 deletions commands/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { extractInviteCode } from "../lib/team/invite-crypto.ts";
import { mintInvite } from "../lib/team/invite.ts";
import { readTeamLocal, updateTeamLocal } from "../lib/team/team-local.ts";
import { JoinKeyExchangeError, joinDryRun, joinRedeem, realJoinRedeemSeams, type JoinRedeemSeams, type JoinResult } from "../lib/team/join.ts";
import { membersRemove, membersSync, teamRemote } from "../lib/team/members.ts";
import { membersRemove, membersSync, preferredRoster, teamRemote } from "../lib/team/members.ts";
import { publishTeam } from "../lib/team/publish.ts";
import { storedForgeToken } from "../lib/team/stored-forge-token.ts";
import { createRelayClient, inviteRelayUrl } from "../lib/team/relay-client.ts";
Expand Down Expand Up @@ -387,10 +387,10 @@ export async function teamMembersSync(args: string[], _ctx: CommandContext = {},
}
}

/** Removable roster handles for the resolved team, from the same `board.members` source `membersRemove` reads. Empty on an unresolved or ambiguous team or any read failure, so the picker falls through to the usage error an omitted handle always got. */
/** Removable roster handles for the resolved team, from the same preferred-roster source `membersRemove` reads. Empty on an unresolved or ambiguous team or any read failure, so the picker falls through to the usage error an omitted handle always got. */
function rosterHandles(args: string[]): string[] {
try {
const members = readStore(teamSettingsPath(resolveTeamSlug(args))).global["board.members"];
const members = preferredRoster(readStore(teamSettingsPath(resolveTeamSlug(args))).global);
if (!Array.isArray(members)) return [];
return members
.filter((m): m is { username: string } => m !== null && typeof m === "object" && typeof (m as { username?: unknown }).username === "string")
Expand Down Expand Up @@ -524,7 +524,8 @@ export async function teamStatus(args: string[], _ctx: CommandContext = {}, deps
const snapshot = readTeamSnapshot(deps.probes, slug, { read, warn: () => {} });
const title = read<string>("board.title");
const name = title && title.length > 0 ? title : slug;
const members = toRosterMembers(read<unknown>("board.members"), (msg) => console.error(msg));
const preferredMembers = read<unknown>("mattstack.roster");
const members = toRosterMembers(Array.isArray(preferredMembers) ? preferredMembers : read<unknown>("board.members"), (msg) => console.error(msg));

const log = await deps.probes.exec(["git", "-C", dir, "log", "-1", "--format=%cI", "origin/main"]);
const lastPush = log.code === 0 ? log.stdout.trim() || null : null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# One-invite board peering

**Problem.** Joining a team takes two invites today: the `rt team invite`
code, and a separate board peering invite the owner mints in the board UI
and hands over mid-onboarding. The one-invite path was already designed
and half-built: `rt team join` reads the team-shared `switchboardAdminToken`
secret and POSTs `{member}` to `<switchboard>/peer/join`, but the deployed
switchboard never grew that route, so every join 404s and silently reports
peering "unavailable". The board UI invite became the workaround.

**Goal.** The team invite is the only invite. A joiner whose team declares
a switchboard ends onboarding with a peered board and zero extra pastes.
The board UI invite becomes a repair path only.

## What already works (verified in source)

- `rt team join` (`lib/team/join.ts:441-455`): reads the admin token from
team secrets and POSTs `/peer/join`; reports `peering:
applied|unavailable|idle`. It ignores the response body.
- Switchboard store (`apps/board/switchboard/store.ts`): `registerBoard`
is an upsert that rotates `token_hash` and returns the fresh token.
- Board resolves its switchboard URL from the team-synced
`board.switchboardUrl` settings key (`apps/board/src/config.ts:754`),
and its token env-first then via the rt daemon's `secrets:read` scope
"board", which whitelists the rt-domain `switchboardToken`
(`lib/daemon/handlers/secrets.ts:64`, `apps/board/src/config.ts:1119`).
Both reads happen at call time; no restart or .env write needed.

## Changes

### 1. Switchboard: no change

The needed route already exists: `POST /boards {username}` (admin bearer,
`apps/board/switchboard/server.ts:37`) upserts via `registerBoard` and
answers `201 {username, token}`, rotating the token on re-registration
(matching the board UI's "re-join with a new invite" semantics). rt's
`/peer/join {member}` call was simply aimed at a route that never
existed. No apps change, no Railway deploy.

### 2. rt: embed the board token at mint, store it at join (m4ttstack/rt)

A join-time admin-token read can never serve a FIRST join: the invitee's
age key becomes a team-secrets recipient only after the owner's members
sync processes their reply, which happens after the join. So the token is
minted where the admin token is readable, the owner's machine:

- `lib/team/invite.ts` (`mintInvite`): when the team declares a
switchboard, read `switchboardAdminToken` from the operator's LOCAL rt
domain, POST `<switchboard>/boards {username: handle}`, and seal the
returned per-board token into the pointer as
`switchboard: { url, token }` (the pointer travels only as ciphertext).
Any failure degrades to an invite without peering plus a warning naming
the board-panel re-invite repair; the mint never fails over peering.
- `lib/team/join.ts`: a pointer carrying `switchboard.token` stores it as
the LOCAL user secret (`rt` domain, `switchboardToken`, never
team-synced) and reports peering applied; no switchboard call, no
team-secret read. Without an embedded token it falls back to the old
admin-token path, now aimed at the real route (POST `/boards`,
capturing the token), which serves re-joins by members whose keys are
already recipients. On `unavailable`, the join message names the
repair.

### 3. rt: roster readers cut to mattstack.roster (m4ttstack/rt)

Writers already dual-write `board.members` + `mattstack.roster`; board
and boxscore read the new key; rt's readers still read the old one. Cut
`rt team status` (`commands/team.ts:527`), the members-remove picker
(`commands/team.ts:393`), and `lib/team/members.ts`'s default read key to
`mattstack.roster`, falling back to `board.members` when the new key is
absent (old team stores). Dual-write stays.

## Rollout

- Switchboard deploys from apps main to the Railway project
`mattstack-switchboard` (no board app release; the board binary is
untouched).
- The rt half ships as **v2.10.1**: the joiner-side code runs on the
invitee's machine, so the fix is inert for new teammates until it is in
the released bundle.
- Owner-side precondition, checked at execution time: the operator's
LOCAL rt domain must actually hold the `switchboardAdminToken`
secret; if absent, seed it before minting invites.

## Out of scope (fast-follow tickets)

- Board members panel goes repair-only (drop the free-text invite row;
per-row buttons become re-invite).
- Settings > Team pane: merge the members list and invite into one list
with per-row state (joined / invited pending / needs re-invite).

## Testing

- Switchboard: route test for auth gate, bad member, happy path, re-join
rotation (existing test file pattern in `apps/board/switchboard`).
- rt: `joinRedeem` unit tests for token capture and store, 2xx-no-token,
404 (today's deployed reality), and no-admin-token; roster-read tests
for new-key, old-key-only, and both-present stores.
- End to end: the VM walkthrough's join leg exercises the real flow ahead
of v2.10.1's tag.
6 changes: 6 additions & 0 deletions lib/setup/intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export interface InvitePointer {
owner: string;
forge: string;
createdAt: string;
/** Board peering, pre-minted at invite time: the owner's machine registers
the invitee's board on the switchboard and seals the per-board token
here, because at join time the invitee cannot yet decrypt team secrets
(their age key becomes a recipient only after the owner's members sync).
Absent when the team has no switchboard or the register failed at mint. */
switchboard?: { url: string; token: string };
}

export interface SetupIntent {
Expand Down
8 changes: 7 additions & 1 deletion lib/setup/steps/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { createTeam, type CreateTeamOpts } from "../../team/create.ts";
import { forgeLogin } from "../../team/forge.ts";
import { JoinKeyExchangeError, joinRedeem, realJoinRedeemSeams } from "../../team/join.ts";
import { writeSecret } from "../../secrets/store.ts";
import { publishTeam } from "../../team/publish.ts";
import { forgeTokenFor } from "./forge-token.ts";
import type { ApplyContext } from "../apply.ts";
Expand Down Expand Up @@ -88,7 +89,12 @@ async function teamJoinRun(ctx: ApplyContext): Promise<StepOutcome> {
// fake) — so join's own key exchange never reaches for a second,
// independently-real keychain seam. ctx.teamSecrets is the same discipline
// for the team-secret (switchboard token) read.
const seams = { ...realJoinRedeemSeams(), ageKeySeam: ctx.secrets.ageKeySeam, forgeToken: (_p: unknown, remote: string) => forgeTokenFor(ctx, remote) };
const seams = {
...realJoinRedeemSeams(),
ageKeySeam: ctx.secrets.ageKeySeam,
forgeToken: (_p: unknown, remote: string) => forgeTokenFor(ctx, remote),
writeLocalSecret: (key: string, value: string) => writeSecret("rt", key, value, ctx.secrets),
};

try {
const result = await joinRedeem(ctx.p, ctx.relay, ctx.teamSecrets, {}, seams);
Expand Down
95 changes: 95 additions & 0 deletions lib/team/__tests__/invite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ function baseSeams(overrides: Partial<MintInviteSeams> = {}): { seams: MintInvit
readTeamLocal: () => ({ createdByRt: true, joinedByRt: false, rtMayManageMembership: true }),
forgeLogin: async () => "octocat",
forgeToken: async () => null,
readLocalSecret: async () => null,
warn: (m) => warnings.push(m),
...overrides,
};
Expand Down Expand Up @@ -330,6 +331,100 @@ describe("mintInvite", () => {
expect(pointer.name).toBe(SLUG);
});

test("a team with a switchboard: mint registers the member's board and seals url+token into the pointer", async () => {
const fetchCalls: { url: string; init?: { method?: string; headers?: Record<string, string>; body?: string } }[] = [];
const p = fakeProbes({
home: HOME,
files: { [GIT_CONFIG_PATH]: gitConfigWithRemote(REMOTE) },
fetch: async (url, init) => {
fetchCalls.push({ url, init });
return { status: 201, body: JSON.stringify({ username: "zaphod", token: "tok-9" }), headers: {} };
},
});
const { seams } = baseSeams({
read: fakeRead({
"mattstack.integrations": { forge: { host: "github.com", provider: "github" }, switchboard: { url: "https://sb.test" } },
"board.title": "Acme Team",
}),
readLocalSecret: async () => "admin-1",
});
const relay = fakeRelayClient();

const result = await mintInvite(p, relay.client, { slug: SLUG, handle: "zaphod", now: NOW }, seams);

expect(fetchCalls).toHaveLength(1);
expect(fetchCalls[0]!.url).toBe("https://sb.test/boards");
expect(fetchCalls[0]!.init?.headers?.Authorization).toBe("Bearer admin-1");
expect(JSON.parse(fetchCalls[0]!.init?.body ?? "{}")).toEqual({ username: "zaphod" });
const { idHex, key } = decodeCode(result.code);
const pointer = await open(relay.createCalls[0]!.ciphertext, key, idHex);
expect(pointer.switchboard).toEqual({ url: "https://sb.test", token: "tok-9" });
});

test("no readable admin token: the mint still succeeds, the pointer carries no switchboard, and the warn names board peering", async () => {
const p = probesWithRemote(REMOTE);
const { seams, warnings } = baseSeams({
read: fakeRead({
"mattstack.integrations": { forge: { host: "github.com", provider: "github" }, switchboard: { url: "https://sb.test" } },
}),
readLocalSecret: async () => null,
});
const relay = fakeRelayClient();

const result = await mintInvite(p, relay.client, { slug: SLUG, handle: "zaphod", now: NOW }, seams);

expect(result.code).toBeTruthy();
expect(p.calls.fetch).toHaveLength(0);
const { idHex, key } = decodeCode(result.code);
const pointer = await open(relay.createCalls[0]!.ciphertext, key, idHex);
expect(pointer.switchboard).toBeUndefined();
expect(warnings.some((w) => w.includes("board peering"))).toBe(true);
});

test("a throwing readLocalSecret stays inside optional peering: the mint still succeeds, warned", async () => {
const p = probesWithRemote(REMOTE);
const { seams, warnings } = baseSeams({
read: fakeRead({
"mattstack.integrations": { forge: { host: "github.com", provider: "github" }, switchboard: { url: "https://sb.test" } },
}),
readLocalSecret: async () => {
throw new Error("keychain sulking");
},
});
const relay = fakeRelayClient();

const result = await mintInvite(p, relay.client, { slug: SLUG, handle: "zaphod", now: NOW }, seams);

expect(result.code).toBeTruthy();
const { idHex, key } = decodeCode(result.code);
const pointer = await open(relay.createCalls[0]!.ciphertext, key, idHex);
expect(pointer.switchboard).toBeUndefined();
expect(warnings.some((w) => w.includes("board peering"))).toBe(true);
});

test("a failing switchboard register: the mint still succeeds without a sealed token, warned", async () => {
const p = fakeProbes({
home: HOME,
files: { [GIT_CONFIG_PATH]: gitConfigWithRemote(REMOTE) },
fetch: async () => ({ status: 500, body: "", headers: {} }),
});
const { seams, warnings } = baseSeams({
read: fakeRead({
"mattstack.integrations": { forge: { host: "github.com", provider: "github" }, switchboard: { url: "https://sb.test" } },
}),
readLocalSecret: async () => "admin-1",
});
const relay = fakeRelayClient();

const result = await mintInvite(p, relay.client, { slug: SLUG, handle: "zaphod", now: NOW }, seams);

expect(result.code).toBeTruthy();
const { idHex, key } = decodeCode(result.code);
const pointer = await open(relay.createCalls[0]!.ciphertext, key, idHex);
expect(pointer.switchboard).toBeUndefined();
expect(warnings.some((w) => w.includes("board peering"))).toBe(true);
});

test("derives forge host/provider from the remote when mattstack.integrations is unset", async () => {
const p = probesWithRemote(REMOTE);
const { seams } = baseSeams({ read: fakeRead({ "board.title": "Acme Team" }) });
Expand Down
Loading
Loading