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
4 changes: 3 additions & 1 deletion apps/host-selfhost/docs/rivet.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ expired; a scope shortfall requires broader grants rather than the same login.
Execution errors produce
bounded diagnostics without persisting arbitrary error content.

A rejected saved sign-in is retained and requires the service owner to reconnect Google Messages. The panel does not offer replacement pairing while a saved session exists. Temporary connection failures remain retryable.

## Validate with synthetic fixtures

Run scoped Vitest tests in `apps/host-selfhost` for `rivet-setup*.test.ts` and
Expand All @@ -50,7 +52,7 @@ production build.

The recorded browser scenario uses the native Rivet producer fixture. In a disposable
Rivet checkout, set `RIVET_SETUP_DEMO_DIR` to a new private directory under `/tmp`
and run `tsx scripts/setup-demo.ts`. Its `ready.json` provides the loopback endpoint.
and run `tsx scripts/setup-native-demo.ts`. Its `ready.json` provides the loopback endpoint.
Set `E2E_RIVET_SETUP_DIRECTORY`, `E2E_RIVET_SETUP_URL`, and
`EXECUTOR_RIVET_SETUP_CONFIG_FILE` to that fixture's directory, URL, and a new JSON
file inside the directory. Trust only the new fixture certificate by starting the
Expand Down
8 changes: 8 additions & 0 deletions apps/host-selfhost/src/rivet-setup-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
setupActive,
setupInstruction,
localSignInUrl,
googleSignInRejected,
} from "../web/rivet-setup-state";

