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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ BUZZ_DEV_VIEWER=
# Secure origins only; no credentials, paths, query strings, or fragments.
# BUZZ_RELAY_URL=wss://relay.example.com

# Optional: on a fresh dev port, save and select BUZZ_RELAY_URL as a community.
# Only 1 enables it and it requires BUZZ_RELAY_URL. A saved choice on that port,
# including Personal space, wins. Restart the dev server after changing this.
# Production builds ignore it.
# BUZZ_DEV_OPEN_RELAY=1

# 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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,20 @@ in the non-live shell/fixture state.
BUZZ_DEV_VIEWER=npub1YOUR_PUBLIC_KEY
# Optional default for unscoped development-broker requests:
BUZZ_RELAY_URL=wss://relay.example.com
# Optional: on a fresh dev port, save and select BUZZ_RELAY_URL as a community.
BUZZ_DEV_OPEN_RELAY=1
# Optional compatibility map for memberships saved with short aliases:
BUZZ_COMMUNITY_ALIASES='{"example":"wss://relay.example.com"}'
```
Never put an nsec/private key in this file. The public pin explicitly authorizes
the account to use; it does not import or change a key. Relay URLs and aliases
are public configuration, not secrets. With both relay settings unset, there is
no default relay or alias map; Personal space and communities saved by canonical
URL remain usable. Configuration does not automatically join a community.
URL remain usable. Configuration does not automatically join a community:
`BUZZ_DEV_OPEN_RELAY=1` only saves and selects the default relay locally on a
dev port whose saved choice is absent. It does not implicitly join a community,
accept an invite, or publish a profile. Normal session traffic and presence still
apply.
3. Start a development target:
```sh
just web
Expand Down
32 changes: 32 additions & 0 deletions dev/vite-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,38 @@ it("loads the broker's Vite config without native-compatibility warnings", () =>
JSON.stringify(command === 'serve' ? '1' : '0'),
);
}
// BUZZ_DEV_OPEN_RELAY exposes the canonical default relay to a live dev
// server only when set to exactly "1"; builds and other values see "".
process.env.BUZZ_RELAY_URL = 'wss://Relay.example.com/';
for (const command of ['serve', 'build']) {
for (const setting of [undefined, '', '0', '1', 'true']) {
if (setting === undefined) delete process.env.BUZZ_DEV_OPEN_RELAY;
else process.env.BUZZ_DEV_OPEN_RELAY = setting;
const result = await loadConfigFromFile(
{ command, mode: command === 'serve' ? 'development' : 'production' }, configFile,
);
assert.equal(
result.config.define['import.meta.env.VITE_BUZZ_OPEN_RELAY'],
JSON.stringify(
command === 'serve' && setting === '1' ? 'https://relay.example.com' : '',
),
);
}
}
// 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 = '';
process.env.BUZZ_DEV_VIEWER = '';
const shell = await loadConfigFromFile(
{ command: 'serve', mode: 'development' }, configFile,
);
assert.equal(shell.config.define['import.meta.env.VITE_BUZZ_OPEN_RELAY'], '""');
// A live server with the flag but no relay URL fails at configuration time.
process.env.BUZZ_DEV_VIEWER = 'a'.repeat(64);
await assert.rejects(
loadConfigFromFile({ command: 'serve', mode: 'development' }, configFile),
/BUZZ_DEV_OPEN_RELAY=1 requires BUZZ_RELAY_URL/,
);
} finally {
process.chdir(cwd);
rmSync(directory, { recursive: true, force: true });
Expand Down
7 changes: 6 additions & 1 deletion docs/communities.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,12 @@ without it unscoped relay operations fail explicitly. `/api/relay/identity` stay
available independently. These settings do not join/select a community or send a
request on startup. Both are public routing values, not credentials; alias mappings
are embedded in the frontend. Restart/rebuild after changing them. Environment
variables override `.env.local`; see `.env.example`. Session acquisition/retry registers again, including startup of a
variables override `.env.local`; see `.env.example`. As a separate opt-in,
`BUZZ_DEV_OPEN_RELAY=1` makes a live dev server save and select the canonical
`BUZZ_RELAY_URL` origin for a viewer whose local client record is absent, labeled
with the relay host. Only `1` enables it and it requires `BUZZ_RELAY_URL`; any
saved record, including Personal space, wins; switching in the UI never writes
configuration; production builds ignore it. Session acquisition/retry registers again, including startup of a
saved custom community after broker restart. Query/sign/publish, policy/claim,
metadata, protected media and live traffic stay bound to the captured destination.
HTTP authority discovery and other upstream fetches reject redirects.
Expand Down
4 changes: 4 additions & 0 deletions docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ need their own validation.
in `.env.local` and restart the server. Only `0` pauses alerts and permission
requests; removing the setting restores normal behavior. Saved preferences are
untouched and production builds ignore the variable.
- To open the default relay's community on a fresh dev port, set
`BUZZ_DEV_OPEN_RELAY=1` alongside `BUZZ_RELAY_URL` in `.env.local` and restart
the server. Only `1` enables it; a viewer's existing saved choice on that port,
including Personal space, wins. Production builds ignore the variable.
- `just fullstack`: reserved, exits unsuccessfully with an explanation. It will
eventually start local Docker services including the Buzz relay backend.
- `just iterate`: install locked dependencies, format Rust, apply Biome safe
Expand Down
4 changes: 3 additions & 1 deletion src/app/pages.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -249,4 +249,6 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
Object.defineProperty(globalThis, "localStorage", originalStorage);
else delete globalThis.localStorage;
}
});
// A real Vite server, the whole service graph and every page render take
// seconds alone, so the default budget expires under a parallel suite.
}, 30_000);
24 changes: 23 additions & 1 deletion src/app/services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,13 @@ const viewer = "a".repeat(64);
const signals: AbortSignal[] = [];
const streams: { url: string; close: ReturnType<typeof vi.fn> }[] = [];
let storageReads: ReturnType<typeof vi.fn>;
let values: Map<string, string>;

