From ce7a2b2cd492adb1102cbd70a5f116028b0dfa2c Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 3 Aug 2026 19:36:50 -0400 Subject: [PATCH] feat(13-06): content:request/content:push channel on the existing bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aiui-protocol.ts: AIUIContentRequest (kind + optional scope, no RPC method or params) and ArchyContentPush (adapted bundle + permitted flag). - contextBroker.ts: handleContentRequest gates on the media/files permission categories (either grants access), resolves scope to content.list-mine / content.browse-peer (fanned out across every known federation peer) / content.owned-list, and routes results through archyContentAdapter's adaptContentItems before crossing the iframe boundary. contentRequestSeq is a monotonic guard: a stale RPC response that resolves after a newer content:request has started is discarded rather than posted (AIUI-03 concurrency edge). - contextBroker.test.ts: permission-denied, own-scope, and stale/ out-of-order coverage. Fixed a pre-existing latent flake risk in this file — perms.toggle() is not idempotent across tests because the permissions store persists to localStorage, which vi.clearAllMocks() does not reset; switched the new tests to perms.enableAll(). Co-Authored-By: Claude Opus 5 (1M context) --- .../services/__tests__/contextBroker.test.ts | 93 ++++++++++++ neode-ui/src/services/contextBroker.ts | 143 ++++++++++++++++++ neode-ui/src/types/aiui-protocol.ts | 34 +++++ 3 files changed, 270 insertions(+) diff --git a/neode-ui/src/services/__tests__/contextBroker.test.ts b/neode-ui/src/services/__tests__/contextBroker.test.ts index d6ba60cf..192c6185 100644 --- a/neode-ui/src/services/__tests__/contextBroker.test.ts +++ b/neode-ui/src/services/__tests__/contextBroker.test.ts @@ -20,6 +20,7 @@ vi.mock('@/api/filebrowser-client', () => ({ import { ContextBroker } from '../contextBroker' import { useAIPermissionsStore } from '@/stores/aiPermissions' +import { rpcClient } from '@/api/rpc-client' describe('ContextBroker', () => { let broker: ContextBroker @@ -160,4 +161,96 @@ describe('ContextBroker', () => { expect(redact(line)).toBe(line) }) }) + + describe('content:request', () => { + const callContentRequest = (id: string, kind: string, scope?: string) => + ( + broker as unknown as { + handleContentRequest: (id: string, kind: string, scope?: string) => Promise + } + ).handleContentRequest(id, kind, scope) + + it('refuses with permitted:false and makes no RPC call when neither media nor files is granted', async () => { + await callContentRequest('req-denied', 'films', 'own') + + expect(rpcClient.call).not.toHaveBeenCalled() + expect(mockPostMessage).toHaveBeenCalledWith( + { + type: 'content:push', + id: 'req-denied', + kind: 'films', + permitted: false, + films: [], + songs: [], + podcasts: [], + }, + expect.any(String), + ) + }) + + it('adapts content.list-mine results into a content:push with permitted:true when media is granted', async () => { + const perms = useAIPermissionsStore() + perms.enableAll() + vi.mocked(rpcClient.call).mockResolvedValueOnce({ + items: [ + { id: 'a', filename: 'movie.mp4', mime_type: 'video/mp4', size_bytes: 10, added_at: '2026-01-01T00:00:00Z' }, + ], + }) + + await callContentRequest('req-1', 'films', 'own') + + expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'content.list-mine' })) + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'content:push', + id: 'req-1', + kind: 'films', + permitted: true, + films: expect.arrayContaining([expect.objectContaining({ id: 'a' })]), + }), + expect.any(String), + ) + }) + + it('discards a stale in-flight response when a newer content:request has since started (out-of-order / AIUI-03 concurrency edge)', async () => { + const perms = useAIPermissionsStore() + perms.enableAll() + + let resolveFirst: (v: { items: unknown[] }) => void = () => {} + const firstPromise = new Promise<{ items: unknown[] }>((resolve) => { + resolveFirst = resolve + }) + + vi.mocked(rpcClient.call) + .mockImplementationOnce(() => firstPromise as unknown as Promise) + .mockResolvedValueOnce({ + items: [ + { id: 'fresh', filename: 'fresh.mp4', mime_type: 'video/mp4', size_bytes: 1, added_at: '2026-02-01T00:00:00Z' }, + ], + }) + + const firstCall = callContentRequest('stale-req', 'films', 'own') + const secondCall = callContentRequest('fresh-req', 'films', 'own') + await secondCall + + // The slow first request resolves AFTER the second one already + // completed — its result must be discarded, not posted. + resolveFirst({ + items: [ + { id: 'old', filename: 'old.mp4', mime_type: 'video/mp4', size_bytes: 1, added_at: '2026-01-01T00:00:00Z' }, + ], + }) + await firstCall + + const staleCalls = mockPostMessage.mock.calls.filter( + ([msg]) => (msg as { type?: string; id?: string }).type === 'content:push' && (msg as { id?: string }).id === 'stale-req', + ) + expect(staleCalls).toHaveLength(0) + + const freshCalls = mockPostMessage.mock.calls.filter( + ([msg]) => (msg as { type?: string; id?: string }).type === 'content:push' && (msg as { id?: string }).id === 'fresh-req', + ) + expect(freshCalls).toHaveLength(1) + }) + }) }) diff --git a/neode-ui/src/services/contextBroker.ts b/neode-ui/src/services/contextBroker.ts index 1ae55e8c..63b2c0cb 100644 --- a/neode-ui/src/services/contextBroker.ts +++ b/neode-ui/src/services/contextBroker.ts @@ -6,12 +6,63 @@ import type { ArchyContextResponse, ArchyActionResponse, ArchyChatResponse, + ArchyContentPush, } from '@/types/aiui-protocol' import { useAIPermissionsStore } from '@/stores/aiPermissions' import { useAppStore } from '@/stores/app' import { useContainerStore, BUNDLED_APPS } from '@/stores/container' import { rpcClient } from '@/api/rpc-client' import { fileBrowserClient } from '@/api/filebrowser-client' +import { + adaptContentItems, + type ArchyContentBundle, + type ArchyContentItem, +} from '@/composables/archyContentAdapter' + +/** Wire shape of `content_owned::OwnedItem` (already-purchased peer + * content, cached locally). Its fields don't match `ArchyContentItem` — + * this is intentional; `normalizeOwnedItem` below bridges the gap rather + * than widening the adapter's input type for one RPC's shape. */ +interface OwnedRpcItem { + onion: string + content_id: string + filename: string + mime_type: string + size_bytes: number + paid_sats: number + purchased_at: string +} + +/** An already-purchased item is, by definition, unlocked for this node — + * map it to `access: 'free'` regardless of what the seller's catalog + * still shows other buyers, so the adapter doesn't lock a card the user + * already paid for. */ +function normalizeOwnedItem(owned: OwnedRpcItem): ArchyContentItem { + return { + id: owned.content_id, + filename: owned.filename, + mime_type: owned.mime_type, + size_bytes: owned.size_bytes, + description: '', + access: 'free', + added_at: owned.purchased_at, + } +} + +function emptyBundle(): ArchyContentBundle { + return { films: [], songs: [], podcasts: [] } +} + +function mergeBundles(bundles: ArchyContentBundle[]): ArchyContentBundle { + return bundles.reduce( + (acc, cur) => ({ + films: [...acc.films, ...cur.films], + songs: [...acc.songs, ...cur.songs], + podcasts: [...acc.podcasts, ...cur.podcasts], + }), + emptyBundle(), + ) +} /** * Context Broker — mediates all communication between AIUI (iframe) and Archy. @@ -24,6 +75,12 @@ export class ContextBroker { private iframe: Ref private allowedOrigin: string private listener: ((e: MessageEvent) => void) | null = null + /** Monotonically-increasing content-request sequence — the AIUI-03 + * concurrency guard. Each `handleContentRequest` call captures its own + * sequence number before awaiting the RPC(s); if a newer content:request + * has started by the time it resolves, its result is discarded instead + * of posted, so a slow response can never overwrite fresher grid data. */ + private contentRequestSeq = 0 constructor(iframe: Ref, aiuiUrl: string) { this.iframe = iframe @@ -85,6 +142,9 @@ export class ContextBroker { case 'chat:request': this.handleChatRequest(msg.id, msg.text) break + case 'content:request': + this.handleContentRequest(msg.id, msg.kind, msg.scope) + break } } @@ -116,6 +176,89 @@ export class ContextBroker { } } + // Content surfaces (D-12/D-14, AIUI-03) — a single generic channel with a + // `kind` discriminator rather than one channel per content type, so + // 13-11's music-library wave can extend `kind` without touching this + // file again. Unlike `chat:request`, this channel carries node data + // (peer files, this node's own shared files, IndeeHub, owned/paid + // content) INTO the iframe, so it is a consent surface: gated on the + // media/files permission categories, checked here rather than trusting + // AIUI to have asked honestly (T-13-33). + private async handleContentRequest(id: string, kind: string, scope?: string) { + const perms = useAIPermissionsStore() + if (!perms.isEnabled('media') && !perms.isEnabled('files')) { + this.postToIframe({ + type: 'content:push', + id, + kind, + permitted: false, + ...emptyBundle(), + } satisfies ArchyContentPush) + return + } + + const seq = ++this.contentRequestSeq + const requestedScope: 'own' | 'peers' | 'owned' = + scope === 'peers' || scope === 'owned' ? scope : 'own' + + const bundle = await this.fetchAdaptedContent(requestedScope) + + // Stale-response guard: a newer content:request has since started — + // discard this result instead of flipping the grids back to older + // data (AIUI-03 concurrency edge). + if (seq !== this.contentRequestSeq) return + + this.postToIframe({ + type: 'content:push', + id, + kind, + permitted: true, + ...bundle, + } satisfies ArchyContentPush) + } + + /** + * Resolve one RPC call (or, for `peers`, a fan-out over every known + * federation peer) into an adapted content bundle. The iframe never + * chooses the RPC method or its params — only `scope` (an enum) reaches + * here, and this function is the only place that turns it into a + * `content.*` method name (T-13-34). + */ + private async fetchAdaptedContent(scope: 'own' | 'peers' | 'owned'): Promise { + try { + if (scope === 'own') { + const res = await rpcClient.call<{ items: ArchyContentItem[] }>({ method: 'content.list-mine' }) + return adaptContentItems(res.items ?? [], { source: 'own' }) + } + + if (scope === 'owned') { + const res = await rpcClient.call<{ items: OwnedRpcItem[] }>({ method: 'content.owned-list' }) + return mergeBundles( + (res.items ?? []).map((owned) => + adaptContentItems([normalizeOwnedItem(owned)], { source: 'peer', peerOnion: owned.onion }), + ), + ) + } + + // scope === 'peers' — aggregate every known federation peer's catalog. + // Any single peer's browse failing (offline, Tor timeout) does not + // fail the whole request; it just contributes an empty bundle. + const { nodes } = await rpcClient.federationListNodes() + const onions = (nodes ?? []).map((n) => n.onion).filter((onion): onion is string => !!onion) + const perPeer = await Promise.all( + onions.map((onion) => + rpcClient + .call<{ items: ArchyContentItem[] }>({ method: 'content.browse-peer', params: { onion } }) + .then((res) => adaptContentItems(res.items ?? [], { source: 'peer', peerOnion: onion })) + .catch(() => emptyBundle()), + ), + ) + return mergeBundles(perPeer) + } catch { + return emptyBundle() + } + } + private async handleContextRequest(id: string, category: AIContextCategory, query?: string) { const perms = useAIPermissionsStore() diff --git a/neode-ui/src/types/aiui-protocol.ts b/neode-ui/src/types/aiui-protocol.ts index d799ba0a..11748275 100644 --- a/neode-ui/src/types/aiui-protocol.ts +++ b/neode-ui/src/types/aiui-protocol.ts @@ -5,6 +5,8 @@ * Archy acts as a context broker — AIUI never directly accesses node data. */ +import type { ArchyContentBundle } from '@/composables/archyContentAdapter' + /** Data categories that AIUI can request access to */ export type AIContextCategory = | 'apps' @@ -57,12 +59,27 @@ export interface AIUIChatRequest { text: string } +/** + * A content-grid request from AIUI's embedded-mode client. Carries only a + * `kind` discriminator and an optional `scope` — the iframe never names an + * RPC method or params (T-13-34); the broker decides the call. A single + * generic channel (not one per content type) so 13-11's music-library wave + * can extend `kind` without touching this file again. + */ +export interface AIUIContentRequest { + type: 'content:request' + id: string + kind: 'films' | 'songs' | 'podcasts' | 'all' + scope?: 'own' | 'peers' | 'owned' +} + export type AIUIRequest = | AIUIContextRequest | AIUIActionRequest | AIUIReadyMessage | AIUIThemeRequest | AIUIChatRequest + | AIUIContentRequest // ─── Archy → AIUI (Responses) ────────────────────────────────────────────── @@ -104,12 +121,29 @@ export interface ArchyChatResponse { error?: string } +/** + * The node's answer to a `content:request` — adapted grid records + * (`archyContentAdapter.ts`'s `adaptContentItems` output) for whichever + * buckets the RPC scope produced. `permitted: false` means neither the + * `media` nor the `files` category is granted (content:request is treated + * as permitted if either is enabled — see `handleContentRequest`); the + * three arrays are empty in that case, never omitted, so AIUI can render an + * empty-state instead of hanging on an unresolved request. + */ +export type ArchyContentPush = { + type: 'content:push' + id: string + kind: string + permitted: boolean +} & Partial + export type ArchyResponse = | ArchyContextResponse | ArchyActionResponse | ArchyThemeResponse | ArchyPermissionsUpdate | ArchyChatResponse + | ArchyContentPush // ─── All messages ───────────────────────────────────────────────────────────