From ab515f12f5cc883ce1a32d9f7097ff8bc15c4d3f Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 6 Jul 2026 12:54:52 +0900 Subject: [PATCH 01/13] =?UTF-8?q?Grafana=20=E4=BE=9D=E5=AD=98=E3=82=92?= =?UTF-8?q?=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker-compose の grafana サービス・ボリューム、プロビジョニング設定、 .env.example の GF_SECURITY_ADMIN_PASSWORD を削除。 Co-Authored-By: Claude Fable 5 --- .env.example | 3 +-- docker-compose.yml | 18 ------------------ grafana/provisioning/datasources/postgres.yml | 17 ----------------- 3 files changed, 1 insertion(+), 37 deletions(-) delete mode 100644 grafana/provisioning/datasources/postgres.yml diff --git a/.env.example b/.env.example index a8f7499..207b8a1 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,3 @@ THQ_WS_AUTH_TOKEN=change-me THQ_WS_AUTH_REQUIRED=true -POSTGRES_PASSWORD=change-me -GF_SECURITY_ADMIN_PASSWORD=change-me \ No newline at end of file +POSTGRES_PASSWORD=change-me \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 77e8d97..ae02016 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,23 +32,5 @@ services: timeout: 5s retries: 5 - grafana: - image: grafana/grafana:11.4.0 - depends_on: - db: - condition: service_healthy - environment: - GF_SECURITY_ADMIN_USER: ${GF_SECURITY_ADMIN_USER:-admin} - GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD:?GF_SECURITY_ADMIN_PASSWORD is required} - GF_USERS_ALLOW_SIGN_UP: "false" - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} - volumes: - - grafana-data:/var/lib/grafana - - ./grafana/provisioning:/etc/grafana/provisioning:ro - ports: - - "3000:3000" - restart: unless-stopped - volumes: db-data: - grafana-data: diff --git a/grafana/provisioning/datasources/postgres.yml b/grafana/provisioning/datasources/postgres.yml deleted file mode 100644 index 39700ce..0000000 --- a/grafana/provisioning/datasources/postgres.yml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: 1 - -datasources: - - name: THQ-Postgres - type: postgres - access: proxy - url: db:5432 - database: thq - user: thq - secureJsonData: - password: $POSTGRES_PASSWORD - jsonData: - sslmode: disable - postgresVersion: 1800 - timescaledb: false - isDefault: true - editable: true From 7318bb893b980bd8ab01273cfe2bbd5c0d2cb6f5 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 6 Jul 2026 13:25:35 +0900 Subject: [PATCH 02/13] =?UTF-8?q?=E3=82=A4=E3=83=99=E3=83=B3=E3=83=88?= =?UTF-8?q?=E9=80=81=E4=BF=A1=E3=82=92GraphQL=20Mutation=E3=81=AB=E7=A7=BB?= =?UTF-8?q?=E8=A1=8C=E3=80=81=E5=90=88=E8=A8=80=E8=91=89=E3=82=923?= =?UTF-8?q?=E7=A8=AE=E9=A1=9E=E3=81=AB=E5=88=86=E9=9B=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sendLogEvent / sendLocation Mutation を追加し、REST送信系 (/api/location, /api/log) と openapi.yaml を廃止 - 認証トークンを役割ごとに分離: - THQ_OBSERVER_AUTH_TOKEN: WebSocket購読のみ - THQ_EVENTS_AUTH_TOKEN: ログイベント送信のみ - THQ_TELEMETRY_AUTH_TOKEN: ログイベント+位置情報送信 - THQ_WS_AUTH_TOKEN / THQ_WS_AUTH_REQUIRED は THQ_AUTH_REQUIRED と上記3変数に置き換え Co-Authored-By: Claude Fable 5 --- .env.example | 8 +- README.md | 196 +++++---- docker-compose.yml | 6 +- openapi.yaml | 400 ------------------ src/config.rs | 171 ++++---- src/domain.rs | 79 ++-- src/graphql.rs | 545 ++++++++++++++++++++++++- src/main.rs | 6 +- src/server.rs | 986 ++++++++++++--------------------------------- 9 files changed, 1037 insertions(+), 1360 deletions(-) delete mode 100644 openapi.yaml diff --git a/.env.example b/.env.example index 207b8a1..ff7368e 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ -THQ_WS_AUTH_TOKEN=change-me -THQ_WS_AUTH_REQUIRED=true -POSTGRES_PASSWORD=change-me \ No newline at end of file +THQ_OBSERVER_AUTH_TOKEN=change-me-observer +THQ_EVENTS_AUTH_TOKEN=change-me-events +THQ_TELEMETRY_AUTH_TOKEN=change-me-telemetry +THQ_AUTH_REQUIRED=true +POSTGRES_PASSWORD=change-me diff --git a/README.md b/README.md index 99ab4b0..1529e24 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,14 @@ # thq-server -A telemetry server for [TrainLCD](https://github.com/TrainLCD). It provides real-time event streaming via WebSocket, a REST API for data ingestion, and a GraphQL API for aggregated reporting — all backed by optional PostgreSQL persistence. +A telemetry server for [TrainLCD](https://github.com/TrainLCD). It provides real-time event streaming via WebSocket and a GraphQL API for data ingestion and aggregated reporting — all backed by optional PostgreSQL persistence. ## Features - **WebSocket** — Real-time broadcast of location updates and log events -- **REST API** — Location ingestion (`POST /api/location`) and log submission (`POST /api/log`) -- **GraphQL** — Aggregated per-line accuracy reports (`POST /graphql`) +- **GraphQL** — Event ingestion (`sendLogEvent`, `sendLocation` mutations) and aggregated per-line accuracy reports (`POST /graphql`) - **PostgreSQL persistence** — Optionally stores all events in the database - **Ring buffer** — Keeps the latest N events in memory (default 1000) -- **Authentication** — WebSocket subprotocol-based auth; REST Bearer token auth +- **Scoped authentication** — Three shared secrets: observer (WebSocket only), events (log submission only), telemetry (log + location submission) - **Line topology** — Automatic segment annotation from a CSV topology file ## Requirements @@ -32,15 +31,18 @@ cargo run -- --config config.toml # With PostgreSQL persistence cargo run -- --database-url postgres://user:pass@localhost:5432/thq -# With WebSocket auth -THQ_WS_AUTH_TOKEN=secret cargo run -- --host 0.0.0.0 --port 8080 +# With authentication +THQ_OBSERVER_AUTH_TOKEN=obs-secret \ +THQ_EVENTS_AUTH_TOKEN=events-secret \ +THQ_TELEMETRY_AUTH_TOKEN=telemetry-secret \ +cargo run -- --host 0.0.0.0 --port 8080 ``` ### Docker Compose ```bash -# Set the auth token in .env -echo 'THQ_WS_AUTH_TOKEN=your-secret' > .env +# Set the auth tokens in .env (see .env.example) +cp .env.example .env # Build & start (includes PostgreSQL) docker compose up --build @@ -51,7 +53,6 @@ Endpoints after startup: | Endpoint | URL | |---|---| | WebSocket | `ws://localhost:8080/ws` | -| REST API | `http://localhost:8080/api/location`, `/api/log` | | GraphQL Playground | `http://localhost:8080/graphql` | | Health check | `http://localhost:8080/healthz` | @@ -64,8 +65,10 @@ host = "0.0.0.0" port = 8080 ring_size = 1000 database_url = "postgres://user:pass@localhost:5432/thq" -ws_auth_token = "change-me" -ws_auth_required = true +observer_auth_token = "change-me-observer" +events_auth_token = "change-me-events" +telemetry_auth_token = "change-me-telemetry" +auth_required = true ``` | Key | Environment variable | Default | Description | @@ -74,71 +77,123 @@ ws_auth_required = true | `port` | — | `8080` | Listen port | | `ring_size` | — | `1000` | Ring buffer capacity | | `database_url` | `DATABASE_URL` | — | PostgreSQL connection URL | -| `ws_auth_token` | `THQ_WS_AUTH_TOKEN` | — | Auth token | -| `ws_auth_required` | `THQ_WS_AUTH_REQUIRED` | `true`* | Require authentication | +| `observer_auth_token` | `THQ_OBSERVER_AUTH_TOKEN` | — | Token for WebSocket observers | +| `events_auth_token` | `THQ_EVENTS_AUTH_TOKEN` | — | Token allowed to send log events | +| `telemetry_auth_token` | `THQ_TELEMETRY_AUTH_TOKEN` | — | Token allowed to send log events **and** location updates | +| `auth_required` | `THQ_AUTH_REQUIRED` | `true`* | Require authentication | + +\* Defaults to `true` when any token is configured. + +## Authentication + +Three shared secrets grant exactly one role each: + +| Token | WebSocket subscribe | `sendLogEvent` | `sendLocation` | +|---|---|---|---| +| Observer | ✅ | ❌ | ❌ | +| Events | ❌ | ✅ | ❌ | +| Telemetry | ❌ | ✅ | ✅ | -\* Defaults to `true` when a token is configured. +- **WebSocket** — send the observer token via subprotocols: `Sec-WebSocket-Protocol: thq, thq-auth-` +- **GraphQL mutations** — send the events or telemetry token via `Authorization: Bearer ` +- **GraphQL queries** — no authentication (aggregated data only) + +Set `auth_required = false` to skip all authentication during local development. ## API -### REST API +### GraphQL -Authenticated endpoints require an `Authorization: Bearer ` header. +Endpoint: `POST /graphql` (Playground: `GET /graphql`) -See [`openapi.yaml`](./openapi.yaml) for the full specification. +#### `sendLogEvent` — Submit a log event -#### `POST /api/location` — Submit a location update +Requires the events token or the telemetry token. -```json -{ - "device": "device-001", - "state": "moving", - "lineId": 11302, - "coords": { - "latitude": 35.6812, - "longitude": 139.7671, - "accuracy": 10.0, - "speed": 45.0 - }, - "timestamp": 1706000000000 +```graphql +mutation { + sendLogEvent(input: { + device: "device-001" + timestamp: 1706000000000 + type: APP # SYSTEM | APP | CLIENT + level: INFO # DEBUG | INFO | WARN | ERROR + message: "GPS signal acquired" + }) { + id + } } ``` -#### `POST /api/log` — Submit a log entry +#### `sendLocation` — Submit a location update -```json -{ - "device": "device-001", - "timestamp": 1706000000000, - "log": { - "type": "app", - "level": "info", - "message": "GPS signal acquired" +Requires the telemetry token; the events token is deliberately not enough to publish positional data. The update is validated, annotated with segment information, broadcast to WebSocket subscribers, and persisted. + +```graphql +mutation { + sendLocation(input: { + device: "device-001" + state: MOVING # ARRIVED | APPROACHING | PASSING | MOVING + lineId: 11302 + coords: { + latitude: 35.6812 + longitude: 139.7671 + accuracy: 10.0 + speed: 45.0 + } + timestamp: 1706000000000 + }) { + id + warning # set when e.g. the reported accuracy exceeds 100 m } } ``` -#### `GET /healthz` — Health check +`stationId` is only meaningful when `state` is `ARRIVED` or `PASSING` and is ignored otherwise. `batteryLevel` (0.0–1.0) and `batteryState` (`UNKNOWN | UNPLUGGED | CHARGING | FULL`) are optional. -No authentication required. Returns `200 OK` if the server is running. +#### `accuracyByLine` — Aggregated accuracy report -### WebSocket +Returns aggregated accuracy metrics per line. Raw location data is never exposed through queries. -Endpoint: `ws://:/ws` +```graphql +query { + accuracyByLine( + lineId: "45" + from: "2024-12-01T00:00:00Z" + to: "2024-12-03T00:00:00Z" + bucketSize: HOUR + limit: 100 + ) { + lineId + buckets { + bucketStart + bucketEnd + avgAccuracy + p90Accuracy + sampleCount + } + } +} +``` + +| Parameter | Type | Description | +|---|---|---| +| `lineId` | `ID!` | Line ID | +| `from` | `DateTime!` | Start of the time range | +| `to` | `DateTime!` | End of the time range | +| `bucketSize` | `TimeBucketSize!` | `MINUTE`, `HOUR`, or `DAY` | +| `limit` | `Int` | Max buckets returned (default 500, cap 2000) | -Once connected, the server broadcasts `location_update` and `log` messages in real time. +Maximum time span per bucket size: MINUTE ≤ 7 days, HOUR ≤ 90 days, DAY ≤ 365 days. -#### Authentication +#### `GET /healthz` — Health check -Send the token via WebSocket subprotocols: +No authentication required. Returns `200 OK` if the server is running. -```text -Sec-WebSocket-Protocol: thq, thq-auth- -``` +### WebSocket -On success the server responds with `Sec-WebSocket-Protocol: thq`. When `ws_auth_required` is `true`, a missing or invalid token results in HTTP 401. +Endpoint: `ws://:/ws` -Set `ws_auth_required = false` to skip authentication during local development. +Once connected, the server broadcasts `location_update` and `log` messages in real time. Authentication uses the observer token (see [Authentication](#authentication)); on success the server responds with `Sec-WebSocket-Protocol: thq`, while a missing or invalid token results in HTTP 401. #### Message formats @@ -190,49 +245,12 @@ Set `ws_auth_required = false` to skip authentication during local development. { "type": "error", "error": { - "type": "websocket_message_error | json_parse_error | payload_parse_error | accuracy_low | invalid_coords | unknown", + "type": "websocket_message_error | json_parse_error", "reason": "..." } } ``` -### GraphQL - -Endpoint: `POST /graphql` (Playground: `GET /graphql`) - -Returns aggregated accuracy metrics per line. Raw location data is never exposed. - -```graphql -query { - accuracyByLine( - lineId: "45" - from: "2024-12-01T00:00:00Z" - to: "2024-12-03T00:00:00Z" - bucketSize: HOUR - limit: 100 - ) { - lineId - buckets { - bucketStart - bucketEnd - avgAccuracy - p90Accuracy - sampleCount - } - } -} -``` - -| Parameter | Type | Description | -|---|---|---| -| `lineId` | `ID!` | Line ID | -| `from` | `DateTime!` | Start of the time range | -| `to` | `DateTime!` | End of the time range | -| `bucketSize` | `TimeBucketSize!` | `MINUTE`, `HOUR`, or `DAY` | -| `limit` | `Int` | Max buckets returned (default 500, cap 2000) | - -Maximum time span per bucket size: MINUTE ≤ 7 days, HOUR ≤ 90 days, DAY ≤ 365 days. - ## Persistence When `database_url` / `DATABASE_URL` is provided, the server connects to PostgreSQL, auto-creates tables, and stores every event. diff --git a/docker-compose.yml b/docker-compose.yml index ae02016..a31517d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,10 @@ services: condition: service_healthy environment: DATABASE_URL: postgres://thq:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/thq - THQ_WS_AUTH_TOKEN: ${THQ_WS_AUTH_TOKEN:?set in .env or shell} - THQ_WS_AUTH_REQUIRED: ${THQ_WS_AUTH_REQUIRED:-true} + THQ_OBSERVER_AUTH_TOKEN: ${THQ_OBSERVER_AUTH_TOKEN:?set in .env or shell} + THQ_EVENTS_AUTH_TOKEN: ${THQ_EVENTS_AUTH_TOKEN:?set in .env or shell} + THQ_TELEMETRY_AUTH_TOKEN: ${THQ_TELEMETRY_AUTH_TOKEN:?set in .env or shell} + THQ_AUTH_REQUIRED: ${THQ_AUTH_REQUIRED:-true} # RUST_LOG: debug,thq_server=debug # RUST_BACKTRACE: 1 THQ_LINE_TOPOLOGY_PATH: /app/static/join.csv diff --git a/openapi.yaml b/openapi.yaml deleted file mode 100644 index 962dc12..0000000 --- a/openapi.yaml +++ /dev/null @@ -1,400 +0,0 @@ -openapi: 3.0.3 -info: - title: THQ Server REST API - description: Telemetry REST API for location updates and logging - version: 0.1.0 - license: - name: MIT - -servers: - - url: http://localhost:8080 - description: Local development server - -security: - - bearerAuth: [] - -paths: - /api/location: - post: - summary: Submit location update - description: | - Submit a location update for a device. The location will be: - - Validated for coordinate bounds and accuracy - - Annotated with segment information (if topology is configured) - - Broadcast to WebSocket subscribers - - Persisted to the database (if configured) - operationId: postLocation - tags: - - Location - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/LocationUpdateRequest' - examples: - moving: - summary: Device is moving - value: - device: "device-001" - state: "moving" - lineId: 11302 - coords: - latitude: 35.6812 - longitude: 139.7671 - accuracy: 10.0 - speed: 45.0 - timestamp: 1706000000000 - arrived: - summary: Device arrived at station - value: - device: "device-001" - state: "arrived" - stationId: 1130201 - lineId: 11302 - coords: - latitude: 35.6812 - longitude: 139.7671 - accuracy: 5.0 - speed: 0.0 - timestamp: 1706000000000 - responses: - '200': - description: Location update accepted - content: - application/json: - schema: - $ref: '#/components/schemas/ApiResponse' - examples: - success: - value: - ok: true - id: "550e8400-e29b-41d4-a716-446655440000" - successWithWarning: - value: - ok: true - id: "550e8400-e29b-41d4-a716-446655440000" - warning: "reported accuracy 150.0m exceeds threshold 100m" - '400': - description: Invalid request - content: - application/json: - schema: - $ref: '#/components/schemas/ApiResponse' - examples: - invalidCoords: - value: - ok: false - error: "latitude 91.000000 or longitude 139.767100 is out of range" - negativeAccuracy: - value: - ok: false - error: "accuracy must be >= 0" - '401': - description: Unauthorized - content: - application/json: - schema: - $ref: '#/components/schemas/ApiResponse' - examples: - missingAuth: - value: - ok: false - error: "missing or invalid Authorization header" - invalidToken: - value: - ok: false - error: "invalid auth token" - - /api/log: - post: - summary: Submit log entry - description: | - Submit a log entry for a device. The log will be: - - Validated (message must not be empty) - - Broadcast to WebSocket subscribers - - Persisted to the database (if configured) - operationId: postLog - tags: - - Logging - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/LogRequest' - examples: - appLog: - summary: Application log - value: - device: "device-001" - timestamp: 1706000000000 - log: - type: "app" - level: "info" - message: "GPS signal acquired" - errorLog: - summary: Error log - value: - device: "device-001" - timestamp: 1706000000000 - log: - type: "client" - level: "error" - message: "Connection lost" - responses: - '200': - description: Log entry accepted - content: - application/json: - schema: - $ref: '#/components/schemas/ApiResponse' - example: - ok: true - id: "550e8400-e29b-41d4-a716-446655440000" - '400': - description: Invalid request - content: - application/json: - schema: - $ref: '#/components/schemas/ApiResponse' - example: - ok: false - error: "log.message must not be empty" - '401': - description: Unauthorized - content: - application/json: - schema: - $ref: '#/components/schemas/ApiResponse' - examples: - missingAuth: - value: - ok: false - error: "missing or invalid Authorization header" - invalidToken: - value: - ok: false - error: "invalid auth token" - - /healthz: - get: - summary: Health check - description: Returns 200 OK if the server is running - operationId: healthz - tags: - - Health - security: [] - responses: - '200': - description: Server is healthy - -components: - schemas: - LocationUpdateRequest: - type: object - required: - - device - - state - - lineId - - coords - - timestamp - properties: - id: - type: string - description: Optional custom ID. If not provided, a UUID will be generated. - example: "custom-id-123" - device: - type: string - description: Device identifier - example: "device-001" - state: - $ref: '#/components/schemas/MovementState' - stationId: - type: integer - format: int32 - nullable: true - description: | - Station ID. Only meaningful when state is "arrived" or "passing". - Ignored when state is "moving" or "approaching". - example: 1130201 - lineId: - type: integer - format: int32 - description: Line ID - example: 11302 - coords: - $ref: '#/components/schemas/Coords' - timestamp: - type: integer - format: int64 - description: Unix timestamp in milliseconds - example: 1706000000000 - batteryLevel: - type: number - format: double - nullable: true - description: Battery level as a decimal (0.0 to 1.0) - example: 0.85 - batteryState: - nullable: true - oneOf: - - $ref: '#/components/schemas/BatteryState' - - LogRequest: - type: object - required: - - device - - timestamp - - log - properties: - id: - type: string - description: Optional custom ID. If not provided, a UUID will be generated. - example: "custom-id-123" - device: - type: string - description: Device identifier - example: "device-001" - timestamp: - type: integer - format: int64 - description: Unix timestamp in milliseconds - example: 1706000000000 - log: - $ref: '#/components/schemas/LogBody' - - Coords: - type: object - required: - - latitude - - longitude - properties: - latitude: - type: number - format: double - minimum: -90 - maximum: 90 - description: Latitude in degrees - example: 35.6812 - longitude: - type: number - format: double - minimum: -180 - maximum: 180 - description: Longitude in degrees - example: 139.7671 - accuracy: - type: number - format: double - nullable: true - minimum: 0 - description: Horizontal accuracy in meters - example: 10.0 - speed: - type: number - format: double - nullable: true - description: Speed in km/h. Defaults to 0 if not provided. - example: 45.0 - - LogBody: - type: object - required: - - type - - level - - message - properties: - type: - $ref: '#/components/schemas/LogType' - level: - $ref: '#/components/schemas/LogLevel' - message: - type: string - minLength: 1 - description: Log message (must not be empty or whitespace-only) - example: "GPS signal acquired" - - MovementState: - type: string - enum: - - arrived - - approaching - - passing - - moving - description: | - Current movement state of the device: - - `arrived`: Stopped at a station - - `approaching`: Approaching a station - - `passing`: Passing through a station without stopping - - `moving`: In motion between stations - - LogType: - type: string - enum: - - system - - app - - client - description: | - Type of log entry: - - `system`: System-generated logs - - `app`: Application logs - - `client`: Client-side logs - - LogLevel: - type: string - enum: - - debug - - info - - warn - - error - description: Log severity level - - BatteryState: - type: integer - enum: - - 0 - - 1 - - 2 - - 3 - description: | - Battery state of the device: - - `0` (UNKNOWN): Battery state is unknown or inaccessible - - `1` (UNPLUGGED): Battery is not charging or discharging - - `2` (CHARGING): Battery is charging - - `3` (FULL): Battery level is full - - ApiResponse: - type: object - required: - - ok - properties: - ok: - type: boolean - description: Whether the request was successful - id: - type: string - description: ID of the created resource (only present on success) - warning: - type: string - description: Warning message (e.g., low accuracy) - error: - type: string - description: Error message (only present on failure) - - securitySchemes: - bearerAuth: - type: http - scheme: bearer - description: | - Bearer token authentication. Use the same token configured via `THQ_WS_AUTH_TOKEN`. - - Example: `Authorization: Bearer your-secret-token` - -tags: - - name: Location - description: Location update endpoints - - name: Logging - description: Log submission endpoints - - name: Health - description: Health check endpoints diff --git a/src/config.rs b/src/config.rs index 2031e7a..fe2064c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -32,13 +32,21 @@ pub struct Cli { #[arg(long, env = "DATABASE_URL", value_name = "URL")] pub database_url: Option, - /// Shared secret token required for WebSocket auth (via Sec-WebSocket-Protocol) - #[arg(long, env = "THQ_WS_AUTH_TOKEN", value_name = "TOKEN")] - pub ws_auth_token: Option, + /// Shared secret for observers subscribing via WebSocket (Sec-WebSocket-Protocol) + #[arg(long, env = "THQ_OBSERVER_AUTH_TOKEN", value_name = "TOKEN")] + pub observer_auth_token: Option, - /// Whether WebSocket auth is required (true/false). Defaults to true when a token is supplied. - #[arg(long, env = "THQ_WS_AUTH_REQUIRED")] - pub ws_auth_required: Option, + /// Shared secret allowed to send log events via GraphQL (Bearer) + #[arg(long, env = "THQ_EVENTS_AUTH_TOKEN", value_name = "TOKEN")] + pub events_auth_token: Option, + + /// Shared secret allowed to send both log events and location updates via GraphQL (Bearer) + #[arg(long, env = "THQ_TELEMETRY_AUTH_TOKEN", value_name = "TOKEN")] + pub telemetry_auth_token: Option, + + /// Whether authentication is required (true/false). Defaults to true when any token is supplied. + #[arg(long, env = "THQ_AUTH_REQUIRED")] + pub auth_required: Option, } #[derive(Debug, Clone)] @@ -47,8 +55,10 @@ pub struct Config { pub port: u16, pub ring_size: usize, pub database_url: Option, - pub ws_auth_token: Option, - pub ws_auth_required: bool, + pub observer_auth_token: Option, + pub events_auth_token: Option, + pub telemetry_auth_token: Option, + pub auth_required: bool, } #[derive(Debug, Deserialize, Default)] @@ -57,8 +67,10 @@ struct FileConfig { port: Option, ring_size: Option, database_url: Option, - ws_auth_token: Option, - ws_auth_required: Option, + observer_auth_token: Option, + events_auth_token: Option, + telemetry_auth_token: Option, + auth_required: Option, } impl Config { @@ -84,22 +96,32 @@ impl Config { if let Some(database_url) = cli.database_url { file_cfg.database_url = Some(database_url); } - if let Some(ws_auth_token) = cli.ws_auth_token { - file_cfg.ws_auth_token = Some(ws_auth_token); + if let Some(observer_auth_token) = cli.observer_auth_token { + file_cfg.observer_auth_token = Some(observer_auth_token); + } + if let Some(events_auth_token) = cli.events_auth_token { + file_cfg.events_auth_token = Some(events_auth_token); } - if let Some(ws_auth_required) = cli.ws_auth_required { - file_cfg.ws_auth_required = Some(ws_auth_required); + if let Some(telemetry_auth_token) = cli.telemetry_auth_token { + file_cfg.telemetry_auth_token = Some(telemetry_auth_token); } + if let Some(auth_required) = cli.auth_required { + file_cfg.auth_required = Some(auth_required); + } + + let any_token = file_cfg.observer_auth_token.is_some() + || file_cfg.events_auth_token.is_some() + || file_cfg.telemetry_auth_token.is_some(); - let ws_auth_required = match (file_cfg.ws_auth_required, file_cfg.ws_auth_token.as_ref()) { + let auth_required = match (file_cfg.auth_required, any_token) { (Some(required), _) => required, - (None, Some(_)) => true, - (None, None) => false, + (None, true) => true, + (None, false) => false, }; - if ws_auth_required && file_cfg.ws_auth_token.is_none() { + if auth_required && !any_token { anyhow::bail!( - "ws_auth_required=true but ws_auth_token is missing; set THQ_WS_AUTH_TOKEN or disable auth" + "auth_required=true but no auth token is configured; set THQ_OBSERVER_AUTH_TOKEN / THQ_EVENTS_AUTH_TOKEN / THQ_TELEMETRY_AUTH_TOKEN or disable auth" ); } @@ -108,8 +130,10 @@ impl Config { port: file_cfg.port.unwrap_or(8080), ring_size: file_cfg.ring_size.unwrap_or(1000).max(1), database_url: file_cfg.database_url, - ws_auth_token: file_cfg.ws_auth_token, - ws_auth_required, + observer_auth_token: file_cfg.observer_auth_token, + events_auth_token: file_cfg.events_auth_token, + telemetry_auth_token: file_cfg.telemetry_auth_token, + auth_required, }) } } @@ -120,6 +144,20 @@ mod tests { use std::fs; use uuid::Uuid; + fn empty_cli() -> Cli { + Cli { + host: None, + port: None, + config: None, + ring_size: None, + database_url: None, + observer_auth_token: None, + events_auth_token: None, + telemetry_auth_token: None, + auth_required: None, + } + } + fn tmp_path(name: &str) -> PathBuf { let mut p = std::env::temp_dir(); p.push(format!("{}_{}.toml", name, Uuid::new_v4())); @@ -128,22 +166,15 @@ mod tests { #[test] fn defaults_are_used_when_no_cli_or_file() { - let cfg = Config::from_cli(Cli { - host: None, - port: None, - config: None, - ring_size: None, - database_url: None, - ws_auth_token: None, - ws_auth_required: None, - }) - .unwrap(); + let cfg = Config::from_cli(empty_cli()).unwrap(); assert_eq!(cfg.host, "0.0.0.0"); assert_eq!(cfg.port, 8080); assert_eq!(cfg.ring_size, 1000); - assert!(cfg.ws_auth_token.is_none()); - assert!(!cfg.ws_auth_required); + assert!(cfg.observer_auth_token.is_none()); + assert!(cfg.events_auth_token.is_none()); + assert!(cfg.telemetry_auth_token.is_none()); + assert!(!cfg.auth_required); } #[test] @@ -152,13 +183,8 @@ mod tests { fs::write(&path, "host = '127.0.0.1'\nport = 9000\nring_size = 50").unwrap(); let cfg = Config::from_cli(Cli { - host: None, - port: None, config: Some(path.clone()), - ring_size: None, - database_url: None, - ws_auth_token: None, - ws_auth_required: None, + ..empty_cli() }) .unwrap(); @@ -173,7 +199,11 @@ mod tests { #[test] fn cli_overrides_file() { let path = tmp_path("config_cli_override"); - fs::write(&path, "host = '0.0.0.0'\nport = 8080\nring_size = 10").unwrap(); + fs::write( + &path, + "host = '0.0.0.0'\nport = 8080\nring_size = 10\nobserver_auth_token = 'file-observer'", + ) + .unwrap(); let cfg = Config::from_cli(Cli { host: Some("127.0.0.1".into()), @@ -181,8 +211,10 @@ mod tests { config: Some(path.clone()), ring_size: Some(5), database_url: Some("postgres://cli/override".into()), - ws_auth_token: Some("cli-token".into()), - ws_auth_required: Some(false), + observer_auth_token: Some("cli-observer".into()), + events_auth_token: Some("cli-events".into()), + telemetry_auth_token: Some("cli-telemetry".into()), + auth_required: Some(false), }) .unwrap(); @@ -190,8 +222,10 @@ mod tests { assert_eq!(cfg.port, 7000); assert_eq!(cfg.ring_size, 5); assert_eq!(cfg.database_url.as_deref(), Some("postgres://cli/override")); - assert_eq!(cfg.ws_auth_token.as_deref(), Some("cli-token")); - assert!(!cfg.ws_auth_required); + assert_eq!(cfg.observer_auth_token.as_deref(), Some("cli-observer")); + assert_eq!(cfg.events_auth_token.as_deref(), Some("cli-events")); + assert_eq!(cfg.telemetry_auth_token.as_deref(), Some("cli-telemetry")); + assert!(!cfg.auth_required); let _ = fs::remove_file(path); } @@ -202,13 +236,8 @@ mod tests { fs::write(&path, "database_url = 'postgres://user:pass@localhost/db'").unwrap(); let cfg = Config::from_cli(Cli { - host: None, - port: None, config: Some(path.clone()), - ring_size: None, - database_url: None, - ws_auth_token: None, - ws_auth_required: None, + ..empty_cli() }) .unwrap(); @@ -221,36 +250,38 @@ mod tests { } #[test] - fn ws_auth_defaults_to_required_when_token_present() { + fn auth_defaults_to_required_when_any_token_present() { let cfg = Config::from_cli(Cli { - host: None, - port: None, - config: None, - ring_size: None, - database_url: None, - ws_auth_token: Some("secret".into()), - ws_auth_required: None, + events_auth_token: Some("secret".into()), + ..empty_cli() }) .unwrap(); - assert!(cfg.ws_auth_required); - assert_eq!(cfg.ws_auth_token.as_deref(), Some("secret")); + assert!(cfg.auth_required); + assert_eq!(cfg.events_auth_token.as_deref(), Some("secret")); } #[test] - fn ws_auth_can_be_disabled_explicitly() { + fn auth_can_be_disabled_explicitly() { let cfg = Config::from_cli(Cli { - host: None, - port: None, - config: None, - ring_size: None, - database_url: None, - ws_auth_token: Some("secret".into()), - ws_auth_required: Some(false), + observer_auth_token: Some("secret".into()), + auth_required: Some(false), + ..empty_cli() }) .unwrap(); - assert!(!cfg.ws_auth_required); - assert_eq!(cfg.ws_auth_token.as_deref(), Some("secret")); + assert!(!cfg.auth_required); + assert_eq!(cfg.observer_auth_token.as_deref(), Some("secret")); + } + + #[test] + fn auth_required_without_tokens_is_rejected() { + let err = Config::from_cli(Cli { + auth_required: Some(true), + ..empty_cli() + }) + .unwrap_err(); + + assert!(err.to_string().contains("no auth token")); } } diff --git a/src/domain.rs b/src/domain.rs index 6c8ba49..eca6de2 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -1,7 +1,8 @@ +use async_graphql::Enum; use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; -#[derive(Debug, Clone, Serialize_repr, Deserialize_repr, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq, Enum)] #[repr(u8)] pub enum BatteryState { Unknown = 0, @@ -10,7 +11,7 @@ pub enum BatteryState { Full = 3, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] #[serde(rename_all = "snake_case")] pub enum MovementState { Arrived, @@ -30,7 +31,7 @@ impl MovementState { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] #[serde(rename_all = "snake_case")] pub enum LogLevel { Debug, @@ -50,7 +51,7 @@ impl LogLevel { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] #[serde(rename_all = "snake_case")] pub enum LogType { System, @@ -68,14 +69,6 @@ impl LogType { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Coords { - pub latitude: f64, - pub longitude: f64, - pub accuracy: Option, - pub speed: Option, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LogBody { pub r#type: LogType, @@ -92,36 +85,6 @@ pub enum IncomingMessage { }, } -/// REST API用の位置情報リクエスト -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LocationUpdateRequest { - #[serde(default)] - pub id: Option, - pub device: String, - pub state: MovementState, - #[serde(default)] - pub station_id: Option, - pub line_id: i32, - pub coords: Coords, - pub timestamp: u64, - #[serde(default)] - pub battery_level: Option, - #[serde(default)] - pub battery_state: Option, -} - -/// REST API用のログリクエスト -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LogRequest { - #[serde(default)] - pub id: Option, - pub device: String, - pub timestamp: u64, - pub log: LogBody, -} - #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum OutgoingMessage { @@ -196,19 +159,23 @@ mod tests { } #[test] - fn location_update_request_deserializes() { - let json = r#"{ - "device":"dev", - "state":"moving", - "lineId":7, - "stationId":42, - "coords":{"latitude":1.0,"longitude":2.0,"accuracy":null,"speed":3.0}, - "timestamp":123 - }"#; - - let req: LocationUpdateRequest = serde_json::from_str(json).unwrap(); - assert_eq!(req.line_id, 7); - assert_eq!(req.station_id, Some(42)); + fn outgoing_log_has_type_field() { + let msg = OutgoingMessage::Log(OutgoingLog { + id: "id1".into(), + device: "dev".into(), + timestamp: 42, + log: LogBody { + r#type: LogType::App, + level: LogLevel::Info, + message: "hello".into(), + }, + }); + + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["type"], "log"); + assert_eq!(json["device"], "dev"); + assert_eq!(json["log"]["level"], "info"); + assert_eq!(json["log"]["message"], "hello"); } #[test] @@ -236,6 +203,6 @@ mod tests { let json = serde_json::to_value(&msg).unwrap(); assert_eq!(json["type"], "location_update"); assert_eq!(json["device"], "dev"); - assert_eq!(json["coords"]["speed"], 3.0); // Some(3.0) serializes as 3.0 + assert_eq!(json["coords"]["speed"], 3.0); } } diff --git a/src/graphql.rs b/src/graphql.rs index ca6868e..ba6cf3b 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -1,15 +1,37 @@ -use std::time::Instant; +use std::{sync::Arc, time::Instant}; use async_graphql::{ - Context, EmptyMutation, EmptySubscription, Enum, Object, Result, Schema, SimpleObject, ID, + Context, EmptySubscription, Enum, InputObject, Object, Result, Schema, SimpleObject, ID, }; use chrono::{DateTime, Duration as ChronoDuration, Utc}; use tracing::info; +use uuid::Uuid; -use crate::storage::{LineAccuracyBucketRow, Storage}; +use crate::{ + domain::{ + BatteryState, LogBody, LogLevel, LogType, MovementState, OutgoingCoords, OutgoingLocation, + OutgoingLog, OutgoingMessage, + }, + segment::SegmentEstimator, + state::TelemetryHub, + storage::{LineAccuracyBucketRow, Storage}, +}; + +const BAD_ACCURACY_THRESHOLD: f64 = 100.0; // meters /// Public schema type so the server can hold and share it. -pub type AppSchema = Schema; +pub type AppSchema = Schema; + +/// Scopes granted by the HTTP-layer Bearer token check, injected per request. +/// +/// - the events token only grants `can_send_events` +/// - the telemetry token grants both flags +/// - the observer token grants neither (it is WebSocket-only) +#[derive(Clone, Copy)] +pub struct MutationAuth { + pub can_send_events: bool, + pub can_send_location: bool, +} const HARD_LIMIT: i32 = 2000; @@ -63,9 +85,15 @@ pub struct LineAccuracyReport { pub buckets: Vec, } -pub fn build_schema(storage: Storage) -> AppSchema { - Schema::build(QueryRoot, EmptyMutation, EmptySubscription) +pub fn build_schema( + storage: Storage, + hub: Arc, + segmenter: SegmentEstimator, +) -> AppSchema { + Schema::build(QueryRoot, MutationRoot, EmptySubscription) .data(storage) + .data(hub) + .data(segmenter) .finish() } @@ -156,6 +184,235 @@ impl QueryRoot { } } +#[derive(InputObject)] +pub struct LogEventInput { + /// Optional custom ID. A random UUID is generated when omitted. + pub id: Option, + /// Device identifier. + pub device: String, + /// Unix timestamp in milliseconds. + pub timestamp: u64, + #[graphql(name = "type")] + pub log_type: LogType, + pub level: LogLevel, + pub message: String, +} + +#[derive(SimpleObject)] +pub struct SendLogEventPayload { + pub id: String, +} + +#[derive(InputObject)] +pub struct CoordsInput { + pub latitude: f64, + pub longitude: f64, + /// Horizontal accuracy in meters. + pub accuracy: Option, + /// Speed in km/h. + pub speed: Option, +} + +#[derive(InputObject)] +pub struct LocationEventInput { + /// Optional custom ID. A random UUID is generated when omitted. + pub id: Option, + /// Device identifier. + pub device: String, + pub state: MovementState, + /// Only meaningful when state is ARRIVED or PASSING; ignored otherwise. + pub station_id: Option, + pub line_id: i32, + pub coords: CoordsInput, + /// Unix timestamp in milliseconds. + pub timestamp: u64, + /// Battery level as a decimal (0.0 to 1.0). + pub battery_level: Option, + pub battery_state: Option, +} + +#[derive(SimpleObject)] +pub struct SendLocationPayload { + pub id: String, + /// Set when the update was accepted with a caveat (e.g. bad accuracy). + pub warning: Option, +} + +pub struct MutationRoot; + +#[Object] +impl MutationRoot { + /// Submit a log event. Requires the events token or the telemetry token. + /// The event is broadcast to WebSocket subscribers and persisted when a + /// database is configured. + async fn send_log_event( + &self, + ctx: &Context<'_>, + input: LogEventInput, + ) -> Result { + let auth = ctx + .data::() + .map_err(|_| "auth context is missing")?; + if !auth.can_send_events { + return Err( + "unauthorized: a valid events or telemetry bearer token is required".into(), + ); + } + + if input.message.trim().is_empty() { + return Err("message must not be empty".into()); + } + + let hub = ctx + .data::>() + .map_err(|_| "telemetry hub is not configured")?; + let storage = ctx + .data::() + .map_err(|_| "storage is not configured")?; + + let id = input.id.unwrap_or_else(|| Uuid::new_v4().to_string()); + let log = OutgoingLog { + id: id.clone(), + device: input.device, + timestamp: input.timestamp, + log: LogBody { + r#type: input.log_type, + level: input.level, + message: input.message, + }, + }; + + match serde_json::to_string(&OutgoingMessage::Log(log.clone())) { + Ok(serialized) => hub.broadcast(serialized).await, + Err(err) => { + tracing::error!(?err, "failed to serialize log message"); + } + } + + if let Err(err) = storage.store_log(&log).await { + tracing::error!(?err, "failed to persist log event"); + } + + Ok(SendLogEventPayload { id }) + } + + /// Submit a location update. Requires the telemetry token; the events + /// token is deliberately not enough to publish positional data. The + /// update is annotated with segment information, broadcast to WebSocket + /// subscribers and persisted when a database is configured. + async fn send_location( + &self, + ctx: &Context<'_>, + input: LocationEventInput, + ) -> Result { + let auth = ctx + .data::() + .map_err(|_| "auth context is missing")?; + if !auth.can_send_location { + return Err("unauthorized: a valid telemetry bearer token is required".into()); + } + + if !input.coords.latitude.is_finite() || !input.coords.longitude.is_finite() { + return Err("latitude/longitude must be finite numbers".into()); + } + + if input.coords.latitude.abs() > 90.0 || input.coords.longitude.abs() > 180.0 { + return Err(format!( + "latitude {:.6} or longitude {:.6} is out of range", + input.coords.latitude, input.coords.longitude + ) + .into()); + } + + let speed = match input.coords.speed { + Some(s) if !s.is_finite() => return Err("speed must be finite".into()), + Some(s) if s < 0.0 => None, + other => other, + }; + + if let Some(acc) = input.coords.accuracy { + if !acc.is_finite() { + return Err("accuracy must be finite".into()); + } + if acc < 0.0 { + return Err("accuracy must be >= 0".into()); + } + } + + if let Some(level) = input.battery_level { + if !(0.0..=1.0).contains(&level) { + return Err("battery_level must be between 0.0 and 1.0".into()); + } + } + + // station_id is only meaningful when not moving/approaching + let station_id = if matches!( + input.state, + MovementState::Moving | MovementState::Approaching + ) { + None + } else { + input.station_id + }; + + let hub = ctx + .data::>() + .map_err(|_| "telemetry hub is not configured")?; + let storage = ctx + .data::() + .map_err(|_| "storage is not configured")?; + let segmenter = ctx + .data::() + .map_err(|_| "segment estimator is not configured")?; + + let id = input.id.unwrap_or_else(|| Uuid::new_v4().to_string()); + let loc = OutgoingLocation { + id: id.clone(), + device: input.device, + state: input.state, + station_id, + line_id: input.line_id, + coords: OutgoingCoords { + latitude: input.coords.latitude, + longitude: input.coords.longitude, + accuracy: input.coords.accuracy, + speed, + }, + timestamp: input.timestamp, + segment_id: None, + from_station_id: None, + to_station_id: None, + battery_level: input.battery_level, + battery_state: input.battery_state, + }; + + let loc = segmenter.annotate(loc).await; + + match serde_json::to_string(&OutgoingMessage::LocationUpdate(loc.clone())) { + Ok(serialized) => hub.broadcast(serialized).await, + Err(err) => { + tracing::error!(?err, "failed to serialize location_update message"); + } + } + + if let Err(err) = storage.store_location(&loc).await { + tracing::error!(?err, "failed to persist location_update"); + } + + let warning = input + .coords + .accuracy + .filter(|v| *v > BAD_ACCURACY_THRESHOLD) + .map(|acc| { + format!( + "reported accuracy {acc:.1}m exceeds threshold {BAD_ACCURACY_THRESHOLD:.0}m" + ) + }); + + Ok(SendLocationPayload { id, warning }) + } +} + impl From for LineAccuracyBucket { fn from(row: LineAccuracyBucketRow) -> Self { Self { @@ -182,6 +439,282 @@ fn estimate_bucket_count(from: DateTime, to: DateTime, bucket_seconds: #[cfg(test)] mod tests { use super::*; + use crate::segment::LineTopology; + + const EVENTS_ONLY: MutationAuth = MutationAuth { + can_send_events: true, + can_send_location: false, + }; + const TELEMETRY: MutationAuth = MutationAuth { + can_send_events: true, + can_send_location: true, + }; + const OBSERVER: MutationAuth = MutationAuth { + can_send_events: false, + can_send_location: false, + }; + + fn test_schema(hub: Arc) -> AppSchema { + build_schema( + Storage::default(), + hub, + SegmentEstimator::new(LineTopology::empty()), + ) + } + + fn request(query: &str, auth: MutationAuth) -> async_graphql::Request { + async_graphql::Request::new(query.to_string()).data(auth) + } + + fn location_mutation(state: &str, extra: &str) -> String { + format!( + r#"mutation {{ + sendLocation(input: {{ + device: "dev", + state: {state}, + lineId: 1, + coords: {{ latitude: 35.6812, longitude: 139.7671{extra} }}, + timestamp: 1706000000000 + }}) {{ id warning }} + }}"# + ) + } + + #[tokio::test] + async fn send_log_event_broadcasts_and_returns_id() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request( + r#"mutation { + sendLogEvent(input: { + id: "custom-id-123", + device: "dev", + timestamp: 1706000000000, + type: APP, + level: INFO, + message: "hello" + }) { id } + }"#, + EVENTS_ONLY, + )) + .await; + + assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors); + let data = resp.data.into_json().unwrap(); + assert_eq!(data["sendLogEvent"]["id"], "custom-id-123"); + + let snapshot = hub.snapshot().await; + assert_eq!(snapshot.len(), 1); + let v: serde_json::Value = serde_json::from_str(&snapshot[0]).unwrap(); + assert_eq!(v["type"], "log"); + assert_eq!(v["log"]["message"], "hello"); + assert_eq!(v["timestamp"], 1706000000000u64); + } + + #[tokio::test] + async fn send_log_event_generates_id_when_omitted() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub); + + let resp = schema + .execute(request( + r#"mutation { + sendLogEvent(input: { + device: "dev", + timestamp: 1, + type: SYSTEM, + level: WARN, + message: "hi" + }) { id } + }"#, + TELEMETRY, + )) + .await; + + assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors); + let data = resp.data.into_json().unwrap(); + assert!(!data["sendLogEvent"]["id"].as_str().unwrap().is_empty()); + } + + #[tokio::test] + async fn send_log_event_rejects_empty_message() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request( + r#"mutation { + sendLogEvent(input: { + device: "dev", + timestamp: 1, + type: APP, + level: INFO, + message: " " + }) { id } + }"#, + EVENTS_ONLY, + )) + .await; + + assert!(!resp.errors.is_empty()); + assert!(resp.errors[0].message.contains("message")); + assert!(hub.snapshot().await.is_empty()); + } + + #[tokio::test] + async fn send_log_event_rejects_observer_scope() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request( + r#"mutation { + sendLogEvent(input: { + device: "dev", + timestamp: 1, + type: APP, + level: INFO, + message: "hi" + }) { id } + }"#, + OBSERVER, + )) + .await; + + assert!(!resp.errors.is_empty()); + assert!(resp.errors[0].message.contains("unauthorized")); + assert!(hub.snapshot().await.is_empty()); + } + + #[tokio::test] + async fn send_location_broadcasts_with_telemetry_scope() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request( + &location_mutation("MOVING", ", speed: 50.0"), + TELEMETRY, + )) + .await; + + assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors); + let data = resp.data.into_json().unwrap(); + assert!(!data["sendLocation"]["id"].as_str().unwrap().is_empty()); + assert!(data["sendLocation"]["warning"].is_null()); + + let snapshot = hub.snapshot().await; + assert_eq!(snapshot.len(), 1); + let v: serde_json::Value = serde_json::from_str(&snapshot[0]).unwrap(); + assert_eq!(v["type"], "location_update"); + assert_eq!(v["coords"]["speed"], 50.0); + } + + #[tokio::test] + async fn send_location_rejects_events_only_scope() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request(&location_mutation("MOVING", ""), EVENTS_ONLY)) + .await; + + assert!(!resp.errors.is_empty()); + assert!(resp.errors[0].message.contains("telemetry")); + assert!(hub.snapshot().await.is_empty()); + } + + #[tokio::test] + async fn send_location_rejects_invalid_latitude() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request( + r#"mutation { + sendLocation(input: { + device: "dev", + state: MOVING, + lineId: 1, + coords: { latitude: 91.0, longitude: 139.7671 }, + timestamp: 1 + }) { id } + }"#, + TELEMETRY, + )) + .await; + + assert!(!resp.errors.is_empty()); + assert!(resp.errors[0].message.contains("out of range")); + assert!(hub.snapshot().await.is_empty()); + } + + #[tokio::test] + async fn send_location_rejects_negative_accuracy() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request( + &location_mutation("MOVING", ", accuracy: -1.0"), + TELEMETRY, + )) + .await; + + assert!(!resp.errors.is_empty()); + assert!(resp.errors[0].message.contains("accuracy")); + assert!(hub.snapshot().await.is_empty()); + } + + #[tokio::test] + async fn send_location_warns_on_low_accuracy() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub); + + let resp = schema + .execute(request( + &location_mutation("MOVING", ", accuracy: 150.0"), + TELEMETRY, + )) + .await; + + assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors); + let data = resp.data.into_json().unwrap(); + assert!(data["sendLocation"]["warning"] + .as_str() + .unwrap() + .contains("accuracy")); + } + + #[tokio::test] + async fn send_location_drops_station_id_when_moving() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request( + r#"mutation { + sendLocation(input: { + device: "dev", + state: MOVING, + stationId: 42, + lineId: 1, + coords: { latitude: 35.6812, longitude: 139.7671 }, + timestamp: 1 + }) { id } + }"#, + TELEMETRY, + )) + .await; + + assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors); + let snapshot = hub.snapshot().await; + assert_eq!(snapshot.len(), 1); + let v: serde_json::Value = serde_json::from_str(&snapshot[0]).unwrap(); + assert!(v["station_id"].is_null()); + } #[test] fn bucket_limits_match_spec() { diff --git a/src/main.rs b/src/main.rs index 9cb2572..d0e4951 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,8 +18,10 @@ async fn main() -> anyhow::Result<()> { host = %config.host, port = config.port, db = %config.database_url.as_deref().unwrap_or(""), - ws_auth_configured = config.ws_auth_token.is_some(), - ws_auth_required = config.ws_auth_required, + observer_auth_configured = config.observer_auth_token.is_some(), + events_auth_configured = config.events_auth_token.is_some(), + telemetry_auth_configured = config.telemetry_auth_token.is_some(), + auth_required = config.auth_required, "starting thq-server" ); match server::run_server(config).await { diff --git a/src/server.rs b/src/server.rs index c742c1e..4d7e981 100644 --- a/src/server.rs +++ b/src/server.rs @@ -8,55 +8,49 @@ use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; use axum::{ extract::{ ws::{Message, WebSocket, WebSocketUpgrade}, - ConnectInfo, FromRequestParts, State, + ConnectInfo, State, }, - http::{ - header::AUTHORIZATION, header::SEC_WEBSOCKET_PROTOCOL, request::Parts, HeaderMap, - StatusCode, - }, - response::{Html, IntoResponse, Json}, - routing::{get, post}, + http::{header::AUTHORIZATION, header::SEC_WEBSOCKET_PROTOCOL, HeaderMap, StatusCode}, + response::{Html, IntoResponse}, + routing::get, Router, }; use futures::{SinkExt, StreamExt}; -use serde::Serialize; use tokio::sync::mpsc; use tracing::warn; use uuid::Uuid; use crate::{ config::Config, - domain::{ - ErrorBody, ErrorType, IncomingMessage, LocationUpdateRequest, LogRequest, MovementState, - OutgoingCoords, OutgoingError, OutgoingLocation, OutgoingLog, OutgoingMessage, - }, - graphql::{build_schema, AppSchema}, + domain::{ErrorBody, ErrorType, IncomingMessage, OutgoingError, OutgoingMessage}, + graphql::{build_schema, AppSchema, MutationAuth}, segment::{LineTopology, SegmentEstimator}, state::TelemetryHub, storage::Storage, }; -const BAD_ACCURACY_THRESHOLD: f64 = 100.0; // meters - +/// Per-scope shared secrets. Each token grants exactly one role: +/// - observer: WebSocket subscription only +/// - events: sendLogEvent mutation only +/// - telemetry: sendLogEvent and sendLocation mutations #[derive(Clone)] struct AuthConfig { - token: Option, + observer_token: Option, + events_token: Option, + telemetry_token: Option, required: bool, } #[derive(Clone)] struct AppState { hub: Arc, - storage: Storage, auth: AuthConfig, schema: AppSchema, - segmenter: SegmentEstimator, } pub async fn run_server(config: Config) -> anyhow::Result<()> { let hub = Arc::new(TelemetryHub::new(config.ring_size)); let storage = Storage::connect(config.database_url.clone()).await?; - let schema = build_schema(storage.clone()); let topology = match LineTopology::from_env_var("THQ_LINE_TOPOLOGY_PATH")? { Some(topo) => { @@ -88,28 +82,29 @@ pub async fn run_server(config: Config) -> anyhow::Result<()> { tracing::info!("database_url not set; persistence is disabled"); } - if !config.ws_auth_required && config.ws_auth_token.is_none() { - warn!("websocket auth is disabled because THQ_WS_AUTH_TOKEN is not set"); + if !config.auth_required { + warn!( + "authentication is disabled; every client gets observer, events and telemetry access" + ); } + let schema = build_schema(storage, hub.clone(), segmenter); + let state = AppState { - hub: hub.clone(), - storage: storage.clone(), + hub, auth: AuthConfig { - token: config.ws_auth_token.clone(), - required: config.ws_auth_required, + observer_token: config.observer_auth_token.clone(), + events_token: config.events_auth_token.clone(), + telemetry_token: config.telemetry_auth_token.clone(), + required: config.auth_required, }, - schema: schema.clone(), - segmenter: segmenter.clone(), + schema, }; let app = Router::new() .route("/", get(ws_handler)) .route("/ws", get(ws_handler)) .route("/healthz", get(healthz)) - .route("/api/location", post(post_location)) - .route("/api/log", post(post_log)) - .with_state(state.clone()) .route("/graphql", get(graphql_playground).post(graphql_handler)) .with_state(state); @@ -158,8 +153,15 @@ async fn ws_handler( async fn healthz() -> impl IntoResponse { StatusCode::OK } -async fn graphql_handler(State(state): State, req: GraphQLRequest) -> GraphQLResponse { - state.schema.execute(req.into_inner()).await.into() + +async fn graphql_handler( + State(state): State, + headers: HeaderMap, + req: GraphQLRequest, +) -> GraphQLResponse { + let auth = mutation_auth(&headers, &state.auth); + let req = req.into_inner().data(auth); + state.schema.execute(req).await.into() } async fn graphql_playground() -> impl IntoResponse { @@ -168,292 +170,49 @@ async fn graphql_playground() -> impl IntoResponse { )) } -#[derive(Serialize)] -struct ApiResponse { - ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - warning: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -/// Extractor that enforces Bearer token authentication for REST API -struct Authenticated; - -#[axum::async_trait] -impl FromRequestParts for Authenticated { - type Rejection = (StatusCode, Json); - - async fn from_request_parts( - parts: &mut Parts, - state: &AppState, - ) -> Result { - if !state.auth.required { - return Ok(Authenticated); - } - - let expected = state.auth.token.as_ref().ok_or_else(|| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiResponse { - ok: false, - id: None, - warning: None, - error: Some("server token is not configured".to_string()), - }), - ) - })?; - - let auth_header = parts - .headers - .get(AUTHORIZATION) - .and_then(|v| v.to_str().ok()); - - let token = auth_header - .and_then(|h| { - h.get(..7).and_then(|pref| { - if pref.eq_ignore_ascii_case("bearer ") { - h.get(7..) - } else { - None - } - }) - }) - .ok_or_else(|| { - ( - StatusCode::UNAUTHORIZED, - Json(ApiResponse { - ok: false, - id: None, - warning: None, - error: Some("missing or invalid Authorization header".to_string()), - }), - ) - })?; - - if token.as_bytes().ct_eq(expected.as_bytes()).into() { - Ok(Authenticated) - } else { - Err(( - StatusCode::UNAUTHORIZED, - Json(ApiResponse { - ok: false, - id: None, - warning: None, - error: Some("invalid auth token".to_string()), - }), - )) - } - } -} - -async fn post_location( - _auth: Authenticated, - State(state): State, - Json(req): Json, -) -> impl IntoResponse { - // Validate coordinates - if !req.coords.latitude.is_finite() || !req.coords.longitude.is_finite() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiResponse { - ok: false, - id: None, - warning: None, - error: Some("latitude/longitude must be finite numbers".to_string()), - }), - ); - } - - if req.coords.latitude.abs() > 90.0 || req.coords.longitude.abs() > 180.0 { - return ( - StatusCode::BAD_REQUEST, - Json(ApiResponse { - ok: false, - id: None, - warning: None, - error: Some(format!( - "latitude {:.6} or longitude {:.6} is out of range", - req.coords.latitude, req.coords.longitude - )), - }), - ); - } - - let speed = match req.coords.speed { - Some(s) if !s.is_finite() => { - return ( - StatusCode::BAD_REQUEST, - Json(ApiResponse { - ok: false, - id: None, - warning: None, - error: Some("speed must be finite".to_string()), - }), - ); - } - Some(s) if s < 0.0 => None, - other => other, - }; - - if let Some(acc) = req.coords.accuracy { - if !acc.is_finite() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiResponse { - ok: false, - id: None, - warning: None, - error: Some("accuracy must be finite".to_string()), - }), - ); - } - if acc < 0.0 { - return ( - StatusCode::BAD_REQUEST, - Json(ApiResponse { - ok: false, - id: None, - warning: None, - error: Some("accuracy must be >= 0".to_string()), - }), - ); - } - } - - if let Some(level) = req.battery_level { - if !(0.0..=1.0).contains(&level) { - return ( - StatusCode::BAD_REQUEST, - Json(ApiResponse { - ok: false, - id: None, - warning: None, - error: Some("battery_level must be between 0.0 and 1.0".to_string()), - }), - ); - } +/// Resolves the mutation scopes granted by the Authorization header. +/// Queries stay open, so the result is carried into the GraphQL context +/// instead of rejecting the request here. +fn mutation_auth(headers: &HeaderMap, auth: &AuthConfig) -> MutationAuth { + if !auth.required { + return MutationAuth { + can_send_events: true, + can_send_location: true, + }; } - // station_id is only meaningful when not moving/approaching - let station_id = if matches!( - req.state, - MovementState::Moving | MovementState::Approaching - ) { - None - } else { - req.station_id + let Some(token) = bearer_token(headers) else { + return MutationAuth { + can_send_events: false, + can_send_location: false, + }; }; - let id = req.id.unwrap_or_else(|| Uuid::new_v4().to_string()); - let loc = OutgoingLocation { - id: id.clone(), - device: req.device, - state: req.state, - station_id, - line_id: req.line_id, - coords: OutgoingCoords { - latitude: req.coords.latitude, - longitude: req.coords.longitude, - accuracy: req.coords.accuracy, - speed, - }, - timestamp: req.timestamp, - segment_id: None, - from_station_id: None, - to_station_id: None, - battery_level: req.battery_level, - battery_state: req.battery_state, + let matches = |expected: &Option| -> bool { + expected + .as_ref() + .map(|e| token.as_bytes().ct_eq(e.as_bytes()).into()) + .unwrap_or(false) }; - // Annotate with segment info - let loc = state.segmenter.annotate(loc).await; - - // Broadcast to WebSocket subscribers - let message = OutgoingMessage::LocationUpdate(loc.clone()); - match serde_json::to_string(&message) { - Ok(serialized) => state.hub.broadcast(serialized).await, - Err(err) => { - tracing::error!(?err, "failed to serialize location_update message"); - } - } + let can_send_location = matches(&auth.telemetry_token); + let can_send_events = can_send_location || matches(&auth.events_token); - // Store in database - if let Err(err) = state.storage.store_location(&loc).await { - tracing::error!(?err, "failed to persist location_update"); + MutationAuth { + can_send_events, + can_send_location, } - - // Check accuracy warning - let warning = req - .coords - .accuracy - .filter(|v| *v > BAD_ACCURACY_THRESHOLD) - .map(|acc| { - format!("reported accuracy {acc:.1}m exceeds threshold {BAD_ACCURACY_THRESHOLD:.0}m") - }); - - ( - StatusCode::OK, - Json(ApiResponse { - ok: true, - id: Some(id), - warning, - error: None, - }), - ) } -async fn post_log( - _auth: Authenticated, - State(state): State, - Json(req): Json, -) -> impl IntoResponse { - // Validate log message - if req.log.message.trim().is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiResponse { - ok: false, - id: None, - warning: None, - error: Some("log.message must not be empty".to_string()), - }), - ); - } - - let id = req.id.unwrap_or_else(|| Uuid::new_v4().to_string()); - let log = OutgoingLog { - id: id.clone(), - device: req.device, - timestamp: req.timestamp, - log: req.log, - }; - - // Broadcast to WebSocket subscribers - let message = OutgoingMessage::Log(log.clone()); - match serde_json::to_string(&message) { - Ok(serialized) => state.hub.broadcast(serialized).await, - Err(err) => { - tracing::error!(?err, "failed to serialize log message"); +fn bearer_token(headers: &HeaderMap) -> Option<&str> { + let header = headers.get(AUTHORIZATION)?.to_str().ok()?; + header.get(..7).and_then(|pref| { + if pref.eq_ignore_ascii_case("bearer ") { + header.get(7..) + } else { + None } - } - - // Store in database - if let Err(err) = state.storage.store_log(&log).await { - tracing::error!(?err, "failed to persist log message"); - } - - ( - StatusCode::OK, - Json(ApiResponse { - ok: true, - id: Some(id), - warning: None, - error: None, - }), - ) + }) } async fn handle_socket(socket: WebSocket, peer: SocketAddr, state: AppState) { @@ -558,6 +317,8 @@ impl AuthError { } } +/// WebSocket observation is granted by the observer token only; the events +/// and telemetry tokens deliberately do not open the subscription channel. fn enforce_ws_auth(header: Option<&str>, auth: &AuthConfig) -> Result<(), AuthError> { if !auth.required { return Ok(()); @@ -571,7 +332,10 @@ fn enforce_ws_auth(header: Option<&str>, auth: &AuthConfig) -> Result<(), AuthEr } let token = parsed.token.ok_or(AuthError::MissingToken)?; - let expected = auth.token.as_ref().ok_or(AuthError::TokenNotConfigured)?; + let expected = auth + .observer_token + .as_ref() + .ok_or(AuthError::TokenNotConfigured)?; if token.as_bytes().ct_eq(expected.as_bytes()).into() { Ok(()) @@ -675,31 +439,96 @@ async fn shutdown_signal() { #[cfg(test)] mod tests { use super::*; - use axum::{body::Body, extract::ws::Message, http::Request}; + use axum::{body::Body, extract::ws::Message, http::Request, routing::post}; use hyper::body::to_bytes; use serde_json::{json, Value}; use tokio::sync::mpsc; use tower::ServiceExt; use uuid::Uuid; - fn test_state() -> AppState { + fn open_auth() -> AuthConfig { + AuthConfig { + observer_token: None, + events_token: None, + telemetry_token: None, + required: false, + } + } + + fn scoped_auth() -> AuthConfig { + AuthConfig { + observer_token: Some("observer-secret".into()), + events_token: Some("events-secret".into()), + telemetry_token: Some("telemetry-secret".into()), + required: true, + } + } + + fn state_with_auth(auth: AuthConfig) -> AppState { + let hub = Arc::new(TelemetryHub::new(10)); AppState { - hub: Arc::new(TelemetryHub::new(10)), - storage: Storage::default(), - auth: AuthConfig { - token: None, - required: false, - }, - schema: build_schema(Storage::default()), - segmenter: SegmentEstimator::new(LineTopology::empty()), + hub: hub.clone(), + auth, + schema: build_schema( + Storage::default(), + hub, + SegmentEstimator::new(LineTopology::empty()), + ), } } - fn test_router() -> Router { + fn graphql_router(state: AppState) -> Router { Router::new() - .route("/api/location", post(post_location)) - .route("/api/log", post(post_log)) - .with_state(test_state()) + .route("/graphql", post(graphql_handler)) + .with_state(state) + } + + fn graphql_request(query: &str, auth_header: Option<&str>) -> Request { + let payload = json!({ "query": query }); + + let mut builder = Request::builder() + .method("POST") + .uri("/graphql") + .header("content-type", "application/json"); + if let Some(value) = auth_header { + builder = builder.header("authorization", value); + } + builder.body(Body::from(payload.to_string())).unwrap() + } + + fn send_log_event_request(auth_header: Option<&str>) -> Request { + graphql_request( + r#"mutation { + sendLogEvent(input: { + device: "test-device", + timestamp: 1706000000000, + type: APP, + level: INFO, + message: "Hello, world!" + }) { id } + }"#, + auth_header, + ) + } + + fn send_location_request(auth_header: Option<&str>) -> Request { + graphql_request( + r#"mutation { + sendLocation(input: { + device: "test-device", + state: MOVING, + lineId: 1, + coords: { latitude: 35.6812, longitude: 139.7671 }, + timestamp: 1706000000000 + }) { id } + }"#, + auth_header, + ) + } + + async fn body_json(response: axum::response::Response) -> Value { + let body = to_bytes(response.into_body()).await.unwrap(); + serde_json::from_slice(&body).unwrap() } #[tokio::test] @@ -737,486 +566,179 @@ mod tests { #[test] fn enforce_requires_token_when_enabled() { - let res = enforce_ws_auth( - Some("thq"), - &AuthConfig { - token: Some("secret".into()), - required: true, - }, - ); - + let res = enforce_ws_auth(Some("thq"), &scoped_auth()); assert_eq!(res.unwrap_err(), AuthError::MissingToken); } #[test] - fn enforce_accepts_correct_token() { - let res = enforce_ws_auth( - Some("thq, thq-auth-secret"), - &AuthConfig { - token: Some("secret".into()), - required: true, - }, - ); - + fn enforce_accepts_observer_token() { + let res = enforce_ws_auth(Some("thq, thq-auth-observer-secret"), &scoped_auth()); assert!(res.is_ok()); } #[test] fn enforce_rejects_wrong_token() { - let res = enforce_ws_auth( - Some("thq, thq-auth-wrong"), - &AuthConfig { - token: Some("secret".into()), - required: true, - }, - ); - + let res = enforce_ws_auth(Some("thq, thq-auth-wrong"), &scoped_auth()); assert_eq!(res.unwrap_err(), AuthError::TokenMismatch); } - // REST API tests - - #[tokio::test] - async fn post_location_success() { - let app = test_router(); - - let payload = json!({ - "device": "test-device", - "state": "moving", - "lineId": 1, - "coords": { - "latitude": 35.6812, - "longitude": 139.7671, - "accuracy": 10.0, - "speed": 50.0 - }, - "timestamp": 1234567890 - }); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/location") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - - let body = to_bytes(response.into_body()).await.unwrap(); - let v: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(v["ok"], true); - assert!(v["id"].is_string()); + #[test] + fn enforce_rejects_non_observer_tokens() { + for token in ["events-secret", "telemetry-secret"] { + let res = enforce_ws_auth(Some(&format!("thq, thq-auth-{token}")), &scoped_auth()); + assert_eq!(res.unwrap_err(), AuthError::TokenMismatch); + } } - #[tokio::test] - async fn post_location_with_custom_id() { - let app = test_router(); - - let payload = json!({ - "id": "custom-id-123", - "device": "test-device", - "state": "arrived", - "stationId": 42, - "lineId": 1, - "coords": { - "latitude": 35.6812, - "longitude": 139.7671 - }, - "timestamp": 1234567890 - }); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/location") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - - let body = to_bytes(response.into_body()).await.unwrap(); - let v: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(v["ok"], true); - assert_eq!(v["id"], "custom-id-123"); - } + // GraphQL mutations over HTTP #[tokio::test] - async fn post_location_warns_on_low_accuracy() { - let app = test_router(); - - let payload = json!({ - "device": "test-device", - "state": "moving", - "lineId": 1, - "coords": { - "latitude": 35.6812, - "longitude": 139.7671, - "accuracy": 150.0, - "speed": 50.0 - }, - "timestamp": 1234567890 - }); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/location") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .unwrap(), - ) - .await - .unwrap(); + async fn graphql_mutation_broadcasts_log_event_when_auth_disabled() { + let state = state_with_auth(open_auth()); + let hub = state.hub.clone(); + let app = graphql_router(state); + let response = app.oneshot(send_log_event_request(None)).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); - let body = to_bytes(response.into_body()).await.unwrap(); - let v: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(v["ok"], true); - assert!(v["warning"].as_str().unwrap().contains("accuracy")); + let v = body_json(response).await; + assert!(v["errors"].is_null(), "errors: {}", v["errors"]); + assert!(v["data"]["sendLogEvent"]["id"].is_string()); + + let snapshot = hub.snapshot().await; + assert_eq!(snapshot.len(), 1); + let msg: Value = serde_json::from_str(&snapshot[0]).unwrap(); + assert_eq!(msg["type"], "log"); + assert_eq!(msg["log"]["message"], "Hello, world!"); } #[tokio::test] - async fn post_location_rejects_invalid_latitude() { - let app = test_router(); - - let payload = json!({ - "device": "test-device", - "state": "moving", - "lineId": 1, - "coords": { - "latitude": 91.0, - "longitude": 139.7671 - }, - "timestamp": 1234567890 - }); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/location") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + async fn log_event_rejects_missing_auth() { + let state = state_with_auth(scoped_auth()); + let hub = state.hub.clone(); + let app = graphql_router(state); - let body = to_bytes(response.into_body()).await.unwrap(); - let v: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(v["ok"], false); - assert!(v["error"].as_str().unwrap().contains("out of range")); + let response = app.oneshot(send_log_event_request(None)).await.unwrap(); + let v = body_json(response).await; + assert!(v["errors"][0]["message"] + .as_str() + .unwrap() + .contains("unauthorized")); + assert!(hub.snapshot().await.is_empty()); } #[tokio::test] - async fn post_location_rejects_negative_accuracy() { - let app = test_router(); - - let payload = json!({ - "device": "test-device", - "state": "moving", - "lineId": 1, - "coords": { - "latitude": 35.6812, - "longitude": 139.7671, - "accuracy": -1.0 - }, - "timestamp": 1234567890 - }); + async fn log_event_accepts_events_token() { + let state = state_with_auth(scoped_auth()); + let hub = state.hub.clone(); + let app = graphql_router(state); let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/location") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .unwrap(), - ) + .oneshot(send_log_event_request(Some("Bearer events-secret"))) .await .unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - let body = to_bytes(response.into_body()).await.unwrap(); - let v: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(v["ok"], false); - assert!(v["error"].as_str().unwrap().contains("accuracy")); + let v = body_json(response).await; + assert!(v["errors"].is_null(), "errors: {}", v["errors"]); + assert_eq!(hub.snapshot().await.len(), 1); } #[tokio::test] - async fn post_location_drops_station_id_when_moving() { - let state = test_state(); + async fn log_event_accepts_telemetry_token() { + let state = state_with_auth(scoped_auth()); let hub = state.hub.clone(); - let app = Router::new() - .route("/api/location", post(post_location)) - .with_state(state); - - let payload = json!({ - "device": "test-device", - "state": "moving", - "stationId": 42, - "lineId": 1, - "coords": { - "latitude": 35.6812, - "longitude": 139.7671 - }, - "timestamp": 1234567890 - }); - - let _response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/location") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .unwrap(), - ) - .await - .unwrap(); - - let snapshot = hub.snapshot().await; - assert_eq!(snapshot.len(), 1); - let v: Value = serde_json::from_str(&snapshot[0]).unwrap(); - assert!(v["station_id"].is_null()); - } - - #[tokio::test] - async fn post_log_success() { - let app = test_router(); - - let payload = json!({ - "device": "test-device", - "timestamp": 1234567890, - "log": { - "type": "app", - "level": "info", - "message": "Hello, world!" - } - }); + let app = graphql_router(state); let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/log") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .unwrap(), - ) + .oneshot(send_log_event_request(Some("Bearer telemetry-secret"))) .await .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - - let body = to_bytes(response.into_body()).await.unwrap(); - let v: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(v["ok"], true); - assert!(v["id"].is_string()); + let v = body_json(response).await; + assert!(v["errors"].is_null(), "errors: {}", v["errors"]); + assert_eq!(hub.snapshot().await.len(), 1); } #[tokio::test] - async fn post_log_rejects_empty_message() { - let app = test_router(); - - let payload = json!({ - "device": "test-device", - "timestamp": 1234567890, - "log": { - "type": "app", - "level": "info", - "message": " " - } - }); + async fn log_event_rejects_observer_token() { + let state = state_with_auth(scoped_auth()); + let hub = state.hub.clone(); + let app = graphql_router(state); let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/log") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .unwrap(), - ) + .oneshot(send_log_event_request(Some("Bearer observer-secret"))) .await .unwrap(); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - let body = to_bytes(response.into_body()).await.unwrap(); - let v: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(v["ok"], false); - assert!(v["error"].as_str().unwrap().contains("message")); + let v = body_json(response).await; + assert!(v["errors"][0]["message"] + .as_str() + .unwrap() + .contains("unauthorized")); + assert!(hub.snapshot().await.is_empty()); } #[tokio::test] - async fn post_log_broadcasts_to_hub() { - let state = test_state(); + async fn location_accepts_telemetry_token() { + let state = state_with_auth(scoped_auth()); let hub = state.hub.clone(); - let app = Router::new() - .route("/api/log", post(post_log)) - .with_state(state); - - let payload = json!({ - "device": "test-device", - "timestamp": 1234567890, - "log": { - "type": "system", - "level": "warn", - "message": "Test warning" - } - }); - - let _response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/log") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .unwrap(), - ) + let app = graphql_router(state); + + let response = app + .oneshot(send_location_request(Some("Bearer telemetry-secret"))) .await .unwrap(); + let v = body_json(response).await; + assert!(v["errors"].is_null(), "errors: {}", v["errors"]); + assert!(v["data"]["sendLocation"]["id"].is_string()); let snapshot = hub.snapshot().await; assert_eq!(snapshot.len(), 1); - let v: Value = serde_json::from_str(&snapshot[0]).unwrap(); - assert_eq!(v["type"], "log"); - assert_eq!(v["log"]["message"], "Test warning"); - } - - // REST API auth tests - - fn auth_required_state() -> AppState { - AppState { - hub: Arc::new(TelemetryHub::new(10)), - storage: Storage::default(), - auth: AuthConfig { - token: Some("secret-token".into()), - required: true, - }, - schema: build_schema(Storage::default()), - segmenter: SegmentEstimator::new(LineTopology::empty()), - } - } - - fn auth_required_router() -> Router { - Router::new() - .route("/api/location", post(post_location)) - .route("/api/log", post(post_log)) - .with_state(auth_required_state()) + let msg: Value = serde_json::from_str(&snapshot[0]).unwrap(); + assert_eq!(msg["type"], "location_update"); } #[tokio::test] - async fn rest_api_rejects_missing_auth() { - let app = auth_required_router(); - - let payload = json!({ - "device": "test-device", - "state": "moving", - "lineId": 1, - "coords": { "latitude": 35.0, "longitude": 139.0 }, - "timestamp": 123 - }); + async fn location_rejects_events_token() { + let state = state_with_auth(scoped_auth()); + let hub = state.hub.clone(); + let app = graphql_router(state); let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/location") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .unwrap(), - ) + .oneshot(send_location_request(Some("Bearer events-secret"))) .await .unwrap(); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - - let body = to_bytes(response.into_body()).await.unwrap(); - let v: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(v["ok"], false); - assert!(v["error"].as_str().unwrap().contains("Authorization")); + let v = body_json(response).await; + assert!(v["errors"][0]["message"] + .as_str() + .unwrap() + .contains("telemetry")); + assert!(hub.snapshot().await.is_empty()); } #[tokio::test] - async fn rest_api_rejects_wrong_token() { - let app = auth_required_router(); - - let payload = json!({ - "device": "test-device", - "state": "moving", - "lineId": 1, - "coords": { "latitude": 35.0, "longitude": 139.0 }, - "timestamp": 123 - }); + async fn location_rejects_observer_token() { + let state = state_with_auth(scoped_auth()); + let hub = state.hub.clone(); + let app = graphql_router(state); let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/location") - .header("content-type", "application/json") - .header("authorization", "Bearer wrong-token") - .body(Body::from(payload.to_string())) - .unwrap(), - ) + .oneshot(send_location_request(Some("Bearer observer-secret"))) .await .unwrap(); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - - let body = to_bytes(response.into_body()).await.unwrap(); - let v: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(v["ok"], false); - assert!(v["error"].as_str().unwrap().contains("invalid")); + let v = body_json(response).await; + assert!(!v["errors"].is_null()); + assert!(hub.snapshot().await.is_empty()); } - #[tokio::test] - async fn rest_api_accepts_correct_token() { - let app = auth_required_router(); - - let payload = json!({ - "device": "test-device", - "state": "moving", - "lineId": 1, - "coords": { "latitude": 35.0, "longitude": 139.0 }, - "timestamp": 123 - }); - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/location") - .header("content-type", "application/json") - .header("authorization", "Bearer secret-token") - .body(Body::from(payload.to_string())) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); + #[test] + fn mutation_auth_grants_everything_when_disabled() { + let auth = mutation_auth(&HeaderMap::new(), &open_auth()); + assert!(auth.can_send_events); + assert!(auth.can_send_location); + } - let body = to_bytes(response.into_body()).await.unwrap(); - let v: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(v["ok"], true); + #[test] + fn mutation_auth_denies_missing_header() { + let auth = mutation_auth(&HeaderMap::new(), &scoped_auth()); + assert!(!auth.can_send_events); + assert!(!auth.can_send_location); } } From 80f79e0c6ddf30d08c0eb9752ffd9da737ce4068 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 6 Jul 2026 13:44:38 +0900 Subject: [PATCH 03/13] =?UTF-8?q?React=20+=20TanStack=20Query=20=E5=90=91?= =?UTF-8?q?=E3=81=91=E6=8E=A5=E7=B6=9A=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/react-tanstack-query.md: GraphQL でのイベント送信・集計取得 - docs/react-websocket-observer.md: WebSocket での観測(購読) Co-Authored-By: Claude Fable 5 --- docs/react-tanstack-query.md | 325 +++++++++++++++++++++++++++++++ docs/react-websocket-observer.md | 259 ++++++++++++++++++++++++ 2 files changed, 584 insertions(+) create mode 100644 docs/react-tanstack-query.md create mode 100644 docs/react-websocket-observer.md diff --git a/docs/react-tanstack-query.md b/docs/react-tanstack-query.md new file mode 100644 index 0000000..90156ca --- /dev/null +++ b/docs/react-tanstack-query.md @@ -0,0 +1,325 @@ +# React.js + TanStack Query で thq-server に接続する + +このドキュメントでは、React アプリケーションから [TanStack Query](https://tanstack.com/query/latest)(旧 React Query)を使って thq-server の GraphQL API に接続する方法を説明します。 + +## 前提 + +thq-server の GraphQL API はエンドポイント `POST /graphql` で公開されています(Playground: `GET /graphql`)。 + +| 操作 | 種別 | 認証 | +|---|---|---| +| `sendLogEvent` | Mutation | イベント用または遠隔測定用トークン | +| `sendLocation` | Mutation | 遠隔測定用トークンのみ | +| `accuracyByLine` | Query | 不要 | + +Mutation の認証は `Authorization: Bearer ` ヘッダで行います。 + +| トークン | できること | +|---|---| +| イベント用(`THQ_EVENTS_AUTH_TOKEN`) | `sendLogEvent` のみ | +| 遠隔測定用(`THQ_TELEMETRY_AUTH_TOKEN`) | `sendLogEvent` + `sendLocation` | + +> **セキュリティ上の注意**: ブラウザ向けにビルドした JavaScript に埋め込んだトークンは、利用者全員から見えます。イベント用・遠隔測定用トークンを Web フロントエンドに直接埋め込むのは避け、ネイティブアプリや自前のバックエンド(BFF)経由で扱ってください。認証不要な `accuracyByLine` の表示だけであればトークンは一切不要です。 + +> **CORS の注意**: 現状の thq-server は CORS ヘッダを返しません。ブラウザから `POST /graphql` を叩く場合は、フロントエンドを同一オリジンで配信するか、リバースプロキシ(nginx 等)を挟んでください。 + +## セットアップ + +```bash +npm install @tanstack/react-query +``` + +アプリのルートに `QueryClientProvider` を設定します。 + +```tsx +// main.tsx +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { App } from "./App"; + +const queryClient = new QueryClient(); + +export function Root() { + return ( + + + + ); +} +``` + +## GraphQL クライアント + +専用ライブラリは不要で、`fetch` の薄いラッパーで十分です。GraphQL はエラーでも HTTP 200 を返すため、`errors` 配列の確認が必須です。 + +```ts +// lib/graphql.ts +const GRAPHQL_ENDPOINT = import.meta.env.VITE_THQ_GRAPHQL_URL ?? "/graphql"; + +export async function gqlRequest>( + query: string, + variables?: TVariables, + token?: string, +): Promise { + const res = await fetch(GRAPHQL_ENDPOINT, { + method: "POST", + headers: { + "content-type": "application/json", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ query, variables }), + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + const json = await res.json(); + if (json.errors?.length) { + // 認証エラーは "unauthorized: ..." というメッセージで返る + throw new Error(json.errors.map((e: { message: string }) => e.message).join("; ")); + } + return json.data as TData; +} +``` + +## Query: 回線ごとの精度レポート(`accuracyByLine`) + +GraphQL Query は認証不要です。`useQuery` でそのまま取得できます。 + +```ts +// hooks/useAccuracyByLine.ts +import { useQuery } from "@tanstack/react-query"; +import { gqlRequest } from "../lib/graphql"; + +const ACCURACY_BY_LINE = /* GraphQL */ ` + query AccuracyByLine( + $lineId: ID! + $from: DateTime! + $to: DateTime! + $bucketSize: TimeBucketSize! + $limit: Int + ) { + accuracyByLine(lineId: $lineId, from: $from, to: $to, bucketSize: $bucketSize, limit: $limit) { + lineId + buckets { + bucketStart + bucketEnd + avgAccuracy + p90Accuracy + sampleCount + avgSpeed + maxSpeed + } + } + } +`; + +export interface AccuracyBucket { + bucketStart: string; + bucketEnd: string; + avgAccuracy: number; + p90Accuracy: number; + sampleCount: number; + avgSpeed: number | null; + maxSpeed: number | null; +} + +interface AccuracyByLineData { + accuracyByLine: { lineId: string; buckets: AccuracyBucket[] }; +} + +export function useAccuracyByLine(params: { + lineId: string; + from: string; // ISO 8601 (例: "2026-07-01T00:00:00Z") + to: string; + bucketSize: "MINUTE" | "HOUR" | "DAY"; + limit?: number; +}) { + return useQuery({ + queryKey: ["accuracyByLine", params], + queryFn: () => gqlRequest(ACCURACY_BY_LINE, params), + staleTime: 60_000, // 集計データなので 1 分程度はキャッシュを新鮮とみなす + }); +} +``` + +使用例: + +```tsx +function AccuracyChart() { + const { data, isPending, error } = useAccuracyByLine({ + lineId: "11302", + from: "2026-07-01T00:00:00Z", + to: "2026-07-06T00:00:00Z", + bucketSize: "HOUR", + }); + + if (isPending) return

