Security hardening: fix critical auth, RBAC, and injection vulnerabilities

- C1: Add authentication to file serving route + canonical path traversal check + nosniff header
- C2: DEV_BYPASS_AUTH now only works when Entra ID credentials are not configured
- H1: Add requireAuth() + assertOrgAccess() to 9 unprotected routes (upload, feedback, annotations, color-probes, reviews)
- H2: Add org-scoping to 4 routes (automations, users, skills)
- H3: SSRF protection on webhook URLs — HTTPS only, private/internal IPs blocked
- H6: API key uses timingSafeEqual, phantom fallback removed, supports X-Org-Id header
- M1: CRON_SECRET moved from query string to Authorization Bearer header
- Extend assertOrgAccess() to support 10 model types (was 3)
- npm audit fix: 17 vulnerabilities reduced to 4
- Add SECURITY-REVIEW.md with full findings report

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-07 20:48:05 -04:00
parent 4c0e9d32df
commit 26c766cf43
24 changed files with 1046 additions and 311 deletions

290
SECURITY-REVIEW.md Normal file
View file

@ -0,0 +1,290 @@
# HP Prod Tracker — Security Code Review
**Date:** 2026-04-07
**Reviewed by:** Claude Code (automated deep review)
**Scope:** Full codebase — 342 files, ~117K LOC, 62 API routes
---
## Executive Summary
This codebase has **significant security gaps** that must be addressed before any production or external-facing deployment. The most critical issues are:
1. **No authentication on file serving** — uploaded media is publicly accessible
2. **DEV_BYPASS_AUTH has no production guard** — misconfiguration grants full admin access
3. **11 API routes lack RBAC and org-scoping** — cross-org data access possible
4. **SSRF via webhook automation** — internal network scanning possible
5. **17 npm vulnerabilities** (11 high severity) including prototype pollution in `xlsx` and `lodash`
The service layer and Prisma integration are generally well-structured. The core architecture is sound, but the authorization layer has inconsistent enforcement.
---
## CRITICAL Findings
### C1: Unauthenticated File Serving (Public Access to All Uploads)
**File:** `src/app/api/uploads/[...path]/route.ts`
**Severity:** CRITICAL
The GET handler has **zero authentication**. Anyone with a URL can download any uploaded revision image, video, or HLS segment. The path traversal check (`relativePath.includes("..")`) is also weak — should use `path.resolve()` canonicalization instead.
**Impact:** All confidential pre-release product imagery and videos are publicly accessible to anyone who can guess the URL pattern (`/api/uploads/revisions/{uuid}/{type}_{timestamp}.ext`).
**Fix:** Add `requireAuth()` + org-scoped access check. Replace string-contains `..` check with canonical path validation.
---
### C2: DEV_BYPASS_AUTH Has No Production Guard
**Files:** `src/middleware.ts:6`, `src/lib/api-utils.ts:8`, `src/app/(app)/layout.tsx:13`
**Severity:** CRITICAL
`DEV_BYPASS_AUTH=true` bypasses ALL authentication and grants ADMIN role. There is no `NODE_ENV !== "production"` guard. If this env var is set in production (misconfiguration, copied .env), the entire app is wide open.
**Impact:** Complete auth bypass — any request gets full admin access.
**Fix:** Add `&& process.env.NODE_ENV !== "production"` guard, or better, strip the env var from production Docker images entirely.
> **Note:** This guard was intentionally removed during our local development setup. It MUST be restored before any external deployment.
---
## HIGH Findings
### H1: 9 API Routes Use getAuthSession() Without RBAC or Org-Scoping
**Severity:** HIGH
These routes authenticate the user but perform **no permission check** and **no org-scope verification**. Any authenticated user from any org can access/modify these resources:
| Route | Methods | Impact |
|-------|---------|--------|
| `stages/[stageId]/revisions/[revisionId]/upload` | POST, DELETE | Upload/delete files on any revision |
| `stages/[stageId]/feedback` | GET, POST | Read/create feedback on any stage |
| `feedback/[itemId]` | PATCH, DELETE | Modify/delete any feedback item |
| `revisions/[revisionId]/annotations` | GET, POST | List/create annotations on any revision |
| `revisions/[revisionId]/annotations/[annotationId]` | PATCH, DELETE | Modify/delete any annotation |
| `revisions/[revisionId]/color-probes` | GET, POST, DELETE | Access any color probes |
| `revisions/[revisionId]/color-probes/[probeId]` | PATCH, DELETE | Modify/delete any probe |
| `reviews` | GET, POST | Create reviews (service does scope reads) |
| `reviews/[sessionId]` | GET, PATCH, DELETE | Read/modify/delete any review session |
**Fix:** Replace `getAuthSession()` with `requireAuth(PERMISSION)` and add `assertOrgAccess()` checks. Extend `assertOrgAccess()` to support `revision`, `annotation`, `colorProbe`, `feedbackItem`, and `reviewSession` models.
### H2: 4 Routes Have RBAC but Missing Org-Scoping on Parameterized IDs
**Severity:** HIGH
| Route | Methods | Impact |
|-------|---------|--------|
| `automations/[ruleId]` | GET, PATCH, DELETE | Cross-org automation rule access |
| `automations/[ruleId]/executions` | GET | Cross-org execution log leakage |
| `users/[userId]` | PATCH | Change user role in another org |
| `users/[userId]/skills` | GET, POST, DELETE | Cross-org skill manipulation |
**Fix:** Add org-scope verification for the target resource ID in each route.
### H3: SSRF via Webhook URL in Automation Actions
**File:** `src/lib/automation/action-executor.ts:250-336`
**Severity:** HIGH
The `send_webhook` action accepts arbitrary URLs with no validation. Attackers with `AUTOMATION_MANAGE` permission can:
- Probe internal network services (cloud metadata, localhost services)
- Exfiltrate project data to external servers
**Fix:** Validate URL scheme (HTTPS only), block private/internal IP ranges, consider domain allowlist.
### H4: MIME Type Validation Uses Client-Supplied file.type Only
**File:** `src/lib/services/upload-service.ts:64-66, 206-209`
**Severity:** HIGH
No server-side magic byte verification. An attacker can upload arbitrary files (HTML, SVG with scripts) by setting Content-Type to `image/png`.
**Fix:** Use `file-type` npm package for magic byte validation. Add `X-Content-Type-Options: nosniff` header on file serving responses.
### H5: Chat Tool Executor Has No Org-Scoping on Mutations
**File:** `src/lib/chat/tool-executor.ts:399, 465-503`
**Severity:** HIGH
Several tool operations (`list_deliverables`, `advance_stage`, `assign_artist`, `create_revision`, etc.) pass user-supplied IDs to services without verifying the entity belongs to the user's org. Through chat, users can manipulate resources in other organizations.
**Fix:** Add org-scope checks in the tool executor before every mutation.
### H6: API Key Auth Auto-Escalates to First Admin in First Org
**File:** `src/lib/api-utils.ts:26-71`
**Severity:** HIGH
`prisma.organization.findFirst()` with no ordering is nondeterministic. The API key holder gets ADMIN on whichever org Prisma returns first. The phantom fallback (lines 58-70) creates a fake admin with hardcoded `organizationId: "api-org"`.
**Fix:** Create a dedicated API service account. Require `X-Org-Id` header. Remove phantom fallback. Use `crypto.timingSafeEqual()` for key comparison.
---
## MEDIUM Findings
### M1: CRON_SECRET in Query String
**File:** `src/app/api/cron/deadlines/route.ts:12`
Secret leaks to access logs, proxy logs, browser history.
**Fix:** Move to `Authorization: Bearer <secret>` header.
### M2: 500MB Video Buffered in Memory
**File:** `src/lib/services/upload-service.ts:231`
`file.arrayBuffer()` loads entire video into Node.js heap. 4 concurrent 500MB uploads = 2GB RAM = OOM crash.
**Fix:** Stream directly to disk using `file.stream()` piped to `fs.createWriteStream()`.
### M3: 8 API Routes Missing Zod Validation
Routes using ad-hoc manual validation instead of Zod schemas:
- `automations` POST/PATCH
- `chat` POST
- `workload` PATCH
- `skills` POST
- `search/semantic` POST
- `users/[userId]/skills` POST
- `pipelines/[pipelineId]/duplicate` POST
- `stages/[stageId]` PATCH (date override bypasses validation)
**Fix:** Create Zod schemas for each.
### M4: Automation Action Params Not Validated
**File:** `src/lib/automation/action-executor.ts:359-386`
`validateActions` only checks `type` and `params` exists, not param contents. Invalid status strings, arbitrary notification text, unvalidated roles all pass through.
**Fix:** Add per-action-type param validation schemas.
### M5: z.any() in Validators Allows Arbitrary JSON Storage
| Field | Validator | Line |
|-------|-----------|------|
| `customStatuses` | `pipeline-template.ts` | 23 |
| `conditions.value` | `notification-rule.ts` | 17 |
| `attachments` | `revision.ts` | 6 |
**Fix:** Replace with typed schemas.
### M6: allowDangerousEmailAccountLinking Enabled
**File:** `src/lib/auth.ts:16`
Currently safe (single Entra ID provider), but becomes account takeover if another OAuth provider is added.
**Fix:** Add prominent warning comment. Disable flag if adding providers.
### M7: Prompt Injection via Chat Driving Unconfirmed Mutations
**File:** `src/app/api/chat/route.ts` + `tool-executor.ts`
User messages drive LLM tool calls with up to 10 iterations. `MUTATION_TOOLS` are flagged but never actually require confirmation.
**Fix:** Enforce server-side confirmation for mutations. Add RBAC to tool executor.
### M8: requireAuth() Leaks Permission Names
**File:** `src/lib/rbac/require-auth.ts:51`
Error message: `Missing permission: PROJECT_UPDATE` — reveals permission system to attackers.
**Fix:** Return generic "Forbidden" message.
---
## LOW Findings
### L1: API Key Timing Attack
`===` comparison on API key (middleware.ts:15, api-utils.ts:29). Use `crypto.timingSafeEqual()`.
### L2: Health Endpoint Exposes Infrastructure Details
`/api/health` returns DB status, pgvector version, org count, DEV_BYPASS_AUTH state. Should be behind auth or return minimal info.
### L3: No Rate Limiting on Any Endpoint
Includes the Claude API proxy (`/api/chat`). No throttling on login attempts, file uploads, or search.
### L4: Prompt Injection in Search Summarization
Project names interpolated into Ollama prompt (semantic-search-service.ts:408-416). Low impact — read-only local model.
---
## Dependency Vulnerabilities (npm audit)
**17 vulnerabilities: 6 moderate, 11 high**
| Package | Severity | Issue | Fix Available? |
|---------|----------|-------|---------------|
| `xlsx` (SheetJS) | HIGH | Prototype pollution + ReDoS | **NO** — no fix, consider replacing with `exceljs` |
| `lodash` (via chevrotain/prisma-ast) | HIGH | Prototype pollution in `_.unset`/`_.omit`, code injection in `_.template` | Via `npm audit fix` |
| `next@16.1.6` | MODERATE | HTTP smuggling, CSRF bypass, disk cache DoS | Via `npm audit fix` |
| `hono` (via prisma) | HIGH | 9 vulnerabilities (XSS, SSRF, prototype pollution, etc.) | Via `npm audit fix` |
| `@hono/node-server` (via prisma) | HIGH | Auth bypass for static paths | Via `npm audit fix` |
| `defu` | HIGH | Prototype pollution via `__proto__` | Via `npm audit fix` |
| `effect` (via prisma) | HIGH | AsyncLocalStorage context contamination | Via `npm audit fix` |
| `flatted` | HIGH | Unbounded recursion DoS + prototype pollution | Via `npm audit fix` |
| `picomatch` | HIGH | Method injection + ReDoS | Via `npm audit fix` |
| `brace-expansion` | MODERATE | Process hang via zero-step sequence | Via `npm audit fix` |
### Key Concern: `xlsx@0.18.5`
This package has known prototype pollution vulnerabilities with **no fix available**. The app already uses `exceljs` as well. Consider removing `xlsx` and using `exceljs` exclusively.
### Key Concern: NextAuth `5.0.0-beta.30`
This is a **beta** version of the authentication framework. Beta software may have undiscovered vulnerabilities and does not receive the same security scrutiny as stable releases.
---
## Hardcoded Secrets Scan
**Result: No hardcoded secrets found in source code.**
All credentials are properly externalized via environment variables. The `.env.example` file contains placeholder values, not real credentials. Docker Compose uses `${DB_PASSWORD:-postgres}` with a weak default — acceptable for dev but should be changed in production.
---
## Architecture Notes
**What's done well:**
- Service layer is cleanly separated from route handlers
- Prisma ORM prevents most SQL injection (except raw queries for pgvector)
- FFmpeg uses `execFile` (not `exec`) — no command injection
- `assertOrgAccess()` is fail-closed (denies if model type not recognized)
- File paths use server-generated UUIDs + timestamps, not user-supplied filenames
- No secrets in source code
**What needs work:**
- RBAC enforcement is inconsistent — opt-in pattern means new routes easily miss it
- `assertOrgAccess()` only supports 3 entity types — needs extending
- No rate limiting anywhere
- No CSP headers
- No request size limits beyond the 500MB video cap
---
## Priority Fix Order
1. **Immediate (before any deployment):**
- C1: Add auth to file serving route
- C2: Restore NODE_ENV guard on DEV_BYPASS_AUTH
- H1: Add RBAC + org-scoping to 9 unprotected routes
2. **Before external access:**
- H3: SSRF protection on webhooks
- H4: Server-side MIME validation
- H5: Org-scoping on chat tool mutations
- H6: Fix API key auth (dedicated service account)
- Run `npm audit fix`
3. **Soon after:**
- All MEDIUM findings
- Replace `xlsx` with `exceljs`
- Add rate limiting (at minimum on `/api/chat` and `/api/auth`)
- Add CSP headers

