diff --git a/.env.example b/.env.example index cc1331259..6125c0a43 100644 --- a/.env.example +++ b/.env.example @@ -850,6 +850,14 @@ ENTERPRISE_SITE_URL=http://localhost:3000 ENTERPRISE_REDIRECT_ALLOW_LIST= # Default true: regulated deployments use admin-provisioned users only. # Set to false to re-enable self-serve signup (e.g. internal dev environment). +# This one variable now drives three things that used to be able to +# disagree: GoTrue's own flag, the gateway refusal in +# deploy/docker/Caddyfile.supabase, and whether web-console renders the +# sign-up form at all. While it is true, the console says accounts are +# created by invitation instead of shipping a form the gateway 404s +# (issue #1328). Re-enabling self-serve signup still needs the Caddy +# refusal lifted as well, and the tenant provisioning path in +# .wolf/decisions.md D-023. ENTERPRISE_DISABLE_SIGNUP=true ENTERPRISE_MAILER_AUTOCONFIRM=true diff --git a/apps/web-console/__tests__/sign-in-next-redirect.test.tsx b/apps/web-console/__tests__/sign-in-next-redirect.test.tsx index 96f6a1368..c95759d93 100644 --- a/apps/web-console/__tests__/sign-in-next-redirect.test.tsx +++ b/apps/web-console/__tests__/sign-in-next-redirect.test.tsx @@ -45,6 +45,11 @@ describe("app/auth/sign-in/page.tsx next-target redirect", () => { vi.clearAllMocks(); mockSignInWithPassword.mockResolvedValue({ error: null }); window.history.pushState({}, "", "/auth/sign-in"); + // The cross-link cases below are the self-serve-enabled shape. The + // flag fails closed (lib/auth/self-serve.ts), so it has to be set + // explicitly here or every link assertion would be testing the + // invitation-only footer instead (issue #1328). + process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP = "false"; }); async function submitForm() { @@ -290,3 +295,26 @@ describe("app/auth/sign-in/page.tsx next-target redirect", () => { expect(mockNavigate).not.toHaveBeenCalled(); }); }); + +describe("app/auth/sign-in/page.tsx sign-up cross-link gating", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.history.pushState({}, "", "/auth/sign-in"); + }); + + it("offers the sign-up link when this deployment accepts self-serve signup", async () => { + process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP = "false"; + render(); + const link = await screen.findByRole("link", { name: /create one/i }); + expect(link.getAttribute("href")).toBe("/auth/sign-up"); + }); + + it("does not link to a sign-up page this deployment refuses (issue #1328)", () => { + process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP = "true"; + render(); + expect(screen.queryByRole("link", { name: /create one/i })).toBeNull(); + expect( + screen.getByText(/accounts on this deployment are created by invitation/i), + ).toBeTruthy(); + }); +}); diff --git a/apps/web-console/__tests__/sign-up-next-redirect.test.tsx b/apps/web-console/__tests__/sign-up-next-redirect.test.tsx index aa8221fdb..978241e95 100644 --- a/apps/web-console/__tests__/sign-up-next-redirect.test.tsx +++ b/apps/web-console/__tests__/sign-up-next-redirect.test.tsx @@ -32,6 +32,10 @@ describe("app/auth/sign-up/page.tsx", () => { mockSignUp.mockResolvedValue({ error: null }); window.history.pushState({}, "", "/auth/sign-up"); process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000"; + // Every case in this describe exercises the form, which only renders + // where the deployment accepts self-serve signup. The flag fails + // closed, so it is set explicitly (issue #1328). + process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP = "false"; vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }), @@ -124,3 +128,79 @@ describe("app/auth/sign-up/page.tsx", () => { ); }); }); + +/** + * Issue #1328: this deployment refuses POST /auth/v1/signup at the gateway + * and at the GoTrue flag, so the console must say accounts are created by + * invitation instead of shipping a form that cannot complete, and must report + * a refusal that does reach the endpoint as a refusal rather than an outage. + */ +describe("app/auth/sign-up/page.tsx self-serve gating", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSignUp.mockResolvedValue({ error: null }); + window.history.pushState({}, "", "/auth/sign-up"); + process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000"; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }), + ); + }); + + it("renders no sign-up form when the deployment refuses self-serve signup", () => { + process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP = "true"; + render(); + expect(screen.queryByLabelText(/^email/i)).toBeNull(); + expect(screen.queryByLabelText(/^password/i)).toBeNull(); + expect(screen.queryByRole("button", { name: /create account/i })).toBeNull(); + }); + + it("says accounts are created by invitation, and points at sign-in", async () => { + process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP = "true"; + render(); + expect( + screen.getByRole("heading", { name: /accounts are created by invitation/i }), + ).toBeTruthy(); + expect( + screen.getByText(/sign-up is not available on this deployment/i), + ).toBeTruthy(); + const link = await screen.findByRole("link", { name: /go to sign in/i }); + expect(link.getAttribute("href")).toBe("/auth/sign-in"); + }); + + it("carries an inbound next param into the sign-in link on the gated page", async () => { + process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP = "true"; + window.history.pushState( + {}, + "", + `/auth/sign-up?next=${encodeURIComponent( + "/oauth/consent?authorization_id=auth-req-123", + )}`, + ); + render(); + const link = await screen.findByRole("link", { name: /go to sign in/i }); + expect(link.getAttribute("href")).toBe( + `/auth/sign-in?next=${encodeURIComponent( + "/oauth/consent?authorization_id=auth-req-123", + )}`, + ); + }); + + it("reports a gateway refusal as a refusal, not as an outage on our end", async () => { + process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP = "false"; + mockSignUp.mockResolvedValue({ + error: { name: "AuthUnknownError", message: "Unexpected end of JSON input" }, + }); + render(); + fireEvent.change(screen.getByLabelText(/^email/i), { + target: { value: "user@example.com" }, + }); + fireEvent.change(screen.getByLabelText(/^password/i), { + target: { value: "hunter2hunter2" }, + }); + fireEvent.click(screen.getByRole("button", { name: /create account/i })); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toMatch(/sign-up is not available on this deployment/i); + expect(alert.textContent).not.toMatch(/something went wrong on our end/i); + }); +}); diff --git a/apps/web-console/app/auth/sign-in/page.tsx b/apps/web-console/app/auth/sign-in/page.tsx index 44ad2883e..02e489d0d 100644 --- a/apps/web-console/app/auth/sign-in/page.tsx +++ b/apps/web-console/app/auth/sign-in/page.tsx @@ -11,6 +11,7 @@ import { Field, Input } from "@/components/ui/input"; import { toUserFacingAuthMessage } from "@/lib/auth/auth-error"; import { appendNextParam, resolveNextTarget } from "@/lib/auth/next-target"; import { navigate } from "@/lib/navigate"; +import { isSelfServeSignupEnabled } from "@/lib/auth/self-serve"; export default function SignInPage() { const supabase = createClient(); @@ -93,15 +94,22 @@ export default function SignInPage() { : "Manage API keys, credits, and usage analytics for your workspace." } footer={ - <> - Don’t have an account?{" "} - - Create one - - + isSelfServeSignupEnabled() ? ( + <> + Don’t have an account?{" "} + + Create one + + + ) : ( + // Issue #1328: every deployment this repo ships refuses self-serve + // signup, so a link to a page that cannot complete is a promise the + // gateway breaks. + <>Accounts on this deployment are created by invitation. + ) } >
diff --git a/apps/web-console/app/auth/sign-up/page.tsx b/apps/web-console/app/auth/sign-up/page.tsx index 22c8baf50..2c2b2d223 100644 --- a/apps/web-console/app/auth/sign-up/page.tsx +++ b/apps/web-console/app/auth/sign-up/page.tsx @@ -2,13 +2,17 @@ import { createClient } from "@/lib/supabase/browser"; import { useState, useRef, useEffect, type FormEvent } from "react"; -import { Mail } from "lucide-react"; +import { Mail, ShieldCheck } from "lucide-react"; import { AuthShell } from "@/components/app-shell/auth-shell"; -import { Button } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { Field, Input } from "@/components/ui/input"; -import { toUserFacingAuthMessage } from "@/lib/auth/auth-error"; +import { + SIGN_UP_UNAVAILABLE_MESSAGE, + toUserFacingSignUpMessage, +} from "@/lib/auth/auth-error"; import { appendNextParam } from "@/lib/auth/next-target"; +import { isSelfServeSignupEnabled } from "@/lib/auth/self-serve"; // Minimal ambient type for the Cloudflare Turnstile widget. The full SDK type // is not installed as a dev dependency; we only need the render/remove surface. @@ -35,7 +39,85 @@ const TURNSTILE_SITE_KEY = ? process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "" : ""; +/** + * Reads an inbound next= target post-mount (window is unavailable during SSR). + * + * Carries an OAuth consent round-trip started on chat through to the "Sign in" + * cross-link and into the verification-email redirect, so it survives the + * signup, confirm-email, callback chain instead of dropping the user onto the + * plain console dashboard (live UI/UX pass, 2026-07-26). Shared by both + * branches below: a visitor who lands here mid-consent needs the way back + * whether or not signup is open. + */ +function useNextParam(): string | null { + const [nextParam, setNextParam] = useState(null); + + useEffect(() => { + const search = new URLSearchParams(window.location.search); + setNextParam(search.get("next")); + }, []); + + return nextParam; +} + +/** + * Route gate for issue #1328. + * + * Every deployment this repo ships refuses POST /auth/v1/signup, at the + * gateway and at the GoTrue flag both, so the form below could never be + * completed there and the gateway 404 read as an outage. The page is not + * deleted, because the refusal is a deployment posture rather than a property + * of this console: a deployment that opens self-serve signup gets the form + * back by setting one variable, with no code change. + */ export default function SignUpPage() { + return isSelfServeSignupEnabled() ? : ; +} + +function SignUpUnavailable() { + const nextParam = useNextParam(); + const signInHref = appendNextParam("/auth/sign-in", nextParam); + + return ( + + Already have an account?{" "} + + Sign in + + + } + > +
+
+ + {SIGN_UP_UNAVAILABLE_MESSAGE} +
+ + Go to sign in + +
+
+ ); +} + +function SignUpForm() { const supabase = createClient(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -43,13 +125,7 @@ export default function SignUpPage() { const [success, setSuccess] = useState(false); const [loading, setLoading] = useState(false); const [captchaToken, setCaptchaToken] = useState(null); - // Populated post-mount (window is unavailable during SSR). Carries an - // inbound ?next= target (e.g. an OAuth consent round-trip started on chat) - // through to the "Sign in" cross-link and into the verification-email - // redirect, so it survives the signup -> confirm-email -> callback chain - // instead of dropping the user onto the plain console dashboard (issue - // found in live UI/UX pass, 2026-07-26). - const [nextParam, setNextParam] = useState(null); + const nextParam = useNextParam(); // Same pre-hydration submit hazard as /auth/sign-in (see the comment there): // no form `action` and no input `name` attributes mean a submit fired before // onSubmit is attached does a native GET that wipes the query string, taking @@ -61,7 +137,6 @@ export default function SignUpPage() { const widgetIdRef = useRef(null); useEffect(() => { - setNextParam(new URLSearchParams(window.location.search).get("next")); setHydrated(true); }, []); @@ -163,7 +238,10 @@ export default function SignUpPage() { }); if (signUpError) { - setError(toUserFacingAuthMessage(signUpError.message)); + // Not toUserFacingAuthMessage: a deployment that refuses signup at + // the gateway answers with a bare 404, and the generic branch + // reported that policy as an outage on our end (issue #1328). + setError(toUserFacingSignUpMessage(signUpError)); return; } diff --git a/apps/web-console/app/console/api-keys/page.tsx b/apps/web-console/app/console/api-keys/page.tsx index 640146e79..61561074b 100644 --- a/apps/web-console/app/console/api-keys/page.tsx +++ b/apps/web-console/app/console/api-keys/page.tsx @@ -45,7 +45,7 @@ export default async function ApiKeysPage() {
diff --git a/apps/web-console/app/console/page.tsx b/apps/web-console/app/console/page.tsx index e3509d37d..c16e58074 100644 --- a/apps/web-console/app/console/page.tsx +++ b/apps/web-console/app/console/page.tsx @@ -27,11 +27,8 @@ import { CardTitle, } from "@/components/ui/card"; import { PageHeader } from "@/components/ui/page-header"; -import { - formatCredits, - formatNumber, - formatTokens, -} from "@/lib/format/credits"; +import { formatNumber, formatTokens } from "@/lib/format/credits"; +import { CreditBalance } from "@/components/billing/credit-balance"; /* * Next steps shown under the metric row. Every entry is a real console route, @@ -171,24 +168,7 @@ export default async function ConsolePage() { {balance ? ( -
-

- {formatCredits(balance.available_credits)} -

-

- Posted{" "} - - {formatCredits(balance.posted_credits)} - {" "} - · Reserved{" "} - - {formatCredits(balance.reserved_credits)} - -

-
+ ) : (

Verify your email to view balances. diff --git a/apps/web-console/components/api-keys/api-key-list.tsx b/apps/web-console/components/api-keys/api-key-list.tsx index c57848e08..d80233a82 100644 --- a/apps/web-console/components/api-keys/api-key-list.tsx +++ b/apps/web-console/components/api-keys/api-key-list.tsx @@ -1,8 +1,5 @@ -import Link from "next/link"; - import type { ApiKey } from "@/lib/control-plane/client"; import { Badge } from "@/components/ui/badge"; -import { buttonVariants } from "@/components/ui/button"; import { DataTable, type Column } from "@/components/ui/data-table"; import { formatShortDate } from "@/lib/format/credits"; import { formatUsdFromCredits } from "@/lib/format/model-pricing"; @@ -117,12 +114,6 @@ export function ApiKeyList({ keys, canManage }: ApiKeyListProps) { cell: (row) => row.status === "active" ? (

- - Rotate -
) : ( diff --git a/apps/web-console/components/billing/billing-overview.tsx b/apps/web-console/components/billing/billing-overview.tsx index 1f5f1c2a5..86dd6e375 100644 --- a/apps/web-console/components/billing/billing-overview.tsx +++ b/apps/web-console/components/billing/billing-overview.tsx @@ -12,6 +12,7 @@ import { import { DataTable, type Column } from "@/components/ui/data-table"; import { EmptyState } from "@/components/ui/empty-state"; import { formatCredits, formatShortDate } from "@/lib/format/credits"; +import { CreditBalance } from "@/components/billing/credit-balance"; interface BillingOverviewProps { balance: BalanceSummary; @@ -90,25 +91,7 @@ export function BillingOverview({ -
-

- {formatCredits(balance.available_credits)} -

-

- Posted{" "} - - {formatCredits(balance.posted_credits)} - {" "} - · Reserved{" "} - - {formatCredits(balance.reserved_credits)} - {" "} - credits -

-
+
+

+ {formatUsdBalanceFromCredits(balance.available_credits)} +

+

+ Posted{" "} + + {formatUsdBalanceFromCredits(balance.posted_credits)} + {" "} + · Reserved{" "} + + {formatUsdBalanceFromCredits(balance.reserved_credits)} + +

+

+ {formatCredits(balance.available_credits)} credits, at{" "} + {formatCredits(CREDITS_PER_USD)} credits per $1.00 +

+
+ ); +} diff --git a/apps/web-console/lib/auth/auth-error.test.ts b/apps/web-console/lib/auth/auth-error.test.ts index 9a366e624..13727da35 100644 --- a/apps/web-console/lib/auth/auth-error.test.ts +++ b/apps/web-console/lib/auth/auth-error.test.ts @@ -10,7 +10,11 @@ * allow-list is the safer shape. */ import { describe, expect, it } from "vitest"; -import { toUserFacingAuthMessage } from "./auth-error"; +import { + SIGN_UP_UNAVAILABLE_MESSAGE, + toUserFacingAuthMessage, + toUserFacingSignUpMessage, +} from "./auth-error"; // Fixed clock so the support reference is deterministic. const AT = new Date("2026-07-27T14:03:22.512Z"); @@ -157,3 +161,85 @@ describe("toUserFacingAuthMessage", () => { }); }); }); + +/** + * Guards the sign-up half of the same boundary (issue #1328). The live + * failure: a deployment that refuses account creation at the gateway answered + * with a bare 404, auth-js surfaced it as an AuthUnknownError carrying a JSON + * parse message and no status, and the allow-list correctly withheld it, so a + * stated policy reached the visitor as an outage on our end. + */ +describe("toUserFacingSignUpMessage", () => { + describe("reports a refusal as a refusal", () => { + it("maps a bare 404 from the auth origin", () => { + expect( + toUserFacingSignUpMessage({ message: "Not Found", status: 404 }, AT), + ).toBe(SIGN_UP_UNAVAILABLE_MESSAGE); + }); + + it("maps a 403 from the auth origin", () => { + expect( + toUserFacingSignUpMessage({ message: "Forbidden", status: 403 }, AT), + ).toBe(SIGN_UP_UNAVAILABLE_MESSAGE); + }); + + it("maps the empty-body 404 shape auth-js reports with no status", () => { + expect( + toUserFacingSignUpMessage( + { + name: "AuthUnknownError", + message: "Unexpected end of JSON input", + }, + AT, + ), + ).toBe(SIGN_UP_UNAVAILABLE_MESSAGE); + }); + + it("maps GoTrue own copy when the signup flag is off", () => { + expect( + toUserFacingSignUpMessage( + { message: "Signups not allowed for this instance", status: 422 }, + AT, + ), + ).toBe(SIGN_UP_UNAVAILABLE_MESSAGE); + }); + }); + + describe("leaves the rest of the boundary alone", () => { + it("still shows an allow-listed message verbatim", () => { + expect( + toUserFacingSignUpMessage({ message: "User already registered" }, AT), + ).toBe("User already registered"); + }); + + it("still withholds an unrecognized message behind the support reference", () => { + expect( + toUserFacingSignUpMessage( + { message: "pg-functions://postgres/public/custom_access_token_hook", status: 500 }, + AT, + ), + ).toBe(GENERIC); + }); + + it("withholds an outage rather than calling it a policy", () => { + // The shape auth-js produces for 500, 502, 503, 504 and for a dead + // transport, measured on 2026-08-29 (see the comment on isSignUpRefusal). + expect( + toUserFacingSignUpMessage( + { name: "AuthRetryableFetchError", message: "HTTP 502", status: 502 }, + AT, + ), + ).toBe(GENERIC); + expect( + toUserFacingSignUpMessage( + { name: "AuthRetryableFetchError", message: "fetch failed", status: 0 }, + AT, + ), + ).toBe(GENERIC); + }); + + it("falls back to the generic message when there is no error object", () => { + expect(toUserFacingSignUpMessage(null, AT)).toBe(GENERIC); + }); + }); +}); diff --git a/apps/web-console/lib/auth/auth-error.ts b/apps/web-console/lib/auth/auth-error.ts index a66f6cadc..1de2c792a 100644 --- a/apps/web-console/lib/auth/auth-error.ts +++ b/apps/web-console/lib/auth/auth-error.ts @@ -114,3 +114,91 @@ export function toUserFacingAuthMessage( return generic; } + +/** + * Copy for a sign-up that the deployment refuses as policy rather than fails. + * + * True of both refusal shapes below, and it never blames an outage: a visitor + * reading this knows the account exists to be created by someone else, not + * that Hive is broken. + */ +export const SIGN_UP_UNAVAILABLE_MESSAGE = + "Sign-up is not available on this deployment. If you were invited, sign in instead. Otherwise ask a workspace administrator to create your account."; + +/** + * The subset of an auth-js error this mapper reads. Declared structurally so a + * caller can pass an AuthError straight through without a cast, and so a test + * can build the shape without constructing an auth-js class. + */ +interface AuthErrorLike { + message?: string | null; + status?: number | null; + name?: string | null; +} + +/** + * True when the auth origin refused account creation as policy. + * + * Three shapes, all observed or reachable on this stack: + * + * - A 404 or 403 from the auth origin. deploy/docker/Caddyfile.supabase + * answers a bare 404 for /auth/v1/signup on the public listener, and that + * is a stated policy, not a fault. + * - GoTrue's own copy when GOTRUE_DISABLE_SIGNUP is set. That string is + * already allow-listed above and would render verbatim; mapping it here + * instead says the same thing in this console's words, with the next step + * the GoTrue string omits. + * - AuthUnknownError, which auth-js raises when the response body is not + * JSON at all. The Caddy refusal has content-length 0, so the JSON parse + * throws and the error arrives carrying no status to branch on. This is + * the exact shape reported live in issue #1328, and it is why a status + * check alone is not enough. + * + * The third rule does not swallow an outage, which is the obvious worry. + * Measured against @supabase/auth-js 2.x, driving supabase.auth.signUp with a + * stubbed fetch, on 2026-08-29: + * + * 500, 502, 503, 504 with an HTML body -> AuthRetryableFetchError, status + * preserved. A dead transport (DNS failure, offline) -> the same class, + * status 0. + * 404 or 403 with an empty or non-JSON body -> AuthUnknownError, no status. + * + * Every shape auth-js recognises as an outage therefore lands in a different + * class than the one below, and reaches the generic branch with its support + * reference. AuthUnknownError means the auth origin answered a non-5xx status + * with something that is not an API response at all, which on this stack is a + * gateway refusal. + */ +function isSignUpRefusal(error: AuthErrorLike): boolean { + if (error.status === 404 || error.status === 403) { + return true; + } + if (error.name === "AuthUnknownError") { + return true; + } + const message = (error.message ?? "").trim(); + return /^(signups? not allowed|email signups are disabled)/i.test(message); +} + +/** + * Same sanitization boundary as toUserFacingAuthMessage, with one extra rule + * ahead of it: a deliberate refusal is reported as a refusal. + * + * Everything the allow-list already handles keeps its existing behaviour, and + * an unrecognized message still degrades to the generic copy plus a support + * reference. Only the refusal shapes above are pulled out of that default, + * because reporting a policy as an outage sends the user to support for + * something support cannot change. + */ +export function toUserFacingSignUpMessage( + error: AuthErrorLike | null | undefined, + now: Date = new Date(), +): string { + if (!error) { + return toUserFacingAuthMessage(null, now); + } + if (isSignUpRefusal(error)) { + return SIGN_UP_UNAVAILABLE_MESSAGE; + } + return toUserFacingAuthMessage(error.message, now); +} diff --git a/apps/web-console/lib/auth/self-serve.test.ts b/apps/web-console/lib/auth/self-serve.test.ts new file mode 100644 index 000000000..fae74ba37 --- /dev/null +++ b/apps/web-console/lib/auth/self-serve.test.ts @@ -0,0 +1,59 @@ +/** + * Guards the deployment flag behind the sign-up route (issue #1328). + * + * The important half is the default: unset and empty must read as disabled. + * An unset build arg reaches next build as an empty string, so a flag that + * treats empty as "signup works" would put the form back on every deployment + * that never sets the variable, which is the state this exists to stop. + */ +import { afterEach, describe, expect, it } from "vitest"; + +import { isSelfServeSignupEnabled } from "./self-serve"; + +const KEY = "NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP"; +const original = process.env[KEY]; + +afterEach(() => { + if (original === undefined) { + delete process.env[KEY]; + } else { + process.env[KEY] = original; + } +}); + +describe("isSelfServeSignupEnabled", () => { + it("is disabled when the variable is unset", () => { + delete process.env[KEY]; + expect(isSelfServeSignupEnabled()).toBe(false); + }); + + it("is disabled when the variable is an empty string", () => { + process.env[KEY] = ""; + expect(isSelfServeSignupEnabled()).toBe(false); + }); + + it("is disabled when the deployment says signup is disabled", () => { + process.env[KEY] = "true"; + expect(isSelfServeSignupEnabled()).toBe(false); + }); + + it("is enabled when the deployment says so explicitly", () => { + process.env[KEY] = "false"; + expect(isSelfServeSignupEnabled()).toBe(true); + }); + + it("accepts 0 as the numeric spelling of false", () => { + process.env[KEY] = "0"; + expect(isSelfServeSignupEnabled()).toBe(true); + }); + + it("is case and whitespace insensitive, since compose passes the value through verbatim", () => { + process.env[KEY] = " False "; + expect(isSelfServeSignupEnabled()).toBe(true); + }); + + it("treats an unrecognized value as disabled rather than guessing", () => { + process.env[KEY] = "maybe"; + expect(isSelfServeSignupEnabled()).toBe(false); + }); +}); diff --git a/apps/web-console/lib/auth/self-serve.ts b/apps/web-console/lib/auth/self-serve.ts new file mode 100644 index 000000000..0d3bece85 --- /dev/null +++ b/apps/web-console/lib/auth/self-serve.ts @@ -0,0 +1,28 @@ +/** + * Whether this deployment accepts self-serve account creation. + * + * Two locks already refuse POST /auth/v1/signup on every deployment this repo + * ships: deploy/docker/Caddyfile.supabase answers 404 for that path on the + * public listener, and GoTrue runs with GOTRUE_DISABLE_SIGNUP set from + * ENTERPRISE_DISABLE_SIGNUP, which defaults to true. The console had no third + * source of truth, so it kept shipping a sign-up page that cannot complete, + * and the gateway's 404 surfaced as "Something went wrong on our end" (issue + * #1328). This flag is that third source: it does not change the policy, it + * lets the UI state it. + * + * Driven by the same variable that drives the GoTrue flag, in the same + * polarity, so re-enabling signup stays one value in one place rather than two + * that can disagree. Wired as a build arg in deploy/docker/docker-compose.yml, + * because next build inlines NEXT_PUBLIC_* into the client bundle. + * + * Fails closed on purpose. Unset and empty both mean disabled: an unset build + * arg reaches Next.js as an empty string, and reading that as "signup works" + * is the state this exists to stop. Self-serve is enabled only when a + * deployment says so explicitly, with false or 0. + */ +export function isSelfServeSignupEnabled(): boolean { + const raw = (process.env.NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP ?? "") + .trim() + .toLowerCase(); + return raw === "false" || raw === "0"; +} diff --git a/apps/web-console/lib/control-plane/client.ts b/apps/web-console/lib/control-plane/client.ts index d463764e4..bc55edd12 100644 --- a/apps/web-console/lib/control-plane/client.ts +++ b/apps/web-console/lib/control-plane/client.ts @@ -2041,41 +2041,6 @@ export async function revokeApiKey(keyId: string): Promise { return key; } -export async function rotateApiKey( - keyId: string, - nickname: string, - expiresAt?: string -): Promise { - const { baseUrl, headers } = await getRequestContext(); - const body: { nickname: string; expires_at?: string } = { nickname }; - if (expiresAt) { - body.expires_at = expiresAt; - } - - const response = await fetch(`${baseUrl}/api/v1/accounts/current/api-keys/${keyId}/rotate`, { - method: "POST", - headers, - cache: "no-store", - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(await readResponseError(response, "Failed to rotate API key")); - } - - const payload = parseJsonValue(await readResponseText(response)); - if (!isJsonObject(payload)) { - throw new Error("Failed to parse API key response"); - } - - const key = decodeApiKey(payload); - if (!key) { - throw new Error("Failed to parse API key response"); - } - - return key; -} - // UpdateApiKeyBudgetInput is the per-key credit cap the New Key modal and the // limits page write via POST .../policy. budgetKind is constrained to what // api_key_policies.budget_kind actually supports server-side ("none" clears diff --git a/apps/web-console/lib/format/credits.ts b/apps/web-console/lib/format/credits.ts index 341458531..529427cd6 100644 --- a/apps/web-console/lib/format/credits.ts +++ b/apps/web-console/lib/format/credits.ts @@ -127,3 +127,63 @@ export function formatPercent( maximumFractionDigits: 1, }).format(value); } + +/** + * One US dollar is one billion Hive credits (.wolf/decisions.md D-046, + * migration 20260823_40_credit_unit_rescale_billion.sql). + * + * Defined here rather than in model-pricing.ts, which is where it used to + * live: balances need it too, and model-pricing already imports this module, + * so the reverse import would be a cycle. model-pricing re-exports it, so + * existing callers are unaffected. + */ +export const CREDITS_PER_USD = 1_000_000_000; + +/** + * Format a credit balance as the US dollars a customer reasons in. + * + * Truncated toward zero, never rounded, which is the one behavioural + * difference from formatUsdFromCredits in model-pricing.ts. A catalog rate is + * a published price and rounds to the nearest cent; an available balance is + * spendable money, and rounding 99,996,364,207 credits up to "$100.00" tells + * a customer they hold more than they can spend. + * + * Precision follows the same rule as the pricing formatter (three significant + * digits, at least two decimals, at most the nine that one credit occupies), + * so a small real balance renders as "$0.000662" rather than a "$0.00" that + * reads as empty. + * + * Locale is pinned to en-US for the same reason model-pricing pins it: the + * unit is US dollars on every surface, and bn-BD renders the same amount as + * "US$0.20", which reads as a second currency. + */ +export function formatUsdBalanceFromCredits(credits: number): string { + // Zero is a real, readable balance. A non-finite value is not: it can only + // come from a decode that failed, and rendering that as "$0.00" would assert + // an empty wallet where nothing was read at all. Same policy, and the same + // em dash, as formatPercent above. + if (!Number.isFinite(credits)) { + return "—"; + } + if (credits === 0) { + return "$0.00"; + } + const usd = credits / CREDITS_PER_USD; + const magnitude = Math.floor(Math.log10(Math.abs(usd))); + const digits = Math.min(9, Math.max(2, 2 - magnitude)); + // Truncate in credits rather than in dollars. The dollar product is a float: + // 8,290,000,000 credits is 8.29 dollars, 8.29 times 100 is + // 828.9999999999999, and truncating that prints $8.28, understating a real + // balance by a cent. CREDITS_PER_USD is a power of ten and digits never + // exceeds nine, so creditsPerStep is an exact integer and this is exact for + // every integer balance. + const creditsPerStep = CREDITS_PER_USD / 10 ** digits; + const truncated = + (Math.trunc(credits / creditsPerStep) * creditsPerStep) / CREDITS_PER_USD; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: digits, + }).format(truncated); +} diff --git a/apps/web-console/lib/format/format.test.ts b/apps/web-console/lib/format/format.test.ts index 770c02231..07a89d63d 100644 --- a/apps/web-console/lib/format/format.test.ts +++ b/apps/web-console/lib/format/format.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; -import { formatCredits, formatLatencyMs, formatShortDate } from "./credits"; +import { + CREDITS_PER_USD, + formatCredits, + formatLatencyMs, + formatShortDate, + formatUsdBalanceFromCredits, +} from "./credits"; import { formatDateTime, formatLongDate } from "./datetime"; import { formatCurrency, formatTakaSubunits } from "./money"; import { intlTag, resolveLocale } from "@/lib/i18n/locales"; @@ -118,3 +124,43 @@ describe("money formatting", () => { expect(formatTakaSubunits("abc")).toBe("৳0.00"); }); }); + +/** + * Issue #1332: the dashboard printed the balance as a bare integer while the + * API keys table printed the same quantity in dollars. The console settled on + * dollars, and a balance is spendable money, so this formatter truncates + * where the pricing formatter rounds. + */ +describe("formatUsdBalanceFromCredits", () => { + it("renders one dollar per billion credits", () => { + expect(formatUsdBalanceFromCredits(CREDITS_PER_USD)).toBe("$1.00"); + }); + + it("never rounds a balance up to money the customer does not hold", () => { + expect(formatUsdBalanceFromCredits(99_996_364_207)).toBe("$99.99"); + }); + + it("truncates in credits, so a float product cannot shave a cent off a real balance", () => { + // 8.29 times 100 is 828.9999999999999 in IEEE 754, so truncating in + // dollars would print $8.28 here. + expect(formatUsdBalanceFromCredits(8_290_000_000)).toBe("$8.29"); + expect(formatUsdBalanceFromCredits(1_230_000_000)).toBe("$1.23"); + }); + + it("keeps a sub-cent balance visible instead of printing zero", () => { + expect(formatUsdBalanceFromCredits(662_000)).toBe("$0.000662"); + }); + + it("renders a single credit, the smallest unit there is", () => { + expect(formatUsdBalanceFromCredits(1)).toBe("$0.000000001"); + }); + + it("renders an empty balance as zero dollars, not as an absence", () => { + expect(formatUsdBalanceFromCredits(0)).toBe("$0.00"); + }); + + it("renders a non-finite value as an absence, never as an empty wallet", () => { + expect(formatUsdBalanceFromCredits(Number.NaN)).toBe("—"); + expect(formatUsdBalanceFromCredits(Number.POSITIVE_INFINITY)).toBe("—"); + }); +}); diff --git a/apps/web-console/lib/format/model-pricing.ts b/apps/web-console/lib/format/model-pricing.ts index cad7951b0..dee481f53 100644 --- a/apps/web-console/lib/format/model-pricing.ts +++ b/apps/web-console/lib/format/model-pricing.ts @@ -1,13 +1,10 @@ -import { formatCredits } from "@/lib/format/credits"; +import { CREDITS_PER_USD, formatCredits } from "@/lib/format/credits"; -/** - * One US dollar is one billion Hive credits (`.wolf/decisions.md` D-046, - * migration `20260823_40_credit_unit_rescale_billion.sql`). Every catalog - * price column stores an integer credit rate per one million metered tokens, - * so dividing by this constant yields the dollars-per-million figure a - * customer recognises. - */ -export const CREDITS_PER_USD = 1_000_000_000; +// Re-exported for the callers that have always imported the credit unit +// from this module. It now lives in lib/format/credits.ts, next to the +// balance formatter that needs it too, because importing this module from +// there would be a cycle. +export { CREDITS_PER_USD }; /** * Which unit a price cell renders in. Both units share one absence policy, diff --git a/apps/web-console/tests/e2e/demo-walkthrough.mjs b/apps/web-console/tests/e2e/demo-walkthrough.mjs index 24c1b7cc6..6af439b50 100644 --- a/apps/web-console/tests/e2e/demo-walkthrough.mjs +++ b/apps/web-console/tests/e2e/demo-walkthrough.mjs @@ -784,6 +784,20 @@ async function createOwnerAccount(browser) { .locator('button[type="submit"]:not([disabled])') .waitFor({ state: "visible", timeout: 10000 }) .catch(() => {}); + // A deployment that refuses self-serve signup now says so on this page + // instead of shipping a form the gateway 404s (issue #1328). That is a + // posture, not a defect: report it and skip, rather than timing out on + // fields that are deliberately absent. + const invitationOnly = await page + .getByRole("heading", { name: "Accounts are created by invitation" }) + .isVisible() + .catch(() => false); + if (invitationOnly) { + entry.verdict = "PASS"; + entry.observed = "self-serve signup is disabled on this deployment, and the console says accounts are created by invitation instead of failing"; + entry.screenshots.push(await shot(page, "owner-01-signup-by-invitation")); + return null; + } await page.locator("#email").fill(ownerEmail); await page.locator("#password").fill(ownerPassword); // Give the Turnstile widget time to resolve in non-interactive managed diff --git a/apps/web-console/tests/e2e/unauth.spec.ts b/apps/web-console/tests/e2e/unauth.spec.ts index 0b55f8332..45bbb3da0 100644 --- a/apps/web-console/tests/e2e/unauth.spec.ts +++ b/apps/web-console/tests/e2e/unauth.spec.ts @@ -58,41 +58,32 @@ test("sign-in form renders and blocks empty or malformed submissions client-side expect((await readValidationMessage(email)).length).toBeGreaterThan(0); }); -test("sign-up form renders and blocks empty or malformed submissions client-side", async ({ +test("sign-up says accounts are created by invitation when self-serve is disabled", async ({ page, }) => { + // The stack this runs against builds web-console with no + // NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP set, which the console reads as + // disabled (lib/auth/self-serve.ts fails closed), matching every deployment + // this repo ships: Caddyfile.supabase 404s /auth/v1/signup and GoTrue runs + // with signup off. Issue #1328 was the console shipping a form anyway and + // reporting the refusal as a server error. await page.goto("/auth/sign-up"); await expect( - page.getByRole("heading", { level: 1, name: "Create your Hive account" }) + page.getByRole("heading", { level: 1, name: "Accounts are created by invitation" }) + ).toBeVisible(); + await expect( + page.getByText("Sign-up is not available on this deployment", { exact: false }) ).toBeVisible(); - const email = page.getByLabel("Email"); - const password = page.getByLabel("Password"); - await expect(email).toBeVisible(); - await expect(password).toBeVisible(); - - await submitForm(page); - - expect(await email.evaluate((element) => (element as HTMLInputElement).validity.valueMissing)).toBe( - true - ); - expect( - await password.evaluate( - (element) => (element as HTMLInputElement).validity.valueMissing - ) - ).toBe(true); - expect((await readValidationMessage(email)).length).toBeGreaterThan(0); - expect((await readValidationMessage(password)).length).toBeGreaterThan(0); - await expect(page).toHaveURL(/\/auth\/sign-up$/); - await email.fill("invalid-email"); - await password.fill("password123"); - await submitForm(page); + // The form is absent, not merely disabled: nothing here can post to the + // signup endpoint the gateway refuses. + await expect(page.locator("#email")).toHaveCount(0); + await expect(page.locator("#password")).toHaveCount(0); + await expect(page.locator("button[type=submit]")).toHaveCount(0); - expect(await email.evaluate((element) => (element as HTMLInputElement).validity.typeMismatch)).toBe( - true - ); - expect((await readValidationMessage(email)).length).toBeGreaterThan(0); + await page.getByRole("link", { name: "Go to sign in" }).click(); + await expect(page).toHaveURL(/\/auth\/sign-in$/); }); test("console redirects logged-out viewers to sign-in", async ({ page }) => { diff --git a/apps/web-console/tests/interaction/route-floors.json b/apps/web-console/tests/interaction/route-floors.json index 0dd9b0dfc..9bd7ba91e 100644 --- a/apps/web-console/tests/interaction/route-floors.json +++ b/apps/web-console/tests/interaction/route-floors.json @@ -22,7 +22,7 @@ }, "routes": { "/auth/sign-in": ["input|#email", "input|#password", "button|Continue"], - "/auth/sign-up": ["input|#email", "input|#password", "button|Create account"], + "/auth/sign-up": ["a|Go to sign in"], "/auth/forgot-password": ["input|#email", "button|Send reset link"], "/auth/reset-password": ["input|#password", "input|#confirm", "button|Update password"], "/no-workspace": ["a|Check again", "button|Sign out"], diff --git a/apps/web-console/tests/unit/api-keys-list-actions.test.tsx b/apps/web-console/tests/unit/api-keys-list-actions.test.tsx new file mode 100644 index 000000000..27d09b366 --- /dev/null +++ b/apps/web-console/tests/unit/api-keys-list-actions.test.tsx @@ -0,0 +1,47 @@ +/** + * Issue #1331: every active key linked to /console/api-keys/[id]/rotate, a + * route that does not exist, so the one action a credentials page most needs + * to be trustworthy answered "This page could not be found". A dead action is + * worse than an absent one: the user believes rotation is available and stops + * looking for the path that does work (create a replacement, revoke the old). + */ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }), +})); + +import { ApiKeyList } from "@/components/api-keys/api-key-list"; +import type { ApiKey } from "@/lib/control-plane/client"; + +const activeKey: ApiKey = { + id: "key-1", + nickname: "production", + status: "active", + redacted_suffix: "9f2a", + created_at: "2026-08-01T00:00:00Z", + updated_at: "2026-08-01T00:00:00Z", + expires_at: null, + last_used_at: null, + expiration_summary: { kind: "never", label: "Never" }, + budget_summary: { kind: "none", label: "Unlimited" }, + allowlist_summary: { mode: "all", group_names: [], label: "All models" }, + spend_credits: 662_000, + budget_limit_credits: null, +}; + +describe("ApiKeyList actions", () => { + it("offers no rotate link, because no rotate route exists", () => { + render(); + expect(screen.queryByRole("link", { name: /rotate/i })).toBeNull(); + expect( + document.querySelector(`a[href*="/rotate"]`), + ).toBeNull(); + }); + + it("still offers the revoke action a manager can actually complete", () => { + render(); + expect(screen.getByRole("button", { name: /revoke/i })).toBeTruthy(); + }); +}); diff --git a/apps/web-console/tests/unit/credit-balance.test.tsx b/apps/web-console/tests/unit/credit-balance.test.tsx new file mode 100644 index 000000000..aff2477a2 --- /dev/null +++ b/apps/web-console/tests/unit/credit-balance.test.tsx @@ -0,0 +1,44 @@ +/** + * Issue #1332: the dashboard rendered the balance as a bare grouped integer + * ("99,996,364,207") with the unit only in the card title, while the API keys + * table rendered the same quantity in dollars ("$0.000662"). One denomination + * now, dollars, with the credit figure and the conversion under it so the two + * surfaces can be reconciled by eye. + */ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import { CreditBalance } from "@/components/billing/credit-balance"; + +// The workspace balance observed live on the demo box, 2026-08-29. +const balance = { + available_credits: 99_996_364_207, + posted_credits: 100_000_000_000, + reserved_credits: 3_635_793, +}; + +describe("CreditBalance", () => { + it("leads with dollars, the denomination the rest of the console uses", () => { + render(); + expect(screen.getByText("$99.99")).toBeTruthy(); + }); + + it("does not print a bare credit integer as the headline figure", () => { + const { container } = render(); + const metric = container.querySelector("p[data-numeric]"); + expect(metric?.textContent?.trim()).toBe("$99.99"); + }); + + it("keeps the credit figure, with the conversion beside it", () => { + render(); + expect( + screen.getByText(/99,996,364,207 credits, at 1,000,000,000 credits per \$1.00/), + ).toBeTruthy(); + }); + + it("draws posted and reserved in the same denomination as the headline", () => { + render(); + expect(screen.getByText("$100.00")).toBeTruthy(); + expect(screen.getByText("$0.00363")).toBeTruthy(); + }); +}); diff --git a/deploy/docker/Dockerfile.web-console.prod b/deploy/docker/Dockerfile.web-console.prod index 31318009a..9b41ca8fd 100644 --- a/deploy/docker/Dockerfile.web-console.prod +++ b/deploy/docker/Dockerfile.web-console.prod @@ -52,10 +52,16 @@ ARG NEXT_PUBLIC_SUPABASE_URL ARG NEXT_PUBLIC_SUPABASE_ANON_KEY ARG NEXT_PUBLIC_APP_URL ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY +# Whether this deployment accepts self-serve signup. Read by +# apps/web-console/lib/auth/self-serve.ts, which fails closed, so an +# unset value hides the sign-up form rather than shipping one the gateway +# refuses (issue #1328). +ARG NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY +ENV NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP=$NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP RUN npm run build diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 41cc869f9..6795fb53c 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -660,6 +660,10 @@ services: NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} + # next dev reads NEXT_PUBLIC_* at runtime, so this one travels as + # environment rather than a build arg. Same source variable as + # web-console-prod below (issue #1328). + NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP: ${ENTERPRISE_DISABLE_SIGNUP:-true} CONTROL_PLANE_BASE_URL: http://control-plane:8081 # Dev-only `next dev` server: publishes raw 0.0.0.0:3000 with no Caddy and # no TLS in front of it, unlike web-console-prod below which sits behind @@ -737,6 +741,12 @@ services: NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} NEXT_PUBLIC_APP_URL: ${CONSOLE_APP_URL:-https://console-hive.scubed.co} NEXT_PUBLIC_TURNSTILE_SITE_KEY: ${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-} + # Same variable, same polarity, as the GoTrue flag in + # docker-compose.enterprise.yml, so the console cannot advertise a + # sign-up the auth service refuses (issue #1328). Defaulted here + # rather than left unset, because an unset build arg reaches + # next build as an empty string and this pair must agree. + NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP: ${ENTERPRISE_DISABLE_SIGNUP:-true} profiles: - chat - enterprise diff --git a/docs/proof/console-honest-surfaces-2026-08-29/capture-log.md b/docs/proof/console-honest-surfaces-2026-08-29/capture-log.md new file mode 100644 index 000000000..a274defc9 --- /dev/null +++ b/docs/proof/console-honest-surfaces-2026-08-29/capture-log.md @@ -0,0 +1,83 @@ +# Visual proof: three console surfaces made honest + +Date: 2026-08-29 +Branch: fix/console-honest-surfaces at ba3bc21, which is the head this +capture was taken from, after the two review fixes (credit-space +truncation, and the em dash for an unreadable balance) landed. An earlier +capture of the same four frames was taken before those two commits and is +superseded by this one. +Pull request: 1336 +Issues: 1328 (sign-up), 1331 (rotate), 1332 (credit denomination) + +## Substrate + +Captured against the demo box, with this branch built into its own image and +run beside the live stack, never in place of it. + +- Source: a fresh clone of `fix/console-honest-surfaces` at `/tmp/proof1336-src` + on the box, not the deploy checkout. +- Image: `hive-web-console-prod:proof1336`, built from + `deploy/docker/Dockerfile.web-console.prod` with the box's own + `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY`, and with + `NEXT_PUBLIC_DISABLE_SELF_SERVE_SIGNUP` fed from the box's real + `ENTERPRISE_DISABLE_SIGNUP`, which is `true`. The gate in these screenshots + is therefore the deployment's own configuration, not a value picked to make + the capture look right. +- Container: `proof1336`, attached to the live `hive_default` compose network + with `CONTROL_PLANE_BASE_URL=http://control-plane:8081`. No host port, no + change to any running service. +- Data: live. The balance and the API keys below are the real Hive Demo + workspace rows, read through the live control-plane. +- Session: minted through the admin one-time-token flow in + `apps/web-console/tests/e2e/support/live-auth.mjs` for + `demo@hive-demo.invalid`. No password was set, reset or rotated. + +## Captured + +Playwright 1.62.0 in `mcr.microsoft.com/playwright:v1.62.0-jammy`, viewport +1440x1000 at device scale factor 2, full page. No URL in this run carries a +credential in a query string or a fragment, so nothing needed redaction in the +pixels or in this log. + +Console transcript from the capture run, verbatim: + +``` +01-dashboard-credits | http://proof1336:3000/console | Hive Demo +credits card text: 99,996,364,207 credits, at 1,000,000,000 credits per $1.00 +02-api-keys-no-rotate | http://proof1336:3000/console/api-keys | API keys +rotate links on api keys page: 0 +03-signup-by-invitation | http://proof1336:3000/auth/sign-up | Accounts are created by invitation +email inputs on the sign-up page: 0 +04-signin-no-create-link | http://proof1336:3000/auth/sign-in | Sign in to your console +create-one links on the sign-in page: 0 +05-billing-balance | http://proof1336:3000/console/billing | Billing +``` + +What each frame shows: + +1. `01-dashboard-credits.png`, issue 1332. The same balance that read + `99,996,364,207` with no unit beside it now reads `$99.99`, with + `Posted $99.99 . Reserved $0.00` under it and the credit figure plus the + conversion below that. Truncation is visible here rather than asserted: the + balance is 99.996364207 dollars and the card says 99.99, not 100.00. +2. `02-api-keys-no-rotate.png`, issue 1331. The active key row offers Revoke + and nothing else, the page counts zero anchors pointing at a rotate href, + and the header now says how rotation is actually done. +3. `03-signup-by-invitation.png`, issue 1328. The route this deployment + refuses says so, in the deployment's own words, with no form to submit and + a link onward to sign-in. Compare the reported failure, which was + "Something went wrong on our end. Reference AUTH-20260829T012555Z." +4. `04-signin-no-create-link.png`, issue 1328. The sign-in page no longer + links to that page at all, and says accounts are created by invitation. +5. `05-billing-balance.png`, issue 1332. The second surface that renders the + same balance, drawn by the same component, so the two pages cannot drift + apart again. The ledger below it still counts in credits, which is + deliberate: that is the unit the ledger, the invoices and the spend-alert + threshold are denominated in. + +## Cleanup + +`docker rm -f proof1336`, `docker rmi hive-web-console-prod:proof1336`, and +`rm -rf /tmp/proof1336-src /tmp/proof1336-state /tmp/proof1336-shots +/tmp/proof1336-pw` on the box. The storage-state file held a real session and +was removed with the rest; it was never copied off the box and never printed.