Skip to content
Open
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
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ BUZZ_DEV_VIEWER=
# Production builds ignore it.
# BUZZ_DEV_OPEN_RELAY=1

# Optional: answer the first-run Builderlab account reads (linked key, owned
# communities, backed-up keys) and fake the key link, unlink, backup and restore
# without kgoose or the native uploader, so the returning-user screens can be
# walked against a real account whose key binding must not change. `unlinked`,
# or a JSON object with optional fields {"npub":"npub1…",
# "communities":["host",…],"known":["host",…],"backups":["npub1…",…],
# "recovery":"nsec1…","backup":"<failure kind>"|"fail-once",
# "unlink":<HTTP status>}. `known` lists the destinations saved to the account. `recovery` is
# the key every restore recovers: use a throwaway key; without it, restore
# answers that recovery is unavailable.
# Dev server only; production builds ignore it. Restart the dev server after
# changing this.
# BUZZ_DEV_FIRST_RUN_ACCOUNT=unlinked

# Optional compatibility aliases for previously saved community IDs.
# Keep the same alias mapped to the same origin to retain existing memberships.
# Values are public configuration included in the frontend bundle, never secrets.
Expand Down
40 changes: 40 additions & 0 deletions browser-host/vite-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,46 @@ it("loads the broker's Vite config without native-compatibility warnings", () =>
(error) => /BUZZ_BUILDERLAB_TENANTS must be/.test(error.message) && !error.message.includes('secret'),
);
process.env.BUZZ_BUILDERLAB_TENANTS = '';
// The first-run account seed reaches only a dev server, and a
// malformed value fails at configuration time without being echoed.
for (const command of ['serve', 'build']) {
process.env.BUZZ_DEV_FIRST_RUN_ACCOUNT = 'unlinked';
const seeded = await loadConfigFromFile(
{ command, mode: command === 'serve' ? 'development' : 'production' }, configFile,
);
assert.equal(
seeded.config.define['import.meta.env.VITE_BUZZ_FIRST_RUN_ACCOUNT'],
JSON.stringify(command === 'serve' ? 'unlinked' : ''),
);
}
// A backup kind resolves against the failure table, and backups
// and a recovery key decode, through the native loader.
const failingSeed = JSON.stringify({
backup: 'unreachable',
known: ['design.example.com'],
backups: ['npub1424242424242424242424242424242424242424242424242424qamrcaj'],
recovery: 'nsec1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqstywftw',
});
process.env.BUZZ_DEV_FIRST_RUN_ACCOUNT = failingSeed;
const failing = await loadConfigFromFile({ command: 'serve', mode: 'development' }, configFile);
assert.equal(
failing.config.define['import.meta.env.VITE_BUZZ_FIRST_RUN_ACCOUNT'],
JSON.stringify(failingSeed),
);
for (const seed of [
'{"npub":"s3cret"}',
'{"backup":"s3cret"}',
'{"backups":["s3cret"]}',
'{"recovery":"nsec1s3cret"}',
'{"known":["http://s3cret.example"]}',
]) {
process.env.BUZZ_DEV_FIRST_RUN_ACCOUNT = seed;
await assert.rejects(
loadConfigFromFile({ command: 'serve', mode: 'development' }, configFile),
(error) => /BUZZ_DEV_FIRST_RUN_ACCOUNT must be/.test(error.message) && !error.message.includes('s3cret'),
);
}
delete process.env.BUZZ_DEV_FIRST_RUN_ACCOUNT;
// Without a viewer pin nothing consumes the seed, so it is neither exposed nor required.
process.env.BUZZ_DEV_OPEN_RELAY = '1';
process.env.BUZZ_RELAY_URL = '';
Expand Down
56 changes: 56 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ not in source. Build defaults are readable binary data, **never secret storage**
| `BUZZ_RELAY_URL` | Live development broker's default community, not an agent relay override or a packaged default. |
| `BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY` | Presence-only alias for fresh-viewer community selection in live development only. Saved viewer choice wins. |
| `BUZZ_DEV_OPEN_RELAY` | Development-specific override of that alias: only `1` enables; `0` explicitly opts out. Requires a relay URL and live viewer pin to have an effect. |
| `BUZZ_DEV_FIRST_RUN_ACCOUNT` | Dev server only: answers the first-run Builderlab account reads (linked key, owned communities, saved communities, backed-up keys) in place of kgoose, and fakes backup recovery. Builds define it empty. See [below](#first-run-account-seed-development). |
| `BUZZ_DEV_VIEWER`, `BUZZ_COMMUNITY_ALIASES`, `BUZZ_DEV_NOTIFICATIONS` | Existing public viewer pin, public routing aliases and dev notification override; unchanged. See the [development setup](contributing.md). |
| `BUZZ_UPDATER_PUBLIC_KEY`, `BUZZ_UPDATER_ENDPOINT` | Native release build, process environment only: two non-empty values register the updater plugin. The same public key and endpoint must reach `tauri build --config` as `plugins.updater`, and update archives must be signed by the matching private key. Desktop preview release runs supply both for every platform (see [releases](releases.md)); candidate-only and local builds report automatic updates as unavailable. |

Expand Down Expand Up @@ -136,6 +137,61 @@ whether to connect with Builderlab at `BUZZ_BUILDERLAB_URL` or bring an existing
Malformed rows fail the Vite configuration and the native build without echoing
the value. The same restart and rebuild rules as `BUZZ_BUILDERLAB_URL` apply.

## First-run account seed (development)

After Builderlab sign-in, the first-launch flow reads four things about the
account from kgoose: the Nostr key bound to it, the communities it owns, the
destinations saved to it ([known communities](communities.md#known-communities))
and the keys it holds a backup of ([identity](identity.md)), binds a key it creates or
imports to an unlinked account, and, on Start over, unlinks the lost key
before binding the new one. A developer's real account is bound to their real key and that binding
must not change, so `BUZZ_DEV_FIRST_RUN_ACCOUNT` replaces those reads, the
binding, the unlink, the key backup and backup recovery — and nothing else —
on a dev server:

```sh
# A brand-new account: the key-setup choice follows sign-in.
BUZZ_DEV_FIRST_RUN_ACCOUNT=unlinked
# A returning user: Welcome back for this npub, with these owned communities.
BUZZ_DEV_FIRST_RUN_ACCOUNT='{"npub":"npub1…","communities":["design.example.com"]}'
# An account with these destinations saved to it (read with the account check; nothing restores them yet).
BUZZ_DEV_FIRST_RUN_ACCOUNT='{"known":["chat.example.com","wss://buzz.example.org:8443"]}'
# A brand-new account whose every backup fails as if Builderlab were unreachable.
BUZZ_DEV_FIRST_RUN_ACCOUNT='{"backup":"unreachable"}'
# A brand-new account whose first backup fails, so Try again then succeeds.
BUZZ_DEV_FIRST_RUN_ACCOUNT='{"backup":"fail-once"}'
# A returning user whose Start over unlink fails as kgoose answering 503 would.
BUZZ_DEV_FIRST_RUN_ACCOUNT='{"npub":"npub1…","unlink":503}'
# A returning user with Restore from backup, which answers that recovery is unavailable.
BUZZ_DEV_FIRST_RUN_ACCOUNT='{"npub":"npub1…","backups":["npub1…"]}'
# The same, where every restore recovers this throwaway key.
BUZZ_DEV_FIRST_RUN_ACCOUNT='{"npub":"npub1…","backups":["npub1…"],"recovery":"nsec1…"}'
```

Every field is optional; without `npub` the account is unlinked. `communities`
lists hostnames (or `wss://`/`https://` origins) the way the Community URL field
accepts them; `known` lists the destinations saved to the account the same way,
answered as kgoose spells them (`wss://host[:port]`). `backup` names a failure kind from
`src/features/identity/backup-failure.ts` (such as `unreachable`, `timeout` or
`refused`), and every backup then fails with that kind's page; `fail-once`
fails only the first backup after each sign-in, as Builderlab not confirming
it, so Try again succeeds. `unlink` is an
HTTP error status (400–599), and every unlink then fails as kgoose answering it
would: a 429 or 5xx offers Try again, and a 401 reads as a lapsed sign-in. `backups` lists the npubs backup status answers
(none when absent), so Welcome back offers Restore from backup when it holds
`npub`. `recovery` is the `nsec1…` every restore recovers, which installing
imports as if it were typed; without it every restore answers that recovery is
unavailable, as every tenant does today. The value is plain text in your
environment, so use a throwaway key, never a real one. Vite exposes the value
as `VITE_BUZZ_FIRST_RUN_ACCOUNT` for `serve` only; `pnpm build` and
`pnpm tauri build` define it empty, so packaged apps always ask kgoose. A
malformed value fails the Vite configuration without being echoed. Restart the
dev server after changing it. With a seed, binding, unlinking and backup
succeed (unless `unlink` or `backup` is set), and a joined community's save to
the account succeeds, without a request, a native signature or a native backup
command, in every build.
Restore opens no browser and runs no native recovery command.

## Whole-video limit

The desktop app reads relay video from its origin one bounded `Range` at a
Expand Down
25 changes: 25 additions & 0 deletions src/bundled/builderlab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,31 @@ agents are not migrated into this type.
plugin-lifetime session. `browserCredential()` returns a verified `Credential`;
`session.credential()` provides access to it. The session handles cancellation,
sign-out, and disposal.
`oauth/first-run.ts` lets the app's first-launch welcome screen run the same
sign-in before any plugin session exists, using this plugin's bundled native
grant; that credential is separate from the Settings session. When a community
URL on that screen names another Builderlab deployment (the
[`BUZZ_BUILDERLAB_TENANTS` table](../../../docs/configuration.md#builderlab-tenant-table-build-input)),
the welcome screen passes that deployment's origin and `browserCredential`
runs the whole round-trip there; the Settings card always uses the build URL,
and so does the sign-in that screen offers for a community no tenant hosts.
A successful first-run sign-in continues into the app-owned setup steps
described in [identity](../../../docs/identity.md): the app reads the key
bound to the account, the destinations saved to it (this plugin's
known-communities list route, at the deployment that signed the user in,
never through the Settings session's client) and the communities it owns
through this plugin's bundled grant
(`src/features/identity/builderlab-account.ts`, reusing the
hosted-communities routes and parsers); a linked account is asked for that
key, otherwise the user creates a key or brings one they already have, and
that key is then bound to the account through the same native signer at that
deployment and backed up (an imported key only once the user agrees);
starting over creates a new key and unlinks the lost one before binding it.
Key backup hands the sign-in to the native uploader only for the build's own
portal (anywhere else the step reports backup unavailable), which is also
the only portal asked which keys the account holds a backup of; Welcome back
restores the linked key from one through native recovery's own browser
sign-in.
- `login/` owns the login experience: pending state, cancellation, retry, email
display, and sign-out.
- `agents/` lists the signed-in account's remote agents beneath login, with
Expand Down
4 changes: 2 additions & 2 deletions src/bundled/builderlab/known-communities/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function unexpected(status: number): Refusal {
/** A listed row's destination. `null` is a row whose address this device could
* never save (dropped on its own), `undefined` a row of a shape this client
* does not understand, which makes the whole response invalid. */
function address(row: unknown): string | null | undefined {
export function listedAddress(row: unknown): string | null | undefined {
if (!row || typeof row !== "object") return undefined;
const { community_url } = row as Record<string, unknown>;
if (typeof community_url !== "string") return undefined;
Expand Down Expand Up @@ -161,7 +161,7 @@ export function createKnownCommunitiesClient(
// Protobuf JSON can omit an empty repeated field.
const rows = value.communities ?? [];
if (!Array.isArray(rows)) return { kind: "invalid_response" };
const addresses = rows.map(address);
const addresses = rows.map(listedAddress);
if (addresses.includes(undefined)) return { kind: "invalid_response" };
const communities = addresses.filter((a): a is string => a !== null);
return {
Expand Down
52 changes: 52 additions & 0 deletions src/bundled/builderlab/oauth/first-run.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { afterEach, expect, it, vi } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import { bundledHost, firstRunCredential } from "./first-run";
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
isTauri: vi.fn(() => true),
}));
afterEach(() => {
vi.resetAllMocks();
vi.unstubAllEnvs();
});

it("requests under the bundled Builderlab grant without a plugin owner", async () => {
vi.mocked(invoke).mockResolvedValueOnce({
status: 200,
headers: {},
body: "{}",
});
const request = { url: "https://app.builderlab.xyz/api/goose/v1/auth/me" };
await bundledHost.request(request);
expect(invoke).toHaveBeenCalledWith("plugin_host_request", {
id: "block.builderlab",
revision: "bundled",
request,
});
expect(await bundledHost.runCommand("any")).toBeNull();
});

it.each([
["the configured build URL", undefined, "https://builderlab.example"],
["a tenant origin", "https://tenant.example", "https://tenant.example"],
])(
"starts the native browser handoff at %s",
async (_label, origin, expected) => {
vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example");
// A refused listener ends the attempt before any exchange request.
vi.mocked(invoke).mockRejectedValueOnce("listener unavailable");
await expect(
firstRunCredential(new AbortController().signal, origin),
).rejects.toThrow("Could not complete");
expect(invoke).toHaveBeenCalledTimes(1);
expect(invoke).toHaveBeenCalledWith(
"oauth_callback_begin",
expect.objectContaining({
authorizationUrl: expect.stringMatching(
new RegExp(`^${expected}/api/goose/v1/auth/login\\?`),
),
useState: true,
}),
);
},
);
27 changes: 27 additions & 0 deletions src/bundled/builderlab/oauth/first-run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { invoke } from "@tauri-apps/api/core";
import type { Host } from "../../../features/host/service";
import manifest from "../manifest.json";
import { browserCredential, type Credential, nativeBridge } from "./browser";

/**
* First launch runs before this plugin can share its Settings session, so the
* welcome screen signs in through the same OAuth client under the bundled
* plugin's own native network grant. Native code grants that origin at build
* time; it does not depend on the plugin's JavaScript having started.
*/
export const bundledHost: Host = {
runCommand: () => Promise.resolve(null),
request: (request) =>
invoke("plugin_host_request", {
id: manifest.id,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P2 — Handle a persisted disabled Builderlab plugin before first-run sign-in. This transport calls plugin_host_request, whose host_grants lookup requires the bundled plugin to be enabled (crates/plugin-manager/src/lib.rs:1112–1122). Disabling Builderlab is persisted, and ordinary sign-out retains that registry (src-tauri/src/sign_out.rs:665–669). Thus this first-run helper can finish browser authentication and then fail credential exchange locally on the next setup. Give the app-owned flow narrowly scoped access independent of Settings-plugin enablement, or explicitly handle the disabled state before opening login, retaining configured-origin enforcement. Cover disabled-plugin → plain sign-out → first-run login.

revision: "bundled",
request,
}),
};

/** `origin`: a tenant's portal chosen from the community URL; default otherwise. */
export const firstRunCredential = (
signal: AbortSignal,
origin?: string,
): Promise<Credential> =>
browserCredential(bundledHost, signal, nativeBridge, origin);
7 changes: 7 additions & 0 deletions src/features/identity/backup-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const retry = (cause: string, then = "Try again.") =>
* and whether Try again can help. Native code names its own kinds and retries
* them all on its next wake; `unavailable`, `interrupted`, `timeout` and
* `unknown` are the renderer's. No message carries a service or IPC answer.
* A leaf module: vite.config.ts validates the development seed against it
* through Node's native loader.
*/
export const BACKUP_FAILURES = {
unavailable: {
Expand Down Expand Up @@ -59,3 +61,8 @@ export const BACKUP_FAILURES = {
>;

export type BackupFailureKind = keyof typeof BACKUP_FAILURES;

export const isBackupFailureKind = (
value: unknown,
): value is BackupFailureKind =>
typeof value === "string" && Object.hasOwn(BACKUP_FAILURES, value);
Loading
Loading