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 { ContextBroker } from '../contextBroker'
|
||||||
import { useAIPermissionsStore } from '@/stores/aiPermissions'
|
import { useAIPermissionsStore } from '@/stores/aiPermissions'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
describe('ContextBroker', () => {
|
describe('ContextBroker', () => {
|
||||||
let broker: ContextBroker
|
let broker: ContextBroker
|
||||||
@@ -160,4 +161,96 @@ describe('ContextBroker', () => {
|
|||||||
expect(redact(line)).toBe(line)
|
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,
|
ArchyContextResponse,
|
||||||
ArchyActionResponse,
|
ArchyActionResponse,
|
||||||
ArchyChatResponse,
|
ArchyChatResponse,
|
||||||
|
ArchyContentPush,
|
||||||
} from '@/types/aiui-protocol'
|
} from '@/types/aiui-protocol'
|
||||||
import { useAIPermissionsStore } from '@/stores/aiPermissions'
|
import { useAIPermissionsStore } from '@/stores/aiPermissions'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useContainerStore, BUNDLED_APPS } from '@/stores/container'
|
import { useContainerStore, BUNDLED_APPS } from '@/stores/container'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { fileBrowserClient } from '@/api/filebrowser-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.
|
* Context Broker — mediates all communication between AIUI (iframe) and Archy.
|
||||||
@@ -24,6 +75,12 @@ export class ContextBroker {
|
|||||||
private iframe: Ref<HTMLIFrameElement | null>
|
private iframe: Ref<HTMLIFrameElement | null>
|
||||||
private allowedOrigin: string
|
private allowedOrigin: string
|
||||||
private listener: ((e: MessageEvent) => void) | null = null
|
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) {
|
constructor(iframe: Ref<HTMLIFrameElement | null>, aiuiUrl: string) {
|
||||||
this.iframe = iframe
|
this.iframe = iframe
|
||||||
@@ -85,6 +142,9 @@ export class ContextBroker {
|
|||||||
case 'chat:request':
|
case 'chat:request':
|
||||||
this.handleChatRequest(msg.id, msg.text)
|
this.handleChatRequest(msg.id, msg.text)
|
||||||
break
|
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) {
|
private async handleContextRequest(id: string, category: AIContextCategory, query?: string) {
|
||||||
const perms = useAIPermissionsStore()
|
const perms = useAIPermissionsStore()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* Archy acts as a context broker — AIUI never directly accesses node data.
|
* 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 */
|
/** Data categories that AIUI can request access to */
|
||||||
export type AIContextCategory =
|
export type AIContextCategory =
|
||||||
| 'apps'
|
| 'apps'
|
||||||
@@ -57,12 +59,27 @@ export interface AIUIChatRequest {
|
|||||||
text: string
|
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 =
|
export type AIUIRequest =
|
||||||
| AIUIContextRequest
|
| AIUIContextRequest
|
||||||
| AIUIActionRequest
|
| AIUIActionRequest
|
||||||
| AIUIReadyMessage
|
| AIUIReadyMessage
|
||||||
| AIUIThemeRequest
|
| AIUIThemeRequest
|
||||||
| AIUIChatRequest
|
| AIUIChatRequest
|
||||||
|
| AIUIContentRequest
|
||||||
|
|
||||||
// ─── Archy → AIUI (Responses) ──────────────────────────────────────────────
|
// ─── Archy → AIUI (Responses) ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -104,12 +121,29 @@ export interface ArchyChatResponse {
|
|||||||
error?: string
|
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 =
|
export type ArchyResponse =
|
||||||
| ArchyContextResponse
|
| ArchyContextResponse
|
||||||
| ArchyActionResponse
|
| ArchyActionResponse
|
||||||
| ArchyThemeResponse
|
| ArchyThemeResponse
|
||||||
| ArchyPermissionsUpdate
|
| ArchyPermissionsUpdate
|
||||||
| ArchyChatResponse
|
| ArchyChatResponse
|
||||||
|
| ArchyContentPush
|
||||||
|
|
||||||
// ─── All messages ───────────────────────────────────────────────────────────
|
// ─── All messages ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user