diff --git a/desktop/tests/e2e/video-attachment.spec.ts b/desktop/tests/e2e/video-attachment.spec.ts index 2c86f5a9bda..4960e7c0a6f 100644 --- a/desktop/tests/e2e/video-attachment.spec.ts +++ b/desktop/tests/e2e/video-attachment.spec.ts @@ -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; diff --git a/desktop/tests/e2e/workflow-local-controls.spec.ts b/desktop/tests/e2e/workflow-local-controls.spec.ts index 6a8319082c8..97f70051c99 100644 --- a/desktop/tests/e2e/workflow-local-controls.spec.ts +++ b/desktop/tests/e2e/workflow-local-controls.spec.ts @@ -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( @@ -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(); @@ -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", }); diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 16844a2f3e2..af83102b88a 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -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); @@ -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); }); @@ -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 [ @@ -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(); diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index 00e54e21850..a036b255245 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -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. /// diff --git a/mobile/lib/shared/mentions/agent_policy.dart b/mobile/lib/shared/mentions/agent_policy.dart index 9d2d57a2742..0abc2e076bb 100644 --- a/mobile/lib/shared/mentions/agent_policy.dart +++ b/mobile/lib/shared/mentions/agent_policy.dart @@ -24,11 +24,14 @@ Future> _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> resolveAgentPolicies( RelaySessionNotifier session, List runtimeEvents, { Set? requestedKeys, void Function()? checkCurrent, + void Function(Map profiles)? onProfileEvidence, }) async { final latest = {}; for (final event in runtimeEvents.where((event) => event.kind == 10100)) { @@ -64,6 +67,7 @@ Future> resolveAgentPolicies( ), ], checkCurrent: checkCurrent); checkCurrent?.call(); + onProfileEvidence?.call(Map.unmodifiable(profiles)); return mergeAgentPolicies(latest.values, policies, owners); } diff --git a/mobile/lib/shared/mentions/selected_mention_authorization.dart b/mobile/lib/shared/mentions/selected_mention_authorization.dart new file mode 100644 index 00000000000..c7ef3710887 --- /dev/null +++ b/mobile/lib/shared/mentions/selected_mention_authorization.dart @@ -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> +readSelectedMentionAuthorization( + RelaySessionNotifier session, + Set requestedKeys, { + required String viewer, + required String channelId, + Set 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 = {}; + 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 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 = {}; + 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 = {}; + 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); +} diff --git a/mobile/test/shared/mentions/selected_mention_authorization_test.dart b/mobile/test/shared/mentions/selected_mention_authorization_test.dart new file mode 100644 index 00000000000..e826d2df59d --- /dev/null +++ b/mobile/test/shared/mentions/selected_mention_authorization_test.dart @@ -0,0 +1,238 @@ +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../crypto/nip_oa_test.dart' show authTag, profile; +import 'agent_policy_test.dart' show PolicySession, signed; + +class _Session extends PolicySession { + _Session(super.events, this.authority, {super.failPolicy}); + final String authority; + void Function(List)? onQuery; + @override + Future fetchRelaySelf() async => authority; + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + onQuery?.call(filters); + return super.queryRelay(filters, timeout: timeout); + } +} + +void main() { + final viewer = nostr.Keys.generate(); + final person = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final relay = nostr.Keys.generate(); + final owned = profile(agent, [authTag(viewer, agent.public)]); + final runtime = signed(agent, 10100, {'respond_to': 'anyone'}); + NostrEvent roster({String? role, bool personMember = true, int time = 100}) => + signed( + relay, + 39002, + '', + time: time, + tags: [ + ['d', 'room'], + ['p', viewer.public], + if (personMember) ['p', person.public, '', 'member'], + if (role != null) ['p', agent.public, '', role], + ], + ); + NostrEvent policy(String mode, {int time = 100}) => signed( + viewer, + 30177, + {'name': 'Agent', 'parallelism': 1, 'respond_to': mode}, + time: time, + tags: [ + ['d', agent.public], + ], + ); + + Future> read( + List events, { + Set prior = const {}, + bool Function()? current, + _Session? session, + Set? keys, + }) async { + final source = session ?? _Session(events, relay.public); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => source)], + ); + container.read(relaySessionProvider); + try { + return await readSelectedMentionAuthorization( + source, + keys ?? {person.public, agent.public}, + viewer: viewer.public, + channelId: 'room', + priorAgentKeys: prior, + isCurrent: current ?? () => true, + ); + } finally { + container.dispose(); + } + } + + test( + 'exact total map preserves ordinary member and nonmember flows', + () async { + for (final member in [true, false]) { + final result = await read([roster(personMember: member)]); + expect(result.keys.toSet(), {person.public, agent.public}); + final ordinary = result[person.public]!; + expect(ordinary.kind, SelectedMentionKind.ordinary); + expect(ordinary.requiresAgentAuthorization, isFalse); + expect(ordinary.isMember, member); + expect(ordinary.invitationRole, 'member'); + expect(() => result.clear(), throwsUnsupportedError); + } + }, + ); + + test( + 'saved false cannot exclude fresh agent or select member role', + () async { + final result = await read([ + roster(role: 'member'), + owned, + policy('anyone'), + ]); + final fresh = result[agent.public]!; + expect(fresh.kind, SelectedMentionKind.agent); + expect(fresh.requiresAgentAuthorization, isTrue); + expect(fresh.invitationRole, 'bot'); + expect(fresh.isMember, isTrue); + expect(fresh.agent!.ownerPubkey, viewer.public); + expect(fresh.agent!.channelIds, ['room']); + expect(result[person.public]!.kind, SelectedMentionKind.ordinary); + }, + ); + + test( + 'saved true with lost provenance remains unresolved even as member', + () async { + final result = await read( + [roster(role: 'member'), owned, profile(agent, [], createdAt: 101)], + prior: {agent.public}, + ); + final lost = result[agent.public]!; + expect(lost.kind, SelectedMentionKind.unresolvedAgent); + expect(lost.isMember, isTrue); + expect(lost.requiresAgentAuthorization, isTrue); + expect(lost.agent, isNull); + expect(lost.invitationRole, isNull); + }, + ); + + test( + 'missing and revoked owner policy deny without runtime fallback', + () async { + for (final policies in >[ + [], + [policy('anyone'), policy('nobody', time: 101)], + ]) { + final result = await read([ + roster(role: 'bot'), + owned, + runtime, + ...policies, + ]); + final fresh = result[agent.public]!; + expect(fresh.kind, SelectedMentionKind.agent); + expect(fresh.agent!.respondTo, 'nobody'); + expect(fresh.agent!.ownerPubkey, viewer.public); + } + }, + ); + + test( + 'known runtime agent without membership never becomes ordinary', + () async { + final result = await read([roster(), runtime]); + expect(result[agent.public]!.kind, SelectedMentionKind.agent); + expect(result[agent.public]!.agent!.channelIds, isEmpty); + expect(result[agent.public]!.isMember, isFalse); + }, + ); + + test( + 'headless bot requires runtime policy; malformed OA is unresolved', + () async { + final bot = (await read([roster(role: 'bot')]))[agent.public]!; + expect(bot.kind, SelectedMentionKind.agent); + expect(bot.agent!.respondTo, isNull); + final good = (await read([roster(role: 'bot'), runtime]))[agent.public]!; + expect(good.agent!.respondTo, 'anyone'); + expect(good.agent!.channelIds, ['room']); + final bad = (await read([ + roster(role: 'bot'), + runtime, + profile(agent, [ + ['auth', viewer.public, '', 'bad'], + ]), + ]))[agent.public]!; + expect(bad.kind, SelectedMentionKind.unresolvedAgent); + expect(bad.agent, isNull); + }, + ); + + test( + 'newest roster removal wins; invalid head does not revive old member', + () async { + final result = await read([ + roster(role: 'bot'), + roster(time: 101), + runtime, + ]); + expect(result[agent.public]!.isMember, isFalse); + expect(result[agent.public]!.agent!.channelIds, isEmpty); + final invalid = NostrEvent.fromJson({ + ...roster(time: 102).toJson(), + 'content': 'tampered', + }); + await expectLater(read([roster(), invalid]), throwsStateError); + await expectLater(read([]), throwsStateError); + }, + ); + + test( + 'policy failures and scope retirement throw rather than partial map', + () async { + await expectLater( + read( + [], + session: _Session([roster(), owned], relay.public, failPolicy: true), + ), + throwsStateError, + ); + var current = true; + final session = _Session([roster(), owned], relay.public) + ..onQuery = (filters) { + if (filters.any((f) => f.kinds.contains(0))) current = false; + }; + await expectLater( + read([], session: session, current: () => current), + throwsStateError, + ); + expect(session.queries.any((f) => f.kinds.contains(30177)), isFalse); + }, + ); + + test('exact request bounds and denial-only taint are validated', () async { + await expectLater(read([roster()], keys: {'not-a-key'}), throwsStateError); + await expectLater( + read([roster()], keys: {person.public}, prior: {agent.public}), + throwsStateError, + ); + final keys = { + for (var i = 0; i < 1001; i++) i.toRadixString(16).padLeft(64, '0'), + }; + await expectLater(read([roster()], keys: keys), throwsStateError); + }); +}