@camstack/server 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { LoggingService } from '../logging/logging.service'
|
|
2
|
+
import { errMsg } from '@camstack/types'
|
|
3
|
+
|
|
4
|
+
interface NpmSearchResult {
|
|
5
|
+
name: string
|
|
6
|
+
version: string
|
|
7
|
+
description: string
|
|
8
|
+
keywords: string[]
|
|
9
|
+
date: string
|
|
10
|
+
publisher: { username: string }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface AddonSearchResult {
|
|
14
|
+
name: string
|
|
15
|
+
version: string
|
|
16
|
+
description: string
|
|
17
|
+
keywords: string[]
|
|
18
|
+
publishedAt: string
|
|
19
|
+
author: string
|
|
20
|
+
installed: boolean
|
|
21
|
+
installedVersion?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class AddonSearchService {
|
|
25
|
+
private cache: { results: NpmSearchResult[]; timestamp: number } | null = null
|
|
26
|
+
private readonly CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
|
27
|
+
|
|
28
|
+
constructor(private readonly loggingService: LoggingService) {}
|
|
29
|
+
|
|
30
|
+
async searchAddons(query?: string): Promise<AddonSearchResult[]> {
|
|
31
|
+
const npmResults = await this.fetchFromNpm()
|
|
32
|
+
|
|
33
|
+
// Filter by additional query if provided
|
|
34
|
+
let filtered = npmResults
|
|
35
|
+
if (query) {
|
|
36
|
+
const q = query.toLowerCase()
|
|
37
|
+
filtered = npmResults.filter(
|
|
38
|
+
(r) =>
|
|
39
|
+
r.name.toLowerCase().includes(q) ||
|
|
40
|
+
r.description?.toLowerCase().includes(q) ||
|
|
41
|
+
r.keywords?.some((k) => k.toLowerCase().includes(q)),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Return without install status — the caller can merge with installed list
|
|
46
|
+
return filtered.map((r) => ({
|
|
47
|
+
name: r.name,
|
|
48
|
+
version: r.version,
|
|
49
|
+
description: r.description ?? '',
|
|
50
|
+
keywords: r.keywords ?? [],
|
|
51
|
+
publishedAt: r.date ?? '',
|
|
52
|
+
author: r.publisher?.username ?? '',
|
|
53
|
+
installed: false, // caller will enrich this
|
|
54
|
+
installedVersion: undefined,
|
|
55
|
+
}))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async fetchFromNpm(): Promise<NpmSearchResult[]> {
|
|
59
|
+
// Return from cache if still fresh
|
|
60
|
+
if (this.cache && Date.now() - this.cache.timestamp < this.CACHE_TTL_MS) {
|
|
61
|
+
return this.cache.results
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// npm registry v1 search: packages with BOTH "camstack" and "addon" keywords
|
|
65
|
+
const url = 'https://registry.npmjs.org/-/v1/search?text=keywords:camstack+keywords:addon&size=250'
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetch(url, {
|
|
69
|
+
headers: { Accept: 'application/json' },
|
|
70
|
+
signal: AbortSignal.timeout(10000),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw new Error(`npm search failed: ${response.status}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const data = (await response.json()) as { objects: Array<{ package: NpmSearchResult }> }
|
|
78
|
+
const results = data.objects.map((o) => o.package)
|
|
79
|
+
|
|
80
|
+
this.cache = { results, timestamp: Date.now() }
|
|
81
|
+
|
|
82
|
+
return results
|
|
83
|
+
} catch (err) {
|
|
84
|
+
const logger = this.loggingService.createLogger('addon-search')
|
|
85
|
+
logger.warn('npm search failed', { meta: { error: errMsg(err) } })
|
|
86
|
+
// Stale cache is better than nothing on transient network failure
|
|
87
|
+
return this.cache?.results ?? []
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub-side singleton provider for the `addon-settings` capability.
|
|
3
|
+
*
|
|
4
|
+
* Acts as a gateway: resolves `addonId` to the target addon and
|
|
5
|
+
* delegates the settings call. For hub-local addons, calls the
|
|
6
|
+
* addon instance directly. For remote agents, proxies via the
|
|
7
|
+
* per-addon Moleculer service (`<addonId>.settings.<method>`).
|
|
8
|
+
*
|
|
9
|
+
* Replaces the `$addonHost` Moleculer service.
|
|
10
|
+
*/
|
|
11
|
+
import type { ICamstackAddon, ConfigUISchemaWithValues } from '@camstack/types'
|
|
12
|
+
import type { IAddonSettingsProvider } from '@camstack/types'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Minimal structural view of the Moleculer broker this provider needs
|
|
16
|
+
* (`call` + `registry`). Typed locally — mirroring the
|
|
17
|
+
* `ReconcileBrokerLike` pattern in `agent-registry.service.ts` —
|
|
18
|
+
* because the lint type-checker cannot resolve Moleculer's
|
|
19
|
+
* `ServiceBroker` type, which left every `broker.*` access `any`-typed.
|
|
20
|
+
*/
|
|
21
|
+
interface SettingsBroker {
|
|
22
|
+
readonly registry: unknown
|
|
23
|
+
call(
|
|
24
|
+
action: string,
|
|
25
|
+
params: Record<string, unknown>,
|
|
26
|
+
opts?: { readonly nodeID?: string; readonly timeout?: number },
|
|
27
|
+
): Promise<unknown>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Dependency interfaces ────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** Resolve an addon by id. Returns null if not loaded on this node. */
|
|
33
|
+
type AddonLookup = (addonId: string) => ICamstackAddon | null
|
|
34
|
+
|
|
35
|
+
/** Resolve which node hosts a given addon. Returns 'hub' for local. */
|
|
36
|
+
type NodeResolver = (addonId: string) => string
|
|
37
|
+
|
|
38
|
+
// ── Input types (match the cap definition's z.infer) ─────────────────
|
|
39
|
+
|
|
40
|
+
interface AddonIdInput {
|
|
41
|
+
readonly addonId: string
|
|
42
|
+
readonly nodeId?: string
|
|
43
|
+
readonly overlay?: Record<string, unknown>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface AddonPatchInput {
|
|
47
|
+
readonly addonId: string
|
|
48
|
+
readonly nodeId?: string
|
|
49
|
+
readonly patch: Record<string, unknown>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface DeviceGetInput {
|
|
53
|
+
readonly addonId: string
|
|
54
|
+
readonly deviceId: number
|
|
55
|
+
readonly nodeId?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface DeviceUpdateInput {
|
|
59
|
+
readonly addonId: string
|
|
60
|
+
readonly deviceId: number
|
|
61
|
+
readonly nodeId?: string
|
|
62
|
+
readonly patch: Record<string, unknown>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface SettingsUpdateResult {
|
|
66
|
+
readonly success: true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Reshape helper ───────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Deep-copy a `ConfigUISchemaWithValues` into mutable-array shape for
|
|
73
|
+
* Zod output. The domain type uses `readonly` arrays; Zod's inferred
|
|
74
|
+
* output uses mutable arrays. Structurally identical at runtime.
|
|
75
|
+
*/
|
|
76
|
+
interface ReshapedSchema {
|
|
77
|
+
tabs?: Array<{ id: string; label: string; icon: string; order?: number }>
|
|
78
|
+
sections: Array<{
|
|
79
|
+
id: string
|
|
80
|
+
title: string
|
|
81
|
+
description?: string
|
|
82
|
+
style?: 'card' | 'accordion'
|
|
83
|
+
defaultCollapsed?: boolean
|
|
84
|
+
columns?: 1 | 2 | 3 | 4
|
|
85
|
+
tab?: string
|
|
86
|
+
order?: number
|
|
87
|
+
fields: unknown[]
|
|
88
|
+
}>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function reshapeForOutput(schema: ConfigUISchemaWithValues): ReshapedSchema {
|
|
92
|
+
return {
|
|
93
|
+
tabs: schema.tabs
|
|
94
|
+
? schema.tabs.map((t) => ({ id: t.id, label: t.label, icon: t.icon, order: t.order }))
|
|
95
|
+
: undefined,
|
|
96
|
+
sections: schema.sections.map((s) => ({
|
|
97
|
+
id: s.id,
|
|
98
|
+
title: s.title,
|
|
99
|
+
description: s.description,
|
|
100
|
+
style: s.style,
|
|
101
|
+
defaultCollapsed: s.defaultCollapsed,
|
|
102
|
+
columns: s.columns,
|
|
103
|
+
tab: s.tab,
|
|
104
|
+
order: s.order,
|
|
105
|
+
fields: [...s.fields],
|
|
106
|
+
})),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* True for Moleculer's `ServiceNotFoundError` — raised when a
|
|
112
|
+
* `<addonId>.settings.*` action is called before the owning forked
|
|
113
|
+
* addon has registered its service (still booting, or wedged in
|
|
114
|
+
* `onInitialize`). Matched by `name` so we don't depend on Moleculer's
|
|
115
|
+
* error class being importable here.
|
|
116
|
+
*/
|
|
117
|
+
function isServiceNotFoundError(err: unknown): boolean {
|
|
118
|
+
return err instanceof Error && err.name === 'ServiceNotFoundError'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Provider factory ─────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
interface AddonSettingsProviderDeps {
|
|
124
|
+
/** Look up a loaded addon by id on this hub node. */
|
|
125
|
+
readonly getAddon: AddonLookup
|
|
126
|
+
/** Resolve which node hosts a given addon. Defaults to 'hub'. */
|
|
127
|
+
readonly resolveNode: NodeResolver
|
|
128
|
+
/** Moleculer broker for remote calls. */
|
|
129
|
+
readonly broker: SettingsBroker
|
|
130
|
+
/** This hub's node ID (for local short-circuit). */
|
|
131
|
+
readonly hubNodeId: string
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function createAddonSettingsProvider(deps: AddonSettingsProviderDeps): IAddonSettingsProvider {
|
|
135
|
+
const { getAddon, resolveNode, broker, hubNodeId } = deps
|
|
136
|
+
|
|
137
|
+
function isLocal(addonId: string, explicitNodeId?: string): boolean {
|
|
138
|
+
// `resolveNode` is consulted FIRST: it knows whether the addon
|
|
139
|
+
// runs in-process on the hub or as a forkable worker. An explicit
|
|
140
|
+
// `nodeId: 'hub'` from the caller is treated as "target the hub
|
|
141
|
+
// cluster" — NOT "force local dispatch" — so for forkable addons
|
|
142
|
+
// we still route via Moleculer to `hub/<addonId>`. Without this
|
|
143
|
+
// check the hub-side stub (which never had `initialize()` run and
|
|
144
|
+
// has no `_ctx.settings`) would respond with defaults only and
|
|
145
|
+
// silently drop every `updateGlobalSettings` patch.
|
|
146
|
+
const resolved = resolveNode(addonId)
|
|
147
|
+
if (resolved !== 'hub' && resolved !== hubNodeId) return false
|
|
148
|
+
const nodeId = explicitNodeId ?? resolved
|
|
149
|
+
return nodeId === 'hub' || nodeId === hubNodeId
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Remote call helpers ──────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
// Resolve the Moleculer nodeID that actually hosts an addon's
|
|
155
|
+
// `<addonId>.settings.*` service. Forkable addons register under
|
|
156
|
+
// `${baseNodeId}/${addonId}`; in-process addons register under the
|
|
157
|
+
// base nodeId itself. Walk the Moleculer registry and pick the
|
|
158
|
+
// first node whose id matches either convention.
|
|
159
|
+
function resolveWorkerNodeId(addonId: string, baseNodeId: string): string | null {
|
|
160
|
+
const registry = broker.registry
|
|
161
|
+
const services = (registry as unknown as {
|
|
162
|
+
getServiceList: (opts: { onlyAvailable: boolean }) => readonly { name: string; nodeID: string }[]
|
|
163
|
+
}).getServiceList({ onlyAvailable: true })
|
|
164
|
+
// Find the node that actually hosts the `<addonId>` service (the
|
|
165
|
+
// moleculer service factory registers addons under their id). The
|
|
166
|
+
// baseNodeId is a hint — we trust the registry's ground truth
|
|
167
|
+
// instead. Forkable addons live at `<parent>/<addonId>`;
|
|
168
|
+
// in-process addons live at `<parent>`.
|
|
169
|
+
const exactNode = `${baseNodeId}/${addonId}`
|
|
170
|
+
const preferred = services.find((s) => s.name === addonId && s.nodeID === exactNode)
|
|
171
|
+
if (preferred) return preferred.nodeID
|
|
172
|
+
const anyForBase = services.find((s) => s.name === addonId && s.nodeID === baseNodeId)
|
|
173
|
+
if (anyForBase) return anyForBase.nodeID
|
|
174
|
+
const anyWithName = services.find((s) => s.name === addonId)
|
|
175
|
+
return anyWithName?.nodeID ?? null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function remoteGet(
|
|
179
|
+
addonId: string,
|
|
180
|
+
nodeId: string,
|
|
181
|
+
action: string,
|
|
182
|
+
extraParams?: Record<string, unknown>,
|
|
183
|
+
): Promise<ReshapedSchema | null> {
|
|
184
|
+
const params = { ...extraParams }
|
|
185
|
+
const workerNodeId = resolveWorkerNodeId(addonId, nodeId)
|
|
186
|
+
try {
|
|
187
|
+
const result: unknown = await broker.call(
|
|
188
|
+
`${addonId}.settings.${action}`,
|
|
189
|
+
params,
|
|
190
|
+
workerNodeId ? { nodeID: workerNodeId, timeout: 10_000 } : { timeout: 10_000 },
|
|
191
|
+
)
|
|
192
|
+
return result ? reshapeForOutput(result as ConfigUISchemaWithValues) : null
|
|
193
|
+
} catch (err) {
|
|
194
|
+
// A forked addon that is still booting (or wedged in
|
|
195
|
+
// `onInitialize`) has not yet registered its
|
|
196
|
+
// `<addonId>.settings.*` Moleculer service. A settings READ
|
|
197
|
+
// against a missing service should degrade to "no schema" so the
|
|
198
|
+
// panel shows its empty state — NOT bubble a 500 to the operator.
|
|
199
|
+
if (isServiceNotFoundError(err)) return null
|
|
200
|
+
throw err
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function remoteUpdate(
|
|
205
|
+
addonId: string,
|
|
206
|
+
nodeId: string,
|
|
207
|
+
action: string,
|
|
208
|
+
extraParams: Record<string, unknown>,
|
|
209
|
+
): Promise<SettingsUpdateResult> {
|
|
210
|
+
const workerNodeId = resolveWorkerNodeId(addonId, nodeId)
|
|
211
|
+
await broker.call(
|
|
212
|
+
`${addonId}.settings.${action}`,
|
|
213
|
+
extraParams,
|
|
214
|
+
workerNodeId ? { nodeID: workerNodeId, timeout: 10_000 } : { timeout: 10_000 },
|
|
215
|
+
)
|
|
216
|
+
return { success: true as const }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Provider implementation ──────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
async getGlobalSettings(input: AddonIdInput) {
|
|
223
|
+
if (isLocal(input.addonId, input.nodeId)) {
|
|
224
|
+
const addon = getAddon(input.addonId)
|
|
225
|
+
if (!addon || typeof addon.getGlobalSettings !== 'function') return null
|
|
226
|
+
const result = await addon.getGlobalSettings(input.overlay)
|
|
227
|
+
return result ? reshapeForOutput(result) : null
|
|
228
|
+
}
|
|
229
|
+
const nodeId = input.nodeId ?? resolveNode(input.addonId)
|
|
230
|
+
return remoteGet(input.addonId, nodeId, 'getGlobalSettings', { ...(input.overlay ? { overlay: input.overlay } : {}) })
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
async updateGlobalSettings(input: AddonPatchInput) {
|
|
234
|
+
if (isLocal(input.addonId, input.nodeId)) {
|
|
235
|
+
const addon = getAddon(input.addonId)
|
|
236
|
+
if (!addon || typeof addon.updateGlobalSettings !== 'function') {
|
|
237
|
+
throw new Error(`Addon "${input.addonId}" does not implement updateGlobalSettings`)
|
|
238
|
+
}
|
|
239
|
+
await addon.updateGlobalSettings(input.patch)
|
|
240
|
+
return { success: true as const }
|
|
241
|
+
}
|
|
242
|
+
const nodeId = input.nodeId ?? resolveNode(input.addonId)
|
|
243
|
+
return remoteUpdate(input.addonId, nodeId, 'updateGlobalSettings', { patch: input.patch })
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
async getDeviceSettings(input: DeviceGetInput) {
|
|
247
|
+
if (isLocal(input.addonId, input.nodeId)) {
|
|
248
|
+
const addon = getAddon(input.addonId)
|
|
249
|
+
if (!addon || typeof addon.getDeviceSettings !== 'function') return null
|
|
250
|
+
const result = await addon.getDeviceSettings(input.deviceId)
|
|
251
|
+
return result ? reshapeForOutput(result) : null
|
|
252
|
+
}
|
|
253
|
+
const nodeId = input.nodeId ?? resolveNode(input.addonId)
|
|
254
|
+
return remoteGet(input.addonId, nodeId, 'getDeviceSettings', { deviceId: input.deviceId })
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
async updateDeviceSettings(input: DeviceUpdateInput) {
|
|
258
|
+
if (isLocal(input.addonId, input.nodeId)) {
|
|
259
|
+
const addon = getAddon(input.addonId)
|
|
260
|
+
if (!addon || typeof addon.updateDeviceSettings !== 'function') {
|
|
261
|
+
throw new Error(`Addon "${input.addonId}" does not implement updateDeviceSettings`)
|
|
262
|
+
}
|
|
263
|
+
await addon.updateDeviceSettings(input.deviceId, input.patch)
|
|
264
|
+
return { success: true as const }
|
|
265
|
+
}
|
|
266
|
+
const nodeId = input.nodeId ?? resolveNode(input.addonId)
|
|
267
|
+
return remoteUpdate(input.addonId, nodeId, 'updateDeviceSettings', {
|
|
268
|
+
deviceId: input.deviceId,
|
|
269
|
+
patch: input.patch,
|
|
270
|
+
})
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export { createAddonSettingsProvider }
|
|
276
|
+
export type { AddonSettingsProviderDeps }
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as path from 'node:path'
|
|
2
|
+
import { LoggingService } from '../logging/logging.service'
|
|
3
|
+
import type { IScopedLogger } from '@camstack/types'
|
|
4
|
+
import { errMsg } from '@camstack/types'
|
|
5
|
+
|
|
6
|
+
// Dynamically imported to tolerate missing / unbuilt packages at startup
|
|
7
|
+
type AddonLoader = import('@camstack/kernel').AddonLoader
|
|
8
|
+
type AddonInstaller = import('@camstack/kernel').AddonInstaller
|
|
9
|
+
type RegisteredAddon = import('@camstack/kernel').RegisteredAddon
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* AddonBridgeService — slim package-management surface.
|
|
13
|
+
*
|
|
14
|
+
* Historically this service also cached per-camera pipeline configs + per-
|
|
15
|
+
* addon config records + hosted an in-process `PipelineRunner` that consumers
|
|
16
|
+
* called via `processFrame` / `processMotionFrame`. Every piece of that
|
|
17
|
+
* plumbing was dead post commit 2c (the runtime scheduler moved to
|
|
18
|
+
* `addon-pipeline-runner`) and the pipeline page redesign finishes the job
|
|
19
|
+
* by deleting the cache, the AddonEngineManager wiring, the bridge-pipeline
|
|
20
|
+
* tRPC router, and the PipelineWiringModule that bolted the legacy addon
|
|
21
|
+
* resolver onto this service.
|
|
22
|
+
*
|
|
23
|
+
* What's left is ONLY the addon package management surface: the installer
|
|
24
|
+
* + the loader + `reloadPackages` + `listAvailableAddons`. These are still
|
|
25
|
+
* used by:
|
|
26
|
+
* - `server/backend/src/api/addon-upload.ts` (multipart upload → install
|
|
27
|
+
* → loader reload)
|
|
28
|
+
* - `server/backend/src/api/bridge-addons.router.ts` (install/uninstall
|
|
29
|
+
* package lifecycle, addon list via the loader)
|
|
30
|
+
*/
|
|
31
|
+
export class AddonBridgeService {
|
|
32
|
+
private readonly logger: IScopedLogger
|
|
33
|
+
|
|
34
|
+
private loader!: AddonLoader
|
|
35
|
+
private installer: AddonInstaller | null = null
|
|
36
|
+
|
|
37
|
+
/** Whether the bridge initialised successfully */
|
|
38
|
+
private available = false
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly loggingService: LoggingService,
|
|
42
|
+
) {
|
|
43
|
+
this.logger = this.loggingService.createLogger('AddonBridge')
|
|
44
|
+
|
|
45
|
+
// Initialize installer eagerly in constructor (no async needed).
|
|
46
|
+
// This ensures install/uninstall works even before onModuleInit completes.
|
|
47
|
+
try {
|
|
48
|
+
const kernel = require('@camstack/kernel') as typeof import('@camstack/kernel')
|
|
49
|
+
const dataDir = process.env.CAMSTACK_DATA ?? 'camstack-data'
|
|
50
|
+
const addonsDir = path.resolve(dataDir, 'addons')
|
|
51
|
+
const workspacePackagesDir = kernel.detectWorkspacePackagesDir(__dirname)
|
|
52
|
+
this.installer = new kernel.AddonInstaller({ addonsDir, workspacePackagesDir: workspacePackagesDir ?? undefined })
|
|
53
|
+
kernel.ensureDir(addonsDir)
|
|
54
|
+
} catch (error: unknown) {
|
|
55
|
+
const msg = errMsg(error)
|
|
56
|
+
this.logger.warn('Installer init failed', { meta: { error: msg } })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async onModuleInit(): Promise<void> {
|
|
61
|
+
this.logger.info('Initializing addon bridge (package management only)...')
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const kernel = await import('@camstack/kernel')
|
|
65
|
+
|
|
66
|
+
this.loader = new kernel.AddonLoader(this.logger.child('AddonLoader'))
|
|
67
|
+
|
|
68
|
+
const dataDir = process.env.CAMSTACK_DATA ?? 'camstack-data'
|
|
69
|
+
const addonsDir = path.resolve(dataDir, 'addons')
|
|
70
|
+
await this.loader.loadFromDirectory(addonsDir)
|
|
71
|
+
|
|
72
|
+
this.available = true
|
|
73
|
+
this.logger.info('Addon bridge initialized', { meta: { count: this.loader.listAddons().length } })
|
|
74
|
+
} catch (error: unknown) {
|
|
75
|
+
const msg = errMsg(error)
|
|
76
|
+
this.logger.warn('Addon bridge loader failed — install/uninstall still available', { meta: { error: msg } })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async onModuleDestroy(): Promise<void> {
|
|
81
|
+
this.logger.info('Addon bridge shut down')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Public API
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/** Whether the bridge is ready for use */
|
|
89
|
+
isAvailable(): boolean {
|
|
90
|
+
return this.available
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Return the underlying AddonLoader (for querying / introspection) */
|
|
94
|
+
getLoader(): AddonLoader {
|
|
95
|
+
return this.loader
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** List IDs of all successfully loaded addons */
|
|
99
|
+
listAvailableAddons(): string[] {
|
|
100
|
+
if (!this.available) return []
|
|
101
|
+
return this.loader.listAddons().map((a: RegisteredAddon) => a.declaration.id)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Return the AddonInstaller instance (may be null if bridge failed to init) */
|
|
105
|
+
getInstaller(): AddonInstaller | null {
|
|
106
|
+
return this.installer
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Re-discover addons from the addons directory (call after install/uninstall) */
|
|
110
|
+
async reloadPackages(): Promise<void> {
|
|
111
|
+
if (!this.available) {
|
|
112
|
+
this.logger.warn('reloadPackages called but bridge is unavailable — skipping')
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const kernel = await import('@camstack/kernel')
|
|
117
|
+
this.loader = new kernel.AddonLoader(this.logger.child('AddonLoader'))
|
|
118
|
+
|
|
119
|
+
const dataDir = process.env.CAMSTACK_DATA ?? 'camstack-data'
|
|
120
|
+
const addonsDir = path.resolve(dataDir, 'addons')
|
|
121
|
+
await this.loader.loadFromDirectory(addonsDir)
|
|
122
|
+
|
|
123
|
+
this.logger.info('Reloaded', { meta: { count: this.loader.listAddons().length } })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('node:fs', () => ({
|
|
4
|
+
existsSync: vi.fn(),
|
|
5
|
+
}))
|
|
6
|
+
|
|
7
|
+
import * as fs from 'node:fs'
|
|
8
|
+
import { AddonPagesService } from './addon-pages.service'
|
|
9
|
+
import type { CapabilityService } from '../capability/capability.service'
|
|
10
|
+
import type { LoggingService } from '../logging/logging.service'
|
|
11
|
+
import type { ConfigService } from '../config/config.service'
|
|
12
|
+
import type { IAddonPageProvider, AddonPageDeclaration, IScopedLogger } from '@camstack/types'
|
|
13
|
+
|
|
14
|
+
function createMockLogger(): IScopedLogger {
|
|
15
|
+
return {
|
|
16
|
+
debug: vi.fn(),
|
|
17
|
+
info: vi.fn(),
|
|
18
|
+
warn: vi.fn(),
|
|
19
|
+
error: vi.fn(),
|
|
20
|
+
child: vi.fn().mockReturnThis(),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createMockLoggingService(): LoggingService {
|
|
25
|
+
return {
|
|
26
|
+
createLogger: vi.fn().mockReturnValue(createMockLogger()),
|
|
27
|
+
} as unknown as LoggingService
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createMockConfigService(): ConfigService {
|
|
31
|
+
return {
|
|
32
|
+
get: vi.fn().mockReturnValue('./data'),
|
|
33
|
+
} as unknown as ConfigService
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createMockPageProvider(
|
|
37
|
+
id: string,
|
|
38
|
+
pages: readonly AddonPageDeclaration[],
|
|
39
|
+
): IAddonPageProvider {
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
listPages: () => pages,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* AddonPagesService now resolves bundles via the `addon-pages-source`
|
|
48
|
+
* collection cap (split from the public `addon-pages` singleton cap
|
|
49
|
+
* earlier this session).
|
|
50
|
+
*/
|
|
51
|
+
function createMockCapabilityService(providers: IAddonPageProvider[] = []): CapabilityService {
|
|
52
|
+
return {
|
|
53
|
+
getSingleton: vi.fn(() => null),
|
|
54
|
+
getCollection: vi.fn((capability: string) => {
|
|
55
|
+
if (capability === 'addon-pages-source') return providers
|
|
56
|
+
return []
|
|
57
|
+
}),
|
|
58
|
+
resolveForDevice: vi.fn(() => null),
|
|
59
|
+
resolveCollectionForDevice: vi.fn(() => []),
|
|
60
|
+
setRegistry: vi.fn(),
|
|
61
|
+
getRegistry: vi.fn(() => null),
|
|
62
|
+
} as unknown as CapabilityService
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildService(providers: IAddonPageProvider[] = []): AddonPagesService {
|
|
66
|
+
return new AddonPagesService(
|
|
67
|
+
createMockLoggingService(),
|
|
68
|
+
createMockConfigService(),
|
|
69
|
+
createMockCapabilityService(providers),
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('AddonPagesService.resolveBundle', () => {
|
|
74
|
+
it('should return null for unregistered addon', () => {
|
|
75
|
+
const service = buildService([])
|
|
76
|
+
const result = service.resolveBundle('nonexistent', 'dist/pages/main.js')
|
|
77
|
+
expect(result).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should return null when file does not exist', () => {
|
|
81
|
+
const provider = createMockPageProvider('benchmark', [])
|
|
82
|
+
const service = buildService([provider])
|
|
83
|
+
|
|
84
|
+
const existsMock = fs.existsSync as unknown as ReturnType<typeof vi.fn>
|
|
85
|
+
existsMock.mockReturnValue(false)
|
|
86
|
+
|
|
87
|
+
const result = service.resolveBundle('benchmark', 'dist/pages/benchmark.js')
|
|
88
|
+
expect(result).toBeNull()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should return resolved path when file exists', () => {
|
|
92
|
+
const provider = createMockPageProvider('benchmark', [])
|
|
93
|
+
const service = buildService([provider])
|
|
94
|
+
|
|
95
|
+
const existsMock = fs.existsSync as unknown as ReturnType<typeof vi.fn>
|
|
96
|
+
existsMock.mockReturnValue(true)
|
|
97
|
+
|
|
98
|
+
const result = service.resolveBundle('benchmark', 'dist/pages/benchmark.js')
|
|
99
|
+
expect(result).not.toBeNull()
|
|
100
|
+
expect(result).toContain('addon-benchmark')
|
|
101
|
+
expect(result).toContain('dist/pages/benchmark.js')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should deny path traversal attempts', () => {
|
|
105
|
+
const provider = createMockPageProvider('benchmark', [])
|
|
106
|
+
const service = buildService([provider])
|
|
107
|
+
const result = service.resolveBundle('benchmark', '../../etc/passwd')
|
|
108
|
+
expect(result).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('should deny absolute path traversal', () => {
|
|
112
|
+
const provider = createMockPageProvider('benchmark', [])
|
|
113
|
+
const service = buildService([provider])
|
|
114
|
+
const result = service.resolveBundle('benchmark', '../../../etc/passwd')
|
|
115
|
+
expect(result).toBeNull()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as path from 'node:path'
|
|
2
|
+
import * as fs from 'node:fs'
|
|
3
|
+
import { LoggingService } from '../logging/logging.service'
|
|
4
|
+
import { ConfigService } from '../config/config.service'
|
|
5
|
+
import { CapabilityService } from '../capability/capability.service'
|
|
6
|
+
import type { IScopedLogger } from '@camstack/types'
|
|
7
|
+
import type { IAddonPageProvider } from '@camstack/types'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* AddonPagesService — server-side helper that backs the static file
|
|
11
|
+
* route `/api/addon-pages/:addonId/*` (registered in `main.ts`).
|
|
12
|
+
*
|
|
13
|
+
* The public listing surface (`addonPages.listPages` on the AppRouter)
|
|
14
|
+
* has been split out of this class — it now lives in the
|
|
15
|
+
* `addon-pages-aggregator` builtin (`@camstack/core/builtins/...`)
|
|
16
|
+
* which walks every `addon-pages-source` collection provider and
|
|
17
|
+
* stamps versioned `bundleUrl`s. The split lets both ends flow through
|
|
18
|
+
* codegen instead of relying on a hand-written wrapper.
|
|
19
|
+
*
|
|
20
|
+
* What stays here: filesystem path resolution + traversal protection
|
|
21
|
+
* for the static file route. Both rely on `CapabilityService` to
|
|
22
|
+
* enumerate registered page providers (so unknown / unregistered
|
|
23
|
+
* addons can't be probed via path traversal tricks) and on
|
|
24
|
+
* `ConfigService` to locate the addons directory.
|
|
25
|
+
*/
|
|
26
|
+
export class AddonPagesService {
|
|
27
|
+
private readonly logger: IScopedLogger
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly loggingService: LoggingService,
|
|
31
|
+
private readonly configService: ConfigService,
|
|
32
|
+
private readonly caps: CapabilityService,
|
|
33
|
+
) {
|
|
34
|
+
this.logger = this.loggingService.createLogger('AddonPagesService')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the filesystem path to an addon's page bundle file.
|
|
39
|
+
* Returns null if the addon is not registered, the file doesn't exist,
|
|
40
|
+
* or the path would escape the addon directory (path traversal protection).
|
|
41
|
+
*/
|
|
42
|
+
resolveBundle(addonId: string, filePath: string): string | null {
|
|
43
|
+
// Check if the addon is a registered page provider via the
|
|
44
|
+
// collection cap. `addon-pages-source` is the new home for the raw
|
|
45
|
+
// per-provider declarations (the public `addon-pages` cap is the
|
|
46
|
+
// aggregated singleton — different shape, different surface).
|
|
47
|
+
const providers = this.caps.getCollection<IAddonPageProvider>('addon-pages-source')
|
|
48
|
+
const isRegistered = providers.some((p) => p.id === addonId)
|
|
49
|
+
if (!isRegistered) {
|
|
50
|
+
this.logger.warn('Bundle resolve failed: addon not registered as page provider', { tags: { addonId } })
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const addonsDir = this.resolveAddonsDir()
|
|
55
|
+
// Addon packages are at addons/@camstack/addon-<id>/dist/
|
|
56
|
+
const addonDistPath = path.join(addonsDir, '@camstack', `addon-${addonId}`, 'dist')
|
|
57
|
+
|
|
58
|
+
const resolvedBase = path.resolve(addonDistPath)
|
|
59
|
+
const resolvedFile = path.resolve(addonDistPath, filePath)
|
|
60
|
+
|
|
61
|
+
// Path traversal protection
|
|
62
|
+
if (!resolvedFile.startsWith(resolvedBase + path.sep) && resolvedFile !== resolvedBase) {
|
|
63
|
+
this.logger.warn('Path traversal denied for addon', { tags: { addonId }, meta: { filePath } })
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!fs.existsSync(resolvedFile)) {
|
|
68
|
+
this.logger.debug('Bundle file not found', { meta: { resolvedFile } })
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return resolvedFile
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Resolve the addons directory from config */
|
|
76
|
+
private resolveAddonsDir(): string {
|
|
77
|
+
const dataPath = this.configService.get<string>('server.dataPath') ?? 'camstack-data'
|
|
78
|
+
return path.resolve(dataPath, 'addons')
|
|
79
|
+
}
|
|
80
|
+
}
|