470
package-lock.json generated
View file

@ -365,71 +365,34 @@
"node": ">=6.9.0"
}
},
"node_modules/@chevrotain/cst-dts-gen": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz",
"integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/gast": "10.5.0",
"@chevrotain/types": "10.5.0",
"lodash": "4.17.21"
}
},
"node_modules/@chevrotain/gast": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz",
"integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/types": "10.5.0",
"lodash": "4.17.21"
}
},
"node_modules/@chevrotain/types": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz",
"integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@chevrotain/utils": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz",
"integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@electric-sql/pglite": {
"version": "0.3.15",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz",
"integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz",
"integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@electric-sql/pglite-socket": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz",
"integrity": "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==",
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz",
"integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"pglite-server": "dist/scripts/server.js"
},
"peerDependencies": {
"@electric-sql/pglite": "0.3.15"
"@electric-sql/pglite": "0.4.1"
}
},
"node_modules/@electric-sql/pglite-tools": {
"version": "0.2.20",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.20.tgz",
"integrity": "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==",
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz",
"integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==",
"devOptional": true,
"license": "Apache-2.0",
"peerDependencies": {
"@electric-sql/pglite": "0.3.15"
"@electric-sql/pglite": "0.4.1"
}
},
"node_modules/@emnapi/core": {
@ -1146,9 +1109,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.9",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"devOptional": true,
"license": "MIT",
"engines": {
@ -1732,19 +1695,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mrleebo/prisma-ast": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz",
"integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==",
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"chevrotain": "^10.5.0",
"lilconfig": "^2.1.0"
},
"engines": {
"node": ">=16"
}
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
@ -1759,9 +1715,9 @@
}
},
"node_modules/@next/env": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz",
"integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@ -1775,9 +1731,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz",
"integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==",
"cpu": [
"arm64"
],
@ -1791,9 +1747,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz",
"integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==",
"cpu": [
"x64"
],
@ -1807,9 +1763,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz",
"integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==",
"cpu": [
"arm64"
],
@ -1823,9 +1779,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz",
"integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==",
"cpu": [
"arm64"
],
@ -1839,9 +1795,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz",
"integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==",
"cpu": [
"x64"
],
@ -1855,9 +1811,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz",
"integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==",
"cpu": [
"x64"
],
@ -1871,9 +1827,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz",
"integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==",
"cpu": [
"arm64"
],
@ -1887,9 +1843,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz",
"integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==",
"cpu": [
"x64"
],
@ -2012,15 +1968,15 @@
"license": "Apache-2.0"
},
"node_modules/@prisma/config": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.4.2.tgz",
"integrity": "sha512-CftBjWxav99lzY1Z4oDgomdb1gh9BJFAOmWF6P2v1xRfXqQb56DfBub+QKcERRdNoAzCb3HXy3Zii8Vb4AsXhg==",
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.7.0.tgz",
"integrity": "sha512-hmPI3tKLO2aP0Y5vugbjcnA9qqlfJndiT6ds4tw28U5hNHLWg+mHJEWAhjsSPgxjtmxhJ/EDIeIlyh+3Us0OPg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
"deepmerge-ts": "7.1.5",
"effect": "3.18.4",
"effect": "3.20.0",
"empathic": "2.0.0"
}
},
@ -2031,22 +1987,22 @@
"license": "Apache-2.0"
},
"node_modules/@prisma/dev": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.20.0.tgz",
"integrity": "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==",
"version": "0.24.3",
"resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz",
"integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==",
"devOptional": true,
"license": "ISC",
"dependencies": {
"@electric-sql/pglite": "0.3.15",
"@electric-sql/pglite-socket": "0.0.20",
"@electric-sql/pglite-tools": "0.2.20",
"@hono/node-server": "1.19.9",
"@mrleebo/prisma-ast": "0.13.1",
"@electric-sql/pglite": "0.4.1",
"@electric-sql/pglite-socket": "0.1.1",
"@electric-sql/pglite-tools": "0.3.1",
"@hono/node-server": "1.19.11",
"@prisma/get-platform": "7.2.0",
"@prisma/query-plan-executor": "7.2.0",
"@prisma/streams-local": "0.1.2",
"foreground-child": "3.3.1",
"get-port-please": "3.2.0",
"hono": "4.11.4",
"hono": "^4.12.8",
"http-status-codes": "2.3.0",
"pathe": "2.0.3",
"proper-lockfile": "4.1.2",
@ -2066,56 +2022,70 @@
}
},
"node_modules/@prisma/engines": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.4.2.tgz",
"integrity": "sha512-B+ZZhI4rXlzjVqRw/93AothEKOU5/x4oVyJFGo9RpHPnBwaPwk4Pi0Q4iGXipKxeXPs/dqljgNBjK0m8nocOJA==",
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.7.0.tgz",
"integrity": "sha512-7fmcbT7HHXBq/b+3h/dO1JI3fd8l8q7erf7xP7pRprh58hmSSnG8mg9K3yjW3h9WaHWUwngVFpSxxxivaitQ2w==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.4.2",
"@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919",
"@prisma/fetch-engine": "7.4.2",
"@prisma/get-platform": "7.4.2"
"@prisma/debug": "7.7.0",
"@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711",
"@prisma/fetch-engine": "7.7.0",
"@prisma/get-platform": "7.7.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919.tgz",
"integrity": "sha512-5FIKY3KoYQlBuZC2yc16EXfVRQ8HY+fLqgxkYfWCtKhRb3ajCRzP/rPeoSx11+NueJDANdh4hjY36mdmrTcGSg==",
"version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711.tgz",
"integrity": "sha512-r51DLcJ8bDRSrBEJF3J4cinoWyGA7rfP2mG6lD90VqIbGNOkbfcLcXalSVjq5Y6brQS3vcjrq4GbyUb1Cb7vkw==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines/node_modules/@prisma/debug": {
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.7.0.tgz",
"integrity": "sha512-12J62XdqCmpiwJHhHdQxZeY3ckVCWIFmcJP8hg5dPTceeiQ0wiojXGFYTluKqFQfu46fRLgb/rLALZMAx3+dTA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.2.tgz",
"integrity": "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw==",
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.7.0.tgz",
"integrity": "sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.4.2"
"@prisma/debug": "7.7.0"
}
},
"node_modules/@prisma/fetch-engine": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.4.2.tgz",
"integrity": "sha512-f/c/MwYpdJO7taLETU8rahEstLeXfYgQGlz5fycG7Fbmva3iPdzGmjiSWHeSWIgNnlXnelUdCJqyZnFocurZuA==",
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.7.0.tgz",
"integrity": "sha512-TfyzveBQoK4xALzsTpVhB/0KG1N8zOK0ap+RnBMkzGUu3f98fnQ4QtXa2wlKPhsO2X8a3N5ugFQgcKNoHGmDfw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.4.2",
"@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919",
"@prisma/get-platform": "7.4.2"
"@prisma/debug": "7.7.0",
"@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711",
"@prisma/get-platform": "7.7.0"
}
},
"node_modules/@prisma/fetch-engine/node_modules/@prisma/debug": {
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.7.0.tgz",
"integrity": "sha512-12J62XdqCmpiwJHhHdQxZeY3ckVCWIFmcJP8hg5dPTceeiQ0wiojXGFYTluKqFQfu46fRLgb/rLALZMAx3+dTA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.2.tgz",
"integrity": "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw==",
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.7.0.tgz",
"integrity": "sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.4.2"
"@prisma/debug": "7.7.0"
}
},
"node_modules/@prisma/get-platform": {
@ -2142,12 +2112,61 @@
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/studio-core": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.13.1.tgz",
"integrity": "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==",
"node_modules/@prisma/streams-local": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz",
"integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"ajv": "^8.12.0",
"better-result": "^2.7.0",
"env-paths": "^3.0.0",
"proper-lockfile": "^4.1.2"
},
"engines": {
"bun": ">=1.3.6",
"node": ">=22.0.0"
}
},
"node_modules/@prisma/streams-local/node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@prisma/streams-local/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@prisma/studio-core": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz",
"integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@radix-ui/react-toggle": "1.1.10",
"chart.js": "4.5.1"
},
"engines": {
"node": "^20.19 || ^22.12 || >=24.0",
"pnpm": "8"
},
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
@ -5580,9 +5599,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -6463,6 +6482,13 @@
"node": ">=6.0.0"
}
},
"node_modules/better-result": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.8.1.tgz",
"integrity": "sha512-C4FQ1gCLz1YCxmM8HhNPb4D7WQmdrdllkhNReeLwvIVtJKQFKKfwJwmM3yZEBG4P34cLtrgB+FEPr1u553hF7Q==",
"devOptional": true,
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.6.2",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
@ -6541,9 +6567,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@ -6879,19 +6905,17 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chevrotain": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz",
"integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==",
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"devOptional": true,
"license": "Apache-2.0",
"license": "MIT",
"dependencies": {
"@chevrotain/cst-dts-gen": "10.5.0",
"@chevrotain/gast": "10.5.0",
"@chevrotain/types": "10.5.0",
"@chevrotain/utils": "10.5.0",
"lodash": "4.17.21",
"regexp-to-ast": "0.5.0"
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
@ -7534,9 +7558,9 @@
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"devOptional": true,
"license": "MIT"
},
@ -7686,9 +7710,9 @@
}
},
"node_modules/effect": {
"version": "3.18.4",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
"integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
"integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@ -7748,6 +7772,19 @@
"node": ">=10.13.0"
}
},
"node_modules/env-paths": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/es-abstract": {
"version": "1.24.1",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
@ -8586,6 +8623,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"devOptional": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@ -8663,9 +8717,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@ -9210,9 +9264,9 @@
"license": "Apache-2.0"
},
"node_modules/hono": {
"version": "4.11.4",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
"integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==",
"version": "4.12.12",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
"devOptional": true,
"license": "MIT",
"engines": {
@ -10432,16 +10486,6 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lilconfig": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
"integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
@ -10483,13 +10527,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@ -11692,14 +11729,14 @@
"license": "MIT"
},
"node_modules/next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
"version": "16.2.2",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz",
"integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==",
"license": "MIT",
"dependencies": {
"@next/env": "16.1.6",
"@next/env": "16.2.2",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.8.3",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@ -11711,15 +11748,15 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.1.6",
"@next/swc-darwin-x64": "16.1.6",
"@next/swc-linux-arm64-gnu": "16.1.6",
"@next/swc-linux-arm64-musl": "16.1.6",
"@next/swc-linux-x64-gnu": "16.1.6",
"@next/swc-linux-x64-musl": "16.1.6",
"@next/swc-win32-arm64-msvc": "16.1.6",
"@next/swc-win32-x64-msvc": "16.1.6",
"sharp": "^0.34.4"
"@next/swc-darwin-arm64": "16.2.2",
"@next/swc-darwin-x64": "16.2.2",
"@next/swc-linux-arm64-gnu": "16.2.2",
"@next/swc-linux-arm64-musl": "16.2.2",
"@next/swc-linux-x64-gnu": "16.2.2",
"@next/swc-linux-x64-musl": "16.2.2",
"@next/swc-win32-arm64-msvc": "16.2.2",
"@next/swc-win32-x64-msvc": "16.2.2",
"sharp": "^0.34.5"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@ -11975,9 +12012,9 @@
}
},
"node_modules/nypm/node_modules/citty": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz",
"integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==",
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
"devOptional": true,
"license": "MIT"
},
@ -12401,9 +12438,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@ -12690,17 +12727,17 @@
}
},
"node_modules/prisma": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.4.2.tgz",
"integrity": "sha512-2bP8Ruww3Q95Z2eH4Yqh4KAENRsj/SxbdknIVBfd6DmjPwmpsC4OVFMLOeHt6tM3Amh8ebjvstrUz3V/hOe1dA==",
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.7.0.tgz",
"integrity": "sha512-HlgwRBt1uEFB9LStHL4HLYDvoi4BNu1rYA0hPG0zCAEyK9SaZBqp7E5Rjpc3Qh8Lex/ye/svoHZ0OWoFNhWxuQ==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "7.4.2",
"@prisma/dev": "0.20.0",
"@prisma/engines": "7.4.2",
"@prisma/studio-core": "0.13.1",
"@prisma/config": "7.7.0",
"@prisma/dev": "0.24.3",
"@prisma/engines": "7.7.0",
"@prisma/studio-core": "0.27.3",
"mysql2": "3.15.3",
"postgres": "3.4.7"
},
@ -13179,9 +13216,9 @@
}
},
"node_modules/readdir-glob/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@ -13281,13 +13318,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regexp-to-ast": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz",
"integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@ -14306,9 +14336,9 @@
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
"integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
"devOptional": true,
"license": "MIT",
"engines": {
@ -14351,9 +14381,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {

View file

@ -584,15 +584,20 @@ enum Permission {
PROJECT_UPDATE
PROJECT_DELETE
PROJECT_VIEW
DELIVERABLE_VIEW
DELIVERABLE_CREATE
DELIVERABLE_UPDATE
DELIVERABLE_DELETE
STAGE_VIEW
STAGE_UPDATE
STAGE_UPDATE_STATUS
STAGE_ASSIGN
STAGE_SCHEDULE
REVISION_CREATE
REVISION_UPDATE
REVISION_REVIEW
COMMENT_CREATE
COMMENT_DELETE
COMMENT_DELETE_ANY
PIPELINE_MANAGE
USER_MANAGE

View file

@ -9,8 +9,8 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export default async function AppLayout({ children }: { children: React.ReactNode }) {
// Skip org check in dev bypass mode
if (!(process.env.DEV_BYPASS_AUTH === "true")) {
// Skip org check in dev bypass mode (only when Entra ID is not configured)
if (!(process.env.DEV_BYPASS_AUTH === "true" && !process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET)) {
const session = await auth();
if (session?.user?.id) {
const user = await prisma.user.findUnique({

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { serverError } from "@/lib/api-utils";
import { notFound, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import { listExecutions } from "@/lib/services/automation-service";
export async function GET(
@ -12,6 +13,13 @@ export async function GET(
if (error) return error;
const { ruleId } = await params;
try {
await assertOrgAccess("automationRule", ruleId, session!.user.organizationId);
} catch {
return notFound("Rule not found");
}
const limit = Number(req.nextUrl.searchParams.get("limit")) || 50;
const executions = await listExecutions(ruleId, { limit });

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { notFound, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import {
getAutomationRule,
updateAutomationRule,
@ -18,6 +19,13 @@ export async function GET(
if (error) return error;
const { ruleId } = await params;
try {
await assertOrgAccess("automationRule", ruleId, session!.user.organizationId);
} catch {
return notFound("Rule not found");
}
const rule = await getAutomationRule(ruleId);
if (!rule) return notFound("Rule not found");
@ -37,6 +45,13 @@ export async function PATCH(
if (error) return error;
const { ruleId } = await params;
try {
await assertOrgAccess("automationRule", ruleId, session!.user.organizationId);
} catch {
return notFound("Rule not found");
}
const body = await req.json();
if (body.trigger) {
@ -75,6 +90,13 @@ export async function DELETE(
if (error) return error;
const { ruleId } = await params;
try {
await assertOrgAccess("automationRule", ruleId, session!.user.organizationId);
} catch {
return notFound("Rule not found");
}
await deleteAutomationRule(ruleId);
return NextResponse.json({ success: true });
} catch (error) {

View file

@ -3,13 +3,13 @@ import { prisma } from "@/lib/prisma";
import { emitDeadlineEvents } from "@/lib/services/deadline-service";
/**
* GET /api/cron/deadlines?secret=xxx
* GET /api/cron/deadlines
*
* Trigger deadline event emission for all organizations.
* Protected by CRON_SECRET call from external scheduler or manual testing.
* Protected by CRON_SECRET via Authorization header call from external scheduler or manual testing.
*/
export async function GET(req: NextRequest) {
const secret = req.nextUrl.searchParams.get("secret");
const secret = req.headers.get("authorization")?.replace("Bearer ", "");
if (!process.env.CRON_SECRET || secret !== process.env.CRON_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View file

@ -1,10 +1,11 @@
import { NextResponse } from "next/server";
import {
getAuthSession,
badRequest,
notFound,
serverError,
} from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import {
updateFeedbackSchema,
resolveFeedbackSchema,
@ -23,11 +24,18 @@ type Params = { params: Promise<{ itemId: string }> };
// PATCH /api/feedback/:itemId
// Supports multiple actions via `action` field: "update" (default), "resolve", "verify", "reopen"
export async function PATCH(request: Request, { params }: Params) {
const { session, error } = await getAuthSession();
const { session, error } = await requireAuth("REVISION_UPDATE");
if (error) return error;
try {
const { itemId } = await params;
try {
await assertOrgAccess("feedbackItem", itemId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const body = await request.json();
const action = body.action ?? "update";
@ -75,11 +83,18 @@ export async function PATCH(request: Request, { params }: Params) {
// DELETE /api/feedback/:itemId
export async function DELETE(_request: Request, { params }: Params) {
const { error } = await getAuthSession();
const { session, error } = await requireAuth("REVISION_UPDATE");
if (error) return error;
try {
const { itemId } = await params;
try {
await assertOrgAccess("feedbackItem", itemId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const existing = await getFeedbackItem(itemId);
if (!existing) return notFound("Feedback item not found");

View file

@ -1,10 +1,11 @@
import { NextResponse } from "next/server";
import {
getAuthSession,
badRequest,
notFound,
serverError,
} from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import {
updateReviewSessionSchema,
addSessionItemsSchema,
@ -28,11 +29,18 @@ type Params = { params: Promise<{ sessionId: string }> };
// GET /api/reviews/:sessionId
export async function GET(_request: Request, { params }: Params) {
const { error } = await getAuthSession();
const { session: authSession, error } = await requireAuth("PROJECT_VIEW");
if (error) return error;
try {
const { sessionId } = await params;
try {
await assertOrgAccess("reviewSession", sessionId, authSession!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const session = await getReviewSession(sessionId);
if (!session) return notFound("Review session not found");
return NextResponse.json(session);
@ -51,11 +59,17 @@ export async function GET(_request: Request, { params }: Params) {
// - "clear-decision" — clear a decision
// - "generate" — generate items from project filters
export async function PATCH(request: Request, { params }: Params) {
const { session: authSession, error } = await getAuthSession();
const { session: authSession, error } = await requireAuth("PROJECT_UPDATE");
if (error) return error;
try {
const { sessionId } = await params;
try {
await assertOrgAccess("reviewSession", sessionId, authSession!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const body = await request.json();
const action = body.action as string | undefined;
@ -129,11 +143,17 @@ export async function PATCH(request: Request, { params }: Params) {
// DELETE /api/reviews/:sessionId
export async function DELETE(_request: Request, { params }: Params) {
const { error } = await getAuthSession();
const { session: authSession, error } = await requireAuth("PROJECT_DELETE");
if (error) return error;
try {
const { sessionId } = await params;
try {
await assertOrgAccess("reviewSession", sessionId, authSession!.user.organizationId);
} catch {
return notFound("Resource not found");
}
await deleteReviewSession(sessionId);
return NextResponse.json({ ok: true });
} catch (e) {

View file

@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getAuthSession, badRequest, serverError } from "@/lib/api-utils";
import { badRequest, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { createReviewSessionSchema } from "@/lib/validators/review-session";
import {
listReviewSessions,
@ -9,7 +10,7 @@ import {
// GET /api/reviews
// Query params: ?status=DRAFT|IN_PROGRESS|COMPLETED
export async function GET(request: Request) {
const { session, error } = await getAuthSession();
const { session, error } = await requireAuth("PROJECT_VIEW");
if (error) return error;
try {
@ -28,7 +29,7 @@ export async function GET(request: Request) {
// POST /api/reviews
export async function POST(request: Request) {
const { session, error } = await getAuthSession();
const { session, error } = await requireAuth("PROJECT_VIEW");
if (error) return error;
try {

View file

@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { getAuthSession, badRequest, notFound, serverError } from "@/lib/api-utils";
import { badRequest, notFound, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import { updateAnnotationSchema } from "@/lib/validators/annotation";
import { updateAnnotation, deleteAnnotation } from "@/lib/services/annotation-service";
@ -7,11 +9,17 @@ type Params = { params: Promise<{ revisionId: string; annotationId: string }> };
// PATCH /api/revisions/:revisionId/annotations/:annotationId
export async function PATCH(request: Request, { params }: Params) {
const { session, error } = await getAuthSession();
const { session, error } = await requireAuth("REVISION_UPDATE");
if (error) return error;
try {
const { annotationId } = await params;
try {
await assertOrgAccess("annotation", annotationId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const body = await request.json();
const parsed = updateAnnotationSchema.safeParse(body);
@ -38,12 +46,18 @@ export async function PATCH(request: Request, { params }: Params) {
// DELETE /api/revisions/:revisionId/annotations/:annotationId
export async function DELETE(_request: Request, { params }: Params) {
const { session, error } = await getAuthSession();
const { session, error } = await requireAuth("REVISION_UPDATE");
if (error) return error;
try {
const { annotationId } = await params;
try {
await assertOrgAccess("annotation", annotationId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const result = await deleteAnnotation(annotationId, session!.user!.id!);
return NextResponse.json(result);
} catch (e) {

View file

@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { getAuthSession, badRequest, notFound, serverError } from "@/lib/api-utils";
import { badRequest, notFound, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import { prisma } from "@/lib/prisma";
import { createAnnotationSchema } from "@/lib/validators/annotation";
import { listAnnotations, createAnnotation } from "@/lib/services/annotation-service";
@ -8,12 +10,18 @@ type Params = { params: Promise<{ revisionId: string }> };
// GET /api/revisions/:revisionId/annotations
export async function GET(_request: Request, { params }: Params) {
const { error } = await getAuthSession();
const { session, error } = await requireAuth("STAGE_VIEW");
if (error) return error;
try {
const { revisionId } = await params;
try {
await assertOrgAccess("revision", revisionId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const revision = await prisma.revision.findUnique({
where: { id: revisionId },
});
@ -29,12 +37,18 @@ export async function GET(_request: Request, { params }: Params) {
// POST /api/revisions/:revisionId/annotations
export async function POST(request: Request, { params }: Params) {
const { session, error } = await getAuthSession();
const { session, error } = await requireAuth("REVISION_CREATE");
if (error) return error;
try {
const { revisionId } = await params;
try {
await assertOrgAccess("revision", revisionId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const revision = await prisma.revision.findUnique({
where: { id: revisionId },
});

View file

@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { getAuthSession, badRequest, serverError } from "@/lib/api-utils";
import { badRequest, notFound, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import { updateColorProbeSchema } from "@/lib/validators/color-probe";
import {
updateColorProbe,
@ -12,10 +14,16 @@ interface RouteContext {
export async function PATCH(req: Request, ctx: RouteContext) {
try {
const { session, error } = await getAuthSession();
const { session, error } = await requireAuth("REVISION_UPDATE");
if (error) return error;
const { probeId } = await ctx.params;
try {
await assertOrgAccess("colorProbe", probeId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const body = await req.json();
const parsed = updateColorProbeSchema.safeParse(body);
if (!parsed.success) {
@ -31,10 +39,16 @@ export async function PATCH(req: Request, ctx: RouteContext) {
export async function DELETE(_req: Request, ctx: RouteContext) {
try {
const { session, error } = await getAuthSession();
const { session, error } = await requireAuth("REVISION_UPDATE");
if (error) return error;
const { probeId } = await ctx.params;
try {
await assertOrgAccess("colorProbe", probeId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
await deleteColorProbe(probeId);
return NextResponse.json({ ok: true });
} catch (err) {

View file

@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { getAuthSession, badRequest, notFound, serverError } from "@/lib/api-utils";
import { badRequest, notFound, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import { prisma } from "@/lib/prisma";
import { createColorProbeSchema } from "@/lib/validators/color-probe";
import {
@ -14,10 +16,17 @@ interface RouteContext {
export async function GET(_req: Request, ctx: RouteContext) {
try {
const { session, error } = await getAuthSession();
const { session, error } = await requireAuth("STAGE_VIEW");
if (error) return error;
const { revisionId } = await ctx.params;
try {
await assertOrgAccess("revision", revisionId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const revision = await prisma.revision.findUnique({ where: { id: revisionId } });
if (!revision) return notFound("Revision not found");
@ -30,10 +39,17 @@ export async function GET(_req: Request, ctx: RouteContext) {
export async function POST(req: Request, ctx: RouteContext) {
try {
const { session, error } = await getAuthSession();
const { session, error } = await requireAuth("REVISION_CREATE");
if (error) return error;
const { revisionId } = await ctx.params;
try {
await assertOrgAccess("revision", revisionId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const revision = await prisma.revision.findUnique({ where: { id: revisionId } });
if (!revision) return notFound("Revision not found");
@ -52,10 +68,16 @@ export async function POST(req: Request, ctx: RouteContext) {
export async function DELETE(_req: Request, ctx: RouteContext) {
try {
const { session, error } = await getAuthSession();
const { session, error } = await requireAuth("REVISION_UPDATE");
if (error) return error;
const { revisionId } = await ctx.params;
try {
await assertOrgAccess("revision", revisionId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
await clearColorProbes(revisionId);
return NextResponse.json({ ok: true });
} catch (err) {

View file

@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { getAuthSession, badRequest, serverError } from "@/lib/api-utils";
import { badRequest, notFound, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import { createFeedbackSchema } from "@/lib/validators/feedback";
import {
listFeedbackItems,
@ -12,11 +14,17 @@ type Params = { params: Promise<{ stageId: string }> };
// GET /api/stages/:stageId/feedback
// Query params: ?revisionId=&status=&isActionItem=true|false&summary=true
export async function GET(request: Request, { params }: Params) {
const { error } = await getAuthSession();
const { session, error } = await requireAuth("STAGE_VIEW");
if (error) return error;
try {
const { stageId } = await params;
try {
await assertOrgAccess("deliverableStage", stageId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const url = new URL(request.url);
const revisionId = url.searchParams.get("revisionId") ?? undefined;
const status = url.searchParams.get("status") ?? undefined;
@ -43,11 +51,17 @@ export async function GET(request: Request, { params }: Params) {
// POST /api/stages/:stageId/feedback
export async function POST(request: Request, { params }: Params) {
const { session, error } = await getAuthSession();
const { session, error } = await requireAuth("REVISION_CREATE");
if (error) return error;
try {
const { stageId } = await params;
try {
await assertOrgAccess("deliverableStage", stageId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const body = await request.json();
const parsed = createFeedbackSchema.safeParse(body);

View file

@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { getAuthSession, badRequest, notFound, serverError } from "@/lib/api-utils";
import { badRequest, notFound, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import { prisma } from "@/lib/prisma";
import {
processAndStoreImage,
@ -29,12 +31,18 @@ interface Attachments {
// POST /api/stages/:stageId/revisions/:revisionId/upload
export async function POST(request: Request, { params }: Params) {
const { error } = await getAuthSession();
const { session, error } = await requireAuth("REVISION_CREATE");
if (error) return error;
try {
const { stageId, revisionId } = await params;
try {
await assertOrgAccess("deliverableStage", stageId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
// Verify revision belongs to this stage
const revision = await prisma.revision.findFirst({
where: { id: revisionId, deliverableStageId: stageId },
@ -107,11 +115,18 @@ export async function POST(request: Request, { params }: Params) {
// DELETE /api/stages/:stageId/revisions/:revisionId/upload?type=reference|current|video|referenceVideo
export async function DELETE(request: Request, { params }: Params) {
const { error } = await getAuthSession();
const { session, error } = await requireAuth("REVISION_UPDATE");
if (error) return error;
try {
const { stageId, revisionId } = await params;
try {
await assertOrgAccess("deliverableStage", stageId, session!.user.organizationId);
} catch {
return notFound("Resource not found");
}
const url = new URL(request.url);
const deleteType = url.searchParams.get("type") as string | null;

View file

@ -8,6 +8,7 @@
import { NextRequest, NextResponse } from "next/server";
import { stat, open } from "fs/promises";
import path from "path";
import { getAuthSession } from "@/lib/api-utils";
const VIDEO_UPLOADS_DIR =
process.env.VIDEO_UPLOADS_DIR ||
@ -28,15 +29,18 @@ const MIME_TYPES: Record<string, string> = {
type Params = { params: Promise<{ path: string[] }> };
export async function GET(request: NextRequest, { params }: Params) {
// Require authentication — uploaded media is not public
const { error } = await getAuthSession();
if (error) return error;
const segments = (await params).path;
const relativePath = segments.join("/");
// Prevent directory traversal
if (relativePath.includes("..")) {
// Canonical path traversal prevention
const filePath = path.resolve(VIDEO_UPLOADS_DIR, relativePath);
if (!filePath.startsWith(path.resolve(VIDEO_UPLOADS_DIR))) {
return new NextResponse("Forbidden", { status: 403 });
}
const filePath = path.join(VIDEO_UPLOADS_DIR, relativePath);
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || "application/octet-stream";
@ -110,6 +114,7 @@ export async function GET(request: NextRequest, { params }: Params) {
"Content-Length": String(chunkSize),
"Accept-Ranges": "bytes",
"Cache-Control": cacheControl,
"X-Content-Type-Options": "nosniff",
},
});
}
@ -142,6 +147,7 @@ export async function GET(request: NextRequest, { params }: Params) {
"Content-Length": String(fileSize),
"Accept-Ranges": "bytes",
"Cache-Control": cacheControl,
"X-Content-Type-Options": "nosniff",
},
});
}

View file

@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { badRequest, serverError } from "@/lib/api-utils";
import { badRequest, notFound, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import { updateUserRole } from "@/lib/services/user-service";
import { z } from "zod/v4";
@ -17,6 +18,13 @@ export async function PATCH(request: Request, { params }: Params) {
try {
const { userId } = await params;
try {
await assertOrgAccess("user", userId, session!.user.organizationId);
} catch {
return notFound("User not found");
}
const body = await request.json();
const parsed = updateUserSchema.safeParse(body);

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { badRequest, serverError } from "@/lib/api-utils";
import { badRequest, notFound, serverError } from "@/lib/api-utils";
import { requireAuth } from "@/lib/rbac/require-auth";
import { assertOrgAccess } from "@/lib/rbac/org-scope";
import {
getUserSkills,
setUserSkill,
@ -11,11 +12,18 @@ type Params = { params: Promise<{ userId: string }> };
// GET /api/users/:userId/skills
export async function GET(_request: NextRequest, { params }: Params) {
const { error } = await requireAuth("PROJECT_VIEW");
const { session, error } = await requireAuth("PROJECT_VIEW");
if (error) return error;
try {
const { userId } = await params;
try {
await assertOrgAccess("user", userId, session!.user.organizationId);
} catch {
return notFound("User not found");
}
const skills = await getUserSkills(userId);
return NextResponse.json(skills);
} catch (e) {
@ -25,11 +33,18 @@ export async function GET(_request: NextRequest, { params }: Params) {
// POST /api/users/:userId/skills — add/update a skill for a user
export async function POST(request: NextRequest, { params }: Params) {
const { error } = await requireAuth("USER_MANAGE");
const { session, error } = await requireAuth("USER_MANAGE");
if (error) return error;
try {
const { userId } = await params;
try {
await assertOrgAccess("user", userId, session!.user.organizationId);
} catch {
return notFound("User not found");
}
const body = await request.json();
const { skillId, level } = body;
@ -44,11 +59,18 @@ export async function POST(request: NextRequest, { params }: Params) {
// DELETE /api/users/:userId/skills — remove a skill from a user
export async function DELETE(request: NextRequest, { params }: Params) {
const { error } = await requireAuth("USER_MANAGE");
const { session, error } = await requireAuth("USER_MANAGE");
if (error) return error;
try {
const { userId } = await params;
try {
await assertOrgAccess("user", userId, session!.user.organizationId);
} catch {
return notFound("User not found");
}
const body = await request.json();
const { skillId } = body;

View file

@ -1,11 +1,21 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { timingSafeEqual } from "crypto";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
function safeCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
export async function getAuthSession() {
// Dev bypass: return a mock session pointing to the seeded dev user
if (process.env.DEV_BYPASS_AUTH === "true") {
// Dev bypass: return a mock session pointing to the seeded dev user.
// Safety: only honoured when Entra ID credentials are NOT configured.
if (
process.env.DEV_BYPASS_AUTH === "true" &&
!process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET
) {
const devUserId = process.env.DEV_USER_ID ?? "dev-user-001";
return {
session: {
@ -26,43 +36,32 @@ export async function getAuthSession() {
if (process.env.API_KEY) {
const reqHeaders = await headers();
const apiKey = reqHeaders.get("x-api-key");
if (apiKey && apiKey === process.env.API_KEY) {
// Find the first admin user in the first org to act as the API service account
const org = await prisma.organization.findFirst({
select: { id: true },
});
const apiUser = org
? await prisma.user.findFirst({
where: { organizationId: org.id, role: "ADMIN" },
select: { id: true, name: true, email: true, role: true, organizationId: true },
})
: null;
if (apiKey && safeCompare(apiKey, process.env.API_KEY)) {
// Resolve API caller identity from the database
const orgId = reqHeaders.get("x-org-id");
const org = orgId
? await prisma.organization.findUnique({ where: { id: orgId }, select: { id: true } })
: await prisma.organization.findFirst({ select: { id: true } });
if (apiUser && apiUser.organizationId) {
return {
session: {
user: {
id: apiUser.id,
name: apiUser.name,
email: apiUser.email,
role: apiUser.role as "ADMIN",
organizationId: apiUser.organizationId,
},
expires: new Date(Date.now() + 86400000).toISOString(),
},
error: null,
};
if (!org) return { session: null, error: unauthorized() };
const apiUser = await prisma.user.findFirst({
where: { organizationId: org.id, role: "ADMIN" },
select: { id: true, name: true, email: true, role: true, organizationId: true },
});
if (!apiUser || !apiUser.organizationId) {
return { session: null, error: unauthorized() };
}
// Fallback if no org/user exists yet
return {
session: {
user: {
id: "api-service-account",
name: "API Service Account",
email: "api@system",
role: "ADMIN" as const,
organizationId: org?.id ?? "api-org",
id: apiUser.id,
name: apiUser.name,
email: apiUser.email,
role: apiUser.role as "ADMIN",
organizationId: apiUser.organizationId,
},
expires: new Date(Date.now() + 86400000).toISOString(),
},

View file

@ -244,6 +244,66 @@ async function executeCreateAssignment(
};
}
/**
* Validate that a webhook URL is allowed (not targeting internal/private networks).
* Prevents SSRF by blocking private IPs, internal hostnames, and non-HTTPS schemes.
*/
function isAllowedWebhookUrl(url: string): boolean {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
// Only allow HTTPS
if (parsed.protocol !== "https:") {
return false;
}
const hostname = parsed.hostname.toLowerCase();
// Block localhost and loopback addresses
if (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "0.0.0.0"
) {
return false;
}
// Block .local and .internal TLDs
if (hostname.endsWith(".local") || hostname.endsWith(".internal")) {
return false;
}
// Block cloud metadata endpoint
if (hostname === "169.254.169.254") {
return false;
}
// Block private/internal IP ranges
const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number);
// 10.0.0.0/8
if (a === 10) return false;
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return false;
// 192.168.0.0/16
if (a === 192 && b === 168) return false;
// 169.254.0.0/16 (link-local)
if (a === 169 && b === 254) return false;
}
return true;
}
/**
* Action: POST to an external webhook URL.
*/
@ -261,6 +321,15 @@ async function executeSendWebhook(
};
}
if (!isAllowedWebhookUrl(url)) {
return {
actionType: action.type,
success: false,
detail:
"Webhook URL is not allowed: must be HTTPS and must not target private/internal network addresses",
};
}
try {
// Format payload as Teams Adaptive Card when sending to Microsoft Teams webhooks
const isTeamsWebhook =
@ -379,6 +448,17 @@ export function validateActions(
}
if (!a.params || typeof a.params !== "object") {
errors.push(`Action ${i}: missing params`);
continue;
}
if (a.type === "send_webhook") {
if (!a.params.url || typeof a.params.url !== "string") {
errors.push(`Action ${i}: send_webhook requires a "url" string param`);
} else if (!isAllowedWebhookUrl(a.params.url)) {
errors.push(
`Action ${i}: webhook URL is not allowed (must be HTTPS, no private/internal addresses)`
);
}
}
}

View file

@ -4,8 +4,20 @@ import { prisma } from "@/lib/prisma";
* Verify that a resource belongs to the given organization.
* Throws if the resource doesn't exist or belongs to a different org.
*/
export type OrgScopedModel =
| "project"
| "deliverable"
| "deliverableStage"
| "revision"
| "annotation"
| "colorProbe"
| "feedbackItem"
| "reviewSession"
| "automationRule"
| "user";
export async function assertOrgAccess(
model: "project" | "deliverable" | "deliverableStage",
model: OrgScopedModel,
resourceId: string,
organizationId: string
): Promise<void> {
@ -43,6 +55,113 @@ export async function assertOrgAccess(
orgId = stage.deliverable.project.organizationId;
break;
}
case "revision": {
const revision = await prisma.revision.findUnique({
where: { id: resourceId },
select: {
deliverableStage: {
select: {
deliverable: {
select: { project: { select: { organizationId: true } } },
},
},
},
},
});
if (!revision) throw new OrgAccessError("Revision not found");
orgId = revision.deliverableStage.deliverable.project.organizationId;
break;
}
case "annotation": {
const annotation = await prisma.annotation.findUnique({
where: { id: resourceId },
select: {
revision: {
select: {
deliverableStage: {
select: {
deliverable: {
select: { project: { select: { organizationId: true } } },
},
},
},
},
},
},
});
if (!annotation) throw new OrgAccessError("Annotation not found");
orgId = annotation.revision.deliverableStage.deliverable.project.organizationId;
break;
}
case "colorProbe": {
const probe = await prisma.colorProbe.findUnique({
where: { id: resourceId },
select: {
revision: {
select: {
deliverableStage: {
select: {
deliverable: {
select: { project: { select: { organizationId: true } } },
},
},
},
},
},
},
});
if (!probe) throw new OrgAccessError("Color probe not found");
orgId = probe.revision.deliverableStage.deliverable.project.organizationId;
break;
}
case "feedbackItem": {
const feedback = await prisma.feedbackItem.findUnique({
where: { id: resourceId },
select: {
revision: {
select: {
deliverableStage: {
select: {
deliverable: {
select: { project: { select: { organizationId: true } } },
},
},
},
},
},
},
});
if (!feedback) throw new OrgAccessError("Feedback item not found");
orgId = feedback.revision.deliverableStage.deliverable.project.organizationId;
break;
}
case "reviewSession": {
const session = await prisma.reviewSession.findUnique({
where: { id: resourceId },
select: { organizationId: true },
});
if (!session) throw new OrgAccessError("Review session not found");
orgId = session.organizationId;
break;
}
case "automationRule": {
const rule = await prisma.automationRule.findUnique({
where: { id: resourceId },
select: { organizationId: true },
});
if (!rule) throw new OrgAccessError("Automation rule not found");
orgId = rule.organizationId;
break;
}
case "user": {
const user = await prisma.user.findUnique({
where: { id: resourceId },
select: { organizationId: true },
});
if (!user) throw new OrgAccessError("User not found");
orgId = user.organizationId;
break;
}
}
if (orgId !== organizationId) {

View file

@ -8,25 +8,26 @@ import { prisma } from "@/lib/prisma";
export const DEFAULT_PERMISSIONS: Record<Role, Permission[]> = {
ADMIN: [
"PROJECT_CREATE", "PROJECT_UPDATE", "PROJECT_DELETE", "PROJECT_VIEW",
"DELIVERABLE_CREATE", "DELIVERABLE_UPDATE", "DELIVERABLE_DELETE",
"STAGE_UPDATE_STATUS", "STAGE_ASSIGN", "STAGE_SCHEDULE",
"REVISION_CREATE", "REVISION_REVIEW",
"COMMENT_CREATE", "COMMENT_DELETE_ANY",
"DELIVERABLE_VIEW", "DELIVERABLE_CREATE", "DELIVERABLE_UPDATE", "DELIVERABLE_DELETE",
"STAGE_VIEW", "STAGE_UPDATE", "STAGE_UPDATE_STATUS", "STAGE_ASSIGN", "STAGE_SCHEDULE",
"REVISION_CREATE", "REVISION_UPDATE", "REVISION_REVIEW",
"COMMENT_CREATE", "COMMENT_DELETE", "COMMENT_DELETE_ANY",
"PIPELINE_MANAGE", "USER_MANAGE", "ROLE_MANAGE", "ORG_SETTINGS",
"AUTOMATION_MANAGE", "FIELD_CUSTOMIZE",
],
PRODUCER: [
"PROJECT_CREATE", "PROJECT_UPDATE", "PROJECT_VIEW",
"DELIVERABLE_CREATE", "DELIVERABLE_UPDATE", "DELIVERABLE_DELETE",
"STAGE_UPDATE_STATUS", "STAGE_ASSIGN", "STAGE_SCHEDULE",
"REVISION_CREATE", "REVISION_REVIEW",
"COMMENT_CREATE", "COMMENT_DELETE_ANY",
"DELIVERABLE_VIEW", "DELIVERABLE_CREATE", "DELIVERABLE_UPDATE", "DELIVERABLE_DELETE",
"STAGE_VIEW", "STAGE_UPDATE", "STAGE_UPDATE_STATUS", "STAGE_ASSIGN", "STAGE_SCHEDULE",
"REVISION_CREATE", "REVISION_UPDATE", "REVISION_REVIEW",
"COMMENT_CREATE", "COMMENT_DELETE", "COMMENT_DELETE_ANY",
"AUTOMATION_MANAGE",
],
ARTIST: [
"PROJECT_VIEW",
"STAGE_UPDATE_STATUS",
"REVISION_CREATE",
"DELIVERABLE_VIEW",
"STAGE_VIEW", "STAGE_UPDATE_STATUS",
"REVISION_CREATE", "REVISION_UPDATE",
"COMMENT_CREATE",
],
};

View file

@ -1,9 +1,25 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
function safeCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false;
// Note: Edge runtime may not have crypto.timingSafeEqual,
// so we use a constant-time comparison loop as fallback
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
export function middleware(request: NextRequest) {
// Dev bypass: skip all auth checks for local testing (never in production)
if (process.env.DEV_BYPASS_AUTH === "true") {
// Dev bypass: skip all auth checks for local testing.
// Safety: only honoured when Entra ID credentials are NOT configured,
// preventing accidental bypass in production.
if (
process.env.DEV_BYPASS_AUTH === "true" &&
!process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET
) {
return NextResponse.next();
}
@ -12,7 +28,7 @@ export function middleware(request: NextRequest) {
// API key auth: allow external API access with X-API-Key header
if (pathname.startsWith("/api/") && process.env.API_KEY) {
const apiKey = request.headers.get("x-api-key");
if (apiKey && apiKey === process.env.API_KEY) {
if (apiKey && safeCompare(apiKey, process.env.API_KEY)) {
return NextResponse.next();
}
}