refactor(extension-*,stage-tamagotchi,stage-ui,server-*): rename to extension, improve DX (#1892)
--------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by-agent: Codex
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
DeliveryConfig,
|
||||
ExtensionIdentity,
|
||||
ExtensionModuleIdentity,
|
||||
MetadataEventSource,
|
||||
WebSocketBaseEvent,
|
||||
WebSocketEvent,
|
||||
@@ -10,7 +12,7 @@ import type {
|
||||
RoutingPolicy,
|
||||
} from './middlewares'
|
||||
import type { ServerWsConsumerSelectionCandidate, ServerWsStickyAssignment } from './server-ws/core'
|
||||
import type { AuthenticatedPeer, Peer } from './types'
|
||||
import type { AuthenticatedPeer, Peer, RegisteredExtensionModule } from './types'
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { timingSafeEqual } from 'node:crypto'
|
||||
@@ -284,7 +286,7 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
// === Registries & Orchestrators ===
|
||||
const peerStore = createServerWsPeerStore<AuthenticatedPeer>()
|
||||
const peers = peerStore.peers
|
||||
const peersByModule = new Map<string, Map<number | undefined, AuthenticatedPeer>>()
|
||||
const peersByModule = new Map<string, Map<number | string | undefined, AuthenticatedPeer>>()
|
||||
const consumers = createConsumerOrchestrator()
|
||||
const heartbeatTtlMs = options?.heartbeat?.readTimeout ?? serverWsDefaultHeartbeatTtlMs
|
||||
const heartbeatMessage = options?.heartbeat?.message ?? MessageHeartbeat.Pong
|
||||
@@ -359,15 +361,26 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
peers.delete(id)
|
||||
unregisterModulePeer(peerInfo, 'heartbeat expired')
|
||||
}
|
||||
else if (peerInfo.missedHeartbeats >= serverWsHealthCheckMissesUnhealthy && peerInfo.healthy !== false && peerInfo.name && peerInfo.identity) {
|
||||
else if (peerInfo.missedHeartbeats >= serverWsHealthCheckMissesUnhealthy && peerInfo.healthy !== false) {
|
||||
// 5 consecutive misses — mark unhealthy
|
||||
peerInfo.healthy = false
|
||||
logger.withFields({ peer: id, peerName: peerInfo.name, missedHeartbeats: peerInfo.missedHeartbeats }).debug('heartbeat late, marking unhealthy')
|
||||
broadcastToAuthenticated({
|
||||
type: 'registry:modules:health:unhealthy',
|
||||
data: { name: peerInfo.name, index: peerInfo.index, identity: peerInfo.identity, reason: 'heartbeat late' },
|
||||
metadata: createEventMetadata(instanceId),
|
||||
})
|
||||
|
||||
if (peerInfo.name && peerInfo.identity) {
|
||||
broadcastToAuthenticated({
|
||||
type: 'registry:modules:health:unhealthy',
|
||||
data: { name: peerInfo.name, index: peerInfo.index, identity: peerInfo.identity, reason: 'heartbeat late' },
|
||||
metadata: createEventMetadata(instanceId),
|
||||
})
|
||||
}
|
||||
|
||||
for (const module of peerInfo.extensionModules?.values() ?? []) {
|
||||
broadcastToAuthenticated({
|
||||
type: 'registry:modules:health:unhealthy',
|
||||
data: { name: module.name, identity: module.identity, reason: 'heartbeat late' },
|
||||
metadata: createEventMetadata(instanceId),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, healthCheckIntervalMs)
|
||||
@@ -376,22 +389,49 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
}
|
||||
|
||||
// === Module Registry & Consumer Management ===
|
||||
function registerModulePeer(p: AuthenticatedPeer, name: string, index?: number) {
|
||||
if (!peersByModule.has(name)) {
|
||||
peersByModule.set(name, new Map())
|
||||
function registerExtensionModulePeer(p: AuthenticatedPeer, module: RegisteredExtensionModule) {
|
||||
p.extensionModules ??= new Map()
|
||||
const previous = p.extensionModules.get(module.identity.id)
|
||||
if (previous && previous.name !== module.name) {
|
||||
unregisterExtensionModuleRegistration(p, previous, 'reannounced')
|
||||
}
|
||||
|
||||
const group = peersByModule.get(name)!
|
||||
if (group.has(index)) {
|
||||
// log instead of silent overwrite
|
||||
logger.withFields({ name, index }).debug('peer replaced for module')
|
||||
p.extensionModules.set(module.identity.id, module)
|
||||
|
||||
if (!peersByModule.has(module.name)) {
|
||||
peersByModule.set(module.name, new Map())
|
||||
}
|
||||
|
||||
peersByModule.get(module.name)!.set(module.identity.id, p)
|
||||
p.healthy = true
|
||||
group.set(index, p)
|
||||
broadcastRegistrySync()
|
||||
}
|
||||
|
||||
function findModulePeer(moduleName: string, moduleIndex: number | undefined, identity?: MetadataEventSource) {
|
||||
if (isExtensionModuleIdentity(identity)) {
|
||||
return peersByModule.get(moduleName)?.get(identity.id)
|
||||
}
|
||||
|
||||
// REVIEW: This keeps legacy indexed websocket module routing while extension modules move to identity keys.
|
||||
if (typeof moduleIndex !== 'undefined') {
|
||||
return peersByModule.get(moduleName)?.get(moduleIndex)
|
||||
}
|
||||
|
||||
const group = peersByModule.get(moduleName)
|
||||
if (!group) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// REVIEW: This preserves the old unindexed module bucket until server module routing is fully identity-based.
|
||||
const legacyPeer = group.get(undefined)
|
||||
if (legacyPeer) {
|
||||
return legacyPeer
|
||||
}
|
||||
|
||||
const peers = [...group.values()]
|
||||
return peers.length === 1 ? peers[0] : undefined
|
||||
}
|
||||
|
||||
function registerConsumer(peerId: string, event: string, mode: ReturnType<typeof normalizeConsumerMode>, group?: string, priority?: number) {
|
||||
consumers.register({ peerId, event, mode, group, priority })
|
||||
}
|
||||
@@ -453,11 +493,11 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
}
|
||||
}
|
||||
|
||||
// broadcast module:de-announced to all authenticated peers
|
||||
// broadcast extension:module:de-announced to all authenticated peers
|
||||
if (peerInfo.identity) {
|
||||
broadcastToAuthenticated({
|
||||
type: 'module:de-announced',
|
||||
data: { name: peerInfo.name, index: peerInfo.index, identity: peerInfo.identity, reason: options?.reason },
|
||||
type: 'extension:module:de-announced',
|
||||
data: { name: peerInfo.name, identity: peerInfo.identity, possibleEvents: [], reason: options?.reason },
|
||||
metadata: createEventMetadata(instanceId),
|
||||
})
|
||||
}
|
||||
@@ -468,18 +508,80 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
broadcastRegistrySync()
|
||||
}
|
||||
|
||||
function unregisterExtensionModuleRegistration(
|
||||
peerInfo: AuthenticatedPeer,
|
||||
module: RegisteredExtensionModule,
|
||||
reason?: string,
|
||||
) {
|
||||
const group = peersByModule.get(module.name)
|
||||
if (group?.get(module.identity.id) === peerInfo) {
|
||||
group.delete(module.identity.id)
|
||||
|
||||
if (group.size === 0) {
|
||||
peersByModule.delete(module.name)
|
||||
}
|
||||
}
|
||||
|
||||
peerInfo.extensionModules?.delete(module.identity.id)
|
||||
broadcastToAuthenticated({
|
||||
type: 'extension:module:de-announced',
|
||||
data: { name: module.name, identity: module.identity, possibleEvents: [], reason },
|
||||
metadata: createEventMetadata(instanceId),
|
||||
})
|
||||
}
|
||||
|
||||
function unregisterExtensionModuleRegistrations(peerInfo: AuthenticatedPeer, reason?: string) {
|
||||
if (!peerInfo.extensionModules?.size) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const module of Array.from(peerInfo.extensionModules.values())) {
|
||||
unregisterExtensionModuleRegistration(peerInfo, module, reason)
|
||||
}
|
||||
|
||||
peerInfo.extensionModules.clear()
|
||||
broadcastRegistrySync()
|
||||
}
|
||||
|
||||
function unregisterModulePeer(peerInfo: AuthenticatedPeer, reason?: string) {
|
||||
unregisterModuleRegistration(peerInfo, { reason })
|
||||
unregisterExtensionModuleRegistrations(peerInfo, reason)
|
||||
}
|
||||
|
||||
function listKnownModules() {
|
||||
return Array.from(peers.values())
|
||||
const legacyModules = Array.from(peers.values())
|
||||
.filter(peerInfo => peerInfo.name && peerInfo.identity)
|
||||
.map(peerInfo => ({
|
||||
name: peerInfo.name,
|
||||
index: peerInfo.index,
|
||||
identity: peerInfo.identity!,
|
||||
}))
|
||||
|
||||
const extensionModules = Array.from(peers.values()).flatMap(peerInfo =>
|
||||
Array.from(peerInfo.extensionModules?.values() ?? []).map(module => ({
|
||||
name: module.name,
|
||||
identity: module.identity,
|
||||
})),
|
||||
)
|
||||
|
||||
return [...legacyModules, ...extensionModules]
|
||||
}
|
||||
|
||||
function isExtensionIdentity(value: unknown): value is ExtensionIdentity {
|
||||
return Boolean(
|
||||
value
|
||||
&& typeof value === 'object'
|
||||
&& typeof (value as Partial<ExtensionIdentity>).id === 'string',
|
||||
)
|
||||
}
|
||||
|
||||
function isExtensionModuleIdentity(value: unknown): value is ExtensionModuleIdentity {
|
||||
return Boolean(
|
||||
value
|
||||
&& typeof value === 'object'
|
||||
&& typeof (value as Partial<ExtensionModuleIdentity>).id === 'string'
|
||||
&& isExtensionIdentity((value as Partial<ExtensionModuleIdentity>).extension),
|
||||
)
|
||||
}
|
||||
|
||||
// === Broadcasting & Registry Synchronization ===
|
||||
@@ -566,7 +668,7 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
if (authenticatedPeer) {
|
||||
markPeerAlive(authenticatedPeer, { parentId: event.metadata?.event.id })
|
||||
|
||||
if (authenticatedPeer.authenticated && event.metadata?.source) {
|
||||
if (authenticatedPeer.authenticated && isExtensionModuleIdentity(event.metadata?.source)) {
|
||||
authenticatedPeer.identity = event.metadata.source
|
||||
}
|
||||
}
|
||||
@@ -610,56 +712,135 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
return
|
||||
}
|
||||
|
||||
case 'module:announce': {
|
||||
case 'peer:authenticate': {
|
||||
const clientToken = typeof event.data.token === 'string' ? event.data.token : ''
|
||||
if (authToken && !timingSafeCompare(clientToken, authToken)) {
|
||||
logger.withFields({ peer: peer.id, peerRemote: peer.remoteAddress, peerRequest: peer.request?.url }).log('peer authentication failed')
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.invalidToken, event.metadata?.event.id))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const authenticatedPeerId = event.data.peerId ?? peer.id
|
||||
send(peer, RESPONSES.peerAuthenticated(authenticatedPeerId, event.metadata?.event.id))
|
||||
const p = peers.get(peer.id)
|
||||
if (p) {
|
||||
p.authenticated = true
|
||||
p.peerIds ??= new Set()
|
||||
p.peerIds.add(peer.id)
|
||||
p.peerIds.add(authenticatedPeerId)
|
||||
}
|
||||
|
||||
sendRegistrySync(peer, event.metadata?.event.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'extension:authenticate': {
|
||||
const clientToken = typeof event.data.token === 'string' ? event.data.token : ''
|
||||
if (authToken && !timingSafeCompare(clientToken, authToken)) {
|
||||
logger.withFields({ peer: peer.id, peerRemote: peer.remoteAddress, peerRequest: peer.request?.url }).log('extension authentication failed')
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.invalidToken, event.metadata?.event.id))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const p = peers.get(peer.id)
|
||||
if (p) {
|
||||
p.authenticated = true
|
||||
p.extensionIdentity = event.data.identity
|
||||
}
|
||||
|
||||
send(peer, RESPONSES.extensionAuthenticated(event.data.identity, event.metadata?.event.id))
|
||||
sendRegistrySync(peer, event.metadata?.event.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'extension:announce': {
|
||||
const p = peers.get(peer.id)
|
||||
if (!p) {
|
||||
return
|
||||
}
|
||||
|
||||
const { name, index, identity } = event.data as { name: string, index?: number, identity?: MetadataEventSource }
|
||||
if (!name || typeof name !== 'string') {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceNameInvalid))
|
||||
|
||||
return
|
||||
}
|
||||
if (typeof index !== 'undefined') {
|
||||
if (!Number.isInteger(index) || index < 0) {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIndexInvalid))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!identity || identity.kind !== 'plugin' || !identity.plugin?.id) {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIdentityInvalid))
|
||||
|
||||
return
|
||||
}
|
||||
if (authToken && !p.authenticated) {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.mustAuthenticateBeforeAnnouncing))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
unregisterModuleRegistration(p, {
|
||||
reason: 're-announcing',
|
||||
unregisterConsumers: false,
|
||||
if (!isExtensionIdentity(event.data.identity)) {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIdentityInvalid))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
p.extensionIdentity = event.data.identity
|
||||
|
||||
send(peer, {
|
||||
type: 'extension:announced',
|
||||
data: event.data,
|
||||
metadata: createEventMetadata(instanceId, event.metadata?.event.id),
|
||||
})
|
||||
|
||||
p.name = name
|
||||
p.index = index
|
||||
p.identity = identity
|
||||
|
||||
registerModulePeer(p, name, index)
|
||||
|
||||
// broadcast module:announced to all authenticated peers
|
||||
for (const other of peers.values()) {
|
||||
// only send to
|
||||
// 1. authenticated peers
|
||||
// 2. other peers except the announcing peer itself
|
||||
if (other.authenticated && !(other.peer.id === peer.id)) {
|
||||
send(other.peer, {
|
||||
type: 'module:announced',
|
||||
data: { name, index, identity },
|
||||
type: 'extension:announced',
|
||||
data: event.data,
|
||||
metadata: createEventMetadata(instanceId, event.metadata?.event.id),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'extension:module:announce': {
|
||||
const p = peers.get(peer.id)
|
||||
if (!p) {
|
||||
return
|
||||
}
|
||||
|
||||
if (authToken && !p.authenticated) {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.mustAuthenticateBeforeAnnouncing))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const { name, identity } = event.data
|
||||
if (!name || typeof name !== 'string') {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceNameInvalid))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!isExtensionModuleIdentity(identity)) {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIdentityInvalid))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (p.extensionIdentity && identity.extension.id !== p.extensionIdentity.id) {
|
||||
send(peer, RESPONSES.error(ServerErrorMessages.moduleAnnounceIdentityInvalid))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
p.extensionIdentity = identity.extension
|
||||
registerExtensionModulePeer(p, { name, identity })
|
||||
|
||||
send(peer, {
|
||||
type: 'extension:module:announced',
|
||||
data: event.data,
|
||||
metadata: createEventMetadata(instanceId, event.metadata?.event.id),
|
||||
})
|
||||
|
||||
for (const other of peers.values()) {
|
||||
if (other.authenticated && !(other.peer.id === peer.id)) {
|
||||
send(other.peer, {
|
||||
type: 'extension:module:announced',
|
||||
data: event.data,
|
||||
metadata: createEventMetadata(instanceId, event.metadata?.event.id),
|
||||
})
|
||||
}
|
||||
@@ -675,7 +856,7 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
identity?: MetadataEventSource
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
const moduleName = data.moduleName ?? data.identity?.plugin?.id ?? ''
|
||||
const moduleName = data.moduleName ?? (isExtensionModuleIdentity(data.identity) ? data.identity.id : '') ?? ''
|
||||
const moduleIndex = data.moduleIndex
|
||||
const config = data.config
|
||||
|
||||
@@ -692,7 +873,7 @@ export function setupApp(options?: AppOptions): { app: H3, closeAllPeers: () =>
|
||||
}
|
||||
}
|
||||
|
||||
const target = peersByModule.get(moduleName)?.get(moduleIndex)
|
||||
const target = findModulePeer(moduleName, moduleIndex, data.identity)
|
||||
if (target) {
|
||||
send(target.peer, {
|
||||
type: 'module:configure',
|
||||
|
||||
@@ -10,7 +10,9 @@ import { matchesLabelSelector, matchesLabelSelectors, matchesRouteExpression } f
|
||||
function createPeer(options: {
|
||||
id: string
|
||||
name: string
|
||||
plugin?: string
|
||||
peerIds?: string[]
|
||||
extensionLabels?: Record<string, string>
|
||||
extension?: string
|
||||
instanceId?: string
|
||||
labels?: Record<string, string>
|
||||
authenticated?: boolean
|
||||
@@ -23,13 +25,41 @@ function createPeer(options: {
|
||||
remoteAddress: '127.0.0.1',
|
||||
},
|
||||
authenticated: options.authenticated ?? true,
|
||||
peerIds: options.peerIds ? new Set(options.peerIds) : undefined,
|
||||
name: options.name,
|
||||
identity: options.plugin && options.instanceId
|
||||
? { kind: 'plugin', plugin: { id: options.plugin }, id: options.instanceId, labels: options.labels }
|
||||
identity: options.extension && options.instanceId
|
||||
? { id: options.instanceId, extension: { id: options.extension }, labels: options.labels }
|
||||
: undefined,
|
||||
extensionIdentity: options.extensionLabels
|
||||
? { id: options.name, sessionId: `${options.id}-session`, labels: options.extensionLabels }
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function createExtensionModulePeer(): AuthenticatedPeer {
|
||||
const peer = createPeer({
|
||||
id: 'peer-extension',
|
||||
name: 'airi-extension-chess',
|
||||
extension: 'airi-extension-chess',
|
||||
instanceId: 'extension-session-1',
|
||||
})
|
||||
|
||||
peer.extensionModules = new Map([
|
||||
['chess-gamelet', {
|
||||
name: 'character',
|
||||
identity: {
|
||||
id: 'chess-gamelet',
|
||||
extension: {
|
||||
id: 'airi-extension-chess',
|
||||
sessionId: 'extension-session-1',
|
||||
},
|
||||
},
|
||||
}],
|
||||
])
|
||||
|
||||
return peer
|
||||
}
|
||||
|
||||
function createSparkNotifyEvent(overrides: Partial<WebSocketEventOf<'spark:notify'>> = {}): WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify'], any> {
|
||||
const data: WebSocketEvents['spark:notify'] = {
|
||||
id: 'evt-1',
|
||||
@@ -45,7 +75,7 @@ function createSparkNotifyEvent(overrides: Partial<WebSocketEventOf<'spark:notif
|
||||
type: 'spark:notify',
|
||||
data,
|
||||
metadata: overrides.metadata ?? {
|
||||
source: { kind: 'plugin', plugin: { id: 'server-runtime' }, id: 'test' },
|
||||
source: { id: 'test', extension: { id: 'server-runtime' } },
|
||||
event: { id: data.id },
|
||||
},
|
||||
route: overrides.route,
|
||||
@@ -70,7 +100,7 @@ describe('match-expression', () => {
|
||||
const peer = createPeer({
|
||||
id: 'peer-1',
|
||||
name: 'stage-ui',
|
||||
plugin: 'stage-ui',
|
||||
extension: 'stage-ui',
|
||||
instanceId: 'stage-ui-1',
|
||||
labels: { env: 'prod' },
|
||||
})
|
||||
@@ -136,7 +166,7 @@ describe('route middleware', () => {
|
||||
type: 'spark:notify',
|
||||
data: 'not-an-object',
|
||||
metadata: {
|
||||
source: { kind: 'plugin', plugin: { id: 'server-runtime' }, id: 'test' },
|
||||
source: { id: 'test', extension: { id: 'server-runtime' } },
|
||||
event: { id: 'evt-primitive' },
|
||||
},
|
||||
route: undefined,
|
||||
@@ -149,7 +179,7 @@ describe('route middleware', () => {
|
||||
const peer = createPeer({
|
||||
id: 'peer-2',
|
||||
name: 'telegram-bot',
|
||||
plugin: 'telegram-bot',
|
||||
extension: 'telegram-bot',
|
||||
instanceId: 'telegram-1',
|
||||
labels: { app: 'telegram', env: 'prod' },
|
||||
})
|
||||
@@ -158,10 +188,56 @@ describe('route middleware', () => {
|
||||
expect(matchesDestinations(['label:env=dev'], peer)).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(matchesDestinations(['label:surface=websocket-extension'], peer)).toBe(true)
|
||||
*/
|
||||
it('matches destinations by extension identity labels', () => {
|
||||
const peer = createPeer({
|
||||
id: 'peer-extension-labels',
|
||||
name: 'airi-extension',
|
||||
extensionLabels: { surface: 'websocket-extension' },
|
||||
})
|
||||
|
||||
expect(matchesDestinations(['label:surface=websocket-extension'], peer)).toBe(true)
|
||||
expect(matchesRouteExpression({ type: 'label', selectors: ['surface=websocket-extension'] }, peer)).toBe(true)
|
||||
expect(matchesDestinations(['label:surface=legacy-plugin'], peer)).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(matchesDestinations(['peer:stage-window'], peer)).toBe(true)
|
||||
*/
|
||||
it('matches destinations by acknowledged peer id aliases', () => {
|
||||
const peer = createPeer({
|
||||
id: 'runtime-peer-1',
|
||||
name: 'stage-window',
|
||||
peerIds: ['runtime-peer-1', 'stage-window'],
|
||||
})
|
||||
|
||||
expect(matchesDestinations(['peer:stage-window'], peer)).toBe(true)
|
||||
expect(matchesDestinations([{ type: 'ids', ids: ['stage-window'] }], peer)).toBe(true)
|
||||
expect(matchesDestinations(['peer:missing'], peer)).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(matchesDestinations(['module:character'], peer)).toBe(true)
|
||||
*/
|
||||
it('matches destinations by announced extension module name', () => {
|
||||
const peer = createExtensionModulePeer()
|
||||
|
||||
expect(matchesDestinations(['module:character'], peer)).toBe(true)
|
||||
expect(matchesDestinations(['character'], peer)).toBe(true)
|
||||
expect(matchesDestinations(['chess-*'], peer)).toBe(true)
|
||||
expect(matchesDestinations(['module:missing'], peer)).toBe(false)
|
||||
expect(matchesDestinations(['missing'], peer)).toBe(false)
|
||||
})
|
||||
|
||||
it('policy middleware filters targets', () => {
|
||||
const peers = new Map<string, AuthenticatedPeer>([
|
||||
['peer-1', createPeer({ id: 'peer-1', name: 'telegram', plugin: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })],
|
||||
['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', plugin: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'dev' } })],
|
||||
['peer-1', createPeer({ id: 'peer-1', name: 'telegram', extension: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })],
|
||||
['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', extension: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'dev' } })],
|
||||
])
|
||||
|
||||
const policy = createPolicyMiddleware({ allowLabels: ['env=prod'] })
|
||||
@@ -185,8 +261,8 @@ describe('route middleware', () => {
|
||||
|
||||
it('policy middleware excludes unauthenticated peers', () => {
|
||||
const peers = new Map<string, AuthenticatedPeer>([
|
||||
['peer-1', createPeer({ id: 'peer-1', name: 'telegram', plugin: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })],
|
||||
['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', plugin: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'prod' }, authenticated: false })],
|
||||
['peer-1', createPeer({ id: 'peer-1', name: 'telegram', extension: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })],
|
||||
['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', extension: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'prod' }, authenticated: false })],
|
||||
])
|
||||
|
||||
const policy = createPolicyMiddleware({ allowLabels: ['env=prod'] })
|
||||
@@ -206,8 +282,8 @@ describe('route middleware', () => {
|
||||
|
||||
it('policy middleware does not authorize bypass by itself', () => {
|
||||
const peers = new Map<string, AuthenticatedPeer>([
|
||||
['peer-1', createPeer({ id: 'peer-1', name: 'telegram', plugin: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })],
|
||||
['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', plugin: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'dev' } })],
|
||||
['peer-1', createPeer({ id: 'peer-1', name: 'telegram', extension: 'telegram-bot', instanceId: 'telegram-1', labels: { env: 'prod' } })],
|
||||
['peer-2', createPeer({ id: 'peer-2', name: 'stage-ui', extension: 'stage-ui', instanceId: 'stage-ui-1', labels: { env: 'dev' } })],
|
||||
])
|
||||
|
||||
const policy = createPolicyMiddleware({ allowLabels: ['env=prod'] })
|
||||
@@ -229,7 +305,7 @@ describe('route middleware', () => {
|
||||
const peer = createPeer({
|
||||
id: 'peer-3',
|
||||
name: 'debug-ui',
|
||||
plugin: 'debug-ui',
|
||||
extension: 'debug-ui',
|
||||
instanceId: 'debug-ui-1',
|
||||
labels: { devtools: 'true' },
|
||||
})
|
||||
|
||||
@@ -10,8 +10,8 @@ export type RouteDecision
|
||||
| { type: 'targets', targetIds: Set<string> }
|
||||
|
||||
export interface RoutingPolicy {
|
||||
allowPlugins?: string[]
|
||||
denyPlugins?: string[]
|
||||
allowExtensions?: string[]
|
||||
denyExtensions?: string[]
|
||||
allowLabels?: string[]
|
||||
denyLabels?: string[]
|
||||
}
|
||||
@@ -29,7 +29,7 @@ type DestinationList = Array<string | RouteTargetExpression>
|
||||
|
||||
function getPeerLabels(peer: AuthenticatedPeer) {
|
||||
return {
|
||||
...peer.identity?.plugin?.labels,
|
||||
...peer.extensionIdentity?.labels,
|
||||
...peer.identity?.labels,
|
||||
}
|
||||
}
|
||||
@@ -71,13 +71,13 @@ export function peerMatchesPolicy(peer: AuthenticatedPeer, policy: RoutingPolicy
|
||||
return false
|
||||
}
|
||||
|
||||
const pluginId = peer.identity?.plugin?.id ?? ''
|
||||
const extensionId = peer.identity?.extension.id ?? peer.extensionIdentity?.id ?? ''
|
||||
|
||||
if (policy.allowPlugins?.length && !policy.allowPlugins.includes(pluginId)) {
|
||||
if (policy.allowExtensions?.length && !policy.allowExtensions.includes(extensionId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (policy.denyPlugins?.length && policy.denyPlugins.includes(pluginId)) {
|
||||
if (policy.denyExtensions?.length && policy.denyExtensions.includes(extensionId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -39,11 +39,30 @@ export function matchesLabelSelectors(selectors: string[], labels: Record<string
|
||||
|
||||
function getPeerLabels(peer: AuthenticatedPeer) {
|
||||
return {
|
||||
...peer.identity?.plugin?.labels,
|
||||
...peer.extensionIdentity?.labels,
|
||||
...peer.identity?.extension.labels,
|
||||
...peer.identity?.labels,
|
||||
}
|
||||
}
|
||||
|
||||
function getPeerExtensionId(peer: AuthenticatedPeer) {
|
||||
return peer.identity?.extension.id ?? peer.extensionIdentity?.id
|
||||
}
|
||||
|
||||
function matchesExtensionModule(peer: AuthenticatedPeer, moduleName: string) {
|
||||
return [...peer.extensionModules?.values() ?? []]
|
||||
.some(module => module.name === moduleName || module.identity.id === moduleName)
|
||||
}
|
||||
|
||||
function matchesExtensionModuleGlob(peer: AuthenticatedPeer, glob: string) {
|
||||
return [...peer.extensionModules?.values() ?? []]
|
||||
.some(module => matchesGlob(glob, module.name) || matchesGlob(glob, module.identity.id))
|
||||
}
|
||||
|
||||
function matchesPeerId(peer: AuthenticatedPeer, peerId: string) {
|
||||
return peer.peer.id === peerId || Boolean(peer.peerIds?.has(peerId))
|
||||
}
|
||||
|
||||
export function matchesRouteExpression(expression: RouteTargetExpression, peer: AuthenticatedPeer): boolean {
|
||||
switch (expression.type) {
|
||||
case 'and':
|
||||
@@ -51,19 +70,19 @@ export function matchesRouteExpression(expression: RouteTargetExpression, peer:
|
||||
case 'or':
|
||||
return expression.any.some(expr => matchesRouteExpression(expr, peer))
|
||||
case 'glob': {
|
||||
const pluginId = peer.identity?.plugin?.id
|
||||
const extensionId = getPeerExtensionId(peer)
|
||||
const matched = matchesGlob(expression.glob, peer.name)
|
||||
|| matchesGlob(expression.glob, pluginId)
|
||||
|| matchesGlob(expression.glob, extensionId)
|
||||
|| matchesGlob(expression.glob, peer.identity?.id)
|
||||
|
||||
return expression.inverted ? !matched : matched
|
||||
}
|
||||
case 'ids': {
|
||||
const matched = expression.ids.includes(peer.peer.id)
|
||||
const matched = expression.ids.some(peerId => matchesPeerId(peer, peerId))
|
||||
return expression.inverted ? !matched : matched
|
||||
}
|
||||
case 'plugin': {
|
||||
const matched = expression.plugins.includes(peer.identity?.plugin?.id ?? '')
|
||||
const matched = expression.plugins.includes(getPeerExtensionId(peer) ?? '')
|
||||
return expression.inverted ? !matched : matched
|
||||
}
|
||||
case 'instance': {
|
||||
@@ -75,7 +94,7 @@ export function matchesRouteExpression(expression: RouteTargetExpression, peer:
|
||||
return expression.inverted ? !matched : matched
|
||||
}
|
||||
case 'module': {
|
||||
const matched = expression.modules.includes(peer.name)
|
||||
const matched = expression.modules.some(module => peer.name === module || matchesExtensionModule(peer, module))
|
||||
return expression.inverted ? !matched : matched
|
||||
}
|
||||
case 'source': {
|
||||
@@ -101,22 +120,24 @@ export function matchesDestination(destination: string | RouteTargetExpression,
|
||||
|
||||
switch (prefix) {
|
||||
case 'plugin':
|
||||
return peer.identity?.plugin?.id === value
|
||||
return getPeerExtensionId(peer) === value
|
||||
case 'instance':
|
||||
return peer.identity?.id === value
|
||||
case 'label':
|
||||
return matchesLabelSelectors([value], getPeerLabels(peer))
|
||||
case 'peer':
|
||||
return peer.peer.id === value
|
||||
return matchesPeerId(peer, value)
|
||||
case 'module':
|
||||
return peer.name === value
|
||||
return peer.name === value || matchesExtensionModule(peer, value)
|
||||
case 'source':
|
||||
return peer.name === value
|
||||
default: {
|
||||
const pluginId = peer.identity?.plugin?.id
|
||||
const extensionId = getPeerExtensionId(peer)
|
||||
// REVIEW: Bare/glob destination matching is kept for existing event payloads that do not use module:<name>.
|
||||
return matchesGlob(destination, peer.name)
|
||||
|| matchesGlob(destination, pluginId)
|
||||
|| matchesGlob(destination, extensionId)
|
||||
|| matchesGlob(destination, peer.identity?.id)
|
||||
|| matchesExtensionModuleGlob(peer, destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
AiriWebSocketEventFormatError,
|
||||
createResponses,
|
||||
heartbeatFrameFrom,
|
||||
parseEvent,
|
||||
} from '.'
|
||||
@@ -75,4 +76,30 @@ describe('airi websocket protocol codec', () => {
|
||||
expect(heartbeatFrameFrom('pong')).toBe('pong')
|
||||
expect(heartbeatFrameFrom('{"type":"ping"}')).toBeUndefined()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(responses.peerAuthenticated('peer-1').type).toBe('peer:authenticated')
|
||||
* expect(responses.extensionAuthenticated({ id: 'airi-extension-chess' }).type).toBe('extension:authenticated')
|
||||
*/
|
||||
it('creates peer and extension authentication responses separately', () => {
|
||||
const responses = createResponses('server-1')
|
||||
|
||||
expect(responses.peerAuthenticated('peer-1')).toMatchObject({
|
||||
type: 'peer:authenticated',
|
||||
data: {
|
||||
authenticated: true,
|
||||
peerId: 'peer-1',
|
||||
},
|
||||
})
|
||||
expect(responses.extensionAuthenticated({ id: 'airi-extension-chess' })).toMatchObject({
|
||||
type: 'extension:authenticated',
|
||||
data: {
|
||||
authenticated: true,
|
||||
identity: {
|
||||
id: 'airi-extension-chess',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DeliveryConfig, MessageHeartbeat, MetadataEventSource, WebSocketBaseEvent, WebSocketEvent } from '@proj-airi/server-shared/types'
|
||||
import type { DeliveryConfig, ExtensionIdentity, MessageHeartbeat, MetadataEventSource, WebSocketBaseEvent, WebSocketEvent } from '@proj-airi/server-shared/types'
|
||||
|
||||
import type {
|
||||
RouteContext,
|
||||
@@ -134,6 +134,20 @@ export function createResponses(serverInstanceId: string) {
|
||||
metadata: createEventMetadata(serverInstanceId, parentId),
|
||||
} satisfies WebSocketEvent<Record<string, unknown>>
|
||||
},
|
||||
peerAuthenticated(peerId: string, parentId?: string) {
|
||||
return {
|
||||
type: 'peer:authenticated',
|
||||
data: { authenticated: true, peerId },
|
||||
metadata: createEventMetadata(serverInstanceId, parentId),
|
||||
} satisfies WebSocketEvent<Record<string, unknown>>
|
||||
},
|
||||
extensionAuthenticated(identity: ExtensionIdentity, parentId?: string) {
|
||||
return {
|
||||
type: 'extension:authenticated',
|
||||
data: { identity, authenticated: true },
|
||||
metadata: createEventMetadata(serverInstanceId, parentId),
|
||||
} satisfies WebSocketEvent<Record<string, unknown>>
|
||||
},
|
||||
notAuthenticated(parentId?: string) {
|
||||
return {
|
||||
type: 'error',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MetadataEventSource } from '@proj-airi/server-shared/types'
|
||||
import type { ExtensionIdentity, ExtensionModuleIdentity } from '@proj-airi/server-shared/types'
|
||||
|
||||
export interface Peer {
|
||||
/**
|
||||
@@ -26,6 +26,16 @@ export interface NamedPeer {
|
||||
peer: Peer
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks one module announced by an extension over a websocket peer.
|
||||
*/
|
||||
export interface RegisteredExtensionModule {
|
||||
/** Human-readable module name used by registry sync and legacy routing lookup. */
|
||||
name: string
|
||||
/** Module identity scoped to the owning extension session. */
|
||||
identity: ExtensionModuleIdentity
|
||||
}
|
||||
|
||||
export enum WebSocketReadyState {
|
||||
CONNECTING = 0,
|
||||
OPEN = 1,
|
||||
@@ -35,7 +45,11 @@ export enum WebSocketReadyState {
|
||||
|
||||
export interface AuthenticatedPeer extends NamedPeer {
|
||||
authenticated: boolean
|
||||
identity?: MetadataEventSource
|
||||
/** Caller-supplied peer ids acknowledged during manual peer authentication. */
|
||||
peerIds?: Set<string>
|
||||
identity?: ExtensionModuleIdentity
|
||||
extensionIdentity?: ExtensionIdentity
|
||||
extensionModules?: Map<string, RegisteredExtensionModule>
|
||||
lastHeartbeatAt?: number
|
||||
healthy?: boolean
|
||||
missedHeartbeats?: number
|
||||
|
||||
Reference in New Issue
Block a user