@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.
Files changed (133) hide show
  1. package/.env.example +17 -0
  2. package/package.json +55 -0
  3. package/src/__tests__/addon-install-e2e.test.ts +75 -0
  4. package/src/__tests__/addon-pages-e2e.test.ts +178 -0
  5. package/src/__tests__/addon-route-session.test.ts +17 -0
  6. package/src/__tests__/addon-settings-router.spec.ts +62 -0
  7. package/src/__tests__/addon-upload.spec.ts +355 -0
  8. package/src/__tests__/agent-registry.spec.ts +162 -0
  9. package/src/__tests__/agent-status-page.spec.ts +84 -0
  10. package/src/__tests__/auth-session-cookie.test.ts +21 -0
  11. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
  12. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
  13. package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
  14. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
  15. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
  16. package/src/__tests__/cap-routers/harness.ts +159 -0
  17. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
  18. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
  19. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
  20. package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
  21. package/src/__tests__/capability-e2e.test.ts +386 -0
  22. package/src/__tests__/cli-e2e.test.ts +129 -0
  23. package/src/__tests__/core-cap-bridge.spec.ts +89 -0
  24. package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
  25. package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
  26. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
  27. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
  28. package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
  29. package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
  30. package/src/__tests__/framework-allowlist.spec.ts +95 -0
  31. package/src/__tests__/https-e2e.test.ts +118 -0
  32. package/src/__tests__/lifecycle-e2e.test.ts +140 -0
  33. package/src/__tests__/live-events-subscription.spec.ts +150 -0
  34. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
  35. package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
  36. package/src/__tests__/post-boot-restart.spec.ts +161 -0
  37. package/src/__tests__/singleton-contention.test.ts +487 -0
  38. package/src/__tests__/streaming-diagnostic.test.ts +512 -0
  39. package/src/__tests__/streaming-scale.test.ts +280 -0
  40. package/src/agent-status-page.ts +121 -0
  41. package/src/api/__tests__/addons-custom.spec.ts +134 -0
  42. package/src/api/__tests__/capabilities.router.test.ts +47 -0
  43. package/src/api/addon-upload.ts +472 -0
  44. package/src/api/addons-custom.router.ts +100 -0
  45. package/src/api/auth-whoami.ts +99 -0
  46. package/src/api/bridge-addons.router.ts +120 -0
  47. package/src/api/capabilities.router.ts +226 -0
  48. package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
  49. package/src/api/core/addon-settings.router.ts +124 -0
  50. package/src/api/core/agents.router.ts +87 -0
  51. package/src/api/core/auth.router.ts +303 -0
  52. package/src/api/core/cap-providers.ts +993 -0
  53. package/src/api/core/capabilities.router.ts +119 -0
  54. package/src/api/core/collection-preference.ts +40 -0
  55. package/src/api/core/event-bus-proxy.router.ts +45 -0
  56. package/src/api/core/hwaccel.router.ts +81 -0
  57. package/src/api/core/live-events.router.ts +60 -0
  58. package/src/api/core/logs.router.ts +162 -0
  59. package/src/api/core/notifications.router.ts +65 -0
  60. package/src/api/core/repl.router.ts +41 -0
  61. package/src/api/core/settings-backend.router.ts +142 -0
  62. package/src/api/core/stream-probe.router.ts +57 -0
  63. package/src/api/core/system-events.router.ts +116 -0
  64. package/src/api/health/health.routes.ts +123 -0
  65. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
  66. package/src/api/oauth2/consent-page.ts +42 -0
  67. package/src/api/oauth2/oauth2-routes.ts +248 -0
  68. package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
  69. package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
  70. package/src/api/trpc/cap-mount-helpers.ts +225 -0
  71. package/src/api/trpc/core-cap-bridge.ts +152 -0
  72. package/src/api/trpc/generated-cap-mounts.ts +707 -0
  73. package/src/api/trpc/generated-cap-routers.ts +6340 -0
  74. package/src/api/trpc/scope-access.ts +110 -0
  75. package/src/api/trpc/trpc.context.ts +255 -0
  76. package/src/api/trpc/trpc.middleware.ts +140 -0
  77. package/src/api/trpc/trpc.router.ts +275 -0
  78. package/src/auth/session-cookie.ts +44 -0
  79. package/src/boot/boot-config.ts +278 -0
  80. package/src/boot/post-boot.service.ts +103 -0
  81. package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
  82. package/src/core/addon/addon-package.service.ts +1684 -0
  83. package/src/core/addon/addon-registry.service.ts +2926 -0
  84. package/src/core/addon/addon-search.service.ts +90 -0
  85. package/src/core/addon/addon-settings-provider.ts +276 -0
  86. package/src/core/addon/addon.tokens.ts +2 -0
  87. package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
  88. package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
  89. package/src/core/addon-pages/addon-pages.service.ts +80 -0
  90. package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
  91. package/src/core/agent/agent-registry.service.ts +507 -0
  92. package/src/core/auth/auth.service.spec.ts +88 -0
  93. package/src/core/auth/auth.service.ts +8 -0
  94. package/src/core/capability/capability.service.ts +57 -0
  95. package/src/core/config/config.schema.ts +3 -0
  96. package/src/core/config/config.service.spec.ts +175 -0
  97. package/src/core/config/config.service.ts +7 -0
  98. package/src/core/events/event-bus.service.spec.ts +212 -0
  99. package/src/core/events/event-bus.service.ts +85 -0
  100. package/src/core/feature/feature.service.spec.ts +96 -0
  101. package/src/core/feature/feature.service.ts +8 -0
  102. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
  103. package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
  104. package/src/core/logging/log-ring-buffer.ts +3 -0
  105. package/src/core/logging/logging.service.spec.ts +247 -0
  106. package/src/core/logging/logging.service.ts +129 -0
  107. package/src/core/logging/scoped-logger.ts +3 -0
  108. package/src/core/moleculer/moleculer.service.ts +612 -0
  109. package/src/core/network/network-quality.service.spec.ts +47 -0
  110. package/src/core/network/network-quality.service.ts +5 -0
  111. package/src/core/notification/notification-wrapper.service.ts +36 -0
  112. package/src/core/notification/toast-wrapper.service.ts +31 -0
  113. package/src/core/provider/provider.tokens.ts +1 -0
  114. package/src/core/repl/repl-engine.service.spec.ts +417 -0
  115. package/src/core/repl/repl-engine.service.ts +156 -0
  116. package/src/core/storage/fs-storage-backend.spec.ts +70 -0
  117. package/src/core/storage/fs-storage-backend.ts +3 -0
  118. package/src/core/storage/settings-store.spec.ts +213 -0
  119. package/src/core/storage/settings-store.ts +2 -0
  120. package/src/core/storage/sql-schema.spec.ts +140 -0
  121. package/src/core/storage/sql-schema.ts +3 -0
  122. package/src/core/storage/storage-location-manager.spec.ts +121 -0
  123. package/src/core/storage/storage-location-manager.ts +3 -0
  124. package/src/core/storage/storage.service.spec.ts +73 -0
  125. package/src/core/storage/storage.service.ts +3 -0
  126. package/src/core/streaming/stream-probe.service.ts +212 -0
  127. package/src/core/topology/topology-emitter.service.ts +101 -0
  128. package/src/launcher.ts +309 -0
  129. package/src/main.ts +1049 -0
  130. package/src/manual-boot.ts +322 -0
  131. package/tsconfig.build.json +8 -0
  132. package/tsconfig.json +21 -0
  133. package/vitest.config.ts +26 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Settings backend router — tRPC proxy for ISettingsBackend operations.
