guesty-mcp-server 0.7.0 → 0.8.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  All notable changes to the Guesty MCP Server will be documented in this file.
4
4
 
5
+ ## [0.8.1] - 2026-04-19
6
+
7
+ ### Fixed
8
+ - Added `.npmignore` to exclude token files, tests, and non-essential markdown from npm package
9
+ - Added `.mcpregistry_*` patterns to `.gitignore` (credential hygiene)
10
+ - Package size reduced from 42.3kB to 35.0kB (26→23 files)
11
+
12
+ ## [0.8.0] - 2026-04-17
13
+
14
+ ### Changed — Enterprise Tier MVP Merge (Owner-approved option-c path, msg 6406)
15
+ - Enterprise aggregators (`get_property_health`, `submit_checkout_photos`,
16
+ `get_maintenance_alerts`) now layer Guesty-side data (reservation status,
17
+ review score, last-clean timestamp) on top of IoT helpers. Single-call
18
+ snapshots for ops dashboards.
19
+ - IoT-only handlers extracted from `iot-tools.js` to internal async helpers
20
+ (`getIoTPropertyHealth`, `submitIoTCheckoutPhotos`, `getIoTMaintenanceAlerts`);
21
+ canonical MCP tool registration moved to `enterprise-tools.js`.
22
+ - Graceful degradation: Guesty sub-fetch failures degrade to null value +
23
+ per-field error note (aggregator still returns IoT data).
24
+ - `iot-tools.js` retains single MCP registration for `get_readiness_score`.
25
+ - Tool count reconciled across README + license.js + package.json + server.json
26
+ to 43 total (39 Guesty + 1 IoT + 3 Enterprise aggregators). Previous 3-way
27
+ drift (README:38, license.js:38, actual registrations:43) resolved.
28
+
29
+ ### Added
30
+ - `__handlers` export on `enterprise-tools.js` for direct smoke-test invocation
31
+ (renamed from legacy `__stubs` — real handlers, not stubs, post-merge).
32
+ - `tests/test-enterprise.js` rewritten: exports + free-tier-gate + enterprise-lift
33
+ smoke tests. Dynamic import + env-stub so test runs without real Guesty creds.
34
+
35
+ ### Fixed
36
+ - `package.json` test script referenced non-existent `tests/test-tools.js`.
37
+ Now runs `tests/test-enterprise.js && tests/test-iot.js`.
38
+
5
39
  ## [0.7.0] - 2026-04-15
6
40
 