読み込み中…

; + if (error) return

エラー: {error.message}

; + + return ( +
    + {data.accuracyByLine.buckets.map((b) => ( +
  • + {b.bucketStart}: 平均精度 {b.avgAccuracy.toFixed(1)} m({b.sampleCount} 件) +
  • + ))} +
+ ); +} +``` + +バケットサイズごとの最大期間(MINUTE ≤ 7 日、HOUR ≤ 90 日、DAY ≤ 365 日)を超えるとエラーになる点に注意してください。 + +## Mutation: ログイベント送信(`sendLogEvent`) + +イベント用または遠隔測定用トークンが必要です。 + +```ts +// hooks/useSendLogEvent.ts +import { useMutation } from "@tanstack/react-query"; +import { gqlRequest } from "../lib/graphql"; + +const SEND_LOG_EVENT = /* GraphQL */ ` + mutation SendLogEvent($input: LogEventInput!) { + sendLogEvent(input: $input) { + id + } + } +`; + +export interface LogEventInput { + id?: string; // 省略時はサーバーが UUID を採番 + device: string; + timestamp: number; // Unix ミリ秒 (Date.now()) + type: "SYSTEM" | "APP" | "CLIENT"; + level: "DEBUG" | "INFO" | "WARN" | "ERROR"; + message: string; // 空文字・空白のみはサーバーが拒否 +} + +export function useSendLogEvent(token: string) { + return useMutation({ + mutationFn: (input: LogEventInput) => + gqlRequest<{ sendLogEvent: { id: string } }>(SEND_LOG_EVENT, { input }, token), + }); +} +``` + +使用例: + +```tsx +const sendLog = useSendLogEvent(eventsToken); + +sendLog.mutate({ + device: "device-001", + timestamp: Date.now(), + type: "APP", + level: "INFO", + message: "GPS signal acquired", +}); +``` + +> `timestamp` はスキーマ上 `Int!` と表示されますが、サーバー内部は 64bit 整数のため `Date.now()` の値(約 1.7 兆)をそのまま渡して問題ありません。 + +### 再送とべき等性 + +`id` にクライアント側で生成した UUID を渡しておくと、同じ `id` の再送はサーバー側で無視(`ON CONFLICT DO NOTHING`)されるため、ネットワークエラー時のリトライを安全に行えます。 + +```ts +sendLog.mutate({ + id: crypto.randomUUID(), + device: "device-001", + timestamp: Date.now(), + type: "CLIENT", + level: "ERROR", + message: "Connection lost", +}); +``` + +## Mutation: 位置情報送信(`sendLocation`) + +**遠隔測定用トークンのみ**が実行できます。イベント用トークンでは `unauthorized: a valid telemetry bearer token is required` エラーになります。 + +```ts +// hooks/useSendLocation.ts +import { useMutation } from "@tanstack/react-query"; +import { gqlRequest } from "../lib/graphql"; + +const SEND_LOCATION = /* GraphQL */ ` + mutation SendLocation($input: LocationEventInput!) { + sendLocation(input: $input) { + id + warning + } + } +`; + +export interface LocationEventInput { + id?: string; + device: string; + state: "ARRIVED" | "APPROACHING" | "PASSING" | "MOVING"; + stationId?: number; // ARRIVED / PASSING のときのみ有効。MOVING / APPROACHING では無視される + lineId: number; + coords: { + latitude: number; // -90〜90 + longitude: number; // -180〜180 + accuracy?: number; // メートル。負値はエラー + speed?: number; // km/h。負値は「不明」として NULL 扱い + }; + timestamp: number; // Unix ミリ秒 + batteryLevel?: number; // 0.0〜1.0 + batteryState?: "UNKNOWN" | "UNPLUGGED" | "CHARGING" | "FULL"; +} + +interface SendLocationData { + sendLocation: { id: string; warning: string | null }; +} + +export function useSendLocation(token: string) { + return useMutation({ + mutationFn: (input: LocationEventInput) => + gqlRequest(SEND_LOCATION, { input }, token), + onSuccess: (data) => { + if (data.sendLocation.warning) { + // 例: "reported accuracy 150.0m exceeds threshold 100m" + console.warn("thq-server warning:", data.sendLocation.warning); + } + }, + }); +} +``` + +Geolocation API と組み合わせる例: + +```tsx +const sendLocation = useSendLocation(telemetryToken); + +useEffect(() => { + const watchId = navigator.geolocation.watchPosition((pos) => { + sendLocation.mutate({ + device: "device-001", + state: "MOVING", + lineId: 11302, + coords: { + latitude: pos.coords.latitude, + longitude: pos.coords.longitude, + accuracy: pos.coords.accuracy, + speed: pos.coords.speed != null ? pos.coords.speed * 3.6 : undefined, // m/s → km/h + }, + timestamp: pos.timestamp, + }); + }); + return () => navigator.geolocation.clearWatch(watchId); +}, []); +``` + +## 接続先の設定(環境変数) + +Vite の場合: + +```bash +# .env.local (フロントエンド側リポジトリ) +VITE_THQ_GRAPHQL_URL=https://thq.example.com/graphql +``` + +繰り返しになりますが、`VITE_` プレフィックスの環境変数はビルド成果物に埋め込まれ公開されます。イベント用・遠隔測定用トークンはビルドに埋め込まず、BFF 等のサーバーサイドで保持してください。 diff --git a/docs/react-websocket-observer.md b/docs/react-websocket-observer.md new file mode 100644 index 0000000..2f0af68 --- /dev/null +++ b/docs/react-websocket-observer.md @@ -0,0 +1,259 @@ +# React.js + TanStack Query で thq-server のデータを WebSocket 観測する + +このドキュメントでは、React アプリケーションから thq-server の WebSocket エンドポイントに接続し、登録されたイベント(位置情報・ログ)をリアルタイムに観測する方法を説明します。イベントの送信方法(GraphQL)については [react-tanstack-query.md](./react-tanstack-query.md) を参照してください。 + +## 前提 + +| 項目 | 内容 | +|---|---| +| エンドポイント | `ws://:/ws`(TLS 環境では `wss://`) | +| 認証 | 観測用トークン(`THQ_OBSERVER_AUTH_TOKEN`)のみ | +| 方向 | サーバー → クライアントの配信専用(送信は GraphQL 側で行う) | + +WebSocket を購読できるのは**観測用トークンだけ**です。イベント用・遠隔測定用トークンでは接続がハンドシェイク時に HTTP 401 で拒否されます。逆に観測用トークンでは GraphQL Mutation を実行できないため、閲覧用クライアントに配るトークンとして安全に分離されています。 + +> ブラウザ向けビルドに埋め込んだトークンは利用者から見えます。観測用トークンは読み取り専用スコープとはいえ、公開サイトに埋め込む場合は漏えい時にローテーションできる運用にしておいてください。 + +## 認証の仕組み + +ブラウザの `WebSocket` API は任意の HTTP ヘッダを付与できないため、認証は **サブプロトコル**(コンストラクタの第 2 引数)で行います。`thq` と `thq-auth-<トークン>` の 2 つを渡してください。 + +```ts +const ws = new WebSocket("wss://thq.example.com/ws", [ + "thq", + `thq-auth-${observerToken}`, +]); +``` + +認証に成功するとサーバーは `Sec-WebSocket-Protocol: thq` を返して接続が確立します。トークンが欠けている・間違っている場合はハンドシェイクが 401 で失敗し、ブラウザからは即座の `close` イベントとして観測されます(ステータスコードは JavaScript からは読めません)。 + +## プロトコル + +### 1. 購読開始 + +接続後、`subscribe` メッセージを 1 回送ります。`device` は観測側の識別名で、サーバーのログに記録されます。 + +```json +{ "type": "subscribe", "device": "web-dashboard" } +``` + +### 2. スナップショット → リアルタイム配信 + +購読直後に、サーバーがメモリ上に保持している直近のイベント(リングバッファ、デフォルト 1000 件)がまとめて送られてきます。その後は新しいイベントが発生するたびにリアルタイムで配信されます。 + +再接続するとスナップショットが再送されるため、クライアント側では **`id` による重複排除**を入れておくと堅牢です。 + +### 3. 受信メッセージの形式 + +すべてテキストフレームの JSON で、`type` フィールドで種別を判定します(フィールド名は snake_case です)。 + +**location_update** — `sendLocation` Mutation で登録された位置情報 + +```json +{ + "type": "location_update", + "id": "uuid", + "device": "device-001", + "state": "arrived | approaching | passing | moving", + "station_id": 1130201, + "line_id": 11302, + "coords": { "latitude": 35.6812, "longitude": 139.7671, "accuracy": 5.0, "speed": 45.0 }, + "timestamp": 1706000000000, + "segment_id": "11302:1130201:1130202", + "from_station_id": 1130201, + "to_station_id": 1130202, + "battery_level": 0.85, + "battery_state": 2 +} +``` + +`segment_id` / `from_station_id` / `to_station_id` はサーバー側の区間推定によって付与されます(トポロジ未設定時や推定不能時は `null`)。 + +**log** — `sendLogEvent` Mutation で登録されたログ + +```json +{ + "type": "log", + "id": "uuid", + "device": "device-001", + "timestamp": 1706000000000, + "log": { "type": "system | app | client", "level": "debug | info | warn | error", "message": "..." } +} +``` + +**error** — プロトコルエラーの通知(不正な JSON を送った場合など) + +```json +{ + "type": "error", + "error": { "type": "websocket_message_error | json_parse_error", "reason": "..." } +} +``` + +なお、バイナリフレームはサーバーに拒否されます。Ping/Pong はブラウザが自動で処理するため意識する必要はありません。 + +## TanStack Query との組み合わせ + +WebSocket の受信データを `queryClient.setQueryData` でキャッシュに書き込み、表示側は通常の `useQuery` で読む構成が定石です。フェッチは発生しないので `staleTime: Infinity` にします。 + +```ts +// hooks/useTelemetryFeed.ts +import { useEffect } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +export interface LocationUpdateEvent { + type: "location_update"; + id: string; + device: string; + state: "arrived" | "approaching" | "passing" | "moving"; + station_id: number | null; + line_id: number; + coords: { + latitude: number; + longitude: number; + accuracy: number | null; + speed: number | null; + }; + timestamp: number; + segment_id: string | null; + from_station_id: number | null; + to_station_id: number | null; + battery_level: number | null; + battery_state: 0 | 1 | 2 | 3 | null; // 0: UNKNOWN, 1: UNPLUGGED, 2: CHARGING, 3: FULL +} + +export interface LogEvent { + type: "log"; + id: string; + device: string; + timestamp: number; + log: { + type: "system" | "app" | "client"; + level: "debug" | "info" | "warn" | "error"; + message: string; + }; +} + +export type TelemetryEvent = LocationUpdateEvent | LogEvent; + +const FEED_KEY = ["telemetryFeed"] as const; +const MAX_EVENTS = 1000; +const MAX_RETRIES = 10; + +export function useTelemetryFeed( + wsUrl: string, + observerToken: string, + device = "web-dashboard", +) { + const queryClient = useQueryClient(); + + useEffect(() => { + let ws: WebSocket | null = null; + let retryTimer: ReturnType; + let retries = 0; + let disposed = false; + + const connect = () => { + ws = new WebSocket(wsUrl, ["thq", `thq-auth-${observerToken}`]); + + ws.onopen = () => { + retries = 0; + ws?.send(JSON.stringify({ type: "subscribe", device })); + }; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.type !== "location_update" && msg.type !== "log") { + if (msg.type === "error") { + console.warn("thq-server error:", msg.error); + } + return; + } + + queryClient.setQueryData(FEED_KEY, (prev = []) => { + // 再接続時のスナップショット再送に備えて id で重複排除する + if (prev.some((e) => e.id === msg.id)) { + return prev; + } + return [...prev, msg].slice(-MAX_EVENTS); + }); + }; + + ws.onclose = () => { + // 認証失敗(401)もハンドシェイク失敗としてここに来るため、 + // 無限リトライにならないよう回数上限と指数バックオフを入れる + if (disposed || retries >= MAX_RETRIES) return; + const delay = Math.min(1000 * 2 ** retries, 30_000); + retries += 1; + retryTimer = setTimeout(connect, delay); + }; + }; + + connect(); + + return () => { + disposed = true; + clearTimeout(retryTimer); + ws?.close(); + }; + }, [wsUrl, observerToken, device, queryClient]); + + return useQuery({ + queryKey: FEED_KEY, + queryFn: () => [], + initialData: [], + staleTime: Infinity, // データは WebSocket 側から書き込むため再フェッチ不要 + gcTime: Infinity, + }); +} +``` + +### 使用例 + +```tsx +function LiveMap() { + const { data: events } = useTelemetryFeed( + import.meta.env.VITE_THQ_WS_URL, + import.meta.env.VITE_THQ_OBSERVER_TOKEN, + ); + + // 端末ごとの最新位置だけを表示する + const latestByDevice = new Map(); + for (const e of events) { + if (e.type === "location_update") { + latestByDevice.set(e.device, e); + } + } + + return ( +
    + {[...latestByDevice.values()].map((e) => ( +
  • + {e.device}: ({e.coords.latitude.toFixed(4)}, {e.coords.longitude.toFixed(4)}) [{e.state}] + {e.segment_id && ` 区間: ${e.segment_id}`} +
  • + ))} +
