feat(13-06): content:request/content:push channel on the existing bridge
- 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f7691fd1bb
commit
ce7a2b2cd4
@@ -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<void>
|
||||
}
|
||||
).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<never>)
|
||||
.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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<ArchyContentBundle>(
|
||||
(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<HTMLIFrameElement | null>
|
||||
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<HTMLIFrameElement | null>, 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<ArchyContentBundle> {
|
||||
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()
|
||||
|
||||
|
||||
@@ -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<ArchyContentBundle>
|
||||
|
||||
export type ArchyResponse =
|
||||
| ArchyContextResponse
|
||||
| ArchyActionResponse
|
||||
| ArchyThemeResponse
|
||||
| ArchyPermissionsUpdate
|
||||
| ArchyChatResponse
|
||||
| ArchyContentPush
|
||||
|
||||
// ─── All messages ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user