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
+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()