+ ); +} +``` + +ログだけを流したい場合も同じフィードから絞り込めます。 + +```tsx +const logs = events.filter((e): e is LogEvent => e.type === "log"); +``` + +## 環境変数(Vite の場合) + +```bash +# .env.local (フロントエンド側リポジトリ) +VITE_THQ_WS_URL=wss://thq.example.com/ws +VITE_THQ_OBSERVER_TOKEN=<観測用トークン> +``` + +## 運用上の注意 + +- **切断は日常的に起きる**ものとして扱ってください。上記フックのようにバックオフ付きで再接続し、スナップショット再送は `id` の重複排除で吸収します。 +- リングバッファの件数はサーバー側の `ring_size`(デフォルト 1000)で決まります。接続前のイベントをそれ以上さかのぼることはできないため、履歴が必要な場合は GraphQL の `accuracyByLine`(集計)や DB を参照してください。 +- WebSocket は CORS の制約を受けないため、GraphQL と違いリバースプロキシなしでクロスオリジン接続できます。ただし `wss://`(TLS)を使わないと HTTPS ページからは接続できません(混在コンテンツとしてブロックされます)。 From 3857240136f5fc6e450087a5ae5ee028796f8689 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 6 Jul 2026 13:56:40 +0900 Subject: [PATCH 04/13] =?UTF-8?q?Mutation=E5=85=A5=E5=8A=9B=E3=81=AEid?= =?UTF-8?q?=E3=82=92=E5=BB=83=E6=AD=A2=E3=81=97=E3=80=81=E5=BF=85=E9=A0=88?= =?UTF-8?q?=E3=81=AEsessionId=E3=82=92=E5=B0=8E=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sendLogEvent / sendLocation の入力から id を削除し、 クライアント生成の一意な文字列 sessionId を必須化 - イベントIDは常にサーバー側でUUIDを採番 - session_id を WS 配信メッセージと DB 両テーブルに追加 (既存テーブルにはべき等な ADD COLUMN で対応) - あわせて sendLogEvent の device を匿名送信のため任意化 (log_events.device を NULL 許容に変更) Co-Authored-By: Claude Fable 5 --- README.md | 20 +++-- docs/react-tanstack-query.md | 48 ++++++----- docs/react-websocket-observer.md | 12 ++- src/domain.rs | 11 ++- src/graphql.rs | 137 ++++++++++++++++++++++++------- src/segment.rs | 4 + src/server.rs | 12 ++- src/storage.rs | 22 ++++- 8 files changed, 199 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 1529e24..d0461c3 100644 --- a/README.md +++ b/README.md @@ -113,24 +113,28 @@ Requires the events token or the telemetry token. ```graphql mutation { sendLogEvent(input: { - device: "device-001" + sessionId: "d0f7..." # client-generated unique session identifier + device: "device-001" # optional — omit to submit anonymously timestamp: 1706000000000 type: APP # SYSTEM | APP | CLIENT level: INFO # DEBUG | INFO | WARN | ERROR message: "GPS signal acquired" }) { - id + sessionId } } ``` +`sessionId` is a mandatory, client-generated unique identifier (any string). Event IDs are always generated server-side. `device` is optional so that log events can be submitted anonymously; omitted values are broadcast and stored as `null`. + #### `sendLocation` — Submit a location update -Requires the telemetry token; the events token is deliberately not enough to publish positional data. The update is validated, annotated with segment information, broadcast to WebSocket subscribers, and persisted. +Requires the telemetry token; the events token is deliberately not enough to publish positional data. Unlike `sendLogEvent`, `device` is mandatory here. The update is validated, annotated with segment information, broadcast to WebSocket subscribers, and persisted. ```graphql mutation { sendLocation(input: { + sessionId: "d0f7..." # client-generated unique session identifier device: "device-001" state: MOVING # ARRIVED | APPROACHING | PASSING | MOVING lineId: 11302 @@ -142,7 +146,7 @@ mutation { } timestamp: 1706000000000 }) { - id + sessionId warning # set when e.g. the reported accuracy exceeds 100 m } } @@ -209,6 +213,7 @@ Once connected, the server broadcasts `location_update` and `log` messages in re { "id": "uuid", "type": "location_update", + "session_id": "client-generated-session-id", "device": "device-id", "state": "arrived | approaching | passing | moving", "station_id": 123, @@ -229,6 +234,7 @@ Once connected, the server broadcasts `location_update` and `log` messages in re { "id": "uuid", "type": "log", + "session_id": "client-generated-session-id", "device": "device-id", "timestamp": 1234567890, "log": { @@ -239,6 +245,8 @@ Once connected, the server broadcasts `location_update` and `log` messages in re } ``` +`device` is `null` when the event was submitted anonymously. + **error** ```json @@ -257,8 +265,8 @@ When `database_url` / `DATABASE_URL` is provided, the server connects to Postgre | Table | Key columns | |---|---| -| `location_logs` | `id`, `device`, `state`, `station_id`, `line_id`, `segment_id`, `from_station_id`, `to_station_id`, `latitude`, `longitude`, `accuracy`, `speed`, `battery_level`, `battery_state`, `timestamp`, `recorded_at` | -| `log_events` | `id`, `device`, `log_type`, `log_level`, `message`, `timestamp`, `recorded_at` | +| `location_logs` | `id`, `session_id`, `device`, `state`, `station_id`, `line_id`, `segment_id`, `from_station_id`, `to_station_id`, `latitude`, `longitude`, `accuracy`, `speed`, `battery_level`, `battery_state`, `timestamp`, `recorded_at` | +| `log_events` | `id`, `session_id`, `device`, `log_type`, `log_level`, `message`, `timestamp`, `recorded_at` | Without a `database_url` the server still accepts WebSocket traffic but does not persist messages. diff --git a/docs/react-tanstack-query.md b/docs/react-tanstack-query.md index 90156ca..21cbdd2 100644 --- a/docs/react-tanstack-query.md +++ b/docs/react-tanstack-query.md @@ -183,14 +183,14 @@ import { gqlRequest } from "../lib/graphql"; const SEND_LOG_EVENT = /* GraphQL */ ` mutation SendLogEvent($input: LogEventInput!) { sendLogEvent(input: $input) { - id + sessionId } } `; export interface LogEventInput { - id?: string; // 省略時はサーバーが UUID を採番 - device: string; + sessionId: string; // 必須。クライアント側で生成した一意な文字列(後述) + device?: string; // 匿名性確保のため省略可。省略時は null として配信・保存される timestamp: number; // Unix ミリ秒 (Date.now()) type: "SYSTEM" | "APP" | "CLIENT"; level: "DEBUG" | "INFO" | "WARN" | "ERROR"; @@ -200,7 +200,7 @@ export interface LogEventInput { export function useSendLogEvent(token: string) { return useMutation({ mutationFn: (input: LogEventInput) => - gqlRequest<{ sendLogEvent: { id: string } }>(SEND_LOG_EVENT, { input }, token), + gqlRequest<{ sendLogEvent: { sessionId: string } }>(SEND_LOG_EVENT, { input }, token), }); } ``` @@ -211,6 +211,7 @@ export function useSendLogEvent(token: string) { const sendLog = useSendLogEvent(eventsToken); sendLog.mutate({ + sessionId, device: "device-001", timestamp: Date.now(), type: "APP", @@ -219,26 +220,34 @@ sendLog.mutate({ }); ``` -> `timestamp` はスキーマ上 `Int!` と表示されますが、サーバー内部は 64bit 整数のため `Date.now()` の値(約 1.7 兆)をそのまま渡して問題ありません。 - -### 再送とべき等性 - -`id` にクライアント側で生成した UUID を渡しておくと、同じ `id` の再送はサーバー側で無視(`ON CONFLICT DO NOTHING`)されるため、ネットワークエラー時のリトライを安全に行えます。 +端末を特定されたくない場合は `device` を省略して匿名で送信できます: ```ts sendLog.mutate({ - id: crypto.randomUUID(), - device: "device-001", + sessionId, timestamp: Date.now(), - type: "CLIENT", - level: "ERROR", - message: "Connection lost", + type: "APP", + level: "INFO", + message: "started", }); ``` +> `timestamp` はスキーマ上 `Int!` と表示されますが、サーバー内部は 64bit 整数のため `Date.now()` の値(約 1.7 兆)をそのまま渡して問題ありません。 + +### sessionId の生成 + +`sessionId` はクライアント側で生成する一意な文字列で、両 Mutation で必須です。セッション(アプリ起動)ごとに 1 回生成して、そのセッション中のすべての送信で使い回す想定です。 + +```ts +// アプリ起動時に 1 回だけ生成する +const sessionId = crypto.randomUUID(); +``` + +なお、イベント自体の ID はサーバー側で常に UUID が採番されます。クライアントから ID を指定することはできないため、送信リクエストの再送はそれぞれ別イベントとして記録される点に注意してください。 + ## Mutation: 位置情報送信(`sendLocation`) -**遠隔測定用トークンのみ**が実行できます。イベント用トークンでは `unauthorized: a valid telemetry bearer token is required` エラーになります。 +**遠隔測定用トークンのみ**が実行できます。イベント用トークンでは `unauthorized: a valid telemetry bearer token is required` エラーになります。また、`sendLogEvent` と異なり **`device` は必須**です(位置情報は端末と紐付いていることが前提のため、匿名では送信できません)。 ```ts // hooks/useSendLocation.ts @@ -248,15 +257,15 @@ import { gqlRequest } from "../lib/graphql"; const SEND_LOCATION = /* GraphQL */ ` mutation SendLocation($input: LocationEventInput!) { sendLocation(input: $input) { - id + sessionId warning } } `; export interface LocationEventInput { - id?: string; - device: string; + sessionId: string; // 必須。クライアント側で生成した一意な文字列 + device: string; // 必須(sendLogEvent と異なり省略不可) state: "ARRIVED" | "APPROACHING" | "PASSING" | "MOVING"; stationId?: number; // ARRIVED / PASSING のときのみ有効。MOVING / APPROACHING では無視される lineId: number; @@ -272,7 +281,7 @@ export interface LocationEventInput { } interface SendLocationData { - sendLocation: { id: string; warning: string | null }; + sendLocation: { sessionId: string; warning: string | null }; } export function useSendLocation(token: string) { @@ -297,6 +306,7 @@ const sendLocation = useSendLocation(telemetryToken); useEffect(() => { const watchId = navigator.geolocation.watchPosition((pos) => { sendLocation.mutate({ + sessionId, device: "device-001", state: "MOVING", lineId: 11302, diff --git a/docs/react-websocket-observer.md b/docs/react-websocket-observer.md index 2f0af68..fea621a 100644 --- a/docs/react-websocket-observer.md +++ b/docs/react-websocket-observer.md @@ -53,6 +53,7 @@ const ws = new WebSocket("wss://thq.example.com/ws", [ { "type": "location_update", "id": "uuid", + "session_id": "client-generated-session-id", "device": "device-001", "state": "arrived | approaching | passing | moving", "station_id": 1130201, @@ -75,12 +76,15 @@ const ws = new WebSocket("wss://thq.example.com/ws", [ { "type": "log", "id": "uuid", + "session_id": "client-generated-session-id", "device": "device-001", "timestamp": 1706000000000, "log": { "type": "system | app | client", "level": "debug | info | warn | error", "message": "..." } } ``` +ログイベントは匿名で送信できるため、`device` が `null` の場合があります(`location_update` の `device` は常に非 null です)。 + **error** — プロトコルエラーの通知(不正な JSON を送った場合など) ```json @@ -103,7 +107,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; export interface LocationUpdateEvent { type: "location_update"; - id: string; + id: string; // サーバー採番のイベント ID(重複排除に使える) + session_id: string; // クライアント側で生成されたセッション ID device: string; state: "arrived" | "approaching" | "passing" | "moving"; station_id: number | null; @@ -124,8 +129,9 @@ export interface LocationUpdateEvent { export interface LogEvent { type: "log"; - id: string; - device: string; + id: string; // サーバー採番のイベント ID(重複排除に使える) + session_id: string; // クライアント側で生成されたセッション ID + device: string | null; // 匿名送信されたイベントは null timestamp: number; log: { type: "system" | "app" | "client"; diff --git a/src/domain.rs b/src/domain.rs index eca6de2..e204d3e 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -96,6 +96,8 @@ pub enum OutgoingMessage { #[derive(Debug, Clone, Serialize)] pub struct OutgoingLocation { pub id: String, + /// Client-generated unique session identifier. + pub session_id: String, pub device: String, pub state: MovementState, pub station_id: Option, @@ -120,7 +122,10 @@ pub struct OutgoingCoords { #[derive(Debug, Clone, Serialize)] pub struct OutgoingLog { pub id: String, - pub device: String, + /// Client-generated unique session identifier. + pub session_id: String, + /// None when the sender chose to stay anonymous. + pub device: Option, pub timestamp: u64, pub log: LogBody, } @@ -162,7 +167,8 @@ mod tests { fn outgoing_log_has_type_field() { let msg = OutgoingMessage::Log(OutgoingLog { id: "id1".into(), - device: "dev".into(), + session_id: "sess-1".into(), + device: Some("dev".into()), timestamp: 42, log: LogBody { r#type: LogType::App, @@ -182,6 +188,7 @@ mod tests { fn outgoing_location_has_type_field() { let msg = OutgoingMessage::LocationUpdate(OutgoingLocation { id: "id1".into(), + session_id: "sess-1".into(), device: "dev".into(), state: MovementState::Moving, station_id: Some(42), diff --git a/src/graphql.rs b/src/graphql.rs index ba6cf3b..0121232 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -186,10 +186,10 @@ impl QueryRoot { #[derive(InputObject)] pub struct LogEventInput { - /// Optional custom ID. A random UUID is generated when omitted. - pub id: Option, - /// Device identifier. - pub device: String, + /// Client-generated unique session identifier (arbitrary string). + pub session_id: String, + /// Device identifier. Optional so events can be submitted anonymously. + pub device: Option, /// Unix timestamp in milliseconds. pub timestamp: u64, #[graphql(name = "type")] @@ -200,7 +200,7 @@ pub struct LogEventInput { #[derive(SimpleObject)] pub struct SendLogEventPayload { - pub id: String, + pub session_id: String, } #[derive(InputObject)] @@ -215,8 +215,8 @@ pub struct CoordsInput { #[derive(InputObject)] pub struct LocationEventInput { - /// Optional custom ID. A random UUID is generated when omitted. - pub id: Option, + /// Client-generated unique session identifier (arbitrary string). + pub session_id: String, /// Device identifier. pub device: String, pub state: MovementState, @@ -233,7 +233,7 @@ pub struct LocationEventInput { #[derive(SimpleObject)] pub struct SendLocationPayload { - pub id: String, + pub session_id: String, /// Set when the update was accepted with a caveat (e.g. bad accuracy). pub warning: Option, } @@ -259,6 +259,10 @@ impl MutationRoot { ); } + if input.session_id.trim().is_empty() { + return Err("sessionId must not be empty".into()); + } + if input.message.trim().is_empty() { return Err("message must not be empty".into()); } @@ -270,9 +274,9 @@ impl MutationRoot { .data::() .map_err(|_| "storage is not configured")?; - let id = input.id.unwrap_or_else(|| Uuid::new_v4().to_string()); let log = OutgoingLog { - id: id.clone(), + id: Uuid::new_v4().to_string(), + session_id: input.session_id, device: input.device, timestamp: input.timestamp, log: LogBody { @@ -293,7 +297,9 @@ impl MutationRoot { tracing::error!(?err, "failed to persist log event"); } - Ok(SendLogEventPayload { id }) + Ok(SendLogEventPayload { + session_id: log.session_id, + }) } /// Submit a location update. Requires the telemetry token; the events @@ -312,6 +318,10 @@ impl MutationRoot { return Err("unauthorized: a valid telemetry bearer token is required".into()); } + if input.session_id.trim().is_empty() { + return Err("sessionId must not be empty".into()); + } + if !input.coords.latitude.is_finite() || !input.coords.longitude.is_finite() { return Err("latitude/longitude must be finite numbers".into()); } @@ -365,9 +375,9 @@ impl MutationRoot { .data::() .map_err(|_| "segment estimator is not configured")?; - let id = input.id.unwrap_or_else(|| Uuid::new_v4().to_string()); let loc = OutgoingLocation { - id: id.clone(), + id: Uuid::new_v4().to_string(), + session_id: input.session_id, device: input.device, state: input.state, station_id, @@ -409,7 +419,10 @@ impl MutationRoot { ) }); - Ok(SendLocationPayload { id, warning }) + Ok(SendLocationPayload { + session_id: loc.session_id, + warning, + }) } } @@ -470,18 +483,19 @@ mod tests { format!( r#"mutation {{ sendLocation(input: {{ + sessionId: "sess-1", device: "dev", state: {state}, lineId: 1, coords: {{ latitude: 35.6812, longitude: 139.7671{extra} }}, timestamp: 1706000000000 - }}) {{ id warning }} + }}) {{ sessionId warning }} }}"# ) } #[tokio::test] - async fn send_log_event_broadcasts_and_returns_id() { + async fn send_log_event_broadcasts_and_returns_session_id() { let hub = Arc::new(TelemetryHub::new(10)); let schema = test_schema(hub.clone()); @@ -489,13 +503,13 @@ mod tests { .execute(request( r#"mutation { sendLogEvent(input: { - id: "custom-id-123", + sessionId: "sess-abc", device: "dev", timestamp: 1706000000000, type: APP, level: INFO, message: "hello" - }) { id } + }) { sessionId } }"#, EVENTS_ONLY, )) @@ -503,39 +517,98 @@ mod tests { assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors); let data = resp.data.into_json().unwrap(); - assert_eq!(data["sendLogEvent"]["id"], "custom-id-123"); + assert_eq!(data["sendLogEvent"]["sessionId"], "sess-abc"); let snapshot = hub.snapshot().await; assert_eq!(snapshot.len(), 1); let v: serde_json::Value = serde_json::from_str(&snapshot[0]).unwrap(); assert_eq!(v["type"], "log"); + assert_eq!(v["session_id"], "sess-abc"); + // the event ID is always generated server-side + assert!(!v["id"].as_str().unwrap().is_empty()); assert_eq!(v["log"]["message"], "hello"); assert_eq!(v["timestamp"], 1706000000000u64); } #[tokio::test] - async fn send_log_event_generates_id_when_omitted() { + async fn send_log_event_accepts_anonymous_device() { let hub = Arc::new(TelemetryHub::new(10)); - let schema = test_schema(hub); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request( + r#"mutation { + sendLogEvent(input: { + sessionId: "sess-anon", + timestamp: 1, + type: APP, + level: INFO, + message: "anonymous hello" + }) { sessionId } + }"#, + EVENTS_ONLY, + )) + .await; + + assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors); + + let snapshot = hub.snapshot().await; + assert_eq!(snapshot.len(), 1); + let v: serde_json::Value = serde_json::from_str(&snapshot[0]).unwrap(); + assert!(v["device"].is_null()); + assert_eq!(v["log"]["message"], "anonymous hello"); + } + + #[tokio::test] + async fn send_location_requires_device() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request( + r#"mutation { + sendLocation(input: { + sessionId: "sess-1", + state: MOVING, + lineId: 1, + coords: { latitude: 35.6812, longitude: 139.7671 }, + timestamp: 1 + }) { sessionId } + }"#, + TELEMETRY, + )) + .await; + + // device is mandatory for positional data, so this fails schema validation + assert!(!resp.errors.is_empty()); + assert!(resp.errors[0].message.contains("device")); + assert!(hub.snapshot().await.is_empty()); + } + + #[tokio::test] + async fn send_log_event_rejects_empty_session_id() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); let resp = schema .execute(request( r#"mutation { sendLogEvent(input: { + sessionId: " ", device: "dev", timestamp: 1, type: SYSTEM, level: WARN, message: "hi" - }) { id } + }) { sessionId } }"#, TELEMETRY, )) .await; - assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors); - let data = resp.data.into_json().unwrap(); - assert!(!data["sendLogEvent"]["id"].as_str().unwrap().is_empty()); + assert!(!resp.errors.is_empty()); + assert!(resp.errors[0].message.contains("sessionId")); + assert!(hub.snapshot().await.is_empty()); } #[tokio::test] @@ -547,12 +620,13 @@ mod tests { .execute(request( r#"mutation { sendLogEvent(input: { + sessionId: "sess-1", device: "dev", timestamp: 1, type: APP, level: INFO, message: " " - }) { id } + }) { sessionId } }"#, EVENTS_ONLY, )) @@ -572,12 +646,13 @@ mod tests { .execute(request( r#"mutation { sendLogEvent(input: { + sessionId: "sess-1", device: "dev", timestamp: 1, type: APP, level: INFO, message: "hi" - }) { id } + }) { sessionId } }"#, OBSERVER, )) @@ -602,7 +677,7 @@ mod tests { assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors); let data = resp.data.into_json().unwrap(); - assert!(!data["sendLocation"]["id"].as_str().unwrap().is_empty()); + assert_eq!(data["sendLocation"]["sessionId"], "sess-1"); assert!(data["sendLocation"]["warning"].is_null()); let snapshot = hub.snapshot().await; @@ -635,12 +710,13 @@ mod tests { .execute(request( r#"mutation { sendLocation(input: { + sessionId: "sess-1", device: "dev", state: MOVING, lineId: 1, coords: { latitude: 91.0, longitude: 139.7671 }, timestamp: 1 - }) { id } + }) { sessionId } }"#, TELEMETRY, )) @@ -697,13 +773,14 @@ mod tests { .execute(request( r#"mutation { sendLocation(input: { + sessionId: "sess-1", device: "dev", state: MOVING, stationId: 42, lineId: 1, coords: { latitude: 35.6812, longitude: 139.7671 }, timestamp: 1 - }) { id } + }) { sessionId } }"#, TELEMETRY, )) diff --git a/src/segment.rs b/src/segment.rs index 4da3552..0307078 100644 --- a/src/segment.rs +++ b/src/segment.rs @@ -492,6 +492,7 @@ mod tests { let first = OutgoingLocation { id: "1".into(), + session_id: "sess".into(), device: "dev".into(), state: MovementState::Arrived, station_id: Some(101), @@ -531,6 +532,7 @@ mod tests { let base = OutgoingLocation { id: "1".into(), + session_id: "sess".into(), device: "dev".into(), state: MovementState::Arrived, station_id: Some(101), @@ -635,6 +637,7 @@ mod tests { let first = OutgoingLocation { id: "1".into(), + session_id: "sess".into(), device: "dev".into(), state: MovementState::Arrived, station_id: Some(101), @@ -676,6 +679,7 @@ mod tests { let loc = OutgoingLocation { id: "1".into(), + session_id: "sess".into(), device: "dev".into(), state: MovementState::Arrived, station_id: Some(1), diff --git a/src/server.rs b/src/server.rs index 4d7e981..e421791 100644 --- a/src/server.rs +++ b/src/server.rs @@ -500,12 +500,13 @@ mod tests { graphql_request( r#"mutation { sendLogEvent(input: { + sessionId: "sess-1", device: "test-device", timestamp: 1706000000000, type: APP, level: INFO, message: "Hello, world!" - }) { id } + }) { sessionId } }"#, auth_header, ) @@ -515,12 +516,13 @@ mod tests { graphql_request( r#"mutation { sendLocation(input: { + sessionId: "sess-1", device: "test-device", state: MOVING, lineId: 1, coords: { latitude: 35.6812, longitude: 139.7671 }, timestamp: 1706000000000 - }) { id } + }) { sessionId } }"#, auth_header, ) @@ -603,12 +605,13 @@ mod tests { let v = body_json(response).await; assert!(v["errors"].is_null(), "errors: {}", v["errors"]); - assert!(v["data"]["sendLogEvent"]["id"].is_string()); + assert_eq!(v["data"]["sendLogEvent"]["sessionId"], "sess-1"); let snapshot = hub.snapshot().await; assert_eq!(snapshot.len(), 1); let msg: Value = serde_json::from_str(&snapshot[0]).unwrap(); assert_eq!(msg["type"], "log"); + assert_eq!(msg["session_id"], "sess-1"); assert_eq!(msg["log"]["message"], "Hello, world!"); } @@ -687,12 +690,13 @@ mod tests { .unwrap(); let v = body_json(response).await; assert!(v["errors"].is_null(), "errors: {}", v["errors"]); - assert!(v["data"]["sendLocation"]["id"].is_string()); + assert_eq!(v["data"]["sendLocation"]["sessionId"], "sess-1"); let snapshot = hub.snapshot().await; assert_eq!(snapshot.len(), 1); let msg: Value = serde_json::from_str(&snapshot[0]).unwrap(); assert_eq!(msg["type"], "location_update"); + assert_eq!(msg["session_id"], "sess-1"); } #[tokio::test] diff --git a/src/storage.rs b/src/storage.rs index d747d23..d8bccb1 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -59,6 +59,7 @@ impl Storage { r#" CREATE TABLE IF NOT EXISTS location_logs ( id TEXT PRIMARY KEY, + session_id TEXT, device TEXT NOT NULL, state TEXT NOT NULL, station_id INTEGER, @@ -108,12 +109,16 @@ impl Storage { sqlx::query("ALTER TABLE location_logs ADD COLUMN IF NOT EXISTS battery_state SMALLINT;") .execute(pool) .await?; + sqlx::query("ALTER TABLE location_logs ADD COLUMN IF NOT EXISTS session_id TEXT;") + .execute(pool) + .await?; sqlx::query( r#" CREATE TABLE IF NOT EXISTS log_events ( id TEXT PRIMARY KEY, - device TEXT NOT NULL, + session_id TEXT, + device TEXT, log_type TEXT NOT NULL, log_level TEXT NOT NULL, message TEXT NOT NULL, @@ -137,6 +142,15 @@ impl Storage { .execute(pool) .await?; + // Allow NULL in device column so log events can be submitted anonymously; + // idempotent on columns already nullable. + sqlx::query("ALTER TABLE log_events ALTER COLUMN device DROP NOT NULL;") + .execute(pool) + .await?; + sqlx::query("ALTER TABLE log_events ADD COLUMN IF NOT EXISTS session_id TEXT;") + .execute(pool) + .await?; + sqlx::query("CREATE INDEX IF NOT EXISTS idx_log_events_device ON log_events (device);") .execute(pool) .await?; @@ -152,9 +166,10 @@ impl Storage { let ts = i64::try_from(loc.timestamp).unwrap_or(i64::MAX); sqlx::query( - "INSERT INTO location_logs (id, device, state, station_id, line_id, segment_id, from_station_id, to_station_id, latitude, longitude, accuracy, speed, timestamp, battery_level, battery_state) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) ON CONFLICT (id) DO NOTHING", + "INSERT INTO location_logs (id, session_id, device, state, station_id, line_id, segment_id, from_station_id, to_station_id, latitude, longitude, accuracy, speed, timestamp, battery_level, battery_state) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ON CONFLICT (id) DO NOTHING", ) .bind(&loc.id) + .bind(&loc.session_id) .bind(&loc.device) .bind(movement_state_str(&loc.state)) .bind(loc.station_id) @@ -184,9 +199,10 @@ impl Storage { let ts = i64::try_from(log.timestamp).unwrap_or(i64::MAX); sqlx::query( - "INSERT INTO log_events (id, device, log_type, log_level, message, timestamp) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (id) DO NOTHING", + "INSERT INTO log_events (id, session_id, device, log_type, log_level, message, timestamp) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO NOTHING", ) .bind(&log.id) + .bind(&log.session_id) .bind(&log.device) .bind(log_type_str(&log.log.r#type)) .bind(log_level_str(&log.log.level)) From abcb597983e82c9bdfb965df86a7dfb8da87eb6c Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 6 Jul 2026 14:14:11 +0900 Subject: [PATCH 05/13] =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=82=BF=E3=83=A9=E3=82=AF=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E8=A8=98=E9=8C=B2=E7=94=A8=E3=81=AE=20sendInteraction?= =?UTF-8?q?Event=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 任意のイベント名(eventName)でユーザー行動を記録する Mutation - 認証・入力仕様はログイベントを踏襲 (sessionId必須・device任意・観測用以外のトークンで送信可) - WS には type: "interaction" として配信 - interaction_events テーブルに永続化(event_name / session_id に索引) Co-Authored-By: Claude Fable 5 --- README.md | 39 ++++++- docs/react-tanstack-query.md | 69 +++++++++++- docs/react-websocket-observer.md | 28 ++++- src/domain.rs | 30 +++++ src/graphql.rs | 185 ++++++++++++++++++++++++++++++- src/storage.rs | 52 ++++++++- 6 files changed, 392 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d0461c3..18f29ba 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A telemetry server for [TrainLCD](https://github.com/TrainLCD). It provides real ## Features - **WebSocket** — Real-time broadcast of location updates and log events -- **GraphQL** — Event ingestion (`sendLogEvent`, `sendLocation` mutations) and aggregated per-line accuracy reports (`POST /graphql`) +- **GraphQL** — Event ingestion (`sendLogEvent`, `sendInteractionEvent`, `sendLocation` mutations) and aggregated per-line accuracy reports (`POST /graphql`) - **PostgreSQL persistence** — Optionally stores all events in the database - **Ring buffer** — Keeps the latest N events in memory (default 1000) - **Scoped authentication** — Three shared secrets: observer (WebSocket only), events (log submission only), telemetry (log + location submission) @@ -88,7 +88,7 @@ auth_required = true Three shared secrets grant exactly one role each: -| Token | WebSocket subscribe | `sendLogEvent` | `sendLocation` | +| Token | WebSocket subscribe | `sendLogEvent` / `sendInteractionEvent` | `sendLocation` | |---|---|---|---| | Observer | ✅ | ❌ | ❌ | | Events | ❌ | ✅ | ❌ | @@ -127,6 +127,23 @@ mutation { `sessionId` is a mandatory, client-generated unique identifier (any string). Event IDs are always generated server-side. `device` is optional so that log events can be submitted anonymously; omitted values are broadcast and stored as `null`. +#### `sendInteractionEvent` — Record a user-driven interaction + +Requires the events token or the telemetry token (any token except the observer one). Unlike `sendLogEvent`, which carries console.* output, this records a named user action such as an app launch, tab change, TTS request result, or feedback submission result. + +```graphql +mutation { + sendInteractionEvent(input: { + sessionId: "d0f7..." # client-generated unique session identifier + device: "device-001" # optional — omit to submit anonymously + timestamp: 1706000000000 + eventName: "tts_request" # arbitrary event name + }) { + sessionId + } +} +``` + #### `sendLocation` — Submit a location update Requires the telemetry token; the events token is deliberately not enough to publish positional data. Unlike `sendLogEvent`, `device` is mandatory here. The update is validated, annotated with segment information, broadcast to WebSocket subscribers, and persisted. @@ -197,7 +214,7 @@ No authentication required. Returns `200 OK` if the server is running. Endpoint: `ws://:/ws` -Once connected, the server broadcasts `location_update` and `log` messages in real time. Authentication uses the observer token (see [Authentication](#authentication)); on success the server responds with `Sec-WebSocket-Protocol: thq`, while a missing or invalid token results in HTTP 401. +Once connected, the server broadcasts `location_update`, `log` and `interaction` messages in real time. Authentication uses the observer token (see [Authentication](#authentication)); on success the server responds with `Sec-WebSocket-Protocol: thq`, while a missing or invalid token results in HTTP 401. #### Message formats @@ -247,6 +264,21 @@ Once connected, the server broadcasts `location_update` and `log` messages in re `device` is `null` when the event was submitted anonymously. +**interaction** + +```json +{ + "id": "uuid", + "type": "interaction", + "session_id": "client-generated-session-id", + "device": "device-id", + "timestamp": 1234567890, + "event_name": "tts_request" +} +``` + +As with `log`, `device` is `null` when the event was submitted anonymously. + **error** ```json @@ -267,6 +299,7 @@ When `database_url` / `DATABASE_URL` is provided, the server connects to Postgre |---|---| | `location_logs` | `id`, `session_id`, `device`, `state`, `station_id`, `line_id`, `segment_id`, `from_station_id`, `to_station_id`, `latitude`, `longitude`, `accuracy`, `speed`, `battery_level`, `battery_state`, `timestamp`, `recorded_at` | | `log_events` | `id`, `session_id`, `device`, `log_type`, `log_level`, `message`, `timestamp`, `recorded_at` | +| `interaction_events` | `id`, `session_id`, `device`, `event_name`, `timestamp`, `recorded_at` | Without a `database_url` the server still accepts WebSocket traffic but does not persist messages. diff --git a/docs/react-tanstack-query.md b/docs/react-tanstack-query.md index 21cbdd2..f0b3c0e 100644 --- a/docs/react-tanstack-query.md +++ b/docs/react-tanstack-query.md @@ -9,6 +9,7 @@ thq-server の GraphQL API はエンドポイント `POST /graphql` で公開さ | 操作 | 種別 | 認証 | |---|---|---| | `sendLogEvent` | Mutation | イベント用または遠隔測定用トークン | +| `sendInteractionEvent` | Mutation | イベント用または遠隔測定用トークン | | `sendLocation` | Mutation | 遠隔測定用トークンのみ | | `accuracyByLine` | Query | 不要 | @@ -16,8 +17,8 @@ Mutation の認証は `Authorization: Bearer ` ヘッダで行います | トークン | できること | |---|---| -| イベント用(`THQ_EVENTS_AUTH_TOKEN`) | `sendLogEvent` のみ | -| 遠隔測定用(`THQ_TELEMETRY_AUTH_TOKEN`) | `sendLogEvent` + `sendLocation` | +| イベント用(`THQ_EVENTS_AUTH_TOKEN`) | `sendLogEvent` + `sendInteractionEvent` | +| 遠隔測定用(`THQ_TELEMETRY_AUTH_TOKEN`) | `sendLogEvent` + `sendInteractionEvent` + `sendLocation` | > **セキュリティ上の注意**: ブラウザ向けにビルドした JavaScript に埋め込んだトークンは、利用者全員から見えます。イベント用・遠隔測定用トークンを Web フロントエンドに直接埋め込むのは避け、ネイティブアプリや自前のバックエンド(BFF)経由で扱ってください。認証不要な `accuracyByLine` の表示だけであればトークンは一切不要です。 @@ -245,6 +246,70 @@ const sessionId = crypto.randomUUID(); なお、イベント自体の ID はサーバー側で常に UUID が採番されます。クライアントから ID を指定することはできないため、送信リクエストの再送はそれぞれ別イベントとして記録される点に注意してください。 +## Mutation: インタラクションイベント送信(`sendInteractionEvent`) + +ユーザー主導のインタラクション(行動)を任意のイベント名で記録します。`sendLogEvent` が `console.*` 相当の出力を送る想定なのに対し、こちらは「何をしたか」をイベント名で記録する用途です。認証は `sendLogEvent` と同じで、観測用以外のトークン(イベント用または遠隔測定用)で送信できます。 + +イベント名は任意の文字列です。例: + +| eventName | 意味 | +|---|---| +| `app_launch` | アプリ起動 | +| `tab_change` | アプリのタブ移動 | +| `tts_request` | TTS(Text-to-Speech)のリクエスト | +| `tts_success` / `tts_failure` | TTS リクエストの成功・失敗 | +| `feedback_request` | フィードバックの送信リクエスト | +| `feedback_success` / `feedback_failure` | フィードバック送信の成功・失敗 | + +```ts +// hooks/useSendInteractionEvent.ts +import { useMutation } from "@tanstack/react-query"; +import { gqlRequest } from "../lib/graphql"; + +const SEND_INTERACTION_EVENT = /* GraphQL */ ` + mutation SendInteractionEvent($input: InteractionEventInput!) { + sendInteractionEvent(input: $input) { + sessionId + } + } +`; + +export interface InteractionEventInput { + sessionId: string; // 必須。クライアント側で生成した一意な文字列 + device?: string; // 匿名性確保のため省略可 + timestamp: number; // Unix ミリ秒 (Date.now()) + eventName: string; // 任意のイベント名。空文字・空白のみはサーバーが拒否 +} + +export function useSendInteractionEvent(token: string) { + return useMutation({ + mutationFn: (input: InteractionEventInput) => + gqlRequest<{ sendInteractionEvent: { sessionId: string } }>( + SEND_INTERACTION_EVENT, + { input }, + token, + ), + }); +} +``` + +使用例: + +```tsx +const sendInteraction = useSendInteractionEvent(eventsToken); + +// アプリ起動時 +sendInteraction.mutate({ sessionId, timestamp: Date.now(), eventName: "app_launch" }); + +// TTS リクエストの結果 +try { + await requestTts(text); + sendInteraction.mutate({ sessionId, timestamp: Date.now(), eventName: "tts_success" }); +} catch { + sendInteraction.mutate({ sessionId, timestamp: Date.now(), eventName: "tts_failure" }); +} +``` + ## Mutation: 位置情報送信(`sendLocation`) **遠隔測定用トークンのみ**が実行できます。イベント用トークンでは `unauthorized: a valid telemetry bearer token is required` エラーになります。また、`sendLogEvent` と異なり **`device` は必須**です(位置情報は端末と紐付いていることが前提のため、匿名では送信できません)。 diff --git a/docs/react-websocket-observer.md b/docs/react-websocket-observer.md index fea621a..8df3942 100644 --- a/docs/react-websocket-observer.md +++ b/docs/react-websocket-observer.md @@ -83,7 +83,20 @@ const ws = new WebSocket("wss://thq.example.com/ws", [ } ``` -ログイベントは匿名で送信できるため、`device` が `null` の場合があります(`location_update` の `device` は常に非 null です)。 +**interaction** — `sendInteractionEvent` Mutation で登録されたユーザーインタラクション + +```json +{ + "type": "interaction", + "id": "uuid", + "session_id": "client-generated-session-id", + "device": "device-001", + "timestamp": 1706000000000, + "event_name": "tts_request" +} +``` + +ログイベントとインタラクションイベントは匿名で送信できるため、`device` が `null` の場合があります(`location_update` の `device` は常に非 null です)。 **error** — プロトコルエラーの通知(不正な JSON を送った場合など) @@ -140,7 +153,16 @@ export interface LogEvent { }; } -export type TelemetryEvent = LocationUpdateEvent | LogEvent; +export interface InteractionEvent { + type: "interaction"; + id: string; // サーバー採番のイベント ID(重複排除に使える) + session_id: string; // クライアント側で生成されたセッション ID + device: string | null; // 匿名送信されたイベントは null + timestamp: number; + event_name: string; // 例: "app_launch", "tts_request" +} + +export type TelemetryEvent = LocationUpdateEvent | LogEvent | InteractionEvent; const FEED_KEY = ["telemetryFeed"] as const; const MAX_EVENTS = 1000; @@ -169,7 +191,7 @@ export function useTelemetryFeed( ws.onmessage = (event) => { const msg = JSON.parse(event.data); - if (msg.type !== "location_update" && msg.type !== "log") { + if (msg.type !== "location_update" && msg.type !== "log" && msg.type !== "interaction") { if (msg.type === "error") { console.warn("thq-server error:", msg.error); } diff --git a/src/domain.rs b/src/domain.rs index e204d3e..2d92e10 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -90,6 +90,7 @@ pub enum IncomingMessage { pub enum OutgoingMessage { LocationUpdate(OutgoingLocation), Log(OutgoingLog), + Interaction(OutgoingInteraction), Error(OutgoingError), } @@ -130,6 +131,19 @@ pub struct OutgoingLog { pub log: LogBody, } +/// A user-driven interaction (e.g. app launch, tab change, TTS request). +/// Unlike logs, which carry console.* output, this records a named action. +#[derive(Debug, Clone, Serialize)] +pub struct OutgoingInteraction { + pub id: String, + /// Client-generated unique session identifier. + pub session_id: String, + /// None when the sender chose to stay anonymous. + pub device: Option, + pub timestamp: u64, + pub event_name: String, +} + #[derive(Debug, Clone, Serialize)] pub struct OutgoingError { pub error: ErrorBody, @@ -184,6 +198,22 @@ mod tests { assert_eq!(json["log"]["message"], "hello"); } + #[test] + fn outgoing_interaction_has_type_field() { + let msg = OutgoingMessage::Interaction(OutgoingInteraction { + id: "id1".into(), + session_id: "sess-1".into(), + device: None, + timestamp: 42, + event_name: "app_launch".into(), + }); + + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["type"], "interaction"); + assert_eq!(json["event_name"], "app_launch"); + assert!(json["device"].is_null()); + } + #[test] fn outgoing_location_has_type_field() { let msg = OutgoingMessage::LocationUpdate(OutgoingLocation { diff --git a/src/graphql.rs b/src/graphql.rs index 0121232..3724871 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -9,8 +9,8 @@ use uuid::Uuid; use crate::{ domain::{ - BatteryState, LogBody, LogLevel, LogType, MovementState, OutgoingCoords, OutgoingLocation, - OutgoingLog, OutgoingMessage, + BatteryState, LogBody, LogLevel, LogType, MovementState, OutgoingCoords, + OutgoingInteraction, OutgoingLocation, OutgoingLog, OutgoingMessage, }, segment::SegmentEstimator, state::TelemetryHub, @@ -203,6 +203,24 @@ pub struct SendLogEventPayload { pub session_id: String, } +#[derive(InputObject)] +pub struct InteractionEventInput { + /// Client-generated unique session identifier (arbitrary string). + pub session_id: String, + /// Device identifier. Optional so events can be submitted anonymously. + pub device: Option, + /// Unix timestamp in milliseconds. + pub timestamp: u64, + /// Arbitrary name of the user-driven interaction, + /// e.g. "app_launch", "tab_change", "tts_request", "feedback_success". + pub event_name: String, +} + +#[derive(SimpleObject)] +pub struct SendInteractionEventPayload { + pub session_id: String, +} + #[derive(InputObject)] pub struct CoordsInput { pub latitude: f64, @@ -302,6 +320,65 @@ impl MutationRoot { }) } + /// Record a user-driven interaction identified by an arbitrary event + /// name (e.g. app launch, tab change, TTS request result). Unlike + /// sendLogEvent, which carries console.* output, this captures a named + /// user action. Requires the events token or the telemetry token. The + /// event is broadcast to WebSocket subscribers and persisted when a + /// database is configured. + async fn send_interaction_event( + &self, + ctx: &Context<'_>, + input: InteractionEventInput, + ) -> Result { + let auth = ctx + .data::() + .map_err(|_| "auth context is missing")?; + if !auth.can_send_events { + return Err( + "unauthorized: a valid events or telemetry bearer token is required".into(), + ); + } + + if input.session_id.trim().is_empty() { + return Err("sessionId must not be empty".into()); + } + + if input.event_name.trim().is_empty() { + return Err("eventName must not be empty".into()); + } + + let hub = ctx + .data::>() + .map_err(|_| "telemetry hub is not configured")?; + let storage = ctx + .data::() + .map_err(|_| "storage is not configured")?; + + let event = OutgoingInteraction { + id: Uuid::new_v4().to_string(), + session_id: input.session_id, + device: input.device, + timestamp: input.timestamp, + event_name: input.event_name, + }; + + match serde_json::to_string(&OutgoingMessage::Interaction(event.clone())) { + Ok(serialized) => hub.broadcast(serialized).await, + Err(err) => { + tracing::error!(?err, "failed to serialize interaction message"); + } + } + + if let Err(err) = storage.store_interaction(&event).await { + tracing::error!(?err, "failed to persist interaction event"); + } + + Ok(SendInteractionEventPayload { + session_id: event.session_id, + }) + } + /// Submit a location update. Requires the telemetry token; the events /// token is deliberately not enough to publish positional data. The /// update is annotated with segment information, broadcast to WebSocket @@ -663,6 +740,110 @@ mod tests { assert!(hub.snapshot().await.is_empty()); } + #[tokio::test] + async fn send_interaction_event_broadcasts_event_name() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request( + r#"mutation { + sendInteractionEvent(input: { + sessionId: "sess-1", + device: "dev", + timestamp: 1706000000000, + eventName: "tts_request" + }) { sessionId } + }"#, + EVENTS_ONLY, + )) + .await; + + assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors); + let data = resp.data.into_json().unwrap(); + assert_eq!(data["sendInteractionEvent"]["sessionId"], "sess-1"); + + let snapshot = hub.snapshot().await; + assert_eq!(snapshot.len(), 1); + let v: serde_json::Value = serde_json::from_str(&snapshot[0]).unwrap(); + assert_eq!(v["type"], "interaction"); + assert_eq!(v["event_name"], "tts_request"); + assert_eq!(v["session_id"], "sess-1"); + assert!(!v["id"].as_str().unwrap().is_empty()); + } + + #[tokio::test] + async fn send_interaction_event_accepts_anonymous_device() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request( + r#"mutation { + sendInteractionEvent(input: { + sessionId: "sess-1", + timestamp: 1, + eventName: "app_launch" + }) { sessionId } + }"#, + TELEMETRY, + )) + .await; + + assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors); + let snapshot = hub.snapshot().await; + assert_eq!(snapshot.len(), 1); + let v: serde_json::Value = serde_json::from_str(&snapshot[0]).unwrap(); + assert!(v["device"].is_null()); + assert_eq!(v["event_name"], "app_launch"); + } + + #[tokio::test] + async fn send_interaction_event_rejects_empty_event_name() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request( + r#"mutation { + sendInteractionEvent(input: { + sessionId: "sess-1", + timestamp: 1, + eventName: " " + }) { sessionId } + }"#, + EVENTS_ONLY, + )) + .await; + + assert!(!resp.errors.is_empty()); + assert!(resp.errors[0].message.contains("eventName")); + assert!(hub.snapshot().await.is_empty()); + } + + #[tokio::test] + async fn send_interaction_event_rejects_observer_scope() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request( + r#"mutation { + sendInteractionEvent(input: { + sessionId: "sess-1", + timestamp: 1, + eventName: "app_launch" + }) { sessionId } + }"#, + OBSERVER, + )) + .await; + + assert!(!resp.errors.is_empty()); + assert!(resp.errors[0].message.contains("unauthorized")); + assert!(hub.snapshot().await.is_empty()); + } + #[tokio::test] async fn send_location_broadcasts_with_telemetry_scope() { let hub = Arc::new(TelemetryHub::new(10)); diff --git a/src/storage.rs b/src/storage.rs index d8bccb1..735108d 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -5,7 +5,8 @@ use sqlx::{postgres::PgPoolOptions, PgPool}; use tracing::info; use crate::domain::{ - BatteryState, LogLevel, LogType, MovementState, OutgoingLocation, OutgoingLog, + BatteryState, LogLevel, LogType, MovementState, OutgoingInteraction, OutgoingLocation, + OutgoingLog, }; #[derive(Clone, sqlx::FromRow)] @@ -130,6 +131,21 @@ impl Storage { .execute(pool) .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS interaction_events ( + id TEXT PRIMARY KEY, + session_id TEXT, + device TEXT, + event_name TEXT NOT NULL, + timestamp BIGINT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + "#, + ) + .execute(pool) + .await?; + sqlx::query( "CREATE INDEX IF NOT EXISTS idx_location_logs_device ON location_logs (device);", ) @@ -155,6 +171,18 @@ impl Storage { .execute(pool) .await?; + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_interaction_events_name ON interaction_events (event_name);", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_interaction_events_session ON interaction_events (session_id);", + ) + .execute(pool) + .await?; + Ok(()) } @@ -215,6 +243,28 @@ impl Storage { Ok(()) } + pub async fn store_interaction(&self, event: &OutgoingInteraction) -> anyhow::Result<()> { + let Some(pool) = &self.pool else { + return Ok(()); + }; + + let ts = i64::try_from(event.timestamp).unwrap_or(i64::MAX); + + sqlx::query( + "INSERT INTO interaction_events (id, session_id, device, event_name, timestamp) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO NOTHING", + ) + .bind(&event.id) + .bind(&event.session_id) + .bind(&event.device) + .bind(&event.event_name) + .bind(ts) + .execute(pool) + .await + .context("failed to insert interaction event")?; + + Ok(()) + } + pub async fn fetch_line_accuracy( &self, line_id: i32, From ee27862222cec9d4dcd862fed30e34cdf0586c31 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 6 Jul 2026 15:00:56 +0900 Subject: [PATCH 06/13] =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=82=BF=E3=83=A9?= =?UTF-8?q?=E3=82=AF=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=A4=E3=83=99=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=81=AB=20properties=20=E3=83=95=E3=82=A3=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=83=89=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Record 相当の フラットなオブジェクトのみ受理するカスタムスカラー Properties を追加 (ネストしたオブジェクト・配列は型レベルで拒否) - WS 配信メッセージに properties を含め、 interaction_events に JSONB カラムとして永続化 - sqlx に json feature を追加 Co-Authored-By: Claude Fable 5 --- Cargo.toml | 2 +- README.md | 24 ++++- docs/react-tanstack-query.md | 41 +++++++- docs/react-websocket-observer.md | 16 +++- src/domain.rs | 96 +++++++++++++++++++ src/graphql.rs | 155 ++++++++++++++++++++++++++++++- src/server.rs | 3 + src/storage.rs | 45 ++++++++- 8 files changed, 368 insertions(+), 14 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c0f95a3..6bef3cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } thiserror = "1" futures = "0.3" -sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio-rustls", "postgres", "chrono", "macros"] } +sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio-rustls", "postgres", "chrono", "macros", "json"] } async-graphql = { version = "6", features = ["chrono"] } async-graphql-axum = "6" chrono = { version = "0.4", features = ["serde"] } diff --git a/README.md b/README.md index 18f29ba..43fff34 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,9 @@ mutation { sendLogEvent(input: { sessionId: "d0f7..." # client-generated unique session identifier device: "device-001" # optional — omit to submit anonymously + appVersion: "1.2.3" + platform: IOS # IOS | ANDROID | MACOS | UNKNOWN + channel: PRODUCTION # PRODUCTION | CANARY timestamp: 1706000000000 type: APP # SYSTEM | APP | CLIENT level: INFO # DEBUG | INFO | WARN | ERROR @@ -136,14 +139,20 @@ mutation { sendInteractionEvent(input: { sessionId: "d0f7..." # client-generated unique session identifier device: "device-001" # optional — omit to submit anonymously + appVersion: "1.2.3" + platform: IOS # IOS | ANDROID | MACOS | UNKNOWN + channel: PRODUCTION # PRODUCTION | CANARY timestamp: 1706000000000 - eventName: "tts_request" # arbitrary event name + eventName: "tab_change" # arbitrary event name + properties: { tab: "map", index: 2, pinned: true } # optional flat map }) { sessionId } } ``` +`properties` is an optional flat object — the TS equivalent is `Record`. Nested objects and arrays are rejected. + #### `sendLocation` — Submit a location update Requires the telemetry token; the events token is deliberately not enough to publish positional data. Unlike `sendLogEvent`, `device` is mandatory here. The update is validated, annotated with segment information, broadcast to WebSocket subscribers, and persisted. @@ -253,6 +262,9 @@ Once connected, the server broadcasts `location_update`, `log` and `interaction` "type": "log", "session_id": "client-generated-session-id", "device": "device-id", + "app_version": "1.2.3", + "platform": "ios | android | macos | unknown", + "channel": "production | canary", "timestamp": 1234567890, "log": { "type": "system | app | client", @@ -272,8 +284,12 @@ Once connected, the server broadcasts `location_update`, `log` and `interaction` "type": "interaction", "session_id": "client-generated-session-id", "device": "device-id", + "app_version": "1.2.3", + "platform": "ios | android | macos | unknown", + "channel": "production | canary", "timestamp": 1234567890, - "event_name": "tts_request" + "event_name": "tab_change", + "properties": { "tab": "map", "index": 2, "pinned": true } } ``` @@ -298,8 +314,8 @@ When `database_url` / `DATABASE_URL` is provided, the server connects to Postgre | Table | Key columns | |---|---| | `location_logs` | `id`, `session_id`, `device`, `state`, `station_id`, `line_id`, `segment_id`, `from_station_id`, `to_station_id`, `latitude`, `longitude`, `accuracy`, `speed`, `battery_level`, `battery_state`, `timestamp`, `recorded_at` | -| `log_events` | `id`, `session_id`, `device`, `log_type`, `log_level`, `message`, `timestamp`, `recorded_at` | -| `interaction_events` | `id`, `session_id`, `device`, `event_name`, `timestamp`, `recorded_at` | +| `log_events` | `id`, `session_id`, `device`, `app_version`, `platform`, `channel`, `log_type`, `log_level`, `message`, `timestamp`, `recorded_at` | +| `interaction_events` | `id`, `session_id`, `device`, `app_version`, `platform`, `channel`, `properties` (JSONB), `event_name`, `timestamp`, `recorded_at` | Without a `database_url` the server still accepts WebSocket traffic but does not persist messages. diff --git a/docs/react-tanstack-query.md b/docs/react-tanstack-query.md index f0b3c0e..a9d69b0 100644 --- a/docs/react-tanstack-query.md +++ b/docs/react-tanstack-query.md @@ -192,6 +192,9 @@ const SEND_LOG_EVENT = /* GraphQL */ ` export interface LogEventInput { sessionId: string; // 必須。クライアント側で生成した一意な文字列(後述) device?: string; // 匿名性確保のため省略可。省略時は null として配信・保存される + appVersion: string; // 必須。アプリのバージョン文字列(空はサーバーが拒否) + platform: "IOS" | "ANDROID" | "MACOS" | "UNKNOWN"; // 必須 + channel: "PRODUCTION" | "CANARY"; // 必須 timestamp: number; // Unix ミリ秒 (Date.now()) type: "SYSTEM" | "APP" | "CLIENT"; level: "DEBUG" | "INFO" | "WARN" | "ERROR"; @@ -214,6 +217,9 @@ const sendLog = useSendLogEvent(eventsToken); sendLog.mutate({ sessionId, device: "device-001", + appVersion: "1.2.3", + platform: "IOS", + channel: "PRODUCTION", timestamp: Date.now(), type: "APP", level: "INFO", @@ -226,6 +232,9 @@ sendLog.mutate({ ```ts sendLog.mutate({ sessionId, + appVersion: "1.2.3", + platform: "IOS", + channel: "PRODUCTION", timestamp: Date.now(), type: "APP", level: "INFO", @@ -277,8 +286,12 @@ const SEND_INTERACTION_EVENT = /* GraphQL */ ` export interface InteractionEventInput { sessionId: string; // 必須。クライアント側で生成した一意な文字列 device?: string; // 匿名性確保のため省略可 + appVersion: string; // 必須。アプリのバージョン文字列(空はサーバーが拒否) + platform: "IOS" | "ANDROID" | "MACOS" | "UNKNOWN"; // 必須 + channel: "PRODUCTION" | "CANARY"; // 必須 timestamp: number; // Unix ミリ秒 (Date.now()) eventName: string; // 任意のイベント名。空文字・空白のみはサーバーが拒否 + properties?: Record; // 省略可(後述) } export function useSendInteractionEvent(token: string) { @@ -295,18 +308,38 @@ export function useSendInteractionEvent(token: string) { 使用例: +`properties` はイベントに付随する属性を格納するフラットなオブジェクトです。値に使えるのは文字列・数値・真偽値・null のみで、ネストしたオブジェクトや配列はサーバーが拒否します(GraphQL 上はカスタムスカラー `Properties`)。 + ```tsx const sendInteraction = useSendInteractionEvent(eventsToken); +// アプリ全体で共通の属性はラップしておくと便利 +const track = ( + eventName: string, + properties?: Record, +) => + sendInteraction.mutate({ + sessionId, + appVersion: "1.2.3", + platform: "IOS", + channel: "PRODUCTION", + timestamp: Date.now(), + eventName, + properties, + }); + // アプリ起動時 -sendInteraction.mutate({ sessionId, timestamp: Date.now(), eventName: "app_launch" }); +track("app_launch"); + +// タブ移動(付随情報は properties で) +track("tab_change", { tab: "map", index: 2 }); // TTS リクエストの結果 try { await requestTts(text); - sendInteraction.mutate({ sessionId, timestamp: Date.now(), eventName: "tts_success" }); -} catch { - sendInteraction.mutate({ sessionId, timestamp: Date.now(), eventName: "tts_failure" }); + track("tts_success"); +} catch (err) { + track("tts_failure", { reason: String(err) }); } ``` diff --git a/docs/react-websocket-observer.md b/docs/react-websocket-observer.md index 8df3942..8946091 100644 --- a/docs/react-websocket-observer.md +++ b/docs/react-websocket-observer.md @@ -78,6 +78,9 @@ const ws = new WebSocket("wss://thq.example.com/ws", [ "id": "uuid", "session_id": "client-generated-session-id", "device": "device-001", + "app_version": "1.2.3", + "platform": "ios | android | macos | unknown", + "channel": "production | canary", "timestamp": 1706000000000, "log": { "type": "system | app | client", "level": "debug | info | warn | error", "message": "..." } } @@ -91,8 +94,12 @@ const ws = new WebSocket("wss://thq.example.com/ws", [ "id": "uuid", "session_id": "client-generated-session-id", "device": "device-001", + "app_version": "1.2.3", + "platform": "ios | android | macos | unknown", + "channel": "production | canary", "timestamp": 1706000000000, - "event_name": "tts_request" + "event_name": "tab_change", + "properties": { "tab": "map", "index": 2, "pinned": true } } ``` @@ -145,6 +152,9 @@ export interface LogEvent { id: string; // サーバー採番のイベント ID(重複排除に使える) session_id: string; // クライアント側で生成されたセッション ID device: string | null; // 匿名送信されたイベントは null + app_version: string; + platform: "ios" | "android" | "macos" | "unknown"; + channel: "production" | "canary"; timestamp: number; log: { type: "system" | "app" | "client"; @@ -158,8 +168,12 @@ export interface InteractionEvent { id: string; // サーバー採番のイベント ID(重複排除に使える) session_id: string; // クライアント側で生成されたセッション ID device: string | null; // 匿名送信されたイベントは null + app_version: string; + platform: "ios" | "android" | "macos" | "unknown"; + channel: "production" | "canary"; timestamp: number; event_name: string; // 例: "app_launch", "tts_request" + properties: Record | null; // 付随情報(フラットなオブジェクトのみ) } export type TelemetryEvent = LocationUpdateEvent | LogEvent | InteractionEvent; diff --git a/src/domain.rs b/src/domain.rs index 2d92e10..b915bee 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -1,7 +1,33 @@ +use std::collections::HashMap; + use async_graphql::Enum; use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; +/// Value type allowed in event properties. +/// TS equivalent: `string | number | boolean | null`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum PropertyValue { + Null, + Bool(bool), + Number(serde_json::Number), + String(String), +} + +/// Flat property map. Nested objects and arrays are rejected at +/// deserialization because PropertyValue has no variant for them. +/// TS equivalent: `Record`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Properties(pub HashMap); + +async_graphql::scalar!( + Properties, + "Properties", + "A flat JSON object whose values are string, number, boolean or null \ + (no nested objects or arrays), i.e. Record." +); + #[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq, Enum)] #[repr(u8)] pub enum BatteryState { @@ -31,6 +57,42 @@ impl MovementState { } } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] +#[serde(rename_all = "snake_case")] +pub enum Platform { + Ios, + Android, + Macos, + Unknown, +} + +impl Platform { + pub fn as_str(&self) -> &'static str { + match self { + Platform::Ios => "ios", + Platform::Android => "android", + Platform::Macos => "macos", + Platform::Unknown => "unknown", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] +#[serde(rename_all = "snake_case")] +pub enum Channel { + Production, + Canary, +} + +impl Channel { + pub fn as_str(&self) -> &'static str { + match self { + Channel::Production => "production", + Channel::Canary => "canary", + } + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] #[serde(rename_all = "snake_case")] pub enum LogLevel { @@ -127,6 +189,9 @@ pub struct OutgoingLog { pub session_id: String, /// None when the sender chose to stay anonymous. pub device: Option, + pub app_version: String, + pub platform: Platform, + pub channel: Channel, pub timestamp: u64, pub log: LogBody, } @@ -140,8 +205,13 @@ pub struct OutgoingInteraction { pub session_id: String, /// None when the sender chose to stay anonymous. pub device: Option, + pub app_version: String, + pub platform: Platform, + pub channel: Channel, pub timestamp: u64, pub event_name: String, + /// Optional flat map of extra attributes describing the interaction. + pub properties: Option, } #[derive(Debug, Clone, Serialize)] @@ -183,6 +253,9 @@ mod tests { id: "id1".into(), session_id: "sess-1".into(), device: Some("dev".into()), + app_version: "1.2.3".into(), + platform: Platform::Ios, + channel: Channel::Production, timestamp: 42, log: LogBody { r#type: LogType::App, @@ -204,14 +277,37 @@ mod tests { id: "id1".into(), session_id: "sess-1".into(), device: None, + app_version: "1.2.3".into(), + platform: Platform::Android, + channel: Channel::Canary, timestamp: 42, event_name: "app_launch".into(), + properties: Some(Properties(HashMap::from([ + ("tab".to_string(), PropertyValue::String("map".into())), + ("count".to_string(), PropertyValue::Number(3.into())), + ("active".to_string(), PropertyValue::Bool(true)), + ("note".to_string(), PropertyValue::Null), + ]))), }); let json = serde_json::to_value(&msg).unwrap(); assert_eq!(json["type"], "interaction"); assert_eq!(json["event_name"], "app_launch"); assert!(json["device"].is_null()); + assert_eq!(json["app_version"], "1.2.3"); + assert_eq!(json["platform"], "android"); + assert_eq!(json["channel"], "canary"); + assert_eq!(json["properties"]["tab"], "map"); + assert_eq!(json["properties"]["count"], 3); + assert_eq!(json["properties"]["active"], true); + assert!(json["properties"]["note"].is_null()); + } + + #[test] + fn property_value_rejects_nested_structures() { + assert!(serde_json::from_str::(r#"{"tab":"map","count":1}"#).is_ok()); + assert!(serde_json::from_str::(r#"{"nested":{"a":1}}"#).is_err()); + assert!(serde_json::from_str::(r#"{"list":[1,2]}"#).is_err()); } #[test] diff --git a/src/graphql.rs b/src/graphql.rs index 3724871..d7712df 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -9,8 +9,8 @@ use uuid::Uuid; use crate::{ domain::{ - BatteryState, LogBody, LogLevel, LogType, MovementState, OutgoingCoords, - OutgoingInteraction, OutgoingLocation, OutgoingLog, OutgoingMessage, + BatteryState, Channel, LogBody, LogLevel, LogType, MovementState, OutgoingCoords, + OutgoingInteraction, OutgoingLocation, OutgoingLog, OutgoingMessage, Platform, Properties, }, segment::SegmentEstimator, state::TelemetryHub, @@ -190,6 +190,10 @@ pub struct LogEventInput { pub session_id: String, /// Device identifier. Optional so events can be submitted anonymously. pub device: Option, + /// Application version string (e.g. "1.2.3"). + pub app_version: String, + pub platform: Platform, + pub channel: Channel, /// Unix timestamp in milliseconds. pub timestamp: u64, #[graphql(name = "type")] @@ -209,11 +213,19 @@ pub struct InteractionEventInput { pub session_id: String, /// Device identifier. Optional so events can be submitted anonymously. pub device: Option, + /// Application version string (e.g. "1.2.3"). + pub app_version: String, + pub platform: Platform, + pub channel: Channel, /// Unix timestamp in milliseconds. pub timestamp: u64, /// Arbitrary name of the user-driven interaction, /// e.g. "app_launch", "tab_change", "tts_request", "feedback_success". pub event_name: String, + /// Optional flat map of extra attributes describing the interaction. + /// Values must be string, number, boolean or null; nested objects and + /// arrays are rejected. + pub properties: Option, } #[derive(SimpleObject)] @@ -281,6 +293,10 @@ impl MutationRoot { return Err("sessionId must not be empty".into()); } + if input.app_version.trim().is_empty() { + return Err("appVersion must not be empty".into()); + } + if input.message.trim().is_empty() { return Err("message must not be empty".into()); } @@ -296,6 +312,9 @@ impl MutationRoot { id: Uuid::new_v4().to_string(), session_id: input.session_id, device: input.device, + app_version: input.app_version, + platform: input.platform, + channel: input.channel, timestamp: input.timestamp, log: LogBody { r#type: input.log_type, @@ -344,6 +363,10 @@ impl MutationRoot { return Err("sessionId must not be empty".into()); } + if input.app_version.trim().is_empty() { + return Err("appVersion must not be empty".into()); + } + if input.event_name.trim().is_empty() { return Err("eventName must not be empty".into()); } @@ -359,8 +382,12 @@ impl MutationRoot { id: Uuid::new_v4().to_string(), session_id: input.session_id, device: input.device, + app_version: input.app_version, + platform: input.platform, + channel: input.channel, timestamp: input.timestamp, event_name: input.event_name, + properties: input.properties, }; match serde_json::to_string(&OutgoingMessage::Interaction(event.clone())) { @@ -581,6 +608,9 @@ mod tests { r#"mutation { sendLogEvent(input: { sessionId: "sess-abc", + appVersion: "1.2.3", + platform: IOS, + channel: PRODUCTION, device: "dev", timestamp: 1706000000000, type: APP, @@ -603,10 +633,41 @@ mod tests { assert_eq!(v["session_id"], "sess-abc"); // the event ID is always generated server-side assert!(!v["id"].as_str().unwrap().is_empty()); + assert_eq!(v["app_version"], "1.2.3"); + assert_eq!(v["platform"], "ios"); + assert_eq!(v["channel"], "production"); assert_eq!(v["log"]["message"], "hello"); assert_eq!(v["timestamp"], 1706000000000u64); } + #[tokio::test] + async fn send_log_event_rejects_empty_app_version() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request( + r#"mutation { + sendLogEvent(input: { + sessionId: "sess-1", + appVersion: " ", + platform: ANDROID, + channel: CANARY, + timestamp: 1, + type: APP, + level: INFO, + message: "hi" + }) { sessionId } + }"#, + EVENTS_ONLY, + )) + .await; + + assert!(!resp.errors.is_empty()); + assert!(resp.errors[0].message.contains("appVersion")); + assert!(hub.snapshot().await.is_empty()); + } + #[tokio::test] async fn send_log_event_accepts_anonymous_device() { let hub = Arc::new(TelemetryHub::new(10)); @@ -617,6 +678,9 @@ mod tests { r#"mutation { sendLogEvent(input: { sessionId: "sess-anon", + appVersion: "1.2.3", + platform: IOS, + channel: PRODUCTION, timestamp: 1, type: APP, level: INFO, @@ -672,6 +736,9 @@ mod tests { r#"mutation { sendLogEvent(input: { sessionId: " ", + appVersion: "1.2.3", + platform: IOS, + channel: PRODUCTION, device: "dev", timestamp: 1, type: SYSTEM, @@ -698,6 +765,9 @@ mod tests { r#"mutation { sendLogEvent(input: { sessionId: "sess-1", + appVersion: "1.2.3", + platform: IOS, + channel: PRODUCTION, device: "dev", timestamp: 1, type: APP, @@ -724,6 +794,9 @@ mod tests { r#"mutation { sendLogEvent(input: { sessionId: "sess-1", + appVersion: "1.2.3", + platform: IOS, + channel: PRODUCTION, device: "dev", timestamp: 1, type: APP, @@ -750,6 +823,9 @@ mod tests { r#"mutation { sendInteractionEvent(input: { sessionId: "sess-1", + appVersion: "1.2.3", + platform: IOS, + channel: PRODUCTION, device: "dev", timestamp: 1706000000000, eventName: "tts_request" @@ -770,6 +846,72 @@ mod tests { assert_eq!(v["event_name"], "tts_request"); assert_eq!(v["session_id"], "sess-1"); assert!(!v["id"].as_str().unwrap().is_empty()); + assert_eq!(v["app_version"], "1.2.3"); + assert_eq!(v["platform"], "ios"); + assert_eq!(v["channel"], "production"); + } + + #[tokio::test] + async fn send_interaction_event_records_flat_properties() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request( + r#"mutation { + sendInteractionEvent(input: { + sessionId: "sess-1", + appVersion: "1.2.3", + platform: IOS, + channel: PRODUCTION, + timestamp: 1, + eventName: "tab_change", + properties: { tab: "map", index: 2, pinned: true, note: null } + }) { sessionId } + }"#, + EVENTS_ONLY, + )) + .await; + + assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors); + let snapshot = hub.snapshot().await; + assert_eq!(snapshot.len(), 1); + let v: serde_json::Value = serde_json::from_str(&snapshot[0]).unwrap(); + assert_eq!(v["properties"]["tab"], "map"); + assert_eq!(v["properties"]["index"], 2); + assert_eq!(v["properties"]["pinned"], true); + assert!(v["properties"]["note"].is_null()); + } + + #[tokio::test] + async fn send_interaction_event_rejects_nested_properties() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub.clone()); + + for bad in [r#"{ nested: { a: 1 } }"#, r#"{ list: [1, 2] }"#] { + let resp = schema + .execute(request( + &format!( + r#"mutation {{ + sendInteractionEvent(input: {{ + sessionId: "sess-1", + appVersion: "1.2.3", + platform: IOS, + channel: PRODUCTION, + timestamp: 1, + eventName: "tab_change", + properties: {bad} + }}) {{ sessionId }} + }}"# + ), + EVENTS_ONLY, + )) + .await; + + assert!(!resp.errors.is_empty(), "expected rejection for {bad}"); + } + + assert!(hub.snapshot().await.is_empty()); } #[tokio::test] @@ -782,6 +924,9 @@ mod tests { r#"mutation { sendInteractionEvent(input: { sessionId: "sess-1", + appVersion: "1.2.3", + platform: IOS, + channel: PRODUCTION, timestamp: 1, eventName: "app_launch" }) { sessionId } @@ -808,6 +953,9 @@ mod tests { r#"mutation { sendInteractionEvent(input: { sessionId: "sess-1", + appVersion: "1.2.3", + platform: IOS, + channel: PRODUCTION, timestamp: 1, eventName: " " }) { sessionId } @@ -831,6 +979,9 @@ mod tests { r#"mutation { sendInteractionEvent(input: { sessionId: "sess-1", + appVersion: "1.2.3", + platform: IOS, + channel: PRODUCTION, timestamp: 1, eventName: "app_launch" }) { sessionId } diff --git a/src/server.rs b/src/server.rs index e421791..38e0436 100644 --- a/src/server.rs +++ b/src/server.rs @@ -501,6 +501,9 @@ mod tests { r#"mutation { sendLogEvent(input: { sessionId: "sess-1", + appVersion: "1.2.3", + platform: IOS, + channel: PRODUCTION, device: "test-device", timestamp: 1706000000000, type: APP, diff --git a/src/storage.rs b/src/storage.rs index 735108d..a23ce76 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -120,6 +120,9 @@ impl Storage { id TEXT PRIMARY KEY, session_id TEXT, device TEXT, + app_version TEXT, + platform TEXT, + channel TEXT, log_type TEXT NOT NULL, log_level TEXT NOT NULL, message TEXT NOT NULL, @@ -137,6 +140,10 @@ impl Storage { id TEXT PRIMARY KEY, session_id TEXT, device TEXT, + app_version TEXT, + platform TEXT, + channel TEXT, + properties JSONB, event_name TEXT NOT NULL, timestamp BIGINT NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() @@ -146,6 +153,19 @@ impl Storage { .execute(pool) .await?; + sqlx::query("ALTER TABLE interaction_events ADD COLUMN IF NOT EXISTS app_version TEXT;") + .execute(pool) + .await?; + sqlx::query("ALTER TABLE interaction_events ADD COLUMN IF NOT EXISTS platform TEXT;") + .execute(pool) + .await?; + sqlx::query("ALTER TABLE interaction_events ADD COLUMN IF NOT EXISTS channel TEXT;") + .execute(pool) + .await?; + sqlx::query("ALTER TABLE interaction_events ADD COLUMN IF NOT EXISTS properties JSONB;") + .execute(pool) + .await?; + sqlx::query( "CREATE INDEX IF NOT EXISTS idx_location_logs_device ON location_logs (device);", ) @@ -166,6 +186,15 @@ impl Storage { sqlx::query("ALTER TABLE log_events ADD COLUMN IF NOT EXISTS session_id TEXT;") .execute(pool) .await?; + sqlx::query("ALTER TABLE log_events ADD COLUMN IF NOT EXISTS app_version TEXT;") + .execute(pool) + .await?; + sqlx::query("ALTER TABLE log_events ADD COLUMN IF NOT EXISTS platform TEXT;") + .execute(pool) + .await?; + sqlx::query("ALTER TABLE log_events ADD COLUMN IF NOT EXISTS channel TEXT;") + .execute(pool) + .await?; sqlx::query("CREATE INDEX IF NOT EXISTS idx_log_events_device ON log_events (device);") .execute(pool) @@ -227,11 +256,14 @@ impl Storage { let ts = i64::try_from(log.timestamp).unwrap_or(i64::MAX); sqlx::query( - "INSERT INTO log_events (id, session_id, device, log_type, log_level, message, timestamp) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO NOTHING", + "INSERT INTO log_events (id, session_id, device, app_version, platform, channel, log_type, log_level, message, timestamp) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (id) DO NOTHING", ) .bind(&log.id) .bind(&log.session_id) .bind(&log.device) + .bind(&log.app_version) + .bind(log.platform.as_str()) + .bind(log.channel.as_str()) .bind(log_type_str(&log.log.r#type)) .bind(log_level_str(&log.log.level)) .bind(&log.log.message) @@ -250,12 +282,21 @@ impl Storage { let ts = i64::try_from(event.timestamp).unwrap_or(i64::MAX); + let properties = event + .properties + .as_ref() + .and_then(|p| serde_json::to_value(p).ok()); + sqlx::query( - "INSERT INTO interaction_events (id, session_id, device, event_name, timestamp) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO NOTHING", + "INSERT INTO interaction_events (id, session_id, device, app_version, platform, channel, properties, event_name, timestamp) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO NOTHING", ) .bind(&event.id) .bind(&event.session_id) .bind(&event.device) + .bind(&event.app_version) + .bind(event.platform.as_str()) + .bind(event.channel.as_str()) + .bind(properties) .bind(&event.event_name) .bind(ts) .execute(pool) From 88ad7840754f86268d78ac101225fcc2204bf6f5 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 6 Jul 2026 15:03:24 +0900 Subject: [PATCH 07/13] =?UTF-8?q?GraphQL=E3=81=AEenum=E5=80=A4=E3=82=92?= =?UTF-8?q?=E5=B0=8F=E6=96=87=E5=AD=97=E3=81=AB=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - async-graphqlのデフォルト(SCREAMING_SNAKE_CASE)を rename_items = "lowercase" で上書き - platform: ios / channel: production など全enumが対象 (type, level, state, batteryState, bucketSize も統一) - WS配信・DB表現(小文字)とGraphQL表記が一致するように BREAKING CHANGE: 旧来の大文字enum値(IOS, PRODUCTION等)は拒否される Co-Authored-By: Claude Fable 5 --- README.md | 22 ++++----- docs/react-tanstack-query.md | 46 +++++++++---------- src/domain.rs | 6 +++ src/graphql.rs | 89 ++++++++++++++++++------------------ src/server.rs | 10 ++-- 5 files changed, 90 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 43fff34..d0ff575 100644 --- a/README.md +++ b/README.md @@ -116,11 +116,11 @@ mutation { sessionId: "d0f7..." # client-generated unique session identifier device: "device-001" # optional — omit to submit anonymously appVersion: "1.2.3" - platform: IOS # IOS | ANDROID | MACOS | UNKNOWN - channel: PRODUCTION # PRODUCTION | CANARY + platform: ios # ios | android | macos | unknown + channel: production # production | canary timestamp: 1706000000000 - type: APP # SYSTEM | APP | CLIENT - level: INFO # DEBUG | INFO | WARN | ERROR + type: app # system | app | client + level: info # debug | info | warn | error message: "GPS signal acquired" }) { sessionId @@ -140,8 +140,8 @@ mutation { sessionId: "d0f7..." # client-generated unique session identifier device: "device-001" # optional — omit to submit anonymously appVersion: "1.2.3" - platform: IOS # IOS | ANDROID | MACOS | UNKNOWN - channel: PRODUCTION # PRODUCTION | CANARY + platform: ios # ios | android | macos | unknown + channel: production # production | canary timestamp: 1706000000000 eventName: "tab_change" # arbitrary event name properties: { tab: "map", index: 2, pinned: true } # optional flat map @@ -162,7 +162,7 @@ mutation { sendLocation(input: { sessionId: "d0f7..." # client-generated unique session identifier device: "device-001" - state: MOVING # ARRIVED | APPROACHING | PASSING | MOVING + state: moving # arrived | approaching | passing | moving lineId: 11302 coords: { latitude: 35.6812 @@ -178,7 +178,7 @@ mutation { } ``` -`stationId` is only meaningful when `state` is `ARRIVED` or `PASSING` and is ignored otherwise. `batteryLevel` (0.0–1.0) and `batteryState` (`UNKNOWN | UNPLUGGED | CHARGING | FULL`) are optional. +`stationId` is only meaningful when `state` is `arrived` or `passing` and is ignored otherwise. `batteryLevel` (0.0–1.0) and `batteryState` (`unknown | unplugged | charging | full`) are optional. #### `accuracyByLine` — Aggregated accuracy report @@ -190,7 +190,7 @@ query { lineId: "45" from: "2024-12-01T00:00:00Z" to: "2024-12-03T00:00:00Z" - bucketSize: HOUR + bucketSize: hour limit: 100 ) { lineId @@ -210,10 +210,10 @@ query { | `lineId` | `ID!` | Line ID | | `from` | `DateTime!` | Start of the time range | | `to` | `DateTime!` | End of the time range | -| `bucketSize` | `TimeBucketSize!` | `MINUTE`, `HOUR`, or `DAY` | +| `bucketSize` | `TimeBucketSize!` | `minute`, `hour`, or `day` | | `limit` | `Int` | Max buckets returned (default 500, cap 2000) | -Maximum time span per bucket size: MINUTE ≤ 7 days, HOUR ≤ 90 days, DAY ≤ 365 days. +Maximum time span per bucket size: minute ≤ 7 days, hour ≤ 90 days, day ≤ 365 days. #### `GET /healthz` — Health check diff --git a/docs/react-tanstack-query.md b/docs/react-tanstack-query.md index a9d69b0..ea2219a 100644 --- a/docs/react-tanstack-query.md +++ b/docs/react-tanstack-query.md @@ -133,7 +133,7 @@ export function useAccuracyByLine(params: { lineId: string; from: string; // ISO 8601 (例: "2026-07-01T00:00:00Z") to: string; - bucketSize: "MINUTE" | "HOUR" | "DAY"; + bucketSize: "minute" | "hour" | "day"; limit?: number; }) { return useQuery({ @@ -152,7 +152,7 @@ function AccuracyChart() { lineId: "11302", from: "2026-07-01T00:00:00Z", to: "2026-07-06T00:00:00Z", - bucketSize: "HOUR", + bucketSize: "hour", }); if (isPending) return

