diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..1e6d473
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,30 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(npm:*)",
+ "Bash(yarn:*)",
+ "Bash(npx:*)",
+ "Bash(node:*)",
+ "Bash(curl:*)",
+ "Bash(gh:*)",
+ "Bash(jq:*)",
+ "Bash(mkdir:*)",
+ "Bash(docker:*)",
+ "Bash(docker compose:*)",
+ "Edit",
+ "Read",
+ "Write",
+ "Grep",
+ "Glob",
+ "LS"
+ ],
+ "deny": [
+ "Read(./.env)",
+ "Read(./.env.*)",
+ "Read(./secrets/**)"
+ ],
+ "ask": [
+ "Bash(git push:*)"
+ ]
+ }
+}
diff --git a/.claude/skills/run-tests/SKILL.md b/.claude/skills/run-tests/SKILL.md
new file mode 100644
index 0000000..0b3b60d
--- /dev/null
+++ b/.claude/skills/run-tests/SKILL.md
@@ -0,0 +1,195 @@
+---
+name: run-tests
+description: Run API tests against InfluxDB 3 Core. Handles service initialization, database setup, and test execution.
+author: InfluxData
+version: "1.0"
+---
+
+# Run Tests Skill
+
+## Purpose
+
+This skill guides running the IoT API test suite against a local InfluxDB 3 Core instance. It covers service setup, database creation, and test execution.
+
+## Quick Reference
+
+| Task | Command |
+|------|---------|
+| Start InfluxDB 3 | `docker compose up -d influxdb3-core` |
+| Check status | `curl -i http://localhost:8181/health` |
+| Run tests | `yarn test` |
+| View logs | `docker logs influxdb3-core` |
+
+## Complete Setup Workflow
+
+### 1. Initialize InfluxDB 3 Core
+
+```bash
+# Create required directories
+mkdir -p test/.influxdb3/core/data test/.influxdb3/core/plugins
+
+# Generate admin token (first time only)
+openssl rand -hex 32 > test/.influxdb3/core/.token
+chmod 600 test/.influxdb3/core/.token
+
+# Start the container
+docker compose up -d influxdb3-core
+
+# Wait for healthy status
+docker compose ps
+```
+
+### 2. Create Databases
+
+```bash
+# Get the token
+TOKEN=$(cat test/.influxdb3/core/.token)
+
+# Create main database
+curl -X POST "http://localhost:8181/api/v3/configure/database" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"db": "iot_center"}'
+
+# Create auth database
+curl -X POST "http://localhost:8181/api/v3/configure/database" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"db": "iot_center_auth"}'
+
+# Verify databases exist
+curl "http://localhost:8181/api/v3/configure/database?format=json" \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+### 3. Configure Environment
+
+Create `.env.local` if it doesn't exist:
+
+```bash
+cat > .env.local << EOF
+INFLUX_HOST=http://localhost:8181
+INFLUX_TOKEN=$(cat test/.influxdb3/core/.token)
+INFLUX_DATABASE=iot_center
+INFLUX_DATABASE_AUTH=iot_center_auth
+EOF
+```
+
+### 4. Run Tests
+
+```bash
+# Install dependencies (if needed)
+yarn
+
+# Run the test suite
+yarn test
+
+# Run with verbose output
+yarn test --verbose
+
+# Run specific test file
+yarn test __tests__/api.test.js
+```
+
+## Troubleshooting
+
+### Container Won't Start
+
+**Symptom:** Container exits immediately
+
+**Check:**
+```bash
+# View logs
+docker logs influxdb3-core
+
+# Verify directories exist
+ls -la test/.influxdb3/core/
+
+# Verify token file exists
+cat test/.influxdb3/core/.token
+```
+
+**Common fixes:**
+- Create missing directories: `mkdir -p test/.influxdb3/core/data test/.influxdb3/core/plugins`
+- Generate token: `openssl rand -hex 32 > test/.influxdb3/core/.token`
+
+### 401 Unauthorized
+
+**Symptom:** API calls return 401
+
+**Check:**
+```bash
+# Verify token matches
+echo "Token in file: $(cat test/.influxdb3/core/.token)"
+echo "Token in .env.local: $(grep INFLUX_TOKEN .env.local)"
+
+# Test with token directly
+curl -i http://localhost:8181/api/v3/configure/database \
+ -H "Authorization: Bearer $(cat test/.influxdb3/core/.token)"
+```
+
+### Database Not Found
+
+**Symptom:** Tests fail with "database not found"
+
+**Fix:** Create the required databases:
+```bash
+TOKEN=$(cat test/.influxdb3/core/.token)
+curl -X POST "http://localhost:8181/api/v3/configure/database" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"db": "iot_center"}'
+curl -X POST "http://localhost:8181/api/v3/configure/database" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"db": "iot_center_auth"}'
+```
+
+### Port Already in Use
+
+**Symptom:** "port is already allocated"
+
+**Fix:**
+```bash
+# Find what's using the port
+lsof -i :8181
+
+# Stop existing container
+docker compose down
+```
+
+## Clean Slate
+
+To start fresh:
+
+```bash
+# Stop and remove containers
+docker compose down
+
+# Remove data (WARNING: deletes all data)
+rm -rf test/.influxdb3/core/data/*
+
+# Regenerate token
+openssl rand -hex 32 > test/.influxdb3/core/.token
+
+# Start fresh
+docker compose up -d influxdb3-core
+```
+
+## Test Configuration
+
+The test suite uses these environment variables:
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `INFLUX_HOST` | `http://localhost:8181` | InfluxDB 3 API URL |
+| `INFLUX_TOKEN` | (from `.env.local`) | Admin token |
+| `INFLUX_DATABASE` | `iot_center` | Main data database |
+| `INFLUX_DATABASE_AUTH` | `iot_center_auth` | Device auth database |
+
+## Related Files
+
+- **Docker Compose**: `compose.yaml`
+- **Test suite**: `__tests__/api.test.js`
+- **Environment defaults**: `.env.development`
+- **Local overrides**: `.env.local` (gitignored)
diff --git a/.env.development b/.env.development
index 3846cd4..4a6a9da 100644
--- a/.env.development
+++ b/.env.development
@@ -1,5 +1,12 @@
# Development environment non-secret defaults
+# InfluxDB 3 Core configuration
-INFLUX_URL=http://localhost:8086
-INFLUX_BUCKET=iot_center
-INFLUX_BUCKET_AUTH=iot_center_devices
\ No newline at end of file
+# InfluxDB 3 server URL (default port is 8181)
+INFLUX_HOST=http://localhost:8181
+
+# Database names (equivalent to v2 buckets)
+INFLUX_DATABASE=iot_center
+INFLUX_DATABASE_AUTH=iot_center_devices
+
+# Token should be set in .env.local (not committed to git)
+# INFLUX_TOKEN=your_admin_token_here
\ No newline at end of file
diff --git a/.github/INSTRUCTIONS.md b/.github/INSTRUCTIONS.md
new file mode 100644
index 0000000..bccbd88
--- /dev/null
+++ b/.github/INSTRUCTIONS.md
@@ -0,0 +1,47 @@
+# AI Instructions Navigation Guide
+
+This repository has multiple instruction files for different AI tools and use cases.
+
+## Quick Navigation
+
+| If you are... | Start here |
+|---------------|------------|
+| **GitHub Copilot** | [../AGENTS.md](../AGENTS.md) |
+| **Claude, ChatGPT, Gemini** | [../AGENTS.md](../AGENTS.md) |
+| **Claude with MCP** | [../CLAUDE.md](../CLAUDE.md) |
+
+## File Organization
+
+```
+iot-api-js/
+├── docs/ # Detailed documentation
+│ ├── architecture.md # System design, project structure
+│ ├── development.md # Setup, testing, debugging
+│ ├── api-reference.md # API endpoints
+│ ├── code-patterns.md # InfluxDB client usage
+│ └── contributing.md # Style guidelines
+├── .claude/
+│ ├── settings.json # Claude permissions
+│ └── skills/run-tests/SKILL.md # Test execution workflow
+├── .github/
+│ └── INSTRUCTIONS.md # THIS FILE
+├── AGENTS.md # AI assistant quick reference
+├── CLAUDE.md # Claude with MCP quick reference
+└── README.md # User-facing documentation
+```
+
+## Documentation
+
+| Topic | Location |
+|-------|----------|
+| System architecture | [../docs/architecture.md](../docs/architecture.md) |
+| Development setup | [../docs/development.md](../docs/development.md) |
+| API endpoints | [../docs/api-reference.md](../docs/api-reference.md) |
+| Code patterns | [../docs/code-patterns.md](../docs/code-patterns.md) |
+| Contributing | [../docs/contributing.md](../docs/contributing.md) |
+
+## Getting Started
+
+1. **New to the repository?** Start with [../README.md](../README.md)
+2. **Using AI assistants?** Read [../AGENTS.md](../AGENTS.md) then explore [../docs/](../docs/)
+3. **Using Claude with MCP?** Check [../CLAUDE.md](../CLAUDE.md) and [../.claude/](../.claude/)
diff --git a/.gitignore b/.gitignore
index 20fccdd..372a633 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,9 @@ yarn-error.log*
.env.development.local
.env.test.local
.env.production.local
+
+# InfluxDB 3 local data
+test/.influxdb3/
+
+# Git worktrees
+.worktrees/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..22497c1
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,40 @@
+# IoT API JS - AI Assistant Guide
+
+Sample application for InfluxData tutorials demonstrating REST APIs with InfluxDB 3 Core.
+
+**Target audience:** Developers learning to build IoT applications with InfluxDB 3.
+
+## Quick Reference
+
+| Task | Command |
+|------|---------|
+| Install | `yarn` |
+| Dev server | `yarn dev -p 5200` |
+| Run tests | `yarn test` |
+| Start InfluxDB | `docker compose up -d influxdb3-core` |
+
+## Key Files
+
+| File | Purpose |
+|------|---------|
+| `lib/influxdb.js` | InfluxDB client factory and helpers |
+| `pages/api/devices/create.js` | Device registration endpoint |
+| `pages/api/devices/_devices.js` | Shared device query logic |
+| `pages/api/devices/[[...deviceParams]].js` | Device CRUD operations |
+| `__tests__/api.test.js` | API integration tests |
+
+## Documentation
+
+| Topic | Location |
+|-------|----------|
+| System architecture | [docs/architecture.md](docs/architecture.md) |
+| Development setup | [docs/development.md](docs/development.md) |
+| API endpoints | [docs/api-reference.md](docs/api-reference.md) |
+| Code patterns | [docs/code-patterns.md](docs/code-patterns.md) |
+| Contributing | [docs/contributing.md](docs/contributing.md) |
+
+## Other Resources
+
+- [CLAUDE.md](CLAUDE.md) - For Claude with MCP
+- [README.md](README.md) - User-facing documentation
+- [.github/INSTRUCTIONS.md](.github/INSTRUCTIONS.md) - Navigation guide
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..2ac573d
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,38 @@
+# IoT API JS - Claude Instructions
+
+Next.js REST API demonstrating InfluxDB 3 Core integration for IoT device data.
+
+## Quick Reference
+
+| Task | Command |
+|------|---------|
+| Install | `yarn` |
+| Dev server | `yarn dev -p 5200` |
+| Run tests | `yarn test` |
+| Start InfluxDB | `docker compose up -d influxdb3-core` |
+
+## Key Files
+
+| File | Purpose |
+|------|---------|
+| `lib/influxdb.js` | Client factory and query/write helpers |
+| `pages/api/devices/create.js` | Device registration |
+| `pages/api/devices/_devices.js` | Shared device queries |
+| `pages/api/devices/[[...deviceParams]].js` | Device CRUD + measurements |
+
+## Documentation
+
+| Topic | Location |
+|-------|----------|
+| System architecture | [docs/architecture.md](docs/architecture.md) |
+| Development setup | [docs/development.md](docs/development.md) |
+| API endpoints | [docs/api-reference.md](docs/api-reference.md) |
+| Code patterns | [docs/code-patterns.md](docs/code-patterns.md) |
+| Contributing | [docs/contributing.md](docs/contributing.md) |
+| Testing workflow | [.claude/skills/run-tests/SKILL.md](.claude/skills/run-tests/SKILL.md) |
+
+## Other Resources
+
+- [AGENTS.md](AGENTS.md) - Guide for general AI assistants
+- [.github/INSTRUCTIONS.md](.github/INSTRUCTIONS.md) - Navigation guide
+- [.claude/](.claude/) - Claude Code settings and skills
diff --git a/README.md b/README.md
index 0df96a6..96434f6 100644
--- a/README.md
+++ b/README.md
@@ -1,38 +1,62 @@
# iot-api-js
-This example project provides a Node.js REST API server that interacts with the InfluxDB v2 HTTP API.
-The project uses the [Next.js](https://nextjs.org/) framework and the [InfluxDB v2 API client library for JavaScript](https://docs.influxdata.com/influxdb/v2/api-guide/client-libraries/nodejs/) to demonstrate how to build an app that collects, stores, and queries IoT device data.
+> [!WARNING]
+> #### ⚠️ Sample Application Notice
+> This is a **reference implementation** for learning purposes. It demonstrates InfluxDB 3 client library usage patterns but is **not production-ready**.
+>
+> **Not included:** Authentication/authorization, rate limiting, comprehensive error handling, input sanitization for all edge cases, secure credential management, high availability, or production logging.
+>
+> **Before deploying to production**, implement proper security controls, follow your organization's security guidelines, and conduct a thorough security review.
+
+This example project provides a Node.js REST API server that interacts with InfluxDB 3 Core.
+The project uses the [Next.js](https://nextjs.org/) framework and the [InfluxDB 3 JavaScript client library](https://github.com/InfluxCommunity/influxdb3-js) to demonstrate how to build an app that collects, stores, and queries IoT device data.
After you have set up and run your `iot-api-js` API server, you can consume your API using the [iot-api-ui](https://github.com/influxdata/iot-api-ui) standalone React frontend.
## Features
-This application demonstrates how you can use InfluxDB client libraries to do the following:
+This application demonstrates how you can use the InfluxDB 3 client library to do the following:
-- Create and manage InfluxDB authorizations (API tokens and permissions).
-- Write and query device metadata in InfluxDB.
-- Write and query telemetry data in InfluxDB.
-- Generate data visualizations with the InfluxDB Giraffe library.
+- Register IoT devices with application-level tokens.
+- Write and query device metadata in InfluxDB 3.
+- Write and query telemetry data using SQL queries.
+- Explore data using the InfluxDB 3 SQL query interface.
## Tutorial and support
-To learn how to build this app from scratch, follow the [InfluxDB v2 OSS tutorial](https://docs.influxdata.com/influxdb/v2/api-guide/tutorials/nodejs/) or [InfluxDB Cloud tutorial](https://docs.influxdata.com/influxdb/cloud/api-guide/tutorials/nodejs/).
-The app is an adaptation of [InfluxData IoT Center](https://github.com/bonitoo-io/iot-center-v2), simplified to accompany the IoT Starter tutorial.
+This app is an adaptation of [InfluxData IoT Center](https://github.com/bonitoo-io/iot-center-v2), simplified to demonstrate InfluxDB 3 Core integration patterns.
-For help, refer to the tutorials and InfluxDB documentation or use the following resources:
+For help, refer to the InfluxDB 3 documentation or use the following resources:
+- [InfluxDB 3 Core Documentation](https://docs.influxdata.com/influxdb3/core/)
+- [InfluxDB 3 JavaScript Client](https://github.com/InfluxCommunity/influxdb3-js)
- [InfluxData Community](https://community.influxdata.com/)
- [InfluxDB Community Slack](https://influxdata.com/slack)
-To report a problem, submit an issue to this repo or to the [`influxdata/docs-v2` repo](https://github.com/influxdata/docs-v2/issues).
+To report a problem, submit an issue to this repo.
## Get started
-### Set up InfluxDB prerequisites
+### Set up InfluxDB 3 Core
-Follow the tutorial instructions to setup your InfluxDB organization, API token, and buckets:
+1. Install and start InfluxDB 3 Core following the [installation guide](https://docs.influxdata.com/influxdb3/core/get-started/setup/).
-- [Set up InfluxDB OSS v2 prerequisites](https://docs.influxdata.com/influxdb/v2/api-guide/tutorials/nodejs/#set-up-influxdb)
-- [Set up InfluxDB Cloud v2 prerequisites](https://docs.influxdata.com/influxdb/cloud/api-guide/tutorials/nodejs/#set-up-influxdb)
+2. Create the required databases:
+
+ ```bash
+ # Create database for telemetry data
+ influxdb3 create database iot_center
+
+ # Create database for device authentication
+ influxdb3 create database iot_center_devices
+ ```
+
+3. Create an admin token for the API server:
+
+ ```bash
+ influxdb3 create token --admin
+ ```
+
+ Save the token value for the next step.
Next, [clone and run the API server](#clone-and-run-the-api-server).
@@ -50,16 +74,19 @@ Next, [clone and run the API server](#clone-and-run-the-api-server).
```bash
# Local environment secrets
- INFLUX_TOKEN=INFLUXDB_ALL_ACCESS_TOKEN
- INFLUX_ORG=INFLUXDB_ORG_ID
+ INFLUX_TOKEN=YOUR_ADMIN_TOKEN
```
- Replace the following:
+ Replace **`YOUR_ADMIN_TOKEN`** with the admin token you created in the previous step.
- - **`INFLUXDB_ALL_ACCESS_TOKEN`** with your InfluxDB **All Access** token.
- - **`INFLUXDB_ORG_ID`** with your InfluxDB organization ID.
+4. If you need to adjust the default host or database names, edit the settings in `.env.development` or set them in `.env.local` (to override `.env.development`):
-4. If you need to adjust the default URL or bucket names to match your InfluxDB instance, edit the settings in `.env.development` or set them in `.env.local` (to override `.env.development`).
+ ```bash
+ # Default settings (can be overridden in .env.local)
+ INFLUX_HOST=http://localhost:8181
+ INFLUX_DATABASE=iot_center
+ INFLUX_DATABASE_AUTH=iot_center_devices
+ ```
5. If you haven't already, follow the [Node.js installation instructions](https://nodejs.org/) to install `node` for your operating system.
6. To check the installed `node` version, enter the following command in your terminal:
@@ -100,23 +127,26 @@ Next, [clone and run the API server](#clone-and-run-the-api-server).
## Troubleshoot
-### Error: could not find bucket
+### Error: could not find database
```json
-{"error":"failed to load data: HttpError: failed to initialize execute state: could not find bucket \"iot_center_devices\""}
+{"error":"failed to load data: database \"iot_center_devices\" not found"}
```
-Solution: [create buckets](#set-up-influxdb-prerequisites) or adjust the defaults in `.env.development` to match your InfluxDB instance.
+Solution: [create the databases](#set-up-influxdb-3-core) or adjust the defaults in `.env.development` to match your InfluxDB instance.
## Learn More
-### InfluxDB
+### InfluxDB 3
-- Develop with the InfluxDB API for [OSS v2](https://docs.influxdata.com/influxdb/v2/api-guide/) or [Cloud v2](https://docs.influxdata.com/influxdb/cloud/api-guide/).
+- [InfluxDB 3 Core Documentation](https://docs.influxdata.com/influxdb3/core/)
+- [InfluxDB 3 Enterprise Documentation](https://docs.influxdata.com/influxdb3/enterprise/)
+- [InfluxDB 3 JavaScript Client](https://github.com/InfluxCommunity/influxdb3-js)
+- [Query data with SQL](https://docs.influxdata.com/influxdb3/core/query-data/sql/)
### Next.js
-To learn more about Next.js, see following resources:
+To learn more about Next.js, see the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
diff --git a/__tests__/api.test.js b/__tests__/api.test.js
new file mode 100644
index 0000000..2a25aca
--- /dev/null
+++ b/__tests__/api.test.js
@@ -0,0 +1,283 @@
+/**
+ * API Endpoint Tests for iot-api-js
+ *
+ * These tests verify the behavior of the IoT API endpoints.
+ * Run with: npm test (after adding test script to package.json)
+ *
+ * Note: These tests mock the InfluxDB client to run without a database.
+ */
+
+import { createMocks } from 'node-mocks-http'
+
+// Mock the influxdb module before importing handlers
+jest.mock('../lib/influxdb', () => ({
+ query: jest.fn(),
+ write: jest.fn(),
+ config: {
+ host: 'http://localhost:8181',
+ token: 'test-token',
+ database: 'iot_center',
+ databaseAuth: 'iot_center_devices',
+ },
+ generateDeviceToken: jest.fn(() => 'iot_mock_token_12345'),
+ Point: {
+ measurement: jest.fn(() => ({
+ setTag: jest.fn().mockReturnThis(),
+ setStringField: jest.fn().mockReturnThis(),
+ toLineProtocol: jest.fn(() => 'deviceauth,deviceId=test-device key="test-key",token="test-token"'),
+ })),
+ },
+}))
+
+import createHandler from '../pages/api/devices/create'
+import devicesHandler from '../pages/api/devices/[[...deviceParams]]'
+import { query, write } from '../lib/influxdb'
+
+describe('POST /api/devices/create', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('creates a new device with valid deviceId', async () => {
+ query.mockResolvedValue([]) // No existing device
+ write.mockResolvedValue(undefined)
+
+ const { req, res } = createMocks({
+ method: 'POST',
+ body: { deviceId: 'sensor-001' },
+ })
+
+ await createHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(200)
+ const data = JSON.parse(res._getData())
+ expect(data.deviceId).toBe('sensor-001')
+ expect(data.token).toBeDefined()
+ expect(data.message).toContain('registered successfully')
+ })
+
+ test('rejects invalid deviceId with special characters', async () => {
+ const { req, res } = createMocks({
+ method: 'POST',
+ body: { deviceId: 'sensor' },
+ })
+
+ await createHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(400)
+ const data = JSON.parse(res._getData())
+ expect(data.error).toContain('Invalid deviceId format')
+ })
+
+ test('rejects deviceId with newlines (injection attempt)', async () => {
+ const { req, res } = createMocks({
+ method: 'POST',
+ body: { deviceId: 'sensor\nmalicious,tag=evil field=1' },
+ })
+
+ await createHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(400)
+ })
+
+ test('rejects missing deviceId', async () => {
+ const { req, res } = createMocks({
+ method: 'POST',
+ body: {},
+ })
+
+ await createHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(400)
+ const data = JSON.parse(res._getData())
+ expect(data.error).toContain('deviceId is required')
+ })
+
+ test('rejects non-POST methods', async () => {
+ const { req, res } = createMocks({
+ method: 'GET',
+ })
+
+ await createHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(405)
+ })
+
+ test('rejects duplicate device registration', async () => {
+ query.mockResolvedValue([
+ { deviceId: 'existing-device', key: 'existing-key', time: new Date() },
+ ])
+
+ const { req, res } = createMocks({
+ method: 'POST',
+ body: { deviceId: 'existing-device' },
+ })
+
+ await createHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(500)
+ const data = JSON.parse(res._getData())
+ expect(data.error).toContain('already registered')
+ })
+})
+
+describe('GET /api/devices', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('lists all devices without tokens', async () => {
+ query.mockResolvedValue([
+ { deviceId: 'device-1', key: 'key-1', time: new Date() },
+ { deviceId: 'device-2', key: 'key-2', time: new Date() },
+ ])
+
+ const { req, res } = createMocks({
+ method: 'GET',
+ query: { deviceParams: [] },
+ })
+
+ await devicesHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(200)
+ const data = JSON.parse(res._getData())
+ expect(data).toHaveLength(2)
+ // Verify tokens are NOT exposed
+ expect(data[0].token).toBeUndefined()
+ expect(data[1].token).toBeUndefined()
+ })
+
+ test('returns specific device without token', async () => {
+ query.mockResolvedValue([
+ { deviceId: 'device-1', key: 'key-1', time: new Date() },
+ ])
+
+ const { req, res } = createMocks({
+ method: 'GET',
+ query: { deviceParams: ['device-1'] },
+ })
+
+ await devicesHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(200)
+ const data = JSON.parse(res._getData())
+ expect(data[0].deviceId).toBe('device-1')
+ // Token should NOT be exposed even for specific device
+ expect(data[0].token).toBeUndefined()
+ })
+})
+
+describe('POST /api/devices/:deviceId/measurements', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('executes valid SELECT query', async () => {
+ query.mockResolvedValue([
+ { time: new Date(), room: 'Kitchen', temp: 22.5 },
+ ])
+
+ const { req, res } = createMocks({
+ method: 'POST',
+ query: { deviceParams: ['device-1', 'measurements'] },
+ body: { query: 'SELECT * FROM home LIMIT 10' },
+ })
+
+ await devicesHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(200)
+ })
+
+ test('rejects DROP TABLE query', async () => {
+ const { req, res } = createMocks({
+ method: 'POST',
+ query: { deviceParams: ['device-1', 'measurements'] },
+ body: { query: 'DROP TABLE home' },
+ })
+
+ await devicesHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(400)
+ const data = JSON.parse(res._getData())
+ expect(data.error).toContain('Only SELECT queries are allowed')
+ })
+
+ test('rejects DELETE query', async () => {
+ const { req, res } = createMocks({
+ method: 'POST',
+ query: { deviceParams: ['device-1', 'measurements'] },
+ body: { query: 'DELETE FROM home WHERE 1=1' },
+ })
+
+ await devicesHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(400)
+ })
+
+ test('rejects UPDATE query', async () => {
+ const { req, res } = createMocks({
+ method: 'POST',
+ query: { deviceParams: ['device-1', 'measurements'] },
+ body: { query: "UPDATE home SET temp=0 WHERE room='Kitchen'" },
+ })
+
+ await devicesHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(400)
+ })
+
+ test('rejects multi-statement injection', async () => {
+ const { req, res } = createMocks({
+ method: 'POST',
+ query: { deviceParams: ['device-1', 'measurements'] },
+ body: { query: 'SELECT * FROM home; DROP TABLE home' },
+ })
+
+ await devicesHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(400)
+ const data = JSON.parse(res._getData())
+ expect(data.error).toContain('blocked operations')
+ })
+
+ test('rejects excessively long queries', async () => {
+ const longQuery = 'SELECT * FROM home WHERE ' + 'x=1 OR '.repeat(500)
+
+ const { req, res } = createMocks({
+ method: 'POST',
+ query: { deviceParams: ['device-1', 'measurements'] },
+ body: { query: longQuery },
+ })
+
+ await devicesHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(400)
+ const data = JSON.parse(res._getData())
+ expect(data.error).toContain('maximum length')
+ })
+
+ test('rejects missing query parameter', async () => {
+ const { req, res } = createMocks({
+ method: 'POST',
+ query: { deviceParams: ['device-1', 'measurements'] },
+ body: {},
+ })
+
+ await devicesHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(400)
+ const data = JSON.parse(res._getData())
+ expect(data.error).toContain('Missing query parameter')
+ })
+
+ test('rejects GET method for measurements', async () => {
+ const { req, res } = createMocks({
+ method: 'GET',
+ query: { deviceParams: ['device-1', 'measurements'] },
+ })
+
+ await devicesHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(405)
+ })
+})
diff --git a/babel.config.js b/babel.config.js
new file mode 100644
index 0000000..2862f69
--- /dev/null
+++ b/babel.config.js
@@ -0,0 +1,5 @@
+module.exports = {
+ presets: [
+ ['@babel/preset-env', { targets: { node: 'current' } }],
+ ],
+};
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..a311e08
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,74 @@
+# Docker Compose for IoT API JS
+# Provides InfluxDB 3 Core for local development and testing.
+name: iot-api-js
+
+secrets:
+ influxdb3-core-token:
+ file: test/.influxdb3/core/.token
+
+services:
+ # ============================================================================
+ # InfluxDB 3 Core
+ # ============================================================================
+ # Local development instance with file-based storage.
+ #
+ # USAGE:
+ # docker compose up -d influxdb3-core
+ #
+ # FIRST-TIME SETUP:
+ # 1. Create directories:
+ # mkdir -p test/.influxdb3/core/data test/.influxdb3/core/plugins
+ #
+ # 2. Generate token:
+ # openssl rand -hex 32 > test/.influxdb3/core/.token
+ # chmod 600 test/.influxdb3/core/.token
+ #
+ # 3. Start service:
+ # docker compose up -d influxdb3-core
+ #
+ # 4. Create databases:
+ # TOKEN=$(cat test/.influxdb3/core/.token)
+ # curl -X POST "http://localhost:8181/api/v3/configure/database" \
+ # -H "Authorization: Bearer $TOKEN" \
+ # -H "Content-Type: application/json" \
+ # -d '{"db": "iot_center"}'
+ # curl -X POST "http://localhost:8181/api/v3/configure/database" \
+ # -H "Authorization: Bearer $TOKEN" \
+ # -H "Content-Type: application/json" \
+ # -d '{"db": "iot_center_devices"}'
+ #
+ # ENDPOINTS:
+ # - API: http://localhost:8181
+ # - Health: http://localhost:8181/health
+ # - Ping: http://localhost:8181/ping
+ # ============================================================================
+ influxdb3-core:
+ container_name: influxdb3-core
+ image: influxdb:3-core
+ pull_policy: always
+ ports:
+ - 8181:8181
+ command:
+ - influxdb3
+ - serve
+ - --node-id=node0
+ - --object-store=file
+ - --data-dir=/var/lib/influxdb3/data
+ - --plugin-dir=/var/lib/influxdb3/plugins
+ - --admin-token-file=/run/secrets/influxdb3-core-token
+ - --log-filter=info
+ volumes:
+ - type: bind
+ source: test/.influxdb3/core/data
+ target: /var/lib/influxdb3/data
+ - type: bind
+ source: test/.influxdb3/core/plugins
+ target: /var/lib/influxdb3/plugins
+ secrets:
+ - influxdb3-core-token
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8181/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 10s
diff --git a/docs/api-reference.md b/docs/api-reference.md
new file mode 100644
index 0000000..8669793
--- /dev/null
+++ b/docs/api-reference.md
@@ -0,0 +1,115 @@
+# API Reference
+
+## Endpoints
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/api/devices` | GET | List all devices |
+| `/api/devices/:id` | GET | Get device by ID |
+| `/api/devices/create` | POST | Register a new device |
+| `/api/devices/:id/measurements` | POST | Query device telemetry |
+
+## Device Endpoints
+
+### List Devices
+
+```http
+GET /api/devices
+```
+
+**Response:** `200 OK`
+```json
+[
+ { "deviceId": "sensor-001", "key": "device_sensor-001_1234567890", "updatedAt": "2024-01-01T00:00:00Z" },
+ { "deviceId": "sensor-002", "key": "device_sensor-002_1234567891", "updatedAt": "2024-01-01T00:00:01Z" }
+]
+```
+
+### Get Device
+
+```http
+GET /api/devices/:deviceId
+```
+
+**Response:** `200 OK`
+```json
+[
+ { "deviceId": "sensor-001", "key": "device_sensor-001_1234567890", "updatedAt": "2024-01-01T00:00:00Z" }
+]
+```
+
+### Create Device
+
+```http
+POST /api/devices/create
+Content-Type: application/json
+
+{ "deviceId": "sensor-001" }
+```
+
+**Validation:**
+- `deviceId` required, 1-64 characters
+- Alphanumeric, hyphens, underscores only
+- Pattern: `^[a-zA-Z0-9_-]{1,64}$`
+
+**Response:** `200 OK`
+```json
+{
+ "deviceId": "sensor-001",
+ "key": "device_sensor-001_1234567890",
+ "token": "iot_abc123...",
+ "database": "iot_center",
+ "host": "http://localhost:8181",
+ "message": "Device registered successfully."
+}
+```
+
+**Errors:**
+- `400` - Invalid or missing deviceId
+- `500` - Device already exists (should be 409)
+
+## Measurement Endpoints
+
+### Query Measurements
+
+```http
+POST /api/devices/:deviceId/measurements
+Content-Type: application/json
+
+{ "query": "SELECT * FROM home WHERE time >= now() - INTERVAL '1 hour' ORDER BY time DESC" }
+```
+
+**Query Validation:**
+- Must be a SELECT statement
+- Max 2000 characters
+- Blocked: DROP, DELETE, UPDATE, INSERT, ALTER, CREATE, TRUNCATE, GRANT, REVOKE, EXEC, EXECUTE, CALL
+- No multi-statement queries (`;` followed by another statement)
+
+**Response:** `200 OK` with `Content-Type: application/csv`
+```csv
+time,room,temp,humidity
+2024-01-01T00:00:00Z,Kitchen,22.5,45.0
+2024-01-01T00:01:00Z,Kitchen,22.6,44.8
+```
+
+**Errors:**
+- `400` - Missing or invalid query
+- `405` - Method not allowed (use POST)
+
+## Error Responses
+
+All errors return JSON:
+
+```json
+{
+ "error": "Error message",
+ "hint": "Optional helpful suggestion"
+}
+```
+
+## Security Notes
+
+- Device tokens are **never** returned in GET responses
+- Tokens are only provided once during device creation
+- All SQL queries are validated before execution
+- DeviceId format is strictly validated to prevent injection
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..4f06848
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,65 @@
+# Architecture
+
+## System Overview
+
+```
+┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
+│ Frontend UI │────▶│ Next.js API │────▶│ InfluxDB 3 │
+│ (iot-api-ui) │ │ (this repo) │ │ Core │
+└─────────────────┘ └─────────────────┘ └─────────────────┘
+```
+
+This sample application demonstrates building REST APIs with InfluxDB 3 Core for IoT data collection.
+
+## Project Structure
+
+```
+iot-api-js/
+├── lib/
+│ └── influxdb.js # InfluxDB client factory and helpers
+├── pages/api/
+│ ├── devices/
+│ │ ├── create.js # POST /api/devices/create
+│ │ ├── _devices.js # Shared device query logic
+│ │ └── [[...deviceParams]].js # GET /api/devices, POST measurements
+│ └── measurements/
+│ └── index.js # Shared query function (not a route)
+├── __tests__/
+│ └── api.test.js # API integration tests
+├── docs/ # Documentation
+├── .claude/ # Claude Code configuration
+│ ├── settings.json
+│ └── skills/
+├── compose.yaml # InfluxDB 3 Core container
+├── .env.development # Default config (committed)
+└── .env.local # Local overrides (gitignored)
+```
+
+## Databases
+
+The application uses two InfluxDB databases:
+
+| Database | Purpose | Measurements |
+|----------|---------|--------------|
+| `iot_center` | Device telemetry data | Custom per device |
+| `iot_center_devices` | Device registration | `deviceauth` |
+
+### Device Auth Schema
+
+The `deviceauth` measurement stores device credentials:
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `time` | timestamp | Registration time |
+| `deviceId` | tag | Unique device identifier |
+| `key` | field (string) | Device key |
+| `token` | field (string) | Application-level auth token |
+
+## Technology Stack
+
+| Component | Technology |
+|-----------|------------|
+| API Framework | Next.js 16 (Pages Router) |
+| Database | InfluxDB 3 Core |
+| Client Library | @influxdata/influxdb3-client |
+| Testing | Jest with mocks |
diff --git a/docs/code-patterns.md b/docs/code-patterns.md
new file mode 100644
index 0000000..d884137
--- /dev/null
+++ b/docs/code-patterns.md
@@ -0,0 +1,134 @@
+# Code Patterns
+
+## InfluxDB 3 Client
+
+### Client Factory
+
+Always use the helper functions from `lib/influxdb.js`:
+
+```javascript
+import { query, write, createClient, config } from '../../../lib/influxdb'
+```
+
+### Querying with SQL
+
+```javascript
+const sql = `
+ SELECT time, deviceId, key
+ FROM deviceauth
+ WHERE deviceId = '${escapeString(deviceId)}'
+ ORDER BY time DESC
+ LIMIT 1
+`
+const rows = await query(sql, config.databaseAuth)
+```
+
+### Writing Data with Points
+
+```javascript
+import { Point } from '@influxdata/influxdb3-client'
+
+const point = Point.measurement('deviceauth')
+ .setTag('deviceId', deviceId)
+ .setStringField('key', deviceKey)
+ .setStringField('token', deviceToken)
+
+await write(point.toLineProtocol(), config.databaseAuth)
+```
+
+### Client Lifecycle
+
+The helpers manage client lifecycle automatically. If using `createClient()` directly:
+
+```javascript
+const client = createClient()
+try {
+ // Use client...
+} finally {
+ await client.close()
+}
+```
+
+## Input Validation
+
+### DeviceId Pattern
+
+```javascript
+const DEVICE_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/
+
+if (!DEVICE_ID_PATTERN.test(deviceId)) {
+ return res.status(400).json({
+ error: 'Invalid deviceId format',
+ hint: 'deviceId must be 1-64 characters, alphanumeric with hyphens and underscores only',
+ })
+}
+```
+
+### SQL Query Validation
+
+```javascript
+const BLOCKED_PATTERNS = [
+ /\b(DROP|DELETE|UPDATE|INSERT|ALTER|CREATE|TRUNCATE|GRANT|REVOKE)\b/i,
+ /\b(EXEC|EXECUTE|CALL)\b/i,
+ /;\s*\w/i, // Multiple statements
+]
+
+function validateQuery(query) {
+ if (query.length > 2000) return { valid: false, error: 'Query too long' }
+ if (!query.trim().toUpperCase().startsWith('SELECT')) {
+ return { valid: false, error: 'Only SELECT queries allowed' }
+ }
+ for (const pattern of BLOCKED_PATTERNS) {
+ if (pattern.test(query)) return { valid: false, error: 'Blocked operation' }
+ }
+ return { valid: true }
+}
+```
+
+## API Handler Pattern
+
+```javascript
+export default async function handler(req, res) {
+ if (req.method !== 'POST') {
+ return res.status(405).json({ error: 'Method not allowed' })
+ }
+
+ try {
+ const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
+ // Validate input...
+ // Process request...
+ res.status(200).json(result)
+ } catch (err) {
+ console.error('Handler error:', err)
+ res.status(500).json({ error: `Operation failed: ${err.message}` })
+ }
+}
+```
+
+## Security Patterns
+
+### Token Protection
+
+Never expose tokens in API responses:
+
+```javascript
+// In _devices.js
+export async function getDevices(deviceId, options = {}) {
+ const { includeToken = false } = options // Default: no token
+
+ // Only include token field for internal verification
+ const tokenField = includeToken ? ', token' : ''
+ // ...
+}
+```
+
+### SQL Escaping
+
+```javascript
+function escapeString(str) {
+ if (typeof str !== 'string') return str
+ return str.replace(/'/g, "''")
+}
+```
+
+Note: Prefer validating input format over escaping when possible.
diff --git a/docs/contributing.md b/docs/contributing.md
new file mode 100644
index 0000000..be4d7b1
--- /dev/null
+++ b/docs/contributing.md
@@ -0,0 +1,83 @@
+# Contributing
+
+## Style Guidelines
+
+- Use ES modules (`import`/`export`)
+- Validate all user input before database operations
+- Never expose tokens in API responses
+- Use descriptive error messages with hints
+- Follow existing patterns in the codebase
+
+## Adding a New Endpoint
+
+1. Create file in `pages/api/` following Next.js conventions
+2. Import helpers from `lib/influxdb.js`
+3. Add input validation (see `DEVICE_ID_PATTERN` example)
+4. Add tests in `__tests__/`
+5. Update [API Reference](api-reference.md)
+
+### Example Structure
+
+```javascript
+import { query, config } from '../../../lib/influxdb'
+
+export default async function handler(req, res) {
+ if (req.method !== 'GET') {
+ return res.status(405).json({ error: 'Method not allowed' })
+ }
+
+ try {
+ // Validate input
+ // Execute query
+ // Return response
+ res.status(200).json(result)
+ } catch (err) {
+ console.error('Error:', err)
+ res.status(500).json({ error: err.message })
+ }
+}
+```
+
+## Testing
+
+### Writing Tests
+
+Tests use Jest with mocked InfluxDB client:
+
+```javascript
+jest.mock('../lib/influxdb', () => ({
+ query: jest.fn(),
+ write: jest.fn(),
+ config: { /* test config */ },
+}))
+
+test('creates device', async () => {
+ query.mockResolvedValue([])
+ write.mockResolvedValue(undefined)
+
+ const { req, res } = createMocks({
+ method: 'POST',
+ body: { deviceId: 'test-device' },
+ })
+
+ await handler(req, res)
+
+ expect(res._getStatusCode()).toBe(200)
+})
+```
+
+### Test Coverage
+
+Ensure tests cover:
+- Happy path
+- Input validation (invalid format, missing fields)
+- Security (injection attempts, blocked operations)
+- Error handling
+- HTTP method validation
+
+## Related Resources
+
+- [InfluxDB 3 Core Documentation](https://docs.influxdata.com/influxdb3/core/)
+- [influxdb3-js Client](https://github.com/InfluxCommunity/influxdb3-js)
+- [IoT Starter Tutorial](https://docs.influxdata.com/influxdb/v2/api-guide/tutorials/nodejs/)
+- [iot-api-ui Frontend](https://github.com/influxdata/iot-api-ui)
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..adf1efa
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,82 @@
+# Development Guide
+
+## Prerequisites
+
+- Node.js 18+ and Yarn
+- Docker (for InfluxDB 3 Core)
+
+## Quick Start
+
+```bash
+# 1. Start InfluxDB 3 Core
+docker compose up -d influxdb3-core
+
+# 2. Get the generated token
+cat test/.influxdb3/core/.token
+
+# 3. Configure environment
+echo "INFLUX_TOKEN=$(cat test/.influxdb3/core/.token)" >> .env.local
+
+# 4. Install and run
+yarn
+yarn dev -p 5200
+
+# 5. Test the API
+curl http://localhost:5200/api/devices
+```
+
+## Environment Variables
+
+Copy `.env.development` to `.env.local` and customize:
+
+```bash
+INFLUX_HOST=http://localhost:8181
+INFLUX_TOKEN=your-token
+INFLUX_DATABASE=iot_center
+INFLUX_DATABASE_AUTH=iot_center_devices
+```
+
+## Running Tests
+
+```bash
+# Run mocked unit tests (no database required)
+yarn test
+
+# Run with verbose output
+yarn test --verbose
+```
+
+For integration testing with a live database, see [.claude/skills/run-tests/SKILL.md](../.claude/skills/run-tests/SKILL.md).
+
+## Debugging
+
+### Check InfluxDB 3 Logs
+
+```bash
+docker logs influxdb3-core
+```
+
+### Query Database Directly
+
+```bash
+TOKEN=$(cat test/.influxdb3/core/.token)
+
+curl -X POST "http://localhost:8181/api/v3/query_sql" \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"db": "iot_center", "q": "SELECT * FROM deviceauth"}'
+```
+
+### Health Check
+
+```bash
+curl http://localhost:8181/health
+```
+
+## Common Issues
+
+| Issue | Solution |
+|-------|----------|
+| Port 8181 in use | `docker compose down` then restart |
+| 401 Unauthorized | Verify token in `.env.local` matches `test/.influxdb3/core/.token` |
+| Database not found | Create databases via InfluxDB API (see skill docs) |
diff --git a/docs/plans/2026-02-27-influxdb3-core-migration-design.md b/docs/plans/2026-02-27-influxdb3-core-migration-design.md
new file mode 100644
index 0000000..5647512
--- /dev/null
+++ b/docs/plans/2026-02-27-influxdb3-core-migration-design.md
@@ -0,0 +1,357 @@
+# InfluxDB 3 Core Migration Design
+
+Standalone demo app for InfluxDB 3 Core (with optional Enterprise support),
+used as a tutorial reference on the InfluxData documentation site.
+
+## Architecture
+
+```
+┌─────────────────┐ ┌──────────────────────────────┐ ┌──────────────────────┐
+│ Frontend UI │────>│ Next.js API (this repo) │────>│ InfluxDB 3 Core │
+│ (iot-api-ui) │ │ │ │ or Enterprise │
+└─────────────────┘ │ pages/api/ │ │ │
+ │ ├── devices/create.js │ │ Databases: │
+ │ ├── devices/[[...params]].js│ │ ├── iot_center │
+ │ └── devices/:id/status [NEW]│ │ └── iot_center_devices
+ │ │ │ │
+ │ lib/ │ │ Caches: │
+ │ ├── influxdb.js (existing) │ │ ├── LVC: deviceStatus
+ │ └── enterprise.js [NEW] │ │ └── DVC: deviceList │
+ └──────────────────────────────┘ │ │
+ │ Plugins: │
+ │ └── sensor_guard.py │
+ └──────────────────────┘
+```
+
+### What stays the same
+
+- Two databases: `iot_center` (telemetry), `iot_center_devices` (device auth)
+- `lib/influxdb.js` as the client factory with `query()`, `write()`, `Point`
+- Existing API routes for device CRUD and measurements
+- Pages Router, Jest tests, Docker Compose
+
+### What's new
+
+- LVC on `iot_center` for fast device status queries
+- DVC on `iot_center_devices` for fast device enumeration
+- Processing Engine plugin (Python) for data validation on write
+- `lib/enterprise.js` for Enterprise-only features
+- New API path for device status via LVC
+- Enterprise service in compose.yaml (Docker profile-gated)
+
+### Technology
+
+| Component | Technology |
+|-----------|------------|
+| API Framework | Next.js 16 (Pages Router) |
+| Database | InfluxDB 3 Core / Enterprise |
+| Client Library | @influxdata/influxdb3-client v2.x |
+| Testing | Jest with mocks |
+| Plugin Runtime | Python (Processing Engine) |
+
+## Caching
+
+### Last Value Cache (LVC) — Device Status
+
+Caches the most recent sensor reading per device for sub-10ms dashboard queries.
+
+**Setup (manual CLI):**
+
+```sh
+influxdb3 create last_cache \
+ --database iot_center \
+ --table sensor_data \
+ --key-columns deviceId \
+ --value-columns temperature,humidity,pressure \
+ --count 1 \
+ --ttl 30mins \
+ --token $TOKEN \
+ deviceStatus
+```
+
+**Query pattern:**
+
+```sql
+SELECT * FROM last_cache('sensor_data', 'deviceStatus')
+WHERE deviceId = '...'
+```
+
+### Distinct Value Cache (DVC) — Device Listing
+
+Caches distinct `deviceId` values for sub-30ms device enumeration.
+
+**Setup (manual CLI):**
+
+```sh
+influxdb3 create distinct_cache \
+ --database iot_center_devices \
+ --table deviceauth \
+ --columns deviceId \
+ --max-cardinality 10000 \
+ --max-age 24h \
+ --token $TOKEN \
+ deviceList
+```
+
+**Query pattern:**
+
+```sql
+SELECT * FROM distinct_cache('deviceauth', 'deviceList')
+```
+
+### Graceful degradation
+
+Both cache queries catch errors and fall back to regular SQL. The app works
+without caches configured -- they are a performance optimization, not a
+requirement. Tutorial flow: "it works without caches, now let's make it fast."
+
+## Processing Engine Plugin
+
+### Trigger type: WAL flush
+
+A WAL flush trigger fires when sensor data is written. This is the most natural
+fit for an IoT pipeline -- validate and enrich data inline without external
+services.
+
+### Plugin: `sensor_guard.py`
+
+Validates incoming sensor data on write:
+
+- Receives batches of written rows from the `sensor_data` table
+- Validates ranges (temperature -50 to 150, humidity 0 to 100)
+- Writes out-of-range readings to a `sensor_alerts` table with original values
+ plus an `alert_type` tag
+- Logs warnings for rejected data
+
+```python
+def process_writes(influxdb3_local, table_batches, args=None):
+ for table_batch in table_batches:
+ if table_batch["table_name"] != "sensor_data":
+ continue
+ for row in table_batch["rows"]:
+ device_id = row.get("deviceId", "unknown")
+ temp = row.get("temperature")
+ humidity = row.get("humidity")
+
+ alerts = []
+ if temp is not None and not (-50 <= temp <= 150):
+ alerts.append("temperature_out_of_range")
+ if humidity is not None and not (0 <= humidity <= 100):
+ alerts.append("humidity_out_of_range")
+
+ for alert_type in alerts:
+ line = (
+ f'sensor_alerts,deviceId={device_id},'
+ f'alert_type={alert_type} '
+ f'temperature={temp},humidity={humidity}'
+ )
+ influxdb3_local.write(line)
+ influxdb3_local.info(
+ f"Alert: {alert_type} for device {device_id}"
+ )
+```
+
+**Trigger setup (manual CLI):**
+
+```sh
+influxdb3 create trigger \
+ --database iot_center \
+ --plugin sensor_guard.py \
+ --trigger-spec "table:sensor_data" \
+ --token $TOKEN \
+ sensorGuardTrigger
+```
+
+### Why not other trigger types?
+
+- **Scheduled**: Could work for deadman alerting, but adds complexity without
+ teaching much beyond what WAL flush shows. Possible follow-up.
+- **HTTP request**: The app already has Next.js API routes. A competing HTTP
+ endpoint inside InfluxDB would confuse the tutorial.
+
+## Enterprise Support
+
+### Configuration
+
+Environment variable `INFLUX_EDITION` controls which edition the app targets.
+Defaults to `core`.
+
+### `lib/enterprise.js`
+
+Provides three Enterprise-specific features:
+
+1. **Fine-grained database tokens**: Enterprise supports read/write tokens
+ scoped to specific databases. Instead of application-level tokens stored in
+ `deviceauth`, Enterprise issues real database tokens per device.
+
+2. **Table-level retention**: Enterprise supports per-table retention periods.
+ Exposes a helper to set retention on `sensor_data` independently from the
+ database default.
+
+3. **Historical queries**: Core limits queries to recent data (~72 hours
+ uncompacted). Enterprise compacts data and enables historical range queries.
+ Provides a helper that removes the time-range guardrails.
+
+### Integration pattern
+
+Existing routes gain small conditional branches:
+
+```javascript
+if (config.edition === 'enterprise') {
+ const { createDatabaseToken } = await import('../../../lib/enterprise.js')
+ // Enterprise token creation
+} else {
+ // Core: generate app-level token
+}
+```
+
+### Docker Compose
+
+Enterprise service behind a profile (does not start by default):
+
+```yaml
+influxdb3-enterprise:
+ container_name: influxdb3-enterprise
+ image: influxdb:3-enterprise
+ profiles: ["enterprise"]
+ ports:
+ - 8181:8181
+```
+
+Users run `docker compose --profile enterprise up -d` to use Enterprise.
+
+### What Enterprise does NOT change
+
+- Same database names, table schemas, API routes
+- Same LVC and DVC (both work on Enterprise)
+- Same Processing Engine plugins (same API)
+- Tutorial flow: "build with Core first, then here's what Enterprise adds"
+
+## Data Flow
+
+### Route Map
+
+| Method | Route | Behavior | New? |
+|--------|-------|----------|------|
+| POST | `/api/devices/create` | Register device, generate token | Enterprise: database tokens |
+| GET | `/api/devices` | List all devices | DVC fast path |
+| GET | `/api/devices/:deviceId` | Get specific device | No change |
+| GET | `/api/devices/:deviceId/status` | Latest readings | NEW (LVC) |
+| POST | `/api/devices/:deviceId/measurements` | Query sensor data | Enterprise: no time limit |
+
+### Write flow (with Processing Engine)
+
+```
+Client writes sensor data
+ |
+ v
+POST /api/devices/:deviceId/measurements
+ |
+ v
+lib/influxdb.js write() --> InfluxDB 3
+ |
+ v
+WAL flush triggers sensor_guard.py
+ |
+ |-- Valid data --> stored in sensor_data
+ | LVC updated automatically
+ |
+ +-- Out-of-range --> alert written to sensor_alerts
+```
+
+### Read flow (with caches)
+
+```
+GET /api/devices
+ |
+ v
+_devices.js --> distinct_cache('deviceauth', 'deviceList')
+ | <30ms response
+ v
+Return device list
+
+
+GET /api/devices/:deviceId/status
+ |
+ v
+[[...deviceParams]].js --> last_cache('sensor_data', 'deviceStatus')
+ | <10ms response
+ v
+Return latest readings
+```
+
+## File Changes
+
+### New files
+
+| File | Purpose |
+|------|---------|
+| `lib/enterprise.js` | Enterprise-only helpers |
+| `plugins/sensor_guard.py` | WAL flush plugin for sensor validation |
+
+### Modified files
+
+| File | Changes |
+|------|---------|
+| `lib/influxdb.js` | Add `config.edition` getter |
+| `pages/api/devices/[[...deviceParams]].js` | Add `status` path, Enterprise conditional |
+| `pages/api/devices/_devices.js` | DVC fast path with fallback |
+| `pages/api/devices/create.js` | Enterprise conditional for database tokens |
+| `compose.yaml` | Add Enterprise service behind profile |
+| `.env.development` | Add `INFLUX_EDITION=core` |
+| `__tests__/api.test.js` | Tests for status endpoint, cache fallback |
+| `docs/architecture.md` | Update with caching, plugins, Enterprise |
+| `README.md` | Setup steps for caches, plugin, Enterprise |
+
+### Unchanged files
+
+| File | Why |
+|------|-----|
+| `pages/api/measurements/index.js` | Shared query helper, no changes |
+| `package.json` | No new dependencies |
+| `jest.config.js` | Test config stays the same |
+
+### Scope
+
+- 2 new files
+- ~8 modified files
+- 0 new npm dependencies
+- 1 Python file (plugin, not a Node dependency)
+
+## Testing
+
+### Jest tests (mock-based)
+
+**Status endpoint (LVC):**
+- Returns latest reading from LVC
+- Returns null when no readings exist
+- Falls back to regular query on cache error
+- Returns 405 for non-GET methods
+
+**Device listing (DVC):**
+- Uses DVC when available
+- Falls back to full table scan on cache error
+- Results match same shape regardless of code path
+
+**Enterprise conditionals:**
+- `INFLUX_EDITION=enterprise` triggers database token flow
+- `INFLUX_EDITION=core` (default) uses app-level token logic
+
+### Plugin testing (manual)
+
+The Processing Engine plugin runs inside InfluxDB, not in Node.js.
+Manual verification documented in README:
+
+1. Write an out-of-range sensor value
+2. Query `sensor_alerts` to confirm the alert appeared
+
+### Error handling
+
+**Cache unavailable:** Catch, log warning, fall back to standard SQL.
+
+**Plugin failures:** Logged server-side by Processing Engine. Do not block
+writes. No app-side handling needed.
+
+**Enterprise on Core:** If `INFLUX_EDITION=enterprise` but server is Core,
+Enterprise API calls return clear HTTP errors (401/404). App passes these
+through with context message.
diff --git a/docs/plans/2026-02-27-influxdb3-core-migration-plan.md b/docs/plans/2026-02-27-influxdb3-core-migration-plan.md
new file mode 100644
index 0000000..61d2f6f
--- /dev/null
+++ b/docs/plans/2026-02-27-influxdb3-core-migration-plan.md
@@ -0,0 +1,893 @@
+# InfluxDB 3 Core Migration Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add LVC/DVC caching, a Processing Engine plugin, and optional Enterprise support to the existing InfluxDB 3 Core demo app.
+
+**Architecture:** Incremental enhancement of the existing Next.js Pages Router app. New features are additive — a new `/status` API path, DVC fast-path in device listing, a Python plugin file, and a separate Enterprise module. Cache queries degrade gracefully to standard SQL.
+
+**Tech Stack:** Next.js 16 (Pages Router), @influxdata/influxdb3-client v2.x, Jest, Python (Processing Engine plugin)
+
+---
+
+### Task 1: Add `edition` to config
+
+Add the `INFLUX_EDITION` getter to the shared config object so all modules can check which edition is active.
+
+**Files:**
+- Modify: `lib/influxdb.js:23-36`
+- Test: `__tests__/api.test.js:13-30` (update mock)
+
+**Step 1: Update the mock in the test file**
+
+In `__tests__/api.test.js`, add `edition` to the mocked config object:
+
+```javascript
+jest.mock('../lib/influxdb', () => ({
+ query: jest.fn(),
+ write: jest.fn(),
+ config: {
+ host: 'http://localhost:8181',
+ token: 'test-token',
+ database: 'iot_center',
+ databaseAuth: 'iot_center_devices',
+ edition: 'core',
+ },
+ generateDeviceToken: jest.fn(() => 'iot_mock_token_12345'),
+ Point: {
+ measurement: jest.fn(() => ({
+ setTag: jest.fn().mockReturnThis(),
+ setStringField: jest.fn().mockReturnThis(),
+ toLineProtocol: jest.fn(() => 'deviceauth,deviceId=test-device key="test-key",token="test-token"'),
+ })),
+ },
+}))
+```
+
+**Step 2: Run tests to verify nothing broke**
+
+Run: `yarn test`
+Expected: All existing tests pass (the new `edition` field is additive)
+
+**Step 3: Add the `edition` getter to `lib/influxdb.js`**
+
+Add after the `databaseAuth` getter at line 35:
+
+```javascript
+ get edition() {
+ return process.env.INFLUX_EDITION || 'core'
+ },
+```
+
+The full config object becomes:
+
+```javascript
+export const config = {
+ get host() {
+ return process.env.INFLUX_HOST
+ },
+ get token() {
+ return process.env.INFLUX_TOKEN
+ },
+ get database() {
+ return process.env.INFLUX_DATABASE
+ },
+ get databaseAuth() {
+ return process.env.INFLUX_DATABASE_AUTH
+ },
+ get edition() {
+ return process.env.INFLUX_EDITION || 'core'
+ },
+}
+```
+
+**Step 4: Run tests to verify**
+
+Run: `yarn test`
+Expected: All tests pass
+
+**Step 5: Commit**
+
+```bash
+git add lib/influxdb.js __tests__/api.test.js
+git commit -m "feat: add edition getter to influxdb config"
+```
+
+---
+
+### Task 2: DVC fast path for device listing
+
+Add distinct value cache query to `getDevices()` with graceful fallback.
+
+**Files:**
+- Modify: `pages/api/devices/_devices.js`
+- Test: `__tests__/api.test.js`
+
+**Step 1: Write the failing test for DVC fast path**
+
+Add to `__tests__/api.test.js` after the existing `GET /api/devices` describe block:
+
+```javascript
+describe('GET /api/devices (DVC fast path)', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('falls back to full query when DVC query fails', async () => {
+ // First call: DVC query fails (cache not configured)
+ // Second call: fallback full query succeeds
+ query
+ .mockRejectedValueOnce(new Error('cache not found'))
+ .mockResolvedValueOnce([
+ { deviceId: 'device-1', key: 'key-1', time: new Date() },
+ ])
+
+ const { req, res } = createMocks({
+ method: 'GET',
+ query: { deviceParams: [] },
+ })
+
+ await devicesHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(200)
+ const data = JSON.parse(res._getData())
+ expect(data).toHaveLength(1)
+ expect(data[0].deviceId).toBe('device-1')
+ // query was called twice: DVC attempt + fallback
+ expect(query).toHaveBeenCalledTimes(2)
+ })
+})
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `yarn test`
+Expected: FAIL — the current `getDevices()` calls `query` only once, so `toHaveBeenCalledTimes(2)` fails.
+
+**Step 3: Implement DVC fast path in `_devices.js`**
+
+Replace the `else` branch (list all devices) in `getDevices()`. The full function becomes:
+
+```javascript
+export async function getDevices(deviceId, options = {}) {
+ const { includeToken = false } = options
+ const database = config.databaseAuth
+
+ let rows
+
+ if (deviceId !== undefined) {
+ const tokenField = includeToken ? ', token' : ''
+ const sql = `
+ SELECT time, deviceId, key${tokenField}
+ FROM deviceauth
+ WHERE deviceId = '${escapeString(deviceId)}'
+ ORDER BY time DESC
+ LIMIT 1
+ `
+ rows = await query(sql, database)
+ } else {
+ // Try DVC for fast device enumeration, fall back to full scan
+ rows = await queryDevicesDvc(database)
+ }
+
+ const devices = {}
+
+ for (const row of rows) {
+ const id = row.deviceId
+ if (!id) {
+ continue
+ }
+
+ if (devices[id]) {
+ const existingTime = new Date(devices[id].updatedAt).getTime()
+ const rowTime = new Date(row.time).getTime()
+ if (rowTime <= existingTime) {
+ continue
+ }
+ }
+
+ devices[id] = {
+ deviceId: id,
+ key: row.key,
+ updatedAt: row.time,
+ }
+
+ if (includeToken && row.token) {
+ devices[id].token = row.token
+ }
+ }
+
+ return devices
+}
+
+/**
+ * Queries devices using the Distinct Value Cache for fast enumeration.
+ * Falls back to a full table scan if the DVC is not configured.
+ */
+async function queryDevicesDvc(database) {
+ try {
+ return await query(
+ "SELECT * FROM distinct_cache('deviceauth', 'deviceList')",
+ database
+ )
+ } catch {
+ console.warn('DVC not available, falling back to full device query')
+ return await query(
+ 'SELECT time, deviceId, key FROM deviceauth ORDER BY time DESC',
+ database
+ )
+ }
+}
+```
+
+**Step 4: Run tests to verify**
+
+Run: `yarn test`
+Expected: All tests pass, including the new DVC fallback test
+
+**Step 5: Commit**
+
+```bash
+git add pages/api/devices/_devices.js __tests__/api.test.js
+git commit -m "feat: add DVC fast path for device listing with fallback"
+```
+
+---
+
+### Task 3: LVC device status endpoint
+
+Add `GET /api/devices/:deviceId/status` that queries the Last Value Cache.
+
+**Files:**
+- Modify: `pages/api/devices/[[...deviceParams]].js`
+- Test: `__tests__/api.test.js`
+
+**Step 1: Write the failing tests**
+
+Add to `__tests__/api.test.js`:
+
+```javascript
+describe('GET /api/devices/:deviceId/status', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('returns latest reading from LVC', async () => {
+ query.mockResolvedValue([
+ { deviceId: 'sensor-001', temperature: 22.5, humidity: 45.0, time: '2026-02-27T10:00:00Z' },
+ ])
+
+ const { req, res } = createMocks({
+ method: 'GET',
+ query: { deviceParams: ['sensor-001', 'status'] },
+ })
+
+ await devicesHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(200)
+ const data = JSON.parse(res._getData())
+ expect(data.deviceId).toBe('sensor-001')
+ expect(data.temperature).toBe(22.5)
+ })
+
+ test('returns null when no readings exist', async () => {
+ query.mockResolvedValue([])
+
+ const { req, res } = createMocks({
+ method: 'GET',
+ query: { deviceParams: ['sensor-999', 'status'] },
+ })
+
+ await devicesHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(200)
+ const data = JSON.parse(res._getData())
+ expect(data).toBeNull()
+ })
+
+ test('falls back to regular query when LVC fails', async () => {
+ query
+ .mockRejectedValueOnce(new Error('cache not found'))
+ .mockResolvedValueOnce([
+ { deviceId: 'sensor-001', temperature: 22.5, humidity: 45.0, time: '2026-02-27T10:00:00Z' },
+ ])
+
+ const { req, res } = createMocks({
+ method: 'GET',
+ query: { deviceParams: ['sensor-001', 'status'] },
+ })
+
+ await devicesHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(200)
+ expect(query).toHaveBeenCalledTimes(2)
+ })
+
+ test('rejects non-GET methods', async () => {
+ const { req, res } = createMocks({
+ method: 'POST',
+ query: { deviceParams: ['sensor-001', 'status'] },
+ })
+
+ await devicesHandler(req, res)
+
+ expect(res._getStatusCode()).toBe(405)
+ })
+})
+```
+
+**Step 2: Run tests to verify they fail**
+
+Run: `yarn test`
+Expected: FAIL — `status` path is not handled yet, so requests fall through to the device listing handler.
+
+**Step 3: Add status handler to `[[...deviceParams]].js`**
+
+Add this block after the `deviceId` / `path` parsing (after line 64), before the measurements handler:
+
+```javascript
+ // Handle device status: GET /api/devices/:deviceId/status
+ if (Array.isArray(path) && path[0] === 'status') {
+ if (req.method !== 'GET') {
+ return res.status(405).json({ error: 'Method not allowed. Use GET for device status.' })
+ }
+ const data = await getDeviceStatus(deviceId)
+ return res.status(200).json(data)
+ }
+```
+
+Add the imports at the top of the file. Update the existing import line:
+
+```javascript
+import { query, config } from '../../../lib/influxdb'
+```
+
+Add the `getDeviceStatus` function at the bottom of the file (before the closing):
+
+```javascript
+/**
+ * Gets the latest sensor readings for a device using the Last Value Cache.
+ * Falls back to a regular query if the LVC is not configured.
+ *
+ * @param {string} deviceId - The device to get status for
+ * @returns {Promise