diff --git a/SECURITY-REVIEW.md b/SECURITY-REVIEW.md new file mode 100644 index 0000000..b71b07d --- /dev/null +++ b/SECURITY-REVIEW.md @@ -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 ` 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 diff --git a/package-lock.json b/package-lock.json index da2149b..180e5d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e988212..12034a3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 6011ec1..2ac256d 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -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({ diff --git a/src/app/api/automations/[ruleId]/executions/route.ts b/src/app/api/automations/[ruleId]/executions/route.ts index f332aea..be96890 100644 --- a/src/app/api/automations/[ruleId]/executions/route.ts +++ b/src/app/api/automations/[ruleId]/executions/route.ts @@ -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 }); diff --git a/src/app/api/automations/[ruleId]/route.ts b/src/app/api/automations/[ruleId]/route.ts index 04ce8e0..7c50ba7 100644 --- a/src/app/api/automations/[ruleId]/route.ts +++ b/src/app/api/automations/[ruleId]/route.ts @@ -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) { diff --git a/src/app/api/cron/deadlines/route.ts b/src/app/api/cron/deadlines/route.ts index 52eae54..36ae83e 100644 --- a/src/app/api/cron/deadlines/route.ts +++ b/src/app/api/cron/deadlines/route.ts @@ -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 }); } diff --git a/src/app/api/feedback/[itemId]/route.ts b/src/app/api/feedback/[itemId]/route.ts index 16daef4..622d1ee 100644 --- a/src/app/api/feedback/[itemId]/route.ts +++ b/src/app/api/feedback/[itemId]/route.ts @@ -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"); diff --git a/src/app/api/reviews/[sessionId]/route.ts b/src/app/api/reviews/[sessionId]/route.ts index c971e18..013b302 100644 --- a/src/app/api/reviews/[sessionId]/route.ts +++ b/src/app/api/reviews/[sessionId]/route.ts @@ -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) { diff --git a/src/app/api/reviews/route.ts b/src/app/api/reviews/route.ts index 94a9e10..e7d748f 100644 --- a/src/app/api/reviews/route.ts +++ b/src/app/api/reviews/route.ts @@ -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 { diff --git a/src/app/api/revisions/[revisionId]/annotations/[annotationId]/route.ts b/src/app/api/revisions/[revisionId]/annotations/[annotationId]/route.ts index fcb753d..7d1c5ee 100644 --- a/src/app/api/revisions/[revisionId]/annotations/[annotationId]/route.ts +++ b/src/app/api/revisions/[revisionId]/annotations/[annotationId]/route.ts @@ -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) { diff --git a/src/app/api/revisions/[revisionId]/annotations/route.ts b/src/app/api/revisions/[revisionId]/annotations/route.ts index 8e60cbd..262d6bc 100644 --- a/src/app/api/revisions/[revisionId]/annotations/route.ts +++ b/src/app/api/revisions/[revisionId]/annotations/route.ts @@ -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 }, }); diff --git a/src/app/api/revisions/[revisionId]/color-probes/[probeId]/route.ts b/src/app/api/revisions/[revisionId]/color-probes/[probeId]/route.ts index b039f84..4cea0f0 100644 --- a/src/app/api/revisions/[revisionId]/color-probes/[probeId]/route.ts +++ b/src/app/api/revisions/[revisionId]/color-probes/[probeId]/route.ts @@ -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) { diff --git a/src/app/api/revisions/[revisionId]/color-probes/route.ts b/src/app/api/revisions/[revisionId]/color-probes/route.ts index 829971f..1c0e66e 100644 --- a/src/app/api/revisions/[revisionId]/color-probes/route.ts +++ b/src/app/api/revisions/[revisionId]/color-probes/route.ts @@ -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) { diff --git a/src/app/api/stages/[stageId]/feedback/route.ts b/src/app/api/stages/[stageId]/feedback/route.ts index 2508411..0521284 100644 --- a/src/app/api/stages/[stageId]/feedback/route.ts +++ b/src/app/api/stages/[stageId]/feedback/route.ts @@ -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); diff --git a/src/app/api/stages/[stageId]/revisions/[revisionId]/upload/route.ts b/src/app/api/stages/[stageId]/revisions/[revisionId]/upload/route.ts index 775c48f..cacb1d8 100644 --- a/src/app/api/stages/[stageId]/revisions/[revisionId]/upload/route.ts +++ b/src/app/api/stages/[stageId]/revisions/[revisionId]/upload/route.ts @@ -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; diff --git a/src/app/api/uploads/[...path]/route.ts b/src/app/api/uploads/[...path]/route.ts index 6e9c491..fc79832 100644 --- a/src/app/api/uploads/[...path]/route.ts +++ b/src/app/api/uploads/[...path]/route.ts @@ -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 = { 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", }, }); } diff --git a/src/app/api/users/[userId]/route.ts b/src/app/api/users/[userId]/route.ts index f6eadf8..d6d245a 100644 --- a/src/app/api/users/[userId]/route.ts +++ b/src/app/api/users/[userId]/route.ts @@ -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); diff --git a/src/app/api/users/[userId]/skills/route.ts b/src/app/api/users/[userId]/skills/route.ts index 81ad66d..712562a 100644 --- a/src/app/api/users/[userId]/skills/route.ts +++ b/src/app/api/users/[userId]/skills/route.ts @@ -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; diff --git a/src/lib/api-utils.ts b/src/lib/api-utils.ts index 6d80e71..4bb2649 100644 --- a/src/lib/api-utils.ts +++ b/src/lib/api-utils.ts @@ -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(), }, diff --git a/src/lib/automation/action-executor.ts b/src/lib/automation/action-executor.ts index 9378314..7935ce3 100644 --- a/src/lib/automation/action-executor.ts +++ b/src/lib/automation/action-executor.ts @@ -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)` + ); + } } } diff --git a/src/lib/rbac/org-scope.ts b/src/lib/rbac/org-scope.ts index 9cd293c..af1bdaa 100644 --- a/src/lib/rbac/org-scope.ts +++ b/src/lib/rbac/org-scope.ts @@ -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 { @@ -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) { diff --git a/src/lib/rbac/permissions.ts b/src/lib/rbac/permissions.ts index a70f14f..4892163 100644 --- a/src/lib/rbac/permissions.ts +++ b/src/lib/rbac/permissions.ts @@ -8,25 +8,26 @@ import { prisma } from "@/lib/prisma"; export const DEFAULT_PERMISSIONS: Record = { 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", ], }; diff --git a/src/middleware.ts b/src/middleware.ts index 03938d2..1769470 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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(); } }