読み込み中…

; @@ -170,7 +170,7 @@ function AccuracyChart() { } ``` -バケットサイズごとの最大期間(MINUTE ≤ 7 日、HOUR ≤ 90 日、DAY ≤ 365 日)を超えるとエラーになる点に注意してください。 +バケットサイズごとの最大期間(minute ≤ 7 日、hour ≤ 90 日、day ≤ 365 日)を超えるとエラーになる点に注意してください。 ## Mutation: ログイベント送信(`sendLogEvent`) @@ -193,11 +193,11 @@ export interface LogEventInput { sessionId: string; // 必須。クライアント側で生成した一意な文字列(後述) device?: string; // 匿名性確保のため省略可。省略時は null として配信・保存される appVersion: string; // 必須。アプリのバージョン文字列(空はサーバーが拒否) - platform: "IOS" | "ANDROID" | "MACOS" | "UNKNOWN"; // 必須 - channel: "PRODUCTION" | "CANARY"; // 必須 + platform: "ios" | "android" | "macos" | "unknown"; // 必須 + channel: "production" | "canary"; // 必須 timestamp: number; // Unix ミリ秒 (Date.now()) - type: "SYSTEM" | "APP" | "CLIENT"; - level: "DEBUG" | "INFO" | "WARN" | "ERROR"; + type: "system" | "app" | "client"; + level: "debug" | "info" | "warn" | "error"; message: string; // 空文字・空白のみはサーバーが拒否 } @@ -218,11 +218,11 @@ sendLog.mutate({ sessionId, device: "device-001", appVersion: "1.2.3", - platform: "IOS", - channel: "PRODUCTION", + platform: "ios", + channel: "production", timestamp: Date.now(), - type: "APP", - level: "INFO", + type: "app", + level: "info", message: "GPS signal acquired", }); ``` @@ -233,11 +233,11 @@ sendLog.mutate({ sendLog.mutate({ sessionId, appVersion: "1.2.3", - platform: "IOS", - channel: "PRODUCTION", + platform: "ios", + channel: "production", timestamp: Date.now(), - type: "APP", - level: "INFO", + type: "app", + level: "info", message: "started", }); ``` @@ -287,8 +287,8 @@ export interface InteractionEventInput { sessionId: string; // 必須。クライアント側で生成した一意な文字列 device?: string; // 匿名性確保のため省略可 appVersion: string; // 必須。アプリのバージョン文字列(空はサーバーが拒否) - platform: "IOS" | "ANDROID" | "MACOS" | "UNKNOWN"; // 必須 - channel: "PRODUCTION" | "CANARY"; // 必須 + platform: "ios" | "android" | "macos" | "unknown"; // 必須 + channel: "production" | "canary"; // 必須 timestamp: number; // Unix ミリ秒 (Date.now()) eventName: string; // 任意のイベント名。空文字・空白のみはサーバーが拒否 properties?: Record; // 省略可(後述) @@ -321,8 +321,8 @@ const track = ( sendInteraction.mutate({ sessionId, appVersion: "1.2.3", - platform: "IOS", - channel: "PRODUCTION", + platform: "ios", + channel: "production", timestamp: Date.now(), eventName, properties, @@ -364,8 +364,8 @@ const SEND_LOCATION = /* GraphQL */ ` export interface LocationEventInput { sessionId: string; // 必須。クライアント側で生成した一意な文字列 device: string; // 必須(sendLogEvent と異なり省略不可) - state: "ARRIVED" | "APPROACHING" | "PASSING" | "MOVING"; - stationId?: number; // ARRIVED / PASSING のときのみ有効。MOVING / APPROACHING では無視される + state: "arrived" | "approaching" | "passing" | "moving"; + stationId?: number; // arrived / passing のときのみ有効。moving / approaching では無視される lineId: number; coords: { latitude: number; // -90〜90 @@ -375,7 +375,7 @@ export interface LocationEventInput { }; timestamp: number; // Unix ミリ秒 batteryLevel?: number; // 0.0〜1.0 - batteryState?: "UNKNOWN" | "UNPLUGGED" | "CHARGING" | "FULL"; + batteryState?: "unknown" | "unplugged" | "charging" | "full"; } interface SendLocationData { @@ -406,7 +406,7 @@ useEffect(() => { sendLocation.mutate({ sessionId, device: "device-001", - state: "MOVING", + state: "moving", lineId: 11302, coords: { latitude: pos.coords.latitude, diff --git a/src/domain.rs b/src/domain.rs index b915bee..bda70fe 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -29,6 +29,7 @@ async_graphql::scalar!( ); #[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq, Enum)] +#[graphql(rename_items = "lowercase")] #[repr(u8)] pub enum BatteryState { Unknown = 0, @@ -38,6 +39,7 @@ pub enum BatteryState { } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] +#[graphql(rename_items = "lowercase")] #[serde(rename_all = "snake_case")] pub enum MovementState { Arrived, @@ -58,6 +60,7 @@ impl MovementState { } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] +#[graphql(rename_items = "lowercase")] #[serde(rename_all = "snake_case")] pub enum Platform { Ios, @@ -78,6 +81,7 @@ impl Platform { } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] +#[graphql(rename_items = "lowercase")] #[serde(rename_all = "snake_case")] pub enum Channel { Production, @@ -94,6 +98,7 @@ impl Channel { } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] +#[graphql(rename_items = "lowercase")] #[serde(rename_all = "snake_case")] pub enum LogLevel { Debug, @@ -114,6 +119,7 @@ impl LogLevel { } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] +#[graphql(rename_items = "lowercase")] #[serde(rename_all = "snake_case")] pub enum LogType { System, diff --git a/src/graphql.rs b/src/graphql.rs index d7712df..a139fb9 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -36,6 +36,7 @@ pub struct MutationAuth { const HARD_LIMIT: i32 = 2000; #[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)] +#[graphql(rename_items = "lowercase")] pub enum TimeBucketSize { Minute, Hour, @@ -250,7 +251,7 @@ pub struct LocationEventInput { /// Device identifier. pub device: String, pub state: MovementState, - /// Only meaningful when state is ARRIVED or PASSING; ignored otherwise. + /// Only meaningful when state is arrived or passing; ignored otherwise. pub station_id: Option, pub line_id: i32, pub coords: CoordsInput, @@ -609,12 +610,12 @@ mod tests { sendLogEvent(input: { sessionId: "sess-abc", appVersion: "1.2.3", - platform: IOS, - channel: PRODUCTION, + platform: ios, + channel: production, device: "dev", timestamp: 1706000000000, - type: APP, - level: INFO, + type: app, + level: info, message: "hello" }) { sessionId } }"#, @@ -651,11 +652,11 @@ mod tests { sendLogEvent(input: { sessionId: "sess-1", appVersion: " ", - platform: ANDROID, - channel: CANARY, + platform: android, + channel: canary, timestamp: 1, - type: APP, - level: INFO, + type: app, + level: info, message: "hi" }) { sessionId } }"#, @@ -679,11 +680,11 @@ mod tests { sendLogEvent(input: { sessionId: "sess-anon", appVersion: "1.2.3", - platform: IOS, - channel: PRODUCTION, + platform: ios, + channel: production, timestamp: 1, - type: APP, - level: INFO, + type: app, + level: info, message: "anonymous hello" }) { sessionId } }"#, @@ -710,7 +711,7 @@ mod tests { r#"mutation { sendLocation(input: { sessionId: "sess-1", - state: MOVING, + state: moving, lineId: 1, coords: { latitude: 35.6812, longitude: 139.7671 }, timestamp: 1 @@ -737,12 +738,12 @@ mod tests { sendLogEvent(input: { sessionId: " ", appVersion: "1.2.3", - platform: IOS, - channel: PRODUCTION, + platform: ios, + channel: production, device: "dev", timestamp: 1, - type: SYSTEM, - level: WARN, + type: system, + level: warn, message: "hi" }) { sessionId } }"#, @@ -766,12 +767,12 @@ mod tests { sendLogEvent(input: { sessionId: "sess-1", appVersion: "1.2.3", - platform: IOS, - channel: PRODUCTION, + platform: ios, + channel: production, device: "dev", timestamp: 1, - type: APP, - level: INFO, + type: app, + level: info, message: " " }) { sessionId } }"#, @@ -795,12 +796,12 @@ mod tests { sendLogEvent(input: { sessionId: "sess-1", appVersion: "1.2.3", - platform: IOS, - channel: PRODUCTION, + platform: ios, + channel: production, device: "dev", timestamp: 1, - type: APP, - level: INFO, + type: app, + level: info, message: "hi" }) { sessionId } }"#, @@ -824,8 +825,8 @@ mod tests { sendInteractionEvent(input: { sessionId: "sess-1", appVersion: "1.2.3", - platform: IOS, - channel: PRODUCTION, + platform: ios, + channel: production, device: "dev", timestamp: 1706000000000, eventName: "tts_request" @@ -862,8 +863,8 @@ mod tests { sendInteractionEvent(input: { sessionId: "sess-1", appVersion: "1.2.3", - platform: IOS, - channel: PRODUCTION, + platform: ios, + channel: production, timestamp: 1, eventName: "tab_change", properties: { tab: "map", index: 2, pinned: true, note: null } @@ -896,8 +897,8 @@ mod tests { sendInteractionEvent(input: {{ sessionId: "sess-1", appVersion: "1.2.3", - platform: IOS, - channel: PRODUCTION, + platform: ios, + channel: production, timestamp: 1, eventName: "tab_change", properties: {bad} @@ -925,8 +926,8 @@ mod tests { sendInteractionEvent(input: { sessionId: "sess-1", appVersion: "1.2.3", - platform: IOS, - channel: PRODUCTION, + platform: ios, + channel: production, timestamp: 1, eventName: "app_launch" }) { sessionId } @@ -954,8 +955,8 @@ mod tests { sendInteractionEvent(input: { sessionId: "sess-1", appVersion: "1.2.3", - platform: IOS, - channel: PRODUCTION, + platform: ios, + channel: production, timestamp: 1, eventName: " " }) { sessionId } @@ -980,8 +981,8 @@ mod tests { sendInteractionEvent(input: { sessionId: "sess-1", appVersion: "1.2.3", - platform: IOS, - channel: PRODUCTION, + platform: ios, + channel: production, timestamp: 1, eventName: "app_launch" }) { sessionId } @@ -1002,7 +1003,7 @@ mod tests { let resp = schema .execute(request( - &location_mutation("MOVING", ", speed: 50.0"), + &location_mutation("moving", ", speed: 50.0"), TELEMETRY, )) .await; @@ -1025,7 +1026,7 @@ mod tests { let schema = test_schema(hub.clone()); let resp = schema - .execute(request(&location_mutation("MOVING", ""), EVENTS_ONLY)) + .execute(request(&location_mutation("moving", ""), EVENTS_ONLY)) .await; assert!(!resp.errors.is_empty()); @@ -1044,7 +1045,7 @@ mod tests { sendLocation(input: { sessionId: "sess-1", device: "dev", - state: MOVING, + state: moving, lineId: 1, coords: { latitude: 91.0, longitude: 139.7671 }, timestamp: 1 @@ -1066,7 +1067,7 @@ mod tests { let resp = schema .execute(request( - &location_mutation("MOVING", ", accuracy: -1.0"), + &location_mutation("moving", ", accuracy: -1.0"), TELEMETRY, )) .await; @@ -1083,7 +1084,7 @@ mod tests { let resp = schema .execute(request( - &location_mutation("MOVING", ", accuracy: 150.0"), + &location_mutation("moving", ", accuracy: 150.0"), TELEMETRY, )) .await; @@ -1107,7 +1108,7 @@ mod tests { sendLocation(input: { sessionId: "sess-1", device: "dev", - state: MOVING, + state: moving, stationId: 42, lineId: 1, coords: { latitude: 35.6812, longitude: 139.7671 }, diff --git a/src/server.rs b/src/server.rs index 38e0436..5696b08 100644 --- a/src/server.rs +++ b/src/server.rs @@ -502,12 +502,12 @@ mod tests { sendLogEvent(input: { sessionId: "sess-1", appVersion: "1.2.3", - platform: IOS, - channel: PRODUCTION, + platform: ios, + channel: production, device: "test-device", timestamp: 1706000000000, - type: APP, - level: INFO, + type: app, + level: info, message: "Hello, world!" }) { sessionId } }"#, @@ -521,7 +521,7 @@ mod tests { sendLocation(input: { sessionId: "sess-1", device: "test-device", - state: MOVING, + state: moving, lineId: 1, coords: { latitude: 35.6812, longitude: 139.7671 }, timestamp: 1706000000000 From 62fa41791f0c8e679d1fbd72dbe8408f9afcefba Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Sat, 11 Jul 2026 14:51:21 +0900 Subject: [PATCH 08/13] =?UTF-8?q?=E8=A6=B3=E6=B8=AC=E3=83=89=E3=82=AD?= =?UTF-8?q?=E3=83=A5=E3=83=A1=E3=83=B3=E3=83=88=E3=81=8B=E3=82=89=E5=B1=A5?= =?UTF-8?q?=E6=AD=B4=E5=8F=96=E5=BE=97=E6=89=8B=E6=AE=B5=E3=81=A8=E3=81=97?= =?UTF-8?q?=E3=81=A6=E3=81=AEDB=E5=8F=82=E7=85=A7=E3=82=92=E5=89=8A?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit フロント(観測用トークンのみ)はDBへ直接アクセスできないため、 バッファ外の履歴取得先としてDBを案内するのは誤り。認証不要の GraphQL Query accuracyByLine(集計)への案内のみ残す。 Co-Authored-By: Claude Opus 4.8 --- docs/react-websocket-observer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/react-websocket-observer.md b/docs/react-websocket-observer.md index 8946091..60095b9 100644 --- a/docs/react-websocket-observer.md +++ b/docs/react-websocket-observer.md @@ -297,5 +297,5 @@ VITE_THQ_OBSERVER_TOKEN=<観測用トークン> ## 運用上の注意 - **切断は日常的に起きる**ものとして扱ってください。上記フックのようにバックオフ付きで再接続し、スナップショット再送は `id` の重複排除で吸収します。 -- リングバッファの件数はサーバー側の `ring_size`(デフォルト 1000)で決まります。接続前のイベントをそれ以上さかのぼることはできないため、履歴が必要な場合は GraphQL の `accuracyByLine`(集計)や DB を参照してください。 +- リングバッファの件数はサーバー側の `ring_size`(デフォルト 1000)で決まります。接続前のイベントをそれ以上さかのぼることはできないため、履歴が必要な場合は GraphQL の `accuracyByLine`(集計)を参照してください。 - WebSocket は CORS の制約を受けないため、GraphQL と違いリバースプロキシなしでクロスオリジン接続できます。ただし `wss://`(TLS)を使わないと HTTPS ページからは接続できません(混在コンテンツとしてブロックされます)。 From 053cd4b83f60b8b01f52152241ce5ebda5a56bfe Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Sat, 11 Jul 2026 14:53:36 +0900 Subject: [PATCH 09/13] =?UTF-8?q?GraphQL=20Playground=20=E3=81=A8=20THQ=5F?= =?UTF-8?q?AUTH=5FREQUIRED=20=E3=82=92=E5=BB=83=E6=AD=A2=E3=81=97=E8=AA=8D?= =?UTF-8?q?=E8=A8=BC=E3=82=92=E5=B8=B8=E6=99=82=E5=BF=85=E9=A0=88=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /graphql の Playground を削除し POST /graphql のみに - 認証の有効/無効トグル THQ_AUTH_REQUIRED を廃止。認証は常に必須とし、 トークンが1つも設定されていない場合は起動時にエラーで終了する - 関連する設定(README / docker-compose / .env.example)とドキュメントを更新 Co-Authored-By: Claude Opus 4.8 --- .env.example | 1 - README.md | 10 ++--- docker-compose.yml | 1 - docs/react-tanstack-query.md | 2 +- src/config.rs | 77 ++++++++++-------------------------- src/main.rs | 1 - src/server.rs | 71 ++------------------------------- 7 files changed, 27 insertions(+), 136 deletions(-) diff --git a/.env.example b/.env.example index ff7368e..53e4246 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,4 @@ THQ_OBSERVER_AUTH_TOKEN=change-me-observer THQ_EVENTS_AUTH_TOKEN=change-me-events THQ_TELEMETRY_AUTH_TOKEN=change-me-telemetry -THQ_AUTH_REQUIRED=true POSTGRES_PASSWORD=change-me diff --git a/README.md b/README.md index d0ff575..a6b4b15 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Endpoints after startup: | Endpoint | URL | |---|---| | WebSocket | `ws://localhost:8080/ws` | -| GraphQL Playground | `http://localhost:8080/graphql` | +| GraphQL | `http://localhost:8080/graphql` (POST) | | Health check | `http://localhost:8080/healthz` | ## Configuration @@ -68,7 +68,6 @@ database_url = "postgres://user:pass@localhost:5432/thq" observer_auth_token = "change-me-observer" events_auth_token = "change-me-events" telemetry_auth_token = "change-me-telemetry" -auth_required = true ``` | Key | Environment variable | Default | Description | @@ -80,9 +79,6 @@ auth_required = true | `observer_auth_token` | `THQ_OBSERVER_AUTH_TOKEN` | — | Token for WebSocket observers | | `events_auth_token` | `THQ_EVENTS_AUTH_TOKEN` | — | Token allowed to send log events | | `telemetry_auth_token` | `THQ_TELEMETRY_AUTH_TOKEN` | — | Token allowed to send log events **and** location updates | -| `auth_required` | `THQ_AUTH_REQUIRED` | `true`* | Require authentication | - -\* Defaults to `true` when any token is configured. ## Authentication @@ -98,13 +94,13 @@ Three shared secrets grant exactly one role each: - **GraphQL mutations** — send the events or telemetry token via `Authorization: Bearer ` - **GraphQL queries** — no authentication (aggregated data only) -Set `auth_required = false` to skip all authentication during local development. +Authentication is always enforced. At least one token must be configured, or the server refuses to start. ## API ### GraphQL -Endpoint: `POST /graphql` (Playground: `GET /graphql`) +Endpoint: `POST /graphql` #### `sendLogEvent` — Submit a log event diff --git a/docker-compose.yml b/docker-compose.yml index a31517d..c2139a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,6 @@ services: THQ_OBSERVER_AUTH_TOKEN: ${THQ_OBSERVER_AUTH_TOKEN:?set in .env or shell} THQ_EVENTS_AUTH_TOKEN: ${THQ_EVENTS_AUTH_TOKEN:?set in .env or shell} THQ_TELEMETRY_AUTH_TOKEN: ${THQ_TELEMETRY_AUTH_TOKEN:?set in .env or shell} - THQ_AUTH_REQUIRED: ${THQ_AUTH_REQUIRED:-true} # RUST_LOG: debug,thq_server=debug # RUST_BACKTRACE: 1 THQ_LINE_TOPOLOGY_PATH: /app/static/join.csv diff --git a/docs/react-tanstack-query.md b/docs/react-tanstack-query.md index ea2219a..bf04b0e 100644 --- a/docs/react-tanstack-query.md +++ b/docs/react-tanstack-query.md @@ -4,7 +4,7 @@ ## 前提 -thq-server の GraphQL API はエンドポイント `POST /graphql` で公開されています(Playground: `GET /graphql`)。 +thq-server の GraphQL API はエンドポイント `POST /graphql` で公開されています。 | 操作 | 種別 | 認証 | |---|---|---| diff --git a/src/config.rs b/src/config.rs index fe2064c..b716f5c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,10 +43,6 @@ pub struct Cli { /// Shared secret allowed to send both log events and location updates via GraphQL (Bearer) #[arg(long, env = "THQ_TELEMETRY_AUTH_TOKEN", value_name = "TOKEN")] pub telemetry_auth_token: Option, - - /// Whether authentication is required (true/false). Defaults to true when any token is supplied. - #[arg(long, env = "THQ_AUTH_REQUIRED")] - pub auth_required: Option, } #[derive(Debug, Clone)] @@ -58,7 +54,6 @@ pub struct Config { pub observer_auth_token: Option, pub events_auth_token: Option, pub telemetry_auth_token: Option, - pub auth_required: bool, } #[derive(Debug, Deserialize, Default)] @@ -70,7 +65,6 @@ struct FileConfig { observer_auth_token: Option, events_auth_token: Option, telemetry_auth_token: Option, - auth_required: Option, } impl Config { @@ -105,23 +99,14 @@ impl Config { if let Some(telemetry_auth_token) = cli.telemetry_auth_token { file_cfg.telemetry_auth_token = Some(telemetry_auth_token); } - if let Some(auth_required) = cli.auth_required { - file_cfg.auth_required = Some(auth_required); - } let any_token = file_cfg.observer_auth_token.is_some() || file_cfg.events_auth_token.is_some() || file_cfg.telemetry_auth_token.is_some(); - let auth_required = match (file_cfg.auth_required, any_token) { - (Some(required), _) => required, - (None, true) => true, - (None, false) => false, - }; - - if auth_required && !any_token { + if !any_token { anyhow::bail!( - "auth_required=true but no auth token is configured; set THQ_OBSERVER_AUTH_TOKEN / THQ_EVENTS_AUTH_TOKEN / THQ_TELEMETRY_AUTH_TOKEN or disable auth" + "no auth token is configured; set THQ_OBSERVER_AUTH_TOKEN / THQ_EVENTS_AUTH_TOKEN / THQ_TELEMETRY_AUTH_TOKEN" ); } @@ -133,7 +118,6 @@ impl Config { observer_auth_token: file_cfg.observer_auth_token, events_auth_token: file_cfg.events_auth_token, telemetry_auth_token: file_cfg.telemetry_auth_token, - auth_required, }) } } @@ -154,7 +138,6 @@ mod tests { observer_auth_token: None, events_auth_token: None, telemetry_auth_token: None, - auth_required: None, } } @@ -166,21 +149,28 @@ mod tests { #[test] fn defaults_are_used_when_no_cli_or_file() { - let cfg = Config::from_cli(empty_cli()).unwrap(); + let cfg = Config::from_cli(Cli { + observer_auth_token: Some("secret".into()), + ..empty_cli() + }) + .unwrap(); assert_eq!(cfg.host, "0.0.0.0"); assert_eq!(cfg.port, 8080); assert_eq!(cfg.ring_size, 1000); - assert!(cfg.observer_auth_token.is_none()); + assert_eq!(cfg.observer_auth_token.as_deref(), Some("secret")); assert!(cfg.events_auth_token.is_none()); assert!(cfg.telemetry_auth_token.is_none()); - assert!(!cfg.auth_required); } #[test] fn file_values_are_loaded() { let path = tmp_path("config_file_values"); - fs::write(&path, "host = '127.0.0.1'\nport = 9000\nring_size = 50").unwrap(); + fs::write( + &path, + "host = '127.0.0.1'\nport = 9000\nring_size = 50\nobserver_auth_token = 'file-observer'", + ) + .unwrap(); let cfg = Config::from_cli(Cli { config: Some(path.clone()), @@ -214,7 +204,6 @@ mod tests { observer_auth_token: Some("cli-observer".into()), events_auth_token: Some("cli-events".into()), telemetry_auth_token: Some("cli-telemetry".into()), - auth_required: Some(false), }) .unwrap(); @@ -225,7 +214,6 @@ mod tests { assert_eq!(cfg.observer_auth_token.as_deref(), Some("cli-observer")); assert_eq!(cfg.events_auth_token.as_deref(), Some("cli-events")); assert_eq!(cfg.telemetry_auth_token.as_deref(), Some("cli-telemetry")); - assert!(!cfg.auth_required); let _ = fs::remove_file(path); } @@ -233,7 +221,11 @@ mod tests { #[test] fn database_url_loaded_from_file() { let path = tmp_path("config_db_url"); - fs::write(&path, "database_url = 'postgres://user:pass@localhost/db'").unwrap(); + fs::write( + &path, + "database_url = 'postgres://user:pass@localhost/db'\nobserver_auth_token = 'x'", + ) + .unwrap(); let cfg = Config::from_cli(Cli { config: Some(path.clone()), @@ -250,37 +242,8 @@ mod tests { } #[test] - fn auth_defaults_to_required_when_any_token_present() { - let cfg = Config::from_cli(Cli { - events_auth_token: Some("secret".into()), - ..empty_cli() - }) - .unwrap(); - - assert!(cfg.auth_required); - assert_eq!(cfg.events_auth_token.as_deref(), Some("secret")); - } - - #[test] - fn auth_can_be_disabled_explicitly() { - let cfg = Config::from_cli(Cli { - observer_auth_token: Some("secret".into()), - auth_required: Some(false), - ..empty_cli() - }) - .unwrap(); - - assert!(!cfg.auth_required); - assert_eq!(cfg.observer_auth_token.as_deref(), Some("secret")); - } - - #[test] - fn auth_required_without_tokens_is_rejected() { - let err = Config::from_cli(Cli { - auth_required: Some(true), - ..empty_cli() - }) - .unwrap_err(); + fn no_tokens_is_rejected() { + let err = Config::from_cli(empty_cli()).unwrap_err(); assert!(err.to_string().contains("no auth token")); } diff --git a/src/main.rs b/src/main.rs index d0e4951..627ce8e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,6 @@ async fn main() -> anyhow::Result<()> { observer_auth_configured = config.observer_auth_token.is_some(), events_auth_configured = config.events_auth_token.is_some(), telemetry_auth_configured = config.telemetry_auth_token.is_some(), - auth_required = config.auth_required, "starting thq-server" ); match server::run_server(config).await { diff --git a/src/server.rs b/src/server.rs index 5696b08..cde36d4 100644 --- a/src/server.rs +++ b/src/server.rs @@ -3,7 +3,6 @@ use std::{net::SocketAddr, sync::Arc}; use subtle::ConstantTimeEq; use anyhow::Context; -use async_graphql::http::{playground_source, GraphQLPlaygroundConfig}; use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; use axum::{ extract::{ @@ -11,13 +10,12 @@ use axum::{ ConnectInfo, State, }, http::{header::AUTHORIZATION, header::SEC_WEBSOCKET_PROTOCOL, HeaderMap, StatusCode}, - response::{Html, IntoResponse}, - routing::get, + response::IntoResponse, + routing::{get, post}, Router, }; use futures::{SinkExt, StreamExt}; use tokio::sync::mpsc; -use tracing::warn; use uuid::Uuid; use crate::{ @@ -38,7 +36,6 @@ struct AuthConfig { observer_token: Option, events_token: Option, telemetry_token: Option, - required: bool, } #[derive(Clone)] @@ -82,12 +79,6 @@ pub async fn run_server(config: Config) -> anyhow::Result<()> { tracing::info!("database_url not set; persistence is disabled"); } - if !config.auth_required { - warn!( - "authentication is disabled; every client gets observer, events and telemetry access" - ); - } - let schema = build_schema(storage, hub.clone(), segmenter); let state = AppState { @@ -96,7 +87,6 @@ pub async fn run_server(config: Config) -> anyhow::Result<()> { observer_token: config.observer_auth_token.clone(), events_token: config.events_auth_token.clone(), telemetry_token: config.telemetry_auth_token.clone(), - required: config.auth_required, }, schema, }; @@ -105,7 +95,7 @@ pub async fn run_server(config: Config) -> anyhow::Result<()> { .route("/", get(ws_handler)) .route("/ws", get(ws_handler)) .route("/healthz", get(healthz)) - .route("/graphql", get(graphql_playground).post(graphql_handler)) + .route("/graphql", post(graphql_handler)) .with_state(state); let addr: SocketAddr = format!("{}:{}", config.host, config.port) @@ -164,23 +154,10 @@ async fn graphql_handler( state.schema.execute(req).await.into() } -async fn graphql_playground() -> impl IntoResponse { - Html(playground_source( - GraphQLPlaygroundConfig::new("/graphql").subscription_endpoint("/graphql"), - )) -} - /// Resolves the mutation scopes granted by the Authorization header. /// Queries stay open, so the result is carried into the GraphQL context /// instead of rejecting the request here. fn mutation_auth(headers: &HeaderMap, auth: &AuthConfig) -> MutationAuth { - if !auth.required { - return MutationAuth { - can_send_events: true, - can_send_location: true, - }; - } - let Some(token) = bearer_token(headers) else { return MutationAuth { can_send_events: false, @@ -320,10 +297,6 @@ impl AuthError { /// WebSocket observation is granted by the observer token only; the events /// and telemetry tokens deliberately do not open the subscription channel. fn enforce_ws_auth(header: Option<&str>, auth: &AuthConfig) -> Result<(), AuthError> { - if !auth.required { - return Ok(()); - } - let raw = header.ok_or(AuthError::MissingHeader)?; let parsed = parse_protocol_header(raw); @@ -446,21 +419,11 @@ mod tests { use tower::ServiceExt; use uuid::Uuid; - fn open_auth() -> AuthConfig { - AuthConfig { - observer_token: None, - events_token: None, - telemetry_token: None, - required: false, - } - } - fn scoped_auth() -> AuthConfig { AuthConfig { observer_token: Some("observer-secret".into()), events_token: Some("events-secret".into()), telemetry_token: Some("telemetry-secret".into()), - required: true, } } @@ -597,27 +560,6 @@ mod tests { // GraphQL mutations over HTTP - #[tokio::test] - async fn graphql_mutation_broadcasts_log_event_when_auth_disabled() { - let state = state_with_auth(open_auth()); - let hub = state.hub.clone(); - let app = graphql_router(state); - - let response = app.oneshot(send_log_event_request(None)).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let v = body_json(response).await; - assert!(v["errors"].is_null(), "errors: {}", v["errors"]); - assert_eq!(v["data"]["sendLogEvent"]["sessionId"], "sess-1"); - - let snapshot = hub.snapshot().await; - assert_eq!(snapshot.len(), 1); - let msg: Value = serde_json::from_str(&snapshot[0]).unwrap(); - assert_eq!(msg["type"], "log"); - assert_eq!(msg["session_id"], "sess-1"); - assert_eq!(msg["log"]["message"], "Hello, world!"); - } - #[tokio::test] async fn log_event_rejects_missing_auth() { let state = state_with_auth(scoped_auth()); @@ -735,13 +677,6 @@ mod tests { assert!(hub.snapshot().await.is_empty()); } - #[test] - fn mutation_auth_grants_everything_when_disabled() { - let auth = mutation_auth(&HeaderMap::new(), &open_auth()); - assert!(auth.can_send_events); - assert!(auth.can_send_location); - } - #[test] fn mutation_auth_denies_missing_header() { let auth = mutation_auth(&HeaderMap::new(), &scoped_auth()); From b4fb8065bc449da94e847e342e5e98ddcaeabbf7 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Sat, 11 Jul 2026 15:00:11 +0900 Subject: [PATCH 10/13] =?UTF-8?q?WS=E5=86=8D=E6=8E=A5=E7=B6=9A=E3=81=AE?= =?UTF-8?q?=E3=83=AA=E3=83=88=E3=83=A9=E3=82=A4=E5=9B=9E=E6=95=B0=E3=83=AA?= =?UTF-8?q?=E3=82=BB=E3=83=83=E3=83=88=E3=82=92=E5=AE=89=E5=AE=9A=E6=8E=A5?= =?UTF-8?q?=E7=B6=9A=E5=BE=8C=E3=81=AB=E9=99=90=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onopen で無条件に retries を 0 に戻すと、接続直後に切断される状態で MAX_RETRIES が実質無効化され、約1秒間隔の無限再接続(接続ストーム)に 陥る。一定時間(STABLE_CONNECTION_MS)安定して接続できた場合にのみ リセットし、安定前に切断されたらリセット予約を取り消すよう修正。 Co-Authored-By: Claude Opus 4.8 --- docs/react-websocket-observer.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/react-websocket-observer.md b/docs/react-websocket-observer.md index 60095b9..50013cc 100644 --- a/docs/react-websocket-observer.md +++ b/docs/react-websocket-observer.md @@ -181,6 +181,7 @@ export type TelemetryEvent = LocationUpdateEvent | LogEvent | InteractionEvent; const FEED_KEY = ["telemetryFeed"] as const; const MAX_EVENTS = 1000; const MAX_RETRIES = 10; +const STABLE_CONNECTION_MS = 10_000; // これだけ安定接続できたらリトライ回数をリセット export function useTelemetryFeed( wsUrl: string, @@ -192,6 +193,7 @@ export function useTelemetryFeed( useEffect(() => { let ws: WebSocket | null = null; let retryTimer: ReturnType; + let stableTimer: ReturnType; let retries = 0; let disposed = false; @@ -199,7 +201,11 @@ export function useTelemetryFeed( ws = new WebSocket(wsUrl, ["thq", `thq-auth-${observerToken}`]); ws.onopen = () => { - retries = 0; + // 接続直後に切断される状態だと MAX_RETRIES が無効化されてしまうため、 + // 一定時間安定して接続できたときにだけリトライ回数をリセットする + stableTimer = setTimeout(() => { + retries = 0; + }, STABLE_CONNECTION_MS); ws?.send(JSON.stringify({ type: "subscribe", device })); }; @@ -222,6 +228,8 @@ export function useTelemetryFeed( }; ws.onclose = () => { + // 安定接続に達する前に切断されたらリセット予約を取り消す(接続ストーム防止) + clearTimeout(stableTimer); // 認証失敗(401)もハンドシェイク失敗としてここに来るため、 // 無限リトライにならないよう回数上限と指数バックオフを入れる if (disposed || retries >= MAX_RETRIES) return; @@ -236,6 +244,7 @@ export function useTelemetryFeed( return () => { disposed = true; clearTimeout(retryTimer); + clearTimeout(stableTimer); ws?.close(); }; }, [wsUrl, observerToken, device, queryClient]); From 04e643e9964a54bf83b28b156bc5f721d86043cd Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Sat, 11 Jul 2026 15:08:10 +0900 Subject: [PATCH 11/13] =?UTF-8?q?=E7=A9=BA/=E7=A9=BA=E7=99=BD=E3=81=AE?= =?UTF-8?q?=E3=81=BF=E3=81=AE=E8=AA=8D=E8=A8=BC=E3=83=88=E3=83=BC=E3=82=AF?= =?UTF-8?q?=E3=83=B3=E3=82=92=E6=8B=92=E5=90=A6=E3=81=97README=E3=81=AE?= =?UTF-8?q?=E3=83=88=E3=83=BC=E3=82=AF=E3=83=B3=E6=A8=A9=E9=99=90=E8=A1=A8?= =?UTF-8?q?=E8=A8=98=E3=82=92=E8=A3=9C=E8=B6=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.rs: clap は空の環境変数を Some("") として渡すため、空文字・ 空白のみのトークンを未設定として正規化。空トークンのまま起動して 認証不能/空トークン認証が通る設定を弾く。検証テストを追加 - README: 環境変数表の events/telemetry トークン説明に sendInteractionEvent の権限を明記(権限表との齟齬を解消) CodeRabbit のレビュー指摘(PR #29)に対応。 Co-Authored-By: Claude Opus 4.8 --- README.md | 4 ++-- src/config.rs | 37 +++++++++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a6b4b15..154058b 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,8 @@ telemetry_auth_token = "change-me-telemetry" | `ring_size` | — | `1000` | Ring buffer capacity | | `database_url` | `DATABASE_URL` | — | PostgreSQL connection URL | | `observer_auth_token` | `THQ_OBSERVER_AUTH_TOKEN` | — | Token for WebSocket observers | -| `events_auth_token` | `THQ_EVENTS_AUTH_TOKEN` | — | Token allowed to send log events | -| `telemetry_auth_token` | `THQ_TELEMETRY_AUTH_TOKEN` | — | Token allowed to send log events **and** location updates | +| `events_auth_token` | `THQ_EVENTS_AUTH_TOKEN` | — | Token allowed to send log and interaction events | +| `telemetry_auth_token` | `THQ_TELEMETRY_AUTH_TOKEN` | — | Token allowed to send log/interaction events **and** location updates | ## Authentication diff --git a/src/config.rs b/src/config.rs index b716f5c..468bc70 100644 --- a/src/config.rs +++ b/src/config.rs @@ -67,6 +67,12 @@ struct FileConfig { telemetry_auth_token: Option, } +/// 空文字・空白のみのトークンは未設定(None)として扱う。 +/// clap は空の環境変数を Some("") として渡すため、ここで弾く。 +fn normalize_token(token: Option) -> Option { + token.filter(|t| !t.trim().is_empty()) +} + impl Config { pub fn from_cli(cli: Cli) -> anyhow::Result { let mut file_cfg = if let Some(path) = cli.config.as_ref() { @@ -100,9 +106,15 @@ impl Config { file_cfg.telemetry_auth_token = Some(telemetry_auth_token); } - let any_token = file_cfg.observer_auth_token.is_some() - || file_cfg.events_auth_token.is_some() - || file_cfg.telemetry_auth_token.is_some(); + // 空文字・空白のみは未設定として扱い、空トークンで起動して + // 認証不能になる設定を弾く + let observer_auth_token = normalize_token(file_cfg.observer_auth_token); + let events_auth_token = normalize_token(file_cfg.events_auth_token); + let telemetry_auth_token = normalize_token(file_cfg.telemetry_auth_token); + + let any_token = observer_auth_token.is_some() + || events_auth_token.is_some() + || telemetry_auth_token.is_some(); if !any_token { anyhow::bail!( @@ -115,9 +127,9 @@ impl Config { port: file_cfg.port.unwrap_or(8080), ring_size: file_cfg.ring_size.unwrap_or(1000).max(1), database_url: file_cfg.database_url, - observer_auth_token: file_cfg.observer_auth_token, - events_auth_token: file_cfg.events_auth_token, - telemetry_auth_token: file_cfg.telemetry_auth_token, + observer_auth_token, + events_auth_token, + telemetry_auth_token, }) } } @@ -247,4 +259,17 @@ mod tests { assert!(err.to_string().contains("no auth token")); } + + #[test] + fn empty_or_blank_tokens_are_treated_as_unset() { + for token in ["", " "] { + let err = Config::from_cli(Cli { + observer_auth_token: Some(token.into()), + ..empty_cli() + }) + .unwrap_err(); + + assert!(err.to_string().contains("no auth token")); + } + } } From da22555fc3f687ea710301673f72e2f5a2c01a0b Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Sat, 11 Jul 2026 16:02:24 +0900 Subject: [PATCH 12/13] fix: apply CodeRabbit auto-fixes Co-Authored-By: Claude Fable 5 --- docs/react-tanstack-query.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/react-tanstack-query.md b/docs/react-tanstack-query.md index bf04b0e..901cf0e 100644 --- a/docs/react-tanstack-query.md +++ b/docs/react-tanstack-query.md @@ -399,11 +399,11 @@ export function useSendLocation(token: string) { Geolocation API と組み合わせる例: ```tsx -const sendLocation = useSendLocation(telemetryToken); +const { mutate: sendLocation } = useSendLocation(telemetryToken); useEffect(() => { const watchId = navigator.geolocation.watchPosition((pos) => { - sendLocation.mutate({ + sendLocation({ sessionId, device: "device-001", state: "moving", @@ -418,7 +418,7 @@ useEffect(() => { }); }); return () => navigator.geolocation.clearWatch(watchId); -}, []); +}, [sendLocation, sessionId]); ``` ## 接続先の設定(環境変数) From e448a4cbb1a55e5e75767ee53415b3236bd0f724 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Sat, 11 Jul 2026 16:48:49 +0900 Subject: [PATCH 13/13] =?UTF-8?q?mutation=E3=81=A81:1=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=E3=81=AE=E5=B1=A5=E6=AD=B4=E5=8F=96=E5=BE=97=E3=82=AF=E3=82=A8?= =?UTF-8?q?=E3=83=AA=E3=82=92=E8=A6=B3=E6=B8=AC=E7=94=A8=E3=83=88=E3=83=BC?= =?UTF-8?q?=E3=82=AF=E3=83=B3=E9=99=90=E5=AE=9A=E3=81=A7=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendLogEvent / sendInteractionEvent / sendLocation に対応する logEvents / interactionEvents / locations クエリを実装。 永続化済みイベントを新しい順に返し、sessionId / device / from / to / limit の共通フィルタと各クエリ固有のフィルタを受け付ける。 生データを公開しない方針を維持するため、履歴クエリは観測用トークン (Authorization: Bearer) を必須とし、RequestAuth に can_read_events スコープを追加。集計のみの accuracyByLine は従来どおり認証不要。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011En6FDKKTV7dqYKh367PT1 --- README.md | 74 +++++- docs/react-tanstack-query.md | 114 +++++++++- docs/react-websocket-observer.md | 4 +- src/domain.rs | 71 ++++++ src/graphql.rs | 379 ++++++++++++++++++++++++++++++- src/server.rs | 94 +++++++- src/storage.rs | 207 +++++++++++++++++ 7 files changed, 910 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 154058b..b8c40e0 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ A telemetry server for [TrainLCD](https://github.com/TrainLCD). It provides real ## Features - **WebSocket** — Real-time broadcast of location updates and log events -- **GraphQL** — Event ingestion (`sendLogEvent`, `sendInteractionEvent`, `sendLocation` mutations) and aggregated per-line accuracy reports (`POST /graphql`) +- **GraphQL** — Event ingestion (`sendLogEvent`, `sendInteractionEvent`, `sendLocation` mutations), history queries (`logEvents`, `interactionEvents`, `locations`) and aggregated per-line accuracy reports (`POST /graphql`) - **PostgreSQL persistence** — Optionally stores all events in the database - **Ring buffer** — Keeps the latest N events in memory (default 1000) -- **Scoped authentication** — Three shared secrets: observer (WebSocket only), events (log submission only), telemetry (log + location submission) +- **Scoped authentication** — Three shared secrets: observer (WebSocket + history queries), events (log submission only), telemetry (log + location submission) - **Line topology** — Automatic segment annotation from a CSV topology file ## Requirements @@ -84,15 +84,16 @@ telemetry_auth_token = "change-me-telemetry" Three shared secrets grant exactly one role each: -| Token | WebSocket subscribe | `sendLogEvent` / `sendInteractionEvent` | `sendLocation` | -|---|---|---|---| -| Observer | ✅ | ❌ | ❌ | -| Events | ❌ | ✅ | ❌ | -| Telemetry | ❌ | ✅ | ✅ | +| Token | WebSocket subscribe | History queries (`logEvents` / `interactionEvents` / `locations`) | `sendLogEvent` / `sendInteractionEvent` | `sendLocation` | +|---|---|---|---|---| +| Observer | ✅ | ✅ | ❌ | ❌ | +| Events | ❌ | ❌ | ✅ | ❌ | +| Telemetry | ❌ | ❌ | ✅ | ✅ | - **WebSocket** — send the observer token via subprotocols: `Sec-WebSocket-Protocol: thq, thq-auth-` - **GraphQL mutations** — send the events or telemetry token via `Authorization: Bearer ` -- **GraphQL queries** — no authentication (aggregated data only) +- **GraphQL history queries** — send the observer token via `Authorization: Bearer `; raw event data is exposed only to the observation role that already sees it in real time over WebSocket +- **GraphQL aggregated queries** (`accuracyByLine`) — no authentication (aggregated data only) Authentication is always enforced. At least one token must be configured, or the server refuses to start. @@ -176,9 +177,64 @@ mutation { `stationId` is only meaningful when `state` is `arrived` or `passing` and is ignored otherwise. `batteryLevel` (0.0–1.0) and `batteryState` (`unknown | unplugged | charging | full`) are optional. +#### `logEvents` / `interactionEvents` / `locations` — History queries + +Each mutation has a matching query returning the persisted events, newest first. All three require the **observer token** (`Authorization: Bearer `) — the same read-only role that observes events in real time over WebSocket — and a configured database. + +```graphql +query { + logEvents( + sessionId: "d0f7..." # all filters are optional + device: "device-001" + from: "2026-07-01T00:00:00Z" # client-reported timestamp range + to: "2026-07-02T00:00:00Z" + type: app # system | app | client + level: error # debug | info | warn | error + limit: 100 # default 100, cap 2000 + ) { + id sessionId device appVersion platform channel + timestamp type level message recordedAt + } +} +``` + +```graphql +query { + interactionEvents(eventName: "tab_change", limit: 50) { + id sessionId device appVersion platform channel + timestamp eventName properties recordedAt + } +} +``` + +```graphql +query { + locations(lineId: 11302, state: moving, from: "2026-07-01T00:00:00Z", to: "2026-07-02T00:00:00Z") { + id sessionId device state stationId lineId + coords { latitude longitude accuracy speed } + timestamp segmentId fromStationId toStationId + batteryLevel batteryState recordedAt + } +} +``` + +Shared parameters (all optional): + +| Parameter | Type | Description | +|---|---|---| +| `sessionId` | `String` | Exact session ID match | +| `device` | `String` | Exact device ID match | +| `from` | `DateTime` | Inclusive lower bound on the client-reported timestamp | +| `to` | `DateTime` | Exclusive upper bound on the client-reported timestamp | +| `limit` | `Int` | Max events returned, newest first (default 100, cap 2000) | + +Per-query filters: `logEvents` also accepts `type` and `level`; `interactionEvents` accepts `eventName`; `locations` accepts `lineId` and `state`. + +Columns added to the storage schema over time are nullable in the results: legacy rows recorded before a column existed return `null` for it (e.g. `sessionId`, `appVersion`, or `lineId` on old rows). `recordedAt` is the server-side persistence time, while `timestamp` is the client-reported unix-millisecond value. + #### `accuracyByLine` — Aggregated accuracy report -Returns aggregated accuracy metrics per line. Raw location data is never exposed through queries. +Returns aggregated accuracy metrics per line. Raw event data is exposed only through the observer-token history queries above; this aggregated report requires no authentication. ```graphql query { diff --git a/docs/react-tanstack-query.md b/docs/react-tanstack-query.md index 901cf0e..83d77a3 100644 --- a/docs/react-tanstack-query.md +++ b/docs/react-tanstack-query.md @@ -11,14 +11,16 @@ thq-server の GraphQL API はエンドポイント `POST /graphql` で公開さ | `sendLogEvent` | Mutation | イベント用または遠隔測定用トークン | | `sendInteractionEvent` | Mutation | イベント用または遠隔測定用トークン | | `sendLocation` | Mutation | 遠隔測定用トークンのみ | +| `logEvents` / `interactionEvents` / `locations` | Query | 観測用トークンのみ | | `accuracyByLine` | Query | 不要 | -Mutation の認証は `Authorization: Bearer ` ヘッダで行います。 +Mutation と履歴取得 Query の認証は `Authorization: Bearer ` ヘッダで行います。 | トークン | できること | |---|---| | イベント用(`THQ_EVENTS_AUTH_TOKEN`) | `sendLogEvent` + `sendInteractionEvent` | | 遠隔測定用(`THQ_TELEMETRY_AUTH_TOKEN`) | `sendLogEvent` + `sendInteractionEvent` + `sendLocation` | +| 観測用(`THQ_OBSERVER_AUTH_TOKEN`) | `logEvents` + `interactionEvents` + `locations`(+ WebSocket 購読) | > **セキュリティ上の注意**: ブラウザ向けにビルドした JavaScript に埋め込んだトークンは、利用者全員から見えます。イベント用・遠隔測定用トークンを Web フロントエンドに直接埋め込むのは避け、ネイティブアプリや自前のバックエンド(BFF)経由で扱ってください。認証不要な `accuracyByLine` の表示だけであればトークンは一切不要です。 @@ -172,6 +174,116 @@ function AccuracyChart() { バケットサイズごとの最大期間(minute ≤ 7 日、hour ≤ 90 日、day ≤ 365 日)を超えるとエラーになる点に注意してください。 +## Query: 履歴取得(`logEvents` / `interactionEvents` / `locations`) + +各 Mutation には 1:1 で対応する履歴取得 Query があり、永続化済みのイベントを新しい順に返します。**観測用トークン**(`THQ_OBSERVER_AUTH_TOKEN`)が必要です。WebSocket でリアルタイム観測できるのと同じ読み取り専用ロールが、過去分もさかのぼれるという位置づけです。サーバーにデータベースが設定されていない場合はエラーになります。 + +| Query | 対応する Mutation | 固有フィルタ | +|---|---|---| +| `logEvents` | `sendLogEvent` | `type`, `level` | +| `interactionEvents` | `sendInteractionEvent` | `eventName` | +| `locations` | `sendLocation` | `lineId`, `state` | + +3 つの Query に共通のフィルタ(すべて省略可): `sessionId`、`device`、`from` / `to`(クライアント報告 `timestamp` に対する範囲。`from` は以上、`to` は未満)、`limit`(デフォルト 100、上限 2000)。 + +```ts +// hooks/useLogEvents.ts +import { useQuery } from "@tanstack/react-query"; +import { gqlRequest } from "../lib/graphql"; + +const LOG_EVENTS = /* GraphQL */ ` + query LogEvents( + $sessionId: String + $device: String + $from: DateTime + $to: DateTime + $type: LogType + $level: LogLevel + $limit: Int + ) { + logEvents( + sessionId: $sessionId + device: $device + from: $from + to: $to + type: $type + level: $level + limit: $limit + ) { + id + sessionId + device + appVersion + platform + channel + timestamp + type + level + message + recordedAt + } + } +`; + +export interface LogEventRecord { + id: string; + sessionId: string | null; // 過去の移行前データでは null になり得る + device: string | null; // 匿名送信されたイベントは null + appVersion: string | null; + platform: "ios" | "android" | "macos" | "unknown" | null; + channel: "production" | "canary" | null; + timestamp: number; // クライアント報告の Unix ミリ秒 + type: "system" | "app" | "client" | null; + level: "debug" | "info" | "warn" | "error" | null; + message: string; + recordedAt: string; // サーバー側で永続化した時刻(ISO 8601) +} + +export function useLogEvents( + token: string, // 観測用トークン + params: { + sessionId?: string; + device?: string; + from?: string; // ISO 8601 + to?: string; + type?: "system" | "app" | "client"; + level?: "debug" | "info" | "warn" | "error"; + limit?: number; + } = {}, +) { + return useQuery({ + queryKey: ["logEvents", params], + queryFn: () => gqlRequest<{ logEvents: LogEventRecord[] }>(LOG_EVENTS, params, token), + }); +} +``` + +`interactionEvents` と `locations` も同じ形で、返るフィールドはそれぞれの Mutation の入力(+ サーバー付与の `id` / `recordedAt`、`locations` は区間推定の `segmentId` / `fromStationId` / `toStationId` も)に対応します。 + +```graphql +query { + interactionEvents(eventName: "tab_change", limit: 50) { + id sessionId device appVersion platform channel + timestamp eventName properties recordedAt + } +} +``` + +```graphql +query { + locations(lineId: 11302, state: moving, from: "2026-07-01T00:00:00Z", to: "2026-07-02T00:00:00Z") { + id sessionId device state stationId lineId + coords { latitude longitude accuracy speed } + timestamp segmentId fromStationId toStationId + batteryLevel batteryState recordedAt + } +} +``` + +> **null の扱い**: ストレージのカラムは段階的に追加されてきたため、追加前に記録されたレガシー行では `sessionId` / `appVersion` / `lineId` などが `null` になります。enum 系フィールド(`platform` / `channel` / `type` / `level` / `state` / `batteryState`)も、既知の値に対応しない場合(新しいサーバーが書いた行を古いサーバーが読むケースなど)は `null` になります。 + +> **セキュリティ上の注意**: 観測用トークンは生の位置情報・ログを閲覧できる読み取り専用トークンです。公開サイトへの埋め込みは避けるか、漏えい時にローテーションできる運用にしてください(WebSocket 観測と同じ注意事項です)。 + ## Mutation: ログイベント送信(`sendLogEvent`) イベント用または遠隔測定用トークンが必要です。 diff --git a/docs/react-websocket-observer.md b/docs/react-websocket-observer.md index 50013cc..5a863b6 100644 --- a/docs/react-websocket-observer.md +++ b/docs/react-websocket-observer.md @@ -10,7 +10,7 @@ | 認証 | 観測用トークン(`THQ_OBSERVER_AUTH_TOKEN`)のみ | | 方向 | サーバー → クライアントの配信専用(送信は GraphQL 側で行う) | -WebSocket を購読できるのは**観測用トークンだけ**です。イベント用・遠隔測定用トークンでは接続がハンドシェイク時に HTTP 401 で拒否されます。逆に観測用トークンでは GraphQL Mutation を実行できないため、閲覧用クライアントに配るトークンとして安全に分離されています。 +WebSocket を購読できるのは**観測用トークンだけ**です。イベント用・遠隔測定用トークンでは接続がハンドシェイク時に HTTP 401 で拒否されます。逆に観測用トークンでは GraphQL Mutation を実行できないため、閲覧用クライアントに配るトークンとして安全に分離されています(観測用トークンは GraphQL の履歴取得 Query `logEvents` / `interactionEvents` / `locations` にも使えます。[react-tanstack-query.md](./react-tanstack-query.md) を参照)。 > ブラウザ向けビルドに埋め込んだトークンは利用者から見えます。観測用トークンは読み取り専用スコープとはいえ、公開サイトに埋め込む場合は漏えい時にローテーションできる運用にしておいてください。 @@ -306,5 +306,5 @@ VITE_THQ_OBSERVER_TOKEN=<観測用トークン> ## 運用上の注意 - **切断は日常的に起きる**ものとして扱ってください。上記フックのようにバックオフ付きで再接続し、スナップショット再送は `id` の重複排除で吸収します。 -- リングバッファの件数はサーバー側の `ring_size`(デフォルト 1000)で決まります。接続前のイベントをそれ以上さかのぼることはできないため、履歴が必要な場合は GraphQL の `accuracyByLine`(集計)を参照してください。 +- リングバッファの件数はサーバー側の `ring_size`(デフォルト 1000)で決まります。接続時のスナップショットでそれ以上さかのぼる必要がある場合は、同じ観測用トークンで GraphQL の履歴取得 Query(`logEvents` / `interactionEvents` / `locations`)を利用してください([react-tanstack-query.md](./react-tanstack-query.md) を参照)。集計データだけでよければ認証不要の `accuracyByLine` もあります。 - WebSocket は CORS の制約を受けないため、GraphQL と違いリバースプロキシなしでクロスオリジン接続できます。ただし `wss://`(TLS)を使わないと HTTPS ページからは接続できません(混在コンテンツとしてブロックされます)。 diff --git a/src/domain.rs b/src/domain.rs index bda70fe..4229216 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -38,6 +38,20 @@ pub enum BatteryState { Full = 3, } +impl BatteryState { + /// Maps the persisted SMALLINT representation back to the enum. + /// None when the stored value does not match a known variant. + pub fn from_i16(value: i16) -> Option { + match value { + 0 => Some(BatteryState::Unknown), + 1 => Some(BatteryState::Unplugged), + 2 => Some(BatteryState::Charging), + 3 => Some(BatteryState::Full), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] #[graphql(rename_items = "lowercase")] #[serde(rename_all = "snake_case")] @@ -57,6 +71,18 @@ impl MovementState { MovementState::Moving => "moving", } } + + /// Inverse of `as_str`. None when the stored value is unknown, e.g. a + /// row written by a newer server version. + pub fn parse(value: &str) -> Option { + match value { + "arrived" => Some(MovementState::Arrived), + "approaching" => Some(MovementState::Approaching), + "passing" => Some(MovementState::Passing), + "moving" => Some(MovementState::Moving), + _ => None, + } + } } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] @@ -78,6 +104,18 @@ impl Platform { Platform::Unknown => "unknown", } } + + /// Inverse of `as_str`. None when the stored value is unknown, e.g. a + /// row written by a newer server version. + pub fn parse(value: &str) -> Option { + match value { + "ios" => Some(Platform::Ios), + "android" => Some(Platform::Android), + "macos" => Some(Platform::Macos), + "unknown" => Some(Platform::Unknown), + _ => None, + } + } } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] @@ -95,6 +133,16 @@ impl Channel { Channel::Canary => "canary", } } + + /// Inverse of `as_str`. None when the stored value is unknown, e.g. a + /// row written by a newer server version. + pub fn parse(value: &str) -> Option { + match value { + "production" => Some(Channel::Production), + "canary" => Some(Channel::Canary), + _ => None, + } + } } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] @@ -116,6 +164,18 @@ impl LogLevel { LogLevel::Error => "error", } } + + /// Inverse of `as_str`. None when the stored value is unknown, e.g. a + /// row written by a newer server version. + pub fn parse(value: &str) -> Option { + match value { + "debug" => Some(LogLevel::Debug), + "info" => Some(LogLevel::Info), + "warn" => Some(LogLevel::Warn), + "error" => Some(LogLevel::Error), + _ => None, + } + } } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] @@ -135,6 +195,17 @@ impl LogType { LogType::Client => "client", } } + + /// Inverse of `as_str`. None when the stored value is unknown, e.g. a + /// row written by a newer server version. + pub fn parse(value: &str) -> Option { + match value { + "system" => Some(LogType::System), + "app" => Some(LogType::App), + "client" => Some(LogType::Client), + _ => None, + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/graphql.rs b/src/graphql.rs index a139fb9..28ef8c6 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -14,7 +14,10 @@ use crate::{ }, segment::SegmentEstimator, state::TelemetryHub, - storage::{LineAccuracyBucketRow, Storage}, + storage::{ + EventFilter, InteractionEventRow, LineAccuracyBucketRow, LocationEventRow, LogEventRow, + Storage, + }, }; const BAD_ACCURACY_THRESHOLD: f64 = 100.0; // meters @@ -25,12 +28,14 @@ pub type AppSchema = Schema; /// Scopes granted by the HTTP-layer Bearer token check, injected per request. /// /// - the events token only grants `can_send_events` -/// - the telemetry token grants both flags -/// - the observer token grants neither (it is WebSocket-only) +/// - the telemetry token grants `can_send_events` and `can_send_location` +/// - the observer token only grants `can_read_events` (raw history queries, +/// mirroring its WebSocket observation role) #[derive(Clone, Copy)] -pub struct MutationAuth { +pub struct RequestAuth { pub can_send_events: bool, pub can_send_location: bool, + pub can_read_events: bool, } const HARD_LIMIT: i32 = 2000; @@ -86,6 +91,89 @@ pub struct LineAccuracyReport { pub buckets: Vec, } +/// A persisted log event, as accepted by the `sendLogEvent` mutation. +/// +/// Fields that were added to the storage schema over time are nullable: +/// legacy rows recorded before the column existed return null. Enum fields +/// are also null when the stored value does not map to a known variant +/// (e.g. a row written by a newer server version). +#[derive(SimpleObject, Clone)] +pub struct LogEvent { + /// Server-generated event ID. + pub id: ID, + pub session_id: Option, + /// Null when the event was submitted anonymously. + pub device: Option, + pub app_version: Option, + pub platform: Option, + pub channel: Option, + /// Client-reported unix timestamp in milliseconds. + pub timestamp: u64, + #[graphql(name = "type")] + pub log_type: Option, + pub level: Option, + pub message: String, + /// Server-side time the event was persisted. + pub recorded_at: DateTime, +} + +/// A persisted interaction event, as accepted by the `sendInteractionEvent` +/// mutation. See `LogEvent` for the nullability rules. +#[derive(SimpleObject, Clone)] +pub struct InteractionEvent { + /// Server-generated event ID. + pub id: ID, + pub session_id: Option, + /// Null when the event was submitted anonymously. + pub device: Option, + pub app_version: Option, + pub platform: Option, + pub channel: Option, + /// Client-reported unix timestamp in milliseconds. + pub timestamp: u64, + pub event_name: String, + pub properties: Option, + /// Server-side time the event was persisted. + pub recorded_at: DateTime, +} + +#[derive(SimpleObject, Clone)] +pub struct Coords { + pub latitude: f64, + pub longitude: f64, + /// Horizontal accuracy in meters. + pub accuracy: Option, + /// Speed in km/h. + pub speed: Option, +} + +/// A persisted location update, as accepted by the `sendLocation` mutation, +/// including the segment annotation added by the server. See `LogEvent` for +/// the nullability rules. +#[derive(SimpleObject, Clone)] +pub struct LocationEvent { + /// Server-generated event ID. + pub id: ID, + pub session_id: Option, + pub device: String, + pub state: Option, + pub station_id: Option, + pub line_id: Option, + pub coords: Coords, + /// Client-reported unix timestamp in milliseconds. + pub timestamp: u64, + /// Segment annotation inferred by the server; null when no topology was + /// loaded or the segment could not be estimated. + pub segment_id: Option, + pub from_station_id: Option, + pub to_station_id: Option, + /// Battery level as a decimal (0.0 to 1.0). + pub battery_level: Option, + pub battery_state: Option, + /// Server-side time the event was persisted. + pub recorded_at: DateTime, +} + pub fn build_schema( storage: Storage, hub: Arc, @@ -183,6 +271,147 @@ impl QueryRoot { buckets: rows.into_iter().map(LineAccuracyBucket::from).collect(), }) } + + /// Persisted log events, newest first. Counterpart of the + /// `sendLogEvent` mutation. Requires the observer token. + #[allow(clippy::too_many_arguments)] // flat filter args mirror accuracyByLine + async fn log_events( + &self, + ctx: &Context<'_>, + session_id: Option, + device: Option, + from: Option>, + to: Option>, + #[graphql(name = "type")] log_type: Option, + level: Option, + #[graphql(default = 100)] limit: i32, + ) -> Result> { + let (storage, filter) = history_query(ctx, session_id, device, from, to, limit)?; + + let started = Instant::now(); + let rows = storage + .fetch_log_events( + &filter, + log_type.map(|t| t.as_str()), + level.map(|l| l.as_str()), + ) + .await + .map_err(|e| format!("failed to fetch log events: {e}"))?; + + info!( + count = rows.len(), + limit = filter.limit, + duration_ms = started.elapsed().as_millis(), + "logEvents resolver completed" + ); + + Ok(rows.into_iter().map(LogEvent::from).collect()) + } + + /// Persisted interaction events, newest first. Counterpart of the + /// `sendInteractionEvent` mutation. Requires the observer token. + #[allow(clippy::too_many_arguments)] // flat filter args mirror accuracyByLine + async fn interaction_events( + &self, + ctx: &Context<'_>, + session_id: Option, + device: Option, + from: Option>, + to: Option>, + event_name: Option, + #[graphql(default = 100)] limit: i32, + ) -> Result> { + let (storage, filter) = history_query(ctx, session_id, device, from, to, limit)?; + + let started = Instant::now(); + let rows = storage + .fetch_interaction_events(&filter, event_name.as_deref()) + .await + .map_err(|e| format!("failed to fetch interaction events: {e}"))?; + + info!( + count = rows.len(), + limit = filter.limit, + duration_ms = started.elapsed().as_millis(), + "interactionEvents resolver completed" + ); + + Ok(rows.into_iter().map(InteractionEvent::from).collect()) + } + + /// Persisted location updates, newest first. Counterpart of the + /// `sendLocation` mutation. Requires the observer token. + #[allow(clippy::too_many_arguments)] // flat filter args mirror accuracyByLine + async fn locations( + &self, + ctx: &Context<'_>, + session_id: Option, + device: Option, + from: Option>, + to: Option>, + line_id: Option, + state: Option, + #[graphql(default = 100)] limit: i32, + ) -> Result> { + let (storage, filter) = history_query(ctx, session_id, device, from, to, limit)?; + + let started = Instant::now(); + let rows = storage + .fetch_locations(&filter, line_id, state.map(|s| s.as_str())) + .await + .map_err(|e| format!("failed to fetch location updates: {e}"))?; + + info!( + count = rows.len(), + limit = filter.limit, + duration_ms = started.elapsed().as_millis(), + "locations resolver completed" + ); + + Ok(rows.into_iter().map(LocationEvent::from).collect()) + } +} + +/// Shared setup for the raw history queries: enforces the observer read +/// scope, validates the time range and resolves the storage handle. +fn history_query<'a>( + ctx: &'a Context<'_>, + session_id: Option, + device: Option, + from: Option>, + to: Option>, + limit: i32, +) -> Result<(&'a Storage, EventFilter)> { + let auth = ctx + .data::() + .map_err(|_| "auth context is missing")?; + if !auth.can_read_events { + return Err("unauthorized: a valid observer bearer token is required".into()); + } + + if let (Some(from), Some(to)) = (from, to) { + if from >= to { + return Err("from must be earlier than to".into()); + } + } + + let storage = ctx + .data::() + .map_err(|_| "storage is not configured; DATABASE_URL is required")?; + if !storage.enabled() { + return Err("database-backed storage is disabled; history queries are unavailable".into()); + } + + Ok(( + storage, + EventFilter { + session_id, + device, + from_ts: from.map(|t| t.timestamp_millis()), + to_ts: to.map(|t| t.timestamp_millis()), + limit: limit.clamp(1, HARD_LIMIT), + }, + )) } #[derive(InputObject)] @@ -282,7 +511,7 @@ impl MutationRoot { input: LogEventInput, ) -> Result { let auth = ctx - .data::() + .data::() .map_err(|_| "auth context is missing")?; if !auth.can_send_events { return Err( @@ -352,7 +581,7 @@ impl MutationRoot { input: InteractionEventInput, ) -> Result { let auth = ctx - .data::() + .data::() .map_err(|_| "auth context is missing")?; if !auth.can_send_events { return Err( @@ -417,7 +646,7 @@ impl MutationRoot { input: LocationEventInput, ) -> Result { let auth = ctx - .data::() + .data::() .map_err(|_| "auth context is missing")?; if !auth.can_send_location { return Err("unauthorized: a valid telemetry bearer token is required".into()); @@ -545,6 +774,69 @@ impl From for LineAccuracyBucket { } } +impl From for LogEvent { + fn from(row: LogEventRow) -> Self { + Self { + id: row.id.into(), + session_id: row.session_id, + device: row.device, + app_version: row.app_version, + platform: row.platform.as_deref().and_then(Platform::parse), + channel: row.channel.as_deref().and_then(Channel::parse), + timestamp: u64::try_from(row.timestamp).unwrap_or(0), + log_type: LogType::parse(&row.log_type), + level: LogLevel::parse(&row.log_level), + message: row.message, + recorded_at: row.recorded_at, + } + } +} + +impl From for InteractionEvent { + fn from(row: InteractionEventRow) -> Self { + Self { + id: row.id.into(), + session_id: row.session_id, + device: row.device, + app_version: row.app_version, + platform: row.platform.as_deref().and_then(Platform::parse), + channel: row.channel.as_deref().and_then(Channel::parse), + timestamp: u64::try_from(row.timestamp).unwrap_or(0), + event_name: row.event_name, + // stored properties are validated flat maps, so a mismatch only + // occurs on hand-edited data; degrade to null rather than fail + properties: row.properties.and_then(|v| serde_json::from_value(v).ok()), + recorded_at: row.recorded_at, + } + } +} + +impl From for LocationEvent { + fn from(row: LocationEventRow) -> Self { + Self { + id: row.id.into(), + session_id: row.session_id, + device: row.device, + state: MovementState::parse(&row.state), + station_id: row.station_id, + line_id: row.line_id, + coords: Coords { + latitude: row.latitude, + longitude: row.longitude, + accuracy: row.accuracy, + speed: row.speed, + }, + timestamp: u64::try_from(row.timestamp).unwrap_or(0), + segment_id: row.segment_id, + from_station_id: row.from_station_id, + to_station_id: row.to_station_id, + battery_level: row.battery_level, + battery_state: row.battery_state.and_then(BatteryState::from_i16), + recorded_at: row.recorded_at, + } + } +} + fn estimate_bucket_count(from: DateTime, to: DateTime, bucket_seconds: i64) -> i64 { let span = to - from; let total_secs = span.num_seconds(); @@ -559,17 +851,20 @@ mod tests { use super::*; use crate::segment::LineTopology; - const EVENTS_ONLY: MutationAuth = MutationAuth { + const EVENTS_ONLY: RequestAuth = RequestAuth { can_send_events: true, can_send_location: false, + can_read_events: false, }; - const TELEMETRY: MutationAuth = MutationAuth { + const TELEMETRY: RequestAuth = RequestAuth { can_send_events: true, can_send_location: true, + can_read_events: false, }; - const OBSERVER: MutationAuth = MutationAuth { + const OBSERVER: RequestAuth = RequestAuth { can_send_events: false, can_send_location: false, + can_read_events: true, }; fn test_schema(hub: Arc) -> AppSchema { @@ -580,7 +875,7 @@ mod tests { ) } - fn request(query: &str, auth: MutationAuth) -> async_graphql::Request { + fn request(query: &str, auth: RequestAuth) -> async_graphql::Request { async_graphql::Request::new(query.to_string()).data(auth) } @@ -1126,6 +1421,68 @@ mod tests { assert!(v["station_id"].is_null()); } + #[tokio::test] + async fn history_queries_reject_missing_read_scope() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub); + + for query in [ + r#"query { logEvents { id } }"#, + r#"query { interactionEvents { id } }"#, + r#"query { locations { id } }"#, + ] { + for auth in [EVENTS_ONLY, TELEMETRY] { + let resp = schema.execute(request(query, auth)).await; + assert!(!resp.errors.is_empty(), "expected rejection for {query}"); + assert!( + resp.errors[0].message.contains("observer"), + "unexpected message for {query}: {}", + resp.errors[0].message + ); + } + } + } + + #[tokio::test] + async fn history_queries_reject_inverted_time_range() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub); + + let resp = schema + .execute(request( + r#"query { + logEvents(from: "2026-07-02T00:00:00Z", to: "2026-07-01T00:00:00Z") { id } + }"#, + OBSERVER, + )) + .await; + + assert!(!resp.errors.is_empty()); + assert!(resp.errors[0] + .message + .contains("from must be earlier than to")); + } + + #[tokio::test] + async fn history_queries_report_disabled_storage_with_read_scope() { + let hub = Arc::new(TelemetryHub::new(10)); + let schema = test_schema(hub); + + for query in [ + r#"query { logEvents { id } }"#, + r#"query { interactionEvents { id } }"#, + r#"query { locations { id } }"#, + ] { + let resp = schema.execute(request(query, OBSERVER)).await; + assert!(!resp.errors.is_empty(), "expected error for {query}"); + assert!( + resp.errors[0].message.contains("storage is disabled"), + "unexpected message for {query}: {}", + resp.errors[0].message + ); + } + } + #[test] fn bucket_limits_match_spec() { assert_eq!(TimeBucketSize::Minute.max_duration().num_days(), 7); diff --git a/src/server.rs b/src/server.rs index cde36d4..3cc8f1b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -21,7 +21,7 @@ use uuid::Uuid; use crate::{ config::Config, domain::{ErrorBody, ErrorType, IncomingMessage, OutgoingError, OutgoingMessage}, - graphql::{build_schema, AppSchema, MutationAuth}, + graphql::{build_schema, AppSchema, RequestAuth}, segment::{LineTopology, SegmentEstimator}, state::TelemetryHub, storage::Storage, @@ -149,19 +149,21 @@ async fn graphql_handler( headers: HeaderMap, req: GraphQLRequest, ) -> GraphQLResponse { - let auth = mutation_auth(&headers, &state.auth); + let auth = request_auth(&headers, &state.auth); let req = req.into_inner().data(auth); state.schema.execute(req).await.into() } -/// Resolves the mutation scopes granted by the Authorization header. -/// Queries stay open, so the result is carried into the GraphQL context -/// instead of rejecting the request here. -fn mutation_auth(headers: &HeaderMap, auth: &AuthConfig) -> MutationAuth { +/// Resolves the scopes granted by the Authorization header. Aggregated +/// queries stay open, so the result is carried into the GraphQL context +/// instead of rejecting the request here; mutations and raw history +/// queries check their scope in the resolver. +fn request_auth(headers: &HeaderMap, auth: &AuthConfig) -> RequestAuth { let Some(token) = bearer_token(headers) else { - return MutationAuth { + return RequestAuth { can_send_events: false, can_send_location: false, + can_read_events: false, }; }; @@ -174,10 +176,12 @@ fn mutation_auth(headers: &HeaderMap, auth: &AuthConfig) -> MutationAuth { let can_send_location = matches(&auth.telemetry_token); let can_send_events = can_send_location || matches(&auth.events_token); + let can_read_events = matches(&auth.observer_token); - MutationAuth { + RequestAuth { can_send_events, can_send_location, + can_read_events, } } @@ -678,9 +682,79 @@ mod tests { } #[test] - fn mutation_auth_denies_missing_header() { - let auth = mutation_auth(&HeaderMap::new(), &scoped_auth()); + fn request_auth_denies_missing_header() { + let auth = request_auth(&HeaderMap::new(), &scoped_auth()); assert!(!auth.can_send_events); assert!(!auth.can_send_location); + assert!(!auth.can_read_events); + } + + #[test] + fn request_auth_grants_read_scope_to_observer_token_only() { + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, "Bearer observer-secret".parse().unwrap()); + let auth = request_auth(&headers, &scoped_auth()); + assert!(auth.can_read_events); + assert!(!auth.can_send_events); + assert!(!auth.can_send_location); + + for token in ["events-secret", "telemetry-secret"] { + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + format!("Bearer {token}").parse().unwrap(), + ); + let auth = request_auth(&headers, &scoped_auth()); + assert!(!auth.can_read_events, "{token} must not grant read scope"); + } + } + + // GraphQL history queries over HTTP + + fn log_events_request(auth_header: Option<&str>) -> Request { + graphql_request(r#"query { logEvents { id message } }"#, auth_header) + } + + #[tokio::test] + async fn log_events_rejects_missing_auth() { + let app = graphql_router(state_with_auth(scoped_auth())); + + let response = app.oneshot(log_events_request(None)).await.unwrap(); + let v = body_json(response).await; + assert!(v["errors"][0]["message"] + .as_str() + .unwrap() + .contains("unauthorized")); + } + + #[tokio::test] + async fn log_events_rejects_events_and_telemetry_tokens() { + for token in ["Bearer events-secret", "Bearer telemetry-secret"] { + let app = graphql_router(state_with_auth(scoped_auth())); + let response = app.oneshot(log_events_request(Some(token))).await.unwrap(); + let v = body_json(response).await; + assert!( + v["errors"][0]["message"] + .as_str() + .unwrap() + .contains("unauthorized"), + "{token} must not grant history access" + ); + } + } + + #[tokio::test] + async fn log_events_accepts_observer_token_and_reports_disabled_storage() { + let app = graphql_router(state_with_auth(scoped_auth())); + + let response = app + .oneshot(log_events_request(Some("Bearer observer-secret"))) + .await + .unwrap(); + let v = body_json(response).await; + // the auth gate passes; without a database the query fails afterwards + let message = v["errors"][0]["message"].as_str().unwrap(); + assert!(!message.contains("unauthorized"), "message: {message}"); + assert!(message.contains("storage"), "message: {message}"); } } diff --git a/src/storage.rs b/src/storage.rs index a23ce76..b9fcdc6 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -20,6 +20,72 @@ pub struct LineAccuracyBucketRow { pub max_speed: Option, } +/// Common optional filters shared by the raw history queries. `None` fields +/// leave the corresponding column unfiltered. +pub struct EventFilter { + pub session_id: Option, + pub device: Option, + /// Inclusive lower bound on the client-reported timestamp, unix millis. + pub from_ts: Option, + /// Exclusive upper bound on the client-reported timestamp, unix millis. + pub to_ts: Option, + pub limit: i32, +} + +/// Raw row of the `log_events` table. Columns added by later migrations are +/// nullable because legacy rows predate them. +#[derive(Clone, sqlx::FromRow)] +pub struct LogEventRow { + pub id: String, + pub session_id: Option, + pub device: Option, + pub app_version: Option, + pub platform: Option, + pub channel: Option, + pub log_type: String, + pub log_level: String, + pub message: String, + pub timestamp: i64, + pub recorded_at: sqlx::types::chrono::DateTime, +} + +/// Raw row of the `interaction_events` table. +#[derive(Clone, sqlx::FromRow)] +pub struct InteractionEventRow { + pub id: String, + pub session_id: Option, + pub device: Option, + pub app_version: Option, + pub platform: Option, + pub channel: Option, + pub event_name: String, + pub properties: Option, + pub timestamp: i64, + pub recorded_at: sqlx::types::chrono::DateTime, +} + +/// Raw row of the `location_logs` table. +#[derive(Clone, sqlx::FromRow)] +pub struct LocationEventRow { + pub id: String, + pub session_id: Option, + pub device: String, + pub state: String, + pub station_id: Option, + pub line_id: Option, + pub segment_id: Option, + pub from_station_id: Option, + pub to_station_id: Option, + pub latitude: f64, + pub longitude: f64, + pub accuracy: Option, + pub speed: Option, + pub timestamp: i64, + pub battery_level: Option, + pub battery_state: Option, + pub recorded_at: sqlx::types::chrono::DateTime, +} + #[derive(Clone, Default)] pub struct Storage { pool: Option, @@ -212,6 +278,25 @@ impl Storage { .execute(pool) .await?; + // history queries page through events by client-reported timestamp + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_location_logs_timestamp ON location_logs (timestamp DESC);", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_log_events_timestamp ON log_events (timestamp DESC);", + ) + .execute(pool) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_interaction_events_timestamp ON interaction_events (timestamp DESC);", + ) + .execute(pool) + .await?; + Ok(()) } @@ -357,6 +442,128 @@ impl Storage { Ok(rows) } + + /// Fetches persisted log events, newest first. + pub async fn fetch_log_events( + &self, + filter: &EventFilter, + log_type: Option<&str>, + level: Option<&str>, + ) -> anyhow::Result> { + let pool = self + .pool + .as_ref() + .ok_or_else(|| anyhow::anyhow!("database is not configured"))?; + + let rows = sqlx::query_as::<_, LogEventRow>( + r#" + SELECT id, session_id, device, app_version, platform, channel, + log_type, log_level, message, timestamp, recorded_at + FROM log_events + WHERE ($1::text IS NULL OR session_id = $1) + AND ($2::text IS NULL OR device = $2) + AND ($3::bigint IS NULL OR timestamp >= $3) + AND ($4::bigint IS NULL OR timestamp < $4) + AND ($5::text IS NULL OR log_type = $5) + AND ($6::text IS NULL OR log_level = $6) + ORDER BY timestamp DESC + LIMIT $7 + "#, + ) + .bind(&filter.session_id) + .bind(&filter.device) + .bind(filter.from_ts) + .bind(filter.to_ts) + .bind(log_type) + .bind(level) + .bind(filter.limit) + .fetch_all(pool) + .await + .context("failed to fetch log events")?; + + Ok(rows) + } + + /// Fetches persisted interaction events, newest first. + pub async fn fetch_interaction_events( + &self, + filter: &EventFilter, + event_name: Option<&str>, + ) -> anyhow::Result> { + let pool = self + .pool + .as_ref() + .ok_or_else(|| anyhow::anyhow!("database is not configured"))?; + + let rows = sqlx::query_as::<_, InteractionEventRow>( + r#" + SELECT id, session_id, device, app_version, platform, channel, + event_name, properties, timestamp, recorded_at + FROM interaction_events + WHERE ($1::text IS NULL OR session_id = $1) + AND ($2::text IS NULL OR device = $2) + AND ($3::bigint IS NULL OR timestamp >= $3) + AND ($4::bigint IS NULL OR timestamp < $4) + AND ($5::text IS NULL OR event_name = $5) + ORDER BY timestamp DESC + LIMIT $6 + "#, + ) + .bind(&filter.session_id) + .bind(&filter.device) + .bind(filter.from_ts) + .bind(filter.to_ts) + .bind(event_name) + .bind(filter.limit) + .fetch_all(pool) + .await + .context("failed to fetch interaction events")?; + + Ok(rows) + } + + /// Fetches persisted location updates, newest first. + pub async fn fetch_locations( + &self, + filter: &EventFilter, + line_id: Option, + state: Option<&str>, + ) -> anyhow::Result> { + let pool = self + .pool + .as_ref() + .ok_or_else(|| anyhow::anyhow!("database is not configured"))?; + + let rows = sqlx::query_as::<_, LocationEventRow>( + r#" + SELECT id, session_id, device, state, station_id, line_id, + segment_id, from_station_id, to_station_id, + latitude, longitude, accuracy, speed, + timestamp, battery_level, battery_state, recorded_at + FROM location_logs + WHERE ($1::text IS NULL OR session_id = $1) + AND ($2::text IS NULL OR device = $2) + AND ($3::bigint IS NULL OR timestamp >= $3) + AND ($4::bigint IS NULL OR timestamp < $4) + AND ($5::integer IS NULL OR line_id = $5) + AND ($6::text IS NULL OR state = $6) + ORDER BY timestamp DESC + LIMIT $7 + "#, + ) + .bind(&filter.session_id) + .bind(&filter.device) + .bind(filter.from_ts) + .bind(filter.to_ts) + .bind(line_id) + .bind(state) + .bind(filter.limit) + .fetch_all(pool) + .await + .context("failed to fetch location updates")?; + + Ok(rows) + } } fn movement_state_str(state: &MovementState) -> &'static str {