7
41
  ### Added
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  The first MCP (Model Context Protocol) server for [Guesty](https://guesty.com) property management. Connect AI agents directly to your Guesty account to manage reservations, communicate with guests, track finances, and update pricing -- all autonomously.
7
7
 
8
- **38 tools** covering reservations, listings, guests, messaging, financials, tasks, calendars, webhooks, pricing, and more.
8
+ **43 tools** covering reservations, listings, guests, messaging, financials, tasks, calendars, webhooks, and pricing — plus **1 IoT tool** (`get_readiness_score`) and **3 Enterprise-tier aggregators** (`get_property_health`, `submit_checkout_photos`, `get_maintenance_alerts`) for property health aggregation, checkout photo intake, and portfolio maintenance alerts.
9
9
 
10
10
  > **Want AI to handle your guest messages 24/7?** [Guesty Copilot](https://guestycopilot.com) -- AI guest management for Guesty hosts, built on this MCP server. Now in beta.
11
11
 
@@ -41,7 +41,7 @@ Or add to your Claude Code settings (`~/.claude/settings.json`):
41
41
  3. Create an API application with `open-api` scope
42
42
  4. Copy your **Client ID** and **Client Secret**
43
43
 
44
- ## All 38 Tools
44
+ ## All 43 Tools
45
45
 
46
46
  ### Reservations & Guests
47
47
  | Tool | Description |
@@ -105,6 +105,15 @@ Or add to your Claude Code settings (`~/.claude/settings.json`):
105
105
  | `get_custom_fields` | Fetch custom fields for listings or reservations |
106
106
  | `get_account_info` | Get account info and subscription details |
107
107
 
108
+ ### Enterprise Tier
109
+ | Tool | Description |
110
+ |------|-------------|
111
+ | `get_property_health` | Aggregate health signal per property: reservation status, open maintenance alerts, review-score, last-clean timestamp, IoT hub status |
112
+ | `submit_checkout_photos` | Accept post-checkout photo uploads and log them to the property's maintenance/cleaning record |
113
+ | `get_maintenance_alerts` | List or filter open maintenance alerts for a property or portfolio |
114
+
115
+ Requires `GUESTY_MCP_LICENSE_KEY` with an Enterprise key (`gmcp_ent_*`). See [pricing](https://guestycopilot.com/pricing).
116
+
108
117
  ## Use Cases
109
118
 
110
119
  - **Guest Communication**: AI agents auto-respond to guest inquiries using real reservation data
@@ -119,6 +128,14 @@ Or add to your Claude Code settings (`~/.claude/settings.json`):
119
128
  - Guesty account with API access (Professional plan or higher)
120
129
  - MCP-compatible AI client (Claude Code, Cursor, Windsurf, etc.)
121
130
 
131
+ ## Environment Variables
132
+
133
+ | Variable | Default | Purpose |
134
+ |----------|---------|---------|
135
+ | `GUESTY_CLIENT_ID` | — | OAuth2 client id (required) |
136
+ | `GUESTY_CLIENT_SECRET` | — | OAuth2 client secret (required) |
137
+ | `IOT_WEBHOOK_PORT` | `3100` | Port for the Enterprise-tier IoT webhook receiver stub (`src/webhook/iot-receiver-server.js`). Local/reverse-proxy only — do not expose publicly. Production requires a reverse proxy that terminates TLS and enforces real HMAC against `IOT_WEBHOOK_SECRET`. |
138
+
122
139
  ## API Reference
123
140
 
124
141
  This server wraps the [Guesty Open API](https://open-api.guesty.com/api-docs). Authentication uses OAuth2 client credentials flow with automatic token caching, retry logic, and rate limit handling.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "guesty-mcp-server",
3
- "version": "0.7.0",
4
- "description": "The first MCP server for Guesty property management. 38 tools for reservations, guests, messaging, pricing, financials, calendars, reviews, tasks, and webhooks.",
3
+ "version": "0.8.1",
4
+ "description": "The first MCP server for Guesty property management. 43 tools (39 Guesty + 1 IoT + 3 Enterprise aggregators) for reservations, guests, messaging, pricing, financials, calendars, reviews, tasks, webhooks, and Enterprise-tier property health / checkout photos / maintenance alerts.",
5
5
  "main": "src/server.js",
6
6
  "bin": {
7
7
  "guesty-mcp-server": "src/server.js",
@@ -10,7 +10,7 @@
10
10
  "type": "module",
11
11
  "scripts": {
12
12
  "start": "node src/server.js",
13
- "test": "node tests/test-tools.js"
13
+ "test": "node tests/test-enterprise.js && node tests/test-iot.js"
14
14
  },
15
15
  "keywords": [
16
16
  "mcp",
package/server.json CHANGED
@@ -2,8 +2,8 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.DLJRealty/guesty",
4
4
  "title": "Guesty MCP Server",
5
- "description": "MCP server for Guesty property management — 38 tools for STR operations.",
6
- "version": "0.7.0",
5
+ "description": "MCP server for Guesty property management — 43 tools (39 Guesty + 1 IoT + 3 Enterprise aggregators) for STR operations.",
6
+ "version": "0.8.0",
7
7
  "repository": {
8
8
  "url": "https://github.com/DLJRealty/guesty-mcp-server",
9
9
  "source": "github"
@@ -11,7 +11,7 @@
11
11
  "packages": [{
12
12
  "registryType": "npm",
13
13
  "identifier": "guesty-mcp-server",
14
- "version": "0.7.0",
14
+ "version": "0.8.0",
15
15
  "transport": { "type": "stdio" },
16
16
  "environmentVariables": [
17
17
  {
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Guesty MCP Server — Enterprise Tier Aggregation Tools
3
+ *
4
+ * Three Enterprise-tier MCP tools that aggregate IoT device signal with
5
+ * Guesty-side data (reservation status, review score, last-clean timestamp)
6
+ * into single-call snapshots for ops dashboards.
7
+ *
8
+ * Tools:
9
+ * - get_property_health aggregated single-property snapshot
10
+ * (IoT devices + reservation + reviews + clean)
11
+ * - submit_checkout_photos post-checkout photo intake (writes through to
12
+ * the IoT baseline store via iot-db.js)
13
+ * - get_maintenance_alerts active IoT maintenance alerts, filterable
14
+ * per listing or portfolio-wide
15
+ *
16
+ * ── MERGE STATUS (2026-04-17, Owner greenlight msg 6406) ────────────────
17
+ * These tools are now the CANONICAL registrations at these names. The
18
+ * same names previously lived in iot-tools.js as IoT-only views — those
19
+ * have been extracted into plain-async internal helpers and are imported
20
+ * here. This is the Owner-approved option-c merge path: one tool name
21
+ * per MCP surface, Enterprise aggregator wraps the IoT getter.
22
+ *
23
+ * Guesty-side layering (reservation status, review score, last-clean)
24
+ * uses guestyGet() from server.js. Each call is wrapped in try/catch so
25
+ * the aggregator degrades gracefully — if Guesty is slow or fails on one
26
+ * sub-query, the tool still returns IoT data with the failing field set
27
+ * to null + a non-fatal error note.
28
+ */
29
+
30
+ import { z } from "zod";
31
+ import { guestyGet } from "./server.js";
32
+ import {
33
+ enterpriseGated,
34
+ getIoTPropertyHealth,
35
+ submitIoTCheckoutPhotos,
36
+ getIoTMaintenanceAlerts,
37
+ } from "./iot-tools.js";
38
+
39
+ // ─────────────────────────────────────────────────────────────────────────
40
+ // Graceful Guesty fetchers — each returns { value, error } so the aggregator
41
+ // can surface partial data even when one sub-call fails.
42
+ // ─────────────────────────────────────────────────────────────────────────
43
+
44
+ async function safeFetchReservationStatus(listingId) {
45
+ try {
46
+ // Current active / upcoming reservation for this listing
47
+ const data = await guestyGet("/v1/reservations", {
48
+ listingId,
49
+ limit: 1,
50
+ sort: "-checkIn",
51
+ "filters[status]": "confirmed,checked_in",
52
+ });
53
+ const r = (data && data.results && data.results[0]) || null;
54
+ if (!r) return { value: { status: "no_active_reservation" }, error: null };
55
+ return {
56
+ value: {
57
+ reservation_id: r._id,
58
+ status: r.status,
59
+ check_in: r.checkIn,
60
+ check_out: r.checkOut,
61
+ guest: r.guest && r.guest.fullName,
62
+ },
63
+ error: null,
64
+ };
65
+ } catch (e) {
66
+ return { value: null, error: e.message };
67
+ }
68
+ }
69
+
70
+ async function safeFetchReviewScore(listingId) {
71
+ try {
72
+ const data = await guestyGet("/v1/reviews", { listingId, limit: 50 });
73
+ const reviews = (data && data.results) || [];
74
+ if (reviews.length === 0) {
75
+ return { value: { average: null, count: 0 }, error: null };
76
+ }
77
+ const sum = reviews.reduce(
78
+ (acc, rv) => acc + (typeof rv.overallRating === "number" ? rv.overallRating : 0),
79
+ 0
80
+ );
81
+ return {
82
+ value: {
83
+ average: Math.round((sum / reviews.length) * 10) / 10,
84
+ count: reviews.length,
85
+ },
86
+ error: null,
87
+ };
88
+ } catch (e) {
89
+ return { value: null, error: e.message };
90
+ }
91
+ }
92
+
93
+ async function safeFetchLastClean(listingId) {
94
+ try {
95
+ // Tasks filtered to cleaning/turnover type, sorted by completion time desc
96
+ const data = await guestyGet("/v1/tasks", {
97
+ "filters[listing]": listingId,
98
+ "filters[type]": "cleaning,turnover",
99
+ "filters[status]": "completed",
100
+ limit: 1,
101
+ sort: "-completedAt",
102
+ });
103
+ const t = (data && data.results && data.results[0]) || null;
104
+ if (!t) return { value: { completed_at: null }, error: null };
105
+ return {
106
+ value: {
107
+ completed_at: t.completedAt || t.updatedAt || null,
108
+ task_id: t._id,
109
+ assignee: t.assignee && t.assignee.fullName,
110
+ },
111
+ error: null,
112
+ };
113
+ } catch (e) {
114
+ return { value: null, error: e.message };
115
+ }
116
+ }
117
+
118
+ // ─────────────────────────────────────────────────────────────────────────
119
+ // Utility: wrap a raw data object in the MCP text-content envelope.
120
+ // ─────────────────────────────────────────────────────────────────────────
121
+ function envelope(obj) {
122
+ return {
123
+ content: [
124
+ { type: "text", text: JSON.stringify(obj, null, 2) },
125
+ ],
126
+ };
127
+ }
128
+
129
+ function errorEnvelope(message, details) {
130
+ return {
131
+ content: [
132
+ {
133
+ type: "text",
134
+ text: JSON.stringify({ error: message, details: details || null }, null, 2),
135
+ },
136
+ ],
137
+ isError: true,
138
+ };
139
+ }
140
+
141
+ // ─────────────────────────────────────────────────────────────────────────
142
+ // Register the 3 Enterprise-tier aggregation tools on the MCP server.
143
+ // ─────────────────────────────────────────────────────────────────────────
144
+
145
+ export function registerEnterpriseTools(server) {
146
+ // ── Tool 1: get_property_health (aggregated) ────────────────────
147
+ server.tool(
148
+ "get_property_health",
149
+ "Aggregate health signal per property: IoT device status + overall IoT state, current reservation status, 50-review average score, and last-clean timestamp. Single-call snapshot for ops dashboards. Sub-fetches that fail degrade gracefully (null value + error note).",
150
+ {
151
+ listingId: z.string().describe("The Guesty listing ID for the property"),
152
+ },
153
+ {
154
+ readOnlyHint: true,
155
+ destructiveHint: false,
156
+ idempotentHint: true,
157
+ openWorldHint: false,
158
+ },
159
+ enterpriseGated("get_property_health", async (params) => {
160
+ try {
161
+ const [iot, reservation, reviews, lastClean] = await Promise.all([
162
+ getIoTPropertyHealth(params.listingId),
163
+ safeFetchReservationStatus(params.listingId),
164
+ safeFetchReviewScore(params.listingId),
165
+ safeFetchLastClean(params.listingId),
166
+ ]);
167
+ const partialErrors = [];
168
+ if (reservation.error) partialErrors.push({ field: "reservation_status", error: reservation.error });
169
+ if (reviews.error) partialErrors.push({ field: "review_score", error: reviews.error });
170
+ if (lastClean.error) partialErrors.push({ field: "last_clean", error: lastClean.error });
171
+
172
+ return envelope({
173
+ property_id: params.listingId,
174
+ iot: {
175
+ devices: iot.devices,
176
+ overall_status: iot.overall_status,
177
+ device_count: iot.devices.length,
178
+ },
179
+ reservation_status: reservation.value,
180
+ review_score: reviews.value,
181
+ last_clean: lastClean.value,
182
+ partial_errors: partialErrors.length ? partialErrors : null,
183
+ fetched_at: new Date().toISOString(),
184
+ });
185
+ } catch (e) {
186
+ return errorEnvelope("Failed to aggregate property health.", e.message);
187
+ }
188
+ })
189
+ );
190
+
191
+ // ── Tool 2: submit_checkout_photos ──────────────────────────────
192
+ server.tool(
193
+ "submit_checkout_photos",
194
+ "Accept post-checkout photo uploads and log them to the property's maintenance/cleaning record. Photos are queued for downstream inspection workflows (Phase 2 vision comparison).",
195
+ {
196
+ listingId: z
197
+ .string()
198
+ .describe("The Guesty listing ID for the property"),
199
+ reservationId: z
200
+ .string()
201
+ .describe("The reservation ID this checkout is for"),
202
+ photos: z
203
+ .array(z.string())
204
+ .describe("Array of photo URLs or file paths to submit"),
205
+ },
206
+ {
207
+ readOnlyHint: false,
208
+ destructiveHint: false,
209
+ idempotentHint: false,
210
+ openWorldHint: false,
211
+ },
212
+ enterpriseGated("submit_checkout_photos", async (params) => {
213
+ try {
214
+ const result = await submitIoTCheckoutPhotos({
215
+ listingId: params.listingId,
216
+ reservationId: params.reservationId,
217
+ photos: params.photos,
218
+ });
219
+ return envelope(result);
220
+ } catch (e) {
221
+ return errorEnvelope("Failed to submit checkout photos.", e.message);
222
+ }
223
+ })
224
+ );
225
+
226
+ // ── Tool 3: get_maintenance_alerts (portfolio-capable) ──────────
227
+ server.tool(
228
+ "get_maintenance_alerts",
229
+ "List or filter open maintenance alerts for a specific property or across the whole portfolio. Supports severity filtering and active-only (unresolved) filtering. IoT-sourced today; future Phase will merge Guesty-native task alerts.",
230
+ {
231
+ listingId: z
232
+ .string()
233
+ .optional()
234
+ .describe("Filter alerts to a specific listing ID. Omit for portfolio-wide."),
235
+ severity: z
236
+ .enum(["critical", "warning", "info", "all"])
237
+ .optional()
238
+ .default("all")
239
+ .describe("Filter by alert severity level (default: all)"),
240
+ active_only: z
241
+ .boolean()
242
+ .optional()
243
+ .default(true)
244
+ .describe("Only return active (unresolved) alerts (default: true)"),
245
+ },
246
+ {
247
+ readOnlyHint: true,
248
+ destructiveHint: false,
249
+ idempotentHint: true,
250
+ openWorldHint: false,
251
+ },
252
+ enterpriseGated("get_maintenance_alerts", async (params) => {
253
+ try {
254
+ const result = await getIoTMaintenanceAlerts({
255
+ listingId: params.listingId,
256
+ severity: params.severity,
257
+ activeOnly: params.active_only,
258
+ });
259
+ return envelope({
260
+ ...result,
261
+ scope: params.listingId ? "listing" : "portfolio",
262
+ fetched_at: new Date().toISOString(),
263
+ });
264
+ } catch (e) {
265
+ return errorEnvelope("Failed to retrieve maintenance alerts.", e.message);
266
+ }
267
+ })
268
+ );
269
+ }
270
+
271
+ // Exported for direct invocation from smoke tests without constructing
272
+ // a full McpServer instance. Each is a real call now, not a stub — tests
273
+ // may need to stub the IoT db + guestyGet for isolated execution.
274
+ export const __handlers = {
275
+ get_property_health: enterpriseGated("get_property_health", async (params) => {
276
+ const [iot, reservation, reviews, lastClean] = await Promise.all([
277
+ getIoTPropertyHealth(params.listingId),
278
+ safeFetchReservationStatus(params.listingId),
279
+ safeFetchReviewScore(params.listingId),
280
+ safeFetchLastClean(params.listingId),
281
+ ]);
282
+ return envelope({
283
+ property_id: params.listingId,
284
+ iot: {
285
+ devices: iot.devices,
286
+ overall_status: iot.overall_status,
287
+ device_count: iot.devices.length,
288
+ },
289
+ reservation_status: reservation.value,
290
+ review_score: reviews.value,
291
+ last_clean: lastClean.value,
292
+ partial_errors: [
293
+ reservation.error && { field: "reservation_status", error: reservation.error },
294
+ reviews.error && { field: "review_score", error: reviews.error },
295
+ lastClean.error && { field: "last_clean", error: lastClean.error },
296
+ ].filter(Boolean),
297
+ fetched_at: new Date().toISOString(),
298
+ });
299
+ }),
300
+ submit_checkout_photos: enterpriseGated("submit_checkout_photos", async (params) => {
301
+ const result = await submitIoTCheckoutPhotos({
302
+ listingId: params.listingId,
303
+ reservationId: params.reservationId,
304
+ photos: params.photos,
305
+ });
306
+ return envelope(result);
307
+ }),
308
+ get_maintenance_alerts: enterpriseGated("get_maintenance_alerts", async (params) => {
309
+ const result = await getIoTMaintenanceAlerts({
310
+ listingId: params.listingId,
311
+ severity: params.severity,
312
+ activeOnly: params.active_only,
313
+ });
314
+ return envelope({
315
+ ...result,
316
+ scope: params.listingId ? "listing" : "portfolio",
317
+ fetched_at: new Date().toISOString(),
318
+ });
319
+ }),
320
+ };
@@ -6,6 +6,7 @@
6
6
  import express from "express";
7
7
  import { randomUUID } from "crypto";
8
8
  import iotRouter from "./iot-webhook.js";
9
+ import iotReceiverRouter from "./webhook/iot-receiver.js";
9
10
  import { initDB } from "./iot-db.js";
10
11
 
11
12
  const app = express();
@@ -15,6 +16,13 @@ app.use(express.json());
15
16
  initDB();
16
17
  app.use(iotRouter);
17
18
 
19
+ // Enterprise Tier — inbound IoT event receiver (stub).
20
+ // Mounted on the same app so hosted deployments can accept events at
21
+ // /webhook/iot/:property_id. For local dev, the standalone boot file
22
+ // src/webhook/iot-receiver-server.js exposes the same router on
23
+ // IOT_WEBHOOK_PORT (default 3100).
24
+ app.use(iotReceiverRouter);
25
+
18
26
  const PORT = process.env.PORT || 3001;
19
27
 
20
28
  // Request counter
package/src/iot-db.js CHANGED
@@ -9,11 +9,25 @@
9
9
 
10
10
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
11
11
  import { homedir } from "os";
12
- import { join } from "path";
12
+ import { join, dirname } from "path";
13
13
  import { randomUUID } from "crypto";
14
14
 
15
- const DATA_DIR = join(homedir(), ".dlj-scripts", "data");
16
- const DB_PATH = join(DATA_DIR, "iot-devices.json");
15
+ // R5_TASK_106_IOT_DB_ENV_OVERRIDE (2026-04-18 CTO): honor IOT_DB_PATH env var
16
+ // so integration tests can isolate to a temp DB without mutating prod data.
17
+ // Unset env var = current prod behavior (default path). Tests set it at top
18
+ // of harness to `os.tmpdir()/iot-test-<ts>.json`.
19
+ //
20
+ // Lazy evaluation: ES module bodies evaluate at import time, BEFORE test
21
+ // harness code runs, so top-level `const DB_PATH = process.env.X || ...`
22
+ // would capture the pre-test env state. getDbPath()/getDataDir() resolve
23
+ // on first call (inside load/flush), after the test has set the env var.
24
+ const DEFAULT_DB_PATH = join(homedir(), ".dlj-scripts", "data", "iot-devices.json");
25
+ function getDbPath() {
26
+ return process.env.IOT_DB_PATH || DEFAULT_DB_PATH;
27
+ }
28
+ function getDataDir() {
29
+ return dirname(getDbPath());
30
+ }
17
31
 
18
32
  // ── Schema shape ───────────────────────────────────────────────────────────
19
33
 
@@ -37,12 +51,14 @@ let _db = null;
37
51
 
38
52
  function load() {
39
53
  if (_db) return _db;
40
- if (!existsSync(DATA_DIR)) {
41
- mkdirSync(DATA_DIR, { recursive: true });
54
+ const dataDir = getDataDir();
55
+ const dbPath = getDbPath();
56
+ if (!existsSync(dataDir)) {
57
+ mkdirSync(dataDir, { recursive: true });
42
58
  }
43
- if (existsSync(DB_PATH)) {
59
+ if (existsSync(dbPath)) {
44
60
  try {
45
- _db = JSON.parse(readFileSync(DB_PATH, "utf-8"));
61
+ _db = JSON.parse(readFileSync(dbPath, "utf-8"));
46
62
  // Ensure all top-level keys exist (forward compat)
47
63
  const empty = emptyDB();
48
64
  for (const key of Object.keys(empty)) {
@@ -61,7 +77,7 @@ function load() {
61
77
  function flush() {
62
78
  const db = load();
63
79
  db._meta.last_write = new Date().toISOString();
64
- writeFileSync(DB_PATH, JSON.stringify(db, null, 2), "utf-8");
80
+ writeFileSync(getDbPath(), JSON.stringify(db, null, 2), "utf-8");
65
81
  }
66
82
 
67
83
  // ── Public API ─────────────────────────────────────────────────────────────
@@ -73,8 +89,9 @@ function flush() {
73
89
  export function initDB() {
74
90
  load();
75
91
  flush();
76
- console.log(`[iot-db] Initialized at ${DB_PATH}`);
77
- return DB_PATH;
92
+ const dbPath = getDbPath();
93
+ console.log(`[iot-db] Initialized at ${dbPath}`);
94
+ return dbPath;
78
95
  }
79
96
 
80
97
  // ── Devices ────────────────────────────────────────────────────────────────