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:
archipelago
2026-08-03 19:36:50 -04:00
co-authored by Claude Opus 5
parent f7691fd1bb
commit ce7a2b2cd4
3 changed files with 270 additions and 0 deletions
@@ -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)
})
})
})
+143
View File
@@ -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()
+34
View File
@@ -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 ───────────────────────────────────────────────────────────