Compare commits

...

43 Commits

Author SHA1 Message Date
Ruslan Bakiev
11d41be89e Retry CI image push
All checks were successful
Build and deploy Backend / build (push) Successful in 53s
2026-05-14 08:54:12 +07:00
Ruslan Bakiev
3a072a7165 Add voice transcription admin review flow
All checks were successful
Build and deploy Backend / build (push) Successful in 7m29s
2026-05-14 08:44:20 +07:00
Ruslan Bakiev
0668660e85 Keep Google nearby results out of places list
All checks were successful
Build and deploy Backend / build (push) Successful in 28s
2026-05-13 22:11:41 +07:00
Ruslan Bakiev
17d8580387 Allow short voice experiences
All checks were successful
Build and deploy Backend / build (push) Successful in 33s
2026-05-13 22:01:49 +07:00
Ruslan Bakiev
11f3812dbb Clarify Telegram bot login message
All checks were successful
Build and deploy Backend / build (push) Successful in 28s
2026-05-13 21:25:53 +07:00
Ruslan Bakiev
a00649584b Put Telegram login timer first in button
All checks were successful
Build and deploy Backend / build (push) Successful in 37s
2026-05-13 21:22:16 +07:00
Ruslan Bakiev
c8af4e895a Stop ticking Telegram login message
All checks were successful
Build and deploy Backend / build (push) Successful in 28s
2026-05-13 21:15:54 +07:00
Ruslan Bakiev
bea31fe8f2 Move bot login timer into message text
All checks were successful
Build and deploy Backend / build (push) Successful in 41s
2026-05-13 21:07:32 +07:00
Ruslan Bakiev
85430fa3fb Expire Telegram login button in bot
All checks were successful
Build and deploy Backend / build (push) Successful in 27s
2026-05-13 20:14:22 +07:00
Ruslan Bakiev
4aea10b195 Use static Telegram login lifetime label
All checks were successful
Build and deploy Backend / build (push) Successful in 29s
2026-05-13 20:10:00 +07:00
Ruslan Bakiev
7deedcb276 Replace bot login polling with completion mutation
All checks were successful
Build and deploy Backend / build (push) Successful in 37s
2026-05-13 19:36:01 +07:00
Ruslan Bakiev
7973c44705 Trigger Dokploy from workflow secret
All checks were successful
Build and deploy Backend / build (push) Successful in 33s
2026-05-13 14:59:28 +07:00
Ruslan Bakiev
41e189655a Verify deploy hook
All checks were successful
Build and deploy Backend / build (push) Successful in 8s
2026-05-13 14:52:44 +07:00
Ruslan Bakiev
8050104ad3 Remove Dokploy webhook from workflow
All checks were successful
Build and deploy Backend / build (push) Successful in 41s
2026-05-13 14:34:00 +07:00
Ruslan Bakiev
c8c248c4af Store Google place types
All checks were successful
Build and deploy Backend / build (push) Successful in 27s
2026-05-09 16:56:04 +07:00
Ruslan Bakiev
4d5aa433e8 Persist Google nearby places
All checks were successful
Build and deploy Backend / build (push) Successful in 26s
2026-05-09 16:05:50 +07:00
Ruslan Bakiev
9384a42e39 Add Google nearby place search
All checks were successful
Build and deploy Backend / build (push) Successful in 29s
2026-05-09 15:19:12 +07:00
Ruslan Bakiev
66dfadef2e Use latest tag for backend deploys
All checks were successful
Build and deploy Backend / build (push) Successful in 29s
2026-05-09 15:02:23 +07:00
Ruslan Bakiev
8943eeb0d7 Fail backend deploy on webhook errors
All checks were successful
Build and deploy Backend / build (push) Successful in 26s
2026-05-09 14:48:56 +07:00
Ruslan Bakiev
0244f64df7 Deploy backend through Dokploy webhook
All checks were successful
Build and deploy Backend / build (push) Successful in 28s
2026-05-09 14:44:03 +07:00
Ruslan Bakiev
fff0d7af11 Reject generated place ids for voice reviews
All checks were successful
Build and deploy Backend / build (push) Successful in 28s
2026-05-09 14:18:07 +07:00
Ruslan Bakiev
6471d2ffcf Fix Telegram avatar proxy headers
All checks were successful
Build and deploy Backend / build (push) Successful in 35s
2026-05-08 20:01:32 +07:00
Ruslan Bakiev
387a504801 Tighten Telegram bot login UX
All checks were successful
Build and deploy Backend / build (push) Successful in 36s
2026-05-08 19:52:07 +07:00
Ruslan Bakiev
de0e230632 Proxy Telegram bot user avatars
All checks were successful
Build and deploy Backend / build (push) Successful in 31s
2026-05-08 19:37:10 +07:00
Ruslan Bakiev
71561724a5 Add Telegram bot login sessions
All checks were successful
Build and deploy Backend / build (push) Successful in 49s
2026-05-08 19:31:40 +07:00
Ruslan Bakiev
a0627f6f2c Reduce backend image push size
All checks were successful
Build and deploy Backend / build (push) Successful in 3m54s
2026-05-08 18:47:51 +07:00
Ruslan Bakiev
11470cd086 Retry backend deploy
Some checks failed
Build and deploy Backend / build (push) Failing after 1m10s
2026-05-08 18:43:41 +07:00
Ruslan Bakiev
ddb4e26e78 Use shared builder for backend builds
Some checks failed
Build and deploy Backend / build (push) Failing after 6m11s
2026-05-08 18:31:21 +07:00
Ruslan Bakiev
fe8a69d9b8 Support Telegram login widget auth
Some checks failed
Build and deploy Backend / build (push) Has been cancelled
2026-05-08 18:26:49 +07:00
Ruslan Bakiev
fbe961c358 Require Telegram auth for app data
All checks were successful
Build and deploy Backend / build (push) Successful in 57s
2026-05-08 17:41:34 +07:00
Ruslan Bakiev
359a4237c3 Load Vault before backend migrations
All checks were successful
Build and deploy Backend / build (push) Successful in 1m12s
2026-05-08 17:16:08 +07:00
Ruslan Bakiev
203d37f3c7 Use build-time Prisma database URL
All checks were successful
Build and deploy Backend / build (push) Successful in 1m8s
2026-05-08 17:09:20 +07:00
Ruslan Bakiev
e55a7f1f37 Load backend secrets from Vault
Some checks failed
Build and deploy Backend / build (push) Failing after 27s
2026-05-08 16:57:41 +07:00
Ruslan Bakiev
f956148141 Add Telegram ownership for voice reviews
All checks were successful
Build and deploy Backend / build (push) Successful in 1m1s
2026-05-08 16:44:32 +07:00
Ruslan Bakiev
bba9c98c82 Apply Prisma migrations on startup
All checks were successful
Build and deploy Backend / build (push) Successful in 3m28s
2026-05-08 16:01:00 +07:00
Ruslan Bakiev
7ca503667f Expose places over GraphQL
All checks were successful
Build and deploy Backend / build (push) Successful in 1m1s
2026-05-08 15:54:15 +07:00
Ruslan Bakiev
aeb40eb692 Fix Prisma client path in container
All checks were successful
Build and deploy Backend / build (push) Successful in 39s
2026-05-08 14:11:15 +07:00
Ruslan Bakiev
d180d2416b Configure registry auth for Gitea builds
All checks were successful
Build and deploy Backend / build (push) Successful in 4m0s
2026-05-08 13:57:37 +07:00
Ruslan Bakiev
0767907fb7 Use Gitea job token for registry
Some checks failed
Build and deploy Backend / build (push) Failing after 7s
2026-05-08 13:53:34 +07:00
Ruslan Bakiev
bb8aa86115 Trigger deployment
Some checks failed
Build and deploy Backend / build (push) Failing after 11s
2026-05-08 13:47:47 +07:00
Ruslan Bakiev
3d61d80824 Add Gitea deployment workflow
Some checks failed
Build and deploy Backend / build (push) Failing after 11m4s
2026-05-08 12:19:51 +07:00
Ruslan Bakiev
79baab1738 Lower voice experience minimum duration 2026-05-08 10:38:20 +07:00
Ruslan Bakiev
504a798c4b Add backend Docker image 2026-05-05 12:10:51 +07:00
27 changed files with 7231 additions and 275 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.env
.git

