diff --git a/apps/aris-backend/package.json b/apps/aris-backend/package.json new file mode 100644 index 0000000..3f11cde --- /dev/null +++ b/apps/aris-backend/package.json @@ -0,0 +1,19 @@ +{ + "name": "@aris/backend", + "version": "0.0.0", + "type": "module", + "main": "src/index.ts", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "test": "bun test src/" + }, + "dependencies": { + "@aris/core": "workspace:*", + "@aris/source-location": "workspace:*", + "@aris/source-weatherkit": "workspace:*", + "better-auth": "^1", + "hono": "^4", + "postgres": "^3" + } +} diff --git a/apps/aris-backend/src/index.ts b/apps/aris-backend/src/index.ts new file mode 100644 index 0000000..9f30f9b --- /dev/null +++ b/apps/aris-backend/src/index.ts @@ -0,0 +1,10 @@ +import { Hono } from "hono" + +const app = new Hono() + +app.get("/health", (c) => c.json({ status: "ok" })) + +export default { + port: 3000, + fetch: app.fetch, +} diff --git a/apps/aris-backend/tsconfig.json b/apps/aris-backend/tsconfig.json new file mode 100644 index 0000000..a88fedd --- /dev/null +++ b/apps/aris-backend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["bun-types"] + }, + "include": ["src"] +} diff --git a/bun.lock b/bun.lock index 3706966..10a722d 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,18 @@ "typescript": "^5", }, }, + "apps/aris-backend": { + "name": "@aris/backend", + "version": "0.0.0", + "dependencies": { + "@aris/core": "workspace:*", + "@aris/source-location": "workspace:*", + "@aris/source-weatherkit": "workspace:*", + "better-auth": "^1", + "hono": "^4", + "postgres": "^3", + }, + }, "packages/aris-core": { "name": "@aris/core", "version": "0.0.0", @@ -52,6 +64,8 @@ }, }, "packages": { + "@aris/backend": ["@aris/backend@workspace:apps/aris-backend"], + "@aris/core": ["@aris/core@workspace:packages/aris-core"], "@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"], @@ -66,6 +80,18 @@ "@ark/util": ["@ark/util@0.56.0", "", {}, "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA=="], + "@better-auth/core": ["@better-auth/core@1.4.17", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-WSaEQDdUO6B1CzAmissN6j0lx9fM9lcslEYzlApB5UzFaBeAOHNUONTdglSyUs6/idiZBoRvt0t/qMXCgIU8ug=="], + + "@better-auth/telemetry": ["@better-auth/telemetry@1.4.17", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.17" } }, "sha512-R1BC4e/bNjQbXu7lG6ubpgmsPj7IMqky5DvMlzAtnAJWJhh99pMh/n6w5gOHa0cqDZgEAuj75IPTxv+q3YiInA=="], + + "@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + + "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], + + "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + "@oxfmt/darwin-arm64": ["@oxfmt/darwin-arm64@0.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aYXuGf/yq8nsyEcHindGhiz9I+GEqLkVq8CfPbd+6VE259CpPEH+CaGHEO1j6vIOmNr8KHRq+IAjeRO2uJpb8A=="], "@oxfmt/darwin-x64": ["@oxfmt/darwin-x64@0.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vs3b8Bs53hbiNvcNeBilzE/+IhDTWKjSBB3v/ztr664nZk65j0xr+5IHMBNz3CFppmX7o/aBta2PxY+t+4KoPg=="], @@ -98,6 +124,8 @@ "@oxlint/win32-x64": ["@oxlint/win32-x64@1.39.0", "", { "os": "win32", "cpu": "x64" }, "sha512-sbi25lfj74hH+6qQtb7s1wEvd1j8OQbTaH8v3xTcDjrwm579Cyh0HBv1YSZ2+gsnVwfVDiCTL1D0JsNqYXszVA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], "@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], @@ -106,16 +134,38 @@ "arktype": ["arktype@2.1.29", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ=="], + "better-auth": ["better-auth@1.4.17", "", { "dependencies": { "@better-auth/core": "1.4.17", "@better-auth/telemetry": "1.4.17", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-VmHGQyKsEahkEs37qguROKg/6ypYpNF13D7v/lkbO7w7Aivz0Bv2h+VyUkH4NzrGY0QBKXi1577mGhDCVwp0ew=="], + + "better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], + "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + + "hono": ["hono@4.11.5", "", {}, "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g=="], + + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + + "kysely": ["kysely@0.28.10", "", {}, "sha512-ksNxfzIW77OcZ+QWSAPC7yDqUSaIVwkTWnTPNiIy//vifNbwsSgQ57OkkncHxxpcBHM3LRfLAZVEh7kjq5twVA=="], + + "nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="], + "oxfmt": ["oxfmt@0.24.0", "", { "dependencies": { "tinypool": "2.0.0" }, "optionalDependencies": { "@oxfmt/darwin-arm64": "0.24.0", "@oxfmt/darwin-x64": "0.24.0", "@oxfmt/linux-arm64-gnu": "0.24.0", "@oxfmt/linux-arm64-musl": "0.24.0", "@oxfmt/linux-x64-gnu": "0.24.0", "@oxfmt/linux-x64-musl": "0.24.0", "@oxfmt/win32-arm64": "0.24.0", "@oxfmt/win32-x64": "0.24.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-UjeM3Peez8Tl7IJ9s5UwAoZSiDRMww7BEc21gDYxLq3S3/KqJnM3mjNxsoSHgmBvSeX6RBhoVc2MfC/+96RdSw=="], "oxlint": ["oxlint@1.39.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.39.0", "@oxlint/darwin-x64": "1.39.0", "@oxlint/linux-arm64-gnu": "1.39.0", "@oxlint/linux-arm64-musl": "1.39.0", "@oxlint/linux-x64-gnu": "1.39.0", "@oxlint/linux-x64-musl": "1.39.0", "@oxlint/win32-arm64": "1.39.0", "@oxlint/win32-x64": "1.39.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.10.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-wSiLr0wjG+KTU6c1LpVoQk7JZ7l8HCKlAkVDVTJKWmCGazsNxexxnOXl7dsar92mQcRnzko5g077ggP3RINSjA=="], + "postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="], + + "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "tinypool": ["tinypool@2.0.0", "", {}, "sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], } } diff --git a/docs/backend-spec.md b/docs/backend-spec.md new file mode 100644 index 0000000..3fe7331 --- /dev/null +++ b/docs/backend-spec.md @@ -0,0 +1,178 @@ +# ARIS Backend Specification + +## Problem Statement + +ARIS needs a backend service that manages per-user FeedEngine instances and delivers real-time feed updates to clients. The backend must handle authentication, maintain WebSocket connections for live updates, and accept context updates (like location) that trigger feed recalculations. + +## Requirements + +### Authentication +- Email/password authentication using BetterAuth +- PostgreSQL for session and user storage +- Session tokens validated via `Authorization: Bearer ` header +- Auth endpoints exposed via BetterAuth's built-in routes + +### FeedEngine Management +- Each authenticated user gets their own FeedEngine instance +- Instances are cached in memory with a 30-minute TTL +- TTL resets on any activity (WebSocket message, location update) +- Default sources registered for each user: `LocationSource`, `WeatherSource`, `TflSource` +- Source configuration is hardcoded initially (customization deferred) + +### WebSocket Connection +- Single endpoint: `GET /ws` (upgrades to WebSocket) +- Authentication via `Authorization: Bearer ` header on upgrade request +- Rejected before upgrade if token is invalid +- Multiple connections per user allowed (e.g., multiple devices) +- All connections for a user receive the same feed updates +- On connect: immediately send current feed state + +### JSON-RPC Protocol +All WebSocket communication uses JSON-RPC 2.0. + +**Client → Server (Requests):** +```json +{ "jsonrpc": "2.0", "method": "location.update", "params": { "lat": 51.5, "lng": -0.1, "accuracy": 10, "timestamp": "2025-01-01T12:00:00Z" }, "id": 1 } +{ "jsonrpc": "2.0", "method": "feed.refresh", "params": {}, "id": 2 } +``` + +**Server → Client (Responses):** +```json +{ "jsonrpc": "2.0", "result": { "ok": true }, "id": 1 } +``` + +**Server → Client (Notifications - no id):** +```json +{ "jsonrpc": "2.0", "method": "feed.update", "params": { "items": [...], "errors": [...] } } +``` + +### JSON-RPC Methods + +| Method | Params | Description | +|--------|--------|-------------| +| `location.update` | `{ lat, lng, accuracy, timestamp }` | Push location update, triggers feed refresh | +| `feed.refresh` | `{}` | Force manual feed refresh | + +### Server Notifications + +| Method | Params | Description | +|--------|--------|-------------| +| `feed.update` | `{ context, items, errors }` | Feed state changed | +| `error` | `{ code, message, data? }` | Source or system error | + +### Error Handling +- Source failures during refresh are reported via `error` notification +- Format: `{ "jsonrpc": "2.0", "method": "error", "params": { "code": -32000, "message": "...", "data": { "sourceId": "weather" } } }` + +## Acceptance Criteria + +1. **Auth Flow** + - [ ] User can sign up with email/password via `POST /api/auth/sign-up` + - [ ] User can sign in via `POST /api/auth/sign-in` and receive session token + - [ ] Invalid credentials return 401 + +2. **WebSocket Connection** + - [ ] `GET /ws` with valid `Authorization` header upgrades to WebSocket + - [ ] `GET /ws` without valid token returns 401 (no upgrade) + - [ ] On successful connect, client receives `feed.update` notification with current state + - [ ] Multiple connections from same user all receive updates + +3. **FeedEngine Lifecycle** + - [ ] First connection for a user creates FeedEngine with default sources + - [ ] Subsequent connections reuse the same FeedEngine + - [ ] FeedEngine is destroyed after 30 minutes of inactivity + - [ ] Activity (any WebSocket message) resets the TTL + +4. **JSON-RPC Methods** + - [ ] `location.update` updates LocationSource and triggers feed refresh + - [ ] `feed.refresh` triggers manual refresh + - [ ] Both return `{ "ok": true }` on success + - [ ] Invalid method returns JSON-RPC error + +5. **Feed Updates** + - [ ] FeedEngine subscription pushes updates to all user's WebSocket connections + - [ ] Updates include `context`, `items`, and `errors` + +## Implementation Approach + +### Phase 1: Project Setup +1. Create `apps/aris-backend` with Hono +2. Configure TypeScript, add dependencies (hono, better-auth, postgres driver) +3. Set up database connection and BetterAuth + +### Phase 2: Authentication +4. Configure BetterAuth with email/password provider +5. Mount BetterAuth routes at `/api/auth/*` +6. Create session validation helper for extracting user from token + +### Phase 3: FeedEngine Manager +7. Create `FeedEngineManager` class: + - `getOrCreate(userId): FeedEngine` - returns cached or creates new + - `touch(userId)` - resets TTL + - `destroy(userId)` - manual cleanup + - Internal TTL cleanup loop +8. Factory function to create FeedEngine with default sources + +### Phase 4: WebSocket Handler +9. Create WebSocket upgrade endpoint at `/ws` +10. Validate `Authorization` header before upgrade +11. On connect: register connection, send initial feed state +12. On disconnect: unregister connection + +### Phase 5: JSON-RPC Handler +13. Create JSON-RPC message parser and dispatcher +14. Implement `location.update` method +15. Implement `feed.refresh` method +16. Wire FeedEngine subscription to broadcast `feed.update` to all user connections + +### Phase 6: Connection Manager +17. Create `ConnectionManager` to track WebSocket connections per user +18. Broadcast helper to send to all connections for a user + +### Phase 7: Integration & Testing +19. Integration test: auth → connect → location update → receive feed +20. Test multiple connections receive same updates +21. Test TTL cleanup + +## Package Structure + +``` +apps/aris-backend/ +├── package.json +├── src/ +│ ├── index.ts # Entry point, Hono app +│ ├── auth.ts # BetterAuth configuration +│ ├── db.ts # Database connection +│ ├── ws/ +│ │ ├── handler.ts # WebSocket upgrade & message handling +│ │ ├── jsonrpc.ts # JSON-RPC parser & types +│ │ └── methods.ts # Method implementations +│ ├── feed/ +│ │ ├── manager.ts # FeedEngineManager (TTL cache) +│ │ ├── factory.ts # Creates FeedEngine with default sources +│ │ └── connections.ts # ConnectionManager (user → WebSocket[]) +│ └── types.ts # Shared types +``` + +## Dependencies + +```json +{ + "dependencies": { + "hono": "^4", + "better-auth": "^1", + "postgres": "^3", + "@aris/core": "workspace:*", + "@aris/source-location": "workspace:*", + "@aris/source-weatherkit": "workspace:*", + "@aris/data-source-tfl": "workspace:*" + } +} +``` + +## Open Questions (Deferred) + +- User source configuration storage (database schema) +- Rate limiting on WebSocket methods +- Reconnection handling (client-side concern) +- Horizontal scaling (would need Redis for shared state)