diff --git a/.env.example b/.env.example index a8f7499..53e4246 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -THQ_WS_AUTH_TOKEN=change-me -THQ_WS_AUTH_REQUIRED=true +THQ_OBSERVER_AUTH_TOKEN=change-me-observer +THQ_EVENTS_AUTH_TOKEN=change-me-events +THQ_TELEMETRY_AUTH_TOKEN=change-me-telemetry POSTGRES_PASSWORD=change-me -GF_SECURITY_ADMIN_PASSWORD=change-me \ No newline at end of file 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 99ab4b0..b8c40e0 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`, `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) -- **Authentication** — WebSocket subprotocol-based auth; REST Bearer token auth +- **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 @@ -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,8 +53,7 @@ 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` | +| GraphQL | `http://localhost:8080/graphql` (POST) | | Health check | `http://localhost:8080/healthz` | ## Configuration @@ -64,8 +65,9 @@ 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" ``` | Key | Environment variable | Default | Description | @@ -74,71 +76,206 @@ 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 and interaction events | +| `telemetry_auth_token` | `THQ_TELEMETRY_AUTH_TOKEN` | — | Token allowed to send log/interaction events **and** location updates | + +## Authentication + +Three shared secrets grant exactly one role each: + +| 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 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) -\* Defaults to `true` when a token is configured. +Authentication is always enforced. At least one token must be configured, or the server refuses to start. ## API -### REST API +### GraphQL -Authenticated endpoints require an `Authorization: Bearer ` header. +Endpoint: `POST /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: { + 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 + message: "GPS signal acquired" + }) { + sessionId + } } ``` -#### `POST /api/log` — Submit a log entry +`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`. -```json -{ - "device": "device-001", - "timestamp": 1706000000000, - "log": { - "type": "app", - "level": "info", - "message": "GPS signal acquired" +#### `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 + appVersion: "1.2.3" + 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 + }) { + sessionId } } ``` -#### `GET /healthz` — Health check +`properties` is an optional flat object — the TS equivalent is `Record`. Nested objects and arrays are rejected. -No authentication required. Returns `200 OK` if the server is running. +#### `sendLocation` — Submit a location update -### WebSocket +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. -Endpoint: `ws://:/ws` +```graphql +mutation { + sendLocation(input: { + sessionId: "d0f7..." # client-generated unique session identifier + 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 + }) { + sessionId + warning # set when e.g. the reported accuracy exceeds 100 m + } +} +``` -Once connected, the server broadcasts `location_update` and `log` messages in real time. +`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. -#### Authentication +#### `logEvents` / `interactionEvents` / `locations` — History queries -Send the token via WebSocket subprotocols: +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. -```text -Sec-WebSocket-Protocol: thq, thq-auth- +```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 + } +} ``` -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. +```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`. -Set `ws_auth_required = false` to skip authentication during local development. +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 event data is exposed only through the observer-token history queries above; this aggregated report requires no authentication. + +```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. + +#### `GET /healthz` — Health check + +No authentication required. Returns `200 OK` if the server is running. + +### WebSocket + +Endpoint: `ws://:/ws` + +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 @@ -154,6 +291,7 @@ Set `ws_auth_required = false` to skip authentication during local development. { "id": "uuid", "type": "location_update", + "session_id": "client-generated-session-id", "device": "device-id", "state": "arrived | approaching | passing | moving", "station_id": 123, @@ -174,7 +312,11 @@ Set `ws_auth_required = false` to skip authentication during local development. { "id": "uuid", "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", @@ -184,63 +326,48 @@ Set `ws_auth_required = false` to skip authentication during local development. } ``` -**error** +`device` is `null` when the event was submitted anonymously. + +**interaction** ```json { - "type": "error", - "error": { - "type": "websocket_message_error | json_parse_error | payload_parse_error | accuracy_low | invalid_coords | unknown", - "reason": "..." - } + "id": "uuid", + "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": "tab_change", + "properties": { "tab": "map", "index": 2, "pinned": true } } ``` -### GraphQL - -Endpoint: `POST /graphql` (Playground: `GET /graphql`) +As with `log`, `device` is `null` when the event was submitted anonymously. -Returns aggregated accuracy metrics per line. Raw location data is never exposed. +**error** -```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 - } +```json +{ + "type": "error", + "error": { + "type": "websocket_message_error | json_parse_error", + "reason": "..." } } ``` -| 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. | 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`, `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/docker-compose.yml b/docker-compose.yml index 77e8d97..c2139a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,9 @@ 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} # RUST_LOG: debug,thq_server=debug # RUST_BACKTRACE: 1 THQ_LINE_TOPOLOGY_PATH: /app/static/join.csv @@ -32,23 +33,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/docs/react-tanstack-query.md b/docs/react-tanstack-query.md new file mode 100644 index 0000000..83d77a3 --- /dev/null +++ b/docs/react-tanstack-query.md @@ -0,0 +1,545 @@ +# 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` で公開されています。 + +| 操作 | 種別 | 認証 | +|---|---|---| +| `sendLogEvent` | Mutation | イベント用または遠隔測定用トークン | +| `sendInteractionEvent` | Mutation | イベント用または遠隔測定用トークン | +| `sendLocation` | Mutation | 遠隔測定用トークンのみ | +| `logEvents` / `interactionEvents` / `locations` | Query | 観測用トークンのみ | +| `accuracyByLine` | Query | 不要 | + +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` の表示だけであればトークンは一切不要です。 + +> **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 日)を超えるとエラーになる点に注意してください。 + +## 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`) + +イベント用または遠隔測定用トークンが必要です。 + +```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) { + sessionId + } + } +`; + +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"; + message: string; // 空文字・空白のみはサーバーが拒否 +} + +export function useSendLogEvent(token: string) { + return useMutation({ + mutationFn: (input: LogEventInput) => + gqlRequest<{ sendLogEvent: { sessionId: string } }>(SEND_LOG_EVENT, { input }, token), + }); +} +``` + +使用例: + +```tsx +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", + message: "GPS signal acquired", +}); +``` + +端末を特定されたくない場合は `device` を省略して匿名で送信できます: + +```ts +sendLog.mutate({ + sessionId, + appVersion: "1.2.3", + platform: "ios", + channel: "production", + timestamp: Date.now(), + 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: インタラクションイベント送信(`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; // 匿名性確保のため省略可 + appVersion: string; // 必須。アプリのバージョン文字列(空はサーバーが拒否) + platform: "ios" | "android" | "macos" | "unknown"; // 必須 + channel: "production" | "canary"; // 必須 + timestamp: number; // Unix ミリ秒 (Date.now()) + eventName: string; // 任意のイベント名。空文字・空白のみはサーバーが拒否 + properties?: Record; // 省略可(後述) +} + +export function useSendInteractionEvent(token: string) { + return useMutation({ + mutationFn: (input: InteractionEventInput) => + gqlRequest<{ sendInteractionEvent: { sessionId: string } }>( + SEND_INTERACTION_EVENT, + { input }, + token, + ), + }); +} +``` + +使用例: + +`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, + }); + +// アプリ起動時 +track("app_launch"); + +// タブ移動(付随情報は properties で) +track("tab_change", { tab: "map", index: 2 }); + +// TTS リクエストの結果 +try { + await requestTts(text); + track("tts_success"); +} catch (err) { + track("tts_failure", { reason: String(err) }); +} +``` + +## Mutation: 位置情報送信(`sendLocation`) + +**遠隔測定用トークンのみ**が実行できます。イベント用トークンでは `unauthorized: a valid telemetry bearer token is required` エラーになります。また、`sendLogEvent` と異なり **`device` は必須**です(位置情報は端末と紐付いていることが前提のため、匿名では送信できません)。 + +```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) { + sessionId + warning + } + } +`; + +export interface LocationEventInput { + sessionId: string; // 必須。クライアント側で生成した一意な文字列 + device: string; // 必須(sendLogEvent と異なり省略不可) + 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: { sessionId: 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 { mutate: sendLocation } = useSendLocation(telemetryToken); + +useEffect(() => { + const watchId = navigator.geolocation.watchPosition((pos) => { + sendLocation({ + sessionId, + 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); +}, [sendLocation, sessionId]); +``` + +## 接続先の設定(環境変数) + +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..5a863b6 --- /dev/null +++ b/docs/react-websocket-observer.md @@ -0,0 +1,310 @@ +# 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 を実行できないため、閲覧用クライアントに配るトークンとして安全に分離されています(観測用トークンは GraphQL の履歴取得 Query `logEvents` / `interactionEvents` / `locations` にも使えます。[react-tanstack-query.md](./react-tanstack-query.md) を参照)。 + +> ブラウザ向けビルドに埋め込んだトークンは利用者から見えます。観測用トークンは読み取り専用スコープとはいえ、公開サイトに埋め込む場合は漏えい時にローテーションできる運用にしておいてください。 + +## 認証の仕組み + +ブラウザの `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", + "session_id": "client-generated-session-id", + "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", + "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": "..." } +} +``` + +**interaction** — `sendInteractionEvent` Mutation で登録されたユーザーインタラクション + +```json +{ + "type": "interaction", + "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": "tab_change", + "properties": { "tab": "map", "index": 2, "pinned": true } +} +``` + +ログイベントとインタラクションイベントは匿名で送信できるため、`device` が `null` の場合があります(`location_update` の `device` は常に非 null です)。 + +**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; // サーバー採番のイベント ID(重複排除に使える) + session_id: string; // クライアント側で生成されたセッション ID + 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; // サーバー採番のイベント 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"; + level: "debug" | "info" | "warn" | "error"; + message: string; + }; +} + +export interface InteractionEvent { + type: "interaction"; + 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; + +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, + observerToken: string, + device = "web-dashboard", +) { + const queryClient = useQueryClient(); + + useEffect(() => { + let ws: WebSocket | null = null; + let retryTimer: ReturnType; + let stableTimer: ReturnType; + let retries = 0; + let disposed = false; + + const connect = () => { + ws = new WebSocket(wsUrl, ["thq", `thq-auth-${observerToken}`]); + + ws.onopen = () => { + // 接続直後に切断される状態だと MAX_RETRIES が無効化されてしまうため、 + // 一定時間安定して接続できたときにだけリトライ回数をリセットする + stableTimer = setTimeout(() => { + retries = 0; + }, STABLE_CONNECTION_MS); + ws?.send(JSON.stringify({ type: "subscribe", device })); + }; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.type !== "location_update" && msg.type !== "log" && msg.type !== "interaction") { + 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 = () => { + // 安定接続に達する前に切断されたらリセット予約を取り消す(接続ストーム防止) + clearTimeout(stableTimer); + // 認証失敗(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); + clearTimeout(stableTimer); + 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 の履歴取得 Query(`logEvents` / `interactionEvents` / `locations`)を利用してください([react-tanstack-query.md](./react-tanstack-query.md) を参照)。集計データだけでよければ認証不要の `accuracyByLine` もあります。 +- WebSocket は CORS の制約を受けないため、GraphQL と違いリバースプロキシなしでクロスオリジン接続できます。ただし `wss://`(TLS)を使わないと HTTPS ページからは接続できません(混在コンテンツとしてブロックされます)。 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 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..468bc70 100644 --- a/src/config.rs +++ b/src/config.rs @@ -32,13 +32,17 @@ 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, } #[derive(Debug, Clone)] @@ -47,8 +51,9 @@ 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, } #[derive(Debug, Deserialize, Default)] @@ -57,8 +62,15 @@ 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, +} + +/// 空文字・空白のみのトークンは未設定(None)として扱う。 +/// clap は空の環境変数を Some("") として渡すため、ここで弾く。 +fn normalize_token(token: Option) -> Option { + token.filter(|t| !t.trim().is_empty()) } impl Config { @@ -84,22 +96,29 @@ 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(ws_auth_required) = cli.ws_auth_required { - file_cfg.ws_auth_required = Some(ws_auth_required); + if let Some(events_auth_token) = cli.events_auth_token { + file_cfg.events_auth_token = Some(events_auth_token); + } + if let Some(telemetry_auth_token) = cli.telemetry_auth_token { + file_cfg.telemetry_auth_token = Some(telemetry_auth_token); } - let ws_auth_required = match (file_cfg.ws_auth_required, file_cfg.ws_auth_token.as_ref()) { - (Some(required), _) => required, - (None, Some(_)) => true, - (None, None) => false, - }; + // 空文字・空白のみは未設定として扱い、空トークンで起動して + // 認証不能になる設定を弾く + 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 ws_auth_required && file_cfg.ws_auth_token.is_none() { + if !any_token { anyhow::bail!( - "ws_auth_required=true but ws_auth_token is missing; set THQ_WS_AUTH_TOKEN or disable auth" + "no auth token is configured; set THQ_OBSERVER_AUTH_TOKEN / THQ_EVENTS_AUTH_TOKEN / THQ_TELEMETRY_AUTH_TOKEN" ); } @@ -108,8 +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, - ws_auth_token: file_cfg.ws_auth_token, - ws_auth_required, + observer_auth_token, + events_auth_token, + telemetry_auth_token, }) } } @@ -120,6 +140,19 @@ 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, + } + } + fn tmp_path(name: &str) -> PathBuf { let mut p = std::env::temp_dir(); p.push(format!("{}_{}.toml", name, Uuid::new_v4())); @@ -129,36 +162,31 @@ 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, + 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.ws_auth_token.is_none()); - assert!(!cfg.ws_auth_required); + assert_eq!(cfg.observer_auth_token.as_deref(), Some("secret")); + assert!(cfg.events_auth_token.is_none()); + assert!(cfg.telemetry_auth_token.is_none()); } #[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 { - 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 +201,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 +213,9 @@ 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()), }) .unwrap(); @@ -190,8 +223,9 @@ 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")); let _ = fs::remove_file(path); } @@ -199,16 +233,15 @@ 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 { - 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 +254,22 @@ mod tests { } #[test] - fn ws_auth_defaults_to_required_when_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, - }) - .unwrap(); + fn no_tokens_is_rejected() { + let err = Config::from_cli(empty_cli()).unwrap_err(); - assert!(cfg.ws_auth_required); - assert_eq!(cfg.ws_auth_token.as_deref(), Some("secret")); + assert!(err.to_string().contains("no auth token")); } #[test] - fn ws_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), - }) - .unwrap(); - - assert!(!cfg.ws_auth_required); - assert_eq!(cfg.ws_auth_token.as_deref(), Some("secret")); + 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")); + } } } diff --git a/src/domain.rs b/src/domain.rs index 6c8ba49..4229216 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -1,7 +1,35 @@ +use std::collections::HashMap; + +use async_graphql::Enum; use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; -#[derive(Debug, Clone, Serialize_repr, Deserialize_repr, PartialEq, Eq)] +/// 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)] +#[graphql(rename_items = "lowercase")] #[repr(u8)] pub enum BatteryState { Unknown = 0, @@ -10,7 +38,22 @@ pub enum BatteryState { Full = 3, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +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")] pub enum MovementState { Arrived, @@ -28,9 +71,82 @@ 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)] +#[graphql(rename_items = "lowercase")] +#[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", + } + } + + /// 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)] +#[graphql(rename_items = "lowercase")] +#[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", + } + } + + /// 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, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] +#[graphql(rename_items = "lowercase")] #[serde(rename_all = "snake_case")] pub enum LogLevel { Debug, @@ -48,9 +164,22 @@ 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, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Enum)] +#[graphql(rename_items = "lowercase")] #[serde(rename_all = "snake_case")] pub enum LogType { System, @@ -66,14 +195,17 @@ impl LogType { LogType::Client => "client", } } -} -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Coords { - pub latitude: f64, - pub longitude: f64, - pub accuracy: Option, - pub speed: Option, + /// 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)] @@ -92,47 +224,20 @@ 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 { LocationUpdate(OutgoingLocation), Log(OutgoingLog), + Interaction(OutgoingInteraction), Error(OutgoingError), } #[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, @@ -157,11 +262,35 @@ 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 app_version: String, + pub platform: Platform, + pub channel: Channel, pub timestamp: u64, 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 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)] pub struct OutgoingError { pub error: ErrorBody, @@ -196,25 +325,73 @@ 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 - }"#; + fn outgoing_log_has_type_field() { + let msg = OutgoingMessage::Log(OutgoingLog { + 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, + 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] + fn outgoing_interaction_has_type_field() { + let msg = OutgoingMessage::Interaction(OutgoingInteraction { + 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 req: LocationUpdateRequest = serde_json::from_str(json).unwrap(); - assert_eq!(req.line_id, 7); - assert_eq!(req.station_id, Some(42)); + 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] 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), @@ -236,6 +413,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..28ef8c6 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -1,19 +1,47 @@ -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, Channel, LogBody, LogLevel, LogType, MovementState, OutgoingCoords, + OutgoingInteraction, OutgoingLocation, OutgoingLog, OutgoingMessage, Platform, Properties, + }, + segment::SegmentEstimator, + state::TelemetryHub, + storage::{ + EventFilter, InteractionEventRow, LineAccuracyBucketRow, LocationEventRow, LogEventRow, + 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 `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 RequestAuth { + pub can_send_events: bool, + pub can_send_location: bool, + pub can_read_events: bool, +} const HARD_LIMIT: i32 = 2000; #[derive(Enum, Copy, Clone, Eq, PartialEq, Debug)] +#[graphql(rename_items = "lowercase")] pub enum TimeBucketSize { Minute, Hour, @@ -63,9 +91,98 @@ pub struct LineAccuracyReport { pub buckets: Vec, } -pub fn build_schema(storage: Storage) -> AppSchema { - Schema::build(QueryRoot, EmptyMutation, EmptySubscription) +/// 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, + segmenter: SegmentEstimator, +) -> AppSchema { + Schema::build(QueryRoot, MutationRoot, EmptySubscription) .data(storage) + .data(hub) + .data(segmenter) .finish() } @@ -154,6 +271,493 @@ 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)] +pub struct LogEventInput { + /// Client-generated unique session identifier (arbitrary string). + 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")] + pub log_type: LogType, + pub level: LogLevel, + pub message: String, +} + +#[derive(SimpleObject)] +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, + /// 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)] +pub struct SendInteractionEventPayload { + pub session_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 { + /// Client-generated unique session identifier (arbitrary string). + pub session_id: String, + /// 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 session_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.session_id.trim().is_empty() { + 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()); + } + + let hub = ctx + .data::>() + .map_err(|_| "telemetry hub is not configured")?; + let storage = ctx + .data::() + .map_err(|_| "storage is not configured")?; + + let log = OutgoingLog { + 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, + 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 { + session_id: log.session_id, + }) + } + + /// 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.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()); + } + + 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, + 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())) { + 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 + /// 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.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()); + } + + 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 loc = OutgoingLocation { + id: Uuid::new_v4().to_string(), + session_id: input.session_id, + 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 { + session_id: loc.session_id, + warning, + }) + } } impl From for LineAccuracyBucket { @@ -170,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(); @@ -182,6 +849,639 @@ fn estimate_bucket_count(from: DateTime, to: DateTime, bucket_seconds: #[cfg(test)] mod tests { use super::*; + use crate::segment::LineTopology; + + const EVENTS_ONLY: RequestAuth = RequestAuth { + can_send_events: true, + can_send_location: false, + can_read_events: false, + }; + const TELEMETRY: RequestAuth = RequestAuth { + can_send_events: true, + can_send_location: true, + can_read_events: false, + }; + const OBSERVER: RequestAuth = RequestAuth { + can_send_events: false, + can_send_location: false, + can_read_events: true, + }; + + fn test_schema(hub: Arc) -> AppSchema { + build_schema( + Storage::default(), + hub, + SegmentEstimator::new(LineTopology::empty()), + ) + } + + fn request(query: &str, auth: RequestAuth) -> 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: {{ + sessionId: "sess-1", + device: "dev", + state: {state}, + lineId: 1, + coords: {{ latitude: 35.6812, longitude: 139.7671{extra} }}, + timestamp: 1706000000000 + }}) {{ sessionId warning }} + }}"# + ) + } + + #[tokio::test] + async fn send_log_event_broadcasts_and_returns_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: "sess-abc", + appVersion: "1.2.3", + platform: ios, + channel: production, + device: "dev", + timestamp: 1706000000000, + type: app, + level: info, + message: "hello" + }) { sessionId } + }"#, + EVENTS_ONLY, + )) + .await; + + assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors); + let data = resp.data.into_json().unwrap(); + 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["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)); + let schema = test_schema(hub.clone()); + + let resp = schema + .execute(request( + r#"mutation { + sendLogEvent(input: { + sessionId: "sess-anon", + appVersion: "1.2.3", + platform: ios, + channel: production, + 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: " ", + appVersion: "1.2.3", + platform: ios, + channel: production, + device: "dev", + timestamp: 1, + type: system, + level: warn, + message: "hi" + }) { sessionId } + }"#, + TELEMETRY, + )) + .await; + + assert!(!resp.errors.is_empty()); + assert!(resp.errors[0].message.contains("sessionId")); + assert!(hub.snapshot().await.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: { + sessionId: "sess-1", + appVersion: "1.2.3", + platform: ios, + channel: production, + device: "dev", + timestamp: 1, + type: app, + level: info, + message: " " + }) { sessionId } + }"#, + 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: { + sessionId: "sess-1", + appVersion: "1.2.3", + platform: ios, + channel: production, + device: "dev", + timestamp: 1, + type: app, + level: info, + message: "hi" + }) { 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_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", + appVersion: "1.2.3", + platform: ios, + channel: production, + 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()); + 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] + 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", + appVersion: "1.2.3", + platform: ios, + channel: production, + 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", + appVersion: "1.2.3", + platform: ios, + channel: production, + 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", + appVersion: "1.2.3", + platform: ios, + channel: production, + 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)); + 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_eq!(data["sendLocation"]["sessionId"], "sess-1"); + 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: { + sessionId: "sess-1", + device: "dev", + state: moving, + lineId: 1, + coords: { latitude: 91.0, longitude: 139.7671 }, + timestamp: 1 + }) { sessionId } + }"#, + 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: { + sessionId: "sess-1", + device: "dev", + state: moving, + stationId: 42, + lineId: 1, + coords: { latitude: 35.6812, longitude: 139.7671 }, + timestamp: 1 + }) { 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["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() { diff --git a/src/main.rs b/src/main.rs index 9cb2572..627ce8e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,8 +18,9 @@ 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(), "starting thq-server" ); match server::run_server(config).await { 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 c742c1e..3cc8f1b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -3,60 +3,51 @@ 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::{ ws::{Message, WebSocket, WebSocketUpgrade}, - ConnectInfo, FromRequestParts, State, + ConnectInfo, State, }, - http::{ - header::AUTHORIZATION, header::SEC_WEBSOCKET_PROTOCOL, request::Parts, HeaderMap, - StatusCode, - }, - response::{Html, IntoResponse, Json}, + http::{header::AUTHORIZATION, header::SEC_WEBSOCKET_PROTOCOL, HeaderMap, StatusCode}, + response::IntoResponse, routing::{get, post}, 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, RequestAuth}, 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, - required: bool, + observer_token: Option, + events_token: Option, + telemetry_token: Option, } #[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,29 +79,23 @@ 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"); - } + 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(), }, - 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)) + .route("/graphql", post(graphql_handler)) .with_state(state); let addr: SocketAddr = format!("{}:{}", config.host, config.port) @@ -158,302 +143,57 @@ 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_playground() -> impl IntoResponse { - Html(playground_source( - GraphQLPlaygroundConfig::new("/graphql").subscription_endpoint("/graphql"), - )) -} - -#[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, +async fn graphql_handler( 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()), - }), - ); - } - } + headers: HeaderMap, + req: GraphQLRequest, +) -> GraphQLResponse { + let auth = request_auth(&headers, &state.auth); + let req = req.into_inner().data(auth); + state.schema.execute(req).await.into() +} - // 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 +/// 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 RequestAuth { + can_send_events: false, + can_send_location: false, + can_read_events: 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; + 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); - // 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"); - } - } - - // Store in database - if let Err(err) = state.storage.store_location(&loc).await { - tracing::error!(?err, "failed to persist location_update"); + RequestAuth { + can_send_events, + can_send_location, + can_read_events, } - - // 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,11 +298,9 @@ 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); @@ -571,7 +309,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 +416,91 @@ 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 scoped_auth() -> AuthConfig { + AuthConfig { + observer_token: Some("observer-secret".into()), + events_token: Some("events-secret".into()), + telemetry_token: Some("telemetry-secret".into()), + } + } + + 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: { + sessionId: "sess-1", + appVersion: "1.2.3", + platform: ios, + channel: production, + device: "test-device", + timestamp: 1706000000000, + type: app, + level: info, + message: "Hello, world!" + }) { sessionId } + }"#, + auth_header, + ) + } + + fn send_location_request(auth_header: Option<&str>) -> Request { + 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 + }) { sessionId } + }"#, + 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 +538,223 @@ 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(); + // GraphQL mutations over HTTP - assert_eq!(response.status(), StatusCode::OK); + #[tokio::test] + 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"], true); - assert_eq!(v["id"], "custom-id-123"); + 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_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 - }); + 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::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_eq!(hub.snapshot().await.len(), 1); } #[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 - }); + async fn log_event_accepts_telemetry_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 telemetry-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("out of range")); + 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_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_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") - .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("accuracy")); + 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_drops_station_id_when_moving() { - 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/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(), - ) + 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_eq!(v["data"]["sendLocation"]["sessionId"], "sess-1"); 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()); + 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] - 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!" - } - }); + 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/log") - .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::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"][0]["message"] + .as_str() + .unwrap() + .contains("telemetry")); + assert!(hub.snapshot().await.is_empty()); } #[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 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/log") - .header("content-type", "application/json") - .body(Body::from(payload.to_string())) - .unwrap(), - ) + .oneshot(send_location_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"].is_null()); + assert!(hub.snapshot().await.is_empty()); } - #[tokio::test] - async fn post_log_broadcasts_to_hub() { - let state = test_state(); - 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(), - ) - .await - .unwrap(); - - 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"); + #[test] + 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); } - // 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()), + #[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"); } } - fn auth_required_router() -> Router { - Router::new() - .route("/api/location", post(post_location)) - .route("/api/log", post(post_log)) - .with_state(auth_required_state()) + // 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 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 log_events_rejects_missing_auth() { + let app = graphql_router(state_with_auth(scoped_auth())); - 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::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 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 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 - }); - - 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(), - ) - .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")); + 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 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 - }); + async fn log_events_accepts_observer_token_and_reports_disabled_storage() { + let app = graphql_router(state_with_auth(scoped_auth())); 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(), - ) + .oneshot(log_events_request(Some("Bearer observer-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); + 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 d747d23..b9fcdc6 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)] @@ -19,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, @@ -59,6 +126,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 +176,19 @@ 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, + app_version TEXT, + platform TEXT, + channel TEXT, log_type TEXT NOT NULL, log_level TEXT NOT NULL, message TEXT NOT NULL, @@ -125,6 +200,38 @@ impl Storage { .execute(pool) .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS interaction_events ( + 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() + ); + "#, + ) + .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);", ) @@ -137,10 +244,59 @@ 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("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) .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?; + + // 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(()) } @@ -152,9 +308,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,10 +341,14 @@ 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, 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) @@ -199,6 +360,37 @@ 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); + + 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, 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) + .await + .context("failed to insert interaction event")?; + + Ok(()) + } + pub async fn fetch_line_accuracy( &self, line_id: i32, @@ -250,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 {