const state = () =>
Expand All @@ -19,6 +20,13 @@ const state = () =>
google: null,
});
describe("Rivet setup state", () => {
it("retains a rejected saved session and directs recovery to its owner", () => {
const rejected = { ...state(), googleSessionPresent: true, error: "GM_REPAIR_REQUIRED" };
expect(googleSignInRejected(rejected)).toBe(true);
expect(setupInstruction(rejected)).toContain("Ask the service owner to reconnect");
expect(googleSignInRejected({ ...rejected, error: "GM_CONNECT_FAILED" })).toBe(false);
expect(googleSignInRejected({ ...rejected, googleAuthenticated: true })).toBe(false);
});
it("local Google sign-in stays bound to the separate setup listener and current one-time attempt", () => {
const uri = new URL("rivet-pair://google");
const id = "00000000-0000-4000-8000-000000000001";
Expand Down
19 changes: 13 additions & 6 deletions apps/host-selfhost/web/rivet-setup-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
decodeSetupStatus,
setupActive,
setupInstruction,
googleSignInRejected,
localSignInUrl,
type SetupStatus,
} from "./rivet-setup-state";
Expand Down Expand Up @@ -151,6 +152,7 @@ export function RivetSetupPanel(props: { readonly integration: string }) {
) : null;
const active = setupActive(status);
const handoff = localSignInUrl(status);
const rejected = googleSignInRejected(status);

return (
<section aria-label="Rivet setup" className="space-y-4 rounded-lg border border-border p-4">
Expand All @@ -164,9 +166,11 @@ export function RivetSetupPanel(props: { readonly integration: string }) {
<strong>
{status.googleAuthenticated
? "Authenticated"
: status.googleConfigured
? "Needs pairing or verification"
: "Not configured"}
: rejected
? "Sign-in expired"
: status.googleConfigured
? "Needs pairing or verification"
: "Not configured"}
</strong>
</span>
</div>
Expand All @@ -177,11 +181,14 @@ export function RivetSetupPanel(props: { readonly integration: string }) {
className="inline-block rounded-md border border-border px-3 py-2 text-sm"
href={handoff}
>
Sign in with Google passkey
Open Google sign-in on this computer
</a>
<p className="text-xs text-muted-foreground">
Opens the Rivet helper in a fresh local Chrome session. Keep your phone nearby with
Bluetooth enabled. This one-time link expires in ten minutes.
Requires the Rivet helper and Chromium installed on this computer. Use your phone's
passkey, then confirm the Messages emoji. This one-time link expires in ten minutes.
</p>
<p className="text-xs text-muted-foreground">
On a phone? Open this Accounts page on your computer to complete Google sign-in.
</p>
</div>
) : null}
Expand Down
9 changes: 8 additions & 1 deletion apps/host-selfhost/web/rivet-setup-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export const setupActive = (status: SetupStatus) =>
status.google !== null &&
["starting", "sign_in", "phone_confirmation", "verifying"].includes(status.google.state);

export const googleSignInRejected = (status: SetupStatus) =>
status.googleSessionPresent &&
!status.googleAuthenticated &&
status.error === "GM_REPAIR_REQUIRED";

export function localSignInUrl(status: SetupStatus): string | null {
if (
status.google?.method !== "local-browser" ||
Expand Down Expand Up @@ -84,7 +89,7 @@ export function setupInstruction(status: SetupStatus): string {
(status.google?.state === "sign_in" || status.google?.state === "starting") &&
status.google.method === "local-browser"
)
return "Open Google sign-in on a nearby computer. Use your phone's passkey in Chrome, then confirm the Messages emoji here.";
return "Open Google sign-in on a nearby computer. Use your phone's passkey in Chromium, then confirm the Messages emoji here.";
if (status.google?.state === "phone_confirmation")
return "Open Google Messages on your phone and confirm the matching emoji.";
if (status.google?.state === "sign_in" || status.google?.state === "starting")
Expand All @@ -93,6 +98,8 @@ export function setupInstruction(status: SetupStatus): string {
return "Verifying Google Messages authentication without fetching history.";
if (status.google?.state === "failed" && !status.googleSessionPresent)
return "Google Messages pairing did not finish. Retry when you are ready.";
if (googleSignInRejected(status))
return "Google rejected your saved sign-in. Ask the service owner to reconnect Google Messages. Your saved session and history are retained.";
if (!status.phoneConnected)
return "Connect your phone: scan the pairing QR in Rivet, review the address, then Save and connect.";
if (status.googleConfigured && !status.googleAuthenticated)
Expand Down
29 changes: 26 additions & 3 deletions e2e/selfhost/rivet-setup-ui.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { randomBytes } from "node:crypto";
import { writeFileSync, rmSync } from "node:fs";
import { writeFileSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { expect } from "@effect/vitest";
import { Effect } from "effect";
Expand Down Expand Up @@ -42,6 +42,13 @@ scenario(
});
yield* Effect.gen(function* () {
writeFileSync(join(directory, "owner-id"), session.user.id, { mode: 0o600 });
yield* Effect.promise(async () => {
const deadline = Date.now() + 10000;
while (!existsSync(join(directory, "native-ready.json"))) {
if (Date.now() > deadline) throw new Error("Native fixture did not become ready");
await new Promise((resolve) => setTimeout(resolve, 25));
}
});
writeFileSync(
configFile,
JSON.stringify({
Expand Down Expand Up @@ -85,7 +92,7 @@ scenario(
});
await step("Start local Google sign-in and cancel the attempt", async () => {
await page.getByRole("button", { name: "Connect Google Messages", exact: true }).click();
await page.getByRole("link", { name: "Sign in with Google passkey" }).waitFor();
await page.getByRole("link", { name: "Open Google sign-in on this computer" }).waitFor();
await page.getByRole("button", { name: "Cancel setup", exact: true }).click();
await page
.getByRole("button", { name: "Connect Google Messages", exact: true })
Expand All @@ -101,7 +108,7 @@ scenario(
.getByRole("button", { name: "Connect Google Messages", exact: true })
.click();

const link = page.getByRole("link", { name: "Sign in with Google passkey" });
const link = page.getByRole("link", { name: "Open Google sign-in on this computer" });
await link.waitFor();
const uri = new URL((await link.getAttribute("href"))!);
expect(uri.protocol).toBe("rivet-pair:");
Expand Down Expand Up @@ -151,6 +158,21 @@ scenario(
await page.getByRole("button", { name: "Check connections", exact: true }).click();
await page.getByText("Authenticated", { exact: true }).waitFor();
});
await step("Retain a rejected saved session at phone width", async () => {
await page.setViewportSize({ width: 390, height: 844 });
writeFileSync(join(directory, "gmessages", "reject.synthetic"), "", { mode: 0o600 });
await page.getByRole("button", { name: "Check connections", exact: true }).click();
await page.getByText("Sign-in expired", { exact: true }).waitFor();
await page.getByText(/Ask the service owner to reconnect/).waitFor();
expect(
await page
.getByRole("button", { name: "Connect Google Messages", exact: true })
.count(),
).toBe(0);
expect(
await page.getByRole("link", { name: "Open Google sign-in on this computer" }).count(),
).toBe(0);
});
});
}).pipe(
Effect.ensuring(
Expand All @@ -161,6 +183,7 @@ scenario(
join(directory, "owner-id"),
join(directory, "connect-phone.synthetic"),
join(directory, "gmessages", "confirm.synthetic"),
join(directory, "gmessages", "reject.synthetic"),
])
rmSync(file, { force: true });
writeFileSync(join(directory, "setup.token"), "b".repeat(64), { mode: 0o600 });
Expand Down
Loading