Shoraky commited on
Commit
ce4b4fb
·
1 Parent(s): d2574c9

Deploy combined Node and Python backend in one Docker Space

Browse files
Files changed (16) hide show
  1. .dockerignore +11 -5
  2. .env.example +11 -2
  3. .gitignore +6 -0
  4. Dockerfile +19 -7
  5. README.md +95 -10
  6. app.py +9 -5
  7. package-lock.json +1749 -0
  8. package.json +30 -0
  9. src/analysis.ts +84 -0
  10. src/auth.ts +54 -0
  11. src/config.ts +42 -0
  12. src/server.ts +494 -0
  13. src/subscriptions.ts +37 -0
  14. src/supabase.ts +53 -0
  15. start.py +55 -0
  16. tsconfig.json +14 -0
.dockerignore CHANGED
@@ -1,5 +1,11 @@
1
- .env
2
- .env.*
3
- __pycache__/
4
- .venv/
5
- test_app.py
 
 
 
 
 
 
 
1
+ # Include only Docker build inputs; credentials and local artifacts stay out.
2
+ *
3
+ !Dockerfile
4
+ !package.json
5
+ !package-lock.json
6
+ !tsconfig.json
7
+ !src/
8
+ !src/**
9
+ !requirements.txt
10
+ !app.py
11
+ !start.py
.env.example CHANGED
@@ -1,5 +1,14 @@
 
 
 
 
 
 
 
 
 
1
  CORE_REPO_ID=your-account/sporalize-core
2
  STORAGE_REPO_ID=your-account/sporalize-application-storage
3
  HF_TOKEN=
4
- ANALYSIS_PROXY_SECRET=
5
- BACKEND_URL=http://localhost:4000
 
1
+ PORT=4000
2
+ TRUST_PROXY_HOPS=1
3
+ NODE_ENV=development
4
+ FRONTEND_URL=http://localhost:8080
5
+ BACKEND_URL=http://localhost:4000
6
+ ANALYSIS_PROXY_SECRET=replace-with-a-random-secret-of-at-least-32-characters
7
+ SUPABASE_URL=https://your-project.supabase.co
8
+ SUPABASE_ANON_KEY=your-publishable-or-anon-key
9
+ SUPABASE_SERVICE_ROLE_KEY=your-service-role-or-secret-key
10
  CORE_REPO_ID=your-account/sporalize-core
11
  STORAGE_REPO_ID=your-account/sporalize-application-storage
12
  HF_TOKEN=
13
+ # CORE_REVISION=optional-pinned-revision
14
+ # Internal Node/AI URLs are set automatically by start.py.
.gitignore CHANGED
@@ -1,3 +1,9 @@
1
  .env
 
 
 
 
2
  __pycache__/
 
3
  .venv/
 
 
1
  .env
2
+ .env.*
3
+ !.env.example
4
+ node_modules/
5
+ dist/
6
  __pycache__/
7
+ *.py[cod]
8
  .venv/
9
+ *.log
Dockerfile CHANGED
@@ -1,11 +1,23 @@
1
- FROM python:3.10-slim
2
- ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 HF_HOME=/home/appuser/.cache/huggingface
3
- RUN apt-get update && apt-get install -y --no-install-recommends libglib2.0-0 libgl1 && rm -rf /var/lib/apt/lists/*
4
- RUN useradd --create-home --uid 1000 appuser
5
  WORKDIR /app
6
- COPY requirements.txt .
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  RUN pip install --no-cache-dir -r requirements.txt
8
- COPY --chown=appuser:appuser app.py .
 
 
9
  USER appuser
10
  EXPOSE 7860
11
- CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
 
1
+ FROM node:22-bookworm-slim AS node-build
 
 
 
2
  WORKDIR /app
3
+ COPY package*.json ./
4
+ RUN npm ci
5
+ COPY tsconfig.json ./
6
+ COPY src ./src
7
+ RUN npm run build && npm prune --omit=dev
8
+
9
+ FROM python:3.10-slim-bookworm
10
+ ENV NODE_ENV=production PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 HF_HOME=/home/appuser/.cache/huggingface PORT=7860
11
+ RUN apt-get update && apt-get install -y --no-install-recommends libglib2.0-0 libgl1 \
12
+ && rm -rf /var/lib/apt/lists/* \
13
+ && useradd --create-home --uid 1000 appuser
14
+ COPY --from=node-build /usr/local/bin/node /usr/local/bin/node
15
+ WORKDIR /app
16
+ COPY requirements.txt ./
17
  RUN pip install --no-cache-dir -r requirements.txt
18
+ COPY --from=node-build --chown=appuser:appuser /app/dist ./dist
19
+ COPY --from=node-build --chown=appuser:appuser /app/node_modules ./node_modules
20
+ COPY --chown=appuser:appuser package.json app.py start.py ./
21
  USER appuser
22
  EXPOSE 7860
23
+ CMD ["python", "start.py"]
README.md CHANGED
@@ -1,21 +1,106 @@
1
- ---
2
- title: Sporalize AI API
3
  sdk: docker
4
  app_port: 7860
5
  ---
6
 
7
- # Sporalize AI API
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
- Deploy `AI/API` as a public Docker Space and `AI/Core` as a private dataset. The API loads `pipeline.py` and weights from Core at startup. The public service is accessible only through the Node backend using `ANALYSIS_PROXY_SECRET`; the health endpoint is public. Supabase credentials stay in Node.
 
 
 
 
 
 
 
 
 
10
 
11
- Copy `.env.example` to `.env` for local use. Set `HF_TOKEN` with read access to Core and write access to a new private storage dataset. Set `BACKEND_URL` and the same `ANALYSIS_PROXY_SECRET` as Node. `CORE_REVISION` can pin the private Core dataset revision.
12
 
13
- Model settings are constants near the top of `app.py`: `POSE_MODEL_FILE`, `POSE_MODEL_REPO_ID`, `YOLO_MODEL_FILE`, `YOLO_MODEL_REPO_ID`, and `YOLO_IMGSZ`. Change those values to select weights or detector input size. Video uploads do not have an application-level size limit.
14
 
15
- The storage dataset contains only the pipeline's `results.json` and source videos under `sessions/<session UUID>/`. Calibration and action input files are temporary. `results.json` is the direct pipeline return after NumPy values are serialized and non-finite values become `null`.
16
 
17
- `POST /api/analyze` accepts `playerId`, `clientId`, `videoOrders`, either uploaded `videos` or `videoDriveLinks`, `actionsJson`, `calibration`, and optional `sessionName`. Drive videos must be publicly downloadable. The service asks Node to authorize/create the session, saves results and videos to the HF dataset, then sends the path and action frame details to Node. Node owns the database and stores frame indices; FPS remains in `results.json`.
18
 
19
- The authorized session endpoint adapts stored reports to the Results screen shape and provides private video URLs. Other endpoints include `/api/progress/{clientId}`, `/api/active-analyses`, `/api/cancel/{clientId}`, and `/api/retry-save/{clientId}`. All except `/healthz` require trusted proxy headers. Run one process because jobs and retry payloads are held in memory and do not survive a restart.
 
 
20
 
21
- Apply `../../Database/migrations/20261016_node_analysis.sql` in Supabase. The older `20261016_analysis_persistence.sql` migration is not used.
 
1
+ ---
2
+ title: Sporalize Backend
3
  sdk: docker
4
  app_port: 7860
5
  ---
6
 
7
+ # Sporalize Backend
8
+
9
+ One Docker service runs Express for authentication, subscriptions and Supabase access, and FastAPI for video analysis. Node exposes port 7860 in the Space; Python binds only to 127.0.0.1:8000. `start.py` supervises both processes and stops the service if either exits. Python calls Node over loopback; `BACKEND_URL` remains the public origin for authentication redirects and media links.
10
+
11
+ ## Local development
12
+
13
+ From this directory:
14
+
15
+ ```powershell
16
+ Copy-Item .env.example .env
17
+ npm ci
18
+ npm run build
19
+ python -m pip install -r requirements.txt
20
+ npm start
21
+ ```
22
+
23
+ Fill in `.env` before starting. Both processes share this file. The default local public port is 4000. Rebuild TypeScript after editing Node code. `npm run dev` starts only Node; use `npm start` for the full application.
24
+
25
+ ## Hugging Face deployment
26
+
27
+ Upload the contents of `Backend/` as the root of a Docker Space, including this README and Dockerfile. Exclude `.env`, `node_modules`, and local build/cache directories. The Space downloads Core from its private dataset repository; local development uses the root `Core/` directory.
28
+
29
+ Set these Space variables/secrets from `.env.example`:
30
+
31
+ - `PORT=7860`, `NODE_ENV=production`, `TRUST_PROXY_HOPS=1` (adjust to the actual trusted proxy chain).
32
+ - `FRONTEND_URL`: public HTTPS Cloudflare frontend origin.
33
+ - `BACKEND_URL`: public HTTPS origin of this Space.
34
+ - `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`.
35
+ - `CORE_REPO_ID`, `STORAGE_REPO_ID`, `HF_TOKEN`; optionally `CORE_REVISION`.
36
+ - `ANALYSIS_PROXY_SECRET`: a random secret with at least 32 characters.
37
+
38
+ Store tokens, the service-role key, and proxy secret as Space secrets. `HF_TOKEN` requires read access to Core and write access to the private storage dataset. Use a dedicated storage dataset for this application. `start.py` sets `ANALYSIS_API_URL` and `BACKEND_INTERNAL_URL` automatically; do not configure a separate public AI URL.
39
+
40
+ For a local Docker check, build with `docker build -t sporalize-backend .`, then run `docker run --rm --env-file .env -e PORT=7860 -p 7860:7860 sporalize-backend`. The `.env` NODE_ENV setting controls whether production HTTPS checks apply.
41
+
42
+ Node health: `GET /health`. AI health is internal at `http://127.0.0.1:8000/healthz` and becomes available after model initialization. Node health alone does not indicate model readiness. Video uploads go through Node directly to Python without Vercel. Validate hosting timeouts and representative large uploads before release.
43
+
44
+ Run one container with one Python worker: analysis jobs and retry payloads are in memory and are lost on restart. Temporary local files are ephemeral. Completed videos and `results.json` are stored in the private HF dataset; Node stores session metadata and frame indices in Supabase. Model constants are in `app.py`.
45
+
46
+ Apply `../Database/migrations/20261016_node_analysis.sql` before analyses. It assumes the legacy actions table is empty. Secret-protected `/internal/analysis` routes authorize sessions and persist completion/failure.
47
+
48
+ ## Supabase dashboard setup
49
+
50
+ 1. Authentication → Providers → Email: enable it and enable **Confirm email**.
51
+ 2. Authentication → Providers → Google: enable it, add the Google client ID/secret, and use Supabase's displayed callback URL in Google Cloud.
52
+ 3. Authentication → URL Configuration:
53
+ - Site URL: `http://localhost:8080` during development.
54
+ - Redirect URLs: `http://localhost:4000/api/auth/callback` and the production equivalent.
55
+
56
+ ## Brevo email verification
57
+
58
+ Brevo is configured as Supabase Auth's SMTP provider; the Express API does not send verification messages directly.
59
+
60
+ 1. Create a Brevo account and verify the sender email/domain you will use.
61
+ 2. In Brevo open **Transactional → Settings → SMTP & API → SMTP** and create an SMTP key.
62
+ 3. In Supabase open **Authentication → Emails → SMTP Settings**, enable custom SMTP, and enter:
63
+ - Host: `smtp-relay.brevo.com`
64
+ - Port: `587`
65
+ - Username: the Brevo SMTP login shown on the SMTP page (not the account email unless they match)
66
+ - Password: the Brevo SMTP key (not the account password)
67
+ - Sender email: the verified Brevo sender
68
+ - Sender name: `Sporalize`
69
+ 4. Keep **Confirm email** enabled under Authentication → Providers → Email.
70
+ 5. In **Authentication → Email Templates → Confirm signup**, set the confirmation link to the backend's absolute URL (replace `https://your-api-domain` with the deployed API origin):
71
+
72
+ ```html
73
+ <h2>Confirm your Sporalize account</h2>
74
+ <p><a href="https://your-api-domain/api/auth/confirm?token_hash={{ .TokenHash }}">Confirm email</a></p>
75
+ ```
76
+
77
+ Set **Site URL** to the frontend origin. Keep the backend callback URL in **Authentication → URL Configuration → Redirect URLs** for Google sign-in, and add its production equivalent. The email template links directly to the backend confirmation endpoint, which verifies the one-time token and creates the application's secure session cookies.
78
+
79
+ Brevo credentials stay in Supabase SMTP settings and are not added to the browser or committed to `.env`.
80
+
81
+ ## Routes
82
 
83
+ - `POST /api/auth/signup` — `{ fname, lname, email, password, academy? }`; sends Supabase's confirmation email when confirmation is enabled.
84
+ - `POST /api/auth/login` — `{ email, password }`.
85
+ - `GET /api/auth/google?redirect=/labs` — starts Google OAuth.
86
+ - `GET /api/auth/callback` — exchanges the Supabase code and sets HttpOnly cookies.
87
+ - `GET /api/auth/confirm?token_hash=...` — verifies the signup email and sets HttpOnly cookies.
88
+ - `GET /api/auth/me` — verifies the session and refreshes it when necessary.
89
+ - `POST /api/auth/logout` — clears the session cookies.
90
+ - `GET /api/auth/profile` and `PATCH /api/auth/profile` — read and update the signed-in user's profile.
91
+ - `POST /api/auth/password` — change or set the signed-in user's password.
92
+ - `DELETE /api/auth/account` — delete the signed-in user's Supabase Auth account and profile row.
93
 
94
+ To allow profiles with no selected role, apply `Database/migrations/20261015_user_role_optional.sql` to the Supabase database once. New accounts start with a `NULL` role and academy; users can complete both from `/labs/profile`.
95
 
96
+ ## Subscription activation flow
97
 
98
+ The access gate already reads `users_subscriptions` and only unlocks Sporalize Labs while `startdate` and `enddate` include today.
99
 
100
+ When a payment provider is added, the server-side webhook flow should:
101
 
102
+ 1. Verify the provider signature and confirm the payment is paid.
103
+ 2. Resolve the authenticated Sporalize user ID and the selected `subscriptions.subscription_id` from trusted checkout metadata.
104
+ 3. Call `assignPaidSubscription` from `src/subscriptions.ts` with the paid period dates.
105
 
106
+ That helper upserts the user's single current plan in `users_subscriptions`. The existing access check then opens Labs, session details, and analysis automatically. The browser return page must not assign access by itself. Store provider payment references/idempotency records when the provider integration is implemented.
app.py CHANGED
@@ -47,7 +47,7 @@ def hub():
47
  def backend(path, user_id, method="GET", body=None):
48
  for attempt in range(3):
49
  try:
50
- response = httpx.request(method, f'{os.environ["BACKEND_URL"].rstrip("/")}/internal/analysis{path}',
51
  json=body, timeout=60, headers={"x-sporalize-proxy-secret": os.environ["ANALYSIS_PROXY_SECRET"], "x-sporalize-user-id": user_id})
52
  response.raise_for_status()
53
  return response.json() if response.content else None
@@ -59,9 +59,13 @@ def backend(path, user_id, method="GET", body=None):
59
 
60
  @lru_cache(maxsize=1)
61
  def runtime():
62
- root = Path(snapshot_download(repo_id=os.environ["CORE_REPO_ID"], repo_type="dataset",
63
- revision=os.environ.get("CORE_REVISION"), token=os.environ["HF_TOKEN"],
64
- allow_patterns=["pipeline.py", "Weights/*"]))
 
 
 
 
65
  spec = importlib.util.spec_from_file_location("sporalize_core", root / "pipeline.py")
66
  module = importlib.util.module_from_spec(spec)
67
  spec.loader.exec_module(module)
@@ -98,7 +102,7 @@ def runtime():
98
 
99
  @asynccontextmanager
100
  async def lifespan(app):
101
- for name in ("CORE_REPO_ID", "STORAGE_REPO_ID", "HF_TOKEN", "BACKEND_URL"):
102
  if not os.environ.get(name):
103
  raise RuntimeError(f"{name} is required")
104
  if len(os.environ.get("ANALYSIS_PROXY_SECRET", "")) < 32:
 
47
  def backend(path, user_id, method="GET", body=None):
48
  for attempt in range(3):
49
  try:
50
+ response = httpx.request(method, f'{os.environ["BACKEND_INTERNAL_URL"].rstrip("/")}/internal/analysis{path}',
51
  json=body, timeout=60, headers={"x-sporalize-proxy-secret": os.environ["ANALYSIS_PROXY_SECRET"], "x-sporalize-user-id": user_id})
52
  response.raise_for_status()
53
  return response.json() if response.content else None
 
59
 
60
  @lru_cache(maxsize=1)
61
  def runtime():
62
+ root = Path(__file__).resolve().parent.parent / "Core"
63
+ if not (root / "pipeline.py").is_file():
64
+ if not os.environ.get("CORE_REPO_ID"):
65
+ raise RuntimeError("CORE_REPO_ID is required when Core is not included locally")
66
+ root = Path(snapshot_download(repo_id=os.environ["CORE_REPO_ID"], repo_type="dataset",
67
+ revision=os.environ.get("CORE_REVISION"), token=os.environ["HF_TOKEN"],
68
+ allow_patterns=["pipeline.py", "Weights/*"]))
69
  spec = importlib.util.spec_from_file_location("sporalize_core", root / "pipeline.py")
70
  module = importlib.util.module_from_spec(spec)
71
  spec.loader.exec_module(module)
 
102
 
103
  @asynccontextmanager
104
  async def lifespan(app):
105
+ for name in ("STORAGE_REPO_ID", "HF_TOKEN", "BACKEND_INTERNAL_URL"):
106
  if not os.environ.get(name):
107
  raise RuntimeError(f"{name} is required")
108
  if len(os.environ.get("ANALYSIS_PROXY_SECRET", "")) < 32:
package-lock.json ADDED
@@ -0,0 +1,1749 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "sporalize-backend",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "sporalize-backend",
9
+ "version": "1.0.0",
10
+ "dependencies": {
11
+ "@supabase/supabase-js": "^2.57.0",
12
+ "bcryptjs": "^3.0.2",
13
+ "cookie-parser": "^1.4.7",
14
+ "cors": "^2.8.5",
15
+ "dotenv": "^16.6.1",
16
+ "express": "^5.1.0",
17
+ "express-rate-limit": "^8.1.0",
18
+ "helmet": "^8.1.0",
19
+ "zod": "^4.1.5"
20
+ },
21
+ "devDependencies": {
22
+ "@types/cookie-parser": "^1.4.9",
23
+ "@types/cors": "^2.8.19",
24
+ "@types/express": "^5.0.3",
25
+ "@types/node": "^22.16.5",
26
+ "tsx": "^4.20.5",
27
+ "typescript": "^5.8.3"
28
+ }
29
+ },
30
+ "node_modules/@esbuild/aix-ppc64": {
31
+ "version": "0.28.2",
32
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
33
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
34
+ "cpu": [
35
+ "ppc64"
36
+ ],
37
+ "dev": true,
38
+ "license": "MIT",
39
+ "optional": true,
40
+ "os": [
41
+ "aix"
42
+ ],
43
+ "engines": {
44
+ "node": ">=18"
45
+ }
46
+ },
47
+ "node_modules/@esbuild/android-arm": {
48
+ "version": "0.28.2",
49
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
50
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
51
+ "cpu": [
52
+ "arm"
53
+ ],
54
+ "dev": true,
55
+ "license": "MIT",
56
+ "optional": true,
57
+ "os": [
58
+ "android"
59
+ ],
60
+ "engines": {
61
+ "node": ">=18"
62
+ }
63
+ },
64
+ "node_modules/@esbuild/android-arm64": {
65
+ "version": "0.28.2",
66
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
67
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
68
+ "cpu": [
69
+ "arm64"
70
+ ],
71
+ "dev": true,
72
+ "license": "MIT",
73
+ "optional": true,
74
+ "os": [
75
+ "android"
76
+ ],
77
+ "engines": {
78
+ "node": ">=18"
79
+ }
80
+ },
81
+ "node_modules/@esbuild/android-x64": {
82
+ "version": "0.28.2",
83
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
84
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
85
+ "cpu": [
86
+ "x64"
87
+ ],
88
+ "dev": true,
89
+ "license": "MIT",
90
+ "optional": true,
91
+ "os": [
92
+ "android"
93
+ ],
94
+ "engines": {
95
+ "node": ">=18"
96
+ }
97
+ },
98
+ "node_modules/@esbuild/darwin-arm64": {
99
+ "version": "0.28.2",
100
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
101
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
102
+ "cpu": [
103
+ "arm64"
104
+ ],
105
+ "dev": true,
106
+ "license": "MIT",
107
+ "optional": true,
108
+ "os": [
109
+ "darwin"
110
+ ],
111
+ "engines": {
112
+ "node": ">=18"
113
+ }
114
+ },
115
+ "node_modules/@esbuild/darwin-x64": {
116
+ "version": "0.28.2",
117
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
118
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
119
+ "cpu": [
120
+ "x64"
121
+ ],
122
+ "dev": true,
123
+ "license": "MIT",
124
+ "optional": true,
125
+ "os": [
126
+ "darwin"
127
+ ],
128
+ "engines": {
129
+ "node": ">=18"
130
+ }
131
+ },
132
+ "node_modules/@esbuild/freebsd-arm64": {
133
+ "version": "0.28.2",
134
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
135
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
136
+ "cpu": [
137
+ "arm64"
138
+ ],
139
+ "dev": true,
140
+ "license": "MIT",
141
+ "optional": true,
142
+ "os": [
143
+ "freebsd"
144
+ ],
145
+ "engines": {
146
+ "node": ">=18"
147
+ }
148
+ },
149
+ "node_modules/@esbuild/freebsd-x64": {
150
+ "version": "0.28.2",
151
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
152
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
153
+ "cpu": [
154
+ "x64"
155
+ ],
156
+ "dev": true,
157
+ "license": "MIT",
158
+ "optional": true,
159
+ "os": [
160
+ "freebsd"
161
+ ],
162
+ "engines": {
163
+ "node": ">=18"
164
+ }
165
+ },
166
+ "node_modules/@esbuild/linux-arm": {
167
+ "version": "0.28.2",
168
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
169
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
170
+ "cpu": [
171
+ "arm"
172
+ ],
173
+ "dev": true,
174
+ "license": "MIT",
175
+ "optional": true,
176
+ "os": [
177
+ "linux"
178
+ ],
179
+ "engines": {
180
+ "node": ">=18"
181
+ }
182
+ },
183
+ "node_modules/@esbuild/linux-arm64": {
184
+ "version": "0.28.2",
185
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
186
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
187
+ "cpu": [
188
+ "arm64"
189
+ ],
190
+ "dev": true,
191
+ "license": "MIT",
192
+ "optional": true,
193
+ "os": [
194
+ "linux"
195
+ ],
196
+ "engines": {
197
+ "node": ">=18"
198
+ }
199
+ },
200
+ "node_modules/@esbuild/linux-ia32": {
201
+ "version": "0.28.2",
202
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
203
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
204
+ "cpu": [
205
+ "ia32"
206
+ ],
207
+ "dev": true,
208
+ "license": "MIT",
209
+ "optional": true,
210
+ "os": [
211
+ "linux"
212
+ ],
213
+ "engines": {
214
+ "node": ">=18"
215
+ }
216
+ },
217
+ "node_modules/@esbuild/linux-loong64": {
218
+ "version": "0.28.2",
219
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
220
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
221
+ "cpu": [
222
+ "loong64"
223
+ ],
224
+ "dev": true,
225
+ "license": "MIT",
226
+ "optional": true,
227
+ "os": [
228
+ "linux"
229
+ ],
230
+ "engines": {
231
+ "node": ">=18"
232
+ }
233
+ },
234
+ "node_modules/@esbuild/linux-mips64el": {
235
+ "version": "0.28.2",
236
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
237
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
238
+ "cpu": [
239
+ "mips64el"
240
+ ],
241
+ "dev": true,
242
+ "license": "MIT",
243
+ "optional": true,
244
+ "os": [
245
+ "linux"
246
+ ],
247
+ "engines": {
248
+ "node": ">=18"
249
+ }
250
+ },
251
+ "node_modules/@esbuild/linux-ppc64": {
252
+ "version": "0.28.2",
253
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
254
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
255
+ "cpu": [
256
+ "ppc64"
257
+ ],
258
+ "dev": true,
259
+ "license": "MIT",
260
+ "optional": true,
261
+ "os": [
262
+ "linux"
263
+ ],
264
+ "engines": {
265
+ "node": ">=18"
266
+ }
267
+ },
268
+ "node_modules/@esbuild/linux-riscv64": {
269
+ "version": "0.28.2",
270
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
271
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
272
+ "cpu": [
273
+ "riscv64"
274
+ ],
275
+ "dev": true,
276
+ "license": "MIT",
277
+ "optional": true,
278
+ "os": [
279
+ "linux"
280
+ ],
281
+ "engines": {
282
+ "node": ">=18"
283
+ }
284
+ },
285
+ "node_modules/@esbuild/linux-s390x": {
286
+ "version": "0.28.2",
287
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
288
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
289
+ "cpu": [
290
+ "s390x"
291
+ ],
292
+ "dev": true,
293
+ "license": "MIT",
294
+ "optional": true,
295
+ "os": [
296
+ "linux"
297
+ ],
298
+ "engines": {
299
+ "node": ">=18"
300
+ }
301
+ },
302
+ "node_modules/@esbuild/linux-x64": {
303
+ "version": "0.28.2",
304
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
305
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
306
+ "cpu": [
307
+ "x64"
308
+ ],
309
+ "dev": true,
310
+ "license": "MIT",
311
+ "optional": true,
312
+ "os": [
313
+ "linux"
314
+ ],
315
+ "engines": {
316
+ "node": ">=18"
317
+ }
318
+ },
319
+ "node_modules/@esbuild/netbsd-arm64": {
320
+ "version": "0.28.2",
321
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
322
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
323
+ "cpu": [
324
+ "arm64"
325
+ ],
326
+ "dev": true,
327
+ "license": "MIT",
328
+ "optional": true,
329
+ "os": [
330
+ "netbsd"
331
+ ],
332
+ "engines": {
333
+ "node": ">=18"
334
+ }
335
+ },
336
+ "node_modules/@esbuild/netbsd-x64": {
337
+ "version": "0.28.2",
338
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
339
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
340
+ "cpu": [
341
+ "x64"
342
+ ],
343
+ "dev": true,
344
+ "license": "MIT",
345
+ "optional": true,
346
+ "os": [
347
+ "netbsd"
348
+ ],
349
+ "engines": {
350
+ "node": ">=18"
351
+ }
352
+ },
353
+ "node_modules/@esbuild/openbsd-arm64": {
354
+ "version": "0.28.2",
355
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
356
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
357
+ "cpu": [
358
+ "arm64"
359
+ ],
360
+ "dev": true,
361
+ "license": "MIT",
362
+ "optional": true,
363
+ "os": [
364
+ "openbsd"
365
+ ],
366
+ "engines": {
367
+ "node": ">=18"
368
+ }
369
+ },
370
+ "node_modules/@esbuild/openbsd-x64": {
371
+ "version": "0.28.2",
372
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
373
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
374
+ "cpu": [
375
+ "x64"
376
+ ],
377
+ "dev": true,
378
+ "license": "MIT",
379
+ "optional": true,
380
+ "os": [
381
+ "openbsd"
382
+ ],
383
+ "engines": {
384
+ "node": ">=18"
385
+ }
386
+ },
387
+ "node_modules/@esbuild/openharmony-arm64": {
388
+ "version": "0.28.2",
389
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
390
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
391
+ "cpu": [
392
+ "arm64"
393
+ ],
394
+ "dev": true,
395
+ "license": "MIT",
396
+ "optional": true,
397
+ "os": [
398
+ "openharmony"
399
+ ],
400
+ "engines": {
401
+ "node": ">=18"
402
+ }
403
+ },
404
+ "node_modules/@esbuild/sunos-x64": {
405
+ "version": "0.28.2",
406
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
407
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
408
+ "cpu": [
409
+ "x64"
410
+ ],
411
+ "dev": true,
412
+ "license": "MIT",
413
+ "optional": true,
414
+ "os": [
415
+ "sunos"
416
+ ],
417
+ "engines": {
418
+ "node": ">=18"
419
+ }
420
+ },
421
+ "node_modules/@esbuild/win32-arm64": {
422
+ "version": "0.28.2",
423
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
424
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
425
+ "cpu": [
426
+ "arm64"
427
+ ],
428
+ "dev": true,
429
+ "license": "MIT",
430
+ "optional": true,
431
+ "os": [
432
+ "win32"
433
+ ],
434
+ "engines": {
435
+ "node": ">=18"
436
+ }
437
+ },
438
+ "node_modules/@esbuild/win32-ia32": {
439
+ "version": "0.28.2",
440
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
441
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
442
+ "cpu": [
443
+ "ia32"
444
+ ],
445
+ "dev": true,
446
+ "license": "MIT",
447
+ "optional": true,
448
+ "os": [
449
+ "win32"
450
+ ],
451
+ "engines": {
452
+ "node": ">=18"
453
+ }
454
+ },
455
+ "node_modules/@esbuild/win32-x64": {
456
+ "version": "0.28.2",
457
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
458
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
459
+ "cpu": [
460
+ "x64"
461
+ ],
462
+ "dev": true,
463
+ "license": "MIT",
464
+ "optional": true,
465
+ "os": [
466
+ "win32"
467
+ ],
468
+ "engines": {
469
+ "node": ">=18"
470
+ }
471
+ },
472
+ "node_modules/@supabase/auth-js": {
473
+ "version": "2.118.0",
474
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.118.0.tgz",
475
+ "integrity": "sha512-xWfPXyhS2KDSfAneMtI6p/tBnbdiz/5vaRKXEqUfzfC9WCniKr2Ds5o5G7RIGig0bom4z6wUY9zaV07WDVTfnQ==",
476
+ "license": "MIT",
477
+ "dependencies": {
478
+ "tslib": "2.8.1"
479
+ },
480
+ "engines": {
481
+ "node": ">=22.0.0"
482
+ }
483
+ },
484
+ "node_modules/@supabase/functions-js": {
485
+ "version": "2.118.0",
486
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.118.0.tgz",
487
+ "integrity": "sha512-4ofletPa/noKAU6KHeqsd3FztGzRPUEJRey1MKY0o+PIHaVePqRPShIXtl37gcVyIWaFM+zNg6r9zh0uUSFOSA==",
488
+ "license": "MIT",
489
+ "dependencies": {
490
+ "tslib": "2.8.1"
491
+ },
492
+ "engines": {
493
+ "node": ">=22.0.0"
494
+ }
495
+ },
496
+ "node_modules/@supabase/phoenix": {
497
+ "version": "0.4.5",
498
+ "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
499
+ "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
500
+ "license": "MIT"
501
+ },
502
+ "node_modules/@supabase/postgrest-js": {
503
+ "version": "2.118.0",
504
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.118.0.tgz",
505
+ "integrity": "sha512-JXy+37KPApHsyotaQACovv/Jy9E22rfiWLRuT+2UjoEAcAIelDFIrsF/FY47Dy19mO7lI0Tq+zDCX8oJ2Ozfiw==",
506
+ "license": "MIT",
507
+ "dependencies": {
508
+ "tslib": "2.8.1"
509
+ },
510
+ "engines": {
511
+ "node": ">=22.0.0"
512
+ }
513
+ },
514
+ "node_modules/@supabase/realtime-js": {
515
+ "version": "2.118.0",
516
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.118.0.tgz",
517
+ "integrity": "sha512-pzCIEWjqD5/xqEZQerpt+q9rsgYkcrkgCJgf0W2ULqC4fYUeB/p9CFa3t9vn+IVh6dMN9r4ZCE262lZvUC+Bfw==",
518
+ "license": "MIT",
519
+ "dependencies": {
520
+ "@supabase/phoenix": "0.4.5",
521
+ "tslib": "2.8.1"
522
+ },
523
+ "engines": {
524
+ "node": ">=22.0.0"
525
+ }
526
+ },
527
+ "node_modules/@supabase/storage-js": {
528
+ "version": "2.118.0",
529
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.118.0.tgz",
530
+ "integrity": "sha512-5MNkKt2Moy7Jud/sPX7/28BwdDxZMCVHGJT00b8op3KRa9LKSjOAYU5IcfGWIstaasAqY8GkbA6oI3IRDgB1YQ==",
531
+ "license": "MIT",
532
+ "dependencies": {
533
+ "iceberg-js": "^0.8.1",
534
+ "tslib": "2.8.1"
535
+ },
536
+ "engines": {
537
+ "node": ">=22.0.0"
538
+ }
539
+ },
540
+ "node_modules/@supabase/supabase-js": {
541
+ "version": "2.118.0",
542
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.118.0.tgz",
543
+ "integrity": "sha512-FxI8UXMVC2UG7IkeiI8fuQZqq2oI3JeSuN8MPIC2RLVaoEAxKS0nqE6kii9Ewpq8f9gNPM6git1Y8f0vN/QLFw==",
544
+ "license": "MIT",
545
+ "dependencies": {
546
+ "@supabase/auth-js": "2.118.0",
547
+ "@supabase/functions-js": "2.118.0",
548
+ "@supabase/postgrest-js": "2.118.0",
549
+ "@supabase/realtime-js": "2.118.0",
550
+ "@supabase/storage-js": "2.118.0"
551
+ },
552
+ "engines": {
553
+ "node": ">=22.0.0"
554
+ },
555
+ "peerDependencies": {
556
+ "@opentelemetry/api": ">=1.0.0"
557
+ },
558
+ "peerDependenciesMeta": {
559
+ "@opentelemetry/api": {
560
+ "optional": true
561
+ }
562
+ }
563
+ },
564
+ "node_modules/@types/body-parser": {
565
+ "version": "1.19.6",
566
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
567
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
568
+ "dev": true,
569
+ "license": "MIT",
570
+ "dependencies": {
571
+ "@types/connect": "*",
572
+ "@types/node": "*"
573
+ }
574
+ },
575
+ "node_modules/@types/connect": {
576
+ "version": "3.4.38",
577
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
578
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
579
+ "dev": true,
580
+ "license": "MIT",
581
+ "dependencies": {
582
+ "@types/node": "*"
583
+ }
584
+ },
585
+ "node_modules/@types/cookie-parser": {
586
+ "version": "1.4.10",
587
+ "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
588
+ "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
589
+ "dev": true,
590
+ "license": "MIT",
591
+ "peerDependencies": {
592
+ "@types/express": "*"
593
+ }
594
+ },
595
+ "node_modules/@types/cors": {
596
+ "version": "2.8.19",
597
+ "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
598
+ "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
599
+ "dev": true,
600
+ "license": "MIT",
601
+ "dependencies": {
602
+ "@types/node": "*"
603
+ }
604
+ },
605
+ "node_modules/@types/express": {
606
+ "version": "5.0.6",
607
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
608
+ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
609
+ "dev": true,
610
+ "license": "MIT",
611
+ "dependencies": {
612
+ "@types/body-parser": "*",
613
+ "@types/express-serve-static-core": "^5.0.0",
614
+ "@types/serve-static": "^2"
615
+ }
616
+ },
617
+ "node_modules/@types/express-serve-static-core": {
618
+ "version": "5.1.3",
619
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz",
620
+ "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==",
621
+ "dev": true,
622
+ "license": "MIT",
623
+ "dependencies": {
624
+ "@types/node": "*",
625
+ "@types/qs": "*",
626
+ "@types/range-parser": "*",
627
+ "@types/send": "*"
628
+ }
629
+ },
630
+ "node_modules/@types/http-errors": {
631
+ "version": "2.0.5",
632
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
633
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
634
+ "dev": true,
635
+ "license": "MIT"
636
+ },
637
+ "node_modules/@types/node": {
638
+ "version": "22.20.5",
639
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz",
640
+ "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==",
641
+ "dev": true,
642
+ "license": "MIT",
643
+ "dependencies": {
644
+ "undici-types": "~6.21.0"
645
+ }
646
+ },
647
+ "node_modules/@types/qs": {
648
+ "version": "6.15.1",
649
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
650
+ "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
651
+ "dev": true,
652
+ "license": "MIT"
653
+ },
654
+ "node_modules/@types/range-parser": {
655
+ "version": "1.2.7",
656
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
657
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
658
+ "dev": true,
659
+ "license": "MIT"
660
+ },
661
+ "node_modules/@types/send": {
662
+ "version": "1.2.1",
663
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
664
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
665
+ "dev": true,
666
+ "license": "MIT",
667
+ "dependencies": {
668
+ "@types/node": "*"
669
+ }
670
+ },
671
+ "node_modules/@types/serve-static": {
672
+ "version": "2.2.0",
673
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
674
+ "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
675
+ "dev": true,
676
+ "license": "MIT",
677
+ "dependencies": {
678
+ "@types/http-errors": "*",
679
+ "@types/node": "*"
680
+ }
681
+ },
682
+ "node_modules/accepts": {
683
+ "version": "2.0.0",
684
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
685
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
686
+ "license": "MIT",
687
+ "dependencies": {
688
+ "mime-types": "^3.0.0",
689
+ "negotiator": "^1.0.0"
690
+ },
691
+ "engines": {
692
+ "node": ">= 0.6"
693
+ }
694
+ },
695
+ "node_modules/bcryptjs": {
696
+ "version": "3.0.3",
697
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
698
+ "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
699
+ "license": "BSD-3-Clause",
700
+ "bin": {
701
+ "bcrypt": "bin/bcrypt"
702
+ }
703
+ },
704
+ "node_modules/body-parser": {
705
+ "version": "2.3.0",
706
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
707
+ "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
708
+ "license": "MIT",
709
+ "dependencies": {
710
+ "bytes": "^3.1.2",
711
+ "content-type": "^2.0.0",
712
+ "debug": "^4.4.3",
713
+ "http-errors": "^2.0.1",
714
+ "iconv-lite": "^0.7.2",
715
+ "on-finished": "^2.4.1",
716
+ "qs": "^6.15.2",
717
+ "raw-body": "^3.0.2",
718
+ "type-is": "^2.1.0"
719
+ },
720
+ "engines": {
721
+ "node": ">=18"
722
+ },
723
+ "funding": {
724
+ "type": "opencollective",
725
+ "url": "https://opencollective.com/express"
726
+ }
727
+ },
728
+ "node_modules/bytes": {
729
+ "version": "3.1.2",
730
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
731
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
732
+ "license": "MIT",
733
+ "engines": {
734
+ "node": ">= 0.8"
735
+ }
736
+ },
737
+ "node_modules/call-bind-apply-helpers": {
738
+ "version": "1.0.2",
739
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
740
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
741
+ "license": "MIT",
742
+ "dependencies": {
743
+ "es-errors": "^1.3.0",
744
+ "function-bind": "^1.1.2"
745
+ },
746
+ "engines": {
747
+ "node": ">= 0.4"
748
+ }
749
+ },
750
+ "node_modules/call-bound": {
751
+ "version": "1.0.4",
752
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
753
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
754
+ "license": "MIT",
755
+ "dependencies": {
756
+ "call-bind-apply-helpers": "^1.0.2",
757
+ "get-intrinsic": "^1.3.0"
758
+ },
759
+ "engines": {
760
+ "node": ">= 0.4"
761
+ },
762
+ "funding": {
763
+ "url": "https://github.com/sponsors/ljharb"
764
+ }
765
+ },
766
+ "node_modules/content-disposition": {
767
+ "version": "2.0.1",
768
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
769
+ "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
770
+ "license": "MIT",
771
+ "engines": {
772
+ "node": ">=18"
773
+ },
774
+ "funding": {
775
+ "type": "opencollective",
776
+ "url": "https://opencollective.com/express"
777
+ }
778
+ },
779
+ "node_modules/content-type": {
780
+ "version": "2.1.0",
781
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
782
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
783
+ "license": "MIT",
784
+ "engines": {
785
+ "node": ">=18"
786
+ },
787
+ "funding": {
788
+ "type": "opencollective",
789
+ "url": "https://opencollective.com/express"
790
+ }
791
+ },
792
+ "node_modules/cookie": {
793
+ "version": "0.7.2",
794
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
795
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
796
+ "license": "MIT",
797
+ "engines": {
798
+ "node": ">= 0.6"
799
+ }
800
+ },
801
+ "node_modules/cookie-parser": {
802
+ "version": "1.4.7",
803
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
804
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
805
+ "license": "MIT",
806
+ "dependencies": {
807
+ "cookie": "0.7.2",
808
+ "cookie-signature": "1.0.6"
809
+ },
810
+ "engines": {
811
+ "node": ">= 0.8.0"
812
+ }
813
+ },
814
+ "node_modules/cookie-signature": {
815
+ "version": "1.0.6",
816
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
817
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
818
+ "license": "MIT"
819
+ },
820
+ "node_modules/cors": {
821
+ "version": "2.8.6",
822
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
823
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
824
+ "license": "MIT",
825
+ "dependencies": {
826
+ "object-assign": "^4",
827
+ "vary": "^1"
828
+ },
829
+ "engines": {
830
+ "node": ">= 0.10"
831
+ },
832
+ "funding": {
833
+ "type": "opencollective",
834
+ "url": "https://opencollective.com/express"
835
+ }
836
+ },
837
+ "node_modules/debug": {
838
+ "version": "4.4.3",
839
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
840
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
841
+ "license": "MIT",
842
+ "dependencies": {
843
+ "ms": "^2.1.3"
844
+ },
845
+ "engines": {
846
+ "node": ">=6.0"
847
+ },
848
+ "peerDependenciesMeta": {
849
+ "supports-color": {
850
+ "optional": true
851
+ }
852
+ }
853
+ },
854
+ "node_modules/depd": {
855
+ "version": "2.0.0",
856
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
857
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
858
+ "license": "MIT",
859
+ "engines": {
860
+ "node": ">= 0.8"
861
+ }
862
+ },
863
+ "node_modules/dotenv": {
864
+ "version": "16.6.1",
865
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
866
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
867
+ "license": "BSD-2-Clause",
868
+ "engines": {
869
+ "node": ">=12"
870
+ },
871
+ "funding": {
872
+ "url": "https://dotenvx.com"
873
+ }
874
+ },
875
+ "node_modules/dunder-proto": {
876
+ "version": "1.0.1",
877
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
878
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
879
+ "license": "MIT",
880
+ "dependencies": {
881
+ "call-bind-apply-helpers": "^1.0.1",
882
+ "es-errors": "^1.3.0",
883
+ "gopd": "^1.2.0"
884
+ },
885
+ "engines": {
886
+ "node": ">= 0.4"
887
+ }
888
+ },
889
+ "node_modules/ee-first": {
890
+ "version": "1.1.1",
891
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
892
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
893
+ "license": "MIT"
894
+ },
895
+ "node_modules/encodeurl": {
896
+ "version": "2.0.0",
897
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
898
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
899
+ "license": "MIT",
900
+ "engines": {
901
+ "node": ">= 0.8"
902
+ }
903
+ },
904
+ "node_modules/es-define-property": {
905
+ "version": "1.0.1",
906
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
907
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
908
+ "license": "MIT",
909
+ "engines": {
910
+ "node": ">= 0.4"
911
+ }
912
+ },
913
+ "node_modules/es-errors": {
914
+ "version": "1.3.0",
915
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
916
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
917
+ "license": "MIT",
918
+ "engines": {
919
+ "node": ">= 0.4"
920
+ }
921
+ },
922
+ "node_modules/es-object-atoms": {
923
+ "version": "1.1.2",
924
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
925
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
926
+ "license": "MIT",
927
+ "dependencies": {
928
+ "es-errors": "^1.3.0"
929
+ },
930
+ "engines": {
931
+ "node": ">= 0.4"
932
+ }
933
+ },
934
+ "node_modules/esbuild": {
935
+ "version": "0.28.2",
936
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
937
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
938
+ "dev": true,
939
+ "hasInstallScript": true,
940
+ "license": "MIT",
941
+ "bin": {
942
+ "esbuild": "bin/esbuild"
943
+ },
944
+ "engines": {
945
+ "node": ">=18"
946
+ },
947
+ "optionalDependencies": {
948
+ "@esbuild/aix-ppc64": "0.28.2",
949
+ "@esbuild/android-arm": "0.28.2",
950
+ "@esbuild/android-arm64": "0.28.2",
951
+ "@esbuild/android-x64": "0.28.2",
952
+ "@esbuild/darwin-arm64": "0.28.2",
953
+ "@esbuild/darwin-x64": "0.28.2",
954
+ "@esbuild/freebsd-arm64": "0.28.2",
955
+ "@esbuild/freebsd-x64": "0.28.2",
956
+ "@esbuild/linux-arm": "0.28.2",
957
+ "@esbuild/linux-arm64": "0.28.2",
958
+ "@esbuild/linux-ia32": "0.28.2",
959
+ "@esbuild/linux-loong64": "0.28.2",
960
+ "@esbuild/linux-mips64el": "0.28.2",
961
+ "@esbuild/linux-ppc64": "0.28.2",
962
+ "@esbuild/linux-riscv64": "0.28.2",
963
+ "@esbuild/linux-s390x": "0.28.2",
964
+ "@esbuild/linux-x64": "0.28.2",
965
+ "@esbuild/netbsd-arm64": "0.28.2",
966
+ "@esbuild/netbsd-x64": "0.28.2",
967
+ "@esbuild/openbsd-arm64": "0.28.2",
968
+ "@esbuild/openbsd-x64": "0.28.2",
969
+ "@esbuild/openharmony-arm64": "0.28.2",
970
+ "@esbuild/sunos-x64": "0.28.2",
971
+ "@esbuild/win32-arm64": "0.28.2",
972
+ "@esbuild/win32-ia32": "0.28.2",
973
+ "@esbuild/win32-x64": "0.28.2"
974
+ }
975
+ },
976
+ "node_modules/escape-html": {
977
+ "version": "1.0.3",
978
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
979
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
980
+ "license": "MIT"
981
+ },
982
+ "node_modules/etag": {
983
+ "version": "1.8.1",
984
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
985
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
986
+ "license": "MIT",
987
+ "engines": {
988
+ "node": ">= 0.6"
989
+ }
990
+ },
991
+ "node_modules/express": {
992
+ "version": "5.3.0",
993
+ "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz",
994
+ "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==",
995
+ "license": "MIT",
996
+ "dependencies": {
997
+ "accepts": "^2.0.0",
998
+ "body-parser": "^2.3.0",
999
+ "content-disposition": "^2.0.1",
1000
+ "content-type": "^2.0.0",
1001
+ "cookie": "^0.7.1",
1002
+ "cookie-signature": "^1.2.1",
1003
+ "debug": "^4.4.0",
1004
+ "depd": "^2.0.0",
1005
+ "encodeurl": "^2.0.0",
1006
+ "escape-html": "^1.0.3",
1007
+ "etag": "^1.8.1",
1008
+ "finalhandler": "^2.1.0",
1009
+ "fresh": "^2.0.0",
1010
+ "http-errors": "^2.0.0",
1011
+ "merge-descriptors": "^2.0.0",
1012
+ "mime-types": "^3.0.0",
1013
+ "on-finished": "^2.4.1",
1014
+ "once": "^1.4.0",
1015
+ "parseurl": "^1.3.3",
1016
+ "proxy-addr": "^2.0.8",
1017
+ "qs": "^6.16.0",
1018
+ "range-parser": "^1.2.1",
1019
+ "router": "^2.2.0",
1020
+ "send": "^1.1.0",
1021
+ "serve-static": "^2.2.0",
1022
+ "statuses": "^2.0.1",
1023
+ "type-is": "^2.1.0",
1024
+ "vary": "^1.1.2"
1025
+ },
1026
+ "engines": {
1027
+ "node": ">= 18"
1028
+ },
1029
+ "funding": {
1030
+ "type": "opencollective",
1031
+ "url": "https://opencollective.com/express"
1032
+ }
1033
+ },
1034
+ "node_modules/express-rate-limit": {
1035
+ "version": "8.7.1",
1036
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz",
1037
+ "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==",
1038
+ "license": "MIT",
1039
+ "dependencies": {
1040
+ "debug": "^4.4.3",
1041
+ "ip-address": "^10.2.0"
1042
+ },
1043
+ "engines": {
1044
+ "node": ">= 16"
1045
+ },
1046
+ "funding": {
1047
+ "url": "https://github.com/sponsors/express-rate-limit"
1048
+ },
1049
+ "peerDependencies": {
1050
+ "express": ">= 4.11"
1051
+ }
1052
+ },
1053
+ "node_modules/express/node_modules/cookie-signature": {
1054
+ "version": "1.2.2",
1055
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
1056
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
1057
+ "license": "MIT",
1058
+ "engines": {
1059
+ "node": ">=6.6.0"
1060
+ }
1061
+ },
1062
+ "node_modules/finalhandler": {
1063
+ "version": "2.1.1",
1064
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
1065
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
1066
+ "license": "MIT",
1067
+ "dependencies": {
1068
+ "debug": "^4.4.0",
1069
+ "encodeurl": "^2.0.0",
1070
+ "escape-html": "^1.0.3",
1071
+ "on-finished": "^2.4.1",
1072
+ "parseurl": "^1.3.3",
1073
+ "statuses": "^2.0.1"
1074
+ },
1075
+ "engines": {
1076
+ "node": ">= 18.0.0"
1077
+ },
1078
+ "funding": {
1079
+ "type": "opencollective",
1080
+ "url": "https://opencollective.com/express"
1081
+ }
1082
+ },
1083
+ "node_modules/forwarded": {
1084
+ "version": "0.2.0",
1085
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
1086
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
1087
+ "license": "MIT",
1088
+ "engines": {
1089
+ "node": ">= 0.6"
1090
+ }
1091
+ },
1092
+ "node_modules/fresh": {
1093
+ "version": "2.0.0",
1094
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
1095
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
1096
+ "license": "MIT",
1097
+ "engines": {
1098
+ "node": ">= 0.8"
1099
+ }
1100
+ },
1101
+ "node_modules/fsevents": {
1102
+ "version": "2.3.3",
1103
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1104
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1105
+ "dev": true,
1106
+ "hasInstallScript": true,
1107
+ "license": "MIT",
1108
+ "optional": true,
1109
+ "os": [
1110
+ "darwin"
1111
+ ],
1112
+ "engines": {
1113
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1114
+ }
1115
+ },
1116
+ "node_modules/function-bind": {
1117
+ "version": "1.1.2",
1118
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
1119
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
1120
+ "license": "MIT",
1121
+ "funding": {
1122
+ "url": "https://github.com/sponsors/ljharb"
1123
+ }
1124
+ },
1125
+ "node_modules/get-intrinsic": {
1126
+ "version": "1.3.0",
1127
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
1128
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
1129
+ "license": "MIT",
1130
+ "dependencies": {
1131
+ "call-bind-apply-helpers": "^1.0.2",
1132
+ "es-define-property": "^1.0.1",
1133
+ "es-errors": "^1.3.0",
1134
+ "es-object-atoms": "^1.1.1",
1135
+ "function-bind": "^1.1.2",
1136
+ "get-proto": "^1.0.1",
1137
+ "gopd": "^1.2.0",
1138
+ "has-symbols": "^1.1.0",
1139
+ "hasown": "^2.0.2",
1140
+ "math-intrinsics": "^1.1.0"
1141
+ },
1142
+ "engines": {
1143
+ "node": ">= 0.4"
1144
+ },
1145
+ "funding": {
1146
+ "url": "https://github.com/sponsors/ljharb"
1147
+ }
1148
+ },
1149
+ "node_modules/get-proto": {
1150
+ "version": "1.0.1",
1151
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
1152
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
1153
+ "license": "MIT",
1154
+ "dependencies": {
1155
+ "dunder-proto": "^1.0.1",
1156
+ "es-object-atoms": "^1.0.0"
1157
+ },
1158
+ "engines": {
1159
+ "node": ">= 0.4"
1160
+ }
1161
+ },
1162
+ "node_modules/gopd": {
1163
+ "version": "1.2.0",
1164
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
1165
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
1166
+ "license": "MIT",
1167
+ "engines": {
1168
+ "node": ">= 0.4"
1169
+ },
1170
+ "funding": {
1171
+ "url": "https://github.com/sponsors/ljharb"
1172
+ }
1173
+ },
1174
+ "node_modules/has-symbols": {
1175
+ "version": "1.1.0",
1176
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
1177
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
1178
+ "license": "MIT",
1179
+ "engines": {
1180
+ "node": ">= 0.4"
1181
+ },
1182
+ "funding": {
1183
+ "url": "https://github.com/sponsors/ljharb"
1184
+ }
1185
+ },
1186
+ "node_modules/hasown": {
1187
+ "version": "2.0.4",
1188
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
1189
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
1190
+ "license": "MIT",
1191
+ "dependencies": {
1192
+ "function-bind": "^1.1.2"
1193
+ },
1194
+ "engines": {
1195
+ "node": ">= 0.4"
1196
+ }
1197
+ },
1198
+ "node_modules/helmet": {
1199
+ "version": "8.3.0",
1200
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
1201
+ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
1202
+ "license": "MIT",
1203
+ "engines": {
1204
+ "node": ">=18.0.0"
1205
+ },
1206
+ "funding": {
1207
+ "url": "https://github.com/sponsors/EvanHahn"
1208
+ }
1209
+ },
1210
+ "node_modules/http-errors": {
1211
+ "version": "2.0.1",
1212
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
1213
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
1214
+ "license": "MIT",
1215
+ "dependencies": {
1216
+ "depd": "~2.0.0",
1217
+ "inherits": "~2.0.4",
1218
+ "setprototypeof": "~1.2.0",
1219
+ "statuses": "~2.0.2",
1220
+ "toidentifier": "~1.0.1"
1221
+ },
1222
+ "engines": {
1223
+ "node": ">= 0.8"
1224
+ },
1225
+ "funding": {
1226
+ "type": "opencollective",
1227
+ "url": "https://opencollective.com/express"
1228
+ }
1229
+ },
1230
+ "node_modules/iceberg-js": {
1231
+ "version": "0.8.1",
1232
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
1233
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
1234
+ "license": "MIT",
1235
+ "engines": {
1236
+ "node": ">=20.0.0"
1237
+ }
1238
+ },
1239
+ "node_modules/iconv-lite": {
1240
+ "version": "0.7.3",
1241
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
1242
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
1243
+ "license": "MIT",
1244
+ "dependencies": {
1245
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
1246
+ },
1247
+ "engines": {
1248
+ "node": ">=0.10.0"
1249
+ },
1250
+ "funding": {
1251
+ "type": "opencollective",
1252
+ "url": "https://opencollective.com/express"
1253
+ }
1254
+ },
1255
+ "node_modules/inherits": {
1256
+ "version": "2.0.4",
1257
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
1258
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
1259
+ "license": "ISC"
1260
+ },
1261
+ "node_modules/ip-address": {
1262
+ "version": "10.7.4",
1263
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz",
1264
+ "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==",
1265
+ "license": "MIT",
1266
+ "engines": {
1267
+ "node": ">= 12"
1268
+ }
1269
+ },
1270
+ "node_modules/ipaddr.js": {
1271
+ "version": "1.9.1",
1272
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
1273
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
1274
+ "license": "MIT",
1275
+ "engines": {
1276
+ "node": ">= 0.10"
1277
+ }
1278
+ },
1279
+ "node_modules/is-promise": {
1280
+ "version": "4.0.0",
1281
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
1282
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
1283
+ "license": "MIT"
1284
+ },
1285
+ "node_modules/math-intrinsics": {
1286
+ "version": "1.1.0",
1287
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
1288
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
1289
+ "license": "MIT",
1290
+ "engines": {
1291
+ "node": ">= 0.4"
1292
+ }
1293
+ },
1294
+ "node_modules/media-typer": {
1295
+ "version": "1.1.1",
1296
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
1297
+ "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
1298
+ "license": "MIT",
1299
+ "engines": {
1300
+ "node": ">= 0.8"
1301
+ },
1302
+ "funding": {
1303
+ "type": "opencollective",
1304
+ "url": "https://opencollective.com/express"
1305
+ }
1306
+ },
1307
+ "node_modules/merge-descriptors": {
1308
+ "version": "2.0.0",
1309
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
1310
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
1311
+ "license": "MIT",
1312
+ "engines": {
1313
+ "node": ">=18"
1314
+ },
1315
+ "funding": {
1316
+ "url": "https://github.com/sponsors/sindresorhus"
1317
+ }
1318
+ },
1319
+ "node_modules/mime-db": {
1320
+ "version": "1.54.0",
1321
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
1322
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
1323
+ "license": "MIT",
1324
+ "engines": {
1325
+ "node": ">= 0.6"
1326
+ }
1327
+ },
1328
+ "node_modules/mime-types": {
1329
+ "version": "3.0.2",
1330
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
1331
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
1332
+ "license": "MIT",
1333
+ "dependencies": {
1334
+ "mime-db": "^1.54.0"
1335
+ },
1336
+ "engines": {
1337
+ "node": ">=18"
1338
+ },
1339
+ "funding": {
1340
+ "type": "opencollective",
1341
+ "url": "https://opencollective.com/express"
1342
+ }
1343
+ },
1344
+ "node_modules/ms": {
1345
+ "version": "2.1.3",
1346
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1347
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1348
+ "license": "MIT"
1349
+ },
1350
+ "node_modules/negotiator": {
1351
+ "version": "1.1.0",
1352
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
1353
+ "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
1354
+ "license": "MIT",
1355
+ "dependencies": {
1356
+ "content-type": "^2.1.0"
1357
+ },
1358
+ "engines": {
1359
+ "node": ">=18"
1360
+ },
1361
+ "funding": {
1362
+ "type": "opencollective",
1363
+ "url": "https://opencollective.com/express"
1364
+ }
1365
+ },
1366
+ "node_modules/object-assign": {
1367
+ "version": "4.1.1",
1368
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
1369
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
1370
+ "license": "MIT",
1371
+ "engines": {
1372
+ "node": ">=0.10.0"
1373
+ }
1374
+ },
1375
+ "node_modules/object-inspect": {
1376
+ "version": "1.13.4",
1377
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
1378
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
1379
+ "license": "MIT",
1380
+ "engines": {
1381
+ "node": ">= 0.4"
1382
+ },
1383
+ "funding": {
1384
+ "url": "https://github.com/sponsors/ljharb"
1385
+ }
1386
+ },
1387
+ "node_modules/on-finished": {
1388
+ "version": "2.4.1",
1389
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
1390
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
1391
+ "license": "MIT",
1392
+ "dependencies": {
1393
+ "ee-first": "1.1.1"
1394
+ },
1395
+ "engines": {
1396
+ "node": ">= 0.8"
1397
+ }
1398
+ },
1399
+ "node_modules/once": {
1400
+ "version": "1.4.0",
1401
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
1402
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
1403
+ "license": "ISC",
1404
+ "dependencies": {
1405
+ "wrappy": "1"
1406
+ }
1407
+ },
1408
+ "node_modules/parseurl": {
1409
+ "version": "1.3.3",
1410
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
1411
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
1412
+ "license": "MIT",
1413
+ "engines": {
1414
+ "node": ">= 0.8"
1415
+ }
1416
+ },
1417
+ "node_modules/path-to-regexp": {
1418
+ "version": "8.4.2",
1419
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
1420
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
1421
+ "license": "MIT",
1422
+ "funding": {
1423
+ "type": "opencollective",
1424
+ "url": "https://opencollective.com/express"
1425
+ }
1426
+ },
1427
+ "node_modules/proxy-addr": {
1428
+ "version": "2.0.8",
1429
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
1430
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
1431
+ "license": "MIT",
1432
+ "dependencies": {
1433
+ "forwarded": "0.2.0",
1434
+ "ipaddr.js": "1.9.1"
1435
+ },
1436
+ "engines": {
1437
+ "node": ">= 0.10"
1438
+ },
1439
+ "funding": {
1440
+ "type": "opencollective",
1441
+ "url": "https://opencollective.com/express"
1442
+ }
1443
+ },
1444
+ "node_modules/qs": {
1445
+ "version": "6.16.1",
1446
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
1447
+ "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
1448
+ "license": "BSD-3-Clause",
1449
+ "dependencies": {
1450
+ "es-define-property": "^1.0.1",
1451
+ "side-channel": "^1.1.1"
1452
+ },
1453
+ "engines": {
1454
+ "node": ">=0.6"
1455
+ },
1456
+ "funding": {
1457
+ "url": "https://github.com/sponsors/ljharb"
1458
+ }
1459
+ },
1460
+ "node_modules/range-parser": {
1461
+ "version": "1.3.0",
1462
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
1463
+ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
1464
+ "license": "MIT",
1465
+ "engines": {
1466
+ "node": ">= 0.6"
1467
+ },
1468
+ "funding": {
1469
+ "type": "opencollective",
1470
+ "url": "https://opencollective.com/express"
1471
+ }
1472
+ },
1473
+ "node_modules/raw-body": {
1474
+ "version": "3.0.2",
1475
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
1476
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
1477
+ "license": "MIT",
1478
+ "dependencies": {
1479
+ "bytes": "~3.1.2",
1480
+ "http-errors": "~2.0.1",
1481
+ "iconv-lite": "~0.7.0",
1482
+ "unpipe": "~1.0.0"
1483
+ },
1484
+ "engines": {
1485
+ "node": ">= 0.10"
1486
+ }
1487
+ },
1488
+ "node_modules/router": {
1489
+ "version": "2.2.0",
1490
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
1491
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
1492
+ "license": "MIT",
1493
+ "dependencies": {
1494
+ "debug": "^4.4.0",
1495
+ "depd": "^2.0.0",
1496
+ "is-promise": "^4.0.0",
1497
+ "parseurl": "^1.3.3",
1498
+ "path-to-regexp": "^8.0.0"
1499
+ },
1500
+ "engines": {
1501
+ "node": ">= 18"
1502
+ }
1503
+ },
1504
+ "node_modules/safer-buffer": {
1505
+ "version": "2.1.2",
1506
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
1507
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
1508
+ "license": "MIT"
1509
+ },
1510
+ "node_modules/send": {
1511
+ "version": "1.2.1",
1512
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
1513
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
1514
+ "license": "MIT",
1515
+ "dependencies": {
1516
+ "debug": "^4.4.3",
1517
+ "encodeurl": "^2.0.0",
1518
+ "escape-html": "^1.0.3",
1519
+ "etag": "^1.8.1",
1520
+ "fresh": "^2.0.0",
1521
+ "http-errors": "^2.0.1",
1522
+ "mime-types": "^3.0.2",
1523
+ "ms": "^2.1.3",
1524
+ "on-finished": "^2.4.1",
1525
+ "range-parser": "^1.2.1",
1526
+ "statuses": "^2.0.2"
1527
+ },
1528
+ "engines": {
1529
+ "node": ">= 18"
1530
+ },
1531
+ "funding": {
1532
+ "type": "opencollective",
1533
+ "url": "https://opencollective.com/express"
1534
+ }
1535
+ },
1536
+ "node_modules/serve-static": {
1537
+ "version": "2.2.1",
1538
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
1539
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
1540
+ "license": "MIT",
1541
+ "dependencies": {
1542
+ "encodeurl": "^2.0.0",
1543
+ "escape-html": "^1.0.3",
1544
+ "parseurl": "^1.3.3",
1545
+ "send": "^1.2.0"
1546
+ },
1547
+ "engines": {
1548
+ "node": ">= 18"
1549
+ },
1550
+ "funding": {
1551
+ "type": "opencollective",
1552
+ "url": "https://opencollective.com/express"
1553
+ }
1554
+ },
1555
+ "node_modules/setprototypeof": {
1556
+ "version": "1.2.0",
1557
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
1558
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
1559
+ "license": "ISC"
1560
+ },
1561
+ "node_modules/side-channel": {
1562
+ "version": "1.1.1",
1563
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
1564
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
1565
+ "license": "MIT",
1566
+ "dependencies": {
1567
+ "es-errors": "^1.3.0",
1568
+ "object-inspect": "^1.13.4",
1569
+ "side-channel-list": "^1.0.1",
1570
+ "side-channel-map": "^1.0.1",
1571
+ "side-channel-weakmap": "^1.0.2"
1572
+ },
1573
+ "engines": {
1574
+ "node": ">= 0.4"
1575
+ },
1576
+ "funding": {
1577
+ "url": "https://github.com/sponsors/ljharb"
1578
+ }
1579
+ },
1580
+ "node_modules/side-channel-list": {
1581
+ "version": "1.0.1",
1582
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
1583
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
1584
+ "license": "MIT",
1585
+ "dependencies": {
1586
+ "es-errors": "^1.3.0",
1587
+ "object-inspect": "^1.13.4"
1588
+ },
1589
+ "engines": {
1590
+ "node": ">= 0.4"
1591
+ },
1592
+ "funding": {
1593
+ "url": "https://github.com/sponsors/ljharb"
1594
+ }
1595
+ },
1596
+ "node_modules/side-channel-map": {
1597
+ "version": "1.0.1",
1598
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
1599
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
1600
+ "license": "MIT",
1601
+ "dependencies": {
1602
+ "call-bound": "^1.0.2",
1603
+ "es-errors": "^1.3.0",
1604
+ "get-intrinsic": "^1.2.5",
1605
+ "object-inspect": "^1.13.3"
1606
+ },
1607
+ "engines": {
1608
+ "node": ">= 0.4"
1609
+ },
1610
+ "funding": {
1611
+ "url": "https://github.com/sponsors/ljharb"
1612
+ }
1613
+ },
1614
+ "node_modules/side-channel-weakmap": {
1615
+ "version": "1.0.2",
1616
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
1617
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
1618
+ "license": "MIT",
1619
+ "dependencies": {
1620
+ "call-bound": "^1.0.2",
1621
+ "es-errors": "^1.3.0",
1622
+ "get-intrinsic": "^1.2.5",
1623
+ "object-inspect": "^1.13.3",
1624
+ "side-channel-map": "^1.0.1"
1625
+ },
1626
+ "engines": {
1627
+ "node": ">= 0.4"
1628
+ },
1629
+ "funding": {
1630
+ "url": "https://github.com/sponsors/ljharb"
1631
+ }
1632
+ },
1633
+ "node_modules/statuses": {
1634
+ "version": "2.0.2",
1635
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
1636
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
1637
+ "license": "MIT",
1638
+ "engines": {
1639
+ "node": ">= 0.8"
1640
+ }
1641
+ },
1642
+ "node_modules/toidentifier": {
1643
+ "version": "1.0.1",
1644
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
1645
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
1646
+ "license": "MIT",
1647
+ "engines": {
1648
+ "node": ">=0.6"
1649
+ }
1650
+ },
1651
+ "node_modules/tslib": {
1652
+ "version": "2.8.1",
1653
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
1654
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
1655
+ "license": "0BSD"
1656
+ },
1657
+ "node_modules/tsx": {
1658
+ "version": "4.23.15",
1659
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
1660
+ "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
1661
+ "dev": true,
1662
+ "license": "MIT",
1663
+ "dependencies": {
1664
+ "esbuild": "~0.28.0"
1665
+ },
1666
+ "bin": {
1667
+ "tsx": "dist/cli.mjs"
1668
+ },
1669
+ "engines": {
1670
+ "node": ">=18.0.0"
1671
+ },
1672
+ "optionalDependencies": {
1673
+ "fsevents": "~2.3.3"
1674
+ }
1675
+ },
1676
+ "node_modules/type-is": {
1677
+ "version": "2.1.0",
1678
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
1679
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
1680
+ "license": "MIT",
1681
+ "dependencies": {
1682
+ "content-type": "^2.0.0",
1683
+ "media-typer": "^1.1.0",
1684
+ "mime-types": "^3.0.0"
1685
+ },
1686
+ "engines": {
1687
+ "node": ">= 18"
1688
+ },
1689
+ "funding": {
1690
+ "type": "opencollective",
1691
+ "url": "https://opencollective.com/express"
1692
+ }
1693
+ },
1694
+ "node_modules/typescript": {
1695
+ "version": "5.9.3",
1696
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1697
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1698
+ "dev": true,
1699
+ "license": "Apache-2.0",
1700
+ "bin": {
1701
+ "tsc": "bin/tsc",
1702
+ "tsserver": "bin/tsserver"
1703
+ },
1704
+ "engines": {
1705
+ "node": ">=14.17"
1706
+ }
1707
+ },
1708
+ "node_modules/undici-types": {
1709
+ "version": "6.21.0",
1710
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
1711
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
1712
+ "dev": true,
1713
+ "license": "MIT"
1714
+ },
1715
+ "node_modules/unpipe": {
1716
+ "version": "1.0.0",
1717
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
1718
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
1719
+ "license": "MIT",
1720
+ "engines": {
1721
+ "node": ">= 0.8"
1722
+ }
1723
+ },
1724
+ "node_modules/vary": {
1725
+ "version": "1.1.2",
1726
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
1727
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
1728
+ "license": "MIT",
1729
+ "engines": {
1730
+ "node": ">= 0.8"
1731
+ }
1732
+ },
1733
+ "node_modules/wrappy": {
1734
+ "version": "1.0.2",
1735
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
1736
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
1737
+ "license": "ISC"
1738
+ },
1739
+ "node_modules/zod": {
1740
+ "version": "4.6.7",
1741
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
1742
+ "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
1743
+ "license": "MIT",
1744
+ "funding": {
1745
+ "url": "https://github.com/sponsors/colinhacks"
1746
+ }
1747
+ }
1748
+ }
1749
+ }
package.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "sporalize-backend",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "tsx watch src/server.ts",
8
+ "build": "tsc -p tsconfig.json",
9
+ "start": "python start.py"
10
+ },
11
+ "dependencies": {
12
+ "@supabase/supabase-js": "^2.57.0",
13
+ "bcryptjs": "^3.0.2",
14
+ "cookie-parser": "^1.4.7",
15
+ "cors": "^2.8.5",
16
+ "dotenv": "^16.6.1",
17
+ "express": "^5.1.0",
18
+ "express-rate-limit": "^8.1.0",
19
+ "helmet": "^8.1.0",
20
+ "zod": "^4.1.5"
21
+ },
22
+ "devDependencies": {
23
+ "@types/cookie-parser": "^1.4.9",
24
+ "@types/cors": "^2.8.19",
25
+ "@types/express": "^5.0.3",
26
+ "@types/node": "^22.16.5",
27
+ "tsx": "^4.20.5",
28
+ "typescript": "^5.8.3"
29
+ }
30
+ }
src/analysis.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import express from "express";
3
+ import type { SupabaseClient } from "@supabase/supabase-js";
4
+ import { z } from "zod";
5
+
6
+ export function stableId(value: string) {
7
+ const hex = createHash("sha256").update(value).digest("hex");
8
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
9
+ }
10
+
11
+ const beginSchema = z.object({ clientId: z.uuid(), playerId: z.string().trim().min(1).max(100), name: z.string().trim().max(150) });
12
+ const reportSchema = z.object({
13
+ index: z.number().int().nonnegative(), action: z.string().min(1).max(50),
14
+ start_frame: z.number().int().nonnegative().optional(), end_frame: z.number().int().nonnegative().optional(),
15
+ error: z.string().max(2000).optional(),
16
+ });
17
+ export const completionSchema = z.discriminatedUnion("status", [
18
+ z.object({ status: z.literal("completed"), path: z.string(), actions: z.array(reportSchema).max(5000) }),
19
+ z.object({ status: z.enum(["failed", "cancelled"]) }),
20
+ ]);
21
+
22
+ export function actionRows(sessionId: string, reports: z.infer<typeof reportSchema>[]) {
23
+ if (new Set(reports.map((report) => report.index)).size !== reports.length) throw new Error("Duplicate action index");
24
+ return reports.filter((report) => report.error === undefined).map((report) => {
25
+ const { start_frame: startFrame, end_frame: endFrame } = report;
26
+ if (startFrame === undefined || endFrame === undefined || endFrame < startFrame) throw new Error("Invalid action interval");
27
+ return { action_id: stableId(`${sessionId}:${report.index}`), type: report.action,
28
+ start_frame: startFrame, end_frame: endFrame };
29
+ });
30
+ }
31
+
32
+ export function analysisRouter(db: SupabaseClient, secret: string) {
33
+ const router = express.Router();
34
+ router.use((req, res, next) => {
35
+ const supplied = Buffer.from(req.header("x-sporalize-proxy-secret") ?? "");
36
+ const expected = Buffer.from(secret);
37
+ if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) return res.sendStatus(403);
38
+ const user = z.uuid().safeParse(req.header("x-sporalize-user-id"));
39
+ if (!user.success) return res.sendStatus(403);
40
+ res.locals.userId = user.data;
41
+ next();
42
+ });
43
+ router.use(express.json({ limit: "2mb" }));
44
+ router.post("/", async (req, res, next) => {
45
+ try {
46
+ const input = beginSchema.parse(req.body);
47
+ const playerId = z.uuid().safeParse(input.playerId);
48
+ const { data, error } = await db.rpc("begin_analysis", {
49
+ p_session_id: input.clientId, p_user_id: res.locals.userId,
50
+ p_player_id: playerId.success ? playerId.data : stableId(`${res.locals.userId}:player:${input.playerId}`),
51
+ p_reference: playerId.success ? null : input.playerId,
52
+ p_name: input.name || `Session ${input.clientId.slice(0, 8)}`,
53
+ });
54
+ if (error) return res.status(error.code === "P0001" ? 409 : 503).json({ error: "Unable to prepare analysis session" });
55
+ return res.json(data);
56
+ } catch (error) { next(error); }
57
+ });
58
+ router.get("/:sessionId", async (req, res, next) => {
59
+ try {
60
+ const sessionId = z.uuid().parse(req.params.sessionId);
61
+ const { data, error } = await db.from("sessions").select("session_id,player_id,path,status,name,created_at")
62
+ .eq("session_id", sessionId).eq("user_id", res.locals.userId).maybeSingle();
63
+ if (error) return res.status(503).json({ error: "Unable to load session" });
64
+ return data ? res.json(data) : res.sendStatus(404);
65
+ } catch (error) { next(error); }
66
+ });
67
+ router.put("/:sessionId", async (req, res, next) => {
68
+ try {
69
+ const sessionId = z.uuid().parse(req.params.sessionId);
70
+ const input = completionSchema.parse(req.body);
71
+ if (input.status === "completed" && input.path !== `sessions/${sessionId}`) return res.sendStatus(400);
72
+ let actions;
73
+ try { actions = input.status === "completed" ? actionRows(sessionId, input.actions) : []; }
74
+ catch { return res.status(400).json({ error: "Invalid action details" }); }
75
+ const { error } = await db.rpc("finish_analysis", {
76
+ p_session_id: sessionId, p_user_id: res.locals.userId, p_status: input.status,
77
+ p_path: input.status === "completed" ? input.path : null, p_actions: actions,
78
+ });
79
+ if (error) return res.status(error.code === "P0001" ? 409 : 503).json({ error: "Unable to save analysis" });
80
+ return res.json({ ok: true });
81
+ } catch (error) { next(error); }
82
+ });
83
+ return router;
84
+ }
src/auth.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Request, Response } from "express";
2
+ import type { Session, User } from "@supabase/supabase-js";
3
+ import { env, isProduction } from "./config.js";
4
+ import { supabaseAuth } from "./supabase.js";
5
+
6
+ const accessCookie = "sp_access";
7
+ const refreshCookie = "sp_refresh";
8
+ const sameSite = isProduction ? "none" as const : "lax" as const;
9
+ const cookieOptions = { httpOnly: true, secure: isProduction, sameSite, path: "/" };
10
+
11
+ export function setSessionCookies(res: Response, session: Session) {
12
+ res.cookie(accessCookie, session.access_token, { ...cookieOptions, maxAge: Math.max(session.expires_in * 1000, 60_000) });
13
+ res.cookie(refreshCookie, session.refresh_token, { ...cookieOptions, maxAge: 1000 * 60 * 60 * 24 * 30 });
14
+ }
15
+
16
+ export function clearSessionCookies(res: Response) {
17
+ res.clearCookie(accessCookie, cookieOptions);
18
+ res.clearCookie(refreshCookie, cookieOptions);
19
+ }
20
+
21
+ export async function authenticatedUser(req: Request, res: Response): Promise<{ user: User; session?: Session } | null> {
22
+ const accessToken = req.cookies?.[accessCookie] as string | undefined;
23
+ const refreshToken = req.cookies?.[refreshCookie] as string | undefined;
24
+ if (!accessToken && !refreshToken) return null;
25
+
26
+ if (accessToken) {
27
+ const { data, error } = await supabaseAuth.auth.getUser(accessToken);
28
+ if (data.user && !error) return { user: data.user };
29
+ }
30
+
31
+ if (!refreshToken) {
32
+ clearSessionCookies(res);
33
+ return null;
34
+ }
35
+ const { data, error } = await supabaseAuth.auth.refreshSession({ refresh_token: refreshToken });
36
+ if (error || !data.user || !data.session) {
37
+ clearSessionCookies(res);
38
+ return null;
39
+ }
40
+ setSessionCookies(res, data.session);
41
+ return { user: data.user, session: data.session };
42
+ }
43
+
44
+ export function safeRedirect(path: unknown) {
45
+ const frontend = new URL(env.FRONTEND_URL);
46
+ const fallback = new URL("/labs", frontend).toString();
47
+ if (typeof path !== "string" || !path.startsWith("/")) return fallback;
48
+ try {
49
+ const target = new URL(path, frontend);
50
+ return target.origin === frontend.origin ? target.toString() : fallback;
51
+ } catch {
52
+ return fallback;
53
+ }
54
+ }
src/config.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "dotenv/config";
2
+ import { z } from "zod";
3
+
4
+ const envSchema = z.object({
5
+ PORT: z.coerce.number().int().min(1).max(65535).default(4000),
6
+ TRUST_PROXY_HOPS: z.coerce.number().int().min(0).max(5).default(1),
7
+ NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
8
+ FRONTEND_URL: z.string().url(),
9
+ BACKEND_URL: z.string().url(),
10
+ ANALYSIS_API_URL: z.string().url().default("http://127.0.0.1:8000"),
11
+ ANALYSIS_PROXY_SECRET: z.string().min(32),
12
+ SUPABASE_URL: z.string().url(),
13
+ SUPABASE_ANON_KEY: z.string().min(1),
14
+ SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
15
+ });
16
+
17
+ const parsed = envSchema.safeParse(process.env);
18
+ if (!parsed.success) {
19
+ console.error("Invalid environment configuration:", parsed.error.flatten().fieldErrors);
20
+ process.exit(1);
21
+ }
22
+
23
+ export const env = parsed.data;
24
+ export const isProduction = env.NODE_ENV === "production";
25
+
26
+ if (isProduction) {
27
+ for (const name of ["FRONTEND_URL", "BACKEND_URL", "SUPABASE_URL"] as const) {
28
+ const url = new URL(env[name]);
29
+ if (url.protocol !== "https:" || ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) {
30
+ console.error(`${name} must use a public HTTPS URL in production`);
31
+ process.exit(1);
32
+ }
33
+ }
34
+ }
35
+
36
+
37
+ // Trusted identity headers must stay inside the combined backend.
38
+ const analysisUrl = new URL(env.ANALYSIS_API_URL);
39
+ if (analysisUrl.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(analysisUrl.hostname)) {
40
+ console.error("ANALYSIS_API_URL must use HTTP on loopback");
41
+ process.exit(1);
42
+ }
src/server.ts ADDED
@@ -0,0 +1,494 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express, { type NextFunction, type Request, type Response } from "express";
2
+ import cors from "cors";
3
+ import cookieParser from "cookie-parser";
4
+ import helmet from "helmet";
5
+ import rateLimit from "express-rate-limit";
6
+ import { Readable } from "node:stream";
7
+ import { pipeline } from "node:stream/promises";
8
+ import { z } from "zod";
9
+ import { env } from "./config.js";
10
+ import { analysisRouter } from "./analysis.js";
11
+ import { authenticatedUser, clearSessionCookies, safeRedirect, setSessionCookies } from "./auth.js";
12
+ import { hashPassword, supabaseAdmin, supabaseAuth, syncProfile, userHasPassword, userPayload } from "./supabase.js";
13
+
14
+ const app = express();
15
+ app.disable("x-powered-by");
16
+ app.set("trust proxy", env.TRUST_PROXY_HOPS);
17
+ app.use(helmet());
18
+ app.use(cors({
19
+ origin: env.FRONTEND_URL,
20
+ credentials: true,
21
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
22
+ }));
23
+ app.use("/internal/analysis", analysisRouter(supabaseAdmin, env.ANALYSIS_PROXY_SECRET));
24
+ app.use(express.json({ limit: "32kb" }));
25
+ app.use(cookieParser());
26
+ app.use((req, res, next) => {
27
+ if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
28
+ if (req.header("origin") !== new URL(env.FRONTEND_URL).origin) return res.sendStatus(403);
29
+ return next();
30
+ });
31
+
32
+ const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 25, standardHeaders: "draft-8", legacyHeaders: false });
33
+ const signupSchema = z.object({ fname: z.string().trim().min(1).max(100), lname: z.string().trim().min(1).max(100), email: z.string().trim().toLowerCase().email().max(255), password: z.string().min(8).max(128), academy: z.string().trim().max(150).optional() });
34
+ const loginSchema = z.object({ email: z.string().trim().toLowerCase().email(), password: z.string().min(1).max(128) });
35
+ const profileRoles = ["football_player", "coach", "club_manager", "academy_manager", "scout", "performance_analyst", "sports_director", "other"] as const;
36
+ const profileSchema = z.object({
37
+ fname: z.string().trim().min(1).max(100),
38
+ lname: z.string().trim().min(1).max(100),
39
+ academy: z.string().trim().max(150).transform((value) => value || null).nullable(),
40
+ role: z.enum(profileRoles).nullable(),
41
+ });
42
+ const passwordSchema = z.object({ currentPassword: z.string().max(128).optional(), newPassword: z.string().min(8).max(128) });
43
+
44
+ async function activeSubscriptionFor(userId: string) {
45
+ const today = new Date().toISOString().slice(0, 10);
46
+ const { data: assignment, error } = await supabaseAdmin.from("users_subscriptions")
47
+ .select("subscription_id,startdate,enddate").eq("user_id", userId).maybeSingle();
48
+ if (error) throw new Error(`Unable to check subscription: ${error.message}`);
49
+ if (!assignment?.subscription_id || assignment.startdate > today || assignment.enddate < today) return null;
50
+
51
+ const { data: plan, error: planError } = await supabaseAdmin.from("subscriptions")
52
+ .select("subscription_id,plan,price").eq("subscription_id", assignment.subscription_id).maybeSingle();
53
+ if (planError) throw new Error(`Unable to load subscription plan: ${planError.message}`);
54
+ return plan ? { ...plan, startdate: assignment.startdate, enddate: assignment.enddate } : null;
55
+ }
56
+
57
+ async function requireActiveSubscription(req: Request, res: Response) {
58
+ const current = await authenticatedUser(req, res);
59
+ if (!current) {
60
+ res.status(401).json({ error: "Authentication required." });
61
+ return null;
62
+ }
63
+ if (!await activeSubscriptionFor(current.user.id)) {
64
+ res.status(402).json({ error: "An active subscription is required to access Sporalize Labs.", code: "subscription_required" });
65
+ return null;
66
+ }
67
+ return current;
68
+ }
69
+
70
+ app.get("/health", (_req, res) => res.json({ ok: true }));
71
+
72
+ app.get("/api/subscriptions", async (_req, res, next) => {
73
+ try {
74
+ const { data, error } = await supabaseAdmin.from("subscriptions")
75
+ .select("subscription_id,plan,price")
76
+ .order("price", { ascending: true });
77
+ if (error) throw new Error(`Unable to load subscription plans: ${error.message}`);
78
+ const plans = (data ?? []).map((row) => ({
79
+ id: row.plan.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-"),
80
+ subscriptionId: row.subscription_id,
81
+ name: row.plan,
82
+ monthlyPrice: Number(row.price),
83
+ }));
84
+ return res.json({ plans });
85
+ } catch (error) { next(error); }
86
+ });
87
+
88
+ app.get("/api/subscription/access", async (req, res, next) => {
89
+ try {
90
+ const current = await authenticatedUser(req, res);
91
+ if (!current) return res.status(401).json({ error: "Authentication required." });
92
+ const subscription = await activeSubscriptionFor(current.user.id);
93
+ return res.json({ active: Boolean(subscription), subscription });
94
+ } catch (error) { next(error); }
95
+ });
96
+
97
+ // Players and sessions are currently a shared workspace: all signed-in users
98
+ // can read the common catalogue. Revisit these read scopes if per-user data is
99
+ // introduced later. The service-role client bypasses RLS, so keep this explicit.
100
+ app.get("/api/players-overview", async (req, res, next) => {
101
+ try {
102
+ const current = await authenticatedUser(req, res);
103
+ if (!current) return res.status(401).json({ error: "Authentication required." });
104
+
105
+ const { data: rows, error } = await supabaseAdmin.from("players")
106
+ .select("player_id,user_id,fname,lname,bdate,height,weight,position,club")
107
+ .order("fname", { ascending: true });
108
+ if (error) throw new Error(`Unable to load players: ${error.message}`);
109
+
110
+ const playerIds = (rows ?? []).map((row) => row.player_id);
111
+ const { data: sessions, error: sessionsError } = playerIds.length
112
+ ? await supabaseAdmin.from("sessions").select("session_id,player_id,name,created_at")
113
+ .in("player_id", playerIds).order("created_at", { ascending: false })
114
+ : { data: [], error: null };
115
+ if (sessionsError) throw new Error(`Unable to load player sessions: ${sessionsError.message}`);
116
+
117
+ const sessionIds = (sessions ?? []).map((row) => row.session_id);
118
+ const { data: actions, error: actionsError } = sessionIds.length
119
+ ? await supabaseAdmin.from("actions").select("action_id,session_id,type")
120
+ .in("session_id", sessionIds)
121
+ : { data: [], error: null };
122
+ if (actionsError) throw new Error(`Unable to load player actions: ${actionsError.message}`);
123
+
124
+ const actionIds = (actions ?? []).map((row) => row.action_id);
125
+ const { data: annotations, error: annotationsError } = actionIds.length
126
+ ? await supabaseAdmin.from("annotations").select("action_id,score").in("action_id", actionIds)
127
+ : { data: [], error: null };
128
+ if (annotationsError) throw new Error(`Unable to load action scores: ${annotationsError.message}`);
129
+
130
+ const scoresByAction = new Map((annotations ?? []).map((row) => [row.action_id, row.score == null ? null : Number(row.score)]));
131
+ const sessionsByPlayer = new Map<string, typeof sessions>();
132
+ for (const session of sessions ?? []) sessionsByPlayer.set(session.player_id, [...(sessionsByPlayer.get(session.player_id) ?? []), session]);
133
+ const actionsBySession = new Map<string, typeof actions>();
134
+ for (const action of actions ?? []) actionsBySession.set(action.session_id, [...(actionsBySession.get(action.session_id) ?? []), action]);
135
+
136
+ const players = (rows ?? []).map((row) => {
137
+ const playerSessions = sessionsByPlayer.get(row.player_id) ?? [];
138
+ const playerActions = playerSessions.flatMap((session) => actionsBySession.get(session.session_id) ?? []);
139
+ const scored = playerActions.map((action) => scoresByAction.get(action.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
140
+ const byAction = [...new Set(playerActions.map((action) => action.type))].map((type) => {
141
+ const matching = playerActions.filter((action) => action.type === type);
142
+ const values = matching.map((action) => scoresByAction.get(action.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
143
+ return { axis: type, score: values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null, samples: values.length };
144
+ });
145
+ const playerScoreSessions = playerSessions.map((session) => {
146
+ const sessionActions = actionsBySession.get(session.session_id) ?? [];
147
+ const values = sessionActions.map((action) => scoresByAction.get(action.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
148
+ return { sessionId: session.session_id, createdAt: Date.parse(session.created_at) || 0, score: values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null, actionCount: sessionActions.length, scoredCount: values.length };
149
+ });
150
+ return {
151
+ playerId: row.player_id,
152
+ name: [row.fname, row.lname].filter(Boolean).join(" "),
153
+ age: row.bdate ? String(Math.max(0, new Date().getFullYear() - new Date(row.bdate).getFullYear())) : "",
154
+ heightCm: row.height == null ? "" : String(row.height),
155
+ weightKg: row.weight == null ? "" : String(row.weight),
156
+ club: row.club ?? "", position: row.position ?? "", dominantFoot: "", notes: "", avatarPath: "",
157
+ createdAt: "", updatedAt: "", hasProfile: true,
158
+ sessionCount: playerSessions.length, actionCount: playerActions.length,
159
+ lastSessionAt: playerScoreSessions[0]?.createdAt ?? 0,
160
+ scores: {
161
+ playerId: row.player_id, rubricVersion: "database", totalActions: playerActions.length,
162
+ scoredActions: scored.length, byAction, byQuality: [],
163
+ overall: scored.length ? scored.reduce((sum, value) => sum + value, 0) / scored.length : null,
164
+ sessions: playerScoreSessions,
165
+ },
166
+ };
167
+ });
168
+
169
+ return res.json({ players });
170
+ } catch (error) { next(error); }
171
+ });
172
+
173
+ app.get("/api/players-overview/:playerId", async (req, res, next) => {
174
+ try {
175
+ const current = await authenticatedUser(req, res);
176
+ if (!current) return res.status(401).json({ error: "Authentication required." });
177
+ const playerId = z.string().uuid().safeParse(req.params.playerId);
178
+ if (!playerId.success) return res.status(404).json({ error: "Player not found." });
179
+ const { data, error } = await supabaseAdmin.from("players").select("player_id")
180
+ .eq("player_id", playerId.data).maybeSingle();
181
+ if (error) throw new Error(`Unable to load player: ${error.message}`);
182
+ if (!data) return res.status(404).json({ error: "Player not found." });
183
+
184
+ const { data: overview, error: overviewError } = await supabaseAdmin.from("players")
185
+ .select("player_id,user_id,fname,lname,bdate,height,weight,position,club")
186
+ .order("fname", { ascending: true });
187
+ if (overviewError) throw new Error(`Unable to load players: ${overviewError.message}`);
188
+ const { data: sessions, error: sessionsError } = await supabaseAdmin.from("sessions")
189
+ .select("session_id,player_id,name,created_at");
190
+ if (sessionsError) throw new Error(`Unable to load sessions: ${sessionsError.message}`);
191
+ const { data: actions, error: actionsError } = (sessions ?? []).length
192
+ ? await supabaseAdmin.from("actions").select("action_id,session_id,type").in("session_id", sessions!.map((row) => row.session_id))
193
+ : { data: [], error: null };
194
+ if (actionsError) throw new Error(`Unable to load actions: ${actionsError.message}`);
195
+ const actionIds = (actions ?? []).map((row) => row.action_id);
196
+ const { data: annotations, error: annotationsError } = actionIds.length
197
+ ? await supabaseAdmin.from("annotations").select("action_id,score").in("action_id", actionIds)
198
+ : { data: [], error: null };
199
+ if (annotationsError) throw new Error(`Unable to load action scores: ${annotationsError.message}`);
200
+
201
+ const scoreMap = new Map((annotations ?? []).map((row) => [row.action_id, row.score == null ? null : Number(row.score)]));
202
+ const ownSessions = (sessions ?? []).filter((row) => row.player_id === playerId.data);
203
+ const ownActions = (actions ?? []).filter((row) => ownSessions.some((session) => session.session_id === row.session_id));
204
+ const ownScores = ownActions.map((row) => scoreMap.get(row.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
205
+ const byAction = [...new Set(ownActions.map((row) => row.type))].map((type) => {
206
+ const matching = ownActions.filter((row) => row.type === type);
207
+ const values = matching.map((row) => scoreMap.get(row.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
208
+ return { axis: type, score: values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null, samples: values.length };
209
+ });
210
+ const player = overview?.find((row) => row.player_id === playerId.data);
211
+ if (!player) return res.status(404).json({ error: "Player not found." });
212
+ const sessionsForPlayer = ownSessions.map((row) => {
213
+ const sessionActions = ownActions.filter((action) => action.session_id === row.session_id);
214
+ const values = sessionActions.map((action) => scoreMap.get(action.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
215
+ return { sessionId: row.session_id, createdAt: Date.parse(row.created_at) || 0, score: values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null, actionCount: sessionActions.length, scoredCount: values.length };
216
+ });
217
+ const { data: peers, error: peersError } = await supabaseAdmin.from("players").select("player_id");
218
+ if (peersError) throw new Error(`Unable to load roster: ${peersError.message}`);
219
+ const squadAverage = { byQuality: [], byAction };
220
+ return res.json({
221
+ playerId: player.player_id, name: [player.fname, player.lname].filter(Boolean).join(" "),
222
+ age: player.bdate ? String(Math.max(0, new Date().getFullYear() - new Date(player.bdate).getFullYear())) : "",
223
+ heightCm: player.height == null ? "" : String(player.height), weightKg: player.weight == null ? "" : String(player.weight),
224
+ club: player.club ?? "", position: player.position ?? "", dominantFoot: "", notes: "", avatarPath: "",
225
+ createdAt: "", updatedAt: "", hasProfile: true, sessionCount: ownSessions.length, actionCount: ownActions.length,
226
+ lastSessionAt: sessionsForPlayer[0]?.createdAt ?? 0,
227
+ scores: { playerId: player.player_id, rubricVersion: "database", totalActions: ownActions.length, scoredActions: ownScores.length, byAction, byQuality: [], overall: ownScores.length ? ownScores.reduce((sum, value) => sum + value, 0) / ownScores.length : null, sessions: sessionsForPlayer },
228
+ squadAverage, rank: null, playerCount: peers?.length ?? 0,
229
+ });
230
+ } catch (error) { next(error); }
231
+ });
232
+
233
+ app.get("/api/sessions-overview", async (req, res, next) => {
234
+ try {
235
+ const current = await authenticatedUser(req, res);
236
+ if (!current) return res.status(401).json({ error: "Authentication required." });
237
+
238
+ const { data: sessions, error } = await supabaseAdmin.from("sessions")
239
+ .select("session_id,player_id,name,path,created_at,status")
240
+ .order("created_at", { ascending: false });
241
+ if (error) throw new Error(`Unable to load sessions: ${error.message}`);
242
+ const playerIds = [...new Set((sessions ?? []).flatMap((row) => row.player_id ? [row.player_id] : []))];
243
+ const { data: players, error: playersError } = playerIds.length
244
+ ? await supabaseAdmin.from("players").select("player_id,fname,lname").in("player_id", playerIds)
245
+ : { data: [], error: null };
246
+ if (playersError) throw new Error(`Unable to load session players: ${playersError.message}`);
247
+ const sessionIds = (sessions ?? []).map((row) => row.session_id);
248
+ const { data: actions, error: actionsError } = sessionIds.length
249
+ ? await supabaseAdmin.from("actions").select("session_id,type").in("session_id", sessionIds)
250
+ : { data: [], error: null };
251
+ if (actionsError) throw new Error(`Unable to load session actions: ${actionsError.message}`);
252
+ const playerNames = new Map((players ?? []).map((row) => [row.player_id, [row.fname, row.lname].filter(Boolean).join(" ")]));
253
+ const breakdownBySession = new Map<string, Record<string, number>>();
254
+ for (const action of actions ?? []) {
255
+ const breakdown = breakdownBySession.get(action.session_id) ?? {};
256
+ breakdown[action.type] = (breakdown[action.type] ?? 0) + 1;
257
+ breakdownBySession.set(action.session_id, breakdown);
258
+ }
259
+ return res.json({ sessions: (sessions ?? []).map((row) => {
260
+ const actionBreakdown = breakdownBySession.get(row.session_id) ?? {};
261
+ return {
262
+ sessionId: row.session_id, playerId: row.player_id ?? "", playerName: row.player_id ? playerNames.get(row.player_id) ?? "" : "",
263
+ name: row.name, createdAt: Date.parse(row.created_at) || 0,
264
+ actionCount: Object.values(actionBreakdown).reduce((sum, count) => sum + count, 0),
265
+ cameraCount: 0, actionBreakdown, path: row.path, status: row.status,
266
+ };
267
+ }) });
268
+ } catch (error) { next(error); }
269
+ });
270
+
271
+ // Session deletion is handled by the AI service through the proxy below so
272
+ // the session folder and database records are deleted together.
273
+
274
+ app.post("/api/auth/signup", authLimiter, async (req, res, next) => {
275
+ try {
276
+ const input = signupSchema.parse(req.body);
277
+ const passwordHash = await hashPassword(input.password);
278
+ const { data, error } = await supabaseAuth.auth.signUp({ email: input.email, password: input.password, options: { data: { fname: input.fname, lname: input.lname, academy: input.academy ?? null } } });
279
+ if (error) {
280
+ console.error("Supabase signup failed", { status: error.status, code: error.code, message: error.message });
281
+ return res.status(400).json({ error: env.NODE_ENV === "development" ? error.message : "Unable to create the account with these details." });
282
+ }
283
+ if (data.user?.identities?.length) await syncProfile(data.user, { ...input, passwordHash });
284
+ if (data.user && data.session) {
285
+ setSessionCookies(res, data.session);
286
+ return res.status(201).json({ user: userPayload(data.user), requiresEmailVerification: false });
287
+ }
288
+ return res.status(201).json({ user: data.user ? userPayload(data.user) : null, requiresEmailVerification: true });
289
+ } catch (error) { next(error); }
290
+ });
291
+
292
+ app.post("/api/auth/login", authLimiter, async (req, res, next) => {
293
+ try {
294
+ const input = loginSchema.parse(req.body);
295
+ const { data, error } = await supabaseAuth.auth.signInWithPassword(input);
296
+ if (error || !data.user || !data.session) return res.status(401).json({ error: "Invalid email or password." });
297
+ await syncProfile(data.user);
298
+ setSessionCookies(res, data.session);
299
+ return res.json({ user: userPayload(data.user) });
300
+ } catch (error) { next(error); }
301
+ });
302
+
303
+ app.get("/api/auth/google", authLimiter, async (req, res, next) => {
304
+ try {
305
+ const redirect = typeof req.query.redirect === "string" ? req.query.redirect : "/labs";
306
+ const { data, error } = await supabaseAuth.auth.signInWithOAuth({ provider: "google", options: { redirectTo: `${env.BACKEND_URL}/api/auth/callback?next=${encodeURIComponent(redirect)}` } });
307
+ if (error || !data.url) return res.status(502).json({ error: "Google sign-in is currently unavailable." });
308
+ return res.redirect(data.url);
309
+ } catch (error) { next(error); }
310
+ });
311
+
312
+ app.get("/api/auth/callback", async (req, res, next) => {
313
+ try {
314
+ const code = typeof req.query.code === "string" ? req.query.code : null;
315
+ // Explicitly terminate any fragment from an unexpected implicit-flow
316
+ // response so access tokens cannot be inherited by this redirect URL.
317
+ if (!code) return res.redirect(`${env.FRONTEND_URL}/login?error=oauth_callback_failed#`);
318
+ const { data, error } = await supabaseAuth.auth.exchangeCodeForSession(code);
319
+ if (error || !data.user || !data.session) return res.redirect(`${env.FRONTEND_URL}/login?error=oauth_callback_failed`);
320
+ await syncProfile(data.user);
321
+ setSessionCookies(res, data.session);
322
+ return res.redirect(safeRedirect(req.query.next));
323
+ } catch (error) { next(error); }
324
+ });
325
+
326
+ // Supabase confirmation emails can use {{ .TokenHash }} to deliver the one-time
327
+ // token directly to this server. This lets us establish the same HttpOnly
328
+ // cookie session used by password and Google sign-in.
329
+ app.get("/api/auth/confirm", async (req, res, next) => {
330
+ try {
331
+ const tokenHash = typeof req.query.token_hash === "string" ? req.query.token_hash : "";
332
+ if (!tokenHash) return res.redirect(`${env.FRONTEND_URL}/login?error=email_confirmation_failed`);
333
+
334
+ const { data, error } = await supabaseAuth.auth.verifyOtp({ token_hash: tokenHash, type: "email" });
335
+ if (error || !data.user || !data.session) return res.redirect(`${env.FRONTEND_URL}/login?error=email_confirmation_failed`);
336
+
337
+ await syncProfile(data.user);
338
+ setSessionCookies(res, data.session);
339
+ return res.redirect(`${env.FRONTEND_URL}/labs`);
340
+ } catch (error) { next(error); }
341
+ });
342
+
343
+ app.post("/api/auth/logout", async (req, res, next) => {
344
+ try {
345
+ clearSessionCookies(res);
346
+ return res.status(204).send();
347
+ } catch (error) { next(error); }
348
+ });
349
+
350
+ app.get("/api/auth/me", async (req, res, next) => {
351
+ try {
352
+ const current = await authenticatedUser(req, res);
353
+ if (!current) return res.status(401).json({ error: "Authentication required." });
354
+ return res.json({ user: userPayload(current.user) });
355
+ } catch (error) { next(error); }
356
+ });
357
+
358
+ app.get("/api/auth/profile", async (req, res, next) => {
359
+ try {
360
+ const current = await authenticatedUser(req, res);
361
+ if (!current) return res.status(401).json({ error: "Authentication required." });
362
+ await syncProfile(current.user);
363
+ const { data, error } = await supabaseAdmin.from("users")
364
+ .select("fname,lname,email,academy,role")
365
+ .eq("user_id", current.user.id)
366
+ .single();
367
+ if (error || !data) throw new Error(`Unable to load user profile: ${error?.message ?? "Profile not found"}`);
368
+ const hasPassword = userHasPassword(current.user);
369
+ return res.json({ profile: { ...data, emailVerified: Boolean(current.user.email_confirmed_at), hasPassword } });
370
+ } catch (error) { next(error); }
371
+ });
372
+
373
+ app.patch("/api/auth/profile", async (req, res, next) => {
374
+ try {
375
+ const current = await authenticatedUser(req, res);
376
+ if (!current) return res.status(401).json({ error: "Authentication required." });
377
+ const input = profileSchema.parse(req.body);
378
+ const profile = { ...input, academy: input.academy?.trim() || null };
379
+ const { data, error } = await supabaseAdmin.from("users")
380
+ .update(profile)
381
+ .eq("user_id", current.user.id)
382
+ .select("fname,lname,email,academy,role")
383
+ .single();
384
+ if (error || !data) throw new Error(`Unable to save user profile: ${error?.message ?? "Profile not found"}`);
385
+
386
+ // Keep auth metadata useful for integrations while the users row remains canonical.
387
+ const { error: metadataError } = await supabaseAdmin.auth.admin.updateUserById(current.user.id, {
388
+ user_metadata: { ...current.user.user_metadata, ...profile },
389
+ });
390
+ if (metadataError) console.error("Unable to sync profile metadata", { code: metadataError.code, message: metadataError.message });
391
+ return res.json({ profile: { ...data, emailVerified: Boolean(current.user.email_confirmed_at), hasPassword: userHasPassword(current.user) } });
392
+ } catch (error) { next(error); }
393
+ });
394
+
395
+ app.post("/api/auth/password", async (req, res, next) => {
396
+ try {
397
+ const current = await authenticatedUser(req, res);
398
+ if (!current) return res.status(401).json({ error: "Authentication required." });
399
+ const input = passwordSchema.parse(req.body);
400
+ const hasPassword = userHasPassword(current.user);
401
+ if (hasPassword) {
402
+ if (!input.currentPassword) return res.status(400).json({ error: "Enter your current password." });
403
+ const { error: verifyError } = await supabaseAuth.auth.signInWithPassword({ email: current.user.email ?? "", password: input.currentPassword });
404
+ if (verifyError) return res.status(400).json({ error: "Your current password is incorrect." });
405
+ }
406
+
407
+ const { error } = await supabaseAdmin.auth.admin.updateUserById(current.user.id, {
408
+ password: input.newPassword,
409
+ app_metadata: { ...current.user.app_metadata, has_password: true },
410
+ });
411
+ if (error) return res.status(400).json({ error: "Unable to update the password. Please try again." });
412
+ const passwordHash = await hashPassword(input.newPassword);
413
+ const { error: profileError } = await supabaseAdmin.from("users").update({ password_hash: passwordHash }).eq("user_id", current.user.id);
414
+ if (profileError) throw new Error(`Password updated but profile hash sync failed: ${profileError.message}`);
415
+ return res.json({ ok: true });
416
+ } catch (error) { next(error); }
417
+ });
418
+
419
+ app.delete("/api/auth/account", async (req, res, next) => {
420
+ try {
421
+ const current = await authenticatedUser(req, res);
422
+ if (!current) return res.status(401).json({ error: "Authentication required." });
423
+ if (req.body?.confirmation !== "DELETE") return res.status(400).json({ error: "Confirm account deletion by typing DELETE." });
424
+ const { error: authError } = await supabaseAdmin.auth.admin.deleteUser(current.user.id);
425
+ if (authError) return res.status(500).json({ error: "Unable to delete your account. Please try again." });
426
+ const { error: profileError } = await supabaseAdmin.from("users").delete().eq("user_id", current.user.id);
427
+ clearSessionCookies(res);
428
+ if (profileError) throw new Error(`Account deleted but profile cleanup failed: ${profileError.message}`);
429
+ return res.status(204).send();
430
+ } catch (error) { next(error); }
431
+ });
432
+
433
+ async function proxyAnalysisRequest(req: Request, res: Response) {
434
+ const current = await requireActiveSubscription(req, res);
435
+ if (!current) return;
436
+
437
+ const target = new URL(req.originalUrl, env.ANALYSIS_API_URL);
438
+ const headers = new Headers();
439
+ headers.set("x-sporalize-proxy-secret", env.ANALYSIS_PROXY_SECRET);
440
+ // Derived from the verified cookie session; never trust a browser-supplied ID.
441
+ headers.set("x-sporalize-user-id", current.user.id);
442
+ headers.set("x-forwarded-host", new URL(env.BACKEND_URL).host);
443
+ headers.set("x-forwarded-proto", new URL(env.BACKEND_URL).protocol.replace(":", ""));
444
+ for (const name of ["accept", "content-type", "range", "if-range"]) {
445
+ const value = req.header(name);
446
+ if (value) headers.set(name, value);
447
+ }
448
+
449
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
450
+ const body = !hasBody ? undefined : req.is("application/json")
451
+ ? JSON.stringify(req.body ?? {})
452
+ : req as unknown as BodyInit;
453
+ const init: RequestInit & { duplex?: "half" } = {
454
+ method: req.method,
455
+ headers,
456
+ body,
457
+ ...(body && typeof body !== "string" ? { duplex: "half" as const } : {}),
458
+ };
459
+
460
+ const upstream = await fetch(target, init);
461
+ res.status(upstream.status);
462
+ res.set("Cross-Origin-Resource-Policy", "cross-origin");
463
+ for (const name of ["content-type", "content-disposition", "cache-control", "accept-ranges", "content-range", "last-modified"]) {
464
+ const value = upstream.headers.get(name);
465
+ if (value) res.set(name, value);
466
+ }
467
+ if (!upstream.body) return res.end();
468
+ await pipeline(Readable.fromWeb(upstream.body as import("node:stream/web").ReadableStream), res);
469
+ }
470
+
471
+ const analysisProxy = (req: Request, res: Response, next: NextFunction) => {
472
+ void proxyAnalysisRequest(req, res).catch((error: unknown) => {
473
+ if (res.headersSent) return res.destroy(error instanceof Error ? error : undefined);
474
+ console.error("Analysis API proxy failed", error);
475
+ return res.status(502).json({ error: "The analysis service is unavailable." });
476
+ });
477
+ };
478
+
479
+ // All analysis API traffic and stored media pass through this subscription
480
+ // gate. Auth and subscription status endpoints remain available without a plan.
481
+ app.use("/api", (req, res, next) => {
482
+ if (req.originalUrl.startsWith("/api/auth/") || req.originalUrl === "/api/subscriptions" || req.originalUrl === "/api/subscription/access" || req.originalUrl === "/api/public-config") return next();
483
+ return analysisProxy(req, res, next);
484
+ });
485
+ app.use("/storage", analysisProxy);
486
+
487
+ app.use((_req, res) => res.status(404).json({ error: "Route not found." }));
488
+ app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
489
+ console.error(error);
490
+ if (error instanceof z.ZodError) return res.status(400).json({ error: "Please check the submitted fields." });
491
+ return res.status(500).json({ error: "Something went wrong. Please try again." });
492
+ });
493
+
494
+ app.listen(env.PORT, () => console.log(`Sporalize API listening on http://localhost:${env.PORT}`));
src/subscriptions.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { supabaseAdmin } from "./supabase.js";
2
+
3
+ type AssignSubscriptionInput = {
4
+ userId: string;
5
+ subscriptionId: string;
6
+ startDate: string;
7
+ endDate: string;
8
+ };
9
+
10
+ /**
11
+ * Persist a paid subscription after a payment provider's webhook has been
12
+ * authenticated and its payment has been confirmed. Never call this directly
13
+ * from a browser request.
14
+ */
15
+ export async function assignPaidSubscription(input: AssignSubscriptionInput) {
16
+ const { userId, subscriptionId, startDate, endDate } = input;
17
+ const isoDate = /^\d{4}-\d{2}-\d{2}$/;
18
+ if (!isoDate.test(startDate) || !isoDate.test(endDate) || endDate < startDate) {
19
+ throw new Error("Invalid subscription period.");
20
+ }
21
+
22
+ const { data: plan, error: planError } = await supabaseAdmin
23
+ .from("subscriptions")
24
+ .select("subscription_id")
25
+ .eq("subscription_id", subscriptionId)
26
+ .maybeSingle();
27
+ if (planError) throw new Error(`Unable to validate subscription plan: ${planError.message}`);
28
+ if (!plan) throw new Error("Subscription plan was not found.");
29
+
30
+ const { data, error } = await supabaseAdmin
31
+ .from("users_subscriptions")
32
+ .upsert({ user_id: userId, subscription_id: subscriptionId, startdate: startDate, enddate: endDate }, { onConflict: "user_id" })
33
+ .select("user_id,subscription_id,startdate,enddate")
34
+ .single();
35
+ if (error) throw new Error(`Unable to assign paid subscription: ${error.message}`);
36
+ return data;
37
+ }
src/supabase.ts ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createClient, type User } from "@supabase/supabase-js";
2
+ import bcrypt from "bcryptjs";
3
+ import { randomBytes } from "node:crypto";
4
+ import { env } from "./config.js";
5
+
6
+ export const supabaseAuth = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
7
+ // OAuth returns a one-time code for the backend callback to exchange, rather
8
+ // than placing session tokens in the browser URL fragment.
9
+ auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false, flowType: "pkce" },
10
+ });
11
+
12
+ export const supabaseAdmin = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
13
+ auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false },
14
+ });
15
+
16
+ export async function hashPassword(password: string) {
17
+ return bcrypt.hash(password, 12);
18
+ }
19
+
20
+ export function userHasPassword(user: User) {
21
+ return user.app_metadata?.has_password === true || Boolean(user.identities?.some((identity) => identity.provider === "email"));
22
+ }
23
+
24
+ async function randomPasswordHash() {
25
+ return bcrypt.hash(randomBytes(32).toString("hex"), 12);
26
+ }
27
+
28
+ export async function syncProfile(user: User, profile?: { fname?: string; lname?: string; academy?: string | null; role?: string | null; passwordHash?: string }) {
29
+ const metadata = user.user_metadata ?? {};
30
+ const { data: existing, error: readError } = await supabaseAdmin.from("users").select("user_id").eq("user_id", user.id).maybeSingle();
31
+ if (readError) throw new Error(`Unable to read user profile: ${readError.message}`);
32
+ if (existing) {
33
+ // The profile table is the canonical source for editable profile fields.
34
+ // Auth callbacks should only keep the email in sync, never reset user edits.
35
+ const { error } = await supabaseAdmin.from("users").update({ email: user.email }).eq("user_id", user.id);
36
+ if (error) throw new Error(`Unable to update user profile: ${error.message}`);
37
+ return;
38
+ }
39
+ const fields = {
40
+ user_id: user.id,
41
+ fname: profile?.fname ?? metadata.fname ?? metadata.full_name?.split(" ")[0] ?? "User",
42
+ lname: profile?.lname ?? metadata.lname ?? metadata.full_name?.split(" ").slice(1).join(" ") ?? "User",
43
+ email: user.email,
44
+ academy: profile?.academy ?? metadata.academy ?? null,
45
+ role: profile?.role ?? metadata.role ?? null,
46
+ };
47
+ const { error } = await supabaseAdmin.from("users").insert({ ...fields, password_hash: profile?.passwordHash ?? await randomPasswordHash() });
48
+ if (error) throw new Error(`Unable to create user profile: ${error.message}`);
49
+ }
50
+
51
+ export function userPayload(user: User) {
52
+ return { id: user.id, email: user.email, emailVerified: Boolean(user.email_confirmed_at), metadata: user.user_metadata ?? {} };
53
+ }
start.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run the public Node API and private Python worker as one service."""
2
+ import os
3
+ from pathlib import Path
4
+ import signal
5
+ import subprocess
6
+ import sys
7
+ import time
8
+
9
+ from dotenv import load_dotenv
10
+
11
+
12
+ def main():
13
+ root = Path(__file__).resolve().parent
14
+ load_dotenv(root / ".env")
15
+ port = int(os.environ.get("PORT", "4000"))
16
+ if not 1 <= port <= 65535 or port == 8000:
17
+ raise ValueError("PORT must be between 1 and 65535 and differ from AI port 8000")
18
+ env = dict(os.environ, PORT=str(port),
19
+ ANALYSIS_API_URL="http://127.0.0.1:8000",
20
+ BACKEND_INTERNAL_URL=f"http://127.0.0.1:{port}")
21
+ children = []
22
+ stopping = False
23
+
24
+ def stop(signum, frame):
25
+ nonlocal stopping
26
+ stopping = True
27
+
28
+ for sig in (signal.SIGINT, signal.SIGTERM):
29
+ signal.signal(sig, stop)
30
+ try:
31
+ children.append(subprocess.Popen([sys.executable, "-m", "uvicorn", "app:app",
32
+ "--host", "127.0.0.1", "--port", "8000", "--workers", "1"], cwd=root, env=env))
33
+ children.append(subprocess.Popen(["node", "dist/server.js"], cwd=root, env=env))
34
+ while not stopping:
35
+ for child in children:
36
+ if child.poll() is not None:
37
+ print(f"Backend process {child.pid} exited ({child.returncode}); stopping service", flush=True)
38
+ return child.returncode or 1
39
+ time.sleep(0.25)
40
+ return 0
41
+ finally:
42
+ for child in children:
43
+ if child.poll() is None:
44
+ child.terminate()
45
+ deadline = time.monotonic() + 15
46
+ for child in children:
47
+ try:
48
+ child.wait(timeout=max(0, deadline - time.monotonic()))
49
+ except subprocess.TimeoutExpired:
50
+ child.kill()
51
+ child.wait()
52
+
53
+
54
+ if __name__ == "__main__":
55
+ sys.exit(main())
tsconfig.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true
12
+ },
13
+ "include": ["src"]
14
+ }