beforeEach(() => {
vi.useFakeTimers();
vi.stubEnv("VITE_BUZZ_LIVE", "1");
plugin.cleanup.mockReset();
const values = new Map<string, string>();
values = new Map<string, string>();
storageReads = vi.fn((key: string) => values.get(key) ?? null);
vi.stubGlobal("localStorage", {
getItem: storageReads,
Expand Down Expand Up @@ -206,6 +207,27 @@ it("still cancels the host and reports an unexpected manager-disposal failure",
expectHostStopped();
});

it("seeds and persists the configured relay through the real app composition", async () => {
await services.dispose();
vi.stubEnv("VITE_BUZZ_OPEN_RELAY", "https://third.example");
services = createServices();
await vi.advanceTimersByTimeAsync(0);
const membership = { id: "https://third.example", name: "third.example" };
expect(services.communities.snapshot()).toMatchObject({
status: "ready",
memberships: [membership],
selected: membership.id,
});
expect(vi.mocked(fetch).mock.calls.map(([url]) => url)).toContain(
"/api/relay/https%3A%2F%2Fthird.example/session",
);
expect(JSON.parse(values.get(`buzz-client.v1:${viewer}`) ?? "null")).toEqual({
profile: { name: "", picture: "" },
memberships: [membership],
selected: membership.id,
});
});

it("joins cleanup already started by disabling a plugin", async () => {
const cleanup = new Promise<void>((resolve) => {
release = resolve;
Expand Down
1 change: 1 addition & 0 deletions src/app/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function createServices() {
ctx,
import.meta.env.VITE_BUZZ_LIVE === "1",
identityNames,
import.meta.env.VITE_BUZZ_OPEN_RELAY ?? "",
);
const relay = communities.relay;
ctx.effect(() => bindAgentMentions(agentControl, communities));
Expand Down
3 changes: 3 additions & 0 deletions src/features/communities/broker-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ async function startup(relayUrl = "", aliases = "") {
vi.stubEnv("BUZZ_RELAY_URL", relayUrl);
vi.stubEnv("BUZZ_COMMUNITY_ALIASES", aliases);
vi.stubEnv("BUZZ_DEV_VIEWER", viewer);
// Neutralize the seed too: loadEnv would otherwise mix a developer's
// .env.local into what these tests assert.
vi.stubEnv("BUZZ_DEV_OPEN_RELAY", "");
vi.mocked(execFileSync).mockReturnValue(
Buffer.from(JSON.stringify({ identity: nip19.nsecEncode(key) })),
);
Expand Down
3 changes: 3 additions & 0 deletions src/features/communities/broker-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,12 @@ const readers = {
const platforms = Object.keys(readers) as (keyof typeof readers)[];

beforeEach(() => {
// Neutralize every BUZZ_ input the config reads: loadEnv would otherwise mix
// a developer's .env.local into what these tests assert.
vi.stubEnv("BUZZ_RELAY_URL", "");
vi.stubEnv("BUZZ_COMMUNITY_ALIASES", "");
vi.stubEnv("BUZZ_DEV_VIEWER", "");
vi.stubEnv("BUZZ_DEV_OPEN_RELAY", "");
onPlatform("darwin");
readCredential.mockReset();
readCredential.mockReturnValue(Buffer.from(credential));
Expand Down
86 changes: 82 additions & 4 deletions src/features/communities/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ import { readView, writeView } from "../../shared/view-state";
const viewer = "a".repeat(64);
const roots: Context[] = [];
const requests: string[] = [];
function setup(saved?: unknown, savedViewer = viewer) {
function setup(saved?: unknown, savedViewer = viewer, openRelay = "") {
const storage = new Map<string, string>();
if (saved)
storage.set(`buzz-client.v1:${savedViewer}`, JSON.stringify(saved));
// A string is stored verbatim so cases can pin the exact stored text.
if (saved !== undefined)
storage.set(
`buzz-client.v1:${savedViewer}`,
typeof saved === "string" ? saved : JSON.stringify(saved),
);
vi.stubGlobal("localStorage", {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
Expand All @@ -36,7 +40,7 @@ function setup(saved?: unknown, savedViewer = viewer) {
);
const ctx = new Context();
roots.push(ctx);
return createCommunities(ctx, true);
return createCommunities(ctx, true, undefined, openRelay);
}
afterEach(async () => {
for (const root of roots.splice(0)) await root.fiber.dispose();
Expand Down Expand Up @@ -424,3 +428,77 @@ it("keeps only valid unique unresolved aliases and never carries them to another
.memberships,
).toEqual([]);
});

it("opens the configured relay for an identity without a saved record and remembers it", async () => {
const client = setup(undefined, viewer, "https://third.example");
await flush();
await flush();
const membership = { id: "https://third.example", name: "third.example" };
expect(client.snapshot()).toMatchObject({
status: "ready",
profile: { name: "", picture: "" },
memberships: [membership],
selected: membership.id,
});
expect(requests.filter((url) => url.endsWith("/session"))).toEqual([
"/api/relay/https%3A%2F%2Fthird.example/session",
]);
const persisted = localStorage.getItem(`buzz-client.v1:${viewer}`);
assert.exists(persisted);
expect(JSON.parse(persisted)).toEqual({
profile: { name: "", picture: "" },
memberships: [membership],
selected: membership.id,
});
// A configured alias for the relay origin is honored like any other join.
const aliased = setup(undefined, "c".repeat(64), "wss://primary.example");
await flush();
expect(aliased.snapshot().memberships).toEqual([
{ id: "primary", name: "primary.example" },
]);
expect(aliased.snapshot().selected).toBe("primary");
});

it("does not seed the configured relay over a stored falsy record", async () => {
const client = setup("null", viewer, "https://third.example");
await flush();
await flush();
expect(client.snapshot()).toMatchObject({
status: "ready",
memberships: [],
selected: null,
});
expect(requests).toEqual(["/api/relay/identity"]);
expect(localStorage.getItem(`buzz-client.v1:${viewer}`)).toBe("null");
});

it("does not seed the configured relay over a stored malformed record", async () => {
const client = setup('{"memberships":', viewer, "https://third.example");
await flush();
await flush();
expect(client.snapshot()).toMatchObject({
status: "ready",
memberships: [],
selected: null,
});
expect(requests).toEqual(["/api/relay/identity"]);
expect(localStorage.getItem(`buzz-client.v1:${viewer}`)).toBe(
'{"memberships":',
);
});

it("keeps a saved record, including Personal space, instead of the configured relay", async () => {
const saved = {
profile: { name: "Local", picture: "" },
memberships: [{ id: "primary", name: "Primary" }],
selected: null,
};
const client = setup(saved, viewer, "https://third.example");
await flush();
await flush();
expect(client.snapshot()).toMatchObject({ ...saved, status: "ready" });
expect(requests).toEqual(["/api/relay/identity"]);
expect(localStorage.getItem(`buzz-client.v1:${viewer}`)).toBe(
JSON.stringify(saved),
);
});
18 changes: 14 additions & 4 deletions src/features/communities/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function createCommunities(
ctx: Context,
live: boolean,
identityNames?: IdentityNames,
openRelay = "",
) {
let state: ClientSnapshot = {
...empty(),
Expand Down Expand Up @@ -120,10 +121,10 @@ export function createCommunities(
throw new Error("Invalid local identity");
if (disposed) return;
let saved = empty();
let seeded = false;
try {
const raw = JSON.parse(
localStorage.getItem(`buzz-client.v1:${viewer}`) ?? "null",
);
const stored = localStorage.getItem(`buzz-client.v1:${viewer}`);
const raw = JSON.parse(stored ?? "null");
if (raw)
saved = {
profile: {
Expand Down Expand Up @@ -177,6 +178,14 @@ export function createCommunities(
: [],
selected: null,
};
else if (openRelay && stored === null) {
// Development opt-in for a viewer with no saved record on this origin.
// Any stored record, including Personal space or one this reader
// cannot understand, wins over the seed.
const { id, name } = communityDestination(openRelay);
saved = { ...saved, memberships: [{ id, name }], selected: id };
seeded = true;
}
if (typeof raw?.selected === "string") {
try {
saved.selected = communityDestination(raw.selected).id;
Expand All @@ -191,7 +200,8 @@ export function createCommunities(
if (!saved.memberships.some((m) => m.id === saved.selected))
saved.selected = null;
if (saved.selected) acquire(saved.selected);
update({ ...saved, viewer, status: "ready" }, false);
// A seeded record is saved once so later configuration changes cannot revoke it.
update({ ...saved, viewer, status: "ready" }, seeded);
})
.catch((error) => {
if (!disposed)
Expand Down
12 changes: 11 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ export default defineConfig(async ({ command, mode }) => {
parseCommunityAliases(aliases);
// Public routing configuration only; the viewer pin and credentials stay in Node.
const defaultRelay = env.BUZZ_RELAY_URL?.trim();
if (defaultRelay) relayOrigin(defaultRelay);
const defaultOrigin = defaultRelay ? relayOrigin(defaultRelay) : "";
// Opt-in seed: a viewer with no saved client record on this dev origin starts
// in the default relay's community. Only "1" enables it; builds never see it.
const openRelay = live && env.BUZZ_DEV_OPEN_RELAY === "1";
if (openRelay && !defaultOrigin)
throw new Error(
"BUZZ_DEV_OPEN_RELAY=1 requires BUZZ_RELAY_URL to name the community to open.",
);
const plugins: PluginOption[] = [react()];
if (live)
plugins.push(
Expand All @@ -33,6 +40,9 @@ export default defineConfig(async ({ command, mode }) => {
command === "serve" && env.BUZZ_DEV_NOTIFICATIONS === "0" ? "1" : "0",
),
"import.meta.env.VITE_BUZZ_COMMUNITY_ALIASES": JSON.stringify(aliases),
"import.meta.env.VITE_BUZZ_OPEN_RELAY": JSON.stringify(
openRelay ? defaultOrigin : "",
),
},
clearScreen: false,
server: {
Expand Down
Loading