fix(desktop): isolate dev keyring from production and make BUZZ_SHARE_IDENTITY keyring-aware (#1680)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-09 17:33:52 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 1f2afce29d
commit e5d215e3c2
11 changed files with 482 additions and 18 deletions
+9 -3
View File
@@ -95,6 +95,9 @@ const overrides = new Map([
// unified-agent-model 1A.1: inline test module moved to nest/tests.rs,
// ratcheting 1575 -> 679 (under the 1000 default; entry kept as a ratchet).
["src-tauri/src/managed_agents/nest.rs", 679],
// keyring-dev-isolation: agent key migration added copy_agent_keys_between_stores
// and load_readonly support; file grew past 1000 default. Queued to split.
["src-tauri/src/managed_agents/storage.rs", 1325],
// harness-persona-sync: persona-runtime resolution threaded into the spawn
// path here. Load-bearing feature growth; queued to split in the resolver
// unify refactor followup. +26 for resolve_effective_prompt_model_provider
@@ -192,7 +195,8 @@ const overrides = new Map([
// + inner fn with baked-env gate + 26 tests. Load-bearing correctness fix.
// am review fix: also clear stale V1 model field on provider rewrite +
// new model-clear test. Load-bearing chimera fix.
["src-tauri/src/migration.rs", 1402],
// keyring-dev-isolation: run_boot_migrations wires agent-key migration.
["src-tauri/src/migration.rs", 1415],
// onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop
// already here) for the single-toggle mark-read/unread menu item — a small
// overage from load-bearing per-message plumbing, not generic debt growth.
@@ -213,8 +217,10 @@ const overrides = new Map([
// cross-process keychain race fix (D3): interprocess lock + BlobLockGuard +
// uid-keyed lockfile path + behavioral tests add ~303 lines. Load-bearing
// security fix for the lost-update race that stranded agent keys.
["src-tauri/src/secret_store.rs", 1043],
["src-tauri/src/app_state.rs", 1033],
["src-tauri/src/secret_store.rs", 1110],
// keyring-dev-isolation: keyring_service() fn (7 lines) replaces the const
// to return "buzz-desktop-dev" in debug builds. Load-bearing isolation fix.
["src-tauri/src/app_state.rs", 1042],
// multi-slot splitting + no-op suppression (#1309): the ReadStateManager
// class grew from ~700 lines to ~1019 with the addition of
// splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots,
+11 -2
View File
@@ -184,7 +184,16 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(
/// Service name for the desktop OS keyring. Shared by the human identity key
/// and managed-agent keys (each addressed by a distinct key name within it).
pub(crate) const KEYRING_SERVICE: &str = "buzz-desktop";
///
/// Debug builds use a distinct service name so dev and production keyring
/// entries never collide on the same machine.
pub(crate) fn keyring_service() -> &'static str {
if cfg!(debug_assertions) {
"buzz-desktop-dev"
} else {
"buzz-desktop"
}
}
/// Keyring key name for the human identity nsec.
const IDENTITY_KEY_NAME: &str = "identity";
@@ -238,7 +247,7 @@ fn load_or_create_identity(data_dir: &std::path::Path) -> Result<Keys, String> {
return load_file_or_generate(&legacy_path, data_dir);
}
let store = crate::secret_store::SecretStore::shared(KEYRING_SERVICE);
let store = crate::secret_store::SecretStore::shared(keyring_service());
resolve_identity_with_store(store, &legacy_path, data_dir)
}
+377 -2
View File
@@ -1,4 +1,5 @@
use std::{
collections::HashMap,
fs::{self, File, OpenOptions},
io::{Read as _, Seek, SeekFrom, Write},
path::{Path, PathBuf},
@@ -6,7 +7,7 @@ use std::{
use tauri::{AppHandle, Manager};
use crate::app_state::KEYRING_SERVICE;
use crate::app_state::keyring_service;
use crate::managed_agents::ManagedAgentRecord;
use crate::secret_store::{KeyringProbe, SecretStore};
@@ -23,7 +24,7 @@ fn agent_keyring_name(pubkey: &str) -> String {
/// races on concurrent blob writes.
fn agent_secret_store() -> Option<&'static SecretStore> {
if cfg!(feature = "system-keyring") {
Some(SecretStore::shared(KEYRING_SERVICE))
Some(SecretStore::shared(keyring_service()))
} else {
None
}
@@ -61,9 +62,18 @@ trait KeyStore {
/// Read a key. `Ok(None)` is "no such entry" (absent); `Err` is a backend
/// failure (keyring unreachable) — the caller MUST NOT collapse the two.
fn load(&self, name: &str) -> Result<Option<String>, String>;
/// Read a key without any side effects (no legacy-key migration, no writes).
/// `Ok(None)` when the blob or the key is absent; `Err` only on backend failure.
fn load_readonly(&self, name: &str) -> Result<Option<String>, String>;
/// Read the entire blob as a map without any side effects.
/// `Ok(None)` when no blob exists yet; `Err` only on backend failure.
/// Callers must not call `migrate_legacy_key` — this is a read-only view.
fn load_all_readonly(&self) -> Result<Option<HashMap<String, String>>, String>;
/// Write `value` and read it back to confirm before the caller strips the
/// inline copy.
fn write_and_verify(&self, name: &str, value: &str) -> Result<(), String>;
/// Insert all entries from `entries` in a single blob mutation.
fn store_all(&self, entries: &HashMap<String, String>) -> Result<(), String>;
}
impl KeyStore for SecretStore {
@@ -73,6 +83,12 @@ impl KeyStore for SecretStore {
fn load(&self, name: &str) -> Result<Option<String>, String> {
SecretStore::load(self, name)
}
fn load_readonly(&self, name: &str) -> Result<Option<String>, String> {
SecretStore::load_readonly(self, name)
}
fn load_all_readonly(&self) -> Result<Option<HashMap<String, String>>, String> {
SecretStore::load_all_readonly(self)
}
fn write_and_verify(&self, name: &str, value: &str) -> Result<(), String> {
self.store(name, value)?;
match self.load(name)? {
@@ -80,6 +96,9 @@ impl KeyStore for SecretStore {
_ => Err("keyring read-back verify failed".to_string()),
}
}
fn store_all(&self, entries: &HashMap<String, String>) -> Result<(), String> {
SecretStore::store_all(self, entries)
}
}
/// Outcome of attempting to lift a record's inline key into the keyring.
@@ -355,6 +374,138 @@ fn persist_agent_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRec
}
}
}
/// One-time migration of agent keys from the production keyring service
/// (`"buzz-desktop"`) to the dev service (`"buzz-desktop-dev"`). Only runs
/// in debug builds — release builds never touch `"buzz-desktop"` from this
/// path.
///
/// Idempotent: skips any key that already exists in the dev service so
/// repeated boots after migration are no-ops. Leaves the production keyring
/// untouched — a dev build and a prod install can coexist without sharing
/// keys after this migration.
///
/// Call this at boot before `hydrate_keys` runs (i.e. before
/// `load_managed_agents` is called) so agents find their keys on first boot
/// after the service-name change.
#[cfg(debug_assertions)]
pub fn migrate_agent_keys_to_dev_service(app: &tauri::AppHandle) {
if !cfg!(feature = "system-keyring") {
return;
}
// Read the JSON store for pubkeys only — we want every instance
// record without running hydrate_keys (which would try the dev
// keyring that is empty, and log noisy "has no key" warnings).
let records = match load_agent_store(app) {
Ok(r) => r,
Err(e) => {
eprintln!("buzz-desktop: keyring-dev-migration: cannot read agent store: {e}");
return;
}
};
let pubkeys: Vec<String> = records
.into_iter()
.filter(|r| !r.pubkey.is_empty())
.map(|r| r.pubkey)
.collect();
if pubkeys.is_empty() {
return;
}
// A fresh non-singleton store for the prod service — its own empty
// cache so reads go to the OS keyring without polluting the dev
// singleton's cache.
let prod_store = crate::secret_store::SecretStore::keyring("buzz-desktop");
let dev_store = crate::secret_store::SecretStore::shared(keyring_service());
copy_agent_keys_between_stores(&pubkeys, &prod_store, dev_store);
}
/// Marker key stored inside the dev blob after a successful agent-key migration.
/// Its presence means all agent keys that existed in the prod service at
/// migration time have been copied; subsequent dev boots skip the migration
/// entirely (no prod keyring access).
#[cfg(debug_assertions)]
const DEV_MIGRATION_MARKER: &str = "_dev_migration_v1";
/// Testable core of [`migrate_agent_keys_to_dev_service`]: copy `agent:<pubkey>`
/// entries from `src` to `dst` for each pubkey, then write a migration-complete
/// marker so future boots skip the entire function with zero prod-keyring access.
///
/// On the first migration boot:
/// 1. One `dst.load_all_readonly()` — dev blob read (1 keychain prompt)
/// 2. One `src.load_all_readonly()` — prod blob read (1 keychain prompt)
/// 3. One `dst.store_all()` — dev blob write (same service as #1; macOS may
/// skip the ACL prompt if the initial grant was "Always Allow")
///
/// On subsequent boots (marker already present):
/// 1. One `dst.load_all_readonly()` — dev blob read (1 keychain prompt)
/// Returns immediately — prod keyring is NEVER accessed.
///
/// Idempotency: keys already present in `dst` are not overwritten (the agent
/// may have rotated their key in the dev service after initial migration).
/// New agents (pubkey not in `src`) are silently skipped — they will mint a
/// fresh key on their next onboarding run.
#[cfg(debug_assertions)]
fn copy_agent_keys_between_stores(pubkeys: &[String], src: &impl KeyStore, dst: &impl KeyStore) {
// One read of the dev blob. If the migration-complete marker is present,
// all prior agent keys are already in the dev service — skip entirely.
let dst_map: HashMap<String, String> = match dst.load_all_readonly() {
Ok(Some(map)) if map.contains_key(DEV_MIGRATION_MARKER) => {
return; // already migrated: 0 prod keyring accesses
}
Ok(Some(map)) => map,
Ok(None) => HashMap::new(),
Err(e) => {
eprintln!("buzz-desktop: keyring-dev-migration: cannot read dev keyring: {e}");
return;
}
};
// One read of the prod blob.
let src_map: HashMap<String, String> = match src.load_all_readonly() {
Ok(Some(map)) => map,
Ok(None) => HashMap::new(), // prod has no blob yet — nothing to copy
Err(e) => {
eprintln!("buzz-desktop: keyring-dev-migration: cannot read prod keyring: {e}");
return;
}
};
// Compute the set of entries to write: agent keys absent from dst, plus
// the migration-complete marker.
let mut to_write: HashMap<String, String> = HashMap::new();
let mut copied = 0usize;
for pubkey in pubkeys {
let name = agent_keyring_name(pubkey);
if dst_map.contains_key(&name) {
continue; // already in dev service — do not overwrite (idempotent)
}
if let Some(nsec) = src_map.get(&name) {
to_write.insert(name, nsec.clone());
copied += 1;
}
// absent from src → new agent, will mint a fresh key
}
// Always write the marker so future boots skip the prod read entirely,
// even when there were no keys to copy (empty dev environment).
to_write.insert(DEV_MIGRATION_MARKER.to_string(), "done".to_string());
if let Err(e) = dst.store_all(&to_write) {
eprintln!("buzz-desktop: keyring-dev-migration: cannot write to dev keyring: {e}");
return;
}
if copied > 0 {
eprintln!(
"buzz-desktop: keyring-dev-migration: copied {copied} agent key(s) from buzz-desktop"
);
}
}
/// Remove an agent's key from the keyring (best-effort). Called when an agent
/// is deleted so its secret does not linger in the OS store.
pub fn delete_agent_key(pubkey: &str) {
@@ -602,6 +753,7 @@ mod tests {
fail_verify: bool,
stored: RefCell<HashMap<String, String>>,
write_count: RefCell<usize>,
read_count: RefCell<usize>,
}
impl FakeKeyStore {
@@ -611,6 +763,7 @@ mod tests {
fail_verify: false,
stored: RefCell::new(HashMap::new()),
write_count: RefCell::new(0),
read_count: RefCell::new(0),
}
}
fn unreachable() -> Self {
@@ -619,6 +772,7 @@ mod tests {
fail_verify: false,
stored: RefCell::new(HashMap::new()),
write_count: RefCell::new(0),
read_count: RefCell::new(0),
}
}
fn verify_fails() -> Self {
@@ -627,6 +781,7 @@ mod tests {
fail_verify: true,
stored: RefCell::new(HashMap::new()),
write_count: RefCell::new(0),
read_count: RefCell::new(0),
}
}
/// Seed a key as already present in the keyring.
@@ -652,8 +807,26 @@ mod tests {
if !self.reachable {
return Err("keyring backend unreachable".to_string());
}
*self.read_count.borrow_mut() += 1;
Ok(self.stored.borrow().get(name).cloned())
}
fn load_readonly(&self, name: &str) -> Result<Option<String>, String> {
// The fake has no side effects, so load_readonly is identical to load.
self.load(name)
}
fn load_all_readonly(&self) -> Result<Option<HashMap<String, String>>, String> {
if !self.reachable {
return Err("keyring backend unreachable".to_string());
}
*self.read_count.borrow_mut() += 1;
let map = self.stored.borrow().clone();
// Return None when completely empty (simulates no blob written yet).
if map.is_empty() {
Ok(None)
} else {
Ok(Some(map))
}
}
fn write_and_verify(&self, name: &str, value: &str) -> Result<(), String> {
if self.fail_verify {
return Err("read-back verify failed".to_string());
@@ -664,6 +837,20 @@ mod tests {
.insert(name.to_string(), value.to_string());
Ok(())
}
fn store_all(&self, entries: &HashMap<String, String>) -> Result<(), String> {
if !self.reachable {
return Err("keyring backend unreachable".to_string());
}
if self.fail_verify {
return Err("read-back verify failed".to_string());
}
*self.write_count.borrow_mut() += 1;
let mut stored = self.stored.borrow_mut();
for (k, v) in entries {
stored.insert(k.clone(), v.clone());
}
Ok(())
}
}
fn record_with_key(nsec: &str) -> ManagedAgentRecord {
@@ -942,4 +1129,192 @@ mod tests {
"2026-05-27T15:16:32 INFO buzz_acp: starting"
);
}
// ── keyring-dev-migration tests ────────────────────────────────────────
#[test]
fn copy_agent_keys_copies_keys_present_in_src_to_dst() {
// Keys in src but not in dst must be copied in a single bulk write,
// and the migration-complete marker must be set.
let src = FakeKeyStore::reachable()
.with_key(&agent_keyring_name("agent-alpha"), "nsec1alpha")
.with_key(&agent_keyring_name("agent-beta"), "nsec1beta");
let dst = FakeKeyStore::reachable();
super::copy_agent_keys_between_stores(
&["agent-alpha".to_string(), "agent-beta".to_string()],
&src,
&dst,
);
assert_eq!(
dst.stored
.borrow()
.get(&agent_keyring_name("agent-alpha"))
.map(String::as_str),
Some("nsec1alpha"),
"agent-alpha must be copied from src to dst"
);
assert_eq!(
dst.stored
.borrow()
.get(&agent_keyring_name("agent-beta"))
.map(String::as_str),
Some("nsec1beta"),
"agent-beta must be copied from src to dst"
);
assert_eq!(
dst.stored
.borrow()
.get(super::DEV_MIGRATION_MARKER)
.map(String::as_str),
Some("done"),
"migration-complete marker must be set after first migration"
);
// Bulk write: exactly 1 store_all call.
assert_eq!(
*dst.write_count.borrow(),
1,
"must perform exactly one bulk write"
);
// Src accessed exactly once (bulk blob read).
assert_eq!(
*src.read_count.borrow(),
1,
"src must be read exactly once (bulk)"
);
}
#[test]
fn copy_agent_keys_skips_keys_already_in_dst() {
// Idempotency: a key already present in dst must NOT be overwritten
// — the agent may have rotated their key in the dev service.
let src =
FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1old");
let dst =
FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1new");
super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst);
// dst value must remain unchanged — src must not overwrite it.
assert_eq!(
dst.stored
.borrow()
.get(&agent_keyring_name("agent-alpha"))
.map(String::as_str),
Some("nsec1new"),
"key already in dst must not be overwritten by migration"
);
// Marker must still be written even though no new keys were copied.
assert_eq!(
dst.stored
.borrow()
.get(super::DEV_MIGRATION_MARKER)
.map(String::as_str),
Some("done"),
"marker must be set even when all keys are already present"
);
}
#[test]
fn copy_agent_keys_skips_keys_absent_from_src() {
// A pubkey with no entry in src (new agent that will mint a fresh key)
// must be silently skipped — no agent key written to dst.
let src = FakeKeyStore::reachable(); // empty
let dst = FakeKeyStore::reachable();
super::copy_agent_keys_between_stores(&["new-agent".to_string()], &src, &dst);
assert!(
dst.stored
.borrow()
.get(&agent_keyring_name("new-agent"))
.is_none(),
"absent src key must produce no agent key write to dst"
);
// Marker must still be written.
assert_eq!(
dst.stored
.borrow()
.get(super::DEV_MIGRATION_MARKER)
.map(String::as_str),
Some("done"),
"marker must be set even when no keys were present in src"
);
}
#[test]
fn copy_agent_keys_skips_all_when_dst_unreachable() {
// When dst keyring is unreachable the migration must be a no-op — never
// data-loss (failing to write is fine; the agent will re-mint on next
// onboarding run).
let src =
FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1alpha");
let dst = FakeKeyStore::unreachable();
super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst);
// No writes attempted to an unreachable dst.
assert_eq!(*dst.write_count.borrow(), 0);
// Src must not have been accessed (failed on dst read, returned early).
assert_eq!(
*src.read_count.borrow(),
0,
"src must not be accessed when dst is unreachable"
);
}
#[test]
fn copy_agent_keys_skips_entirely_when_marker_present() {
// After the first migration, the marker is in dst. Subsequent calls
// must return immediately — the prod keyring (src) must never be read.
let src =
FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1alpha");
let dst = FakeKeyStore::reachable()
.with_key(super::DEV_MIGRATION_MARKER, "done")
.with_key(&agent_keyring_name("agent-alpha"), "nsec1dev");
super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst);
// Src must not have been accessed at all.
assert_eq!(
*src.read_count.borrow(),
0,
"src must not be read when migration-complete marker is present"
);
// Dst must not have been written.
assert_eq!(
*dst.write_count.borrow(),
0,
"dst must not be written when migration-complete marker is present"
);
// Dev key must remain unchanged.
assert_eq!(
dst.stored
.borrow()
.get(&agent_keyring_name("agent-alpha"))
.map(String::as_str),
Some("nsec1dev"),
"dev key must not be overwritten on subsequent boots"
);
}
#[test]
fn copy_agent_keys_writes_marker_even_with_empty_agent_list() {
// An empty pubkey list (no agents yet) must still write the marker so
// future boots skip the prod read.
let src = FakeKeyStore::reachable();
let dst = FakeKeyStore::reachable();
super::copy_agent_keys_between_stores(&[], &src, &dst);
assert_eq!(
dst.stored
.borrow()
.get(super::DEV_MIGRATION_MARKER)
.map(String::as_str),
Some("done"),
"marker must be set even when pubkey list is empty"
);
}
}
+10
View File
@@ -145,6 +145,16 @@ pub fn run_boot_migrations(app: &tauri::AppHandle) {
migrate_legacy_app_data_dir(app);
sync_shared_agent_data(app);
// Dev-build-only: copy any agent keys that exist in the production
// keyring ("buzz-desktop") into the dev service ("buzz-desktop-dev")
// so existing agents don't lose their keys after the service-name split.
// Must run after sync_shared_agent_data (JSON symlinked) and before
// any load_managed_agents call (which runs hydrate_keys against the
// dev service and would log "has no key" for un-migrated entries).
#[cfg(debug_assertions)]
if is_dev {
crate::managed_agents::migrate_agent_keys_to_dev_service(app);
}
migrate_packs_to_teams(app);
reconcile_persona_team_dirs(app);
migrate_persona_provider_to_runtime(app);
+63
View File
@@ -575,6 +575,69 @@ impl SecretStore {
}
}
/// Read the secret for `key` without any legacy-migration side effects.
///
/// Unlike [`load`](Self::load), this method never calls
/// `migrate_legacy_key` and therefore never writes to or deletes from the
/// keyring. Use this when the caller must guarantee the store is not
/// mutated — for example, when reading from a foreign service (prod) to
/// copy values into a dev service.
///
/// Returns `Ok(Some(value))` when the key is present in the blob,
/// `Ok(None)` when the blob is absent or the key is not in it, and `Err`
/// only when the backend is unavailable.
pub fn load_readonly(&self, key: &str) -> Result<Option<String>, String> {
#[cfg(feature = "system-keyring")]
{
match self.load_blob()? {
Some(map) => Ok(map.get(key).cloned()),
None => Ok(None),
}
}
#[cfg(not(feature = "system-keyring"))]
{
let _ = key;
Err("system-keyring feature disabled".to_string())
}
}
/// Read the entire blob without any legacy-migration side effects.
///
/// Returns the full key→value map when a blob exists, `Ok(None)` when no
/// blob has been written yet, and `Err` only when the backend is
/// unavailable. Never calls `migrate_legacy_key`.
pub fn load_all_readonly(&self) -> Result<Option<HashMap<String, String>>, String> {
#[cfg(feature = "system-keyring")]
{
self.load_blob()
}
#[cfg(not(feature = "system-keyring"))]
{
Err("system-keyring feature disabled".to_string())
}
}
/// Insert all entries from `entries` into the blob in a single mutation.
///
/// Entries that already exist in the blob are overwritten; entries not
/// present in `entries` are left unchanged. If the resulting blob is
/// identical to what is already stored, no keychain write occurs.
pub fn store_all(&self, entries: &HashMap<String, String>) -> Result<(), String> {
#[cfg(feature = "system-keyring")]
{
self.mutate_blob(|map| {
for (k, v) in entries {
map.insert(k.clone(), v.clone());
}
})
}
#[cfg(not(feature = "system-keyring"))]
{
let _ = entries;
Err("system-keyring feature disabled".to_string())
}
}
/// On first launch after upgrading from the per-key DPK format, read the
/// old DPK entry for `key`, write it into a new blob, and delete the old
/// item. Returns `Ok(None)` when no old entry exists.
+1 -1
View File
@@ -203,7 +203,7 @@ function OnboardingLoadingGate() {
tabIndex={-1}
type="button"
>
Continue with Block Inc. workspace
Continue with default workspace
</Button>
<Button
@@ -14,6 +14,9 @@ type EphemeralChannelLike = Pick<Channel, "ttlSeconds" | "ttlDeadline">;
export const EPHEMERAL_CHANNEL_LABEL = "Ephemeral";
/** Default TTL for ephemeral channels: 7 days of inactivity. */
export const DEFAULT_EPHEMERAL_TTL_SECONDS = 7 * 24 * 60 * 60;
export type EphemeralChannelDisplay = {
detailLabel: string | null;
tooltipLabel: string;
@@ -34,6 +34,7 @@ import {
} from "@/features/channels/hooks";
import { compareMembersByRole } from "@/features/channels/lib/memberUtils";
import {
DEFAULT_EPHEMERAL_TTL_SECONDS,
formatTtlDuration,
parseTtlDuration,
} from "@/features/channels/lib/ephemeralChannel";
@@ -95,8 +96,6 @@ type ChannelManagementSheetProps = {
transparentChrome?: boolean;
};
const DEFAULT_EPHEMERAL_TTL_SECONDS = 7 * 24 * 60 * 60;
export function ChannelManagementSheet({
animateSplitEnter = false,
channel,
@@ -549,7 +548,7 @@ export function ChannelManagementSheet({
>
{ttlInvalid
? "Enter a duration like 1d, 12h, or 30m."
: "Defaults to 1d when left empty. Resets the deletion countdown from now whenever changed."}
: "Defaults to 7d when left empty. Resets the deletion countdown from now whenever changed."}
</p>
</div>
) : null}
@@ -2,6 +2,7 @@ import { ChevronDown, ClockFading, Hash, type LucideIcon } from "lucide-react";
import * as React from "react";
import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks";
import { DEFAULT_EPHEMERAL_TTL_SECONDS } from "@/features/channels/lib/ephemeralChannel";
import type { ChannelTemplate, ChannelVisibility } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
@@ -12,8 +13,6 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Switch } from "@/shared/ui/switch";
import { Textarea } from "@/shared/ui/textarea";
/** Default TTL for ephemeral channels: 7 days of inactivity. */
const EPHEMERAL_TTL_SECONDS = 604800;
const CREATE_FIELD_SHELL_CLASS =
"rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50";
const CREATE_FIELD_CONTROL_CLASS =
@@ -125,7 +124,7 @@ export function CreateChannelDialog({
name: trimmedName,
description: description.trim() || undefined,
visibility,
ttlSeconds: ephemeral ? EPHEMERAL_TTL_SECONDS : undefined,
ttlSeconds: ephemeral ? DEFAULT_EPHEMERAL_TTL_SECONDS : undefined,
templateId: selectedTemplateId ?? undefined,
});
@@ -249,7 +249,7 @@ export function WelcomeSetup({
}}
type="button"
>
Continue with Block Inc. workspace
Continue with default workspace
</Button>
)}
+3 -3
View File
@@ -595,7 +595,7 @@ test("first-run default workspace handoff gives immediate stepper feedback", asy
await expect(page.getByText("Welcome to Buzz")).toBeVisible();
await page
.getByRole("button", { name: "Continue with Block Inc. workspace" })
.getByRole("button", { name: "Continue with default workspace" })
.click();
await page.waitForTimeout(80);
@@ -603,7 +603,7 @@ test("first-run default workspace handoff gives immediate stepper feedback", asy
0,
);
await expect(
page.getByRole("button", { name: "Continue with Block Inc. workspace" }),
page.getByRole("button", { name: "Continue with default workspace" }),
).toBeVisible();
await expect(page.getByRole("progressbar")).toHaveAttribute(
"aria-valuenow",
@@ -671,7 +671,7 @@ test("welcome presents custom workspace setup as joining a workspace", async ({
await page.goto("/");
await expect(
page.getByRole("button", { name: "Continue with Block Inc. workspace" }),
page.getByRole("button", { name: "Continue with default workspace" }),
).toHaveCount(0);
await page.getByRole("button", { name: "Join a workspace" }).click();