3
+ *
4
+ * Exposes the core collection-based operations (get, set, query, insert,
5
+ * update, delete, count, isEmpty) so forked worker addons can use
6
+ * context.settingsBackend via tRPC instead of requiring in-process access
7
+ * to the SQLite database.
8
+ *
9
+ * Introduced for Task 11 — TrpcSettingsBackend for forked workers.
10
+ */
11
+ import { z } from 'zod'
12
+ import type { ISettingsBackend } from '@camstack/types'
13
+ import { trpcRouter, protectedProcedure } from '../trpc/trpc.middleware.js'
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Zod schemas
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const CollectionKeySchema = z.object({
20
+ collection: z.string(),
21
+ key: z.string(),
22
+ })
23
+
24
+ const SetValueSchema = z.object({
25
+ collection: z.string(),
26
+ key: z.string(),
27
+ value: z.unknown(),
28
+ })
29
+
30
+ const QueryFilterSchema = z.object({
31
+ where: z.record(z.string(), z.unknown()).optional(),
32
+ whereIn: z.record(z.string(), z.array(z.unknown())).optional(),
33
+ whereBetween: z.record(z.string(), z.tuple([z.unknown(), z.unknown()])).optional(),
34
+ orderBy: z.object({
35
+ field: z.string(),
36
+ direction: z.enum(['asc', 'desc']),
37
+ }).optional(),
38
+ limit: z.number().optional(),
39
+ offset: z.number().optional(),
40
+ }).optional()
41
+
42
+ const QueryInputSchema = z.object({
43
+ collection: z.string(),
44
+ filter: QueryFilterSchema,
45
+ })
46
+
47
+ const InsertInputSchema = z.object({
48
+ collection: z.string(),
49
+ record: z.object({
50
+ id: z.string(),
51
+ data: z.record(z.string(), z.unknown()),
52
+ }),
53
+ })
54
+
55
+ const UpdateInputSchema = z.object({
56
+ collection: z.string(),
57
+ id: z.string(),
58
+ data: z.record(z.string(), z.unknown()),
59
+ })
60
+
61
+ const CountInputSchema = z.object({
62
+ collection: z.string(),
63
+ filter: QueryFilterSchema,
64
+ })
65
+
66
+ const IsEmptyInputSchema = z.object({
67
+ collection: z.string(),
68
+ })
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Router factory
72
+ // ---------------------------------------------------------------------------
73
+
74
+ export function createSettingsBackendRouter(
75
+ getBackend: () => ISettingsBackend | null,
76
+ ) {
77
+ const requireBackend = (): ISettingsBackend => {
78
+ const backend = getBackend()
79
+ if (!backend) {
80
+ throw new Error('Settings backend not available — settings-store addon may not be initialized yet')
81
+ }
82
+ return backend
83
+ }
84
+
85
+ return trpcRouter({
86
+ get: protectedProcedure
87
+ .input(CollectionKeySchema)
88
+ .query(async ({ input }) => {
89
+ const result = await requireBackend().get(input)
90
+ return { value: result }
91
+ }),
92
+
93
+ set: protectedProcedure
94
+ .input(SetValueSchema)
95
+ .mutation(async ({ input }) => {
96
+ await requireBackend().set({ collection: input.collection, key: input.key, value: input.value })
97
+ return { success: true as const }
98
+ }),
99
+
100
+ query: protectedProcedure
101
+ .input(QueryInputSchema)
102
+ .query(async ({ input }) => {
103
+ const records = await requireBackend().query({ collection: input.collection, filter: input.filter ?? undefined })
104
+ return { records: records.map(r => ({ id: r.id, data: r.data })) }
105
+ }),
106
+
107
+ insert: protectedProcedure
108
+ .input(InsertInputSchema)
109
+ .mutation(async ({ input }) => {
110
+ await requireBackend().insert(input)
111
+ return { success: true as const }
112
+ }),
113
+
114
+ update: protectedProcedure
115
+ .input(UpdateInputSchema)
116
+ .mutation(async ({ input }) => {
117
+ await requireBackend().update(input)
118
+ return { success: true as const }
119
+ }),
120
+
121
+ delete: protectedProcedure
122
+ .input(CollectionKeySchema)
123
+ .mutation(async ({ input }) => {
124
+ await requireBackend().delete(input)
125
+ return { success: true as const }
126
+ }),
127
+
128
+ count: protectedProcedure
129
+ .input(CountInputSchema)
130
+ .query(async ({ input }) => {
131
+ const result = await requireBackend().count({ collection: input.collection, filter: input.filter ?? undefined })
132
+ return { count: result }
133
+ }),
134
+
135
+ isEmpty: protectedProcedure
136
+ .input(IsEmptyInputSchema)
137
+ .query(async ({ input }) => {
138
+ const result = await requireBackend().isEmpty(input)
139
+ return { empty: result }
140
+ }),
141
+ })
142
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Stream probe router — fixed core API (not a capability).
3
+ *
4
+ * Thin wrapper over `StreamProbeService` (backend REAL_LOGIC: ffprobe +
5
+ * HTTP probes + 1h cache). Not pluggable: there is exactly one stream
6
+ * probe implementation shipping with the server.
7
+ *
8
+ * Previously these endpoints lived under `streaming.probeStream` — moved
9
+ * here as part of eliminating the `streaming` aggregator capability.
10
+ */
11
+ import { z } from 'zod'
12
+ import { classifyStream } from '@camstack/types'
13
+ import type { StreamProbeService } from '../../core/streaming/stream-probe.service.js'
14
+ import { trpcRouter, adminProcedure } from '../trpc/trpc.middleware.js'
15
+
16
+ const ProbedStreamSchema = z.object({
17
+ width: z.number().optional(),
18
+ height: z.number().optional(),
19
+ codec: z.string().optional(),
20
+ fps: z.number().optional(),
21
+ bitrateKbps: z.number().optional(),
22
+ quality: z.enum(['high', 'mid', 'low']),
23
+ })
24
+
25
+ const FieldProbeResultSchema = z.object({
26
+ status: z.enum(['ok', 'error']),
27
+ labels: z.array(z.string()).optional(),
28
+ error: z.string().optional(),
29
+ })
30
+
31
+ export function createStreamProbeRouter(sp: StreamProbeService | null) {
32
+ return trpcRouter({
33
+ probe: adminProcedure
34
+ .input(z.object({ url: z.string(), force: z.boolean().optional() }))
35
+ .output(ProbedStreamSchema)
36
+ .mutation(async ({ input }) => {
37
+ if (!sp) throw new Error('StreamProbeService not available')
38
+ const metadata = await sp.probe(input.url, { force: input.force })
39
+ return { ...metadata, quality: classifyStream(metadata) }
40
+ }),
41
+ /**
42
+ * Generic field probe — decides stream vs HTTP reachability based on
43
+ * the field key (keys starting with `stream` trigger ffprobe;
44
+ * everything else falls back to an HTTP GET that aborts at headers).
45
+ * Wraps `StreamProbeService.probeField` — used by device providers
46
+ * that need the kernel's probe without re-implementing ffprobe /
47
+ * HTTP fetch in every addon.
48
+ */
49
+ probeField: adminProcedure
50
+ .input(z.object({ key: z.string(), value: z.unknown() }))
51
+ .output(FieldProbeResultSchema)
52
+ .mutation(async ({ input }) => {
53
+ if (!sp) throw new Error('StreamProbeService not available')
54
+ return sp.probeField(input.key, input.value)
55
+ }),
56
+ })
57
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * System events router — fixed core API (not a capability).
3
+ *
4
+ * Exposes recent-events query and live subscribe over the `EventBusService`.
5
+ * Supports hierarchical filtering via EventFilter: agentId → addonId → deviceId.
6
+ * All filters are applied server-side by the SystemEventBus — the client
7
+ * only receives matching events.
8
+ */
9
+ import { z } from 'zod'
10
+ import type { SystemEvent } from '@camstack/types'
11
+ import type { EventBusService } from '../../core/events/event-bus.service.js'
12
+ import { trpcRouter, protectedProcedure, iterableSubscription } from '../trpc/trpc.middleware.js'
13
+
14
+ type SerializedEvent = {
15
+ id: string
16
+ timestamp: string
17
+ source: { type: string; id: string | number; nodeId?: string; addonId?: string; deviceId?: number }
18
+ category: string
19
+ data: unknown
20
+ }
21
+
22
+ function serialize(e: SystemEvent): SerializedEvent {
23
+ return {
24
+ id: e.id,
25
+ timestamp: new Date(e.timestamp).toISOString(),
26
+ source: {
27
+ type: e.source.type,
28
+ id: e.source.id,
29
+ ...(e.source.nodeId ? { nodeId: e.source.nodeId } : {}),
30
+ ...(e.source.addonId ? { addonId: e.source.addonId } : {}),
31
+ ...(e.source.deviceId !== undefined ? { deviceId: e.source.deviceId } : {}),
32
+ },
33
+ category: e.category,
34
+ data: e.data,
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Source / agent / addon / device fields shared by query + subscribe.
40
+ * Category handling is split (asymmetric): see the per-method schemas
41
+ * below.
42
+ */
43
+ const ScopeFieldsSchema = z.object({
44
+ /** Legacy source filter (exact type+id match). */
45
+ source: z.object({
46
+ type: z.string(),
47
+ id: z.union([z.string(), z.number()]),
48
+ }).optional(),
49
+ /** Agent/node filter (prefix match: 'hub' matches 'hub/pipeline'). */
50
+ agentId: z.string().optional(),
51
+ /** Addon filter. Matches source.addonId or source.id when type='addon'. */
52
+ addonId: z.string().optional(),
53
+ /** Device filter. Matches source.deviceId or source.id when type='device'. */
54
+ deviceId: z.number().optional(),
55
+ })
56
+
57
+ /**
58
+ * `getRecent` accepts a category array so the UI can drive a
59
+ * server-side whitelist when reading the historical buffer. Without
60
+ * this, getRecent returns the last `limit` events of any category and
61
+ * a noisy category (per-frame metrics) can displace relevant events
62
+ * (provider.motion, detection.result) out of the window — which was
63
+ * the symptom of "no events visible after page refresh".
64
+ *
65
+ * The underlying ring-buffer filter
66
+ * (`addon-context-factory.ts:getRecent`) already iterates the
67
+ * `category` field as `string | string[]`.
68
+ */
69
+ const GetRecentInputSchema = ScopeFieldsSchema.extend({
70
+ category: z.union([z.string(), z.array(z.string())]).optional(),
71
+ limit: z.number().int().min(1).max(500).optional(),
72
+ })
73
+
74
+ /**
75
+ * `subscribe` keeps category as a single string. The bus's
76
+ * `extractCategoryPattern` keys handlers by a single pattern; passing
77
+ * an array would silently route only the first category. UIs that
78
+ * need multi-category live filtering must subscribe by deviceId
79
+ * (single-category-per-call or wildcard) and narrow client-side.
80
+ */
81
+ const SubscribeInputSchema = ScopeFieldsSchema.extend({
82
+ category: z.string().optional(),
83
+ })
84
+
85
+ export function createSystemEventsRouter(eb: EventBusService) {
86
+ return trpcRouter({
87
+ getRecent: protectedProcedure
88
+ .input(GetRecentInputSchema)
89
+ .query(({ input }) => {
90
+ return eb.getRecent({
91
+ ...(input.source ? { source: input.source } : {}),
92
+ ...(input.agentId ? { agentId: input.agentId } : {}),
93
+ ...(input.addonId ? { addonId: input.addonId } : {}),
94
+ ...(input.deviceId !== undefined ? { deviceId: input.deviceId } : {}),
95
+ ...(input.category ? { category: input.category } : {}),
96
+ }, input.limit).map(serialize)
97
+ }),
98
+
99
+ subscribe: protectedProcedure
100
+ .input(SubscribeInputSchema)
101
+ .subscription(({ input }) => {
102
+ return iterableSubscription<unknown>((push) => {
103
+ return eb.subscribe(
104
+ {
105
+ ...(input.source ? { source: input.source } : {}),
106
+ ...(input.agentId ? { agentId: input.agentId } : {}),
107
+ ...(input.addonId ? { addonId: input.addonId } : {}),
108
+ ...(input.deviceId !== undefined ? { deviceId: input.deviceId } : {}),
109
+ ...(input.category ? { category: input.category } : {}),
110
+ },
111
+ (event: SystemEvent) => push(serialize(event)),
112
+ )
113
+ })
114
+ }),
115
+ })
116
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Health endpoints — fast probe surface for monitoring (k8s, uptime,
3
+ * external watchdogs).
4
+ *
5
+ * Routes:
6
+ * - GET /health → hub self-health
7
+ * - GET /health/agents → list of online agent node IDs
8
+ * - GET /health/agents/:nodeId → forward to agent's `$agent.health`
9
+ * - GET /health/cluster → hub + every online agent in one shot
10
+ *
11
+ * The same health shape is exposed by every agent at its own
12
+ * `http://<agent>:4444/health`. Both surfaces are backed by the
13
+ * `$agent.health` Moleculer action so monitors that talk to the hub
14
+ * and monitors that talk directly to an agent see identical payloads.
15
+ */
16
+ import type { FastifyInstance, FastifyReply } from 'fastify'
17
+ import type {
18
+ AgentHealth,
19
+ AgentHealthError,
20
+ ClusterHealth,
21
+ HubHealth,
22
+ } from '@camstack/types'
23
+ import type { MoleculerService } from '../../core/moleculer/moleculer.service'
24
+ import type { AgentRegistryService } from '../../core/agent/agent-registry.service'
25
+
26
+ interface HealthRoutesDeps {
27
+ readonly moleculer: MoleculerService
28
+ readonly agentRegistry: AgentRegistryService
29
+ readonly hubVersion: string
30
+ }
31
+
32
+ interface ProcessLike {
33
+ uptime(): number
34
+ pid: number
35
+ cpuUsage(previous?: NodeJS.CpuUsage): NodeJS.CpuUsage
36
+ memoryUsage(): NodeJS.MemoryUsage
37
+ }
38
+
39
+ const AGENT_HEALTH_TIMEOUT_MS = 3_000
40
+
41
+ function nowIso(): string {
42
+ return new Date().toISOString()
43
+ }
44
+
45
+ async function buildHubHealth(deps: HealthRoutesDeps, proc: ProcessLike = process): Promise<HubHealth> {
46
+ const nodes = await deps.agentRegistry.listNodes()
47
+ const remote = nodes.filter((n) => !n.isHub)
48
+ const online = remote.filter((n) => n.isOnline !== false).length
49
+ const total = remote.length
50
+ const memUsage = proc.memoryUsage()
51
+ const totalMem = memUsage.heapTotal + memUsage.external + memUsage.arrayBuffers
52
+ const memoryPercent = totalMem > 0 ? Math.round((memUsage.heapUsed / totalMem) * 100) : 0
53
+ return {
54
+ ok: true,
55
+ nodeId: 'hub',
56
+ version: deps.hubVersion,
57
+ uptimeSeconds: Math.round(proc.uptime()),
58
+ pid: proc.pid,
59
+ agents: { total, online, offline: total - online },
60
+ cpuPercent: 0,
61
+ memoryPercent,
62
+ checkedAt: nowIso(),
63
+ }
64
+ }
65
+
66
+ async function fetchAgentHealth(
67
+ deps: HealthRoutesDeps,
68
+ nodeId: string,
69
+ ): Promise<AgentHealth | AgentHealthError> {
70
+ try {
71
+ const result = (await deps.moleculer.broker.call(
72
+ '$agent.health',
73
+ {},
74
+ { nodeID: nodeId, timeout: AGENT_HEALTH_TIMEOUT_MS },
75
+ )) as AgentHealth
76
+ return result
77
+ } catch (err) {
78
+ return {
79
+ ok: false,
80
+ nodeId,
81
+ error: err instanceof Error ? err.message : String(err),
82
+ }
83
+ }
84
+ }
85
+
86
+ export function registerHealthRoutes(fastify: FastifyInstance, deps: HealthRoutesDeps): void {
87
+ fastify.get('/health', async (): Promise<HubHealth> => buildHubHealth(deps))
88
+
89
+ fastify.get('/health/agents', async (): Promise<{ readonly agents: readonly string[] }> => {
90
+ const nodes = await deps.agentRegistry.listNodes()
91
+ return {
92
+ agents: nodes
93
+ .filter((n) => !n.isHub && n.isOnline !== false)
94
+ .map((n) => n.info.id),
95
+ }
96
+ })
97
+
98
+ fastify.get<{ Params: { nodeId: string } }>(
99
+ '/health/agents/:nodeId',
100
+ async (req, reply: FastifyReply) => {
101
+ const { nodeId } = req.params
102
+ if (!nodeId) {
103
+ return reply.status(400).send({ ok: false, error: 'nodeId required' })
104
+ }
105
+ const result = await fetchAgentHealth(deps, nodeId)
106
+ if (!result.ok) {
107
+ return reply.status(503).send(result)
108
+ }
109
+ return result
110
+ },
111
+ )
112
+
113
+ fastify.get('/health/cluster', async (): Promise<ClusterHealth> => {
114
+ const hub = await buildHubHealth(deps)
115
+ const nodes = await deps.agentRegistry.listNodes()
116
+ const remote = nodes.filter((n) => !n.isHub && n.isOnline !== false)
117
+ const agents = await Promise.all(
118
+ remote.map((n) => fetchAgentHealth(deps, n.info.id)),
119
+ )
120
+ const ok = hub.ok && agents.every((a) => a.ok)
121
+ return { ok, hub, agents, checkedAt: nowIso() }
122
+ })
123
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { validateAuthorizeQuery, isRedirectUriAllowed } from '../oauth2-routes.js'
3
+
4
+ describe('validateAuthorizeQuery', () => {
5
+ const known = new Set(['export-alexa'])
6
+ it('accepts a well-formed query for a known integration', () => {
7
+ const r = validateAuthorizeQuery(
8
+ { response_type: 'code', integration: 'export-alexa', redirect_uri: 'https://cb/r', state: 's' },
9
+ known,
10
+ )
11
+ expect(r.ok).toBe(true)
12
+ })
13
+ it('rejects an unknown integration', () => {
14
+ const r = validateAuthorizeQuery(
15
+ { response_type: 'code', integration: 'nope', redirect_uri: 'https://cb/r', state: 's' },
16
+ known,
17
+ )
18
+ expect(r.ok).toBe(false)
19
+ })
20
+ it('rejects a missing state', () => {
21
+ const r = validateAuthorizeQuery(
22
+ { response_type: 'code', integration: 'export-alexa', redirect_uri: 'https://cb/r' },
23
+ known,
24
+ )
25
+ expect(r.ok).toBe(false)
26
+ })
27
+ it('rejects a non-code response_type', () => {
28
+ const r = validateAuthorizeQuery(
29
+ { response_type: 'token', integration: 'export-alexa', redirect_uri: 'https://cb/r', state: 's' },
30
+ known,
31
+ )
32
+ expect(r.ok).toBe(false)
33
+ })
34
+ })
35
+
36
+ describe('isRedirectUriAllowed', () => {
37
+ const prefixes = ['https://layla.amazon.com/', 'https://pitangui.amazon.com/']
38
+
39
+ it('returns true when redirect_uri starts with an allowed prefix', () => {
40
+ expect(isRedirectUriAllowed('https://layla.amazon.com/oauth/callback', prefixes)).toBe(true)
41
+ expect(isRedirectUriAllowed('https://pitangui.amazon.com/oauth/callback', prefixes)).toBe(true)
42
+ })
43
+
44
+ it('returns false when redirect_uri does not match any allowed prefix', () => {
45
+ expect(isRedirectUriAllowed('https://evil.example/grab', prefixes)).toBe(false)
46
+ expect(isRedirectUriAllowed('https://layla.amazon.com.evil.example/', prefixes)).toBe(false)
47
+ })
48
+
49
+ it('returns false when allowedPrefixes is empty', () => {
50
+ expect(isRedirectUriAllowed('https://layla.amazon.com/oauth/callback', [])).toBe(false)
51
+ })
52
+ })
@@ -0,0 +1,42 @@
1
+ function escapeHtml(s: string): string {
2
+ return s.replace(/[&<>"']/g, (c) => (
3
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!
4
+ ))
5
+ }
6
+
7
+ interface ConsentPageInput {
8
+ displayName: string
9
+ username: string
10
+ scopeSummary: string
11
+ /** Hidden form fields replayed on POST. */
12
+ hidden: Record<string, string>
13
+ }
14
+
15
+ /** Allow/Deny consent screen. Submitting POSTs to the same path with the
16
+ * hidden query fields + `consent=allow|deny`. */
17
+ export function renderConsentPage(input: ConsentPageInput): string {
18
+ const hidden = Object.entries(input.hidden)
19
+ .map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`)
20
+ .join('\n ')
21
+ return `<!doctype html>
22
+ <html lang="en"><head><meta charset="utf-8">
23
+ <title>CamStack · Authorize ${escapeHtml(input.displayName)}</title>
24
+ <style>
25
+ body{font-family:system-ui,sans-serif;max-width:480px;margin:4rem auto;padding:0 1rem;color:#1f2937}
26
+ .card{border:1px solid #e5e7eb;border-radius:.5rem;padding:1.5rem}
27
+ .meta{font-size:.85rem;color:#6b7280;margin:.5rem 0}
28
+ button{font-size:1rem;padding:.5rem 1.25rem;border-radius:.375rem;cursor:pointer;border:1px solid transparent}
29
+ .allow{background:#2563eb;color:#fff}.deny{background:#f3f4f6;color:#1f2937;border-color:#d1d5db;margin-left:.5rem}
30
+ </style></head><body>
31
+ <div class="card">
32
+ <h1>Authorize ${escapeHtml(input.displayName)}</h1>
33
+ <p class="meta">Signed in as <strong>${escapeHtml(input.username)}</strong></p>
34
+ <p class="meta">This will grant: ${escapeHtml(input.scopeSummary)}</p>
35
+ <form method="POST">
36
+ ${hidden}
37
+ <button class="allow" name="consent" value="allow" type="submit">Allow</button>
38
+ <button class="deny" name="consent" value="deny" type="submit">Deny</button>
39
+ </form>
40
+ </div>
41
+ </body></html>`
42
+ }