Skip to content
Draft
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
76 changes: 76 additions & 0 deletions dev/relay-broker-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -494,3 +494,79 @@ test("both real sign and publish routes admit direct replies but reject arbitrar
await h.close();
}
});

test("lifecycle uses dedicated shape-limited host routes, never the message writer", async () => {
const h = await harness((call) =>
Response.json(
call.url.endsWith("/events")
? { accepted: true, event_id: call.body.id }
: [],
),
);
try {
const transport = await connectBrokerTransport(h.base);
expect(transport.writer.kinds).not.toContain(9008);
const id = "11111111-1111-4111-8111-111111111111";
const template = {
kind: 9008,
tags: [["h", id]],
content: "",
created_at: 1700000000,
};
expect((await h.post("sign", template)).status).toBe(400);
const invalid = [
{
...template,
kind: 9002,
tags: [
["h", id],
["name", "rename"],
],
},
{
...template,
kind: 9022,
tags: [
["h", id],
["p", transport.viewer],
],
},
{ ...template, content: "extra" },
{
...template,
tags: [
["h", id],
["h", id],
],
},
];
for (const event of invalid) {
expect((await h.post("channel-lifecycle-sign", event)).status).toBe(400);
expect((await h.post("channel-lifecycle-publish", event)).status).toBe(
400,
);
}
const signal = new AbortController().signal;
const signed = await transport.channelLifecycle.sign(template, signal);
expect(verifyEvent(signed)).toBe(true);
expect(signed).toMatchObject(template);
expect((await h.post("publish", signed)).status).toBe(400);
await transport.channelLifecycle.publish(signed, signal);
expect(h.calls.filter((call) => call.url.endsWith("/events"))).toHaveLength(
1,
);
const foreignKey = new Uint8Array(32).fill(5);
const foreign = finalizeEvent(
{ ...template, tags: template.tags.map((tag) => [...tag]) },
foreignKey,
);
expect((await h.post("channel-lifecycle-publish", foreign)).status).toBe(
400,
);
expect(h.calls.filter((call) => call.url.endsWith("/events"))).toHaveLength(
1,
);
} finally {
await h.close();
}
});
26 changes: 23 additions & 3 deletions dev/relay-broker.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { validateLifecycleTemplate } from "../src/features/relay/channel-lifecycle-protocol.ts";
import {
assertSidebarStarIntent,
mutateSidebarStar,
Expand Down Expand Up @@ -722,6 +723,7 @@ export function relayBrokerPlugin({
...(await getAuthority(relay)),
relayUrl: relay,
writeKinds: [7, 9, ...WORKFLOW_KINDS],
channelLifecycle: true,
workflowReads: true,
sidebarPreferences: true,
readState: true,
Expand Down Expand Up @@ -932,6 +934,8 @@ export function relayBrokerPlugin({
![
"/api/relay/query",
"/api/relay/sign",
"/api/relay/channel-lifecycle-sign",
"/api/relay/channel-lifecycle-publish",
"/api/relay/publish",
"/api/relay/read-state-sign",
"/api/relay/read-state-publish",
Expand Down Expand Up @@ -1062,10 +1066,26 @@ export function relayBrokerPlugin({
sent: false,
});
const timings = [];
const signing = route === "/api/relay/sign";
const publishing = route === "/api/relay/publish";
const lifecycle =
route === "/api/relay/channel-lifecycle-sign" ||
route === "/api/relay/channel-lifecycle-publish";
const signing =
route === "/api/relay/sign" ||
route === "/api/relay/channel-lifecycle-sign";
const publishing =
route === "/api/relay/publish" ||
route === "/api/relay/channel-lifecycle-publish";
if (signing || publishing) {
if (![7, 9].includes(filters?.kind)) {
if (lifecycle) {
try {
validateLifecycleTemplate(filters);
} catch {
return json(res, 400, {
error: "Invalid channel lifecycle command",
sent: false,
});
}
} else if (![7, 9].includes(filters?.kind)) {
try {
validateWorkflowEvent(
{ ...filters, pubkey: signing ? viewer : filters.pubkey },
Expand Down
33 changes: 33 additions & 0 deletions docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,39 @@ groups are available; navigation history does not own them. The saved-groups
browser regression records every visible return frame and holds the redundant
decode path, so eventual restoration cannot conceal a fallback-group/scroll jump.

## Channel lifecycle

The row menu resolves fresh relay-authored metadata (`39000`), administrators
(`39001`) and membership (`39002`) at exact channel coordinates before offering
Archive/Delete/Leave or DM Hide. Archive requires a direct owner/admin role;
Delete requires a direct owner role; the last owner cannot Leave. DMs offer Hide
only. Delegated owner-agent authority and community-admin overrides are not
inferred or supported by this slice; the relay remains the final authority.

Each command has explicit confirmation; Delete additionally requires the channel
name. The lifecycle owner rechecks authority before signing and again before
publication, validates the returned command, and confirms relay-owned state before
removing a row. Archive retains membership; confirmed Delete/Leave use the existing
access-loss purge. Commands use narrow development-broker routes, never the message
outbox or automatic replay. Hosts without this capability display an unavailable
notice; native/direct-signer parity is deferred.

DM Hide publishes `41012`, not Leave or Delete. The separate relay-authored `30622`
visibility snapshot (`d=viewer`, `p=viewer`, hidden DM `h` tags) only filters sidebar
rows; it does not deny access or prevent exact conversation navigation. Visibility
refreshes with the channel roster, preserves the last good set on failure and
rejects older snapshots. Live cross-device visibility updates and an in-app DM
reopen/unhide flow are deferred; opening a DM through another supported client's
`41010` flow and refreshing restores the row.

A definitive rejection offers retry without optimistic removal. If publication or
confirmation has an uncertain outcome, the dialog warns that the command may have
taken effect, disables blind resubmission and asks the user to close and refresh
channels. Cancellation/cache clear/session replacement fence late results but cannot
retract a request already sent. Cancellation returns focus to the originating row;
confirmed removal moves an active conversation to another available destination
(or the neutral Messages page) with a sidebar/search focus fallback.

## Performance and correctness carried from Astra

The port retains the prepared-store implementation and its behavior tests:
Expand Down
55 changes: 55 additions & 0 deletions src/bundled/channels/ChannelLifecycleDialog.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
.dialog {
margin: auto;
color: var(--text-primary);
background: var(--bg-float);
border: 1px solid var(--border-primary);
border-radius: var(--radius-panel);
padding: var(--space-6);
width: min(480px, calc(100vw - 2 * var(--space-4)));
max-height: calc(100dvh - 2 * var(--space-4));
overflow: auto;
box-shadow: var(--shadow-sm);
font-size: var(--text-body-sm);
line-height: var(--text-body-sm--line-height);
letter-spacing: var(--text-body-sm--letter-spacing);
font-weight: var(--type-weight-normal);
}
.dialog::backdrop {
background: var(--bg-scrim);
}
.dialog h2 {
font-size: var(--text-heading);
line-height: var(--text-heading--line-height);
letter-spacing: var(--text-heading--letter-spacing);
font-weight: var(--type-weight-medium);
margin: 0 0 var(--space-4);
}
.dialog p {
margin: var(--space-4) 0;
}
.dialog label {
display: grid;
gap: var(--space-2);
}
.dialog input {
width: 100%;
padding: var(--space-2) var(--space-3);
color: var(--text-primary);
background: var(--bg-inset);
border: 1px solid var(--border-primary);
border-radius: var(--radius-row);
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-2);
margin-top: var(--space-6);
}
.actions [data-destructive]:not([data-disabled]) {
color: var(--red-12);
background: var(--red-3);
}
.actions [data-destructive]:hover:not([data-disabled]) {
background: var(--red-4);
}
137 changes: 137 additions & 0 deletions src/bundled/channels/ChannelLifecycleDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "../../shared/design-system/ui/Button";
import {
ChannelLifecycleUnconfirmed,
type ChannelLifecycleCapability,
} from "../../features/relay/channel-lifecycle";
import type { ChannelLifecycleAction } from "../../features/relay/channel-lifecycle-protocol";
import styles from "./ChannelLifecycleDialog.module.css";

const copy = {
archive: {
title: "Archive channel",
detail:
"Archive this channel for everyone and remove it from the sidebar. Messages are retained. A channel administrator can unarchive it from another supported client.",
},
delete: {
title: "Delete channel",
detail:
"Delete this channel for everyone. You cannot undo this action from Buzz.",
},
leave: {
title: "Leave channel",
detail:
"Leave this channel and remove it from your sidebar. You may need an invitation to rejoin a private channel.",
},
hide: {
title: "Hide conversation",
detail:
"Hide this conversation from your sidebar only. Messages and membership are kept; other participants are not removed.",
},
} as const;

export function ChannelLifecycleDialog({
channelId,
channelName,
action,
lifecycle,
close,
completed,
}: {
channelId: string;
channelName: string;
action: ChannelLifecycleAction;
lifecycle: ChannelLifecycleCapability;
close(): void;
completed(): void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [refreshRequired, setRefreshRequired] = useState(false);
const [confirmation, setConfirmation] = useState("");
const operation = useRef<AbortController | undefined>(undefined);
useEffect(() => {
dialog.current?.showModal();
return () => {
operation.current?.abort();
};
}, []);
const submit = async () => {
if (
operation.current ||
refreshRequired ||
(action === "delete" && confirmation !== channelName)
)
return;
const controller = new AbortController();
operation.current = controller;
setBusy(true);
setError("");
try {
await lifecycle.run(action, channelId, controller.signal);
if (!controller.signal.aborted) completed();
} catch (error) {
if (!controller.signal.aborted) {
setError(error instanceof Error ? error.message : String(error));
setRefreshRequired(error instanceof ChannelLifecycleUnconfirmed);
}
} finally {
operation.current = undefined;
if (!controller.signal.aborted) setBusy(false);
}
};
return (
<dialog
ref={dialog}
data-buzz-ui=""
className={styles.dialog}
aria-labelledby="channel-lifecycle-title"
aria-describedby="channel-lifecycle-description"
onCancel={(event) => {
event.preventDefault();
if (!busy) close();
}}
>
<h2 id="channel-lifecycle-title">
{copy[action].title}: {channelName}
</h2>
<p id="channel-lifecycle-description">{copy[action].detail}</p>
{action === "delete" && (
<label>
Type {channelName} to confirm
<input
aria-label="Channel name confirmation"
value={confirmation}
disabled={busy}
onChange={(event) => setConfirmation(event.target.value)}
autoComplete="off"
/>
</label>
)}
{error && <p role="alert">{error}</p>}
{busy && (
<p role="status">
Checking permissions and waiting for relay confirmation…
</p>
)}
<div className={styles.actions}>
<Button type="button" disabled={busy} onClick={close}>
Cancel
</Button>
<Button
type="button"
data-destructive=""
disabled={
busy ||
refreshRequired ||
(action === "delete" && confirmation !== channelName)
}
onClick={() => void submit()}
>
{copy[action].title}
</Button>
</div>
</dialog>
);
}
Loading
Loading