View File

@@ -0,0 +1,60 @@
name: Build and deploy Backend
on:
push:
branches:
- main
jobs:
build:
runs-on: builder
env:
SERVICE_NAME: backend
IMAGE_SHA: gitea.dsrptlab.com/mapflow/backend:${{ github.sha }}
IMAGE_LATEST: gitea.dsrptlab.com/mapflow/backend:latest
steps:
- uses: actions/checkout@v4
- name: Configure Gitea registry auth
run: |
set -euo pipefail
mkdir -p ~/.docker
auth="$(printf '%s:%s' "${{ secrets.REGISTRY_USERNAME }}" "${{ secrets.REGISTRY_TOKEN }}" | base64 | tr -d '\n')"
printf '{"auths":{"gitea.dsrptlab.com":{"auth":"%s"}}}\n' "$auth" > ~/.docker/config.json
- name: Build and push image
run: |
set -euo pipefail
for attempt in 1 2 3; do
if docker buildx build --push --provenance=false --tag "$IMAGE_SHA" --tag "$IMAGE_LATEST" .; then
exit 0
fi
sleep "$((attempt * 10))"
done
exit 1
- name: Skip stale deployment
run: |
set -euo pipefail
latest_sha="$(git ls-remote origin refs/heads/main | awk '{print $1}')"
if [ "$latest_sha" = "${GITHUB_SHA}" ]; then
touch .deploy-current
else
echo "A newer main commit exists: $latest_sha. Skipping deploy for ${GITHUB_SHA}."
fi
- name: Trigger Dokploy deploy webhook
run: |
set -euo pipefail
[ -f .deploy-current ] || exit 0
payload=$(cat <<JSON
{"ref":"refs/heads/main","after":"$GITHUB_SHA","commits":[{"id":"$GITHUB_SHA","message":"$SERVICE_NAME #${GITHUB_RUN_NUMBER:-0} ${GITHUB_SHA:0:7}","modified":["Dockerfile"]}]}
JSON
)
response_file="$(mktemp)"
status_code="$(curl -sS -o "$response_file" -w "%{http_code}" -X POST "${{ secrets.DOKPLOY_DEPLOY_WEBHOOK }}" \
-H "x-gitea-event: push" \
-H "Content-Type: application/json" \
-d "$payload")"
cat "$response_file"
[ "$status_code" = "200" ]

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN DATABASE_URL="postgresql://mapflow:mapflow@localhost:5432/mapflow" npm run prisma:generate
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY --from=build /app/src/generated ./dist/generated
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/prisma.config.ts ./prisma.config.ts
EXPOSE 4000
CMD ["node", "dist/entrypoint.js"]

91
package-lock.json generated
View File

