diff --git a/AGENTS.md b/AGENTS.md index 6cc24fe..3e90e5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,7 +223,9 @@ docker build web/ # self-contained build ## 7. Backend Integration -ChronoMind uses the shared **platform-service** (port 4003) from `learning_ai_common_plat`. Four ChronoMind-specific modules exist: +ChronoMind has a **product-specific backend** in `backend/` (Fastify 5, port 4011) plus the shared **platform-service** (port 4003) for auth, billing, flags, etc. + +### Product Backend (`backend/`, port 4011) | Module | Container | Endpoints | Tests | |--------|-----------|-----------|-------| @@ -234,6 +236,11 @@ ChronoMind uses the shared **platform-service** (port 4003) from `learning_ai_co All documents include `productId: "chronomind"`. +```bash +cd backend && npm run dev # Dev server on port 4011 +cd backend && npm test # 130 Vitest tests +``` + ### Sync Protocol - `syncVersion` monotonic integer — optimistic concurrency, 409 on stale writes - Delta sync: `GET /timers/sync?since=` returns only changed timers diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..7611314 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,9 @@ +# ChronoMind Backend — Environment Variables +PORT=4011 +HOST=0.0.0.0 +NODE_ENV=development +CORS_ORIGIN=http://localhost:3000 +COSMOS_ENDPOINT=https://cosmos-mywisprai.documents.azure.com:443/ +COSMOS_KEY= +COSMOS_DATABASE=lysnrai +JWT_SECRET= diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..53c4997 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.tsbuildinfo diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..3d042a6 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,2689 @@ +{ + "name": "@chronomind/backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@chronomind/backend", + "version": "0.1.0", + "dependencies": { + "@azure/cosmos": "^4.2.0", + "@bytelyst/auth": "file:../../learning_ai_common_plat/packages/auth", + "@bytelyst/config": "file:../../learning_ai_common_plat/packages/config", + "@bytelyst/cosmos": "file:../../learning_ai_common_plat/packages/cosmos", + "@bytelyst/errors": "file:../../learning_ai_common_plat/packages/errors", + "@bytelyst/fastify-core": "file:../../learning_ai_common_plat/packages/fastify-core", + "fastify": "^5.2.1", + "jose": "^6.0.8", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } + }, + "../../learning_ai_common_plat/packages/auth": { + "name": "@bytelyst/auth", + "version": "0.1.0", + "dependencies": { + "@bytelyst/errors": "workspace:*" + }, + "peerDependencies": { + "bcryptjs": ">=2.4.0", + "jose": ">=5.0.0" + } + }, + "../../learning_ai_common_plat/packages/config": { + "name": "@bytelyst/config", + "version": "0.1.0", + "devDependencies": { + "@azure/identity": "^4.13.0", + "@azure/keyvault-secrets": "^4.10.0" + }, + "peerDependencies": { + "@azure/identity": ">=4.0.0", + "@azure/keyvault-secrets": ">=4.8.0", + "zod": ">=3.20.0" + }, + "peerDependenciesMeta": { + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + } + } + }, + "../../learning_ai_common_plat/packages/cosmos": { + "name": "@bytelyst/cosmos", + "version": "0.1.0", + "peerDependencies": { + "@azure/cosmos": ">=4.0.0" + } + }, + "../../learning_ai_common_plat/packages/errors": { + "name": "@bytelyst/errors", + "version": "0.1.0" + }, + "../../learning_ai_common_plat/packages/fastify-core": { + "name": "@bytelyst/fastify-core", + "version": "0.1.0", + "dependencies": { + "@bytelyst/errors": "workspace:*" + }, + "devDependencies": { + "@fastify/swagger": "^9.7.0", + "fastify-metrics": "^10.6.0" + }, + "peerDependencies": { + "@fastify/cors": ">=10.0.0", + "@fastify/swagger": ">=9.0.0", + "fastify": ">=5.0.0", + "fastify-metrics": ">=10.0.0" + }, + "peerDependenciesMeta": { + "@fastify/swagger": { + "optional": true + }, + "fastify-metrics": { + "optional": true + } + } + }, + "node_modules/@azure-rest/core-client": { + "version": "2.5.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@azure-rest/core-client/-/core-client-2.5.1.tgz", + "integrity": "sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-http-compat": { + "version": "2.3.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@azure/core-http-compat/-/core-http-compat-2.3.2.tgz", + "integrity": "sha512-Tf6ltdKzOJEgxZeWLCjMxrxbodB/ZeCbzzA1A2qHbhzAjzjHoBVSUeSl/baT/oHAxhc4qdqVaDKnc2+iE932gw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@azure/core-client": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.6.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.22.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz", + "integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/cosmos": { + "version": "4.9.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@azure/cosmos/-/cosmos-4.9.1.tgz", + "integrity": "sha512-fPnfL4JsmJJ/jEYUhlznKfrEr2pMvJwBncGVcUC2Xi7Nlj0MrUMRE+UOrptl/lRV2W7l68Br+b9Ikzm0KiZZHg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-rest-pipeline": "^1.19.1", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/keyvault-keys": "^4.9.0", + "@azure/logger": "^1.1.4", + "fast-json-stable-stringify": "^2.1.0", + "priorityqueuejs": "^2.0.0", + "semaphore": "^1.1.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/keyvault-common": { + "version": "2.0.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@azure/keyvault-common/-/keyvault-common-2.0.0.tgz", + "integrity": "sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-client": "^1.5.0", + "@azure/core-rest-pipeline": "^1.8.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.10.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/keyvault-keys": { + "version": "4.10.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@azure/keyvault-keys/-/keyvault-keys-4.10.0.tgz", + "integrity": "sha512-eDT7iXoBTRZ2n3fLiftuGJFD+yjkiB1GNqzU2KbY1TLYeXeSPVTVgn2eJ5vmRTZ11978jy2Kg2wI7xa9Tyr8ag==", + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-http-compat": "^2.2.0", + "@azure/core-lro": "^2.7.2", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.0", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/keyvault-common": "^2.0.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@bytelyst/auth": { + "resolved": "../../learning_ai_common_plat/packages/auth", + "link": true + }, + "node_modules/@bytelyst/config": { + "resolved": "../../learning_ai_common_plat/packages/config", + "link": true + }, + "node_modules/@bytelyst/cosmos": { + "resolved": "../../learning_ai_common_plat/packages/cosmos", + "link": true + }, + "node_modules/@bytelyst/errors": { + "resolved": "../../learning_ai_common_plat/packages/errors", + "link": true + }, + "node_modules/@bytelyst/fastify-core": { + "resolved": "../../learning_ai_common_plat/packages/fastify-core", + "link": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.13", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@types/node/-/node-22.19.13.tgz", + "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.3.tgz", + "integrity": "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.7.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/fastify/-/fastify-5.7.4.tgz", + "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/priorityqueuejs": { + "version": "2.0.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/priorityqueuejs/-/priorityqueuejs-2.0.0.tgz", + "integrity": "sha512-19BMarhgpq3x4ccvVi8k2QpJZcymo/iFUcrhPd4V96kYGovOdTsWwy7fxChYi4QY+m2EnGBWSX9Buakz+tWNQQ==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semaphore": { + "version": "1.1.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/semaphore/-/semaphore-1.1.0.tgz", + "integrity": "sha512-O4OZEaNtkMd/K0i6js9SL+gqy0ZCBMgUvlSqHKi4IBdjhe7wB8pwztUk1BbZ1fmrvpwFrPbHzqd2w5pTcJH6LA==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..f1af60c --- /dev/null +++ b/backend/package.json @@ -0,0 +1,32 @@ +{ + "name": "@chronomind/backend", + "version": "0.1.0", + "private": true, + "description": "ChronoMind product-specific backend — timers, routines, households, shared timers", + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc", + "start": "node dist/server.js", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@bytelyst/auth": "file:../../learning_ai_common_plat/packages/auth", + "@bytelyst/config": "file:../../learning_ai_common_plat/packages/config", + "@bytelyst/cosmos": "file:../../learning_ai_common_plat/packages/cosmos", + "@bytelyst/errors": "file:../../learning_ai_common_plat/packages/errors", + "@bytelyst/fastify-core": "file:../../learning_ai_common_plat/packages/fastify-core", + "@azure/cosmos": "^4.2.0", + "fastify": "^5.2.1", + "jose": "^6.0.8", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts new file mode 100644 index 0000000..d86560a --- /dev/null +++ b/backend/src/lib/auth.ts @@ -0,0 +1,6 @@ +/** + * Re-export from @bytelyst/auth — shared across all services. + * JWT auth middleware — validates tokens issued by platform-service. + * Shares the same JWT_SECRET so it can verify without network calls. + */ +export { extractAuth, requireRole, type AuthPayload } from '@bytelyst/auth'; diff --git a/backend/src/lib/config.ts b/backend/src/lib/config.ts new file mode 100644 index 0000000..dfdd78b --- /dev/null +++ b/backend/src/lib/config.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + PORT: z.coerce.number().default(4011), + HOST: z.string().default('0.0.0.0'), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + CORS_ORIGIN: z.string().optional(), + SERVICE_NAME: z.string().default('chronomind-backend'), + COSMOS_ENDPOINT: z.string().min(1, 'COSMOS_ENDPOINT is required'), + COSMOS_KEY: z.string().min(1, 'COSMOS_KEY is required'), + COSMOS_DATABASE: z.string().default('lysnrai'), + JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'), +}); + +export const config = envSchema.parse(process.env); diff --git a/backend/src/lib/cosmos-init.ts b/backend/src/lib/cosmos-init.ts new file mode 100644 index 0000000..03b3a12 --- /dev/null +++ b/backend/src/lib/cosmos-init.ts @@ -0,0 +1,28 @@ +import { initializeAllContainers, registerContainers } from '@bytelyst/cosmos'; +import type { ContainerConfig } from '@bytelyst/cosmos'; +import { config } from './config.js'; + +const CONTAINER_DEFS: Record = { + timers: { partitionKeyPath: '/userId' }, + routines: { partitionKeyPath: '/userId' }, + households: { partitionKeyPath: '/id' }, + shared_timers: { partitionKeyPath: '/householdId' }, + // Webhooks + webhook_subscriptions: { partitionKeyPath: '/userId' }, + webhook_events: { partitionKeyPath: '/subscriptionId', defaultTtl: 30 * 86400 }, +}; + +export async function initCosmosIfNeeded(): Promise { + registerContainers(CONTAINER_DEFS); + + const shouldInit = config.NODE_ENV !== 'production' || process.env.COSMOS_AUTO_INIT === 'true'; + if (!shouldInit) return; + + try { + await initializeAllContainers(); + process.stdout.write('[chronomind-backend] Cosmos containers ensured\n'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`[chronomind-backend] Cosmos init failed: ${msg}\n`); + } +} diff --git a/backend/src/lib/cosmos.ts b/backend/src/lib/cosmos.ts new file mode 100644 index 0000000..e82cae5 --- /dev/null +++ b/backend/src/lib/cosmos.ts @@ -0,0 +1,4 @@ +/** + * Re-export from @bytelyst/cosmos — shared across all services. + */ +export { getContainer, getCosmosClient, getDatabase } from '@bytelyst/cosmos'; diff --git a/backend/src/lib/errors.ts b/backend/src/lib/errors.ts new file mode 100644 index 0000000..dd79e9e --- /dev/null +++ b/backend/src/lib/errors.ts @@ -0,0 +1,12 @@ +/** + * Re-export from @bytelyst/errors — shared across all services. + */ +export { + ServiceError, + BadRequestError, + UnauthorizedError, + ForbiddenError, + NotFoundError, + ConflictError, + TooManyRequestsError, +} from '@bytelyst/errors'; diff --git a/backend/src/lib/request-context.ts b/backend/src/lib/request-context.ts new file mode 100644 index 0000000..731877f --- /dev/null +++ b/backend/src/lib/request-context.ts @@ -0,0 +1,34 @@ +/** + * Request-level product context helpers for ChronoMind backend. + */ + +import type { FastifyRequest } from 'fastify'; +import { BadRequestError } from './errors.js'; + +export interface JwtPayload { + sub: string; + email?: string; + role?: string; + productId?: string; + type?: string; +} + +declare module 'fastify' { + interface FastifyRequest { + jwtPayload?: JwtPayload; + } +} + +const PRODUCT_ID = 'chronomind'; + +export function getRequestProductId(req: FastifyRequest): string { + const jwtPid = req.jwtPayload?.productId; + if (jwtPid && jwtPid !== PRODUCT_ID) { + throw new BadRequestError(`Invalid productId: expected ${PRODUCT_ID}, got ${jwtPid}`); + } + const header = req.headers['x-product-id']; + if (typeof header === 'string' && header.length > 0 && header !== PRODUCT_ID) { + throw new BadRequestError(`Invalid productId: expected ${PRODUCT_ID}, got ${header}`); + } + return PRODUCT_ID; +} diff --git a/backend/src/modules/households/households.test.ts b/backend/src/modules/households/households.test.ts new file mode 100644 index 0000000..b178f91 --- /dev/null +++ b/backend/src/modules/households/households.test.ts @@ -0,0 +1,198 @@ +/** + * Households module unit tests — validates schemas, constants, and types. + */ + +import { describe, it, expect } from 'vitest'; +import { + CreateHouseholdSchema, + UpdateHouseholdSchema, + CreateInviteSchema, + AcceptInviteSchema, + RemoveMemberSchema, + HouseholdQuerySchema, + MEMBER_ROLES, + INVITE_STATUSES, + MAX_HOUSEHOLD_MEMBERS, +} from './types.js'; + +// ── Constants ── + +describe('household constants', () => { + it('has 2 member roles', () => { + expect(MEMBER_ROLES).toEqual(['admin', 'member']); + }); + + it('has 4 invite statuses', () => { + expect(INVITE_STATUSES).toEqual(['pending', 'accepted', 'expired', 'revoked']); + }); + + it('has max 6 members', () => { + expect(MAX_HOUSEHOLD_MEMBERS).toBe(6); + }); +}); + +// ── CreateHouseholdSchema ── + +describe('CreateHouseholdSchema', () => { + it('accepts valid input', () => { + const result = CreateHouseholdSchema.safeParse({ + name: 'Smith Family', + displayName: 'John Smith', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('Smith Family'); + expect(result.data.displayName).toBe('John Smith'); + } + }); + + it('rejects missing name', () => { + const result = CreateHouseholdSchema.safeParse({ displayName: 'John' }); + expect(result.success).toBe(false); + }); + + it('rejects missing displayName', () => { + const result = CreateHouseholdSchema.safeParse({ name: 'Family' }); + expect(result.success).toBe(false); + }); + + it('rejects empty name', () => { + const result = CreateHouseholdSchema.safeParse({ name: '', displayName: 'John' }); + expect(result.success).toBe(false); + }); + + it('rejects name > 200 chars', () => { + const result = CreateHouseholdSchema.safeParse({ + name: 'x'.repeat(201), + displayName: 'John', + }); + expect(result.success).toBe(false); + }); +}); + +// ── UpdateHouseholdSchema ── + +describe('UpdateHouseholdSchema', () => { + it('accepts name update', () => { + const result = UpdateHouseholdSchema.safeParse({ name: 'New Name' }); + expect(result.success).toBe(true); + }); + + it('accepts empty update', () => { + const result = UpdateHouseholdSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('rejects empty name string', () => { + const result = UpdateHouseholdSchema.safeParse({ name: '' }); + expect(result.success).toBe(false); + }); +}); + +// ── CreateInviteSchema ── + +describe('CreateInviteSchema', () => { + it('provides default 72h expiry', () => { + const result = CreateInviteSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.expiresInHours).toBe(72); + } + }); + + it('accepts custom expiry', () => { + const result = CreateInviteSchema.safeParse({ expiresInHours: 24 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.expiresInHours).toBe(24); + } + }); + + it('rejects expiry > 168 hours', () => { + const result = CreateInviteSchema.safeParse({ expiresInHours: 200 }); + expect(result.success).toBe(false); + }); + + it('rejects expiry < 1 hour', () => { + const result = CreateInviteSchema.safeParse({ expiresInHours: 0 }); + expect(result.success).toBe(false); + }); +}); + +// ── AcceptInviteSchema ── + +describe('AcceptInviteSchema', () => { + it('accepts valid invite', () => { + const result = AcceptInviteSchema.safeParse({ + code: 'ABC123DEF456', + displayName: 'Jane Smith', + }); + expect(result.success).toBe(true); + }); + + it('rejects missing code', () => { + const result = AcceptInviteSchema.safeParse({ displayName: 'Jane' }); + expect(result.success).toBe(false); + }); + + it('rejects missing displayName', () => { + const result = AcceptInviteSchema.safeParse({ code: 'ABC123' }); + expect(result.success).toBe(false); + }); + + it('rejects empty code', () => { + const result = AcceptInviteSchema.safeParse({ code: '', displayName: 'Jane' }); + expect(result.success).toBe(false); + }); +}); + +// ── RemoveMemberSchema ── + +describe('RemoveMemberSchema', () => { + it('accepts valid userId', () => { + const result = RemoveMemberSchema.safeParse({ userId: 'user_123' }); + expect(result.success).toBe(true); + }); + + it('rejects empty userId', () => { + const result = RemoveMemberSchema.safeParse({ userId: '' }); + expect(result.success).toBe(false); + }); + + it('rejects missing userId', () => { + const result = RemoveMemberSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +// ── HouseholdQuerySchema ── + +describe('HouseholdQuerySchema', () => { + it('provides defaults for empty query', () => { + const result = HouseholdQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(20); + expect(result.data.offset).toBe(0); + } + }); + + it('coerces string numbers', () => { + const result = HouseholdQuerySchema.safeParse({ limit: '10', offset: '5' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(10); + expect(result.data.offset).toBe(5); + } + }); + + it('rejects limit > 50', () => { + const result = HouseholdQuerySchema.safeParse({ limit: 100 }); + expect(result.success).toBe(false); + }); + + it('rejects negative offset', () => { + const result = HouseholdQuerySchema.safeParse({ offset: -1 }); + expect(result.success).toBe(false); + }); +}); diff --git a/backend/src/modules/households/repository.ts b/backend/src/modules/households/repository.ts new file mode 100644 index 0000000..377d158 --- /dev/null +++ b/backend/src/modules/households/repository.ts @@ -0,0 +1,95 @@ +/** + * Households repository — Cosmos DB CRUD for household membership. + * + * Container: households (partition key: /id) + * + * Unlike timers/routines (partitioned by /userId), households are + * partitioned by their own /id since multiple users share the same doc. + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { HouseholdDoc, HouseholdQuery } from './types.js'; + +function container() { + return getContainer('households'); +} + +export async function getHousehold(id: string): Promise { + try { + const { resource } = await container().item(id, id).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function createHousehold(doc: HouseholdDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as HouseholdDoc; +} + +export async function replaceHousehold(doc: HouseholdDoc): Promise { + const { resource } = await container().item(doc.id, doc.id).replace(doc); + return resource as HouseholdDoc; +} + +export async function deleteHousehold(id: string): Promise { + try { + const existing = await getHousehold(id); + if (!existing) return false; + await container().item(id, id).delete(); + return true; + } catch { + return false; + } +} + +export async function listHouseholdsForUser( + userId: string, + productId: string, + query: HouseholdQuery +): Promise<{ items: HouseholdDoc[]; total: number }> { + const countResult = await container() + .items.query({ + query: + 'SELECT VALUE COUNT(1) FROM c WHERE c.productId = @productId AND ARRAY_CONTAINS(c.members, { "userId": @userId }, true)', + parameters: [ + { name: '@productId', value: productId }, + { name: '@userId', value: userId }, + ], + }) + .fetchAll(); + const total = countResult.resources[0] ?? 0; + + const { resources } = await container() + .items.query({ + query: + 'SELECT * FROM c WHERE c.productId = @productId AND ARRAY_CONTAINS(c.members, { "userId": @userId }, true) ORDER BY c.createdAt DESC OFFSET @offset LIMIT @limit', + parameters: [ + { name: '@productId', value: productId }, + { name: '@userId', value: userId }, + { name: '@offset', value: query.offset }, + { name: '@limit', value: query.limit }, + ], + }) + .fetchAll(); + + return { items: resources, total }; +} + +export async function findHouseholdByInviteCode( + code: string, + productId: string +): Promise { + const { resources } = await container() + .items.query({ + query: + 'SELECT * FROM c WHERE c.productId = @productId AND ARRAY_CONTAINS(c.invites, { "code": @code, "status": "pending" }, true)', + parameters: [ + { name: '@productId', value: productId }, + { name: '@code', value: code }, + ], + }) + .fetchAll(); + return resources[0] ?? null; +} diff --git a/backend/src/modules/households/routes.ts b/backend/src/modules/households/routes.ts new file mode 100644 index 0000000..5eb4931 --- /dev/null +++ b/backend/src/modules/households/routes.ts @@ -0,0 +1,264 @@ +/** + * Household REST endpoints — ChronoMind Family tier. + * + * GET /households — list user's households + * GET /households/:id — single household + * POST /households — create household + * PUT /households/:id — update household name (admin only) + * DELETE /households/:id — delete household (admin only) + * POST /households/:id/invite — generate invite code (admin only) + * POST /households/join — accept invite code + * DELETE /households/:id/members — remove member (admin only) + * DELETE /households/:id/leave — leave household (non-admin) + */ + +import crypto from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import { BadRequestError, NotFoundError, ForbiddenError, ConflictError } from '../../lib/errors.js'; +import { extractAuth } from '../../lib/auth.js'; +import * as repo from './repository.js'; +import { + CreateHouseholdSchema, + UpdateHouseholdSchema, + CreateInviteSchema, + AcceptInviteSchema, + RemoveMemberSchema, + HouseholdQuerySchema, + MAX_HOUSEHOLD_MEMBERS, + type HouseholdDoc, + type HouseholdMember, + type HouseholdInvite, +} from './types.js'; + +const PRODUCT_ID = 'chronomind'; + +function isAdmin(household: HouseholdDoc, userId: string): boolean { + return household.members.some(m => m.userId === userId && m.role === 'admin'); +} + +function isMember(household: HouseholdDoc, userId: string): boolean { + return household.members.some(m => m.userId === userId); +} + +function generateInviteCode(): string { + return crypto.randomBytes(6).toString('hex').toUpperCase(); +} + +export async function householdRoutes(app: FastifyInstance) { + // List households for current user + app.get('/households', async req => { + const auth = await extractAuth(req); + const parsed = HouseholdQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const { items, total } = await repo.listHouseholdsForUser(auth.sub, PRODUCT_ID, parsed.data); + return { items, total, limit: parsed.data.limit, offset: parsed.data.offset }; + }); + + // Get single household + app.get('/households/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const household = await repo.getHousehold(id); + if (!household || household.productId !== PRODUCT_ID) + throw new NotFoundError('Household not found'); + if (!isMember(household, auth.sub)) throw new ForbiddenError('Not a member of this household'); + return household; + }); + + // Create household + app.post('/households', async (req, reply) => { + const auth = await extractAuth(req); + const parsed = CreateHouseholdSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const now = new Date().toISOString(); + const member: HouseholdMember = { + userId: auth.sub, + displayName: parsed.data.displayName, + role: 'admin', + joinedAt: now, + }; + + const doc: HouseholdDoc = { + id: crypto.randomUUID(), + productId: PRODUCT_ID, + name: parsed.data.name, + members: [member], + invites: [], + createdAt: now, + createdBy: auth.sub, + }; + + req.log.info({ householdId: doc.id }, 'Creating household'); + const created = await repo.createHousehold(doc); + reply.code(201); + return created; + }); + + // Update household name (admin only) + app.put('/households/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const parsed = UpdateHouseholdSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const household = await repo.getHousehold(id); + if (!household || household.productId !== PRODUCT_ID) + throw new NotFoundError('Household not found'); + if (!isAdmin(household, auth.sub)) throw new ForbiddenError('Only admin can update household'); + + const updated: HouseholdDoc = { ...household, ...parsed.data }; + const result = await repo.replaceHousehold(updated); + return result; + }); + + // Delete household (admin only) + app.delete('/households/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const household = await repo.getHousehold(id); + if (!household || household.productId !== PRODUCT_ID) + throw new NotFoundError('Household not found'); + if (!isAdmin(household, auth.sub)) throw new ForbiddenError('Only admin can delete household'); + + await repo.deleteHousehold(id); + req.log.info({ householdId: id }, 'Deleted household'); + return { success: true }; + }); + + // Generate invite code (admin only) + app.post('/households/:id/invite', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const parsed = CreateInviteSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const household = await repo.getHousehold(id); + if (!household || household.productId !== PRODUCT_ID) + throw new NotFoundError('Household not found'); + if (!isAdmin(household, auth.sub)) throw new ForbiddenError('Only admin can create invites'); + + if (household.members.length >= MAX_HOUSEHOLD_MEMBERS) { + throw new BadRequestError( + `Household is at maximum capacity (${MAX_HOUSEHOLD_MEMBERS} members)` + ); + } + + const now = new Date(); + const invite: HouseholdInvite = { + code: generateInviteCode(), + createdBy: auth.sub, + createdAt: now.toISOString(), + expiresAt: new Date(now.getTime() + parsed.data.expiresInHours * 3600_000).toISOString(), + status: 'pending', + }; + + household.invites.push(invite); + await repo.replaceHousehold(household); + req.log.info({ householdId: id, inviteCode: invite.code }, 'Created invite'); + return { code: invite.code, expiresAt: invite.expiresAt }; + }); + + // Accept invite code (join household) + app.post('/households/join', async req => { + const auth = await extractAuth(req); + const parsed = AcceptInviteSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const household = await repo.findHouseholdByInviteCode(parsed.data.code, PRODUCT_ID); + if (!household) throw new NotFoundError('Invalid or expired invite code'); + + if (isMember(household, auth.sub)) { + throw new ConflictError('Already a member of this household'); + } + + if (household.members.length >= MAX_HOUSEHOLD_MEMBERS) { + throw new BadRequestError( + `Household is at maximum capacity (${MAX_HOUSEHOLD_MEMBERS} members)` + ); + } + + const now = new Date().toISOString(); + const invite = household.invites.find( + i => i.code === parsed.data.code && i.status === 'pending' + ); + if (!invite || new Date(invite.expiresAt) < new Date()) { + throw new NotFoundError('Invite code has expired'); + } + + // Mark invite as accepted + invite.status = 'accepted'; + invite.acceptedBy = auth.sub; + invite.acceptedAt = now; + + // Add member + const member: HouseholdMember = { + userId: auth.sub, + displayName: parsed.data.displayName, + role: 'member', + joinedAt: now, + }; + household.members.push(member); + + const updated = await repo.replaceHousehold(household); + req.log.info({ householdId: household.id, userId: auth.sub }, 'Member joined household'); + return updated; + }); + + // Remove member (admin only) + app.delete('/households/:id/members', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const parsed = RemoveMemberSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const household = await repo.getHousehold(id); + if (!household || household.productId !== PRODUCT_ID) + throw new NotFoundError('Household not found'); + if (!isAdmin(household, auth.sub)) throw new ForbiddenError('Only admin can remove members'); + + if (parsed.data.userId === auth.sub) { + throw new BadRequestError('Admin cannot remove themselves. Delete the household instead.'); + } + + const memberIdx = household.members.findIndex(m => m.userId === parsed.data.userId); + if (memberIdx === -1) throw new NotFoundError('Member not found in household'); + + household.members.splice(memberIdx, 1); + const updated = await repo.replaceHousehold(household); + req.log.info({ householdId: id, removedUserId: parsed.data.userId }, 'Removed member'); + return updated; + }); + + // Leave household (non-admin) + app.delete('/households/:id/leave', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + + const household = await repo.getHousehold(id); + if (!household || household.productId !== PRODUCT_ID) + throw new NotFoundError('Household not found'); + if (!isMember(household, auth.sub)) throw new NotFoundError('Not a member of this household'); + + if (isAdmin(household, auth.sub)) { + throw new BadRequestError('Admin cannot leave. Transfer admin or delete the household.'); + } + + household.members = household.members.filter(m => m.userId !== auth.sub); + const updated = await repo.replaceHousehold(household); + req.log.info({ householdId: id, userId: auth.sub }, 'Member left household'); + return { success: true, householdId: updated.id }; + }); +} diff --git a/backend/src/modules/households/types.ts b/backend/src/modules/households/types.ts new file mode 100644 index 0000000..583dbfe --- /dev/null +++ b/backend/src/modules/households/types.ts @@ -0,0 +1,93 @@ +/** + * Household types — ChronoMind Family tier. + * + * Cosmos container: `households` (partition key: `/id`) + * Product ID: "chronomind" + * + * A household is a group of up to 6 members who can share timers. + * One admin (creator) manages members. Members join via invite code. + */ + +import { z } from 'zod'; + +// ── Enums / constants ── + +export const MEMBER_ROLES = ['admin', 'member'] as const; +export type MemberRole = (typeof MEMBER_ROLES)[number]; + +export const INVITE_STATUSES = ['pending', 'accepted', 'expired', 'revoked'] as const; +export type InviteStatus = (typeof INVITE_STATUSES)[number]; + +export const MAX_HOUSEHOLD_MEMBERS = 6; + +// ── Sub-document interfaces ── + +export interface HouseholdMember { + userId: string; + displayName: string; + role: MemberRole; + joinedAt: string; +} + +export interface HouseholdInvite { + code: string; + createdBy: string; + createdAt: string; + expiresAt: string; + status: InviteStatus; + acceptedBy?: string; + acceptedAt?: string; +} + +// ── Main document ── + +export interface HouseholdDoc { + id: string; + productId: string; + name: string; + members: HouseholdMember[]; + invites: HouseholdInvite[]; + createdAt: string; + createdBy: string; + + _ts?: number; + _etag?: string; +} + +// ── Zod schemas ── + +export const CreateHouseholdSchema = z.object({ + name: z.string().min(1).max(200), + displayName: z.string().min(1).max(200), +}); + +export const UpdateHouseholdSchema = z.object({ + name: z.string().min(1).max(200).optional(), +}); + +export const CreateInviteSchema = z.object({ + expiresInHours: z.number().int().min(1).max(168).default(72), +}); + +export const AcceptInviteSchema = z.object({ + code: z.string().min(1).max(32), + displayName: z.string().min(1).max(200), +}); + +export const RemoveMemberSchema = z.object({ + userId: z.string().min(1), +}); + +export const HouseholdQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(50).default(20), + offset: z.coerce.number().int().min(0).default(0), +}); + +// ── Inferred types ── + +export type CreateHouseholdInput = z.infer; +export type UpdateHouseholdInput = z.infer; +export type CreateInviteInput = z.infer; +export type AcceptInviteInput = z.infer; +export type RemoveMemberInput = z.infer; +export type HouseholdQuery = z.infer; diff --git a/backend/src/modules/routines/repository.ts b/backend/src/modules/routines/repository.ts new file mode 100644 index 0000000..6892b69 --- /dev/null +++ b/backend/src/modules/routines/repository.ts @@ -0,0 +1,182 @@ +/** + * Routines repository — Cosmos DB CRUD + sync + batch upsert. + * + * Container: routines (partition key: /userId) + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { RoutineDoc, RoutineQuery, BatchUpsertRoutinesResult } from './types.js'; + +function container() { + return getContainer('routines'); +} + +export async function listRoutines( + userId: string, + productId: string, + query: RoutineQuery +): Promise<{ items: RoutineDoc[]; total: number }> { + const conditions: string[] = ['c.userId = @userId', 'c.productId = @productId']; + const params: { name: string; value: string | number | boolean }[] = [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + ]; + + if (query.status) { + conditions.push('c.status = @status'); + params.push({ name: '@status', value: query.status }); + } + if (query.isTemplate !== undefined) { + conditions.push('c.isTemplate = @isTemplate'); + params.push({ name: '@isTemplate', value: query.isTemplate }); + } + if (query.category) { + conditions.push('c.category = @category'); + params.push({ name: '@category', value: query.category }); + } + + const where = `WHERE ${conditions.join(' AND ')}`; + const sortField = `c.${query.sortBy}`; + const orderDir = query.sortOrder.toUpperCase(); + + const countResult = await container() + .items.query({ + query: `SELECT VALUE COUNT(1) FROM c ${where}`, + parameters: params, + }) + .fetchAll(); + const total = countResult.resources[0] ?? 0; + + const { resources } = await container() + .items.query({ + query: `SELECT * FROM c ${where} ORDER BY ${sortField} ${orderDir} OFFSET @offset LIMIT @limit`, + parameters: [ + ...params, + { name: '@offset', value: query.offset }, + { name: '@limit', value: query.limit }, + ], + }) + .fetchAll(); + + return { items: resources, total }; +} + +export async function getRoutine(id: string, userId: string): Promise { + try { + const { resource } = await container().item(id, userId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function createRoutine(doc: RoutineDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as RoutineDoc; +} + +export async function updateRoutine( + id: string, + userId: string, + updates: Partial, + expectedSyncVersion: number +): Promise<{ doc: RoutineDoc | null; conflict: boolean; serverVersion?: number }> { + try { + const { resource: existing } = await container().item(id, userId).read(); + if (!existing) return { doc: null, conflict: false }; + + if (expectedSyncVersion <= existing.syncVersion) { + return { doc: null, conflict: true, serverVersion: existing.syncVersion }; + } + + const now = new Date().toISOString(); + const merged: RoutineDoc = { + ...existing, + ...updates, + syncVersion: expectedSyncVersion, + lastSyncedAt: now, + }; + const { resource } = await container().item(id, userId).replace(merged); + return { doc: resource as RoutineDoc, conflict: false }; + } catch { + return { doc: null, conflict: false }; + } +} + +export async function deleteRoutine(id: string, userId: string): Promise { + try { + const { resource: existing } = await container().item(id, userId).read(); + if (!existing) return false; + await container().item(id, userId).delete(); + return true; + } catch { + return false; + } +} + +export async function getRoutinesSince( + userId: string, + productId: string, + sinceTimestamp: string, + limit: number +): Promise { + const { resources } = await container() + .items.query({ + query: + 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId AND c.lastSyncedAt >= @since ORDER BY c.lastSyncedAt ASC OFFSET 0 LIMIT @limit', + parameters: [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + { name: '@since', value: sinceTimestamp }, + { name: '@limit', value: limit }, + ], + }) + .fetchAll(); + return resources; +} + +export async function batchUpsertRoutines( + userId: string, + productId: string, + routines: Array & { id: string; syncVersion: number }> +): Promise { + const synced: string[] = []; + const conflicts: Array<{ id: string; serverVersion: number }> = []; + const errors: Array<{ id: string; error: string }> = []; + + for (const routine of routines) { + try { + const existing = await getRoutine(routine.id, userId); + const now = new Date().toISOString(); + + if (existing) { + if (routine.syncVersion >= existing.syncVersion) { + const merged: RoutineDoc = { + ...existing, + ...routine, + userId, + productId, + lastSyncedAt: now, + } as RoutineDoc; + await container().item(routine.id, userId).replace(merged); + synced.push(routine.id); + } else { + conflicts.push({ id: routine.id, serverVersion: existing.syncVersion }); + } + } else { + const doc = { + ...routine, + userId, + productId, + lastSyncedAt: now, + }; + await container().items.create(doc); + synced.push(routine.id); + } + } catch (err) { + errors.push({ id: routine.id, error: err instanceof Error ? err.message : 'Unknown error' }); + } + } + + return { synced, conflicts, errors }; +} diff --git a/backend/src/modules/routines/routes.ts b/backend/src/modules/routines/routes.ts new file mode 100644 index 0000000..e5f5c6d --- /dev/null +++ b/backend/src/modules/routines/routes.ts @@ -0,0 +1,155 @@ +/** + * Routine REST endpoints — ChronoMind cloud sync. + * + * GET /routines — list user's routines (filterable, paginated) + * GET /routines/sync — delta sync (routines modified since timestamp) + * GET /routines/:id — single routine + * POST /routines — create routine + * PUT /routines/:id — update routine (with syncVersion conflict check) + * DELETE /routines/:id — delete routine + * POST /routines/batch — batch upsert + */ + +import type { FastifyInstance } from 'fastify'; +import { BadRequestError, NotFoundError, ConflictError } from '../../lib/errors.js'; +import { extractAuth } from '../../lib/auth.js'; +import * as repo from './repository.js'; +import { + CreateRoutineSchema, + UpdateRoutineSchema, + RoutineQuerySchema, + RoutineSyncQuerySchema, + BatchUpsertRoutinesSchema, + type RoutineDoc, +} from './types.js'; + +const PRODUCT_ID = 'chronomind'; + +export async function routineRoutes(app: FastifyInstance) { + // Sync — must be before :id param route + app.get('/routines/sync', async req => { + const auth = await extractAuth(req); + const parsed = RoutineSyncQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const routines = await repo.getRoutinesSince( + auth.sub, + PRODUCT_ID, + parsed.data.since, + parsed.data.limit + ); + return { routines, count: routines.length }; + }); + + // List routines + app.get('/routines', async req => { + const auth = await extractAuth(req); + const parsed = RoutineQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const { items, total } = await repo.listRoutines(auth.sub, PRODUCT_ID, parsed.data); + return { items, total, limit: parsed.data.limit, offset: parsed.data.offset }; + }); + + // Get single routine + app.get('/routines/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const routine = await repo.getRoutine(id, auth.sub); + if (!routine) throw new NotFoundError('Routine not found'); + if (routine.productId !== PRODUCT_ID) throw new NotFoundError('Routine not found'); + return routine; + }); + + // Create routine + app.post('/routines', async (req, reply) => { + const auth = await extractAuth(req); + const parsed = CreateRoutineSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const input = parsed.data; + const now = new Date().toISOString(); + + const doc: RoutineDoc = { + id: input.id, + userId: auth.sub, + productId: PRODUCT_ID, + name: input.name, + description: input.description, + steps: input.steps, + totalDurationMinutes: input.totalDurationMinutes, + status: input.status, + currentStepIndex: input.currentStepIndex, + isTemplate: input.isTemplate, + category: input.category, + createdAt: now, + startedAt: input.startedAt, + elapsedBeforePause: input.elapsedBeforePause, + deviceId: input.deviceId, + lastSyncedAt: now, + syncVersion: input.syncVersion, + }; + + req.log.info({ routineId: doc.id, isTemplate: doc.isTemplate }, 'Creating routine'); + const created = await repo.createRoutine(doc); + reply.code(201); + return created; + }); + + // Update routine (with syncVersion conflict check) + app.put('/routines/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + + const parsed = UpdateRoutineSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const { syncVersion, ...updates } = parsed.data; + const result = await repo.updateRoutine(id, auth.sub, updates, syncVersion); + + if (result.conflict) { + throw new ConflictError( + `Sync conflict: server version is ${result.serverVersion}, received ${syncVersion}` + ); + } + if (!result.doc) throw new NotFoundError('Routine not found'); + + req.log.info({ routineId: id, syncVersion }, 'Updated routine'); + return result.doc; + }); + + // Delete routine + app.delete('/routines/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const success = await repo.deleteRoutine(id, auth.sub); + if (!success) throw new NotFoundError('Routine not found'); + req.log.info({ routineId: id }, 'Deleted routine'); + return { success: true }; + }); + + // Batch upsert + app.post('/routines/batch', async req => { + const auth = await extractAuth(req); + const parsed = BatchUpsertRoutinesSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const now = new Date().toISOString(); + const enriched = parsed.data.routines.map(r => ({ + ...r, + createdAt: now, + lastSyncedAt: now, + })); + + req.log.info({ count: enriched.length }, 'Batch upsert routines'); + const result = await repo.batchUpsertRoutines(auth.sub, PRODUCT_ID, enriched); + return result; + }); +} diff --git a/backend/src/modules/routines/routines.test.ts b/backend/src/modules/routines/routines.test.ts new file mode 100644 index 0000000..d52976f --- /dev/null +++ b/backend/src/modules/routines/routines.test.ts @@ -0,0 +1,345 @@ +/** + * Routines module unit tests — validates schemas, constants, and types. + */ + +import { describe, it, expect } from 'vitest'; +import { + CreateRoutineSchema, + UpdateRoutineSchema, + RoutineQuerySchema, + RoutineSyncQuerySchema, + BatchUpsertRoutinesSchema, + TRANSITION_TYPES, + ROUTINE_STATUSES, + STEP_STATUSES, +} from './types.js'; + +// ── Constants ── + +describe('routine constants', () => { + it('has 4 transition types', () => { + expect(TRANSITION_TYPES).toEqual(['immediate', '1m_break', '5m_break', 'custom']); + }); + + it('has 6 routine statuses', () => { + expect(ROUTINE_STATUSES).toEqual([ + 'template', + 'ready', + 'active', + 'paused', + 'completed', + 'cancelled', + ]); + expect(ROUTINE_STATUSES).toHaveLength(6); + }); + + it('has 4 step statuses', () => { + expect(STEP_STATUSES).toEqual(['pending', 'active', 'skipped', 'completed']); + }); +}); + +// ── CreateRoutineSchema ── + +describe('CreateRoutineSchema', () => { + const validStep = { + id: 'step_1', + label: 'Warm up', + durationMinutes: 5, + transition: 'immediate', + }; + + const validMinimal = { + id: 'routine_001', + name: 'Morning Routine', + steps: [validStep], + totalDurationMinutes: 5, + }; + + it('accepts minimal valid input with defaults', () => { + const result = CreateRoutineSchema.safeParse(validMinimal); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe('ready'); + expect(result.data.isTemplate).toBe(false); + expect(result.data.currentStepIndex).toBe(0); + expect(result.data.syncVersion).toBe(1); + expect(result.data.elapsedBeforePause).toBe(0); + } + }); + + it('accepts template routine', () => { + const result = CreateRoutineSchema.safeParse({ + ...validMinimal, + status: 'template', + isTemplate: true, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.isTemplate).toBe(true); + expect(result.data.status).toBe('template'); + } + }); + + it('accepts multi-step routine with transitions', () => { + const result = CreateRoutineSchema.safeParse({ + ...validMinimal, + steps: [ + validStep, + { + id: 'step_2', + label: 'Exercise', + durationMinutes: 20, + transition: '5m_break', + notes: 'Stretch first', + }, + { + id: 'step_3', + label: 'Cool down', + durationMinutes: 10, + transition: 'custom', + customTransitionMinutes: 3, + }, + ], + totalDurationMinutes: 43, + category: 'health', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.steps).toHaveLength(3); + expect(result.data.steps[1].notes).toBe('Stretch first'); + expect(result.data.steps[2].customTransitionMinutes).toBe(3); + } + }); + + it('rejects missing id', () => { + const result = CreateRoutineSchema.safeParse({ + name: 'Test', + steps: [validStep], + totalDurationMinutes: 5, + }); + expect(result.success).toBe(false); + }); + + it('rejects missing name', () => { + const result = CreateRoutineSchema.safeParse({ + id: 'routine_001', + steps: [validStep], + totalDurationMinutes: 5, + }); + expect(result.success).toBe(false); + }); + + it('rejects empty steps array', () => { + const result = CreateRoutineSchema.safeParse({ + ...validMinimal, + steps: [], + }); + expect(result.success).toBe(false); + }); + + it('rejects step with invalid transition', () => { + const result = CreateRoutineSchema.safeParse({ + ...validMinimal, + steps: [{ ...validStep, transition: 'invalid' }], + }); + expect(result.success).toBe(false); + }); + + it('rejects step duration > 480 minutes', () => { + const result = CreateRoutineSchema.safeParse({ + ...validMinimal, + steps: [{ ...validStep, durationMinutes: 500 }], + }); + expect(result.success).toBe(false); + }); + + it('rejects step duration < 0.5 minutes', () => { + const result = CreateRoutineSchema.safeParse({ + ...validMinimal, + steps: [{ ...validStep, durationMinutes: 0.1 }], + }); + expect(result.success).toBe(false); + }); + + it('rejects > 50 steps', () => { + const steps = Array.from({ length: 51 }, (_, i) => ({ + ...validStep, + id: `step_${i}`, + })); + const result = CreateRoutineSchema.safeParse({ ...validMinimal, steps }); + expect(result.success).toBe(false); + }); + + it('rejects invalid status', () => { + const result = CreateRoutineSchema.safeParse({ + ...validMinimal, + status: 'deleted', + }); + expect(result.success).toBe(false); + }); +}); + +// ── UpdateRoutineSchema ── + +describe('UpdateRoutineSchema', () => { + it('accepts status update with syncVersion', () => { + const result = UpdateRoutineSchema.safeParse({ status: 'paused', syncVersion: 2 }); + expect(result.success).toBe(true); + }); + + it('accepts step updates', () => { + const result = UpdateRoutineSchema.safeParse({ + steps: [ + { + id: 's1', + label: 'Step 1', + durationMinutes: 5, + transition: 'immediate', + status: 'completed', + }, + { + id: 's2', + label: 'Step 2', + durationMinutes: 10, + transition: '1m_break', + status: 'active', + }, + ], + currentStepIndex: 1, + syncVersion: 3, + }); + expect(result.success).toBe(true); + }); + + it('requires syncVersion', () => { + const result = UpdateRoutineSchema.safeParse({ status: 'active' }); + expect(result.success).toBe(false); + }); + + it('rejects syncVersion < 1', () => { + const result = UpdateRoutineSchema.safeParse({ status: 'active', syncVersion: 0 }); + expect(result.success).toBe(false); + }); + + it('rejects invalid status', () => { + const result = UpdateRoutineSchema.safeParse({ status: 'deleted', syncVersion: 2 }); + expect(result.success).toBe(false); + }); +}); + +// ── RoutineQuerySchema ── + +describe('RoutineQuerySchema', () => { + it('provides defaults for empty query', () => { + const result = RoutineQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sortBy).toBe('createdAt'); + expect(result.data.sortOrder).toBe('desc'); + expect(result.data.limit).toBe(50); + expect(result.data.offset).toBe(0); + } + }); + + it('accepts all filter combinations', () => { + const result = RoutineQuerySchema.safeParse({ + status: 'template', + isTemplate: 'true', + category: 'health', + sortBy: 'name', + sortOrder: 'asc', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.isTemplate).toBe(true); + } + }); + + it('coerces string numbers', () => { + const result = RoutineQuerySchema.safeParse({ limit: '25', offset: '5' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(25); + expect(result.data.offset).toBe(5); + } + }); + + it('rejects limit > 100', () => { + const result = RoutineQuerySchema.safeParse({ limit: 200 }); + expect(result.success).toBe(false); + }); + + it('rejects invalid sortBy', () => { + const result = RoutineQuerySchema.safeParse({ sortBy: 'random' }); + expect(result.success).toBe(false); + }); +}); + +// ── RoutineSyncQuerySchema ── + +describe('RoutineSyncQuerySchema', () => { + it('accepts valid since timestamp', () => { + const result = RoutineSyncQuerySchema.safeParse({ since: '2026-03-01T00:00:00.000Z' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(100); + } + }); + + it('rejects missing since', () => { + const result = RoutineSyncQuerySchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('rejects invalid since format', () => { + const result = RoutineSyncQuerySchema.safeParse({ since: 'yesterday' }); + expect(result.success).toBe(false); + }); +}); + +// ── BatchUpsertRoutinesSchema ── + +describe('BatchUpsertRoutinesSchema', () => { + const validRoutine = { + id: 'routine_batch_1', + name: 'Batch Routine', + steps: [{ id: 's1', label: 'Step', durationMinutes: 5, transition: 'immediate' }], + totalDurationMinutes: 5, + }; + + it('accepts array of valid routines', () => { + const result = BatchUpsertRoutinesSchema.safeParse({ + routines: [validRoutine, { ...validRoutine, id: 'routine_batch_2', name: 'Second' }], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.routines).toHaveLength(2); + } + }); + + it('rejects empty routines array', () => { + const result = BatchUpsertRoutinesSchema.safeParse({ routines: [] }); + expect(result.success).toBe(false); + }); + + it('rejects missing routines field', () => { + const result = BatchUpsertRoutinesSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('validates each routine in the array', () => { + const result = BatchUpsertRoutinesSchema.safeParse({ + routines: [validRoutine, { id: 'bad' }], + }); + expect(result.success).toBe(false); + }); + + it('rejects > 50 routines', () => { + const routines = Array.from({ length: 51 }, (_, i) => ({ + ...validRoutine, + id: `routine_${i}`, + })); + const result = BatchUpsertRoutinesSchema.safeParse({ routines }); + expect(result.success).toBe(false); + }); +}); diff --git a/backend/src/modules/routines/types.ts b/backend/src/modules/routines/types.ts new file mode 100644 index 0000000..773aa59 --- /dev/null +++ b/backend/src/modules/routines/types.ts @@ -0,0 +1,154 @@ +/** + * Routine types — ChronoMind cross-platform cloud sync. + * + * Cosmos container: `routines` (partition key: `/userId`) + * Product ID: "chronomind" + */ + +import { z } from 'zod'; + +// ── Enums / constants ── + +export const TRANSITION_TYPES = ['immediate', '1m_break', '5m_break', 'custom'] as const; +export type TransitionType = (typeof TRANSITION_TYPES)[number]; + +export const ROUTINE_STATUSES = [ + 'template', + 'ready', + 'active', + 'paused', + 'completed', + 'cancelled', +] as const; +export type RoutineStatus = (typeof ROUTINE_STATUSES)[number]; + +export const STEP_STATUSES = ['pending', 'active', 'skipped', 'completed'] as const; +export type StepStatus = (typeof STEP_STATUSES)[number]; + +// ── Sub-document interfaces ── + +export interface RoutineStep { + id: string; + label: string; + durationMinutes: number; + transition: TransitionType; + customTransitionMinutes?: number; + notes?: string; + status: StepStatus; + startedAt?: string; + completedAt?: string; +} + +// ── Main document ── + +export interface RoutineDoc { + id: string; + userId: string; + productId: string; + + name: string; + description?: string; + steps: RoutineStep[]; + totalDurationMinutes: number; + status: RoutineStatus; + currentStepIndex: number; + isTemplate: boolean; + category?: string; + + createdAt: string; + startedAt?: string; + pausedAt?: string; + completedAt?: string; + elapsedBeforePause: number; + + // Sync metadata + deviceId?: string; + lastSyncedAt?: string; + syncVersion: number; + + _ts?: number; + _etag?: string; +} + +// ── Zod schemas ── + +const RoutineStepSchema = z.object({ + id: z.string().min(1).max(128), + label: z.string().min(1).max(500), + durationMinutes: z.number().min(0.5).max(480), + transition: z.enum(TRANSITION_TYPES), + customTransitionMinutes: z.number().min(0).max(60).optional(), + notes: z.string().max(2000).optional(), + status: z.enum(STEP_STATUSES).default('pending'), + startedAt: z.string().datetime().optional(), + completedAt: z.string().datetime().optional(), +}); + +export const CreateRoutineSchema = z.object({ + id: z.string().min(1).max(128), + name: z.string().min(1).max(500), + description: z.string().max(2000).optional(), + steps: z.array(RoutineStepSchema).min(1).max(50), + totalDurationMinutes: z.number().min(0), + status: z.enum(ROUTINE_STATUSES).default('ready'), + currentStepIndex: z.number().int().min(0).default(0), + isTemplate: z.boolean().default(false), + category: z.string().max(128).optional(), + elapsedBeforePause: z.number().min(0).default(0), + startedAt: z.string().datetime().optional(), + deviceId: z.string().max(256).optional(), + syncVersion: z.number().int().min(0).default(1), +}); + +export const UpdateRoutineSchema = z.object({ + name: z.string().min(1).max(500).optional(), + description: z.string().max(2000).optional(), + steps: z.array(RoutineStepSchema).min(1).max(50).optional(), + totalDurationMinutes: z.number().min(0).optional(), + status: z.enum(ROUTINE_STATUSES).optional(), + currentStepIndex: z.number().int().min(0).optional(), + isTemplate: z.boolean().optional(), + category: z.string().max(128).optional(), + startedAt: z.string().datetime().optional(), + pausedAt: z.string().datetime().optional(), + completedAt: z.string().datetime().optional(), + elapsedBeforePause: z.number().min(0).optional(), + deviceId: z.string().max(256).optional(), + syncVersion: z.number().int().min(1), +}); + +export const RoutineQuerySchema = z.object({ + status: z.enum(ROUTINE_STATUSES).optional(), + isTemplate: z + .string() + .transform(v => v === 'true') + .optional(), + category: z.string().optional(), + sortBy: z.enum(['createdAt', 'name', 'totalDurationMinutes']).default('createdAt'), + sortOrder: z.enum(['asc', 'desc']).default('desc'), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +export const RoutineSyncQuerySchema = z.object({ + since: z.string().datetime(), + limit: z.coerce.number().int().min(1).max(500).default(100), +}); + +export const BatchUpsertRoutinesSchema = z.object({ + routines: z.array(CreateRoutineSchema).min(1).max(50), +}); + +// ── Inferred types ── + +export type CreateRoutineInput = z.infer; +export type UpdateRoutineInput = z.infer; +export type RoutineQuery = z.infer; +export type RoutineSyncQuery = z.infer; +export type BatchUpsertRoutinesInput = z.infer; + +export interface BatchUpsertRoutinesResult { + synced: string[]; + conflicts: Array<{ id: string; serverVersion: number }>; + errors: Array<{ id: string; error: string }>; +} diff --git a/backend/src/modules/shared-timers/repository.ts b/backend/src/modules/shared-timers/repository.ts new file mode 100644 index 0000000..7593029 --- /dev/null +++ b/backend/src/modules/shared-timers/repository.ts @@ -0,0 +1,91 @@ +/** + * Shared timers repository — Cosmos DB CRUD for household shared timers. + * + * Container: shared_timers (partition key: /householdId) + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { SharedTimerDoc, SharedTimerQuery } from './types.js'; + +function container() { + return getContainer('shared_timers'); +} + +export async function listSharedTimers( + householdId: string, + productId: string, + query: SharedTimerQuery +): Promise<{ items: SharedTimerDoc[]; total: number }> { + const conditions: string[] = ['c.householdId = @householdId', 'c.productId = @productId']; + const params: { name: string; value: string | number }[] = [ + { name: '@householdId', value: householdId }, + { name: '@productId', value: productId }, + ]; + + if (query.state) { + conditions.push('c.state = @state'); + params.push({ name: '@state', value: query.state }); + } + if (query.type) { + conditions.push('c.type = @type'); + params.push({ name: '@type', value: query.type }); + } + + const where = `WHERE ${conditions.join(' AND ')}`; + const sortField = `c.${query.sortBy}`; + const orderDir = query.sortOrder.toUpperCase(); + + const countResult = await container() + .items.query({ + query: `SELECT VALUE COUNT(1) FROM c ${where}`, + parameters: params, + }) + .fetchAll(); + const total = countResult.resources[0] ?? 0; + + const { resources } = await container() + .items.query({ + query: `SELECT * FROM c ${where} ORDER BY ${sortField} ${orderDir} OFFSET @offset LIMIT @limit`, + parameters: [ + ...params, + { name: '@offset', value: query.offset }, + { name: '@limit', value: query.limit }, + ], + }) + .fetchAll(); + + return { items: resources, total }; +} + +export async function getSharedTimer( + id: string, + householdId: string +): Promise { + try { + const { resource } = await container().item(id, householdId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function createSharedTimer(doc: SharedTimerDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as SharedTimerDoc; +} + +export async function replaceSharedTimer(doc: SharedTimerDoc): Promise { + const { resource } = await container().item(doc.id, doc.householdId).replace(doc); + return resource as SharedTimerDoc; +} + +export async function deleteSharedTimer(id: string, householdId: string): Promise { + try { + const existing = await getSharedTimer(id, householdId); + if (!existing) return false; + await container().item(id, householdId).delete(); + return true; + } catch { + return false; + } +} diff --git a/backend/src/modules/shared-timers/routes.ts b/backend/src/modules/shared-timers/routes.ts new file mode 100644 index 0000000..32f46e3 --- /dev/null +++ b/backend/src/modules/shared-timers/routes.ts @@ -0,0 +1,183 @@ +/** + * Shared timer REST endpoints — ChronoMind Family tier. + * + * All endpoints require the caller to be a member of the household. + * + * GET /households/:householdId/timers — list shared timers + * GET /households/:householdId/timers/:id — single shared timer + * POST /households/:householdId/timers — create shared timer + * PUT /households/:householdId/timers/:id — update shared timer (creator only) + * DELETE /households/:householdId/timers/:id — delete shared timer (creator or admin) + * POST /households/:householdId/timers/:id/ack — acknowledge (dismiss/snooze) a timer + */ + +import crypto from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import { BadRequestError, NotFoundError, ForbiddenError } from '../../lib/errors.js'; +import { extractAuth } from '../../lib/auth.js'; +import { getHousehold } from '../households/repository.js'; +import * as repo from './repository.js'; +import { + CreateSharedTimerSchema, + UpdateSharedTimerSchema, + AcknowledgeTimerSchema, + SharedTimerQuerySchema, + type SharedTimerDoc, +} from './types.js'; + +const PRODUCT_ID = 'chronomind'; + +async function requireMembership(householdId: string, userId: string) { + const household = await getHousehold(householdId); + if (!household || household.productId !== PRODUCT_ID) { + throw new NotFoundError('Household not found'); + } + const member = household.members.find(m => m.userId === userId); + if (!member) throw new ForbiddenError('Not a member of this household'); + return { household, member }; +} + +export async function sharedTimerRoutes(app: FastifyInstance) { + // List shared timers for a household + app.get('/households/:householdId/timers', async req => { + const auth = await extractAuth(req); + const { householdId } = req.params as { householdId: string }; + await requireMembership(householdId, auth.sub); + + const parsed = SharedTimerQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const { items, total } = await repo.listSharedTimers(householdId, PRODUCT_ID, parsed.data); + return { items, total, limit: parsed.data.limit, offset: parsed.data.offset }; + }); + + // Get single shared timer + app.get('/households/:householdId/timers/:id', async req => { + const auth = await extractAuth(req); + const { householdId, id } = req.params as { householdId: string; id: string }; + await requireMembership(householdId, auth.sub); + + const timer = await repo.getSharedTimer(id, householdId); + if (!timer) throw new NotFoundError('Shared timer not found'); + return timer; + }); + + // Create shared timer + app.post('/households/:householdId/timers', async (req, reply) => { + const auth = await extractAuth(req); + const { householdId } = req.params as { householdId: string }; + await requireMembership(householdId, auth.sub); + + const parsed = CreateSharedTimerSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + if (parsed.data.householdId !== householdId) { + throw new BadRequestError('householdId in body must match URL param'); + } + + const now = new Date().toISOString(); + const doc: SharedTimerDoc = { + id: crypto.randomUUID(), + householdId, + productId: PRODUCT_ID, + createdBy: auth.sub, + label: parsed.data.label, + description: parsed.data.description, + type: parsed.data.type, + state: 'active', + urgency: parsed.data.urgency, + duration: parsed.data.duration, + targetTime: parsed.data.targetTime, + category: parsed.data.category, + cascade: parsed.data.cascade, + acknowledgements: [], + createdAt: now, + updatedAt: now, + }; + + req.log.info({ sharedTimerId: doc.id, householdId }, 'Creating shared timer'); + const created = await repo.createSharedTimer(doc); + reply.code(201); + return created; + }); + + // Update shared timer (creator only) + app.put('/households/:householdId/timers/:id', async req => { + const auth = await extractAuth(req); + const { householdId, id } = req.params as { householdId: string; id: string }; + await requireMembership(householdId, auth.sub); + + const timer = await repo.getSharedTimer(id, householdId); + if (!timer) throw new NotFoundError('Shared timer not found'); + if (timer.createdBy !== auth.sub) + throw new ForbiddenError('Only the creator can update this timer'); + + const parsed = UpdateSharedTimerSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const now = new Date().toISOString(); + const updated: SharedTimerDoc = { ...timer, ...parsed.data, updatedAt: now }; + const result = await repo.replaceSharedTimer(updated); + req.log.info({ sharedTimerId: id, householdId }, 'Updated shared timer'); + return result; + }); + + // Delete shared timer (creator or admin) + app.delete('/households/:householdId/timers/:id', async req => { + const auth = await extractAuth(req); + const { householdId, id } = req.params as { householdId: string; id: string }; + const { household } = await requireMembership(householdId, auth.sub); + + const timer = await repo.getSharedTimer(id, householdId); + if (!timer) throw new NotFoundError('Shared timer not found'); + + const isCreator = timer.createdBy === auth.sub; + const isAdmin = household.members.some(m => m.userId === auth.sub && m.role === 'admin'); + if (!isCreator && !isAdmin) { + throw new ForbiddenError('Only the creator or admin can delete this timer'); + } + + await repo.deleteSharedTimer(id, householdId); + req.log.info({ sharedTimerId: id, householdId }, 'Deleted shared timer'); + return { success: true }; + }); + + // Acknowledge (dismiss/snooze) a shared timer — per-user + app.post('/households/:householdId/timers/:id/ack', async req => { + const auth = await extractAuth(req); + const { householdId, id } = req.params as { householdId: string; id: string }; + await requireMembership(householdId, auth.sub); + + const timer = await repo.getSharedTimer(id, householdId); + if (!timer) throw new NotFoundError('Shared timer not found'); + + const parsed = AcknowledgeTimerSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + // Replace or add acknowledgement for this user + const now = new Date().toISOString(); + const existingIdx = timer.acknowledgements.findIndex(a => a.userId === auth.sub); + const ack = { userId: auth.sub, state: parsed.data.state, at: now }; + + if (existingIdx >= 0) { + timer.acknowledgements[existingIdx] = ack; + } else { + timer.acknowledgements.push(ack); + } + timer.updatedAt = now; + + const result = await repo.replaceSharedTimer(timer); + req.log.info( + { sharedTimerId: id, householdId, ackState: parsed.data.state }, + 'Timer acknowledged' + ); + return result; + }); +} diff --git a/backend/src/modules/shared-timers/shared-timers.test.ts b/backend/src/modules/shared-timers/shared-timers.test.ts new file mode 100644 index 0000000..aa54014 --- /dev/null +++ b/backend/src/modules/shared-timers/shared-timers.test.ts @@ -0,0 +1,249 @@ +/** + * Shared timers module unit tests — validates schemas, constants, and types. + */ + +import { describe, it, expect } from 'vitest'; +import { + CreateSharedTimerSchema, + UpdateSharedTimerSchema, + AcknowledgeTimerSchema, + SharedTimerQuerySchema, + TIMER_TYPES, + TIMER_STATES, + URGENCY_LEVELS, + CASCADE_PRESETS, +} from './types.js'; + +// ── Constants ── + +describe('shared timer constants', () => { + it('has 3 timer types', () => { + expect(TIMER_TYPES).toEqual(['countdown', 'alarm', 'pomodoro']); + }); + + it('has 7 timer states', () => { + expect(TIMER_STATES).toHaveLength(7); + }); + + it('has 5 urgency levels', () => { + expect(URGENCY_LEVELS).toHaveLength(5); + }); + + it('has 4 cascade presets', () => { + expect(CASCADE_PRESETS).toEqual(['minimal', 'standard', 'aggressive', 'custom']); + }); +}); + +// ── CreateSharedTimerSchema ── + +describe('CreateSharedTimerSchema', () => { + const validMinimal = { + householdId: 'household_001', + label: 'Dinner ready', + type: 'countdown', + duration: 1800, + }; + + it('accepts minimal valid input', () => { + const result = CreateSharedTimerSchema.safeParse(validMinimal); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.urgency).toBe('standard'); + expect(result.data.label).toBe('Dinner ready'); + } + }); + + it('accepts full input with cascade', () => { + const result = CreateSharedTimerSchema.safeParse({ + ...validMinimal, + description: 'Let everyone know dinner is ready', + urgency: 'important', + targetTime: '2026-03-01T18:00:00.000Z', + category: 'cooking', + cascade: { preset: 'standard', intervals: [15, 5, 1] }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.cascade?.intervals).toEqual([15, 5, 1]); + } + }); + + it('accepts alarm type with targetTime', () => { + const result = CreateSharedTimerSchema.safeParse({ + ...validMinimal, + type: 'alarm', + targetTime: '2026-03-01T07:00:00.000Z', + }); + expect(result.success).toBe(true); + }); + + it('rejects missing householdId', () => { + const result = CreateSharedTimerSchema.safeParse({ + label: 'Test', + type: 'countdown', + duration: 300, + }); + expect(result.success).toBe(false); + }); + + it('rejects missing label', () => { + const result = CreateSharedTimerSchema.safeParse({ + householdId: 'h1', + type: 'countdown', + duration: 300, + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid type', () => { + const result = CreateSharedTimerSchema.safeParse({ + ...validMinimal, + type: 'stopwatch', + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid urgency', () => { + const result = CreateSharedTimerSchema.safeParse({ + ...validMinimal, + urgency: 'extreme', + }); + expect(result.success).toBe(false); + }); + + it('rejects negative duration', () => { + const result = CreateSharedTimerSchema.safeParse({ + ...validMinimal, + duration: -10, + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid targetTime', () => { + const result = CreateSharedTimerSchema.safeParse({ + ...validMinimal, + targetTime: 'not-a-date', + }); + expect(result.success).toBe(false); + }); + + it('rejects label > 500 chars', () => { + const result = CreateSharedTimerSchema.safeParse({ + ...validMinimal, + label: 'x'.repeat(501), + }); + expect(result.success).toBe(false); + }); +}); + +// ── UpdateSharedTimerSchema ── + +describe('UpdateSharedTimerSchema', () => { + it('accepts state update', () => { + const result = UpdateSharedTimerSchema.safeParse({ state: 'fired' }); + expect(result.success).toBe(true); + }); + + it('accepts label and urgency update', () => { + const result = UpdateSharedTimerSchema.safeParse({ + label: 'New label', + urgency: 'critical', + }); + expect(result.success).toBe(true); + }); + + it('accepts cascade update', () => { + const result = UpdateSharedTimerSchema.safeParse({ + cascade: { preset: 'aggressive', intervals: [30, 10, 5] }, + }); + expect(result.success).toBe(true); + }); + + it('accepts empty update', () => { + const result = UpdateSharedTimerSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('rejects invalid state', () => { + const result = UpdateSharedTimerSchema.safeParse({ state: 'deleted' }); + expect(result.success).toBe(false); + }); + + it('rejects invalid urgency', () => { + const result = UpdateSharedTimerSchema.safeParse({ urgency: 'extreme' }); + expect(result.success).toBe(false); + }); +}); + +// ── AcknowledgeTimerSchema ── + +describe('AcknowledgeTimerSchema', () => { + it('accepts dismissed', () => { + const result = AcknowledgeTimerSchema.safeParse({ state: 'dismissed' }); + expect(result.success).toBe(true); + }); + + it('accepts snoozed', () => { + const result = AcknowledgeTimerSchema.safeParse({ state: 'snoozed' }); + expect(result.success).toBe(true); + }); + + it('rejects other states', () => { + const result = AcknowledgeTimerSchema.safeParse({ state: 'active' }); + expect(result.success).toBe(false); + }); + + it('rejects missing state', () => { + const result = AcknowledgeTimerSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +// ── SharedTimerQuerySchema ── + +describe('SharedTimerQuerySchema', () => { + it('provides defaults for empty query', () => { + const result = SharedTimerQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sortBy).toBe('createdAt'); + expect(result.data.sortOrder).toBe('desc'); + expect(result.data.limit).toBe(50); + expect(result.data.offset).toBe(0); + } + }); + + it('accepts state and type filters', () => { + const result = SharedTimerQuerySchema.safeParse({ + state: 'active', + type: 'countdown', + sortBy: 'targetTime', + sortOrder: 'asc', + }); + expect(result.success).toBe(true); + }); + + it('coerces string numbers', () => { + const result = SharedTimerQuerySchema.safeParse({ limit: '25', offset: '10' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(25); + expect(result.data.offset).toBe(10); + } + }); + + it('rejects limit > 100', () => { + const result = SharedTimerQuerySchema.safeParse({ limit: 200 }); + expect(result.success).toBe(false); + }); + + it('rejects invalid sortBy', () => { + const result = SharedTimerQuerySchema.safeParse({ sortBy: 'random' }); + expect(result.success).toBe(false); + }); + + it('rejects invalid state filter', () => { + const result = SharedTimerQuerySchema.safeParse({ state: 'deleted' }); + expect(result.success).toBe(false); + }); +}); diff --git a/backend/src/modules/shared-timers/types.ts b/backend/src/modules/shared-timers/types.ts new file mode 100644 index 0000000..0e60e16 --- /dev/null +++ b/backend/src/modules/shared-timers/types.ts @@ -0,0 +1,118 @@ +/** + * Shared timer types — ChronoMind Family tier. + * + * Cosmos container: `shared_timers` (partition key: `/householdId`) + * Product ID: "chronomind" + * + * Shared timers are visible to all household members. The creator + * owns the timer; any member can snooze/dismiss their own view. + */ + +import { z } from 'zod'; + +// ── Reuse timer enums ── + +export const TIMER_TYPES = ['countdown', 'alarm', 'pomodoro'] as const; +export const TIMER_STATES = [ + 'active', + 'paused', + 'fired', + 'snoozed', + 'dismissed', + 'completed', + 'warning', +] as const; +export const URGENCY_LEVELS = ['critical', 'important', 'standard', 'gentle', 'passive'] as const; +export const CASCADE_PRESETS = ['minimal', 'standard', 'aggressive', 'custom'] as const; + +// ── Sub-document interfaces ── + +export interface SharedCascadeConfig { + preset: (typeof CASCADE_PRESETS)[number]; + intervals?: number[]; +} + +export interface SharedTimerAck { + userId: string; + state: 'dismissed' | 'snoozed'; + at: string; +} + +// ── Main document ── + +export interface SharedTimerDoc { + id: string; + householdId: string; + productId: string; + createdBy: string; + + label: string; + description?: string; + type: (typeof TIMER_TYPES)[number]; + state: (typeof TIMER_STATES)[number]; + urgency: (typeof URGENCY_LEVELS)[number]; + duration: number; + targetTime?: string; + category?: string; + + cascade?: SharedCascadeConfig; + acknowledgements: SharedTimerAck[]; + + createdAt: string; + updatedAt: string; + completedAt?: string; + + _ts?: number; + _etag?: string; +} + +// ── Zod schemas ── + +const CascadeSchema = z.object({ + preset: z.enum(CASCADE_PRESETS), + intervals: z.array(z.number().min(0).max(120)).max(20).optional(), +}); + +export const CreateSharedTimerSchema = z.object({ + householdId: z.string().min(1).max(128), + label: z.string().min(1).max(500), + description: z.string().max(2000).optional(), + type: z.enum(TIMER_TYPES), + urgency: z.enum(URGENCY_LEVELS).default('standard'), + duration: z.number().min(0), + targetTime: z.string().datetime().optional(), + category: z.string().max(128).optional(), + cascade: CascadeSchema.optional(), +}); + +export const UpdateSharedTimerSchema = z.object({ + label: z.string().min(1).max(500).optional(), + description: z.string().max(2000).optional(), + state: z.enum(TIMER_STATES).optional(), + urgency: z.enum(URGENCY_LEVELS).optional(), + duration: z.number().min(0).optional(), + targetTime: z.string().datetime().optional(), + category: z.string().max(128).optional(), + cascade: CascadeSchema.optional(), + completedAt: z.string().datetime().optional(), +}); + +export const AcknowledgeTimerSchema = z.object({ + state: z.enum(['dismissed', 'snoozed'] as const), +}); + +export const SharedTimerQuerySchema = z.object({ + state: z.enum(TIMER_STATES).optional(), + type: z.enum(TIMER_TYPES).optional(), + sortBy: z.enum(['createdAt', 'targetTime', 'updatedAt']).default('createdAt'), + sortOrder: z.enum(['asc', 'desc']).default('desc'), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +// ── Inferred types ── + +export type CreateSharedTimerInput = z.infer; +export type UpdateSharedTimerInput = z.infer; +export type AcknowledgeTimerInput = z.infer; +export type SharedTimerQuery = z.infer; diff --git a/backend/src/modules/timers/repository.ts b/backend/src/modules/timers/repository.ts new file mode 100644 index 0000000..197ffd9 --- /dev/null +++ b/backend/src/modules/timers/repository.ts @@ -0,0 +1,191 @@ +/** + * Timers repository — Cosmos DB CRUD + sync + batch upsert. + * + * Container: timers (partition key: /userId) + */ + +import { getContainer } from '../../lib/cosmos.js'; +import type { TimerDoc, TimerQuery, BatchUpsertResult } from './types.js'; + +function container() { + return getContainer('timers'); +} + +export async function listTimers( + userId: string, + productId: string, + query: TimerQuery +): Promise<{ items: TimerDoc[]; total: number }> { + const conditions: string[] = ['c.userId = @userId', 'c.productId = @productId']; + const params: { name: string; value: string | number }[] = [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + ]; + + if (query.state) { + conditions.push('c.state = @state'); + params.push({ name: '@state', value: query.state }); + } + if (query.type) { + conditions.push('c.type = @type'); + params.push({ name: '@type', value: query.type }); + } + if (query.urgency) { + conditions.push('c.urgency = @urgency'); + params.push({ name: '@urgency', value: query.urgency }); + } + if (query.category) { + conditions.push('c.category = @category'); + params.push({ name: '@category', value: query.category }); + } + + const where = `WHERE ${conditions.join(' AND ')}`; + const sortField = `c.${query.sortBy}`; + const orderDir = query.sortOrder.toUpperCase(); + + // Count query + const countResult = await container() + .items.query({ + query: `SELECT VALUE COUNT(1) FROM c ${where}`, + parameters: params, + }) + .fetchAll(); + const total = countResult.resources[0] ?? 0; + + // Data query with pagination + const { resources } = await container() + .items.query({ + query: `SELECT * FROM c ${where} ORDER BY ${sortField} ${orderDir} OFFSET @offset LIMIT @limit`, + parameters: [ + ...params, + { name: '@offset', value: query.offset }, + { name: '@limit', value: query.limit }, + ], + }) + .fetchAll(); + + return { items: resources, total }; +} + +export async function getTimer(id: string, userId: string): Promise { + try { + const { resource } = await container().item(id, userId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function createTimer(doc: TimerDoc): Promise { + const { resource } = await container().items.create(doc); + return resource as TimerDoc; +} + +export async function updateTimer( + id: string, + userId: string, + updates: Partial, + expectedSyncVersion: number +): Promise<{ doc: TimerDoc | null; conflict: boolean; serverVersion?: number }> { + try { + const { resource: existing } = await container().item(id, userId).read(); + if (!existing) return { doc: null, conflict: false }; + + // Optimistic concurrency: reject stale writes + if (expectedSyncVersion <= existing.syncVersion) { + return { doc: null, conflict: true, serverVersion: existing.syncVersion }; + } + + const now = new Date().toISOString(); + const merged: TimerDoc = { + ...existing, + ...updates, + syncVersion: expectedSyncVersion, + lastSyncedAt: now, + }; + const { resource } = await container().item(id, userId).replace(merged); + return { doc: resource as TimerDoc, conflict: false }; + } catch { + return { doc: null, conflict: false }; + } +} + +export async function deleteTimer(id: string, userId: string): Promise { + try { + const { resource: existing } = await container().item(id, userId).read(); + if (!existing) return false; + await container().item(id, userId).delete(); + return true; + } catch { + return false; + } +} + +export async function getTimersSince( + userId: string, + productId: string, + sinceTimestamp: string, + limit: number +): Promise { + const { resources } = await container() + .items.query({ + query: + 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId AND c.lastSyncedAt >= @since ORDER BY c.lastSyncedAt ASC OFFSET 0 LIMIT @limit', + parameters: [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + { name: '@since', value: sinceTimestamp }, + { name: '@limit', value: limit }, + ], + }) + .fetchAll(); + return resources; +} + +export async function batchUpsert( + userId: string, + productId: string, + timers: Array & { id: string; syncVersion: number }> +): Promise { + const synced: string[] = []; + const conflicts: Array<{ id: string; serverVersion: number }> = []; + const errors: Array<{ id: string; error: string }> = []; + + for (const timer of timers) { + try { + const existing = await getTimer(timer.id, userId); + const now = new Date().toISOString(); + + if (existing) { + // Upsert: accept if incoming syncVersion >= existing + if (timer.syncVersion >= existing.syncVersion) { + const merged: TimerDoc = { + ...existing, + ...timer, + userId, + productId, + lastSyncedAt: now, + }; + await container().item(timer.id, userId).replace(merged); + synced.push(timer.id); + } else { + conflicts.push({ id: timer.id, serverVersion: existing.syncVersion }); + } + } else { + // New document + const doc: TimerDoc = { + ...timer, + userId, + productId, + lastSyncedAt: now, + } as TimerDoc; + await container().items.create(doc); + synced.push(timer.id); + } + } catch (err) { + errors.push({ id: timer.id, error: err instanceof Error ? err.message : 'Unknown error' }); + } + } + + return { synced, conflicts, errors }; +} diff --git a/backend/src/modules/timers/routes.ts b/backend/src/modules/timers/routes.ts new file mode 100644 index 0000000..37fbb48 --- /dev/null +++ b/backend/src/modules/timers/routes.ts @@ -0,0 +1,158 @@ +/** + * Timer REST endpoints — ChronoMind cloud sync. + * + * GET /timers — list user's timers (filterable, paginated) + * GET /timers/sync — delta sync (timers modified since timestamp) + * GET /timers/:id — single timer + * POST /timers — create timer + * PUT /timers/:id — update timer (with syncVersion conflict check) + * DELETE /timers/:id — delete timer + * POST /timers/batch — batch upsert (offline queue flush / initial sync) + */ + +import type { FastifyInstance } from 'fastify'; +import { BadRequestError, NotFoundError, ConflictError } from '../../lib/errors.js'; +import { extractAuth } from '../../lib/auth.js'; +import * as repo from './repository.js'; +import { + CreateTimerSchema, + UpdateTimerSchema, + TimerQuerySchema, + TimerSyncQuerySchema, + BatchUpsertSchema, + type TimerDoc, +} from './types.js'; + +const PRODUCT_ID = 'chronomind'; + +export async function timerRoutes(app: FastifyInstance) { + // Sync — must be before :id param route + app.get('/timers/sync', async req => { + const auth = await extractAuth(req); + const parsed = TimerSyncQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const timers = await repo.getTimersSince( + auth.sub, + PRODUCT_ID, + parsed.data.since, + parsed.data.limit + ); + return { timers, count: timers.length }; + }); + + // List timers + app.get('/timers', async req => { + const auth = await extractAuth(req); + const parsed = TimerQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const { items, total } = await repo.listTimers(auth.sub, PRODUCT_ID, parsed.data); + return { items, total, limit: parsed.data.limit, offset: parsed.data.offset }; + }); + + // Get single timer + app.get('/timers/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const timer = await repo.getTimer(id, auth.sub); + if (!timer) throw new NotFoundError('Timer not found'); + if (timer.productId !== PRODUCT_ID) throw new NotFoundError('Timer not found'); + return timer; + }); + + // Create timer + app.post('/timers', async (req, reply) => { + const auth = await extractAuth(req); + const parsed = CreateTimerSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const input = parsed.data; + const now = new Date().toISOString(); + + const doc: TimerDoc = { + id: input.id, + userId: auth.sub, + productId: PRODUCT_ID, + label: input.label, + description: input.description, + type: input.type, + state: input.state, + urgency: input.urgency, + duration: input.duration, + targetTime: input.targetTime, + createdAt: now, + startedAt: input.startedAt, + cascade: input.cascade, + pomodoro: input.pomodoro, + isCalendarSync: input.isCalendarSync, + calendarEventId: input.calendarEventId, + category: input.category, + deviceId: input.deviceId, + lastSyncedAt: now, + syncVersion: input.syncVersion, + }; + + req.log.info({ timerId: doc.id, type: doc.type }, 'Creating timer'); + const created = await repo.createTimer(doc); + reply.code(201); + return created; + }); + + // Update timer (with syncVersion conflict check) + app.put('/timers/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + + const parsed = UpdateTimerSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const { syncVersion, ...updates } = parsed.data; + const result = await repo.updateTimer(id, auth.sub, updates, syncVersion); + + if (result.conflict) { + throw new ConflictError( + `Sync conflict: server version is ${result.serverVersion}, received ${syncVersion}` + ); + } + if (!result.doc) throw new NotFoundError('Timer not found'); + + req.log.info({ timerId: id, syncVersion }, 'Updated timer'); + return result.doc; + }); + + // Delete timer + app.delete('/timers/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const success = await repo.deleteTimer(id, auth.sub); + if (!success) throw new NotFoundError('Timer not found'); + req.log.info({ timerId: id }, 'Deleted timer'); + return { success: true }; + }); + + // Batch upsert (initial sync / offline queue flush) + app.post('/timers/batch', async req => { + const auth = await extractAuth(req); + const parsed = BatchUpsertSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const now = new Date().toISOString(); + const enriched = parsed.data.timers.map(t => ({ + ...t, + createdAt: now, + lastSyncedAt: now, + })); + + req.log.info({ count: enriched.length }, 'Batch upsert timers'); + const result = await repo.batchUpsert(auth.sub, PRODUCT_ID, enriched); + return result; + }); +} diff --git a/backend/src/modules/timers/timers.test.ts b/backend/src/modules/timers/timers.test.ts new file mode 100644 index 0000000..7242a52 --- /dev/null +++ b/backend/src/modules/timers/timers.test.ts @@ -0,0 +1,408 @@ +/** + * Timers module unit tests — validates schemas, constants, and type guards. + */ + +import { describe, it, expect } from 'vitest'; +import { + CreateTimerSchema, + UpdateTimerSchema, + TimerQuerySchema, + TimerSyncQuerySchema, + BatchUpsertSchema, + TIMER_TYPES, + TIMER_STATES, + URGENCY_LEVELS, + CASCADE_PRESETS, +} from './types.js'; + +// ── Constants ── + +describe('type constants', () => { + it('has 3 timer types', () => { + expect(TIMER_TYPES).toEqual(['countdown', 'alarm', 'pomodoro']); + }); + + it('has 7 timer states', () => { + expect(TIMER_STATES).toEqual([ + 'active', + 'paused', + 'fired', + 'snoozed', + 'dismissed', + 'completed', + 'warning', + ]); + expect(TIMER_STATES).toHaveLength(7); + }); + + it('has 5 urgency levels', () => { + expect(URGENCY_LEVELS).toEqual(['critical', 'important', 'standard', 'gentle', 'passive']); + expect(URGENCY_LEVELS).toHaveLength(5); + }); + + it('has 4 cascade presets', () => { + expect(CASCADE_PRESETS).toEqual(['minimal', 'standard', 'aggressive', 'custom']); + }); +}); + +// ── CreateTimerSchema ── + +describe('CreateTimerSchema', () => { + const validMinimal = { + id: 'timer_001', + label: 'Morning alarm', + type: 'alarm', + duration: 0, + targetTime: '2026-03-01T07:00:00.000Z', + }; + + it('accepts minimal valid input with defaults', () => { + const result = CreateTimerSchema.safeParse(validMinimal); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.id).toBe('timer_001'); + expect(result.data.label).toBe('Morning alarm'); + expect(result.data.type).toBe('alarm'); + expect(result.data.state).toBe('active'); + expect(result.data.urgency).toBe('standard'); + expect(result.data.syncVersion).toBe(1); + } + }); + + it('accepts full countdown timer with cascade', () => { + const result = CreateTimerSchema.safeParse({ + ...validMinimal, + type: 'countdown', + state: 'active', + urgency: 'critical', + duration: 300, + description: 'Important meeting prep', + cascade: { + preset: 'aggressive', + intervals: [30, 15, 5, 1], + }, + deviceId: 'iphone-14-pro', + category: 'work', + syncVersion: 3, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.cascade?.preset).toBe('aggressive'); + expect(result.data.cascade?.intervals).toEqual([30, 15, 5, 1]); + expect(result.data.urgency).toBe('critical'); + expect(result.data.syncVersion).toBe(3); + } + }); + + it('accepts pomodoro timer with full config', () => { + const result = CreateTimerSchema.safeParse({ + ...validMinimal, + type: 'pomodoro', + pomodoro: { + focusMinutes: 25, + shortBreakMinutes: 5, + longBreakMinutes: 15, + roundsBeforeLong: 4, + currentRound: 1, + isBreak: false, + totalRoundsCompleted: 0, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.pomodoro?.focusMinutes).toBe(25); + expect(result.data.pomodoro?.roundsBeforeLong).toBe(4); + } + }); + + it('accepts timer with calendar sync', () => { + const result = CreateTimerSchema.safeParse({ + ...validMinimal, + isCalendarSync: true, + calendarEventId: 'cal_abc123', + }); + expect(result.success).toBe(true); + }); + + it('rejects missing id', () => { + const result = CreateTimerSchema.safeParse({ + label: 'Morning alarm', + type: 'alarm', + duration: 0, + targetTime: '2026-03-01T07:00:00.000Z', + }); + expect(result.success).toBe(false); + }); + + it('rejects missing label', () => { + const result = CreateTimerSchema.safeParse({ + id: 'timer_001', + type: 'alarm', + duration: 0, + targetTime: '2026-03-01T07:00:00.000Z', + }); + expect(result.success).toBe(false); + }); + + it('rejects missing type', () => { + const result = CreateTimerSchema.safeParse({ + id: 'timer_001', + label: 'Morning alarm', + duration: 0, + targetTime: '2026-03-01T07:00:00.000Z', + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid type', () => { + const result = CreateTimerSchema.safeParse({ ...validMinimal, type: 'stopwatch' }); + expect(result.success).toBe(false); + }); + + it('rejects invalid state', () => { + const result = CreateTimerSchema.safeParse({ ...validMinimal, state: 'deleted' }); + expect(result.success).toBe(false); + }); + + it('rejects invalid urgency', () => { + const result = CreateTimerSchema.safeParse({ ...validMinimal, urgency: 'extreme' }); + expect(result.success).toBe(false); + }); + + it('rejects invalid targetTime format', () => { + const result = CreateTimerSchema.safeParse({ ...validMinimal, targetTime: 'not-a-date' }); + expect(result.success).toBe(false); + }); + + it('rejects negative duration', () => { + const result = CreateTimerSchema.safeParse({ ...validMinimal, duration: -10 }); + expect(result.success).toBe(false); + }); + + it('rejects label > 500 chars', () => { + const result = CreateTimerSchema.safeParse({ ...validMinimal, label: 'x'.repeat(501) }); + expect(result.success).toBe(false); + }); + + it('rejects pomodoro focusMinutes > 120', () => { + const result = CreateTimerSchema.safeParse({ + ...validMinimal, + pomodoro: { + focusMinutes: 150, + shortBreakMinutes: 5, + longBreakMinutes: 15, + roundsBeforeLong: 4, + currentRound: 0, + isBreak: false, + totalRoundsCompleted: 0, + }, + }); + expect(result.success).toBe(false); + }); +}); + +// ── UpdateTimerSchema ── + +describe('UpdateTimerSchema', () => { + it('accepts state update with syncVersion', () => { + const result = UpdateTimerSchema.safeParse({ state: 'paused', syncVersion: 2 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.state).toBe('paused'); + expect(result.data.syncVersion).toBe(2); + } + }); + + it('accepts complete update with timestamps', () => { + const result = UpdateTimerSchema.safeParse({ + state: 'completed', + completedAt: '2026-03-01T08:00:00.000Z', + syncVersion: 5, + }); + expect(result.success).toBe(true); + }); + + it('accepts pomodoro round update', () => { + const result = UpdateTimerSchema.safeParse({ + pomodoro: { + focusMinutes: 25, + shortBreakMinutes: 5, + longBreakMinutes: 15, + roundsBeforeLong: 4, + currentRound: 3, + isBreak: true, + totalRoundsCompleted: 2, + }, + syncVersion: 4, + }); + expect(result.success).toBe(true); + }); + + it('requires syncVersion', () => { + const result = UpdateTimerSchema.safeParse({ state: 'paused' }); + expect(result.success).toBe(false); + }); + + it('rejects syncVersion < 1', () => { + const result = UpdateTimerSchema.safeParse({ state: 'paused', syncVersion: 0 }); + expect(result.success).toBe(false); + }); + + it('rejects invalid state', () => { + const result = UpdateTimerSchema.safeParse({ state: 'deleted', syncVersion: 2 }); + expect(result.success).toBe(false); + }); + + it('rejects invalid completedAt format', () => { + const result = UpdateTimerSchema.safeParse({ + completedAt: 'yesterday', + syncVersion: 2, + }); + expect(result.success).toBe(false); + }); +}); + +// ── TimerQuerySchema ── + +describe('TimerQuerySchema', () => { + it('provides defaults for empty query', () => { + const result = TimerQuerySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sortBy).toBe('createdAt'); + expect(result.data.sortOrder).toBe('desc'); + expect(result.data.limit).toBe(50); + expect(result.data.offset).toBe(0); + } + }); + + it('coerces string numbers for limit and offset', () => { + const result = TimerQuerySchema.safeParse({ limit: '25', offset: '10' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(25); + expect(result.data.offset).toBe(10); + } + }); + + it('accepts all filter combinations', () => { + const result = TimerQuerySchema.safeParse({ + state: 'active', + type: 'pomodoro', + urgency: 'critical', + category: 'work', + sortBy: 'targetTime', + sortOrder: 'asc', + }); + expect(result.success).toBe(true); + }); + + it('rejects limit > 100', () => { + const result = TimerQuerySchema.safeParse({ limit: 200 }); + expect(result.success).toBe(false); + }); + + it('rejects negative offset', () => { + const result = TimerQuerySchema.safeParse({ offset: -1 }); + expect(result.success).toBe(false); + }); + + it('rejects invalid sortBy', () => { + const result = TimerQuerySchema.safeParse({ sortBy: 'random' }); + expect(result.success).toBe(false); + }); + + it('rejects invalid state filter', () => { + const result = TimerQuerySchema.safeParse({ state: 'deleted' }); + expect(result.success).toBe(false); + }); +}); + +// ── TimerSyncQuerySchema ── + +describe('TimerSyncQuerySchema', () => { + it('accepts valid since timestamp', () => { + const result = TimerSyncQuerySchema.safeParse({ since: '2026-03-01T00:00:00.000Z' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(100); + } + }); + + it('accepts custom limit', () => { + const result = TimerSyncQuerySchema.safeParse({ + since: '2026-03-01T00:00:00.000Z', + limit: '50', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(50); + } + }); + + it('rejects missing since', () => { + const result = TimerSyncQuerySchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('rejects invalid since format', () => { + const result = TimerSyncQuerySchema.safeParse({ since: 'yesterday' }); + expect(result.success).toBe(false); + }); + + it('rejects limit > 500', () => { + const result = TimerSyncQuerySchema.safeParse({ + since: '2026-03-01T00:00:00.000Z', + limit: 1000, + }); + expect(result.success).toBe(false); + }); +}); + +// ── BatchUpsertSchema ── + +describe('BatchUpsertSchema', () => { + const validTimer = { + id: 'timer_batch_1', + label: 'Batch timer', + type: 'countdown', + duration: 600, + targetTime: '2026-03-01T10:00:00.000Z', + }; + + it('accepts array of valid timers', () => { + const result = BatchUpsertSchema.safeParse({ + timers: [validTimer, { ...validTimer, id: 'timer_batch_2', label: 'Second timer' }], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.timers).toHaveLength(2); + } + }); + + it('rejects empty timers array', () => { + const result = BatchUpsertSchema.safeParse({ timers: [] }); + expect(result.success).toBe(false); + }); + + it('rejects missing timers field', () => { + const result = BatchUpsertSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('validates each timer in the array', () => { + const result = BatchUpsertSchema.safeParse({ + timers: [validTimer, { id: 'bad', type: 'invalid' }], + }); + expect(result.success).toBe(false); + }); + + it('rejects > 100 timers', () => { + const timers = Array.from({ length: 101 }, (_, i) => ({ + ...validTimer, + id: `timer_${i}`, + })); + const result = BatchUpsertSchema.safeParse({ timers }); + expect(result.success).toBe(false); + }); +}); diff --git a/backend/src/modules/timers/types.ts b/backend/src/modules/timers/types.ts new file mode 100644 index 0000000..0c36a62 --- /dev/null +++ b/backend/src/modules/timers/types.ts @@ -0,0 +1,175 @@ +/** + * Timer types — ChronoMind cross-platform cloud sync. + * + * Cosmos container: `timers` (partition key: `/userId`) + * Product ID: "chronomind" + */ + +import { z } from 'zod'; + +// ── Enums / constants ── + +export const TIMER_TYPES = ['countdown', 'alarm', 'pomodoro'] as const; +export type TimerType = (typeof TIMER_TYPES)[number]; + +export const TIMER_STATES = [ + 'active', + 'paused', + 'fired', + 'snoozed', + 'dismissed', + 'completed', + 'warning', +] as const; +export type TimerState = (typeof TIMER_STATES)[number]; + +export const URGENCY_LEVELS = ['critical', 'important', 'standard', 'gentle', 'passive'] as const; +export type UrgencyLevel = (typeof URGENCY_LEVELS)[number]; + +export const CASCADE_PRESETS = ['minimal', 'standard', 'aggressive', 'custom'] as const; +export type CascadePreset = (typeof CASCADE_PRESETS)[number]; + +// ── Sub-document interfaces ── + +export interface CascadeConfig { + preset: CascadePreset; + intervals: number[]; +} + +export interface PomodoroConfig { + focusMinutes: number; + shortBreakMinutes: number; + longBreakMinutes: number; + roundsBeforeLong: number; + currentRound: number; + isBreak: boolean; + totalRoundsCompleted: number; +} + +// ── Main document ── + +export interface TimerDoc { + id: string; + userId: string; + productId: string; + + label: string; + description?: string; + type: TimerType; + state: TimerState; + urgency: UrgencyLevel; + + duration: number; + targetTime: string; + createdAt: string; + startedAt?: string; + pausedAt?: string; + firedAt?: string; + completedAt?: string; + + cascade?: CascadeConfig; + pomodoro?: PomodoroConfig; + + isCalendarSync?: boolean; + calendarEventId?: string; + category?: string; + + deviceId?: string; + lastSyncedAt?: string; + syncVersion: number; + + _ts?: number; + _etag?: string; +} + +// ── Zod schemas ── + +const CascadeSchema = z.object({ + preset: z.enum(CASCADE_PRESETS), + intervals: z.array(z.number().int().min(0)), +}); + +const PomodoroSchema = z.object({ + focusMinutes: z.number().int().min(1).max(120), + shortBreakMinutes: z.number().int().min(1).max(60), + longBreakMinutes: z.number().int().min(1).max(120), + roundsBeforeLong: z.number().int().min(1).max(20), + currentRound: z.number().int().min(0), + isBreak: z.boolean(), + totalRoundsCompleted: z.number().int().min(0), +}); + +export const CreateTimerSchema = z.object({ + id: z.string().min(1).max(128), + label: z.string().min(1).max(500), + description: z.string().max(2000).optional(), + type: z.enum(TIMER_TYPES), + state: z.enum(TIMER_STATES).default('active'), + urgency: z.enum(URGENCY_LEVELS).default('standard'), + duration: z.number().int().min(0), + targetTime: z.string().datetime(), + cascade: CascadeSchema.optional(), + pomodoro: PomodoroSchema.optional(), + isCalendarSync: z.boolean().optional(), + calendarEventId: z.string().max(500).optional(), + category: z.string().max(128).optional(), + deviceId: z.string().max(256).optional(), + startedAt: z.string().datetime().optional(), + syncVersion: z.number().int().min(0).default(1), +}); + +export const UpdateTimerSchema = z.object({ + label: z.string().min(1).max(500).optional(), + description: z.string().max(2000).optional(), + state: z.enum(TIMER_STATES).optional(), + urgency: z.enum(URGENCY_LEVELS).optional(), + duration: z.number().int().min(0).optional(), + targetTime: z.string().datetime().optional(), + startedAt: z.string().datetime().optional(), + pausedAt: z.string().datetime().optional(), + firedAt: z.string().datetime().optional(), + completedAt: z.string().datetime().optional(), + cascade: CascadeSchema.optional(), + pomodoro: PomodoroSchema.optional(), + isCalendarSync: z.boolean().optional(), + calendarEventId: z.string().max(500).optional(), + category: z.string().max(128).optional(), + deviceId: z.string().max(256).optional(), + syncVersion: z.number().int().min(1), +}); + +export const TimerQuerySchema = z.object({ + state: z.enum(TIMER_STATES).optional(), + type: z.enum(TIMER_TYPES).optional(), + urgency: z.enum(URGENCY_LEVELS).optional(), + category: z.string().optional(), + sortBy: z.enum(['createdAt', 'targetTime', 'label']).default('createdAt'), + sortOrder: z.enum(['asc', 'desc']).default('desc'), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +export const TimerSyncQuerySchema = z.object({ + since: z.string().datetime(), + limit: z.coerce.number().int().min(1).max(500).default(100), +}); + +export const BatchUpsertSchema = z.object({ + timers: z.array(CreateTimerSchema).min(1).max(100), +}); + +// ── Inferred types ── + +export type CreateTimerInput = z.infer; +export type UpdateTimerInput = z.infer; +export type TimerQuery = z.infer; +export type TimerSyncQuery = z.infer; +export type BatchUpsertInput = z.infer; + +// ── Batch result ── + +export interface BatchUpsertResult { + synced: string[]; + conflicts: Array<{ id: string; serverVersion: number }>; + errors: Array<{ id: string; error: string }>; +} diff --git a/backend/src/modules/webhooks/dispatcher.ts b/backend/src/modules/webhooks/dispatcher.ts new file mode 100644 index 0000000..8f2af86 --- /dev/null +++ b/backend/src/modules/webhooks/dispatcher.ts @@ -0,0 +1,200 @@ +import { createHmac } from 'node:crypto'; +import type { WebhookEventType, WebhookSubscriptionDoc, WebhookEventDoc } from './types.js'; +import * as repo from './repository.js'; + +// ── HMAC Signing ────────────────────────────────────────────── + +export function signPayload(payload: string, secret: string): string { + return createHmac('sha256', secret).update(payload).digest('hex'); +} + +export function buildSignatureHeader(payload: string, secret: string): string { + const timestamp = Math.floor(Date.now() / 1000); + const signature = createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex'); + return `t=${timestamp},v1=${signature}`; +} + +// ── Delivery ────────────────────────────────────────────────── + +export interface DeliveryResult { + subscriptionId: string; + eventId: string; + success: boolean; + statusCode?: number; + error?: string; +} + +/** + * Dispatch a webhook event to all matching subscriptions for a user. + * Returns delivery results for each subscription. + */ +export async function dispatchEvent( + userId: string, + productId: string, + eventType: WebhookEventType, + payload: Record, + log?: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void } +): Promise { + const subscriptions = await repo.findSubscriptionsForEvent(userId, productId, eventType); + + if (subscriptions.length === 0) { + return []; + } + + const results: DeliveryResult[] = []; + + for (const sub of subscriptions) { + const result = await deliverToSubscription(sub, eventType, payload, log); + results.push(result); + } + + return results; +} + +/** + * Deliver a single event to a single subscription. + * Creates an event log entry and handles retries. + */ +async function deliverToSubscription( + sub: WebhookSubscriptionDoc, + eventType: WebhookEventType, + payload: Record, + log?: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void } +): Promise { + const eventId = crypto.randomUUID(); + const now = new Date().toISOString(); + + // Create event log entry + const eventDoc: WebhookEventDoc = { + id: eventId, + subscriptionId: sub.id, + userId: sub.userId, + productId: sub.productId, + eventType, + payload, + createdAt: now, + attempts: 0, + maxRetries: sub.maxRetries, + }; + + await repo.createEvent(eventDoc); + + // Attempt delivery with retries + const maxAttempts = (sub.maxRetries || 3) + 1; + let lastError: string | undefined; + let statusCode: number | undefined; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const bodyJson = JSON.stringify({ + id: eventId, + type: eventType, + timestamp: now, + data: payload, + }); + + const signatureHeader = buildSignatureHeader(bodyJson, sub.secret); + + const controller = new globalThis.AbortController(); + const timeout = globalThis.setTimeout(() => controller.abort(), 10_000); + + const response = await fetch(sub.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Signature': signatureHeader, + 'X-Webhook-Id': eventId, + 'X-Webhook-Event': eventType, + 'User-Agent': 'ChronoMind-Webhooks/1.0', + }, + body: bodyJson, + signal: controller.signal, + }); + + globalThis.clearTimeout(timeout); + statusCode = response.status; + + if (response.ok) { + // Success — update event log + await repo.updateEvent({ + ...eventDoc, + deliveredAt: new Date().toISOString(), + statusCode, + attempts: attempt, + }); + await repo.resetFailureCount(sub.id, sub.userId); + + log?.info({ subscriptionId: sub.id, eventType, attempt, statusCode }, 'webhook delivered'); + + return { + subscriptionId: sub.id, + eventId, + success: true, + statusCode, + }; + } + + lastError = `HTTP ${statusCode}`; + } catch (err: unknown) { + lastError = err instanceof Error ? err.message : String(err); + } + + // Exponential backoff between retries (100ms, 200ms, 400ms, ...) + if (attempt < maxAttempts) { + const delay = Math.min(100 * Math.pow(2, attempt - 1), 5000); + await new Promise(resolve => globalThis.setTimeout(resolve, delay)); + } + } + + // All attempts failed + await repo.updateEvent({ + ...eventDoc, + attempts: maxAttempts, + error: lastError, + statusCode, + }); + await repo.incrementFailureCount(sub.id, sub.userId); + + log?.error({ subscriptionId: sub.id, eventType, error: lastError }, 'webhook delivery failed'); + + return { + subscriptionId: sub.id, + eventId, + success: false, + statusCode, + error: lastError, + }; +} + +// ── Verify Signature (for consumers) ────────────────────────── + +export function verifySignature( + signatureHeader: string, + body: string, + secret: string, + toleranceSeconds = 300 +): boolean { + const parts = signatureHeader.split(','); + const timestampPart = parts.find(p => p.startsWith('t=')); + const signaturePart = parts.find(p => p.startsWith('v1=')); + + if (!timestampPart || !signaturePart) return false; + + const timestamp = parseInt(timestampPart.slice(2), 10); + const signature = signaturePart.slice(3); + + // Check timestamp tolerance + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - timestamp) > toleranceSeconds) return false; + + // Verify HMAC + const expected = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'); + + // Constant-time comparison + if (expected.length !== signature.length) return false; + let diff = 0; + for (let i = 0; i < expected.length; i++) { + diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i); + } + return diff === 0; +} diff --git a/backend/src/modules/webhooks/repository.ts b/backend/src/modules/webhooks/repository.ts new file mode 100644 index 0000000..0030e8f --- /dev/null +++ b/backend/src/modules/webhooks/repository.ts @@ -0,0 +1,192 @@ +import { getContainer } from '../../lib/cosmos.js'; +import { NotFoundError, ConflictError } from '../../lib/errors.js'; +import type { + WebhookSubscriptionDoc, + WebhookEventDoc, + CreateSubscription, + UpdateSubscription, + WebhookEventType, +} from './types.js'; + +const SUBS_CONTAINER = 'webhook_subscriptions'; +const EVENTS_CONTAINER = 'webhook_events'; + +function subsContainer() { + return getContainer(SUBS_CONTAINER); +} + +function eventsContainer() { + return getContainer(EVENTS_CONTAINER); +} + +// ── Subscription CRUD ───────────────────────────────────────── + +export async function listSubscriptions( + userId: string, + productId: string +): Promise { + const { resources } = await subsContainer() + .items.query( + { + query: + 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId ORDER BY c.createdAt DESC', + parameters: [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + ], + }, + { partitionKey: userId } + ) + .fetchAll(); + + return resources; +} + +export async function getSubscription(id: string, userId: string): Promise { + const { resource } = await subsContainer().item(id, userId).read(); + if (!resource) { + throw new NotFoundError(`Webhook subscription '${id}' not found`); + } + return resource; +} + +export async function createSubscription( + id: string, + userId: string, + productId: string, + input: CreateSubscription +): Promise { + const now = new Date().toISOString(); + const doc: WebhookSubscriptionDoc = { + id, + userId, + productId, + url: input.url, + secret: input.secret, + events: input.events, + active: true, + description: input.description, + createdAt: now, + updatedAt: now, + failureCount: 0, + maxRetries: input.maxRetries ?? 3, + }; + + try { + const { resource } = await subsContainer().items.create(doc); + return resource as WebhookSubscriptionDoc; + } catch (err: unknown) { + if (err && typeof err === 'object' && 'code' in err && err.code === 409) { + throw new ConflictError(`Subscription '${id}' already exists`); + } + throw err; + } +} + +export async function updateSubscription( + id: string, + userId: string, + updates: UpdateSubscription +): Promise { + const existing = await getSubscription(id, userId); + + const updated: WebhookSubscriptionDoc = { + ...existing, + ...updates, + updatedAt: new Date().toISOString(), + }; + + const { resource } = await subsContainer().item(id, userId).replace(updated); + return resource as WebhookSubscriptionDoc; +} + +export async function deleteSubscription(id: string, userId: string): Promise { + await getSubscription(id, userId); // verify exists + await subsContainer().item(id, userId).delete(); +} + +// ── Find Subscriptions for Event ────────────────────────────── + +export async function findSubscriptionsForEvent( + userId: string, + productId: string, + eventType: WebhookEventType +): Promise { + const { resources } = await subsContainer() + .items.query( + { + query: + 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId AND c.active = true AND ARRAY_CONTAINS(c.events, @eventType)', + parameters: [ + { name: '@userId', value: userId }, + { name: '@productId', value: productId }, + { name: '@eventType', value: eventType }, + ], + }, + { partitionKey: userId } + ) + .fetchAll(); + + return resources; +} + +// ── Increment Failure Count ─────────────────────────────────── + +export async function incrementFailureCount(id: string, userId: string): Promise { + const existing = await getSubscription(id, userId); + const failureCount = (existing.failureCount || 0) + 1; + + // Auto-disable after 10 consecutive failures + const active = failureCount < 10; + + await subsContainer() + .item(id, userId) + .replace({ + ...existing, + failureCount, + active, + updatedAt: new Date().toISOString(), + }); +} + +export async function resetFailureCount(id: string, userId: string): Promise { + const existing = await getSubscription(id, userId); + await subsContainer() + .item(id, userId) + .replace({ + ...existing, + failureCount: 0, + lastDeliveryAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); +} + +// ── Event Log ───────────────────────────────────────────────── + +export async function createEvent(doc: WebhookEventDoc): Promise { + const { resource } = await eventsContainer().items.create(doc); + return resource as WebhookEventDoc; +} + +export async function updateEvent(doc: WebhookEventDoc): Promise { + const { resource } = await eventsContainer().item(doc.id, doc.subscriptionId).replace(doc); + return resource as WebhookEventDoc; +} + +export async function listEvents(subscriptionId: string, limit = 50): Promise { + const { resources } = await eventsContainer() + .items.query( + { + query: + 'SELECT TOP @limit * FROM c WHERE c.subscriptionId = @subscriptionId ORDER BY c.createdAt DESC', + parameters: [ + { name: '@subscriptionId', value: subscriptionId }, + { name: '@limit', value: limit }, + ], + }, + { partitionKey: subscriptionId } + ) + .fetchAll(); + + return resources; +} diff --git a/backend/src/modules/webhooks/routes.ts b/backend/src/modules/webhooks/routes.ts new file mode 100644 index 0000000..aedfd9c --- /dev/null +++ b/backend/src/modules/webhooks/routes.ts @@ -0,0 +1,111 @@ +import type { FastifyInstance } from 'fastify'; + +import { + CreateSubscriptionSchema, + UpdateSubscriptionSchema, + WEBHOOK_EVENT_TYPES, +} from './types.js'; +import * as repo from './repository.js'; +import { dispatchEvent } from './dispatcher.js'; +import { extractAuth } from '../../lib/auth.js'; +import { BadRequestError } from '../../lib/errors.js'; + +const PRODUCT_ID = 'chronomind'; + +export async function webhookRoutes(app: FastifyInstance) { + // Event types — must be before :id param route + app.get('/webhooks/event-types', async (_req, reply) => { + return reply.send({ + eventTypes: WEBHOOK_EVENT_TYPES.map(type => ({ + type, + category: type.split('.')[0], + action: type.split('.')[1], + })), + }); + }); + + // Test — must be before :id param route + app.post('/webhooks/test', async (req, reply) => { + const auth = await extractAuth(req); + const body = req.body as { subscriptionId?: string; eventType?: string }; + + if (!body.subscriptionId) { + throw new BadRequestError('subscriptionId is required'); + } + + await repo.getSubscription(body.subscriptionId, auth.sub); + + const eventType = (body.eventType || 'timer.fired') as (typeof WEBHOOK_EVENT_TYPES)[number]; + if (!WEBHOOK_EVENT_TYPES.includes(eventType)) { + throw new BadRequestError(`Invalid event type: ${eventType}`); + } + + const results = await dispatchEvent( + auth.sub, + PRODUCT_ID, + eventType, + { + test: true, + message: 'This is a test webhook event from ChronoMind', + timestamp: new Date().toISOString(), + }, + req.log + ); + + return reply.send({ results }); + }); + + // List subscriptions + app.get('/webhooks', async req => { + const auth = await extractAuth(req); + return repo.listSubscriptions(auth.sub, PRODUCT_ID); + }); + + // Get subscription + app.get('/webhooks/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + return repo.getSubscription(id, auth.sub); + }); + + // Create subscription + app.post('/webhooks', async (req, reply) => { + const auth = await extractAuth(req); + const parsed = CreateSubscriptionSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + const id = crypto.randomUUID(); + const sub = await repo.createSubscription(id, auth.sub, PRODUCT_ID, parsed.data); + return reply.status(201).send(sub); + }); + + // Update subscription + app.put('/webhooks/:id', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const parsed = UpdateSubscriptionSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + return repo.updateSubscription(id, auth.sub, parsed.data); + }); + + // Delete subscription + app.delete('/webhooks/:id', async (req, reply) => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + await repo.deleteSubscription(id, auth.sub); + return reply.status(204).send(); + }); + + // List events for subscription + app.get('/webhooks/:id/events', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + // Verify ownership + await repo.getSubscription(id, auth.sub); + const limit = parseInt((req.query as Record).limit || '50', 10); + return repo.listEvents(id, Math.min(limit, 100)); + }); +} diff --git a/backend/src/modules/webhooks/types.ts b/backend/src/modules/webhooks/types.ts new file mode 100644 index 0000000..33b3cac --- /dev/null +++ b/backend/src/modules/webhooks/types.ts @@ -0,0 +1,95 @@ +import { z } from 'zod'; + +// ── Webhook Event Types ─────────────────────────────────────── + +export const WEBHOOK_EVENT_TYPES = [ + 'timer.created', + 'timer.fired', + 'timer.dismissed', + 'timer.completed', + 'timer.snoozed', + 'timer.paused', + 'timer.resumed', + 'routine.started', + 'routine.completed', + 'routine.step_completed', + 'household.member_joined', + 'household.member_left', + 'shared_timer.created', + 'shared_timer.fired', + 'shared_timer.acknowledged', +] as const; + +export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number]; + +// ── Subscription Schemas ────────────────────────────────────── + +export const WebhookSubscriptionSchema = z.object({ + id: z.string().min(1), + userId: z.string().min(1), + productId: z.string().min(1), + url: z.string().url(), + secret: z.string().min(16).max(256), + events: z.array(z.enum(WEBHOOK_EVENT_TYPES)).min(1), + active: z.boolean().default(true), + description: z.string().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + lastDeliveryAt: z.string().optional(), + failureCount: z.number().default(0), + maxRetries: z.number().default(3), +}); + +export const CreateSubscriptionSchema = z.object({ + url: z.string().url(), + secret: z.string().min(16).max(256), + events: z.array(z.enum(WEBHOOK_EVENT_TYPES)).min(1), + description: z.string().optional(), + maxRetries: z.number().min(0).max(10).optional(), +}); + +export const UpdateSubscriptionSchema = z.object({ + url: z.string().url().optional(), + secret: z.string().min(16).max(256).optional(), + events: z.array(z.enum(WEBHOOK_EVENT_TYPES)).min(1).optional(), + active: z.boolean().optional(), + description: z.string().optional(), + maxRetries: z.number().min(0).max(10).optional(), +}); + +// ── Event Payload Schema ────────────────────────────────────── + +export const WebhookEventSchema = z.object({ + id: z.string().min(1), + subscriptionId: z.string().min(1), + userId: z.string().min(1), + productId: z.string().min(1), + eventType: z.enum(WEBHOOK_EVENT_TYPES), + payload: z.record(z.unknown()), + createdAt: z.string(), + deliveredAt: z.string().optional(), + statusCode: z.number().optional(), + attempts: z.number().default(0), + maxRetries: z.number().default(3), + nextRetryAt: z.string().optional(), + error: z.string().optional(), +}); + +// ── TypeScript Types ────────────────────────────────────────── + +export type WebhookSubscription = z.infer; +export type CreateSubscription = z.infer; +export type UpdateSubscription = z.infer; +export type WebhookEvent = z.infer; + +// ── Cosmos Document Shapes ──────────────────────────────────── + +export interface WebhookSubscriptionDoc extends WebhookSubscription { + _ts?: number; + _etag?: string; +} + +export interface WebhookEventDoc extends WebhookEvent { + _ts?: number; + _etag?: string; +} diff --git a/backend/src/modules/webhooks/webhooks.test.ts b/backend/src/modules/webhooks/webhooks.test.ts new file mode 100644 index 0000000..0160906 --- /dev/null +++ b/backend/src/modules/webhooks/webhooks.test.ts @@ -0,0 +1,356 @@ +import { describe, it, expect } from 'vitest'; +import { + WebhookSubscriptionSchema, + CreateSubscriptionSchema, + UpdateSubscriptionSchema, + WebhookEventSchema, + WEBHOOK_EVENT_TYPES, + type WebhookSubscription, + type CreateSubscription, + type WebhookEvent, +} from './types.js'; +import { signPayload, buildSignatureHeader, verifySignature } from './dispatcher.js'; + +// ── Types & Schema Tests ────────────────────────────────────── + +describe('Webhook Types', () => { + it('should define 15 event types', () => { + expect(WEBHOOK_EVENT_TYPES).toHaveLength(15); + }); + + it('should include all timer event types', () => { + const timerEvents = WEBHOOK_EVENT_TYPES.filter(e => e.startsWith('timer.')); + expect(timerEvents).toEqual([ + 'timer.created', + 'timer.fired', + 'timer.dismissed', + 'timer.completed', + 'timer.snoozed', + 'timer.paused', + 'timer.resumed', + ]); + }); + + it('should include all routine event types', () => { + const routineEvents = WEBHOOK_EVENT_TYPES.filter(e => e.startsWith('routine.')); + expect(routineEvents).toEqual([ + 'routine.started', + 'routine.completed', + 'routine.step_completed', + ]); + }); + + it('should include all household event types', () => { + const householdEvents = WEBHOOK_EVENT_TYPES.filter(e => e.startsWith('household.')); + expect(householdEvents).toEqual(['household.member_joined', 'household.member_left']); + }); + + it('should include all shared_timer event types', () => { + const sharedEvents = WEBHOOK_EVENT_TYPES.filter(e => e.startsWith('shared_timer.')); + expect(sharedEvents).toEqual([ + 'shared_timer.created', + 'shared_timer.fired', + 'shared_timer.acknowledged', + ]); + }); + + it('should have unique event types', () => { + const unique = new Set(WEBHOOK_EVENT_TYPES); + expect(unique.size).toBe(WEBHOOK_EVENT_TYPES.length); + }); +}); + +describe('WebhookSubscriptionSchema', () => { + const validSub: WebhookSubscription = { + id: 'sub-1', + userId: 'user-1', + productId: 'chronomind', + url: 'https://example.com/webhook', + secret: 'super-secret-key-1234567', + events: ['timer.fired', 'timer.dismissed'], + active: true, + failureCount: 0, + maxRetries: 3, + }; + + it('should validate a correct subscription', () => { + const result = WebhookSubscriptionSchema.safeParse(validSub); + expect(result.success).toBe(true); + }); + + it('should reject subscription without url', () => { + const result = WebhookSubscriptionSchema.safeParse({ ...validSub, url: '' }); + expect(result.success).toBe(false); + }); + + it('should reject subscription with invalid url', () => { + const result = WebhookSubscriptionSchema.safeParse({ ...validSub, url: 'not-a-url' }); + expect(result.success).toBe(false); + }); + + it('should reject subscription with short secret', () => { + const result = WebhookSubscriptionSchema.safeParse({ ...validSub, secret: 'short' }); + expect(result.success).toBe(false); + }); + + it('should reject subscription with empty events', () => { + const result = WebhookSubscriptionSchema.safeParse({ ...validSub, events: [] }); + expect(result.success).toBe(false); + }); + + it('should reject subscription with invalid event type', () => { + const result = WebhookSubscriptionSchema.safeParse({ + ...validSub, + events: ['timer.fired', 'invalid.event'], + }); + expect(result.success).toBe(false); + }); + + it('should default active to true', () => { + const withoutActive = { ...validSub }; + delete (withoutActive as Record).active; + const result = WebhookSubscriptionSchema.safeParse(withoutActive); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.active).toBe(true); + } + }); + + it('should default failureCount to 0', () => { + const withoutCount = { ...validSub }; + delete (withoutCount as Record).failureCount; + const result = WebhookSubscriptionSchema.safeParse(withoutCount); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.failureCount).toBe(0); + } + }); +}); + +describe('CreateSubscriptionSchema', () => { + const validCreate: CreateSubscription = { + url: 'https://hooks.zapier.com/abc123', + secret: 'webhook-signing-secret-abc123', + events: ['timer.fired'], + }; + + it('should validate a correct create payload', () => { + const result = CreateSubscriptionSchema.safeParse(validCreate); + expect(result.success).toBe(true); + }); + + it('should accept optional description', () => { + const result = CreateSubscriptionSchema.safeParse({ + ...validCreate, + description: 'My Zapier integration', + }); + expect(result.success).toBe(true); + }); + + it('should accept optional maxRetries', () => { + const result = CreateSubscriptionSchema.safeParse({ + ...validCreate, + maxRetries: 5, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.maxRetries).toBe(5); + } + }); + + it('should reject maxRetries > 10', () => { + const result = CreateSubscriptionSchema.safeParse({ + ...validCreate, + maxRetries: 15, + }); + expect(result.success).toBe(false); + }); + + it('should accept multiple event types', () => { + const result = CreateSubscriptionSchema.safeParse({ + ...validCreate, + events: ['timer.fired', 'timer.dismissed', 'routine.completed'], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.events).toHaveLength(3); + } + }); +}); + +describe('UpdateSubscriptionSchema', () => { + it('should validate partial updates', () => { + const result = UpdateSubscriptionSchema.safeParse({ active: false }); + expect(result.success).toBe(true); + }); + + it('should validate url-only update', () => { + const result = UpdateSubscriptionSchema.safeParse({ + url: 'https://new-endpoint.example.com/hook', + }); + expect(result.success).toBe(true); + }); + + it('should validate events update', () => { + const result = UpdateSubscriptionSchema.safeParse({ + events: ['timer.created', 'timer.completed'], + }); + expect(result.success).toBe(true); + }); + + it('should reject empty events array in update', () => { + const result = UpdateSubscriptionSchema.safeParse({ events: [] }); + expect(result.success).toBe(false); + }); +}); + +describe('WebhookEventSchema', () => { + const validEvent: WebhookEvent = { + id: 'evt-1', + subscriptionId: 'sub-1', + userId: 'user-1', + productId: 'chronomind', + eventType: 'timer.fired', + payload: { timerId: 'timer-1', label: 'Meeting' }, + createdAt: new Date().toISOString(), + attempts: 1, + maxRetries: 3, + }; + + it('should validate a correct event', () => { + const result = WebhookEventSchema.safeParse(validEvent); + expect(result.success).toBe(true); + }); + + it('should accept delivered event with statusCode', () => { + const result = WebhookEventSchema.safeParse({ + ...validEvent, + deliveredAt: new Date().toISOString(), + statusCode: 200, + }); + expect(result.success).toBe(true); + }); + + it('should accept failed event with error', () => { + const result = WebhookEventSchema.safeParse({ + ...validEvent, + error: 'Connection refused', + attempts: 4, + }); + expect(result.success).toBe(true); + }); +}); + +// ── Dispatcher Tests ────────────────────────────────────────── + +describe('Webhook Dispatcher — HMAC Signing', () => { + const secret = 'test-secret-key-for-hmac-1234'; + const payload = JSON.stringify({ type: 'timer.fired', data: { id: 'timer-1' } }); + + it('should produce consistent HMAC signatures', () => { + const sig1 = signPayload(payload, secret); + const sig2 = signPayload(payload, secret); + expect(sig1).toBe(sig2); + expect(sig1).toMatch(/^[0-9a-f]{64}$/); // SHA-256 hex + }); + + it('should produce different signatures for different payloads', () => { + const sig1 = signPayload('payload-1', secret); + const sig2 = signPayload('payload-2', secret); + expect(sig1).not.toBe(sig2); + }); + + it('should produce different signatures for different secrets', () => { + const sig1 = signPayload(payload, 'secret-1-aaaaaaaaaa'); + const sig2 = signPayload(payload, 'secret-2-bbbbbbbbbb'); + expect(sig1).not.toBe(sig2); + }); +}); + +describe('Webhook Dispatcher — Signature Header', () => { + const secret = 'test-secret-key-for-hmac-5678'; + const body = '{"type":"timer.fired","data":{}}'; + + it('should build a valid signature header', () => { + const header = buildSignatureHeader(body, secret); + expect(header).toMatch(/^t=\d+,v1=[0-9a-f]{64}$/); + }); + + it('should include a recent timestamp', () => { + const header = buildSignatureHeader(body, secret); + const tPart = header.split(',')[0]; + const timestamp = parseInt(tPart.slice(2), 10); + const now = Math.floor(Date.now() / 1000); + expect(Math.abs(now - timestamp)).toBeLessThan(5); + }); +}); + +describe('Webhook Dispatcher — Signature Verification', () => { + const secret = 'test-secret-for-verification!'; + const body = JSON.stringify({ type: 'timer.dismissed', data: { id: 't-99' } }); + + it('should verify a valid signature', () => { + const header = buildSignatureHeader(body, secret); + expect(verifySignature(header, body, secret)).toBe(true); + }); + + it('should reject a tampered body', () => { + const header = buildSignatureHeader(body, secret); + expect(verifySignature(header, body + 'tampered', secret)).toBe(false); + }); + + it('should reject a wrong secret', () => { + const header = buildSignatureHeader(body, secret); + expect(verifySignature(header, body, 'wrong-secret-1234567890')).toBe(false); + }); + + it('should reject a malformed header', () => { + expect(verifySignature('invalid', body, secret)).toBe(false); + }); + + it('should reject missing timestamp', () => { + expect(verifySignature('v1=abc123', body, secret)).toBe(false); + }); + + it('should reject missing signature', () => { + expect(verifySignature('t=1234567890', body, secret)).toBe(false); + }); + + it('should reject expired timestamp', () => { + // Build a header with a timestamp from 10 minutes ago + const oldTimestamp = Math.floor(Date.now() / 1000) - 600; + // signPayload produces HMAC of the raw string, matching verifySignature's `${timestamp}.${body}` pattern + const sig = signPayload(`${oldTimestamp}.${body}`, secret); + const header = `t=${oldTimestamp},v1=${sig}`; + // Default tolerance is 300 seconds (5 minutes) — 600s ago should be rejected + expect(verifySignature(header, body, secret, 300)).toBe(false); + }); + + it('should accept within tolerance window', () => { + const header = buildSignatureHeader(body, secret); + // Use a large tolerance window + expect(verifySignature(header, body, secret, 3600)).toBe(true); + }); +}); + +// ── Event Type Categorization Tests ─────────────────────────── + +describe('Event Type Categories', () => { + it('all event types should have category.action format', () => { + for (const type of WEBHOOK_EVENT_TYPES) { + const parts = type.split('.'); + expect(parts).toHaveLength(2); + expect(parts[0].length).toBeGreaterThan(0); + expect(parts[1].length).toBeGreaterThan(0); + } + }); + + it('should have 4 categories', () => { + const categories = new Set(WEBHOOK_EVENT_TYPES.map(t => t.split('.')[0])); + expect(categories.size).toBe(4); + expect(categories).toContain('timer'); + expect(categories).toContain('routine'); + expect(categories).toContain('household'); + expect(categories).toContain('shared_timer'); + }); +}); diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..ce0b2ea --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,55 @@ +/** + * ChronoMind Backend — Fastify server entry point. + * + * Product-specific service for timers, routines, households, shared timers. + * Common platform features (auth, billing, flags, etc.) come from platform-service. + * Port: 4011 (configurable via PORT env var). + */ + +import { createServiceApp, startService } from '@bytelyst/fastify-core'; +import { timerRoutes } from './modules/timers/routes.js'; +import { routineRoutes } from './modules/routines/routes.js'; +import { householdRoutes } from './modules/households/routes.js'; +import { sharedTimerRoutes } from './modules/shared-timers/routes.js'; +import { webhookRoutes } from './modules/webhooks/routes.js'; +import { initCosmosIfNeeded } from './lib/cosmos-init.js'; +import { config } from './lib/config.js'; + +import { jwtVerify } from 'jose'; +import type { JwtPayload } from './lib/request-context.js'; + +const jwtSecret = new TextEncoder().encode(process.env.JWT_SECRET || ''); + +await initCosmosIfNeeded(); + +const app = await createServiceApp({ + name: 'chronomind-backend', + version: '0.1.0', + description: 'ChronoMind product-specific backend — timers, routines, households, shared timers, webhooks', + corsOrigin: config.CORS_ORIGIN, + swagger: { + title: 'ChronoMind Backend', + description: 'Timers, routines, households, shared timers, webhooks', + port: config.PORT, + }, + metrics: true, +}); + +app.addHook('onRequest', async req => { + const auth = req.headers.authorization; + if (!auth?.startsWith('Bearer ')) return; + try { + const { payload } = await jwtVerify(auth.slice(7), jwtSecret, { issuer: 'bytelyst-platform' }); + req.jwtPayload = payload as unknown as JwtPayload; + } catch { + // Token invalid/expired — leave jwtPayload undefined. + } +}); + +await app.register(timerRoutes, { prefix: '/api' }); +await app.register(routineRoutes, { prefix: '/api' }); +await app.register(householdRoutes, { prefix: '/api' }); +await app.register(sharedTimerRoutes, { prefix: '/api' }); +await app.register(webhookRoutes, { prefix: '/api' }); + +await startService(app, { port: config.PORT, host: config.HOST }); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..b874306 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "src/**/*.test.ts"] +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..cc7b926 --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + passWithNoTests: true, + }, +});