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
11 changes: 11 additions & 0 deletions desktop/tests/e2e/video-attachment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1614,6 +1614,17 @@ test("playback speed persists across videos and reloads", async ({ page }) => {
const player = page
.locator(`[data-message-id="${emitted.id}"]`)
.getByTestId("video-player");
// Interacting with the previous player can leave the timeline reading an
// older row. Open buffered arrivals through the normal latest-message UI;
// subscription readiness alone does not make their rows visible.
await expect
.poll(async () => {
if (await player.isVisible()) return true;
const scrollToLatest = page.getByTestId("message-scroll-to-latest");
if (await scrollToLatest.isVisible()) await scrollToLatest.click();
return false;
})
.toBe(true);
await expect(player).toBeVisible();
await player.getByRole("button", { name: "Play video" }).click();
return player;
Expand Down
9 changes: 7 additions & 2 deletions desktop/tests/e2e/workflow-local-controls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ async function addMessageStep(
) {
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByLabel("Message text").fill("Workflow notification");
// The outgoing trigger inspector also labels its input "Message text".
const stepText = dialog
.getByLabel("Message text", { exact: true })
.and(dialog.locator("#wf-step-0-text"));
await stepText.fill("Workflow notification");
await expect(stepText).toHaveValue("Workflow notification");
}

async function createEnabled(
Expand Down Expand Up @@ -288,6 +293,7 @@ test("round-trips and reopens structured message-text conditions", async ({
await openTriggerInspector(dialog);
const matchControls = dialog.getByRole("group", { name: "Match" });
const operatorButtons = matchControls.getByRole("button");
await waitForAnimations(page);
const firstOperatorBox = await operatorButtons.nth(0).boundingBox();
const secondOperatorBox = await operatorButtons.nth(1).boundingBox();
const thirdOperatorBox = await operatorButtons.nth(2).boundingBox();
Expand All @@ -299,7 +305,6 @@ test("round-trips and reopens structured message-text conditions", async ({
Math.abs((secondOperatorBox?.y ?? 0) - (firstOperatorBox?.y ?? 0)),
).toBeLessThan(1);
expect(thirdOperatorBox?.y).toBeGreaterThan(firstOperatorBox?.y ?? 0);
await waitForAnimations(page);
await matchControls.screenshot({
path: "test-results/workflow-message-condition-operators.png",
});
Expand Down
42 changes: 39 additions & 3 deletions desktop/tests/e2e/workflows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@ async function createWorkflow(

await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByLabel("Message text").fill("Workflow notification");
const messageText = dialog
.getByLabel("Message text")
.and(dialog.locator("#wf-step-0-text"));
await messageText.fill("Workflow notification");
await expect(messageText).toHaveValue("Workflow notification");
if (options?.stepName) {
await dialog.getByRole("button", { name: "Step details" }).click();
await dialog.getByLabel("Name (optional)").fill(options.stepName);
Expand Down Expand Up @@ -217,8 +221,32 @@ test("creates a workflow via the form builder", async ({ page }) => {
const workflowName = `test_workflow_${Date.now()}`;

await navigateToWorkflows(page);
// Keep the outgoing trigger's Message text mounted during the step handoff.
await page.evaluate(() => {
const animate = Element.prototype.animate;
Element.prototype.animate = function (...args) {
const animation = animate.apply(this, args);
if (this.querySelector("#wf-trigger-trigger_text-value")) {
animation.updatePlaybackRate(0.1);
}
return animation;
};
});
await createWorkflow(page, workflowName);

const yaml = await page.evaluate(() => {
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
.reverse()
.find((candidate) => candidate.command === "create_workflow");
return (call?.payload as { yamlDefinition?: string } | undefined)
?.yamlDefinition;
});
const saved = parseYaml(yaml ?? "");
expect(saved.steps[0].id).toBe("step_1");
expect(saved.steps[0].text).toBe("Workflow notification");
expect(saved.trigger.on).toBe("message_posted");
expect(saved.trigger.filter).toBeUndefined();

// Verify workflow appears in the list
await expect(page.getByTestId("workflows-view")).toContainText(workflowName);
});
Expand Down Expand Up @@ -700,7 +728,11 @@ test("captures the built editor at desktop and narrow widths", async ({
await editWorkflowName(dialog, "editor_screenshot");
await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByLabel("Message text").fill("Notify the workflow channel");
const messageText = dialog
.getByLabel("Message text")
.and(dialog.locator("#wf-step-0-text"));
await messageText.fill("Notify the workflow channel");
await expect(messageText).toHaveValue("Notify the workflow channel");
const inspector = dialog.getByTestId("workflow-node-inspector");

for (const viewport of [
Expand Down Expand Up @@ -805,7 +837,11 @@ test("pane routes use stable IDs and Form/YAML changes stay synchronized", async

await dialog.getByRole("button", { name: "Add step", exact: true }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await dialog.getByLabel("Message text").fill("first message");
const messageText = dialog
.getByLabel("Message text")
.and(dialog.locator("#wf-step-0-text"));
await messageText.fill("first message");
await expect(messageText).toHaveValue("first message");
await expect(page).toHaveURL(/pane=step%3Astep_1/);

await dialog.getByRole("button", { name: "Add after Step 1" }).click();
Expand Down
1 change: 1 addition & 0 deletions mobile/lib/shared/mentions/agent_identity_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import '../../shared/relay/relay.dart';
part 'agent_policy.dart';
part 'agent_authorization.dart';
part 'agent_publication.dart';
part 'selected_mention_authorization.dart';

/// A relay agent parsed from its kind:10100 agent-profile event.
///
Expand Down
4 changes: 4 additions & 0 deletions mobile/lib/shared/mentions/agent_policy.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ Future<List<NostrEvent>> _queryAgentFilters(

/// Overlay current owner-authenticated policy onto the existing runtime
/// directory. This does not expand discovery to owner-only coordinates yet.
/// [onProfileEvidence] observes immutable latest query heads after the final
/// scope check. It does not resolve ownership or mutate any shared profile cache.
Future<List<AgentDirectoryEntry>> resolveAgentPolicies(
RelaySessionNotifier session,
List<NostrEvent> runtimeEvents, {
Set<String>? requestedKeys,
void Function()? checkCurrent,
void Function(Map<String, NostrEvent> profiles)? onProfileEvidence,
}) async {
final latest = <String, NostrEvent>{};
for (final event in runtimeEvents.where((event) => event.kind == 10100)) {
Expand Down Expand Up @@ -64,6 +67,7 @@ Future<List<AgentDirectoryEntry>> resolveAgentPolicies(
),
], checkCurrent: checkCurrent);
checkCurrent?.call();
onProfileEvidence?.call(Map.unmodifiable(profiles));
return mergeAgentPolicies(latest.values, policies, owners);
}

Expand Down
197 changes: 197 additions & 0 deletions mobile/lib/shared/mentions/selected_mention_authorization.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
part of 'agent_identity_provider.dart';

/// Evidence requirements, not a claim that an ordinary identity is human.
enum SelectedMentionKind {
/// No fresh agent evidence for this key. Absence of agent evidence, not
/// a humanity claim: the ordinary membership flow applies.
ordinary,

/// Fresh verified agent evidence for this key: a verified NIP-OA profile
/// owner, a `bot` destination-roster role, or a signature-verified latest
/// kind:10100 runtime event. Evidence only, never permission to write.
agent,

/// Agent evidence that is not safely established: an invalid profile
/// attestation, an unverifiable latest runtime head, or prior-agent
/// provenance whose fresh evidence is gone. Never demoted to the ordinary
/// flow; carries no agent entry or invitation role.
unresolvedAgent,
}

/// Fresh evidence for one exact selected key. This is NOT permission to write:
/// consumers must apply their existing agent eligibility evaluator to [agent],
/// normal channel invitation permission, consent and scope fences separately.
/// An existing member needs no role write, whatever their current role is.
class SelectedMentionAuthorization {
/// Fresh evidence classification for this exact key. Values state what
/// fresh evidence showed, never permission, role, or humanity.
final SelectedMentionKind kind;

/// Presence in the destination's signed roster at read time only, not a
/// role, eligibility, or authorization claim.
final bool isMember;

/// Policy projection with relay-authenticated destination membership only.
/// Null for ordinary identities or unresolved prior-agent evidence. Missing
/// owner policy produces a deny-all entry, never runtime fallback.
final AgentDirectoryEntry? agent;

const SelectedMentionAuthorization._(this.kind, this.isMember, this.agent);

/// True when this key's evidence must be routed through the agent
/// authorization evaluator rather than the ordinary flow: agent or
/// unresolvedAgent evidence. Routing only, never a granted write;
/// eligibility, consent, and invitation fences still apply separately.
bool get requiresAgentAuthorization => kind != SelectedMentionKind.ordinary;

/// Role selection only, never an invitation authorization. A consumer must
/// first validate eligibility and consent and skip writes for existing members.
String? get invitationRole => switch (kind) {
SelectedMentionKind.ordinary => 'member',
SelectedMentionKind.agent => 'bot',
SelectedMentionKind.unresolvedAgent => null,
};
}

/// Read all selected keys, not just candidates whose saved isAgent bit is true.
/// [priorAgentKeys] is denial-only taint: lost provenance cannot turn a saved
/// agent capability into an ordinary-member invitation or notification bypass.
/// Absence of agent evidence permits the existing ordinary membership flow,
/// NOT a humanity assertion. Positive fresh evidence overrides saved false.
///
/// Every requested key has a result or the entire read throws. This read makes
/// no cache writes and does not opt into resolveProfileOwners. Check [isCurrent]
/// again before each side effect; re-read after consent and accepted invitations.
Future<Map<String, SelectedMentionAuthorization>>
readSelectedMentionAuthorization(
RelaySessionNotifier session,
Set<String> requestedKeys, {
required String viewer,
required String channelId,
Set<String> priorAgentKeys = const {},
required bool Function() isCurrent,
}) async {
void check() {
if (!isCurrent()) throw StateError('Mention authorization scope changed');
}

check();
if (requestedKeys.length > 1000 ||
!requestedKeys.containsAll(priorAgentKeys) ||
requestedKeys.any((key) => !RegExp(r'^[0-9a-f]{64}$').hasMatch(key)) ||
!RegExp(r'^[0-9a-f]{64}$').hasMatch(viewer) ||
channelId.isEmpty) {
throw StateError('Invalid selected mention request');
}
if (requestedKeys.isEmpty) return const {};
final authority = await session.fetchRelaySelf();
check();
final membership = await _membershipPages(
session,
authority,
viewer,
channelId,
check,
);
check();
NostrEvent? roster;
for (final event in membership) {
if (event.kind != 39002 ||
event.pubkey != authority ||
event.getTagValue('d') != channelId) {
continue;
}
if (roster == null || _newer(event, roster)) roster = event;
}
// A malformed newest head is unavailable, never permission to invite someone
// whose current membership could not be established. Missing head is likewise
// not a trustworthy empty roster at this destination.
if (roster == null ||
!verifySignedEvent(roster) ||
roster.tags.where((t) => t.isNotEmpty && t[0] == 'd').length != 1) {
throw StateError('Destination membership unavailable');
}
final members = <String, String>{};
for (final tag in roster.tags.where((t) => t.isNotEmpty && t[0] == 'p')) {
if (tag.length < 2 || members.containsKey(tag[1])) {
throw StateError('Ambiguous destination membership');
}
members[tag[1]] = tag.length >= 4 ? tag[3] : 'member';
}
final runtime = await _queryAgentFilters(session, [
for (final key in requestedKeys)
NostrFilter(kinds: const [10100], authors: [key], limit: 1),
], checkCurrent: check);
check();
Map<String, NostrEvent> profiles = const {};
final policies = await resolveAgentPolicies(
session,
runtime.where((e) => requestedKeys.contains(e.pubkey)).toList(),
requestedKeys: requestedKeys,
checkCurrent: check,
onProfileEvidence: (value) => profiles = value,
);
check();
final latestRuntime = <String, NostrEvent>{};
for (final event in runtime) {
if (event.kind != 10100 || !requestedKeys.contains(event.pubkey)) continue;
final previous = latestRuntime[event.pubkey];
if (previous == null || _newer(event, previous)) {
latestRuntime[event.pubkey] = event;
}
}
final byKey = {for (final policy in policies) policy.pubkey: policy};
final result = <String, SelectedMentionAuthorization>{};
for (final key in requestedKeys) {
final profile = profiles[key];
final owner = profile == null ? null : verifiedOaOwnerPubkey(profile);
final invalidProfile =
profile != null &&
(!verifySignedEvent(profile) ||
(owner == null &&
profile.tags.any((t) => t.isNotEmpty && t[0] == 'auth')));
final runtimeHead = latestRuntime[key];
final knownAgent =
owner != null ||
members[key] == 'bot' ||
(runtimeHead != null && verifySignedEvent(runtimeHead));
final unresolved =
invalidProfile ||
(runtimeHead != null && !verifySignedEvent(runtimeHead)) ||
(!knownAgent && priorAgentKeys.contains(key));
final kind = unresolved
? SelectedMentionKind.unresolvedAgent
: knownAgent
? SelectedMentionKind.agent
: SelectedMentionKind.ordinary;
AgentDirectoryEntry? agent;
if (kind == SelectedMentionKind.agent) {
final policy = byKey[key];
// Ownership without a matching valid policy must not revive headless
// runtime authority. mergeAgentPolicies remains the sole policy parser.
final missingOwnedPolicy = owner != null && policy?.ownerPubkey != owner;
agent = AgentDirectoryEntry(
pubkey: key,
ownerPubkey: owner,
displayName: policy?.displayName,
respondTo: missingOwnedPolicy ? 'nobody' : policy?.respondTo,
respondToAllowlist: missingOwnedPolicy
? const []
: policy?.respondToAllowlist ?? const [],
channelIds:
members.containsKey(viewer) &&
members.containsKey(key) &&
(owner == viewer || members[key] == 'bot')
? [channelId]
: const [],
);
}
result[key] = SelectedMentionAuthorization._(
kind,
members.containsKey(key),
agent,
);
}
check();
return Map.unmodifiable(result);
}
Loading
Loading