Skip to content
Closed
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
1 change: 1 addition & 0 deletions crates/plugin-manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2302,6 +2302,7 @@ mod tests {
.map(|op| format!("{} {}", op.method, op.path))
.collect();
assert!(operations.contains(&format!("GET {base}v1/auth/me")));
assert!(operations.contains(&format!("POST {base}v1/buzz/nsec-backups/status")));
assert!(operations.contains(&format!("POST {base}v3/beekeeper/register-agent")));
assert!(operations.contains(&format!("POST {base}v1/buzz/nsec-backups/status")));
assert_eq!(operations.len(), super::BUILDERLAB_FI_ROUTES.len());
Expand Down
8 changes: 8 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ the interface unless an available tool actually supports that action.
unavailable until you sign in. Sign-in, sign-out and when the current
sign-in ends also live under
**Settings → Buzz → Identity → Organization sign-in**.
- A community may require account sign-in even when you already have a key.
Sign-in keeps that key. If the account is linked to another identity, review
the link or change accounts with your sign-in provider in your browser, then
choose **Sign in again**. Buzz cannot force a browser account chooser. Your
current sign-in remains active until replacement succeeds; a successful account
change applies to all your communities and agents. A build without authority configured for that exact community
needs the administrator's supported build. Sign-in alone does not grant
community membership or require joining a sponsor's billing workspace.
- A community invite or address connects someone to a community; creating an
identity alone does not grant membership. The same identity can be used in
more than one community.
Expand Down
153 changes: 153 additions & 0 deletions docs/community-auth-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Required-community client support and acceptance

BUZZ-518 adds guidance from the host-specific `requires_nip_fi` discovery field.
It does not reinterpret `limitation.federated_identity`: that remains capability
advertisement, and the native client retains its existing attachment and denial
behavior when the requirement field is absent. Malformed requirement discovery
offers administrator/build guidance rather than implying free access.
An incompatible descriptor preserves `required=true` with compatibility reported
separately. A non-FI build or incompatible descriptor shows supported-build guidance
and does not offer a sign-in or join action it cannot complete.

Account recovery uses native sign-in renewal, which keeps an existing valid
shared FI session until replacement succeeds. Cancel or browser failure does not
first sign the user out globally. A successful account change applies to all
communities and derived agent access. Welcome detects a browser returning the
same account instead of pretending that it switched accounts.

The traced adapter contract has no account-selection parameter:
`EnterpriseAdapterLoginStartAction.start` accepts `return_to` and the handoff
challenge fields; `BbidentityAuthService.startLogin` builds the Auth0 URL without
an explicit chooser hint (cash-server main `51533fb7bd3d071f06f06484675f5d0d722609d2`). Changing the browser's provider
account may therefore be required before **Sign in again**. No unsupported hint
is added, and a real chooser/cancel run is still a readiness gate.

First-run account requests obtain the latest credential for the same account
subject on each request and reject a different subject, including a response
arriving after an account change. Backup uses the latest public native revision.
The existing FI service owns registration; retirement permits one re-registration before dispatch; it does not
replay arbitrary failures or change the request's original session revision.

## Producer prerequisite and rollout

[BUZZ-511](https://linear.app/squareup/issue/BUZZ-511) owns the discovery producer
and specification, published as draft [block/buzz#8988](https://github.com/block/buzz/pull/8988).
The consumer fixture `src/features/communities/discovery-fixture.ts` pins selected
emitted fields to producer head `82c64ce008171855606511c1340cddaa949196fe`:

- [Normative Discovery contract](https://github.com/block/buzz/blob/82c64ce008171855606511c1340cddaa949196fe/docs/nips/NIP-FI.md#L891).
- [Executable host/mode matrix](https://github.com/block/buzz/blob/82c64ce008171855606511c1340cddaa949196fe/crates/buzz-relay/src/nip_fi_policy/tests.rs#L247).
- [Producer response](https://github.com/block/buzz/blob/82c64ce008171855606511c1340cddaa949196fe/crates/buzz-relay/src/nip11.rs#L400).

The top-level boolean is stored requirement OR global Enforce. Capability remains
mode.evaluates OR effective requirement: a free Shadow host advertises capability
without requiring FI; a free Off host omits FI capability. Required output includes
`core: "client-attached"`, freshness class `offline-jwt`, and a null residual upstream
revocation bound. Neither capability nor policy grants issuer/account-origin trust.
Root NIP-11 and `/info` agree and return `no-store`; unknown/non-serving hosts return
404. For a required host, unavailable policy or missing verifier, deny map or
finite lifetime returns 503.
Buzz treats 503 as a recoverable discovery failure, never as optional/free access.
Client policy remains separate from descriptor compatibility, including
unsupported descriptors in negative fixtures.

This pins a published draft, not a deployed producer. Its exact-head source recheck
resolved the prior findings with no new blockers; hosted CI and human acceptance
remain gates. Update the pin if its contract changes. Production baseline verification
and activation/live-fleet gates remain unresolved.
Static fixtures do not establish live interoperability or completed activation.

1. Finalize and release the BUZZ-511 producer/specification on the revised BUZZ-510
parent; revalidate the pinned fixture after any producer revision.
2. Produce exact-host clients and verify discovery, signup/linking and recovery.
With the field absent, both shipped and new clients retain legacy discovery
and denial behavior. Shipped FI clients still use the existing capability
fields; new non-FI clients show upgrade/configuration guidance for required hosts.
3. Verify free controls and supported-client access before enabling a durable
requirement. Old non-FI clients still need the separate BUZZ-517 upgrade path.
Publishing the new field does not grant membership or change issuer trust.

Observe success using the exact build/descriptor and host, the unchanged device
npub, successful admission to the original community, and renewal/reconnection
through two actual assertion lifetimes. Record timestamps and results, never
tokens or authentication headers. The procedure below is required live evidence;
CI and component tests are not substitutes.

Client rollback is a revert or replacement with a verified compatible build.
It does not clear the relay's durable requirement, reverse an identity binding,
erase the device key, or reset saved backup consent or the native FI session.
Those remain in their existing stores and formats. Do not use a data wipe or
automatic unlink as rollback. Restoring access after a bad client rollout still
requires a client that can satisfy the active policy; reverting guidance alone
does not make a required community free.

The first-run Welcome component accepts the existing native FI service and uses
the bundled Builderlab authority grant for account checks and explicit linking.
It reuses the existing linked-account/import/create choices. Account tokens stay
native; requests carry only the public session revision. Native validation still
checks the enabled artifact, authority, operation and current session. A conflicting
binding stops before a challenge is requested; no automatic unlink is added.

**FOUNDATION boundary:** current main already supplies Welcome with the bundled
`services.firstRunFederatedIdentity` owner. This PR preserves that composition
and makes no `App.tsx` changes. Any further FOUNDATION wiring change still needs
explicit human guidance under `AGENTS.md`. Component tests alone do not establish
packaged support.

## Configuration and artifacts

| Artifact or integration | Supported configuration and limits |
| --- | --- |
| Candidate V1 desktop | Compile the same validated `BUZZ_BUILD_FEDERATED_IDENTITY_CONFIG` into Vite and Cargo. `communityHosts` must match the exact target host/port, or its configured single-label wildcard. `identityOrigin` is a bare HTTPS origin; `adapterBase` is its adapter prefix. Builderlab first run requires its `/api/goose/` API shape. |
| Existing V1 desktop releases | Inspect the actual build descriptor. A Block-only descriptor does not cover bb-public. Preview 233.1 had no FI descriptor; code support does not add configuration to an old artifact. |
| Runtime-descriptor proposal | PR #2152 is separate and is not assumed here. Its presence does not establish first-run or bundled-plugin configuration. |
| Local agents and bundled Builderlab agents | Existing native FI grants/assertions and NIP-OA attestation remain authoritative. Explicit linking uses the configured FI origin, derived by `crates/plugin-manager/build.rs`, plus the signer's existing production-origin allowance. A relay-advertised origin cannot expand that trust. |
| Agent Git evidence | PR #2240 is separate; this change does not claim its behavior. |
| CLI | This repository does not add an interactive CLI account flow. A CLI/agent needs its own supported assertion path and exact authority/host configuration. |
| Mobile | This repository does not produce the separate mobile client. Dogfood rc.17 (`b5b69d3d`) supports issuer-selected lifetimes but its inspected configuration covered the Block tenant, not bb-public. Mobile needs its own exact-host candidate and acceptance evidence. |

For bb-public staging, the planned origin is `https://app.builderlabstaging.xyz`,
the adapter prefix is `/api/goose`, and the API base is `/api/goose/`. Match only
the intended community hosts; do not infer support from a build's name. Candidate
packaging and service activation belong to BUZZ-514–516. No shipped candidate or
successful live flow is asserted by this document.

## Required live acceptance

Use disposable accounts and keys in an isolated OS account. Do not overwrite an
operator's saved identity. Record build hash, descriptor, exact relay host, issuer,
and assertion lifetime without recording credentials or private keys.

1. Fresh signup: sign in from Welcome, confirm the account is unlinked, create
and link a key, then join the originally entered required community. Exercise
backup only where the build/service explicitly supports it.
2. Returning account: confirm its bound npub is shown before import/restore.
A different key must be refused. Cancel and change account without creating a key.
3. Existing device key: record its npub, sign in and bind it, reconnect the same
community, and confirm the npub is unchanged after restart.
4. Conflict: retain the conflicting mapping and device key. Review the mapping
or explicitly select another account. Never silently unbind or regenerate.
Verify provider account selection and cancellation/failure with an existing
shared session. Cancel/failure must leave that session usable; a provider
returning the same account must not be reported as an account switch.
5. Keep a required-community connection active across at least two actual
assertion lifetimes. Disconnect/reconnect and rediscover policy. Revoke the
account, key, and agent separately; protected access must stop and recover only
after permitted fresh authentication.
6. Check a free community, an old relay without the new field, an unsupported
host/authority, malformed discovery, storage refusal, browser cancellation,
expired session and network recovery. No relay may choose a new sign-in origin.

Socket renewal already belongs to the native FI/socket owners (including #2142).
Audio acceptance remains BUZZ-457; this change adds no audio renewal mechanism.
Server membership checks remain independent of sponsor billing membership.

Local component/service tests and the public-state recovery fixture do not prove
native credential custody, packaged SSO, mobile behavior or the live two-lifetime
contract. Keep those gaps explicit on the draft PR.

Sources: [BUZZ-518 and its artifact correction](https://linear.app/squareup/issue/BUZZ-518/v1-clients-complete-account-signuplinking-and-auth-recovery-for),
[native session foundation](https://github.com/block/buzz-app/pull/1424),
[Builderlab FI forwarding](https://github.com/block/buzz-app/pull/1461),
[quiet socket renewal](https://github.com/block/buzz-app/pull/2142),
and the local [identity contract](identity.md) and [build configuration](configuration.md).
17 changes: 17 additions & 0 deletions src/bundled/builderlab/oauth/first-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,23 @@ describe("an FI build", () => {
}),
"Could not load your Builderlab account. Try again.",
],
[
"the session changes before the sign-in answer is consumed",
(fi: ReturnType<typeof federatedFixture>) => {
fi.request.mockResolvedValueOnce({
status: 200,
headers: {},
body: JSON.stringify(me),
});
vi.mocked(fi.service.signIn).mockImplementationOnce(async () => {
fi.publish({ status: "signed-in", revision: "returned" });
const returned = fi.service.snapshot();
fi.publish({ revision: "concurrent-replacement" });
return returned;
});
},
"Could not load your Builderlab account. Try again.",
],
[
"the session changes during the account read",
(fi: ReturnType<typeof federatedFixture>) =>
Expand Down
30 changes: 25 additions & 5 deletions src/bundled/builderlab/oauth/first-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,7 @@ async function federatedCredential(
throw new Error(recoveryMessage("sign-in-canceled"));
if (session.status !== "signed-in")
throw new Error(recoveryMessage(session.failure ?? ""));
const account = await federatedAccount(identity).catch(() => undefined);
signal.throwIfAborted();
if (!account || identity.snapshot().revision !== session.revision)
throw new Error("Could not load your Builderlab account. Try again.");
return Object.freeze({ value: session.revision, account });
return await readFederatedCredential(identity, signal, session.revision);
} catch (error) {
if (signal.aborted) throw new Error("Sign-in canceled.");
throw error instanceof Error
Expand All @@ -155,3 +151,27 @@ async function federatedCredential(
signal.removeEventListener("abort", cancel);
}
}

/** Read the current native account without starting another browser sign-in. */
export async function readFederatedCredential(
identity: Pick<FirstRunIdentity, "snapshot" | "request">,
signal?: AbortSignal,
expectedRevision?: string,
): Promise<Credential> {
signal?.throwIfAborted();
const session = identity.snapshot();
if (session.status !== "signed-in")
throw new Error("Sign in to Builderlab first.");
if (expectedRevision !== undefined && session.revision !== expectedRevision)
throw new Error("Could not load your Builderlab account. Try again.");
const account = await federatedAccount(identity).catch(() => undefined);
signal?.throwIfAborted();
const after = identity.snapshot();
if (
!account ||
after.status !== "signed-in" ||
after.revision !== session.revision
)
throw new Error("Could not load your Builderlab account. Try again.");
return Object.freeze({ value: session.revision, account });
}
58 changes: 57 additions & 1 deletion src/features/communities/FindCommunity.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { requiredFiDiscovery } from "./discovery-fixture";
import "@testing-library/jest-dom/vitest";
import {
act,
Expand Down Expand Up @@ -30,7 +31,62 @@ const policy = {
privacy_markdown: "# Privacy",
age_attestation_required: true,
};
afterEach(cleanup);
afterEach(() => {
cleanup();
vi.unstubAllEnvs();
});

it.each([
[true, true, true],
[false, true, true],
[true, true, false],
[false, false, true],
])(
"renders required-host guidance for FI build=%s, required=%s, compatible=%s",
async (fi, required, compatible) => {
vi.stubEnv("VITE_BUZZ_FEDERATED_IDENTITY", fi ? "1" : "0");
const request = relay(() => ({
"/": Response.json({
...buzz,
...requiredFiDiscovery,
requires_nip_fi: required,
federated_identity: {
...requiredFiDiscovery.federated_identity,
core: compatible ? "client-attached" : "unsupported",
identityOrigin: "https://untrusted.invalid",
},
}),
"/api/join-policy": new Response(null, { status: 404 }),
}));
render(
<FindCommunity
url={url}
onCancel={vi.fn()}
onJoin={vi.fn()}
request={request}
/>,
);
await screen.findByRole("heading", { name: "Join Community" });
if (required && (!fi || !compatible)) {
expect(screen.getByRole("alert")).toHaveTextContent(
"supported Buzz build configured for this host",
);
expect(screen.getByRole("button", { name: "Join" })).toBeDisabled();
} else {
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByRole("button", { name: "Join" })).toBeEnabled();
if (required)
expect(
screen.getByText(/account sign-in and a linked Buzz identity/),
).toBeVisible();
else
expect(
screen.queryByText(/account sign-in and a linked Buzz identity/),
).toBeNull();
}
expect(document.body).not.toHaveTextContent("untrusted.invalid");
},
);

function deferred<T>() {
let resolve!: (value: T) => void;
Expand Down
24 changes: 23 additions & 1 deletion src/features/communities/FindCommunity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Avatar } from "../../shared/design-system/ui/Avatar";
import { Button } from "../../shared/design-system/ui/Button";
import { Checkbox } from "../../shared/design-system/ui/Checkbox";
import { LoadingRegion } from "../../shared/design-system/ui/LoadingRegion";
import { nativeBuilderlab } from "../../bundled/builderlab/session";
import {
FindCommunityError,
findCommunity,
Expand All @@ -25,6 +26,11 @@ export type JoinConsent = Readonly<{ adult: boolean; agreed: boolean }>;

/** Classified copy; raw transport errors are never shown. */
const FAILURES: Record<FindFailure, { message: string; retry: boolean }> = {
"unsupported-auth": {
message:
"This community requires sign-in that this Buzz build cannot confirm. Ask its administrator for a supported build and try again.",
retry: false,
},
unreachable: {
message: "Could not reach the relay. Check your connection and try again.",
retry: true,
Expand Down Expand Up @@ -158,6 +164,8 @@ export function FindCommunity({
}
const { found } = state;
const name = communityName(found.name, found.host);
const unsupportedSignIn =
found.requiresNipFi && (!nativeBuilderlab() || !found.nipFiCompatible);
const policy = found.policy;
const needsAge = policy?.age_attestation_required === true;
const needsAgreement = Boolean(
Expand All @@ -177,6 +185,20 @@ export function FindCommunity({
return (
<>
{heading("Join Community")}
{unsupportedSignIn ? (
<p role="alert" className="text-body text-danger">
This community requires account sign-in. Ask its administrator for a
supported Buzz build configured for this host and compatible community
settings. Your key is kept.
</p>
) : (
found.requiresNipFi && (
<p className="text-body text-subtle">
This community requires account sign-in and a linked Buzz identity.
Your existing key will be kept.
</p>
)
)}
<div className="flex items-center gap-3 rounded-[var(--radius-row)] bg-surface-inset p-4">
<Avatar
alt=""
Expand Down Expand Up @@ -252,7 +274,7 @@ export function FindCommunity({
<Button
variant="prominent"
size={onboarding ? "lg" : "md"}
disabled={!consented || joining}
disabled={!consented || joining || !!unsupportedSignIn}
onClick={() => void join()}
>
Join
Expand Down
Loading
Loading