From 896d6072f425603ab9d064e091d6a43912259f95 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 23 Sep 2026 11:17:41 +1000 Subject: [PATCH 1/5] feat(communities): add BUZZ_DEV_OPEN_RELAY to open the default relay on fresh dev ports Set BUZZ_DEV_OPEN_RELAY=1 in .env.local to have a live dev server save and select the canonical BUZZ_RELAY_URL origin, labeled with its host, for a viewer whose local client record on that origin is absent. Only "1" enables it and it requires BUZZ_RELAY_URL, failing config loading otherwise, so the relay URL keeps its documented meaning. Any saved record, including Personal space, wins over the seed, and production builds never see it. The Vite config computes the seed only for a live dev server and exposes it via define like VITE_BUZZ_LIVE; the communities service seeds and persists the membership once during the normal identity load. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- .env.example | 6 +++ README.md | 6 ++- dev/vite-config.test.mjs | 32 +++++++++++++++ docs/communities.md | 7 +++- docs/contributing.md | 4 ++ src/app/services.ts | 1 + src/features/communities/service.test.ts | 50 +++++++++++++++++++++++- src/features/communities/service.ts | 13 +++++- vite.config.ts | 12 +++++- 9 files changed, 124 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 1a8354876..1af559134 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/README.md b/README.md index 2cc6f2d18..c6b86d606 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ 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"}' ``` @@ -76,7 +78,9 @@ in the non-live shell/fixture state. 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, without publishing anything. 3. Start a development target: ```sh just web diff --git a/dev/vite-config.test.mjs b/dev/vite-config.test.mjs index 60ae5d35f..ecd6c6d5b 100644 --- a/dev/vite-config.test.mjs +++ b/dev/vite-config.test.mjs @@ -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 }); diff --git a/docs/communities.md b/docs/communities.md index c2f3323cd..c30f8e07f 100644 --- a/docs/communities.md +++ b/docs/communities.md @@ -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. diff --git a/docs/contributing.md b/docs/contributing.md index b449a8bcf..ba2952a69 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -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 diff --git a/src/app/services.ts b/src/app/services.ts index c36a3f944..7ccd00668 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -35,6 +35,7 @@ export function createServices() { const communities = createCommunities( ctx, import.meta.env.VITE_BUZZ_LIVE === "1", + import.meta.env.VITE_BUZZ_OPEN_RELAY ?? "", ); const relay = communities.relay; ctx.effect(() => bindAgentMentions(agentControl, communities)); diff --git a/src/features/communities/service.test.ts b/src/features/communities/service.test.ts index a6f8e2e83..31ba51495 100644 --- a/src/features/communities/service.test.ts +++ b/src/features/communities/service.test.ts @@ -9,7 +9,7 @@ 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(); if (saved) storage.set(`buzz-client.v1:${savedViewer}`, JSON.stringify(saved)); @@ -36,7 +36,7 @@ function setup(saved?: unknown, savedViewer = viewer) { ); const ctx = new Context(); roots.push(ctx); - return createCommunities(ctx, true); + return createCommunities(ctx, true, openRelay); } afterEach(async () => { for (const root of roots.splice(0)) await root.fiber.dispose(); @@ -424,3 +424,49 @@ 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("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), + ); +}); diff --git a/src/features/communities/service.ts b/src/features/communities/service.ts index 8a9dfac5b..c93cbbc56 100644 --- a/src/features/communities/service.ts +++ b/src/features/communities/service.ts @@ -22,7 +22,7 @@ const empty = (): Saved => ({ memberships: [], selected: null, }); -export function createCommunities(ctx: Context, live: boolean) { +export function createCommunities(ctx: Context, live: boolean, openRelay = "") { let state: ClientSnapshot = { ...empty(), status: live ? "loading" : "unavailable", @@ -109,6 +109,7 @@ export function createCommunities(ctx: Context, live: boolean) { 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", @@ -166,6 +167,13 @@ export function createCommunities(ctx: Context, live: boolean) { : [], selected: null, }; + else if (openRelay) { + // Development opt-in for a viewer with no saved record on this origin. + // Any saved record, including Personal space, 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; @@ -180,7 +188,8 @@ export function createCommunities(ctx: Context, live: boolean) { 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) diff --git a/vite.config.ts b/vite.config.ts index b569bbe90..a8fbab499 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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( @@ -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: { From 3c6f28f1dce76155ed40e3779413ae7662975cf3 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 23 Sep 2026 13:07:53 +1000 Subject: [PATCH 2/5] fix(communities): seed the open relay only when the stored record is absent The seed ran whenever the parsed local client record was falsy, so a stored `null`, `false`, `0` or `""` was overwritten while malformed JSON was left alone. The docs say the seed is for a viewer whose record is absent, so check the raw stored string for null instead of the parsed value: any stored text, including one this reader cannot parse, now wins over the seed. Nothing in the app writes a scalar or removes the key, so this matches the code to its documentation rather than fixing an observed bug. Cover the two rows that had no test: a stored falsy record and a stored malformed record both open Personal space, request only the identity, and leave the stored text untouched. The setup helper now stores a string argument verbatim so a case can pin the exact stored text. Also exercise the define end to end in src/app/services.test.ts, where the real createServices composition seeds, selects, acquires a session for, and persists the configured relay. Co-Authored-By: Claude Opus 5 Signed-off-by: Matt Toohey --- src/app/services.test.ts | 24 +++++++++++++++- src/features/communities/service.test.ts | 36 ++++++++++++++++++++++-- src/features/communities/service.ts | 10 +++---- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/app/services.test.ts b/src/app/services.test.ts index 83f4917c4..161706381 100644 --- a/src/app/services.test.ts +++ b/src/app/services.test.ts @@ -32,12 +32,13 @@ const viewer = "a".repeat(64); const signals: AbortSignal[] = []; const streams: { url: string; close: ReturnType }[] = []; let storageReads: ReturnType; +let values: Map; beforeEach(() => { vi.useFakeTimers(); vi.stubEnv("VITE_BUZZ_LIVE", "1"); plugin.cleanup.mockReset(); - const values = new Map(); + values = new Map(); storageReads = vi.fn((key: string) => values.get(key) ?? null); vi.stubGlobal("localStorage", { getItem: storageReads, @@ -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((resolve) => { release = resolve; diff --git a/src/features/communities/service.test.ts b/src/features/communities/service.test.ts index 31ba51495..f6ba3a8ab 100644 --- a/src/features/communities/service.test.ts +++ b/src/features/communities/service.test.ts @@ -11,8 +11,12 @@ const roots: Context[] = []; const requests: string[] = []; function setup(saved?: unknown, savedViewer = viewer, openRelay = "") { const storage = new Map(); - 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), @@ -455,6 +459,34 @@ it("opens the configured relay for an identity without a saved record and rememb 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: "" }, diff --git a/src/features/communities/service.ts b/src/features/communities/service.ts index c93cbbc56..4433fa47e 100644 --- a/src/features/communities/service.ts +++ b/src/features/communities/service.ts @@ -111,9 +111,8 @@ export function createCommunities(ctx: Context, live: boolean, openRelay = "") { 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: { @@ -167,9 +166,10 @@ export function createCommunities(ctx: Context, live: boolean, openRelay = "") { : [], selected: null, }; - else if (openRelay) { + else if (openRelay && stored === null) { // Development opt-in for a viewer with no saved record on this origin. - // Any saved record, including Personal space, wins over the seed. + // 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; From c66d73078d708b19060ca50773cea8fe6be6f1e7 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 23 Sep 2026 13:14:55 +1000 Subject: [PATCH 3/5] test(communities): isolate the broker suites from a local open-relay seed The broker startup tests stub the BUZZ_ inputs to empty so loadEnv cannot mix a developer's .env.local into their assertions, but BUZZ_DEV_OPEN_RELAY was left unstubbed. With that flag set locally and BUZZ_RELAY_URL stubbed empty, config resolution hit the new guard and failed 22 tests. Co-Authored-By: Claude Opus 5 --- src/features/communities/broker-config.test.ts | 3 +++ src/features/communities/broker-identity.test.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/features/communities/broker-config.test.ts b/src/features/communities/broker-config.test.ts index 436cd5bbc..d21f306d6 100644 --- a/src/features/communities/broker-config.test.ts +++ b/src/features/communities/broker-config.test.ts @@ -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) })), ); diff --git a/src/features/communities/broker-identity.test.ts b/src/features/communities/broker-identity.test.ts index 245191895..74c077d1e 100644 --- a/src/features/communities/broker-identity.test.ts +++ b/src/features/communities/broker-identity.test.ts @@ -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)); From 14f1fdb6ba1e734741a2617c60d630a102b8d877 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 23 Sep 2026 13:20:10 +1000 Subject: [PATCH 4/5] test(app): give the page runtime integration test room under a parallel suite Booting a real Vite server, SSR-loading the service graph and rendering every bundled page costs seconds on its own, leaving almost no headroom in the 5s default. Under the full suite it timed out, matching how other heavyweight suites here declare their own budget. Co-Authored-By: Claude Opus 5 --- src/app/pages.integration.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 7c6470c4f..837c47173 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -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); From 71ffe4f120ba5fd8f9ab84d06b541a85244f7c22 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 23 Sep 2026 15:02:27 +1000 Subject: [PATCH 5/5] docs(readme): clarify open relay presence behavior Signed-off-by: Matt Toohey --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c6b86d606..f618bbecb 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,9 @@ in the non-live shell/fixture state. no default relay or alias map; Personal space and communities saved by canonical 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, without publishing anything. + 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