@@ -16,12 +16,12 @@
"fastify": "^5.8.5",
"graphql": "^16.13.2",
"mercurius": "^16.9.0",
"pg": "^8.20.0"
"pg": "^8.20.0",
"prisma": "^7.8.0"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@types/pg": "^8.20.0",
"prisma": "^7.8.0",
"tsx": "^4.21.0",
"typescript": "^6.0.3"
}
@@ -198,14 +198,12 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz",
"integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@electric-sql/pglite-socket": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz",
"integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"pglite-server": "dist/scripts/server.js"
@@ -218,7 +216,6 @@
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz",
"integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==",
"devOptional": true,
"license": "Apache-2.0",
"peerDependencies": {
"@electric-sql/pglite": "0.4.1"
@@ -964,7 +961,6 @@
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@lukeed/ms": {
@@ -1537,7 +1533,6 @@
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz",
"integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"c12": "3.3.4",
@@ -1556,7 +1551,6 @@
"version": "0.24.3",
"resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz",
"integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==",
"devOptional": true,
"license": "ISC",
"dependencies": {
"@electric-sql/pglite": "0.4.1",
@@ -1582,7 +1576,6 @@
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -1604,7 +1597,6 @@
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz",
"integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1618,14 +1610,12 @@
"version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz",
"integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz",
"integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.8.0"
@@ -1635,7 +1625,6 @@
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz",
"integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.8.0",
@@ -1647,7 +1636,6 @@
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz",
"integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.8.0"
@@ -1657,7 +1645,6 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz",
"integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.2.0"
@@ -1667,21 +1654,18 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz",
"integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/query-plan-executor": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz",
"integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/streams-local": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz",
"integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"ajv": "^8.12.0",
@@ -1698,7 +1682,6 @@
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz",
"integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@radix-ui/react-toggle": "1.1.10",
@@ -1782,14 +1765,12 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
@@ -1805,7 +1786,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
@@ -1829,7 +1809,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
@@ -1848,7 +1827,6 @@
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
@@ -1874,7 +1852,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
@@ -1894,7 +1871,6 @@
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
@@ -1913,7 +1889,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
@@ -1929,7 +1904,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/node": {
@@ -1962,7 +1936,6 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
@@ -2149,7 +2122,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
@@ -2199,7 +2171,6 @@
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz",
"integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==",
"devOptional": true,
"license": "MIT"
},
"node_modules/bintrees": {
@@ -2284,7 +2255,6 @@
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
"integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"chokidar": "^5.0.0",
@@ -2342,7 +2312,6 @@
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
@@ -2355,7 +2324,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
@@ -2431,7 +2399,6 @@
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/content-disposition": {
@@ -2499,7 +2466,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -2514,7 +2480,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT",
"peer": true
},
@@ -2540,7 +2505,6 @@
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
@@ -2550,7 +2514,6 @@
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
@@ -2566,7 +2529,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
@@ -2594,7 +2556,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/dotenv": {
@@ -2660,7 +2621,6 @@
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
"integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -2677,7 +2637,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -2706,7 +2665,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
@@ -2935,14 +2893,12 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/fast-check": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
"devOptional": true,
"funding": [
{
"type": "individual",
@@ -3150,7 +3106,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"devOptional": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
@@ -3290,7 +3245,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz",
"integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==",
"devOptional": true,
"license": "MIT"
},
"node_modules/get-proto": {
@@ -3323,7 +3277,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz",
"integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==",
"devOptional": true,
"license": "MIT",
"bin": {
"giget": "dist/cli.mjs"
@@ -3362,21 +3315,18 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"devOptional": true,
"license": "ISC"
},
"node_modules/grammex": {
"version": "3.1.12",
"resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz",
"integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/graphmatch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz",
"integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/graphql": {
@@ -3487,7 +3437,6 @@
"version": "4.12.16",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz",
"integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -3517,14 +3466,12 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -3633,14 +3580,12 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"devOptional": true,
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -3795,7 +3740,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
"devOptional": true,
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
@@ -3961,7 +3905,6 @@
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.1",
@@ -3982,7 +3925,6 @@
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
@@ -4047,7 +3989,6 @@
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
@@ -4132,7 +4073,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -4169,14 +4109,12 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
"devOptional": true,
"license": "MIT"
},
"node_modules/pg": {
@@ -4328,7 +4266,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.4",
@@ -4340,7 +4277,6 @@
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
"integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==",
"devOptional": true,
"license": "Unlicense",
"engines": {
"node": ">=12"
@@ -4393,7 +4329,6 @@
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz",
"integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -4466,7 +4401,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
@@ -4478,7 +4412,6 @@
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"devOptional": true,
"license": "ISC"
},
"node_modules/protobufjs": {
@@ -4542,7 +4475,6 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
"devOptional": true,
"funding": [
{
"type": "individual",
@@ -4627,7 +4559,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
"integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"defu": "^6.1.6",
@@ -4638,7 +4569,6 @@
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"engines": {
@@ -4649,7 +4579,6 @@
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
@@ -4679,7 +4608,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
@@ -4702,7 +4630,6 @@
"version": "2.33.4",
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
"integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==",
"devOptional": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/remeda"
@@ -4763,7 +4690,6 @@
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 4"
@@ -4857,14 +4783,12 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"devOptional": true,
"license": "MIT",
"peer": true
},
@@ -4926,8 +4850,7 @@
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==",
"devOptional": true
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"node_modules/serve-static": {
"version": "2.2.1",
@@ -4965,7 +4888,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -4978,7 +4900,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5060,7 +4981,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"devOptional": true,
"license": "ISC",
"engines": {
"node": ">=14"
@@ -5100,7 +5020,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -5119,7 +5038,6 @@
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/stream-shift": {
@@ -5300,7 +5218,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
"integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"typescript": ">=5"
@@ -5325,7 +5242,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"devOptional": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -5445,7 +5361,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz",
"integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"grammex": "^3.1.11",

View File

@@ -26,12 +26,12 @@
"fastify": "^5.8.5",
"graphql": "^16.13.2",
"mercurius": "^16.9.0",
"pg": "^8.20.0"
"pg": "^8.20.0",
"prisma": "^7.8.0"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@types/pg": "^8.20.0",
"prisma": "^7.8.0",
"tsx": "^4.21.0",
"typescript": "^6.0.3"
}

View File

@@ -0,0 +1,40 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "VoiceExperienceStatus" AS ENUM ('UPLOADED', 'TRANSCRIBING', 'TRANSCRIBED', 'ANALYZING', 'ANALYZED', 'FAILED');
-- CreateTable
CREATE TABLE "Place" (
"id" TEXT NOT NULL,
"googlePlaceId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"latitude" DOUBLE PRECISION NOT NULL,
"longitude" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Place_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VoiceExperience" (
"id" TEXT NOT NULL,
"placeId" TEXT NOT NULL,
"durationSeconds" INTEGER NOT NULL,
"audioObjectKey" TEXT NOT NULL,
"status" "VoiceExperienceStatus" NOT NULL DEFAULT 'UPLOADED',
"transcript" TEXT,
"analysis" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VoiceExperience_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Place_googlePlaceId_key" ON "Place"("googlePlaceId");
-- AddForeignKey
ALTER TABLE "VoiceExperience" ADD CONSTRAINT "VoiceExperience_placeId_fkey" FOREIGN KEY ("placeId") REFERENCES "Place"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,24 @@
-- AlterTable
ALTER TABLE "VoiceExperience" ADD COLUMN "userId" TEXT;
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"telegramId" TEXT NOT NULL,
"username" TEXT,
"firstName" TEXT,
"lastName" TEXT,
"photoUrl" TEXT,
"languageCode" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_telegramId_key" ON "User"("telegramId");
-- AddForeignKey
ALTER TABLE "VoiceExperience" ADD CONSTRAINT "VoiceExperience_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,37 @@
-- CreateTable
CREATE TABLE "UserSession" (
"id" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TelegramLoginRequest" (
"id" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"sessionToken" TEXT,
"userId" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TelegramLoginRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "UserSession_tokenHash_key" ON "UserSession"("tokenHash");
-- CreateIndex
CREATE UNIQUE INDEX "TelegramLoginRequest_tokenHash_key" ON "TelegramLoginRequest"("tokenHash");
-- AddForeignKey
ALTER TABLE "UserSession" ADD CONSTRAINT "UserSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TelegramLoginRequest" ADD CONSTRAINT "TelegramLoginRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Place" ADD COLUMN "googlePrimaryType" TEXT,
ADD COLUMN "googleTypes" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "TelegramLoginRequest" ADD COLUMN "telegramChatId" TEXT,
ADD COLUMN "telegramMessageId" INTEGER;

View File

@@ -0,0 +1,10 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isAdmin" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "VoiceExperience" ADD COLUMN "audioAccessToken" TEXT,
ADD COLUMN "audioContentBase64" TEXT,
ADD COLUMN "audioMimeType" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "VoiceExperience_audioAccessToken_key" ON "VoiceExperience"("audioAccessToken");

View File

@@ -22,17 +22,63 @@ model Place {
name String
latitude Float
longitude Float
googlePrimaryType String?
googleTypes String[] @default([])
experiences VoiceExperience[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id String @id @default(cuid())
telegramId String @unique
username String?
firstName String?
lastName String?
photoUrl String?
languageCode String?
isAdmin Boolean @default(false)
sessions UserSession[]
loginRequests TelegramLoginRequest[]
voiceExperiences VoiceExperience[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model UserSession {
id String @id @default(cuid())
tokenHash String @unique
userId String
user User @relation(fields: [userId], references: [id])
expiresAt DateTime
createdAt DateTime @default(now())
}
model TelegramLoginRequest {
id String @id @default(cuid())
tokenHash String @unique
status String @default("PENDING")
sessionToken String?
telegramChatId String?
telegramMessageId Int?
userId String?
user User? @relation(fields: [userId], references: [id])
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VoiceExperience {
id String @id @default(cuid())
placeId String
place Place @relation(fields: [placeId], references: [id])
userId String?
user User? @relation(fields: [userId], references: [id])
durationSeconds Int
audioObjectKey String
audioContentBase64 String?
audioMimeType String?
audioAccessToken String? @unique
status VoiceExperienceStatus @default(UPLOADED)
transcript String?
analysis Json?

View File

@@ -0,0 +1,377 @@
import { randomBytes } from 'node:crypto';
import { config } from '../config.js';
import { prisma } from '../prisma.js';
import {
sha256Hex,
upsertTelegramUser,
type TelegramUserPayload,
} from './telegram.js';
type TelegramUpdate = {
message?: {
chat: { id: number };
text?: string;
from?: TelegramBotUser;
};
};
type TelegramBotUser = {
id: number;
is_bot?: boolean;
first_name?: string;
last_name?: string;
username?: string;
language_code?: string;
};
type TelegramProfilePhotos = {
photos: { file_id: string }[][];
};
type TelegramFile = {
file_path: string;
};
type TelegramSentMessage = {
message_id: number;
};
const loginPrefix = 'login_';
const loginExpirationHandles = new Map<string, ReturnType<typeof setTimeout>>();
function randomToken() {
return randomBytes(32).toString('base64url');
}
function expiresIn(seconds: number) {
return new Date(Date.now() + seconds * 1000);
}
function botApiUrl(method: string) {
if (!config.telegramMiniAppBotToken) {
throw new Error('TELEGRAM_MINI_APP_BOT_TOKEN is required.');
}
return `https://api.telegram.org/bot${config.telegramMiniAppBotToken}/${method}`;
}
async function callTelegram<T>(method: string, body: Record<string, unknown>) {
const response = await fetch(botApiUrl(method), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`Telegram ${method} failed with ${response.status}.`);
}
const payload = (await response.json()) as {
ok: boolean;
description?: string;
result?: T;
};
if (!payload.ok) {
throw new Error(payload.description ?? `Telegram ${method} failed.`);
}
return payload.result as T;
}
function userPayload(from: TelegramBotUser): TelegramUserPayload {
return {
id: from.id,
username: from.username,
firstName: from.first_name,
lastName: from.last_name,
languageCode: from.language_code,
};
}
async function telegramUserPhotoUrl(userId: number) {
const photos = await callTelegram<TelegramProfilePhotos>('getUserProfilePhotos', {
user_id: userId,
limit: 1,
});
const variants = photos.photos[0];
const fileId = variants?.[variants.length - 1]?.file_id;
return fileId ? `${config.publicApiUrl}/telegram/photo/${fileId}` : undefined;
}
export async function fetchTelegramPhoto(fileId: string) {
const file = await callTelegram<TelegramFile>('getFile', { file_id: fileId });
const response = await fetch(
`https://api.telegram.org/file/bot${config.telegramMiniAppBotToken}/${file.file_path}`,
);
if (!response.ok) {
throw new Error(`Telegram file fetch failed with ${response.status}.`);
}
return {
contentType: file.file_path.toLowerCase().endsWith('.png')
? 'image/png'
: 'image/jpeg',
bytes: Buffer.from(await response.arrayBuffer()),
};
}
function formatRemaining(expiresAt: Date) {
const seconds = Math.max(0, Math.ceil((expiresAt.getTime() - Date.now()) / 1000));
const minutes = Math.floor(seconds / 60).toString();
const rest = (seconds % 60).toString().padStart(2, '0');
return `${minutes}:${rest}`;
}
function loginReplyMarkup(token: string, expiresAt: Date) {
return {
inline_keyboard: [
[
{
text: `${formatRemaining(expiresAt)} Войти`,
url: `${config.webAppUrl}?telegram_login=${encodeURIComponent(token)}`,
},
],
],
};
}
function loginMessageText(expiresAt: Date) {
return 'Чтобы войти на сайт map.craftee.vn, воспользуйтесь ссылкой ниже.';
}
async function sendLoginMessage(
chatId: number,
text: string,
token?: string,
expiresAt?: Date,
) {
const replyMarkup = token && expiresAt ? loginReplyMarkup(token, expiresAt) : undefined;
return callTelegram<TelegramSentMessage>('sendMessage', {
chat_id: chatId,
text,
reply_markup: replyMarkup,
});
}
async function editLoginMessage(
chatId: string,
messageId: number,
text: string,
) {
await callTelegram('editMessageText', {
chat_id: chatId,
message_id: messageId,
text,
reply_markup: { inline_keyboard: [] },
});
}
function scheduleLoginExpiration(
requestId: string,
chatId: string,
messageId: number,
expiresAt: Date,
) {
const existingHandle = loginExpirationHandles.get(requestId);
if (existingHandle) {
clearTimeout(existingHandle);
}
const handle = setTimeout(() => {
void (async () => {
const updated = await prisma.telegramLoginRequest.updateMany({
where: { id: requestId, status: 'CONFIRMED' },
data: { status: 'EXPIRED' },
});
loginExpirationHandles.delete(requestId);
if (updated.count > 0) {
await editLoginMessage(chatId, messageId, 'Ссылка входа устарела.');
}
})();
}, Math.max(0, expiresAt.getTime() - Date.now()));
loginExpirationHandles.set(requestId, handle);
}
function cancelLoginExpiration(requestId: string) {
const handle = loginExpirationHandles.get(requestId);
if (handle) {
clearTimeout(handle);
loginExpirationHandles.delete(requestId);
}
}
async function expireLoginMessageNow(
requestId: string,
chatId: string,
messageId: number,
) {
cancelLoginExpiration(requestId);
const updated = await prisma.telegramLoginRequest.updateMany({
where: { id: requestId, status: 'CONFIRMED' },
data: { status: 'EXPIRED' },
});
if (updated.count > 0) {
await editLoginMessage(chatId, messageId, 'Ссылка входа устарела.');
}
}
async function refreshExpiredLoginMessage(
requestId: string,
chatId: string,
messageId: number,
) {
const request = await prisma.telegramLoginRequest.findUnique({
where: { id: requestId },
select: { status: true },
});
if (request?.status === 'CONFIRMED') {
await expireLoginMessageNow(requestId, chatId, messageId);
}
}
export async function createTelegramBotLogin() {
const token = randomToken();
const expiresAt = expiresIn(config.botLoginMaxAgeSeconds);
await prisma.telegramLoginRequest.create({
data: {
tokenHash: sha256Hex(token),
expiresAt,
},
});
return {
token,
botUrl: `https://t.me/${config.telegramBotUsername}?start=${loginPrefix}${token}`,
expiresAt: expiresAt.toISOString(),
};
}
export async function completeTelegramBotLogin(token: string) {
const request = await prisma.telegramLoginRequest.findUnique({
where: { tokenHash: sha256Hex(token) },
include: { user: true },
});
if (!request) {
throw new Error('Telegram login token is invalid.');
}
if (request.expiresAt <= new Date()) {
if (request.telegramChatId && request.telegramMessageId) {
await refreshExpiredLoginMessage(
request.id,
request.telegramChatId,
request.telegramMessageId,
);
} else {
await prisma.telegramLoginRequest.update({
where: { id: request.id },
data: { status: 'EXPIRED' },
});
}
throw new Error('Telegram login token is expired.');
}
if (request.status !== 'CONFIRMED' || !request.sessionToken || !request.user) {
throw new Error('Telegram login is not confirmed.');
}
cancelLoginExpiration(request.id);
await prisma.telegramLoginRequest.update({
where: { id: request.id },
data: { status: 'USED' },
});
if (request.telegramChatId && request.telegramMessageId) {
await editLoginMessage(
request.telegramChatId,
request.telegramMessageId,
'Вход выполнен.\nМожно вернуться в MapFlow.',
);
}
return {
sessionToken: request.sessionToken,
user: request.user,
};
}
export async function handleTelegramBotWebhook(
update: TelegramUpdate,
secretToken?: string,
) {
if (config.telegramWebhookSecret && secretToken !== config.telegramWebhookSecret) {
throw new Error('Telegram webhook secret is invalid.');
}
const message = update.message;
const text = message?.text;
const from = message?.from;
const chatId = message?.chat.id;
if (!message || !text || !from || !chatId) {
return;
}
const [command, payload] = text.split(' ');
if (command !== '/start' || !payload?.startsWith(loginPrefix)) {
await sendLoginMessage(chatId, 'Начните вход с сайта MapFlow.');
return;
}
const token = payload.slice(loginPrefix.length);
const request = await prisma.telegramLoginRequest.findUnique({
where: { tokenHash: sha256Hex(token) },
});
if (!request || request.status !== 'PENDING' || request.expiresAt <= new Date()) {
await sendLoginMessage(
chatId,
'Ссылка входа устарела.\nВернитесь на сайт и начните вход заново.',
);
return;
}
const user = await upsertTelegramUser({
...userPayload(from),
photoUrl: await telegramUserPhotoUrl(from.id),
});
const sessionToken = randomToken();
await prisma.userSession.create({
data: {
tokenHash: sha256Hex(sessionToken),
userId: user.id,
expiresAt: expiresIn(config.sessionMaxAgeSeconds),
},
});
const sentMessage = await sendLoginMessage(
chatId,
loginMessageText(request.expiresAt),
token,
request.expiresAt,
);
await prisma.telegramLoginRequest.update({
where: { id: request.id },
data: {
status: 'CONFIRMED',
sessionToken,
telegramChatId: chatId.toString(),
telegramMessageId: sentMessage.message_id,
userId: user.id,
},
});
scheduleLoginExpiration(
request.id,
chatId.toString(),
sentMessage.message_id,
request.expiresAt,
);
}

207
src/auth/telegram.ts Normal file
View File

@@ -0,0 +1,207 @@
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
import { config } from '../config.js';
import { prisma } from '../prisma.js';
type TelegramInitDataUser = {
id: number;
username?: string;
first_name?: string;
last_name?: string;
photo_url?: string;
language_code?: string;
};
export type TelegramLoginData = {
id: number;
first_name?: string;
last_name?: string;
username?: string;
photo_url?: string;
auth_date: number;
hash: string;
};
export type TelegramUserPayload = {
id: number;
username?: string;
firstName?: string;
lastName?: string;
photoUrl?: string;
languageCode?: string;
};
type TelegramAuthData = {
telegramInitData?: string;
telegramLoginData?: string;
mapflowSessionToken?: string;
};
function hmacSha256(key: string | Buffer, value: string) {
return createHmac('sha256', key).update(value).digest();
}
export function sha256(value: string) {
return createHash('sha256').update(value).digest();
}
export function sha256Hex(value: string) {
return createHash('sha256').update(value).digest('hex');
}
function assertValidHash(receivedHash: string, expectedHash: Buffer, dataType: string) {
const received = Buffer.from(receivedHash, 'hex');
if (received.length !== expectedHash.length) {
throw new Error(`Telegram ${dataType} hash is invalid.`);
}
if (!timingSafeEqual(received, expectedHash)) {
throw new Error(`Telegram ${dataType} hash is invalid.`);
}
}
function assertConfiguredBotToken() {
if (!config.telegramMiniAppBotToken) {
throw new Error('TELEGRAM_MINI_APP_BOT_TOKEN is required.');
}
}
function assertFreshAuthDate(authDate: number, dataType: string) {
if (!Number.isFinite(authDate)) {
throw new Error(`Telegram ${dataType} auth_date is required.`);
}
const ageSeconds = Math.floor(Date.now() / 1000) - authDate;
if (ageSeconds > config.telegramAuthMaxAgeSeconds) {
throw new Error(`Telegram ${dataType} is expired.`);
}
}
function parseTelegramInitData(initData: string): TelegramUserPayload {
assertConfiguredBotToken();
const params = new URLSearchParams(initData);
const receivedHash = params.get('hash');
if (!receivedHash) {
throw new Error('Telegram init data hash is required.');
}
assertFreshAuthDate(Number(params.get('auth_date')), 'init data');
const dataCheckString = [...params.entries()]
.filter(([key]) => key !== 'hash')
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${key}=${value}`)
.join('\n');
const secretKey = hmacSha256('WebAppData', config.telegramMiniAppBotToken);
const expectedHash = hmacSha256(secretKey, dataCheckString);
assertValidHash(receivedHash, expectedHash, 'init data');
const rawUser = params.get('user');
if (!rawUser) {
throw new Error('Telegram user is required.');
}
const user = JSON.parse(rawUser) as TelegramInitDataUser;
return {
id: user.id,
username: user.username,
firstName: user.first_name,
lastName: user.last_name,
photoUrl: user.photo_url,
languageCode: user.language_code,
};
}
function parseTelegramLoginData(loginData: TelegramLoginData): TelegramUserPayload {
assertConfiguredBotToken();
assertFreshAuthDate(Number(loginData.auth_date), 'login data');
const dataCheckString = Object.entries(loginData)
.filter(([key, value]) => key !== 'hash' && value !== undefined && value !== null)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${key}=${value}`)
.join('\n');
const expectedHash = hmacSha256(sha256(config.telegramMiniAppBotToken), dataCheckString);
assertValidHash(loginData.hash, expectedHash, 'login data');
return {
id: Number(loginData.id),
username: loginData.username,
firstName: loginData.first_name,
lastName: loginData.last_name,
photoUrl: loginData.photo_url,
};
}
function parseTelegramLoginJson(loginData: string) {
return parseTelegramLoginData(JSON.parse(loginData) as TelegramLoginData);
}
export async function upsertTelegramUser(user: TelegramUserPayload) {
return prisma.user.upsert({
where: { telegramId: String(user.id) },
create: {
telegramId: String(user.id),
username: user.username,
firstName: user.firstName,
lastName: user.lastName,
photoUrl: user.photoUrl,
languageCode: user.languageCode,
},
update: {
username: user.username,
firstName: user.firstName,
lastName: user.lastName,
photoUrl: user.photoUrl,
languageCode: user.languageCode,
},
});
}
export async function getOrCreateTelegramUser(initData: string) {
return upsertTelegramUser(parseTelegramInitData(initData));
}
export async function getOrCreateTelegramLoginUser(loginData: TelegramLoginData) {
return upsertTelegramUser(parseTelegramLoginData(loginData));
}
export async function getUserBySessionToken(sessionToken: string) {
const session = await prisma.userSession.findUnique({
where: { tokenHash: sha256Hex(sessionToken) },
include: { user: true },
});
if (!session || session.expiresAt <= new Date()) {
throw new Error('Telegram authorization is required.');
}
return session.user;
}
export async function requireTelegramUser(authData: TelegramAuthData) {
if (authData.mapflowSessionToken) {
return getUserBySessionToken(authData.mapflowSessionToken);
}
if (authData.telegramInitData) {
return upsertTelegramUser(parseTelegramInitData(authData.telegramInitData));
}
if (authData.telegramLoginData) {
return upsertTelegramUser(parseTelegramLoginJson(authData.telegramLoginData));
}
throw new Error('Telegram authorization is required.');
}
export async function requireAdminTelegramUser(authData: TelegramAuthData) {
const user = await requireTelegramUser(authData);
if (!user.isAdmin) {
throw new Error('MapFlow admin access is required.');
}
return user;
}

View File

@@ -1,8 +1,23 @@
import 'dotenv/config';
import { loadVaultEnvironment } from './vault/env.js';
await loadVaultEnvironment();
export const config = {
host: process.env.HOST ?? '0.0.0.0',
port: Number(process.env.PORT ?? '4000'),
databaseUrl: process.env.DATABASE_URL ?? '',
hatchetToken: process.env.HATCHET_CLIENT_TOKEN ?? '',
telegramMiniAppBotToken: process.env.TELEGRAM_MINI_APP_BOT_TOKEN ?? '',
telegramBotUsername: process.env.TELEGRAM_BOT_USERNAME ?? 'carfteebot',
telegramWebhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET ?? '',
googlePlacesApiKey: process.env.GOOGLE_PLACES_API_KEY ?? '',
webAppUrl: process.env.WEB_APP_URL ?? 'https://map.craftee.vn',
publicApiUrl: process.env.PUBLIC_API_URL ?? 'https://api.map.craftee.vn',
telegramAuthMaxAgeSeconds: Number(
process.env.TELEGRAM_AUTH_MAX_AGE_SECONDS ?? '86400',
),
sessionMaxAgeSeconds: Number(process.env.SESSION_MAX_AGE_SECONDS ?? '2592000'),
botLoginMaxAgeSeconds: Number(process.env.BOT_LOGIN_MAX_AGE_SECONDS ?? '300'),
};

26
src/entrypoint.ts Normal file
View File

@@ -0,0 +1,26 @@
import { spawn } from 'node:child_process';
import { loadVaultEnvironment } from './vault/env.js';
function run(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
env: process.env,
stdio: 'inherit',
});
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${command} ${args.join(' ')} exited with ${code}`));
});
});
}
await loadVaultEnvironment();
await run('npm', ['run', 'prisma:migrate:deploy']);
await import('./server.js');

File diff suppressed because one or more lines are too long

View File

@@ -126,6 +126,42 @@ exports.Prisma.PlaceScalarFieldEnum = {
name: 'name',
latitude: 'latitude',
longitude: 'longitude',
googlePrimaryType: 'googlePrimaryType',
googleTypes: 'googleTypes',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.UserScalarFieldEnum = {
id: 'id',
telegramId: 'telegramId',
username: 'username',
firstName: 'firstName',
lastName: 'lastName',
photoUrl: 'photoUrl',
languageCode: 'languageCode',
isAdmin: 'isAdmin',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.UserSessionScalarFieldEnum = {
id: 'id',
tokenHash: 'tokenHash',
userId: 'userId',
expiresAt: 'expiresAt',
createdAt: 'createdAt'
};
exports.Prisma.TelegramLoginRequestScalarFieldEnum = {
id: 'id',
tokenHash: 'tokenHash',
status: 'status',
sessionToken: 'sessionToken',
telegramChatId: 'telegramChatId',
telegramMessageId: 'telegramMessageId',
userId: 'userId',
expiresAt: 'expiresAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -133,8 +169,12 @@ exports.Prisma.PlaceScalarFieldEnum = {
exports.Prisma.VoiceExperienceScalarFieldEnum = {
id: 'id',
placeId: 'placeId',
userId: 'userId',
durationSeconds: 'durationSeconds',
audioObjectKey: 'audioObjectKey',
audioContentBase64: 'audioContentBase64',
audioMimeType: 'audioMimeType',
audioAccessToken: 'audioAccessToken',
status: 'status',
transcript: 'transcript',
analysis: 'analysis',
@@ -157,16 +197,16 @@ exports.Prisma.QueryMode = {
insensitive: 'insensitive'
};
exports.Prisma.NullsOrder = {
first: 'first',
last: 'last'
};
exports.Prisma.JsonNullValueFilter = {
DbNull: Prisma.DbNull,
JsonNull: Prisma.JsonNull,
AnyNull: Prisma.AnyNull
};
exports.Prisma.NullsOrder = {
first: 'first',
last: 'last'
};
exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
UPLOADED: 'UPLOADED',
TRANSCRIBING: 'TRANSCRIBING',
@@ -178,6 +218,9 @@ exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
exports.Prisma.ModelName = {
Place: 'Place',
User: 'User',
UserSession: 'UserSession',
TelegramLoginRequest: 'TelegramLoginRequest',
VoiceExperience: 'VoiceExperience'
};

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{
"name": "prisma-client-5316d8e66293a4954be6e26169692366997bf25fe9186e98c87e62eed3dc691e",
"name": "prisma-client-af7d1a24a80c81a94e157e22f4f12e91cca6099374f7a2346452663b0168688a",
"main": "index.js",
"types": "index.d.ts",
"browser": "default.js",

View File

@@ -22,17 +22,63 @@ model Place {
name String
latitude Float
longitude Float
googlePrimaryType String?
googleTypes String[] @default([])
experiences VoiceExperience[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id String @id @default(cuid())
telegramId String @unique
username String?
firstName String?
lastName String?
photoUrl String?
languageCode String?
isAdmin Boolean @default(false)
sessions UserSession[]
loginRequests TelegramLoginRequest[]
voiceExperiences VoiceExperience[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model UserSession {
id String @id @default(cuid())
tokenHash String @unique
userId String
user User @relation(fields: [userId], references: [id])
expiresAt DateTime
createdAt DateTime @default(now())
}
model TelegramLoginRequest {
id String @id @default(cuid())
tokenHash String @unique
status String @default("PENDING")
sessionToken String?
telegramChatId String?
telegramMessageId Int?
userId String?
user User? @relation(fields: [userId], references: [id])
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VoiceExperience {
id String @id @default(cuid())
placeId String
place Place @relation(fields: [placeId], references: [id])
userId String?
user User? @relation(fields: [userId], references: [id])
durationSeconds Int
audioObjectKey String
audioContentBase64 String?
audioMimeType String?
audioAccessToken String? @unique
status VoiceExperienceStatus @default(UPLOADED)
transcript String?
analysis Json?

234
src/graphql/places.ts Normal file
View File

@@ -0,0 +1,234 @@
import { prisma } from '../prisma.js';
import { config } from '../config.js';
import type { Prisma } from '../generated/prisma/client.js';
const googleNearbySearchUrl =
'https://places.googleapis.com/v1/places:searchNearby';
const maxNearbyRadiusMeters = 500;
const nearbyPlaceTypes = [
'restaurant',
'cafe',
'bar',
'bakery',
'meal_takeaway',
'meal_delivery',
];
type NearbyPlacesInput = {
latitude: number;
longitude: number;
radiusMeters: number;
};
type GoogleNearbyPlace = {
id?: string;
displayName?: {
text?: string;
};
primaryType?: string;
types?: string[];
location?: {
latitude?: number;
longitude?: number;
};
};
type GoogleNearbyResponse = {
places?: GoogleNearbyPlace[];
};
type PersistableGooglePlace = {
googlePlaceId: string;
name: string;
latitude: number;
longitude: number;
googlePrimaryType: string | null;
googleTypes: string[];
};
type PlaceWithRecentExperiences = Prisma.PlaceGetPayload<{
include: {
experiences: {
include: {
user: true;
};
};
};
}>;
function serializeVoiceExperience(
experience: Awaited<ReturnType<typeof prisma.voiceExperience.findMany>>[number],
) {
return {
...experience,
createdAt: experience.createdAt.toISOString(),
};
}
function serializePlace(place: PlaceWithRecentExperiences) {
return {
...place,
experiences: place.experiences.map(serializeVoiceExperience),
};
}
export async function listPlaces() {
const places = await prisma.place.findMany({
where: {
experiences: { some: {} },
},
include: {
experiences: {
orderBy: { createdAt: 'desc' },
take: 10,
include: { user: true },
},
},
orderBy: { updatedAt: 'desc' },
});
return places.map(serializePlace);
}
export async function listNearbyPlaces(input: NearbyPlacesInput) {
if (!Number.isFinite(input.latitude) || !Number.isFinite(input.longitude)) {
throw new Error('Nearby place search requires a valid coordinate.');
}
if (
!Number.isInteger(input.radiusMeters) ||
input.radiusMeters <= 0 ||
input.radiusMeters > maxNearbyRadiusMeters
) {
throw new Error(
`Nearby place radius must be from 1 to ${maxNearbyRadiusMeters} meters.`,
);
}
if (config.googlePlacesApiKey === '') {
throw new Error('GOOGLE_PLACES_API_KEY is required for nearby place search.');
}
const response = await fetch(googleNearbySearchUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': config.googlePlacesApiKey,
'X-Goog-FieldMask': [
'places.id',
'places.displayName',
'places.location',
'places.primaryType',
'places.types',
].join(','),
},
body: JSON.stringify({
includedTypes: nearbyPlaceTypes,
maxResultCount: 20,
rankPreference: 'DISTANCE',
locationRestriction: {
circle: {
center: {
latitude: input.latitude,
longitude: input.longitude,
},
radius: input.radiusMeters,
},
},
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Google Nearby Search failed: ${response.status} ${body}`);
}
const payload = (await response.json()) as GoogleNearbyResponse;
return parseGoogleNearbyPlaces(input, payload).map((place) =>
serializeGoogleNearbyPlace(place),
);
}
export async function listVoiceExperiences() {
const experiences = await prisma.voiceExperience.findMany({
include: { place: true, user: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
return experiences.map(serializeVoiceExperience);
}
function distanceMeters(
fromLatitude: number,
fromLongitude: number,
toLatitude: number,
toLongitude: number,
) {
const earthRadiusMeters = 6371000;
const fromLat = degreesToRadians(fromLatitude);
const toLat = degreesToRadians(toLatitude);
const deltaLat = degreesToRadians(toLatitude - fromLatitude);
const deltaLon = degreesToRadians(toLongitude - fromLongitude);
const a =
Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
Math.cos(fromLat) *
Math.cos(toLat) *
Math.sin(deltaLon / 2) *
Math.sin(deltaLon / 2);
return earthRadiusMeters * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function degreesToRadians(value: number) {
return (value * Math.PI) / 180;
}
function parseGoogleNearbyPlaces(
input: NearbyPlacesInput,
payload: GoogleNearbyResponse,
) {
const placesByGoogleId = new Map<string, PersistableGooglePlace>();
for (const place of payload.places ?? []) {
const googlePlaceId = place.id;
const name = place.displayName?.text;
const latitude = place.location?.latitude;
const longitude = place.location?.longitude;
const googleTypes = place.types ?? [];
if (
!googlePlaceId ||
!name ||
latitude === undefined ||
longitude === undefined
) {
continue;
}
if (
distanceMeters(input.latitude, input.longitude, latitude, longitude) >
input.radiusMeters
) {
continue;
}
placesByGoogleId.set(googlePlaceId, {
googlePlaceId,
name,
latitude,
longitude,
googlePrimaryType: place.primaryType ?? null,
googleTypes,
});
}
return [...placesByGoogleId.values()];
}
function serializeGoogleNearbyPlace(place: PersistableGooglePlace) {
return {
id: place.googlePlaceId,
googlePlaceId: place.googlePlaceId,
name: place.name,
latitude: place.latitude,
longitude: place.longitude,
googlePrimaryType: place.googlePrimaryType,
googleTypes: place.googleTypes,
experiences: [],
};
}

View File

@@ -1,6 +1,28 @@
import { GraphQLJSONObject } from './scalars.js';
import {
getOrCreateTelegramLoginUser,
getOrCreateTelegramUser,
requireAdminTelegramUser,
requireTelegramUser,
type TelegramLoginData,
} from '../auth/telegram.js';
import {
completeTelegramBotLogin,
createTelegramBotLogin,
} from '../auth/telegram-bot-login.js';
import {
listNearbyPlaces,
listPlaces,
listVoiceExperiences,
} from './places.js';
import { createVoiceExperience } from './voice-experiences.js';
export type GraphqlContext = {
telegramInitData?: string;
telegramLoginData?: string;
mapflowSessionToken?: string;
};
export const schema = /* GraphQL */ `
scalar JSON
@@ -19,11 +41,26 @@ export const schema = /* GraphQL */ `
name: String!
latitude: Float!
longitude: Float!
googlePrimaryType: String
googleTypes: [String!]!
experiences: [VoiceExperience!]!
}
type User {
id: ID!
telegramId: String!
username: String
firstName: String
lastName: String
photoUrl: String
languageCode: String
isAdmin: Boolean!
}
type VoiceExperience {
id: ID!
place: Place!
user: User
status: VoiceExperienceStatus!
durationSeconds: Int!
audioObjectKey: String!
@@ -39,13 +76,58 @@ export const schema = /* GraphQL */ `
longitude: Float!
durationSeconds: Int!
audioObjectKey: String!
audioContentBase64: String!
audioMimeType: String!
}
input NearbyPlacesInput {
latitude: Float!
longitude: Float!
radiusMeters: Int!
}
input AuthenticateTelegramInput {
initData: String!
}
input AuthenticateTelegramLoginInput {
id: Float!
first_name: String
last_name: String
username: String
photo_url: String
auth_date: Float!
hash: String!
}
type AuthPayload {
user: User!
}
type TelegramBotLoginPayload {
token: String!
botUrl: String!
expiresAt: String!
}
type TelegramBotLoginSession {
sessionToken: String!
user: User!
}
type Query {
health: String!
me: User!
places: [Place!]!
nearbyPlaces(input: NearbyPlacesInput!): [Place!]!
voiceExperiences: [VoiceExperience!]!
}
type Mutation {
startTelegramBotLogin: TelegramBotLoginPayload!
completeTelegramBotLogin(token: String!): TelegramBotLoginSession!
authenticateTelegram(input: AuthenticateTelegramInput!): AuthPayload!
authenticateTelegramLogin(input: AuthenticateTelegramLoginInput!): AuthPayload!
createVoiceExperience(input: CreateVoiceExperienceInput!): VoiceExperience!
}
`;
@@ -54,11 +136,50 @@ export const resolvers = {
JSON: GraphQLJSONObject,
Query: {
health: () => 'ok',
me: async (_: unknown, __: unknown, context: unknown) => {
const graphqlContext = context as GraphqlContext;
return requireTelegramUser(graphqlContext);
},
places: async (_: unknown, __: unknown, context: unknown) => {
const graphqlContext = context as GraphqlContext;
await requireTelegramUser(graphqlContext);
return listPlaces();
},
nearbyPlaces: async (
_: unknown,
args: { input: Parameters<typeof listNearbyPlaces>[0] },
context: unknown,
) => {
const graphqlContext = context as GraphqlContext;
await requireTelegramUser(graphqlContext);
return listNearbyPlaces(args.input);
},
voiceExperiences: async (_: unknown, __: unknown, context: unknown) => {
const graphqlContext = context as GraphqlContext;
await requireAdminTelegramUser(graphqlContext);
return listVoiceExperiences();
},
},
Mutation: {
startTelegramBotLogin: async () => createTelegramBotLogin(),
completeTelegramBotLogin: async (_: unknown, args: { token: string }) =>
completeTelegramBotLogin(args.token),
authenticateTelegram: async (
_: unknown,
args: { input: { initData: string } },
) => ({ user: await getOrCreateTelegramUser(args.input.initData) }),
authenticateTelegramLogin: async (
_: unknown,
args: { input: TelegramLoginData },
) => ({ user: await getOrCreateTelegramLoginUser(args.input) }),
createVoiceExperience: async (
_: unknown,
args: { input: Parameters<typeof createVoiceExperience>[0] },
) => createVoiceExperience(args.input),
context: unknown,
) => {
const graphqlContext = context as GraphqlContext;
const user = await requireTelegramUser(graphqlContext);
return createVoiceExperience(args.input, user.id);
},
},
};

View File

@@ -1,6 +1,10 @@
import { randomBytes } from 'node:crypto';
import { enqueueVoiceExperience } from '../hatchet/enqueue-voice-experience.js';
import { prisma } from '../prisma.js';
const forbiddenGeneratedPlaceIdPrefix = 'manual-';
export type CreateVoiceExperienceInput = {
googlePlaceId: string;
googleName: string;
@@ -8,23 +12,46 @@ export type CreateVoiceExperienceInput = {
longitude: number;
durationSeconds: number;
audioObjectKey: string;
audioContentBase64: string;
audioMimeType: string;
};
export async function createVoiceExperience(input: CreateVoiceExperienceInput) {
if (input.durationSeconds < 60) {
throw new Error('Voice experience must be at least 60 seconds.');
function randomAudioAccessToken() {
return randomBytes(32).toString('base64url');
}
export async function createVoiceExperience(
input: CreateVoiceExperienceInput,
userId: string,
) {
const googlePlaceId = input.googlePlaceId.trim();
const googleName = input.googleName.trim();
if (googlePlaceId === '') {
throw new Error('Google place id is required.');
}
if (googlePlaceId.startsWith(forbiddenGeneratedPlaceIdPrefix)) {
throw new Error('Voice experience must be linked to a Google place.');
}
if (googleName === '') {
throw new Error('Google place name is required.');
}
if (input.audioContentBase64.trim() === '') {
throw new Error('Voice experience audio is required.');
}
if (input.audioMimeType.trim() === '') {
throw new Error('Voice experience audio MIME type is required.');
}
const place = await prisma.place.upsert({
where: { googlePlaceId: input.googlePlaceId },
where: { googlePlaceId },
create: {
googlePlaceId: input.googlePlaceId,
name: input.googleName,
googlePlaceId,
name: googleName,
latitude: input.latitude,
longitude: input.longitude,
},
update: {
name: input.googleName,
name: googleName,
latitude: input.latitude,
longitude: input.longitude,
},
@@ -33,11 +60,15 @@ export async function createVoiceExperience(input: CreateVoiceExperienceInput) {
const experience = await prisma.voiceExperience.create({
data: {
placeId: place.id,
userId,
durationSeconds: input.durationSeconds,
audioObjectKey: input.audioObjectKey,
audioContentBase64: input.audioContentBase64,
audioMimeType: input.audioMimeType,
audioAccessToken: randomAudioAccessToken(),
status: 'UPLOADED',
},
include: { place: true },
include: { place: true, user: true },
});
await enqueueVoiceExperience({ experienceId: experience.id });

View File

@@ -4,17 +4,81 @@ import mercurius from 'mercurius';
import { config } from './config.js';
import { prisma } from './prisma.js';
import { resolvers, schema } from './graphql/schema.js';
import {
fetchTelegramPhoto,
handleTelegramBotWebhook,
} from './auth/telegram-bot-login.js';
const app = Fastify({ logger: true });
const app = Fastify({
logger: true,
bodyLimit: 25 * 1024 * 1024,
});
app.register(mercurius, {
schema,
resolvers,
graphiql: true,
context: async (request) => {
const initDataHeader = request.headers['x-telegram-init-data'];
const loginDataHeader = request.headers['x-telegram-login-data'];
const sessionTokenHeader = request.headers['x-mapflow-session-token'];
return {
telegramInitData: Array.isArray(initDataHeader) ? initDataHeader[0] : initDataHeader,
telegramLoginData: Array.isArray(loginDataHeader) ? loginDataHeader[0] : loginDataHeader,
mapflowSessionToken: Array.isArray(sessionTokenHeader) ? sessionTokenHeader[0] : sessionTokenHeader,
};
},
});
app.get('/health', async () => ({ ok: true }));
app.post('/telegram/webhook', async (request, reply) => {
const secretHeader = request.headers['x-telegram-bot-api-secret-token'];
const secretToken = Array.isArray(secretHeader) ? secretHeader[0] : secretHeader;
await handleTelegramBotWebhook(request.body as never, secretToken);
return reply.send({ ok: true });
});
app.get('/telegram/photo/:fileId', async (request, reply) => {
const params = request.params as { fileId: string };
const photo = await fetchTelegramPhoto(params.fileId);
reply.header('content-type', photo.contentType);
reply.header('cache-control', 'public, max-age=86400');
reply.header('access-control-allow-origin', '*');
reply.header('cross-origin-resource-policy', 'cross-origin');
return reply.send(photo.bytes);
});
app.get('/audio/voice-experiences/:experienceId', async (request, reply) => {
const params = request.params as { experienceId: string };
const query = request.query as { token?: string };
if (!query.token) {
return reply.code(401).send({ error: 'Audio token is required.' });
}
const experience = await prisma.voiceExperience.findUnique({
where: { id: params.experienceId },
select: {
audioAccessToken: true,
audioContentBase64: true,
audioMimeType: true,
},
});
if (
!experience ||
experience.audioAccessToken !== query.token ||
!experience.audioContentBase64 ||
!experience.audioMimeType
) {
return reply.code(404).send({ error: 'Audio was not found.' });
}
reply.header('content-type', experience.audioMimeType);
reply.header('cache-control', 'no-store');
return reply.send(Buffer.from(experience.audioContentBase64, 'base64'));
});
app.addHook('onClose', async () => {
await prisma.$disconnect();
});

65
src/vault/env.ts Normal file
View File

@@ -0,0 +1,65 @@
type VaultConfig = {
address: string;
token: string;
mount: string;
sharedPath: string;
projectPath: string;
};
function requireEnv(name: string) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required when VAULT_ENABLED=true.`);
}
return value;
}
function vaultConfig(): VaultConfig {
return {
address: requireEnv('VAULT_ADDR').replace(/\/$/, ''),
token: requireEnv('VAULT_TOKEN'),
mount: requireEnv('VAULT_KV_MOUNT'),
sharedPath: requireEnv('VAULT_SHARED_PATH'),
projectPath: requireEnv('VAULT_PROJECT_PATH'),
};
}
async function readVaultPath(config: VaultConfig, path: string) {
const response = await fetch(
`${config.address}/v1/${config.mount}/data/${path}`,
{ headers: { 'X-Vault-Token': config.token } },
);
if (!response.ok) {
throw new Error(`Vault read failed for ${path}: ${response.status}.`);
}
const payload = (await response.json()) as {
data?: { data?: Record<string, unknown> };
};
const data = payload.data?.data;
if (!data) {
throw new Error(`Vault path ${path} has no KV v2 data.`);
}
return data;
}
function applyEnvironment(values: Record<string, unknown>) {
for (const [key, value] of Object.entries(values)) {
if (typeof value !== 'string') {
throw new Error(`Vault value ${key} must be a string.`);
}
process.env[key] = value;
}
}
export async function loadVaultEnvironment() {
if (process.env.VAULT_ENABLED !== 'true') {
return;
}
const config = vaultConfig();
applyEnvironment(await readVaultPath(config, config.sharedPath));
applyEnvironment(await readVaultPath(config, config.projectPath));
}