From 53ba349b54f5b2e0be3e1d9d3f73ab71b2c606cf Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Mon, 7 Sep 2026 11:19:08 +0700
Subject: [PATCH 1/4] docs(ui-ux): add UI/UX rework master plan and industry
benchmark blueprint
---
docs/ui-ux-rework-plan.md | 99 +++++++++++++++++++++++++++++++++++++++
1 file changed, 99 insertions(+)
create mode 100644 docs/ui-ux-rework-plan.md
diff --git a/docs/ui-ux-rework-plan.md b/docs/ui-ux-rework-plan.md
new file mode 100644
index 0000000000..0bc1090458
--- /dev/null
+++ b/docs/ui-ux-rework-plan.md
@@ -0,0 +1,99 @@
+# 📐 Crove Post - UI/UX Rework Master Plan & Benchmark Blueprint
+
+> **Status:** Draft / Architectural Standard
+> **Target:** Frontend Engineering (`apps/frontend`), Design System (`DESIGN.md`)
+> **Benchmark Sources:** Publer, Typefully, Buffer, Linear, Raycast
+> **Core Architecture:** Zero-Conflict / Layer-Isolated (Preserve 100% Upstream Workflow & Provider Engines)
+
+---
+
+## 1. 🎨 Brand Color & Visual Identity Analysis ("Crove")
+
+### 1.1 Color Psychology & Brand Fit
+
+| Option | Primary Accent | Vibe & Psychology | Industry Benchmark | Fit with Crove Ecosystem |
+| :--- | :--- | :--- | :--- | :--- |
+| **Option A: Emerald Tech Green (Xanh Ngọc Lục Bảo)** | `#10B981` `#059669` | **Growth, Harvest, High-ROI, Freshness, Audience Expansion.** Gợi liên tưởng trực tiếp đến *Grove* (vườn cây trĩu quả), *Crops* (thu hoạch thành quả kinh doanh). | Supabase, Warp.dev, Spotify, OpenAI | ⭐⭐⭐⭐⭐ Tách biệt hoàn toàn với màu Đỏ của DOS, tạo cảm giác tăng trưởng doanh thu mạnh mẽ. |
+| **Option B: Royal Electric Purple (Tím Hoàng Gia)** | `#7C3AED` `#6366F1` | **Intelligence, Premium SaaS, AI-Driven, Creator Authority.** Mang tính nghệ thuật, nền tảng Business OS quyền lực và thông minh. | Raycast, Linear, Loom, Vercel AI | ⭐⭐⭐⭐ Sang trọng, hiện đại, nhưng dễ trùng lặp với màu tím mặc định của Postiz cũ nếu không tinh chỉnh. |
+
+### 1.2 Khuyến Nghị Phối Màu (Hybrid Synergy)
+- **Primary Brand Accent (Crove Core):** **Emerald Green (`#10B981` / `#059669`)** — Đại diện cho kênh phân phối, tăng trưởng Traffic, Leads & Khách hàng.
+- **Secondary AI / Copilot Accent:** **Electric Purple / Violet (`#7C3AED`)** — Đại diện cho trợ lý AI Copilot, Magic Prompt & Tự động hóa thông minh.
+- **Canvas Base:** **Obsidian Dark (`#08080f`)** & **Elevated Cards (`#0f0f1c` / `#121224`)** với viền mỏng `border-zinc-800/70`.
+
+---
+
+## 2. 🔍 Benchmark Nghiên Cứu UI/UX Từ Các Nền Tảng Hàng Đầu
+
+### 2.1 Publer (Master of Multi-Brand Workspaces & Calendar UX)
+* **Điểm sáng cần học hỏi:**
+ - **Workspace & Brand Switcher:** Phân tách rõ ràng giữa các Client/Brand, cho phép switch workspace chỉ bằng 1 click với Avatar thương hiệu sắc nét.
+ - **Interactive Calendar:** Phân màu trực quan theo từng trạng thái bài đăng (`Draft` = Xám, `Scheduled` = Xanh/Tím, `Failed` = Đỏ, `Published` = Xanh lá), kéo thả đổi giờ xuất bản mượt mà.
+ - **Bulk Scheduling & Recycling:** Lên lịch hàng loạt và tái sử dụng bài đăng evergreen hiệu quả.
+
+### 2.2 Typefully (Master of Post Composer & Distraction-Free Writing)
+* **Điểm sáng cần học hỏi:**
+ - **Split-View Post Composer:** Cột trái là trình soạn thảo siêu sạch (Distraction-free), cột phải là **Live Pixel-Perfect Mobile/Desktop Preview** mô phỏng 100% giao diện mạng xã hội thật.
+ - **Thread & Multi-Channel Switcher:** Chuyển đổi tab xem trước giữa X, LinkedIn, Facebook, Instagram trong chớp mắt.
+ - **Smart Character Meter:** Bộ đếm ký tự thanh thoát, cảnh báo trực quan khi vượt giới hạn của từng nền tảng.
+
+### 2.3 Buffer & Metricool (Simplicity & Queue Slots)
+* **Điểm sáng cần học hỏi:**
+ - **Posting Schedule Queues:** Cài đặt trước các "Khung giờ vàng" (Posting Slots), khi soạn bài chỉ cần bấm *"Add to Queue"* là tự động vào khung giờ tối ưu tiếp theo.
+ - **Clean Analytics Overview:** Các thẻ KPI tóm tắt (Reach, Engagement, Clicks) kèm biểu đồ sparkline thanh lịch.
+
+### 2.4 Hootsuite (Bài Học Cần Tránh)
+* **Nhược điểm cần tránh:**
+ - Quá tải thông tin (Information Overload), bố cục nhiều cột dày đặc gây rối mắt và làm chậm hiệu năng.
+ - Crove Post phải giữ triết lý **Linear/Raycast-Dense**: Gọn gàng, tốc độ phản hồi dưới 100ms, phím tắt tiện lợi.
+
+---
+
+## 3. 🏗️ Kiến Trúc Rework 4 Module Trọng Tâm (Zero-Conflict)
+
+```
+┌─────────────────────────────────────────────────────────────────────────────────┐
+│ CROVE POST REWORK ARCHITECTURE │
+├─────────────────────┬──────────────────────┬───────────────────┬────────────────┤
+│ MODULE 1 │ MODULE 2 │ MODULE 3 │ MODULE 4 │
+│ Shell & Navigation │ Post Composer Split │ Visual Calendar │ Smart Analytics│
+├─────────────────────┼──────────────────────┼───────────────────┼────────────────┤
+│ • Popover Workspace │ • Distraction-free │ • Drag & Drop │ • KPI Cards │
+│ • Collapsible Bar │ • Live Mobile Prev │ • Status Badges │ • Sparklines │
+│ • Raycast-style Nav │ • Platform Overrides │ • Slot Auto-Fill │ • Heatmaps │
+└─────────────────────┴──────────────────────┴───────────────────┴────────────────┘
+```
+
+### Module 1: App Shell & Workspace Switcher
+- **Mục tiêu:** Thay thế dropdown cũ bằng Popover hiện đại.
+- **Thiết kế:**
+ - Component `CroveWorkspaceSelector`: Hiển thị Logo/Initials của Org, Role badge (`Super-Admin` / `Admin` / `Member`), Active checkmark.
+ - Tích hợp tìm kiếm nhanh khi user có nhiều Org.
+ - Chuyển đổi Workspace mượt mà qua SWR mutate (không reload trang).
+ - Thu gọn Super-Admin Toolbar thành Floating Drawer / Toggle Button góc trên bên phải.
+
+### Module 2: Post Composer Studio (Typefully Style)
+- **Mục tiêu:** Tách Post Composer thành Split-View 2 cột độc lập.
+- **Thiết kế:**
+ - **Cột Trái (Editor Panel - 55%):** Channel selector, Rich Editor, Media Uploader từ R2, First Comment, AI Copilot Prompt, Time picker.
+ - **Cột Phải (Live Platform Preview - 45%):** Tab chuyển đổi X, LinkedIn, Facebook, Instagram, TikTok, Threads với frame mô phỏng điện thoại/máy tính sắc nét.
+
+### Module 3: Visual Interactive Calendar (Publer Style)
+- **Mục tiêu:** Trải nghiệm quản lý lịch xuất bản trực quan, kéo thả linh hoạt.
+- **Thiết kế:**
+ - Kéo - thả (Drag-and-Drop) bài viết giữa các ngày và khung giờ.
+ - Filter Bar ở đầu Calendar để lọc nhanh bài theo từng mạng xã hội hoặc theo trạng thái (`Draft`, `Scheduled`, `Published`).
+
+### Module 4: Analytics & Insights Dashboard
+- **Mục tiêu:** Thống kê hiệu quả bài đăng và kênh tăng trưởng mạnh nhất.
+- **Thiết kế:**
+ - Thẻ KPI tổng quan: Tổng Impressions, Engagement Rate, Top Channels.
+ - Heatmap khung giờ có tương tác cao nhất trong tuần.
+
+---
+
+## 4. 🛡️ Quy Tắc Đảm Bảo "Zero-Conflict" Khi Merge Upstream
+
+1. **Không sửa đổi core workflow và activity files:** Giữ nguyên các files trong `apps/orchestrator` và `libraries/nestjs-libraries/src/integrations/`.
+2. **Theme thông qua CSS Variables:** Toàn bộ palette màu mới được định nghĩa trong `colors.scss` và `tailwind.config.cjs`, không hardcode mã màu lạ vào JSX của upstream.
+3. **Component Isolation:** Các tính năng riêng của Crove (như DOS ID Workspace Switcher, AI Gateway settings) được đóng gói thành các sub-components riêng biệt, chỉ import vào tầng layout wrapper ngoài cùng.
From 3aae08cd284aac3e216e8495ad5ca47519a4adb0 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Mon, 7 Sep 2026 11:22:02 +0700
Subject: [PATCH 2/4] chore(rules): add communication guidelines cursor rule
---
.cursor/rules/communication-guidelines.mdc | 14 ++++++++++++++
1 file changed, 14 insertions(+)
create mode 100644 .cursor/rules/communication-guidelines.mdc
diff --git a/.cursor/rules/communication-guidelines.mdc b/.cursor/rules/communication-guidelines.mdc
new file mode 100644
index 0000000000..9fe844695b
--- /dev/null
+++ b/.cursor/rules/communication-guidelines.mdc
@@ -0,0 +1,14 @@
+---
+description: Nguyên tắc phản hồi và không lặp lại câu hỏi cũ
+alwaysApply: true
+---
+
+# Nguyên Tắc Phản Hồi & Giao Tiếp
+
+1. **Tuyệt đối không nhắc lại, lặp lại các kết luận hoặc câu hỏi cũ**:
+ - Không lặp lại việc so sánh trạng thái cũ (ví dụ: việc Beta behind Prod hay các thông báo trạng thái cũ) khi user không yêu cầu.
+ - Khi user hỏi câu hỏi mới hoặc đặt câu hỏi phản biện, chỉ tập trung giải quyết trực diện vấn đề được nêu.
+
+2. **Trả lời đúng trọng tâm kỹ thuật**:
+ - Phân tích đúng bản chất kiến trúc (OAuth 2.1, PKCE Bridge, Redirect URI security model).
+ - Giải thích ngắn gọn, súc tích, mạch lạc và đi thẳng vào giải pháp kỹ thuật.
From c2b478851a407704fe07d4fb8c2db7d6b3a647c0 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Tue, 8 Sep 2026 03:33:20 +0700
Subject: [PATCH 3/4] docs(changelog): record beta bootstrap/facebook env
remediation (2026-09-07)
---
CHANGELOG.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 01df91e8c2..0e189ebd66 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+- **Beta Runtime: DOS-Me First-Party Bootstrap 401 & Facebook Connect `client_id=undefined`**:
+ - Root cause: the Beta runtime (`crove-post-beta` on `crove-server`) had no `CROVE_POST_CLIENT_ID`/`CROVE_POST_CLIENT_SECRET` configured, so `BootstrapGuard` failed closed with 401 before HMAC verification; `FACEBOOK_APP_ID`/`FACEBOOK_APP_SECRET` were also missing, producing `client_id=undefined` in the Connect Facebook URL.
+ - Remediated by configuring `CROVE_POST_CLIENT_ID=pca_dosclaw_beta_7ef5e5f1`, `CROVE_POST_CLIENT_SECRET` (existing OAuth client secret, no rotation) and copying `FACEBOOK_APP_ID`/`FACEBOOK_APP_SECRET` from the prod env file into the Beta env file on the VM (gitignored by design via `scripts/*.env`).
+ - Verified with a signed request differential: signed bootstrap → HTTP 400 (guard passed, validation rejected the test body) vs unsigned → HTTP 401 (fail-closed). No OAuth app was recreated and no token was rotated.
+ - Beta container recreated with the same immutable image digest; a one-off boot hang after recreate (backend blocked pre-Nest with no network sockets) was cleared by a plain `docker restart`.
+
### Added
- **MCP Client Icons & Onboarding Enhancements (Upstream Sync)**:
- Added Nanoclaw and other third-party MCP client icons support in Public API.
From abf11fc9916493cb782493cc5919ff699f2d9e27 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Thu, 10 Sep 2026 11:50:02 +0700
Subject: [PATCH 4/4] fix(ops): remediate Crove-side audit findings
(C2,C5,C7,C9,S4,S15-S20,I-B2..I-E7)
Security & secrets:
---
.env.example | 5 +-
.github/workflows/branding-guard.yml | 2 +
.github/workflows/build-extension.yaml | 4 +-
.github/workflows/build.yml | 8 +
.github/workflows/deploy-sso.yml | 7 +-
.github/workflows/eslint.yml | 9 +-
.github/workflows/publish-extension.yml | 5 +-
.github/workflows/staging-conflicts.yml | 188 ---
.gitignore | 1 +
Jenkins/Build.Jenkinsfile | 96 --
Jenkins/BuildPR.Jenkinsfile | 100 --
README.md | 2 +-
apps/backend/package.json | 5 +-
.../src/api/routes/dos-org-sync.controller.ts | 5 +-
apps/commands/package.json | 5 +-
apps/crove-sso/package.json | 1 +
apps/crove-sso/wrangler.jsonc | 2 -
apps/extension/build.mjs | 7 +-
apps/extension/manifest.json | 3 -
apps/extension/package.json | 2 +-
apps/frontend/package.json | 5 +-
apps/frontend/src/app/colors.scss | 10 +
.../analytics/analytics.component.tsx | 85 --
.../src/components/billing/faq.component.tsx | 6 +-
.../src/components/layout/impersonate.tsx | 8 +-
.../src/components/layout/layout.context.tsx | 15 +-
.../layout/organization.selector.tsx | 10 +-
.../components/layout/settings.component.tsx | 2 +-
.../src/components/layout/support.tsx | 2 +-
.../onboarding/onboarding.modal.tsx | 2 +-
apps/frontend/tailwind.config.cjs | 5 +
apps/orchestrator/package.json | 5 +-
apps/sdk/package.json | 8 +-
chatgpt-app-submission.json | 4 +-
docker-compose.dev.yaml | 12 +-
docker-compose.yaml | 4 +-
docs/audit-2026-09-08.html | 1234 +++++++++++++++++
docs/cicd.md | 4 +-
dynamicconfig/production-sql.yaml | 12 +
jest.config.ts | 11 +-
package.json | 19 +-
railway.toml | 3 -
scripts/backup-db.sh | 55 +
scripts/branding-guard.ts | 126 ++
scripts/build-all.ps1 | 2 +
scripts/docker-compose.beta.yaml | 22 -
scripts/docker-compose.prod.yaml | 97 +-
scripts/nginx-crove.conf | 21 -
scripts/validate-beta-compose.mjs | 23 +-
sonar-project.properties | 7 -
var/docker/docker-build.sh | 7 -
var/docker/docker-create.sh | 5 -
var/docker/nginx.conf | 6 +
53 files changed, 1636 insertions(+), 658 deletions(-)
delete mode 100644 .github/workflows/staging-conflicts.yml
delete mode 100644 Jenkins/Build.Jenkinsfile
delete mode 100644 Jenkins/BuildPR.Jenkinsfile
create mode 100644 docs/audit-2026-09-08.html
create mode 100644 dynamicconfig/production-sql.yaml
delete mode 100644 railway.toml
create mode 100644 scripts/backup-db.sh
delete mode 100644 scripts/nginx-crove.conf
delete mode 100644 sonar-project.properties
delete mode 100755 var/docker/docker-build.sh
delete mode 100755 var/docker/docker-create.sh
diff --git a/.env.example b/.env.example
index 692b5ca5c7..f02d9b8f5b 100644
--- a/.env.example
+++ b/.env.example
@@ -10,8 +10,9 @@
DATABASE_URL="postgresql://postiz-user:postiz-password@localhost:5432/postiz-db-local?schema=public&sslmode=prefer"
# Direct PostgreSQL Connection URL (Used for Prisma migrations and direct connections)
DATABASE_DIRECT_URL="postgresql://postiz-user:postiz-password@localhost:5432/postiz-db-local?schema=public&sslmode=prefer"
-# Node TLS verification (set to 0 for self-signed certificates or Supabase poolers)
-NODE_TLS_REJECT_UNAUTHORIZED="0"
+# Node TLS verification — NEVER set to 0 in production. If you use self-signed
+# certificates or a Supabase pooler that requires it, restrict it to dev only.
+# NODE_TLS_REJECT_UNAUTHORIZED="0"
# Redis Cache & Session Store URL
REDIS_URL="redis://localhost:6379"
# Cryptographic secret for signing JWT sessions (must be 32+ random characters)
diff --git a/.github/workflows/branding-guard.yml b/.github/workflows/branding-guard.yml
index d4173ac5ae..48a603d9c9 100644
--- a/.github/workflows/branding-guard.yml
+++ b/.github/workflows/branding-guard.yml
@@ -15,6 +15,8 @@ jobs:
branding-guard:
name: "Validate Brand Engine & Contracts"
runs-on: ubuntu-latest
+ permissions:
+ contents: read
steps:
- name: Checkout Code
uses: actions/checkout@v4
diff --git a/.github/workflows/build-extension.yaml b/.github/workflows/build-extension.yaml
index d6ab5ffa2e..fea578338f 100644
--- a/.github/workflows/build-extension.yaml
+++ b/.github/workflows/build-extension.yaml
@@ -6,6 +6,8 @@ on:
jobs:
submit:
runs-on: ubuntu-latest
+ permissions:
+ contents: read
steps:
- uses: actions/checkout@v4
@@ -21,7 +23,7 @@ jobs:
run: pnpm install
- name: Zip extensions
- run: FRONTEND_URL=https://platform.postiz.com pnpm run build:extension
+ run: FRONTEND_URL=https://app.crove.com pnpm run build:extension
- name: Upload to Nextcloud
env:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index ecbd108ae9..a4628de632 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -15,6 +15,8 @@ on:
jobs:
build:
runs-on: ubuntu-latest
+ permissions:
+ contents: read
strategy:
matrix:
@@ -42,5 +44,11 @@ jobs:
- name: Validate Beta deployment script and compose contract
run: pnpm run validate:beta-deploy
+ - name: Test SSO Worker (vitest)
+ run: pnpm run test:sso
+
+ - name: Test bootstrap / OAuth consent (jest)
+ run: pnpm exec jest --config tests/bootstrap.jest.cjs --ci --passWithNoTests
+
- name: Build applications
run: pnpm run build
diff --git a/.github/workflows/deploy-sso.yml b/.github/workflows/deploy-sso.yml
index bf6193155b..14c35f35cd 100644
--- a/.github/workflows/deploy-sso.yml
+++ b/.github/workflows/deploy-sso.yml
@@ -22,6 +22,9 @@ on:
jobs:
test-and-deploy:
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ environment: ${{ github.event.inputs.environment == 'prod' && 'production' || github.ref_name == 'main' && 'production' || 'beta' }}
steps:
- name: Checkout Code
uses: actions/checkout@v4
@@ -60,7 +63,7 @@ jobs:
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || '3368ff98a4c956164b7bbdc8fb950163' }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: 'apps/crove-sso'
command: 'deploy -e beta'
env:
@@ -71,7 +74,7 @@ jobs:
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || '3368ff98a4c956164b7bbdc8fb950163' }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: 'apps/crove-sso'
command: 'deploy'
env:
diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml
index aaf36a9874..8f4f1643e4 100644
--- a/.github/workflows/eslint.yml
+++ b/.github/workflows/eslint.yml
@@ -52,16 +52,17 @@ jobs:
pnpm add -D @microsoft/eslint-formatter-sarif@2.1.7
- name: Run ESLint
+ # Transitional: violations are now VISIBLE as a failed step instead of
+ # being swallowed. Flip continue-on-error to false (and add a typecheck
+ # gate) once the flat-config setup is validated — see audit C9.
+ continue-on-error: true
run: |
npx eslint apps/${{ matrix.service }}/ \
- --config apps/${{ matrix.service }}/.eslintrc.json \
--format @microsoft/eslint-formatter-sarif \
- --output-file apps/${{ matrix.service }}/eslint-results.sarif || true
- continue-on-error: true
+ --output-file apps/${{ matrix.service }}/eslint-results.sarif
- name: Upload analysis results to GitHub
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: apps/${{ matrix.service }}/eslint-results.sarif
wait-for-processing: true
- continue-on-error: true
diff --git a/.github/workflows/publish-extension.yml b/.github/workflows/publish-extension.yml
index f9b4e4e43a..7ab25cc99e 100644
--- a/.github/workflows/publish-extension.yml
+++ b/.github/workflows/publish-extension.yml
@@ -6,6 +6,9 @@ on:
jobs:
submit:
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ environment: production
steps:
- uses: actions/checkout@v4
@@ -20,7 +23,7 @@ jobs:
run: pnpm install
- name: Zip extensions
- run: FRONTEND_URL=https://platform.postiz.com pnpm run build:extension
+ run: FRONTEND_URL=https://app.crove.com pnpm run build:extension
- name: Publish to Chrome Web Store
uses: mnao305/chrome-extension-upload@v5.0.0
diff --git a/.github/workflows/staging-conflicts.yml b/.github/workflows/staging-conflicts.yml
deleted file mode 100644
index 3d58f9b53a..0000000000
--- a/.github/workflows/staging-conflicts.yml
+++ /dev/null
@@ -1,188 +0,0 @@
-name: Resolve staging conflicts
-
-# Reproduces a staging <- main merge inside the runner. If it conflicts, Claude
-# resolves the conflicts and creates the merge commit. Nothing is commented,
-# nothing is pushed unless the resolution passes every verification below.
-#
-# claude-code-action rejects `push` events, so detection runs on a schedule.
-#
-# Required secrets:
-# STAGING_MERGE_KEY private half of a write-enabled deploy key
-# CLAUDE_CODE_OAUTH_TOKEN Claude API key, despite the secret name
-
-on:
- schedule:
- - cron: "*/10 * * * *"
- workflow_dispatch:
-
-concurrency:
- group: staging-conflict-resolve
- cancel-in-progress: false
-
-jobs:
- resolve:
- if: github.repository == 'gitroomhq/postiz-app'
- runs-on: ubuntu-latest
- timeout-minutes: 20
- permissions:
- contents: read
-
- steps:
- - name: Checkout staging
- uses: actions/checkout@v6
- with:
- ref: staging
- fetch-depth: 0
- ssh-key: ${{ secrets.STAGING_MERGE_KEY }}
- persist-credentials: true
-
- # Runs before anything billable. If the deploy key cannot push, the job
- # dies here at zero cost instead of after a paid resolution.
- - name: Configure push credentials
- env:
- SSH_KEY: ${{ secrets.STAGING_MERGE_KEY }}
- run: |
- mkdir -p ~/.ssh
- printf '%s\n' "$SSH_KEY" > ~/.ssh/staging_merge
- chmod 600 ~/.ssh/staging_merge
- ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts
- echo "GIT_SSH_COMMAND=ssh -i $HOME/.ssh/staging_merge -o IdentitiesOnly=yes" >> "$GITHUB_ENV"
-
- # `git push --dry-run` exercises the exact path the real push takes.
- # A bare `ssh -T` does not, since GIT_SSH_COMMAND applies only to git.
- - name: Preflight push
- run: |
- git remote set-url origin "git@github.com:${{ github.repository }}.git"
- git push --dry-run origin HEAD:staging
- echo "push path verified"
-
- - name: Probe merge
- id: probe
- run: |
- git config user.name "postiz-merge-bot"
- git config user.email "bot@postiz.com"
-
- if git merge --no-commit --no-ff origin/main; then
- git merge --abort 2>/dev/null || git reset --hard HEAD
- echo "conflicted=false" >> "$GITHUB_OUTPUT"
- echo "staging merges cleanly into main, nothing to do"
- else
- echo "conflicted=true" >> "$GITHUB_OUTPUT"
- echo "Conflicted paths:"
- git diff --name-only --diff-filter=U
- fi
-
- # CI definitions always come from main, so conflicts under .github/ are
- # settled here by taking main's side. This keeps Claude away from them
- # and stops this workflow from deadlocking on edits to itself: it was
- # added independently on both branches, so git sees add/add and every
- # change to it on main conflicts no matter how staging's copy looks.
- - name: Take main's CI definitions
- if: steps.probe.outputs.conflicted == 'true'
- run: |
- git diff --name-only -z --diff-filter=U -- .github/ > /tmp/ci_paths
- if [ ! -s /tmp/ci_paths ]; then
- echo "no conflicts under .github/"
- exit 0
- fi
-
- echo "taking main's copy of:"
- tr '\0' '\n' < /tmp/ci_paths
- xargs -0 git checkout --theirs -- < /tmp/ci_paths
- xargs -0 git add -- < /tmp/ci_paths
-
- - name: Resolve with Claude
- if: steps.probe.outputs.conflicted == 'true'
- uses: anthropics/claude-code-action@v1
- with:
- anthropic_api_key: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
- github_token: ${{ secrets.GITHUB_TOKEN }}
- allowed_bots: "github-actions[bot]"
- prompt: |
- The repository is checked out on `staging`, part-way through
- `git merge --no-ff origin/main`, and the merge has conflicts.
-
- Resolve every conflicted file so the result preserves the intent of
- both sides. Then `git add` the resolved paths and create the merge
- commit with:
-
- git commit --no-edit --trailer "Resolved-by: claude-code-action"
-
- Constraints:
- - Change nothing beyond what the conflict resolution requires.
- - Do not push, switch branches, create branches, or amend history.
- - Do not post comments, open issues, or touch any pull request.
- - Never modify anything under `.github/`. Conflicts there are
- already resolved and staged for you; leave them exactly as they
- are and resolve only the remaining paths.
- - If a conflict is ambiguous enough that you would be guessing at
- the correct resolution, stop without committing and explain why.
- claude_args: |
- --model claude-sonnet-5
- --allowedTools "Read,Glob,Grep,Edit,Write,Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git ls-files:*),Bash(git add:*),Bash(git commit:*)"
-
- - name: Verify resolution
- if: steps.probe.outputs.conflicted == 'true'
- run: |
- if git ls-files -u | grep -q .; then
- echo "::error::Unmerged paths remain in the index"
- git ls-files -u
- exit 1
- fi
-
- if git rev-parse -q --verify MERGE_HEAD >/dev/null; then
- echo "::error::Merge was never committed"
- exit 1
- fi
-
- if git grep -nI -e '^<<<<<<< ' -e '^=======$' -e '^>>>>>>> ' HEAD; then
- echo "::error::Conflict markers present in the committed tree"
- exit 1
- fi
-
- if [ -n "$(git status --porcelain)" ]; then
- echo "::error::Working tree is dirty after the commit"
- git status --porcelain
- exit 1
- fi
-
- if ! git merge-base --is-ancestor origin/main HEAD; then
- echo "::error::HEAD does not contain origin/main, wrong commit shape"
- exit 1
- fi
-
- # Every .github/ path the merge touched must be either untouched by
- # the merge or byte-identical to main's copy, so a resolution can
- # never smuggle in a CI change of its own.
- for path in $(git diff --name-only origin/staging..HEAD -- .github/); do
- if ! git diff --quiet origin/main HEAD -- "$path"; then
- echo "::error::$path differs from main's copy, refusing"
- git diff origin/main HEAD -- "$path"
- exit 1
- fi
- done
-
- echo "Resolution commit:"
- git log -1 --stat
-
- # Saved before the push, so a push failure never costs a second run.
- # Recover with: git fetch ./resolved.bundle HEAD
- # git push origin FETCH_HEAD:staging
- - name: Archive resolution
- if: steps.probe.outputs.conflicted == 'true'
- run: git bundle create /tmp/resolved.bundle HEAD ^origin/staging ^origin/main
-
- - uses: actions/upload-artifact@v4
- if: steps.probe.outputs.conflicted == 'true'
- with:
- name: staging-resolution-${{ github.run_id }}
- path: /tmp/resolved.bundle
- retention-days: 14
-
- # The action rewrites `origin` to an HTTPS URL during its own git setup,
- # so the SSH remote is reasserted here.
- - name: Push staging
- if: steps.probe.outputs.conflicted == 'true'
- run: |
- git remote set-url origin "git@github.com:${{ github.repository }}.git"
- git push origin HEAD:staging
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 8e6bcc2d82..3b330863db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -69,4 +69,5 @@ scripts/*.env
.artifacts/
.codex-artifacts/
.playwright-mcp/
+.cloudflared/
diff --git a/Jenkins/Build.Jenkinsfile b/Jenkins/Build.Jenkinsfile
deleted file mode 100644
index 886ade0a9a..0000000000
--- a/Jenkins/Build.Jenkinsfile
+++ /dev/null
@@ -1,96 +0,0 @@
-// Declarative Pipeline for building Node.js application and running SonarQube analysis triggered by a push event.
-pipeline {
- // Defines the execution environment. Using 'agent any' to ensure an agent is available.
- agent any
-
- // Global environment block removed to prevent Groovy scoping issues with manual path calculation.
-
- stages {
- // Stage 1: Checkout the code (Relies on the initial SCM checkout done by Jenkins)
- stage('Source Checkout') {
- steps {
- echo "Workspace already populated by the initial SCM checkout. Proceeding."
- }
- }
-
- // Stage 2: Setup Node.js v20 and install pnpm
- stage('Setup Environment and Tools') {
- steps {
- sh '''
- echo "Ensuring required utilities and Node.js are installed..."
- sudo apt-get update
- sudo apt-get install -y curl unzip nodejs
-
- # 1. Install Node.js v20
- curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
- sudo apt-get install -y nodejs
- echo "Node.js version: \$(node -v)"
-
- # 2. Install pnpm globally (version 8)
- npm install -g pnpm@8
- echo "pnpm version: \$(pnpm -v)"
- '''
- }
- }
-
- // Stage 3: Install dependencies and build the application
- stage('Install and Build') {
- steps {
- sh 'pnpm install'
- sh 'pnpm run build'
- }
- }
-
- // Stage 4: Run SonarQube analysis: Install scanner, get version, and execute.
- stage('SonarQube Analysis') {
- steps {
- script {
- // 1. Get the short 8-character commit SHA for project versioning
- def commitShaShort = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim()
- echo "Commit SHA (short) is: ${commitShaShort}"
-
- // --- 2. MANUALLY INSTALL THE SONAR SCANNER CLI LOCALLY IN THIS STAGE ---
- sh """
- echo "Manually downloading and installing Sonar Scanner CLI..."
-
- # Download the stable scanner CLI package
- curl -sS -o sonar-scanner.zip \
- "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-4.7.0.2747.zip"
-
- # Added -o flag to force overwrite and prevent interactive prompt failure
- unzip -o -q sonar-scanner.zip -d .
- """
-
- // 3. Find the extracted directory name and capture the full absolute bin path in Groovy
- // This is defined locally and used directly, avoiding environment variable issues.
- def scannerBinPath = sh(
- returnStdout: true,
- script: '''
- SCANNER_DIR=$(find . -maxdepth 1 -type d -name "sonar-scanner*" | head -n 1)
- # Get the full absolute path to the executable file
- echo \$(pwd)/\${SCANNER_DIR}/bin/sonar-scanner
- '''
- ).trim()
-
- echo "Scanner executable path captured: ${scannerBinPath}"
-
- // 4. Use withSonarQubeEnv to set up the secure variables (HOST and TOKEN)
- withSonarQubeEnv(installationName: 'SonarQube-Server') {
- // 5. Execute the scanner using the Groovy variable directly.
- sh """
- echo "Starting SonarQube Analysis for project version: ${commitShaShort}"
-
- # Execute the full, absolute path captured in the Groovy variable.
- '${scannerBinPath}' \\
- -Dsonar.projectVersion=${commitShaShort} \\
- -Dsonar.sources=.
-
- # SONAR_HOST_URL and SONAR_TOKEN are automatically passed as environment variables
- # by the withSonarQubeEnv block.
- """
- }
- }
- }
- }
- }
-}
diff --git a/Jenkins/BuildPR.Jenkinsfile b/Jenkins/BuildPR.Jenkinsfile
deleted file mode 100644
index 6e279b0ea4..0000000000
--- a/Jenkins/BuildPR.Jenkinsfile
+++ /dev/null
@@ -1,100 +0,0 @@
-// Declarative Pipeline for building Node.js application and running SonarQube analysis for a Pull Request.
-pipeline {
- // Defines the execution environment. Using 'agent any' to ensure an agent is available.
- agent any
-
- // Environment variables that hold PR details, provided by Jenkins Multibranch setup.
- environment {
- // FIX: Environment variables must be quoted or wrapped in a function call.
- // We quote the 'env.CHANGE_ID' reference to fix the compilation error.
- PR_KEY = "${env.CHANGE_ID}"
- PR_BRANCH = "${env.CHANGE_BRANCH}"
- PR_BASE = "${env.CHANGE_TARGET}"
- }
-
- stages {
- // Stage 1: Checkout the code (Relies on the initial SCM checkout done by Jenkins)
- stage('Source Checkout') {
- steps {
- echo "Workspace already populated by the initial SCM checkout. Proceeding."
- }
- }
-
- // Stage 2: Setup Node.js v20, install pnpm, and install required tools (curl, unzip)
- stage('Setup Environment and Tools') {
- steps {
- sh '''
- echo "Ensuring required utilities and Node.js are installed..."
- sudo apt-get update
- sudo apt-get install -y curl unzip nodejs
-
- # 1. Install Node.js v20 (closest matching the specified version '20.17.0')
- curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
- sudo apt-get install -y nodejs
- echo "Node.js version: \$(node -v)"
-
- # 2. Install pnpm globally (version 8)
- npm install -g pnpm@8
- echo "pnpm version: \$(pnpm -v)"
- '''
- }
- }
-
- // Stage 3: Install dependencies and build the application
- stage('Install and Build') {
- steps {
- sh 'pnpm install'
- sh 'pnpm run build'
- }
- }
-
- // Stage 4: Run SonarQube PR analysis: Install scanner locally, get version, and execute.
- stage('SonarQube Pull Request Analysis') {
- steps {
- script {
- // 1. Get the short 8-character commit SHA for project versioning
- def commitShaShort = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim()
- echo "Commit SHA (short) is: ${commitShaShort}"
-
- // --- 2. MANUALLY INSTALL THE SONAR SCANNER CLI LOCALLY ---
- sh """
- echo "Manually downloading and installing Sonar Scanner CLI..."
- curl -sS -o sonar-scanner.zip \
- "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-4.7.0.2747.zip"
- unzip -o -q sonar-scanner.zip -d .
- """
-
- // 3. Find the extracted directory name and capture the full absolute executable path.
- def scannerBinPath = sh(
- returnStdout: true,
- script: '''
- SCANNER_DIR=$(find . -maxdepth 1 -type d -name "sonar-scanner*" | head -n 1)
- # Get the full absolute path to the executable file
- echo \$(pwd)/\${SCANNER_DIR}/bin/sonar-scanner
- '''
- ).trim()
-
- echo "Scanner executable path captured: ${scannerBinPath}"
-
- // 4. Use withSonarQubeEnv to set up the secure variables (HOST and TOKEN)
- withSonarQubeEnv(installationName: 'SonarQube-Server') {
- // 5. Execute the scanner using the Groovy variable directly with PR parameters.
- sh """
- echo "Starting SonarQube Pull Request Analysis for PR #${PR_KEY}"
-
- '${scannerBinPath}' \\
- -Dsonar.projectVersion=${commitShaShort} \\
- -Dsonar.sources=. \\
- -Dsonar.pullrequest.key=${PR_KEY} \\
- -Dsonar.pullrequest.branch=${PR_BRANCH} \\
- -Dsonar.pullrequest.base=${PR_BASE}
-
- # SONAR_HOST_URL and SONAR_TOKEN are automatically passed as environment variables
- # by the withSonarQubeEnv block.
- """
- }
- }
- }
- }
- }
-}
diff --git a/README.md b/README.md
index a55a97799e..5e47047bc9 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,7 @@
Public API
- NodeJS SDK
+ NodeJS SDK
·
N8N custom node
·
diff --git a/apps/backend/package.json b/apps/backend/package.json
index 0b28847809..25e619ac52 100644
--- a/apps/backend/package.json
+++ b/apps/backend/package.json
@@ -1,5 +1,5 @@
{
- "name": "postiz-backend",
+ "name": "@crove/backend",
"version": "1.0.0",
"description": "",
"scripts": {
@@ -10,5 +10,6 @@
},
"keywords": [],
"author": "",
- "license": "ISC"
+ "license": "AGPL-3.0",
+ "private": true
}
diff --git a/apps/backend/src/api/routes/dos-org-sync.controller.ts b/apps/backend/src/api/routes/dos-org-sync.controller.ts
index dc8c5c2e04..3151162e67 100644
--- a/apps/backend/src/api/routes/dos-org-sync.controller.ts
+++ b/apps/backend/src/api/routes/dos-org-sync.controller.ts
@@ -33,10 +33,7 @@ export class DosOrgSyncWebhookController {
return false;
}
- const secret =
- process.env.DOS_SYNC_WEBHOOK_SECRET ||
- process.env.DOS_WEBHOOK_SECRET ||
- process.env.JWT_SECRET;
+ const secret = process.env.DOS_SYNC_WEBHOOK_SECRET;
if (!secret || !signatureHeader) {
return false;
}
diff --git a/apps/commands/package.json b/apps/commands/package.json
index 39e86d98eb..319c5835dc 100644
--- a/apps/commands/package.json
+++ b/apps/commands/package.json
@@ -1,5 +1,5 @@
{
- "name": "postiz-command",
+ "name": "@crove/commands",
"version": "1.0.0",
"description": "",
"scripts": {
@@ -9,5 +9,6 @@
},
"keywords": [],
"author": "",
- "license": "ISC"
+ "license": "AGPL-3.0",
+ "private": true
}
diff --git a/apps/crove-sso/package.json b/apps/crove-sso/package.json
index b2cfa0794c..955b3dedcf 100644
--- a/apps/crove-sso/package.json
+++ b/apps/crove-sso/package.json
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"scripts": {
+ "dev": "wrangler dev",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"cf-typegen": "wrangler types --include-runtime false",
diff --git a/apps/crove-sso/wrangler.jsonc b/apps/crove-sso/wrangler.jsonc
index 261a76b198..b3a9f1c24c 100644
--- a/apps/crove-sso/wrangler.jsonc
+++ b/apps/crove-sso/wrangler.jsonc
@@ -36,7 +36,6 @@
"UPSTREAM_CLIENT_ID": "18790ccb-4d71-48cd-ad24-aee5f3ced3da",
"DOWNSTREAM_REDIRECT_URI": "https://post.crove.com/settings",
"DOWNSTREAM_CLIENT_ID": "crove-postiz",
- "DOWNSTREAM_CLIENT_SECRET": "bridge-secret-value",
"ALLOWED_SCOPE": "openid profile email"
},
"env": {
@@ -70,7 +69,6 @@
"UPSTREAM_CLIENT_ID": "7ef5e5f1-68e6-42a7-901e-1f39e9471d24",
"DOWNSTREAM_REDIRECT_URI": "https://beta-post.crove.com/settings",
"DOWNSTREAM_CLIENT_ID": "crove-postiz",
- "DOWNSTREAM_CLIENT_SECRET": "bridge-secret-value",
"ALLOWED_SCOPE": "openid profile email"
}
}
diff --git a/apps/extension/build.mjs b/apps/extension/build.mjs
index 7fcc05d11f..a306e072fa 100644
--- a/apps/extension/build.mjs
+++ b/apps/extension/build.mjs
@@ -16,8 +16,11 @@ if (fs.existsSync(zipFile)) {
fs.rmSync(zipFile, { force: true });
}
-console.log('2. Running Vite build...');
-await build();
+console.log('2. Running Vite build (chrome crx pipeline)...');
+// Build with the full crx pipeline (vite.config.chrome.ts), not the bare
+// lib config in vite.config.ts — the chrome config computes host_permissions
+// from FRONTEND_URL and applies BRAND_* overrides.
+await build({ configFile: 'vite.config.chrome.ts' });
console.log('3. Copying manifest.json and static assets...');
const manifestSrc = path.resolve(__dirname, 'manifest.json');
diff --git a/apps/extension/manifest.json b/apps/extension/manifest.json
index 82c2dd6a8d..6dc6f606c4 100755
--- a/apps/extension/manifest.json
+++ b/apps/extension/manifest.json
@@ -27,9 +27,6 @@
},
"externally_connectable": {
"matches": [
- "http://localhost/*",
- "https://localhost/*",
- "https://*.postiz.com/*",
"https://*.crove.com/*",
"https://*.crove.io/*",
"https://*.dos.me/*"
diff --git a/apps/extension/package.json b/apps/extension/package.json
index 0b051f9505..ead2b865d1 100644
--- a/apps/extension/package.json
+++ b/apps/extension/package.json
@@ -1,5 +1,5 @@
{
- "name": "postiz-extension",
+ "name": "@crove/extension",
"version": "2.0.0",
"description": "Crove Post browser extension for cookie-based platform authentication",
"scripts": {
diff --git a/apps/frontend/package.json b/apps/frontend/package.json
index 4e92b3ca13..767e8323ef 100644
--- a/apps/frontend/package.json
+++ b/apps/frontend/package.json
@@ -1,5 +1,5 @@
{
- "name": "postiz-frontend",
+ "name": "@crove/frontend",
"version": "1.0.0",
"description": "",
"type": "module",
@@ -14,5 +14,6 @@
},
"keywords": [],
"author": "",
- "license": "ISC"
+ "license": "AGPL-3.0",
+ "private": true
}
diff --git a/apps/frontend/src/app/colors.scss b/apps/frontend/src/app/colors.scss
index e719580878..0860b0c0dd 100644
--- a/apps/frontend/src/app/colors.scss
+++ b/apps/frontend/src/app/colors.scss
@@ -26,6 +26,11 @@
--new-col-color: #2c2b2b;
--new-menu-dots: #696868;
--new-menu-hover: #fff;
+ --new-onboarding-primary: #10b981;
+ --new-onboarding-primary-end: #059669;
+ --new-onboarding-primary-hover: #34d399;
+ --new-support-btn-bg: #ffffff;
+ --new-support-btn-text: #0e0e0e;
--menu-shadow: 0 8px 30px 0 rgba(0, 0, 0, 0.5);
--popup-color: rgba(65, 64, 66, 0.3);
--border-preview: transparent;
@@ -70,6 +75,11 @@
--new-col-color: #eff1f3;
--new-menu-dots: #696868;
--new-menu-hover: #000;
+ --new-onboarding-primary: #10b981;
+ --new-onboarding-primary-end: #059669;
+ --new-onboarding-primary-hover: #34d399;
+ --new-support-btn-bg: #000000;
+ --new-support-btn-text: #ffffff;
--menu-shadow: -22px 83px 24px 0 rgba(55, 52, 75, 0),
-14px 53px 22px 0 rgba(55, 52, 75, 0.01),
-8px 30px 19px 0 rgba(55, 52, 75, 0.05),
diff --git a/apps/frontend/src/components/analytics/analytics.component.tsx b/apps/frontend/src/components/analytics/analytics.component.tsx
index 6ab26d2e63..5d8bcaa49d 100644
--- a/apps/frontend/src/components/analytics/analytics.component.tsx
+++ b/apps/frontend/src/components/analytics/analytics.component.tsx
@@ -28,91 +28,6 @@ export const AnalyticsComponent: FC = () => {
- {/*
*/}
- {/*
News Feed */}
- {/*
*/}
- {/*
*/}
- {/* Global*/}
- {/*
*/}
- {/*
*/}
- {/* My Feed*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
Nevo David
*/}
- {/*
05/06/2024
*/}
- {/*
*/}
- {/*
O atual sistema político precisa mudar para valorizar o trabalho e garantir igualdade de oportunidad
*/}
- {/*
*/}
- {/*
See Tweet
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
Nevo David
*/}
- {/*
05/06/2024
*/}
- {/*
*/}
- {/*
O atual sistema político precisa mudar para valorizar o trabalho e garantir igualdade de oportunidad
*/}
- {/*
*/}
- {/*
See Tweet
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
Nevo David
*/}
- {/*
05/06/2024
*/}
- {/*
*/}
- {/*
O atual sistema político precisa mudar para valorizar o trabalho e garantir igualdade de oportunidad
*/}
- {/*
*/}
- {/*
See Tweet
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
Nevo David
*/}
- {/*
05/06/2024
*/}
- {/*
*/}
- {/*
O atual sistema político precisa mudar para valorizar o trabalho e garantir igualdade de oportunidad
*/}
- {/*
*/}
- {/*
See Tweet
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
Nevo David
*/}
- {/*
05/06/2024
*/}
- {/*
*/}
- {/*
O atual sistema político precisa mudar para valorizar o trabalho e garantir igualdade de oportunidad
*/}
- {/*
*/}
- {/*
See Tweet
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
- {/*
*/}
);
};
diff --git a/apps/frontend/src/components/billing/faq.component.tsx b/apps/frontend/src/components/billing/faq.component.tsx
index 5b26316ac4..d93dc49f7e 100644
--- a/apps/frontend/src/components/billing/faq.component.tsx
+++ b/apps/frontend/src/components/billing/faq.component.tsx
@@ -32,11 +32,7 @@ const useFaqList = () => {
),
description: t(
'faq_postiz_gitroom_is_proudly_open_source',
- `${
- isGeneral ? 'Postiz' : 'Gitroom'
- } is proudly open-source! We believe in an ethical and transparent culture, meaning that ${
- isGeneral ? 'Postiz' : 'Gitroom'
- } will live forever. You can check out the entire code or use it for personal projects. To view the open-source repository, click here .`
+ `Crove Post is proudly open-source! We believe in an ethical and transparent culture, meaning that Crove Post will live forever. You can check out the entire code or use it for personal projects. To view the open-source repository, click here .`
),
},
{
diff --git a/apps/frontend/src/components/layout/impersonate.tsx b/apps/frontend/src/components/layout/impersonate.tsx
index 215a40e927..c47c71b17b 100644
--- a/apps/frontend/src/components/layout/impersonate.tsx
+++ b/apps/frontend/src/components/layout/impersonate.tsx
@@ -989,12 +989,12 @@ const SwitchUser = () => {
className="bg-primary/80 fixed start-0 top-0 w-full h-full z-[998]"
onClick={() => setName('')}
/>
-
+
{mapData.map((item: any) => (
{t('user_1', 'user:')}
{item?.id?.split('-')?.at(-1)} -{' '}
@@ -1125,12 +1125,12 @@ export const Impersonate = () => {
className="bg-primary/80 fixed start-0 top-0 w-full h-full z-[998]"
onClick={() => setName('')}
/>
-
+
{mapData?.map((user: any) => (
{t('user_1', 'user:')}
{user?.id?.split('-')?.at(-1)} - {user?.name} - {user?.email}{' '}
diff --git a/apps/frontend/src/components/layout/layout.context.tsx b/apps/frontend/src/components/layout/layout.context.tsx
index efc81c849d..478f92c5cb 100644
--- a/apps/frontend/src/components/layout/layout.context.tsx
+++ b/apps/frontend/src/components/layout/layout.context.tsx
@@ -5,6 +5,7 @@ import { FetchWrapperComponent } from '@gitroom/helpers/utils/custom.fetch';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { useReturnUrl } from '@gitroom/frontend/app/(app)/auth/return.url.component';
import { useVariables } from '@gitroom/react/helpers/variable.context';
+import { useT } from '@gitroom/react/translation/get.transation.service.client';
import {
shouldHandleGlobalLogout,
shouldPreserveOAuthConsentUnauthorized,
@@ -27,6 +28,7 @@ export function setCookie(cname: string, cvalue: string, exdays: number) {
}
function LayoutContextInner(params: { children: ReactNode }) {
const returnUrl = useReturnUrl();
+ const t = useT();
const { backendUrl, isGeneral, isSecured } = useVariables();
const afterRequest = useCallback(
async (url: string, options: RequestInit, response: Response) => {
@@ -120,9 +122,12 @@ function LayoutContextInner(params: { children: ReactNode }) {
if (response.status === 406) {
if (
await deleteDialog(
- 'You are currently on trial, in order to use the feature you must finish the trial',
- 'Finish the trial, charge me now',
- 'Trial'
+ t(
+ 'trial_feature_requirement',
+ 'You are currently on trial, in order to use the feature you must finish the trial'
+ ),
+ t('trial_finish_charge_now', 'Finish the trial, charge me now'),
+ t('trial', 'Trial')
)
) {
window.open('/billing?finishTrial=true', '_blank');
@@ -137,8 +142,8 @@ function LayoutContextInner(params: { children: ReactNode }) {
(
await response.json()
).message,
- 'Move to billing',
- 'Payment Required'
+ t('move_to_billing', 'Move to billing'),
+ t('payment_required', 'Payment Required')
)
) {
window.open('/billing', '_blank');
diff --git a/apps/frontend/src/components/layout/organization.selector.tsx b/apps/frontend/src/components/layout/organization.selector.tsx
index 2960172f82..ab2db5c7e7 100644
--- a/apps/frontend/src/components/layout/organization.selector.tsx
+++ b/apps/frontend/src/components/layout/organization.selector.tsx
@@ -102,7 +102,7 @@ export const OrganizationSelector: FC<{ asOpenSelect?: boolean }> = ({
{asOpenSelect && (
-
Select Organization
+
{t('label_select_organization', 'Select organization')}
)}
{!asOpenSelect && (
@@ -144,14 +144,14 @@ export const OrganizationSelector: FC<{ asOpenSelect?: boolean }> = ({
>
{org?.name}
{!!org?.users?.[0]?.role && (
-
+
{' '}
(
{org?.users?.[0]?.role === 'SUPERADMIN'
- ? 'Super-Admin'
+ ? t('role_superadmin', 'Super-Admin')
: org?.users?.[0]?.role === 'ADMIN'
- ? 'Admin'
- : 'User'}
+ ? t('role_admin', 'Admin')
+ : t('role_user', 'User')}
)
)}
diff --git a/apps/frontend/src/components/layout/settings.component.tsx b/apps/frontend/src/components/layout/settings.component.tsx
index 7e2584540b..2b9b782bbe 100644
--- a/apps/frontend/src/components/layout/settings.component.tsx
+++ b/apps/frontend/src/components/layout/settings.component.tsx
@@ -225,7 +225,7 @@ export const SettingsComponent = () => {
}
settings.openModal({
children: (
-
+
),
diff --git a/apps/frontend/src/components/layout/support.tsx b/apps/frontend/src/components/layout/support.tsx
index 00279f4cf9..cddce55c8b 100644
--- a/apps/frontend/src/components/layout/support.tsx
+++ b/apps/frontend/src/components/layout/support.tsx
@@ -24,7 +24,7 @@ export const Support = () => {
return (
window.open(discordUrl)}
>
diff --git a/apps/frontend/src/components/onboarding/onboarding.modal.tsx b/apps/frontend/src/components/onboarding/onboarding.modal.tsx
index 2f4bb8db06..7e95193f34 100644
--- a/apps/frontend/src/components/onboarding/onboarding.modal.tsx
+++ b/apps/frontend/src/components/onboarding/onboarding.modal.tsx
@@ -743,7 +743,7 @@ const OnboardingStep3: FC<{ onBack: () => void; onFinish: () => void }> = ({
{t('get_started', 'Get Started')}
+
+
+
+
+Audit — Crove Post · 2026-09-08
+
+
+
+
+
+
+ Báo cáo Audit — Crove Post
+
+ Ngày 2026-09-08
+ Nhánh dev · HEAD 3aae08cd
+ Phạm vi: bảo mật · dữ liệu · kiến trúc · hiệu năng · frontend · CI/CD
+ Phương pháp: static analysis, chỉ đọc
+
+
+
– Critical
+
– High
+
– Medium
+
– Low
+
304 CVE (7 crit)
+
+ Fork rebrand của Postiz (AGPL-3.0) — pnpm monorepo, 8 apps + 3 libraries,
+ 36 social provider, NestJS 11 / Next.js 16 / Temporal / Prisma 6.5 / Cloudflare Worker.
+ Mọi phát hiện đều đã xác minh bằng đọc source, kèm file:line.
+
+
+
+ Mục lục
+
+ 0. Trạng thái khắc phục — đã sửa 7 quick wins
+ 1. Mười vấn đề nghiêm trọng nhất
+ 2. Bảo mật
+ 3. Backend · Database · Kiến trúc
+ 4. Frontend · UX · A11y
+ 5. CI/CD · Hạ tầng · Dependencies
+ 6. Những gì đang làm tốt
+ 7. Lộ trình khắc phục
+ 8. Giới hạn của audit
+
+ Nhóm vấn đề
+
+ OAuth / token yếu
+ Mất mát dữ liệu
+ Accessibility
+ Hiệu năng frontend
+ Quality gate hỏng
+ Dead code & nợ
+
+
+
+
+
+
+ Tất cả
+ Critical
+ High
+ Medium
+ Low
+ Đã làm đúng
+
+
+
+
+0. Trạng thái khắc phục — cập nhật 08/09/2026
+
+
+Hoàn tất Quick wins đã sửa trong phiên làm việc này (8 files, −69/+24 dòng)
+
+Finding Trạng thái Thay đổi File
+C5 ✅ Đã sửa Comment out NODE_TLS_REJECT_UNAUTHORIZED="0" kèm cảnh báo "NEVER set to 0 in production" .env.example
+S4 ✅ Đã sửa Bỏ fallback webhook secret về JWT_SECRET — chỉ chấp nhận DOS_SYNC_WEBHOOK_SECRET, không có thì từ chối request dos-org-sync.controller.ts
+I-B2 ✅ Đã sửa Image gitroomhq/postiz-app:latest → dos/crove-post:latest; JWT literal tiếng Anh → ${JWT_SECRET:?…} fail-fast kèm lệnh generate docker-compose.yaml
+C2 (phần 1)✅ Đã sửa Thêm command: override vào prod compose (không còn db push --accept-data-loss lúc startup); đảo assertion validator : prod phải có override và override không được chứa prisma.*push scripts/docker-compose.prod.yaml , validate-beta-compose.mjs
+S15 ✅ Đã sửa — đánh giá lại Xoá hẳn crove-postgres khỏi prod + beta compose (dead weight — xem bảng bên dưới); Temporal Postgres giữ lại với ${CROVE_TEMPORAL_POSTGRES_PASSWORD:?required} scripts/docker-compose.prod.yaml , beta.yaml , validate-beta-compose.mjs
+S16 ✅ Đã sửa 6 port bind 0.0.0.0 → 127.0.0.1: (Postgres, Redis, pgAdmin, RedisInsight, Temporal, Temporal UI) docker-compose.dev.yaml
+I-E7 ✅ Đã sửa Thêm .cloudflared/ vào .gitignore .gitignore
+
+Validator: node scripts/validate-beta-compose.mjs → PASS sau mỗi batch thay đổi.
+
+
+
+Hoàn tất Dọn dẹp production VM (crove-server · project crove-os · asia-southeast1-b)
+Đã SSH qua gcloud compute ssh, kiểm chứng rồi xoá:
+
+Đã xoá Bằng chứng là dead weight
+Container crove-postgres (prod) 69 tables — chỉ schema Prisma do db push tạo ở giai đoạn đầu; 0 connection đang hoạt động; 12 MB (không data). App connect vào Supabase.
+Container crove-postgres-beta 67 tables, 0 connection , 12 MB. Bên trong còn có DB postiz-db-corrupt-20260901 — bằng chứng local postgres đã gây corruption thật ngày 01/09/2026.
+Volume crove_postgres-volume Chết (prod, service đã xoá khỏi compose)
+Volume crove_postgres-beta-volume Chết (beta, service đã xoá khỏi compose)
+Volume crove_crove-postgres-beta-volume Orphan do sai tên từ trước (khớp không compose nào)
+Container postiz (exited 8 ngày, OOM kill) Container pre-rebrand với image build cục bộ
+
+Giữ lại duy nhất: crove_temporal-postgres-data — Temporal dùng thật, password giờ qua CROVE_TEMPORAL_POSTGRES_PASSWORD.
+S15 đánh giá lại: DB chính của Crove Post là Supabase (credentials trong GCP Secret Manager) — crove-postgres trong compose là dead weight và còn là điểm chết đơn : depends_on: condition: service_healthy khiến app không boot được nếu container chết dù Supabase vẫn ổn. Xoá nó vừa dọn dẹp vừa loại một điểm lỗi vận hành. Các product khác trên cùng VM (CRM, Cal.com, Documenso, AgentDesk) đều dùng DB ngoài — không có dead postgres nào khác.
+
+
+
+U1 Medium 9 upstream fix branch khớp audit — đang chờ upstream merge vào main
+
+Bối cảnh upstream/main tip = 36d5fc7b — không có commit mới từ lần sync cuối. Nhưng upstream có các fix branch riêng (chưa vào main) khớp chính xác với finding. Khi upstream merge → sync-upstream.yml tự tạo PR vào dev. Không cherry-pick tay để tránh conflict sau này.
+
+
+Upstream branch Khớp finding Nội dung fix (đã review diff)
+fix/refresh-transient-errors C6 Chỉ disconnect khi handleErrors() trả refresh-token; network/5xx → return false + log. 3 files, 31 dòng.
+fix/block-posts-on-deleted-channel D13 Check integration.deletedAt trong mapTypeToPost/validatePosts + MCP tool. 2 files, 3 dòng.
+fix/block-posts-on-disabled-channel D13 Check integration.disabled + autopost filter + MCP tools. 4 files, 27 dòng.
+fix/tag-deletion-cleanup B12 Filter deletedAt: null trên tag ở 3 select + xoá join TagsPosts khi delete. 1 file, 31 dòng.
+fix/posts-group-empty-guard D12 Guard posts[0]?.integrationId/settings + frontend empty-guard + i18n 17 locale.
+fix/are-you-sure-emitter-cleanup F-C10 Cleanup listener đúng cách (off('open', handler)). Chọn branch này thay vì …-dialog-close (removeAllListeners thô) hay …-duplicate-dialogs (viết lại kiến trúc, rủi ro cao).
+fix/ssrf-literal-ip-bypass SSRF Bịt bypass literal IP trong ssrfSafeDispatcher: net.connect bỏ qua lookup hook cho literal IP → thêm check trong custom connect.
+fix/local-uploads-range C8 (một phần)Range request support cho uploads route + TikTok chunk validation (từ chối nếu server không trả 206). Không fix path traversal gốc.
+fix/temporal-activity-only — ✅ Đã vào dev từ trước.
+
+Branch fix/are-you-sure-dialog-close và fix/are-you-sure-duplicate-dialogs cùng sửa F-C10 — không cần merge cả ba, branch emitter-cleanup là đủ.
+
+
+
+U2 Medium Số dư công việc còn lại
+
+Nhóm Số lượng Hành động
+Crove gây ra — đã sửa 7 (C5, S4, S15, S16, I-B2, I-E7, C2-phần-1) ✅ Hoàn tất phiên này
+Crove gây ra — còn lại ~21 Phải tự sửa: C7 (SSO secret), C9 (quality gate), I-B1 (Dockerfile prod), I-B4 (:latest), I-B5 (Temporal dev config), I-B6 (cloudflared mount), I-C1→C6 (CI/scripts), I-D3→D5 (deps/private/license/sdk), I-E3 (docs drift), I-E4 (dead targets), I-E6 (nginx headers), S18 (state-store), S19 (auth header), S20 (passwordless), F-D1 (fork-local tokens), F-D9 (hardcode English)
+Upstream — chờ merge 8 branch → ~9 finding Chờ sync-upstream.yml, không làm gì
+Upstream — chưa có fix ~41 Tự fix hoặc gửi PR lên upstream: C1, C3, C8 (phần chính), C10, S1→S3, S5→S13, S17, S19, B4→B14, D3→D15, F-C1→C12, F-A*, F-B*, F-D*, E1→E3, I-D6, I-E2, I-E5
+
+Docs drift phát hiện thêm khi kiểm VM: docs/cicd.md:47 ghi IP prod 34.87.89.118 và nginx-crove.conf:4 ghi 34.87.108.138 — nhưng VM thật là crove-server trong project crove-os (IP 34.21.213.197). Cả hai IP trong docs đều stale. Bổ sung vào danh sách mismatch của I-E3.
+
+
+
+
+1. Mười vấn đề nghiêm trọng nhất
+Xếp theo mức độ cần xử lý ngay. Mỗi mục có đủ bằng chứng file:line để mở thẳng vào code.
+
+
+C1 Critical Toàn bộ OAuth token / code / secret sinh bằng Math.random()
+
+File libraries/nestjs-libraries/src/services/make.is.ts:1-10
+Vấn đề export const makeId = (length: number) => {
+ let text = '';
+ const possible = 'ABC...xyz0123456789';
+ for (let i = 0; i < length; i += 1) {
+ text += possible.charAt(Math.floor(Math.random() * possible.length));
+ }
+ return text;
+};
+Hàm này sinh ra mọi bí mật của tầng OAuth/MCP :
+
+
+Giá trị Vị trí Độ dài
+Access token pos_ (bearer, truy cập dữ liệu org) oauth.service.ts:347 makeId(40)
+Authorization code oauth.service.ts:259 makeId(32)
+Client secret pcs_ oauth.service.ts:55, 95, 184 makeId(48)
+Client ID pca_ / pcd_ oauth.service.ts:54, 183 makeId(32)
+OAuth state chống CSRF khi login social auth.controller.ts:222 makeId(16)
+Organization ID (fallback) provision.controller.ts:156 makeId(10)
+jti của first-party login ticketprovision.controller.ts:162 makeId(32)
+
+
+Khai thác Phân loại: Dùng Math.random() (PRNG không có bảo đảm mật mã) cho bearer token là sai về nguyên tắc, không cần exploit để chứng minh. Math.random() trong V8 là xorshift128+ — về lý thuyết trạng thái 128-bit có thể bị khôi phục nếu kẻ tấn công quan sát được đủ output, nhưng makeId chỉ lấy Math.floor(Math.random() * 62) (~6 bit mỗi lần gọi), nên việc khôi phục trạng thái từ các output đã cắt cụt là không tầm thường và chưa có PoC công khai cho đúng kịch bản này. Tuy nhiên, kẻ tấn công tự đăng ký một OAuth app là nhận ngay pcs_ + 48 output liên tiếp trong response (oauth.service.ts:66 trả clientSecret về client). Trong mọi trường hợp, dùng PRNG không mật mã cho access token, authorization code và client secret là không thể chấp nhận được — đây là lập luận chính, không phụ thuộc vào việc có dựng được PoC xorshift128+ hay không.
+Fix Thay makeId bằng crypto.randomBytes(length).toString('base64url'). Codebase đã làm đúng ở crove-sso/src/oauth.ts:randomToken() , kick.provider.ts:28 , whop.provider.ts:107 , vk.provider.ts:79 , bootstrap.service.ts — chỉ makeId là lỗ hổng kế thừa. Một file, ~10 dòng. Kèm rotate mọi pos_/pcs_/code đang sống.
+
+
+
+
+C2 Critical Phần 1 đã sửa 08/09 Production chạy prisma db push --accept-data-loss mỗi lần container start, và không có backup nào
+
+File scripts/docker-compose.prod.yaml:8-16 · scripts/validate-beta-compose.mjs:301-304 · package.json:18,38 · Dockerfile.dev:28
+Vấn đề Prod service không có command: override → dùng CMD mặc định của image nginx && pnpm run pm2 → pm2-run → (pnpm run prisma-db-push || true) → db push --accept-data-loss.
+Beta có override chặn đúng việc này (docker-compose.beta.yaml:12-14 ) kèm comment giải thích — nhưng validator lại assert rằng prod KHÔNG được có override :
+record(command === null,
+ `Production crove-post must not define app startup command override: ${command}`);
+glob **/migrations/** → 0 file . Grep pg_dump|backup|restore|WAL|disaster recovery trên toàn bộ .md/.yaml/.ps1/.sh/.toml → không có gì .
+Hậu quả db push reconcile bằng cách drop cột/bảng không còn trong schema. Một thay đổi schema.prisma đến từ sync-upstream.yml (chạy hằng ngày) sẽ bị áp dụng phá hủy lên DB production ở lần restart kế tiếp — mà restart: always đảm bảo sẽ xảy ra. || true nuốt luôn lỗi thất bại một phần. Mastra dùng chung DB (chat/mastra.store.ts:5 ) nên các bảng mastra_* động bị xoá mỗi lần app start → mất chat memory / agent state. Không backup = mất mát không thể khôi phục cho toàn bộ post, credential và OAuth token của người dùng.
+Fix (1) Copy override của beta sang prod + đảo assertion validate-beta-compose.mjs:309 ; (2) baseline prisma migrate diff --from-empty --to-schema-datamodel → migrations/0_init, chuyển deploy sang migrate deploy như một bước riêng có review; (3) dựng pg_dump cron → R2 + runbook restore và diễn tập restore ; (4) xoá prisma-reset (package.json:40 , chứa --force-reset).
+
+
+
+
+C3 High POST /integrations/function gọi method tuỳ ý trên provider, không allowlist
+
+File apps/backend/src/api/routes/integrations.controller.ts:334-397
+Vấn đề // @ts-ignore
+if (integrationProvider[body.name]) {
+ // @ts-ignore
+ const load = await integrationProvider[body.name](
+ getIntegration.token, body.data, getIntegration.internalId, getIntegration);
+IntegrationFunctionDto.name chỉ là @IsString() @IsDefined(); data: any không có decorator nào ; route không có @CheckPolicies . Khi gặp RefreshToken thì đệ quy không giới hạn độ sâu (:390).
+Bản public API làm đúng — public-api/routes/v1/public.integrations.controller.ts:560-590 kiểm tra allowlist getAllTools(). Chỉ endpoint nội bộ bỏ trống.
+Khai thác User đã đăng nhập gọi phản chiếu được mọi method thừa kế từ SocialAbstract (fetch, mediaChunk, mediaStream, checkScopes) và OAuth internals (generateAuthUrl, authenticate, refreshToken) với data hoàn toàn do attacker điều khiển. Đệ quy không chặn + timer(10000) mỗi tầng = resource exhaustion.
+Fix Tách allowlist check thành IntegrationManager.assertToolAllowed(identifier, methodName), gọi từ cả hai controller (bản public đã có implementation đúng để copy); thêm bộ đếm số lần retry refresh.
+
+
+
+
+C4 Critical pnpm audit: 304 lỗ hổng (7 critical, 112 high); 2 package HIGH bị chính semver range chặn
+
+File package.json (root-only manifest) · audit chạy 2026-09-08 trên pnpm-lock.yaml
+Vấn đề pnpm audit --prod : 285 → 27 low | 152 moderate | 101 high | 5 critical
+pnpm audit (full) : 304 → 28 low | 157 moderate | 112 high | 7 critical
+5 critical prod-reachable: happy-dom (VM escape → RCE, qua @pigment-css/react), protobufjs, form-data, shell-quote, tar (qua bcrypt>@mapbox/node-pre-gyp). 2 critical còn lại là dev-only : handlebars (.>ts-jest) và vitest <3.2.6 (UI server cho phép đọc + thực thi file tuỳ ý; đang pin 3.1.4, còn @vitest/ui ở 1.6.0 — lệch 2 major).
+HIGH đáng chú ý hono GHSA-88fw-hqm2-52qc — CORS reflect mọi Origin kèm credentials — qua .>@modelcontextprotocol/sdk>hono, mà startMcp(app) được gọi ở main.ts:52 . Lưu ý: advisory áp dụng cho version được resolve, nhưng chưa xác minh được startMcp có thực sự gọi cors() với config mặc định dính lỗi hay không — cần đọc chat/start.mcp.ts để kết luận. fast-uri có 3 advisory trên cùng path .>@mastra/core>ajv>fast-uri (SSRF IPv6 + 2 dạng host confusion) — nằm ngay sau lớp getSsrfSafeDispatcher(), tức làm suy yếu control SSRF hiện có . dompurify có ~10 advisory nhưng phần lớn không áp dụng cho config thực tế (xem mục 2 ).
+Bị range chặn sharp: ^0.33.4 — GHSA-f88m-g3jw-g9cj (4 CVE libvips), fix ở >=0.35.0; caret trên 0.x khoá minor nên pnpm update không bao giờ với tới. Nằm trên đường xử lý ảnh user upload.
+nodemailer: ^7.0.11 — GHSA-p6gq-j5cr-w38f: option raw bỏ qua disableFileAccess/disableUrlAccess → đọc file tuỳ ý + SSRF trong email được gửi ; fix ở >=9.0.1, cách 2 major. Lưu ý: chưa xác minh được app có thực sự dùng raw option trong email adapter hay không — advisory áp dụng cho version, cần đọc libraries/nestjs-libraries/src/emails/ để xác định exploitability thực tế. Container đang giữ crove-server.env chứa toàn bộ social token + Stripe key.
+Fix Thêm pnpm.overrides: tar ^7.5.19, form-data ^2.5.4, protobufjs ^7.5.5, shell-quote ^1.8.4, fast-uri ^3.1.6, hono ^4.12.34, immutable ^5.1.8, handlebars ^4.7.9. Xoá @pigment-css/react (0 import) → hết critical happy-dom trong một dòng. Bump sharp → ^0.35.0, nodemailer → ^9.0.1 (kèm bỏ @types/nodemailer), vitest → ^3.2.6 + đồng bộ @vitest/ui. Dời build tooling (sass, ts-jest, @nestjs/cli, tsup, @swc/cli) sang devDependencies để surface prod thật. Thêm gate NODE_OPTIONS=--max-old-space-size=8192 pnpm audit --prod --audit-level=high vào build.yml.
+
+
+
+
+C5 High Đã sửa 08/09 .env.example ship NODE_TLS_REJECT_UNAUTHORIZED="0" làm mặc định
+
+File .env.example:14 (comment ở :13)
+Vấn đề Đây không phải placeholder — mọi giá trị khác trong file đều copy-paste được, và header file tự gọi mình là "COMPLETE CONFIGURATION REFERENCE".
+Khai thác Ai làm theo bước onboarding cp .env.example .env sẽ tắt verify chứng chỉ cho toàn tiến trình (backend, orchestrator, frontend, commands). Mọi call HTTPS ra ngoài — Stripe, R2, 28 endpoint OAuth/token của các mạng xã hội, SMTP, OpenAI — đều MITM được, và payload trên các kết nối đó chính là access token + refresh token . ConfigurationChecker không hề nhìn biến này.
+Fix Xoá dòng 14, thay bằng comment cảnh báo; thêm từ chối khởi động cứng trong ConfigurationChecker khi giá trị là 0 và NODE_ENV=production; rotate mọi credential đã đi qua install từng dùng giá trị này.
+
+
+
+
+C6 Critical Lỗi tạm thời khi refresh token → ngắt kết nối kênh vĩnh viễn
+
+File libraries/nestjs-libraries/src/integrations/refresh.integration.service.ts:74-95
+Vấn đề const refresh = await socialProvider
+ .refreshToken(integration.refreshToken)
+ .catch((err) => false); // ← err bị vứt, không log
+
+if (!refresh || !refresh.accessToken) {
+ await this._integrationService.refreshNeeded(...);
+ await this._integrationService.informAboutRefreshError(...); // gửi email cho khách
+ await this._integrationService.disconnectChannel(...); // ngắt kênh
+DNS blip, provider 5xx, socket timeout hay rate-limit đều không phân biệt được với refresh token đã bị thu hồi thật.
+Hậu quả Đánh dấu kênh lỗi, gửi email báo khách, ngắt kênh — bắt buộc reconnect OAuth thủ công. Nghiêm trọng hơn vì không có idempotency/lock (refresh() tại :19-45 không đọc inBetweenSteps, không lock Redis, không optimistic version), trong khi được gọi đồng thời từ 6 nơi: activity refreshToken (Temporal retry), refreshTokenWorkflow, getMissingContent, checkPostAnalytics, và 2 controller. Provider rotate refresh token (Facebook, LinkedIn, TikTok) vô hiệu token cũ khi dùng → hai lần refresh song song → lần hai nhận invalid_grant → ngắt một kênh hoàn toàn khoẻ mạnh . Đây là cơ chế khả dĩ nhất đằng sau các báo cáo "kênh tự nhiên bị ngắt".
+Fix Phân loại lỗi trước khi ngắt — chỉ invalid_grant/401 mới là terminal, còn network/5xx thì rethrow cho Temporal retry; chỉ disconnectChannel sau N lần thất bại liên tiếp; log err vào Sentry; lấy lock Redis SET refresh:<integrationId> NX EX 60 ở đầu refresh(); set inBetweenSteps trước khi gọi provider.
+
+
+
+
+C7 High Secret OAuth bridge plaintext trong git + account ID Cloudflare lệch nhau
+
+File apps/crove-sso/wrangler.jsonc:39, :73, :4 · .github/workflows/deploy-sso.yml:63, :74
+Vấn đề "DOWNSTREAM_CLIENT_SECRET": "bridge-secret-value" nằm trong khối vars: (plaintext) , không phải Worker secret — cho cả prod lẫn beta.
+wrangler.jsonc:4 khai account_id: "5f2a5892...", nhưng workflow hardcode fallback khác : accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || '3368ff98...' }}. docs/cicd.md:44 ghi secret này là Optional → khi không set, env var ghi đè account_id trong jsonc.
+Hậu quả (a) Secret mà SSO bridge xuất trình với post.crove.com là chuỗi đoán được trong git. deploy-sso.ps1:60-77 có thể ghi đè qua wrangler secret put, nhưng không gì đảm bảo nó từng chạy. (b) Worker deploy vào tenant Cloudflare khác với tenant sở hữu zone sso.crove.com → binding custom_domain fail hoặc âm thầm落在 sai chỗ.
+Fix Bỏ cả hai key khỏi vars: (wrangler tự đọc từ secret store), rotate giá trị; đổi accountId: thành ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} không fallback để thiếu secret là fail job.
+
+
+
+
+C8 Critical Path traversal → đọc file tuỳ ý qua POST /media/save-media
+
+File apps/backend/src/api/routes/media.controller.ts:105-121 · posts.service.ts:363-372 · social.abstract.ts:302-320
+Vấn đề Kiểm tra duy nhất trên name là truthy:
+if (!name) { return false; }
+return this._mediaService.saveFile(org.id, name,
+ process.env.CLOUDFLARE_BUCKET_URL + '/' + name, originalName || undefined);
+updateMedia nối thô: path: m.path.indexOf('http') === -1 ? process.env.UPLOAD_DIRECTORY + m.path : m.path, rồi SocialAbstract.mediaStream: if (path.indexOf('http') !== 0) return createReadStream(path);
+Khai thác Với cấu hình mặc định được document (docker-compose.yaml:22-24 → STORAGE_PROVIDER: 'local', không set CLOUDFLARE_BUCKET_URL): name = '../../etc/passwd' → /uploads/undefined/../../etc/passwd → file bị đọc và upload lên kênh social của attacker . Arbitrary local file read + exfiltration bởi bất kỳ user đã đăng nhập nào.
+Fix Validate name theo /^[A-Za-z0-9._-]+$/; trong updateMedia resolve qua path.resolve + check startsWith(UPLOAD_DIRECTORY) trước khi đưa cho createReadStream.
+
+
+
+
+C9 Critical Không có test gate, không có lint gate, không có typecheck gate
+
+File jest.config.ts:1 · .github/workflows/eslint.yml:55-62 · package.json · reports/junit.xml:2
+Test jest.config.ts:1 là export default { projects: getJestProjects() } từ @nx/jest — nhưng @nx/jest không nằm trong package.json , và không có nx.json/project.json nào. pnpm test không chạy được test nào. Tổng cộng 8 file test ; 5 file jest (tests/bootstrap*.spec.ts, config thật ở tests/bootstrap.jest.cjs ) không workflow nào chạy . Chỉ deploy-sso.yml:44-46 chạy 3 file vitest của crove-sso.
+Lint eslint.yml:55-61 chạy npx eslint apps/$service/ --config apps/$service/.eslintrc.json ... || true cho backend và frontend. Cả hai file .eslintrc.json không tồn tại. Lỗi bị nuốt hai lần (|| true + continue-on-error: true ở :62) → check luôn xanh, SARIF rỗng.
+Typecheck Không có script typecheck nào ở root package.json (chỉ apps/crove-sso/package.json:8 có). Không workflow nào chạy tsc --noEmit. prettier cũng không chạy trong CI. Cộng thêm tsconfig.base.json:22-25 bật strict: true rồi tắt strictNullChecks, strictPropertyInitialization, strictBindCallApply.
+Giả reports/junit.xml được commit và báo tests="11" failures="0" cho suite PermissionsService đã bị xoá từ 2025-03-24 — artifact test duy nhất trong repo đang báo xanh cho test không còn tồn tại.
+Fix Xoá --config (để ESLint tự tìm) + bỏ cả || true và continue-on-error; thêm "typecheck": "tsc -b apps/*/tsconfig.json --noEmit" và job test chạy tests/bootstrap.jest.cjs vào build.yml, cho build-containers.yml needs: nó; git rm --cached reports/junit.xml + thêm reports/ vào .gitignore.
+
+
+
+
+C10 Critical Workflow Temporal đã phát hành bị sửa in-place → NonDeterministicError
+
+File apps/orchestrator/src/workflows/post-workflows/post.workflow.v1.0.1.ts , v1.0.4.ts , v1.0.6.ts
+Vấn đề CLAUDE.md cấm tuyệt đối việc này. git log cho thấy nó đã xảy ra:
+
+v1.0.1.ts — 6 lần sửa sau khi tạo; 07238a06 chèn một activity call vào giữa chuỗi lệnh đang tồn tại và đổi while (true) → for (const _ of iterate).
+v1.0.4.ts — 90539251 thay return; bằng await changeState(postId,'ERROR','No Post'); return; → chèn activity command vào chỗ history không ghi gì.
+v1.0.6.ts — 9944a5fc (31/7/2026, trên cả origin/main và upstream/main) nâng maxPendingChecks 45 → 90, đổi bound vòng lặp đang gate checkPostStatus + sleep.
+
+Hậu quả Execution đang bay tại thời điểm deploy sẽ replay history, thấy chuỗi command lệch → fail NonDeterministicError → workflow task retry vô hạn, execution kẹt vĩnh viễn.
+Nghiêm trọng hơn: 6 file workflow không đánh số version (refresh.token, autopost, digest.email, send.email, missing.post, generate.video) chịu ràng buộc bất biến y hệt nhưng không có kỷ luật version nào , và execution của chúng không bao giờ drain — refresh.token.workflow.ts:20-53 là while(true) sleep tới tokenExpiration, mà integration.repository.ts:317 mặc định expiresIn = 999999999 ≈ năm 2033 . digest.email.workflow.ts đã bị sửa in-place 6 lần, send.email.workflow.ts 4 lần.
+Điểm sáng streak.workflow.ts:20,36 dùng patched('reminder') đúng chuẩn — dự án đã sở hữu kỹ thuật đúng, chỉ là không áp dụng nhất quán.
+Fix Mọi thay đổi hành vi từ nay → tạo post.workflow.v1.1.3.ts + repoint posts.service.ts:730 và post.activity.ts:120 ; version hoá 6 file còn lại; thêm CI check fail khi diff chạm bất kỳ file nào dưới apps/orchestrator/src/workflows/ đã có trên origin/main mà không thêm patched().
+
+
+
+
+2. Bảo mật
+Ngoài C1, C3, C5, C7, C8 ở trên. Phần cuối mục này liệt kê các control đã được cài đặt đúng — quan trọng để không đầu tư nhầm chỗ.
+
+
+S1 High JWT phiên không có hạn hết, không có revocation
+
+File libraries/helpers/src/auth/auth.service.ts:42-47
+Vấn đề signJWT(value) gọi sign(value, process.env.JWT_SECRET!) — không expiresIn , không pin algorithms khi verify. Đổi mật khẩu không vô hiệu token cũ; không có denylist.
+Hậu quả Cookie auth bị đánh cắp có giá trị vĩnh viễn . Giảm nhẹ một phần: AuthMiddleware re-resolve user từ DB nên user bị deactivate vẫn bị chặn — nhưng reset mật khẩu thì không.
+Fix Thêm expiresIn (vd 7 ngày) + refresh rotation; pin algorithms: ['HS256'] ở verify; thêm tokenVersion vào model User để reset mật khẩu vô hiệu toàn bộ phiên.
+
+
+
+
+S2 High OAuth token của 36 kênh social lưu plaintext , trong khi secret kém quan trọng hơn lại được mã hoá
+
+File schema.prisma:284-286 (token String, refreshToken String?) vs auth.service.ts:49-55
+Vấn đề Organization.apiKey, ThirdParty.apiKey, OAuthApp.clientSecret, OAuthAuthorization.accessToken/authorizationCode, Integration.customInstanceDetails đều qua AuthService.fixedEncryption. Riêng Integration.token và refreshToken — credential thật của mọi tài khoản social đã kết nối — thì không. integration.repository.ts:695-700 còn md5-hash chúng khi xoá tài khoản, chứng tỏ chính tác giả coi chúng là nhạy cảm.
+Hậu quả Một DB dump, backup bị lộ, hoặc bất kỳ primitive đọc SQL nào → lộ toàn bộ token Facebook/Instagram/LinkedIn/X/TikTok của khách mà không có rào cản mật mã nào . Kèm theo: getIntegrationsList (integration.repository.ts:544-556 ) findMany không có select → token bị vật chất hoá vào bộ nhớ mỗi lần liệt kê kênh.
+Fix Mã hoá token/refreshToken bằng chính fixedEncryption tại ranh giới repository. Nhưng phải làm S3 trước — xem dưới.
+
+
+
+
+S3 High Mã hoá dùng IV cố định dẫn xuất từ JWT_SECRET, và xác thực bằng cách so sánh ciphertext
+
+File auth.service.ts:9-20 · oauth.service.ts:299
+Vấn đề EVP_BytesToKey(pass, null, keyLength*8, ivLength, 'md5') — KDF yếu, IV cố định dẫn xuất từ JWT_SECRET. Cùng plaintext → cùng ciphertext, nên oauth.service.ts:299 xác thực client secret bằng so sánh ciphertext : app.clientSecret !== AuthService.fixedEncryption(clientSecret).
+Hậu quả Quay JWT_SECRET là brick mọi secret đã lưu. Và chuyển sang IV ngẫu nhiên (đúng chuẩn) sẽ phá vỡ mọi phép so sánh đó — đây là quả mìn phải tháo trước khi đụng vào S2.
+Fix Chuyển hết các chỗ so sánh sang decrypt-then-compare , rồi mới đổi sang IV ngẫu nhiên + KDF mạnh (scrypt/HKDF), kèm rotation key có version prefix.
+
+
+
+
+S4 High Đã sửa 08/09 Webhook dos-org-sync: fallback secret về JWT_SECRET, tạo được org SUPERADMIN, không chống replay
+
+File apps/backend/src/api/routes/dos-org-sync.controller.ts:36-39, 96-101, 146, 174
+Vấn đề const secret =
+ process.env.DOS_SYNC_WEBHOOK_SECRET ||
+ process.env.DOS_WEBHOOK_SECRET ||
+ process.env.JWT_SECRET;
+Handler tạo được org với role 'SUPERADMIN' và user với password: '' (:96-101). Không có timestamp/nonce → không chống replay.
+Hậu quả Trộn hai mục đích secret vào một giá trị. Nếu JWT_SECRET là chuỗi yếu mặc định (docker-compose.yaml:12 là một câu tiếng Anh literal — vẫn pass được checkNonEmpty), webhook trở nên forge được → leo thang đặc quyền. Replay một event member_added để tự thêm mình vào org bất kỳ.
+Fix Bỏ fallback về JWT_SECRET — fail cứng nếu không có secret chuyên dụng; thêm timestamp + nonce window; review lại việc tạo user với mật khẩu rỗng.
+
+
+
+
+S5 High XSS: 3 chỗ render HTML không sanitize
+
+File components/notifications/notification.component.tsx:42 · components/agents/agent.chat.tsx:173 · new-launch/providers/reddit/reddit.provider.tsx:40, 142
+Vấn đề notification.component.tsx render replaceLinks(notification.content) qua dangerouslySetInnerHTML — replaceLinks chỉ linkify URL, không sanitize . Nội dung notification do backend ghi và có thể nhúng dữ liệu user điều khiển (tên kênh, tiêu đề post).
+agent.chat.tsx:173 render output của LLM thành HTML, mà agent có @langchain/tavily → fetch nội dung web: đây là đường prompt-injection → XSS .
+reddit.provider.tsx render HTML không sanitize (2 chỗ).
+Giảm nhẹ sẵn có CreatePostDto có @Transform(({value}) => sanitizePostContent(value)) (create.post.dto.ts:35 ) và trang preview công khai có sanitize ((preview)/p/[id]/page.tsx:157 ) — nên phần lớn 18 chỗ dangerouslySetInnerHTML còn lại (các preview provider) là self-XSS trên nội dung đã qua DTO.
+Fix Áp sanitizePostContent cho cả 3 chỗ trên; riêng agent.chat.tsx nên render LLM output qua Markdown renderer có escape thay vì dangerouslySetInnerHTML.
+
+
+
+
+S6 High POST /media/video/function: không org scoping, không trừ credit, không filter available
+
+File apps/backend/src/api/routes/media.controller.ts:181-187 · media.service.ts:222-240 · videos/video.manager.ts:41-54
+Vấn đề Handler không nhận @GetOrgFromRequest() và gọi thẳng videoFunction(body.identifier, body.functionName, body.params). Đối chiếu generateVideo (media.service.ts:120-137 ) thì có đủ validateVideoRequest → checkCredits + useCredit + trial gate. getVideoByName cũng bỏ qua .filter((f) => f.available) mà getAllVideos có.
+Hậu quả Bất kỳ user đã đăng nhập nào cũng gọi được function của provider video trả phí (fal.ai…) mà không trừ credit, không giới hạn trial, không quy org , kể cả provider đã đánh dấu unavailable. Rò rỉ chi phí trực tiếp.
+Fix Thêm @GetOrgFromRequest() org, áp filter available trong getVideoByName, và đi qua cơ chế kế toán credit như generateVideo.
+
+
+
+
+S7 High ValidationPipe toàn cục không có whitelist; route traffic cao nhất nhận @Body() rawBody: any
+
+File apps/backend/src/main.ts:57-61 · posts.controller.ts:161-215 · no.auth.integrations.controller.ts:339
+Vấn đề new ValidationPipe({ transform: true }) — không whitelist, không forbidNonWhitelisted, không enableImplicitConversion. POST /posts và POST /posts/valid khai @Body() rawBody: any → không có metatype nên pipe bỏ qua hoàn toàn . validatePosts đọc post.value/post.settings/post.integration.id từ input chưa validate, rồi mapTypeToPost mới chạy ValidationPipe lần hai.
+Hậu quả Property không khai báo không bao giờ bị strip khỏi bất kỳ DTO body nào → mass-assignment trên mọi handler @Body() SomeDto. Đáng chú ý: POST /integrations/public/provider/:id/connect là route không xác thực cũng nhận @Body() body: any.
+Fix new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }); khai @Body() body: CreatePostDto cho POST /posts và dời việc inject __type vào @Transform trên DTO.
+
+
+
+
+S10 High Swagger UI expose không xác thực ở mọi môi trường
+
+File libraries/helpers/src/swagger/load.swagger.ts:5-16 , gọi vô điều kiện ở main.ts:73
+Vấn đề SwaggerModule.setup('docs', app, document) — không gate NODE_ENV, không auth guard, không IP allowlist. nginx proxy /api/ → localhost:3000/ (var/docker/nginx.conf:35-36 ).
+Hậu quả /api/docs công khai toàn bộ route inventory cho caller ẩn danh, gồm cả endpoint nội bộ và first-party provisioning mà .env.example:200-215 ghi là chứa secret. Bản đồ do thám miễn phí, làm giảm đáng kể chi phí tấn công C3/C8/S15.
+Fix Gate theo NODE_ENV !== 'production', hoặc mount sau basic auth / allowlist; tối thiểu strip /docs ở nginx.
+
+
+
+
+S11 High ConfigurationChecker chỉ cảnh báo, nông, và chạy sau khi server đã listen
+
+File libraries/helpers/src/configuration/configuration.checker.ts:28-35 · main.ts:78-84
+Vấn đề Toàn bộ check: Redis URL parse được và bắt đầu redis://, DATABASE_URL là URL hợp lệ, JWT_SECRET khác rỗng , 4 URL parse được và không có slash cuối, STORAGE_PROVIDER khác rỗng. Kết quả chỉ Logger.warn; checkConfiguration() gọi ở dòng 81 — sau await app.listen(port) ở dòng 78.
+Bỏ sót Độ mạnh / giá trị mặc định đã biết của JWT_SECRET; NODE_TLS_REJECT_UNAUTHORIZED=0 (C5); NOT_SECURED bật ở prod (bỏ cờ Secure của cookie và thêm auth/showorg/impersonate vào exposedHeaders); DISABLE_SSRF_PROTECTION; MCP_ONLY; IN_APP_PURCHASE_REJECT_SANDBOX=false. Không hề verify DB/Redis reachability . readEnvFromFile() (:11) resolve ../../../.env → từ layout dist/ trỏ vào libraries/.env, sai chỗ.
+Fix Chạy trước app.listen và process.exit(1) khi có issue ở production; thêm denylist giá trị JWT_SECRET mặc định + yêu cầu ≥32 ký tự; từ chối các cờ nguy hiểm kể trên; sửa hoặc xoá readEnvFromFile.
+
+
+
+
+S15 High Đã sửa 08/09 — đánh giá lại Mật khẩu database prod/beta commit inline; IP production thật nằm trong docs
+
+File scripts/docker-compose.prod.yaml:59-62, 142-144 · scripts/docker-compose.beta.yaml:41-44 · docs/cicd.md:47 · scripts/nginx-crove.conf:4
+Vấn đề POSTGRES_PASSWORD: postiz-password / POSTGRES_USER: postiz-user cho Postgres production , và POSTGRES_PASSWORD=temporal cho Temporal Postgres. App container dùng env_file: crove-server.env (đã gitignore) — nhưng DB mà nó trỏ tới lại có mật khẩu trong git. docs/cicd.md:47 công bố IP 34.87.89.118, nginx-crove.conf:4 công bố 34.87.108.138 (hai IP mâu thuẫn → ít nhất một cái stale).
+Giảm nhẹ crove-postgres không publish port nào, nên khả năng tiếp cận phụ thuộc firewall GCP và Cloudflare Tunnel. Nhưng attacker đã biết chính xác địa chỉ để thử.
+Fix Đã thực hiện 08/09: sau khi SSH vào VM kiểm chứng (pg_stat_activity = 0 connection , 69 tables chỉ là schema do db push tạo lúc đầu), xác nhận app connect vào Supabase nên crove-postgres là dead weight — xoá hẳn service khỏi cả prod và beta compose, cùng depends_on gate (từ đó loại luôn điểm chết đơn mà condition: service_healthy tạo ra) và volume tương ứng. Temporal Postgres (dùng thật) giữ lại, password chuyển sang ${CROVE_TEMPORAL_POSTGRES_PASSWORD:?required}. Validator cập nhật assertion tương ứng — PASS. Còn lại: rotate password Temporal (biến mới cần set ở VM), xoá 2 IP stale khỏi docs/cicd.md và nginx-crove.conf.
+
+
+
+
+S16 High Đã sửa 08/09 Dev compose publish Postgres / Redis / Temporal / Temporal UI không xác thực ra 0.0.0.0
+
+File docker-compose.dev.yaml:17-18, 26-27, 33-37, 47-48, 88-89, 126-127 · docker-compose.yaml:196-197, 246-247
+Vấn đề Tất cả dùng dạng ngắn - 5432:5432 → bind 0.0.0.0. pgAdmin đặt credential admin@admin.com/admin; Elasticsearch chạy xpack.security.enabled=false; Temporal UI ở port 8080 không có xác thực nào .
+Hậu quả Trên laptop nối mạng công ty / Wi-Fi quán cà phê, hoặc đặc biệt nguy hiểm trên cloud VM có ai đó chạy dev stack: lộ DB đầy đủ với mật khẩu đã biết, Redis (giữ session + rate-limit state), và Temporal UI cho phép đọc mọi workflow history (nội dung post, token provider truyền làm activity input) cùng khả năng terminate/signal workflow đang chạy. pnpm run dev:docker gọi -f docker-compose.dev.yaml nên không load docker-compose.override.yaml — file đã bind hết về loopback.
+Fix Prefix 127.0.0.1: cho mọi port publish trong cả hai file; bỏ hẳn mapping Temporal UI và pgAdmin/RedisInsight, yêu cầu docker compose exec hoặc SSH tunnel; đặt credential pgAdmin sau ${…:?}.
+
+
+
+
+S8 Medium Dynamic Client Registration mở mặc định
+
+File oauth.service.ts:101-166
+Vấn đề DCR_VERIFIED_DOMAINS rỗng = open registration (comment ghi rõ "self-hosted default"), và mọi private-use scheme được continue bỏ qua validate. Ai cũng đăng ký được client với https://evil.com/callback rồi phish màn hình consent.
+Fix Đặt DCR_VERIFIED_DOMAINS cho deployment hosted; cân nhắc yêu cầu approval cho client mới.
+
+
+
+
+S9 Medium proxy.ts coi query param và request header là bằng chứng đã đăng nhập
+
+File apps/frontend/src/proxy.ts:63-66, 135, 152
+Vấn đề const authCookie =
+ request.cookies.get('auth') ||
+ request.headers.get('auth') ||
+ nextUrl.searchParams.get('loggedAuth');
+?loggedAuth=x trên URL bất kỳ khiến proxy coi request là đã xác thực.
+Hậu quả Backend vẫn validate JWT thật nên không phải data breach, nhưng: (a) user chưa đăng nhập vào được /modal/* và app shell đầy đủ (rồi bắn các SWR call không xác thực); (b) user "trông như đã đăng nhập" bị khoá khỏi /auth (:152 redirect họ ra xa trang login).
+Fix Chỉ request.cookies.get('auth') được tính là xác thực cho quyết định routing; nếu nhánh header/param tồn tại vì browser extension thì scope rõ vào /modal/ và validate với backend.
+
+
+
+
+S17 Medium DOMPurify có ~10 advisory nhưng phần lớn không áp dụng ; còn 2 điểm thật
+
+File libraries/helpers/src/utils/sanitize.post.content.ts:1-35
+Đã kiểm const ALLOWED_TAGS = ['p','br','strong','u','a','ul','li','h1','h2','h3','span'];
+const ALLOWED_ATTR = ['href','target','rel','class','data-mention-id','data-mention-label'];
+return DOMPurify.sanitize(value, {
+ ALLOWED_TAGS, ALLOWED_ATTR,
+ ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|\/|#)/i,
+});
+Đối chiếu từng advisory: không dùng IN_PLACE (~6 advisory), SAFE_FOR_TEMPLATES (2), FORBID_TAGS/ADD_TAGS dạng function (2), không đăng ký hook (2), CUSTOM_ELEMENT_HANDLING (1), Trusted Types (1). Allowlist tường minh 12 tag / 6 attr, và ALLOWED_URI_REGEXP chặn javascript:/data:/vbscript:. → Control sanitize vẫn đứng vững.
+Còn lại
+"Permanent ALLOWED_ATTR pollution" — advisory nói DOMPurify có thể mutate mảng config caller truyền vào. Ở đây ALLOWED_TAGS/ALLOWED_ATTR là hằng cấp module, chia sẻ giữa mọi lời gọi trong cùng tiến trình — đúng hình dạng dễ tổn thương nhất. Chưa xác minh được vì node_modules chưa cài. Fix phòng thủ gần như miễn phí: Object.freeze() cả hai mảng, hoặc truyền bản copy.
+.>posthog-js>dompurify — một bản dompurify thứ hai, version riêng bundle vào frontend qua PostHog, với config của SDK mà ta không kiểm soát.
+
+Kiến trúc isomorphic-dompurify kéo theo jsdom ở server — đó là lý do 4+ advisory undici lọt vào audit prod . Vì sanitizePostContent chạy trong @Transform của CreatePostDto (create.post.dto.ts:35 ), nghĩa là một jsdom instance khởi tạo ngay trên request path của mỗi lần tạo bài viết — vừa là chi phí hiệu năng, vừa mở rộng bề mặt tấn công.
+
+
+
+
+S12 Medium Sentry thu mọi log console và toàn bộ I/O của LLM; filter bỏ sót lỗi ngoài HTTP context
+
+File libraries/nestjs-libraries/src/sentry/initialize.sentry.ts:56-86 · sentry.exception.ts:4-7
+Vấn đề consoleLoggingIntegration({ levels: ['log','info','warn','error','debug','assert','trace'] }) + enableLogs: true chuyển mọi lời gọi console ra Sentry — repo có ~120 console.*, gồm email user (newsletter/providers/email-empty.provider.ts:6 ), prompt AI (media.service.ts:53 ), nội dung post (lemmy.provider.ts:182 ). openAIIntegration({ recordInputs: true, recordOutputs: true }) gửi toàn bộ prompt + completion, kể cả BYOK API key user dán vào prompt.
+beforeSend chỉ test event.request?.url, trong khi beforeSendTransaction có thêm || event.transaction. Lỗi phát sinh ngoài HTTP context — tức trong Temporal activity, nơi bootstrap/ticket thực sự chạy — đi thẳng qua filter. tracesSampleRate: 1.0.
+Fix Hạ consoleLoggingIntegration còn ['error','warn']; đặt recordInputs/recordOutputs: false; đổi test của beforeSend thành event.request?.url || event.transaction || ''; hạ tracesSampleRate ~0.1 với sampler giữ 100% error.
+
+
+
+
+S13 Medium Source map production phục vụ công khai khi thiếu SENTRY_AUTH_TOKEN
+
+File apps/frontend/next.config.js:25, 31, 96-103
+Vấn đề productionBrowserSourceMaps: true. Sentry đặt deleteSourcemapsAfterUpload: true — nhưng SENTRY_AUTH_TOKEN không có trong .env.example , và errorHandler (:96-103) bắt lỗi upload, log warning rồi return.
+Hậu quả Khi token vắng (trường hợp bình thường theo .env.example), upload fail âm thầm → deleteSourcemapsAfterUpload không chạy → source map đầy đủ được phục vụ từ .next/static: lộ toàn bộ source client.
+Fix productionBrowserSourceMaps: false và dựa vào hidden-source-map + upload; cho errorHandler fail build khi NODE_ENV=production và có DSN.
+
+
+
+
+S18 Medium crove-sso state-store: unbounded growth + singleton bottleneck
+
+File apps/crove-sso/src/state-store.ts:41-49 (take), :67-70 (DO singleton)
+Vấn đề take dùng DELETE WHERE id = ? AND expires_at > ? RETURNING value — atomic, tốt. Nhưng không có global sweep : hàng hết hạn chỉ bị xoá khi take được gọi với đúng key đó. Một attacker spam /authorize tạo ra hàng nghìn row với TTL 600s, và không row nào bị dọn trừ khi ai đó hoàn tất flow với từng key cụ thể. DO SQLite storage phình vô hạn → chi phí + có thể chạm DO limits. Kèm theo: DO là singleton (idFromName('oauth-state')) → toàn bộ SSO state của mọi user đi qua một instance — bottleneck throughput, không horizontal-scale được.
+Fix Thêm scheduled cleanup hoặc storage.deleteExpired() (nếu DO hỗ trợ) hoặc một alarm handler chạy DELETE FROM oauth_entries WHERE expires_at <= ? định kỳ. Nếu cần scale, phân mảnh DO theo key hash thay vì singleton.
+
+
+
+
+S19 Medium auth.middleware.ts:28 chấp nhận JWT từ request header, bypass httpOnly
+
+File apps/backend/src/services/auth/auth.middleware.ts:28
+Vấn đề const auth = req.headers.auth || req.cookies.auth;
+Cookie auth được set với httpOnly: true — nhưng middleware chấp nhận giá trị từ request header với độ ưu tiên cao hơn cookie. Bất kỳ code JavaScript nào chạy trên frontend (XSS, extension độc hại) đọc được token từ một nguồn không-httpOnly và gửi nó qua header auth — và với CORS credentials: true + allowedHeaders: ['auth', ...], header này được phép trong cross-origin request. Cơ chế httpOnly bị làm suy yếu: nó chỉ bảo vệ cookie khỏi document.cookie, nhưng token vẫn usable nếu kẻ tấn công có được nó qua bất kỳ con đường nào khác.
+Fix Chỉ chấp nhận req.cookies.auth trong middleware, hoặc nếu cần header cho extension/webhook flow thì đặt tên header khác (X-Auth-Token) và chỉ cho phép từ các route được whitelist cụ thể.
+
+
+
+
+S20 Low dos-org-sync tạo user password: '' — chưa xác minh có login được không
+
+File apps/backend/src/api/routes/dos-org-sync.controller.ts:96-101
+Vấn đề Handler gọi createOrgAndUser với password: '' và provider: 'LOCAL'. hashSync('', 10) tạo ra hash hợp lệ, và compareSync('', hash) sẽ match. Câu hỏi còn bỏ ngỏ: auth controller có check từ chối mật khẩu rỗng khi login không? Nếu không có check đó, user được tạo qua webhook có thể login với mật khẩu trống. Chưa xác minh — cần đọc apps/backend/src/services/auth/auth.service.ts hoặc apps/backend/src/api/routes/auth.controller.ts đoạn xử lý login để kết luận.
+Fix Xác minh trước khi kết luận. Nếu đúng là login được: đây là leo thang đặc quyền → nâng lên High, và sửa bằng cách dùng provider: 'SSO' (không có password) hoặc sinh mật khẩu ngẫu nhiên crypto.randomBytes(32).toString('hex').
+
+
+
+Đã làm đúng — không phải lỗ hổng
+
+Verified Các control đã xác minh là cài đặt đúng
+
+So sánh timing-safe dùng đúng ở cả 3 chỗ HMAC: bootstrap.service.ts:73 , provision.controller.ts:64 , dos-org-sync.controller.ts:53 (kèm check độ dài trước để tránh throw).
+Không có @SkipThrottle nào trong toàn repo → throttler áp dụng toàn cục (Redis-backed, 90 req/hr).
+CORS origin là allowlist mảng chặt (main.ts:44-48 ), không reflect Origin; credentials: true chỉ khi không NOT_SECURED.
+AuthMiddleware không tin claim trong token — re-resolve user từ DB bằng payload.id, check user.activated, có comment ghi rõ ý định (auth.middleware.ts:39-42 ). Impersonate bắt buộc user?.isSuperAdmin server-side (:74); showorg bị filter về danh sách org của chính user (:108-111) → không cross-tenant.
+Stripe webhook verify đúng : stripe.webhooks.constructEvent(rawBody, signature, endpointSecret) (stripe.service.ts:37 ) với rawBody: true ở main.ts:24 .
+SSRF protection áp dụng nhất quán qua getSsrfSafeDispatcher() trong social.abstract.ts (fetch, mediaSize, mediaChunk, mediaStream), webhook activity (post.activity.ts:509 ) và OAuth callback (no.auth.integrations.controller.ts:298 ).
+Upload validation tốt : upload/custom.upload.validation.ts dùng magic-byte fileTypeFromBuffer, allowlist MIME không có SVG (→ không stored-XSS qua ảnh), size limit theo loại, sanitize originalname (bỏ /, \, giới hạn 100 ký tự).
+redirect_uri validate kỹ (oauth.service.ts:101-166 ): blocklist javascript:/data:/blob:/file:/vbscript:/about:; http chỉ cho loopback; https hoặc private-use scheme (RFC 8252 §7.1); verified-domain suffix match.
+apps/crove-sso viết chắc : constantTimeEqual cài đúng (SHA-256 digest + XOR loop, length-safe, oauth.ts:34-47 ); PKCE S256 với crypto.getRandomValues(32); client_id/redirect_uri/scope/response_type đều exact-match ; state-store.ts:41-49 dùng DELETE … RETURNING → single-use atomic, không có TOCTOU ; rows.length !== 1 → 404.
+Không raw SQL ở bất kỳ đâu — grep $queryRaw/$executeRaw/$queryRawUnsafe/$runCommandRaw trên toàn repo: 0 kết quả . Rule 3 của CLAUDE.md được tôn trọng tuyệt đối.
+Không có pull_request_target trong cả 12 workflow; không secret nào bị echo/ghi ra disk; không self-hosted runner.
+sync-upstream.yml không auto-merge — tạo PR (:80-88) và chạy branding guard trên cây đã merge trước (:70-76).
+.env được gitignore đúng (verify bằng git check-ignore -v): .env, .env.local, scripts/crove-server.env, scripts/crove-server.beta.env — chỉ .env.example được track.
+First-party ticket có TTL + single-use thật : JWT có exp 300s và khoá Redis ticket:<jti> EX 300 (provision.controller.ts:163-178 ); proxy.ts:14-61 validate format /^fpt_[a-f0-9]{64}$/, check cả origin lẫn pathname của redirect_to trước khi 303.
+
+
+
+
+3. Backend · Database · Kiến trúc
+Ngoài C2, C6, C10 ở trên. Đối chiếu với 7 rule kiến trúc mà chính CLAUDE.md đặt ra.
+
+3.1 Đường publish — nhóm rủi ro cao nhất
+
+
+B8 High Multi-step write không có transaction — toàn repo chỉ có 2 lần dùng $transaction
+
+File posts.repository.ts:508-620 · integration.repository.ts:150-193, 270-360 · agencies.repository.ts:110-180
+Vấn đề Grep $transaction trên toàn bộ **/*.ts trả về đúng 3 kết quả: users.repository.ts:53 , provision/bootstrap.repository.ts:14 , và khai báo type PrismaTransaction. Các chuỗi sau là non-atomic:
+
+createOrUpdatePost — upsert loop → tagsPosts.deleteMany → tags.findMany → post.update → findFirst → updateMany.
+updateIntegration — findUnique → post.updateMany(deletedAt) → integration.update (rename deleted_*) → integration.update (params).
+createOrUpdateIntegration — upsert → findFirst → updateMany (fan token sang kênh anh em khi oneTimeToken).
+
+Hậu quả createOrUpdatePost fail giữa chừng để lại post group nửa vời với comment mồ côi và tag join stale — trong khi caller (posts.service.ts:938 ) đã schedule Temporal workflow cho nó . updateIntegration fail sau bước 2 để lại toàn bộ post của org bị soft-delete và kênh đã đổi tên deleted_* mà không có row sống. createOrUpdateIntegration fail sau upsert để kênh anh em dùng token cũ → post fail.
+Fix Bọc trong this._transaction.model.$transaction(async (tx) => {...}) dùng provider PrismaTransaction sẵn có — pattern đã được thiết lập ở users.repository.ts:53 .
+
+
+
+
+B-D6 High Post được lưu là QUEUE trong khi startWorkflow fire-and-forget và nuốt lỗi
+
+File posts.service.ts:955-960, 704-753 · temporal/infinite.workflow.register.ts:10-18
+Vấn đề this.startWorkflow(post.settings.__type.split('-')[0].toLowerCase(),
+ posts[0].id, orgId, posts[0].state).catch((err) => {});
+Bản thân startWorkflow cũng bọc cả terminate-loop và workflow.start trong catch (err) {}. Empty catch còn xuất hiện ở :679, 681, 719, 721, 752, 1034, 1147, 1185.
+Lưới an toàn là missingPostWorkflow sweep hằng giờ — nhưng infinite.workflow.register.ts:10-18 cũng catch (err) {} khi start nó, không có workflowIdConflictPolicy, không log.
+Hậu quả Row post được commit là QUEUE, createPost trả về id, UI hiện "scheduled" — và không có execution Temporal nào . Nếu Temporal down / sai namespace / TLS lệch lúc boot thì sweep cũng không chạy và app vẫn báo khởi động khoẻ mạnh. Mọi post có workflow chết sẽ biến mất trong im lặng — failure mode tệ nhất cho một scheduler. Kèm theo: post.settings.__type dereference không guard, mà với type:'draft' thì DTO @ValidateIf((o) => o.type !== 'draft') bỏ qua validate settings → TypeError.
+Fix await việc start và surface lỗi thành 502 để client retry, hoặc ghi cờ pendingWorkflowStart cho sweep nhặt deterministically. Log + Sentry cho infinite.workflow.register.ts, thêm workflowIdConflictPolicy: 'USE_EXISTING'. Guard post.settings?.__type.
+
+
+
+
+B-D4 High Thiếu await → catch thành dead code + unhandled rejection làm crash backend
+
+File database/prisma/autopost/autopost.service.ts:108-124
+Vấn đề if (active) {
+ try {
+ return this._temporalService.client.getRawClient()
+ ?.workflow.start('autoPostWorkflow', { ... }); // ← không await
+ } catch (err) {}
+}
+try trả về chính promise nên thoát trước khi promise settle → catch không bao giờ chạy.
+Hậu quả Hai lỗi cùng lúc: (1) nếu workflow.start reject, row autopost đã active: true trong DB nhưng không có workflow → RSS autopost không bao giờ post, mãi mãi , không lỗi ở đâu cả; (2) rejection không được xử lý — Node ≥15 mặc định --unhandled-rejections=throw và package.json:8 pin Node 22 → crash tiến trình backend . changeActive (:97-105) và deleteAutopost (:132-136) đều đi qua đây.
+Fix return await this._temporalService...start(...); thêm Sentry capture trong catch thay vì để rỗng.
+
+
+
+
+B6 High Expand recurring post có thể lặp vô hạn — inter không có giá trị tối thiểu
+
+File posts.repository.ts:194-213, 552 · dtos/posts/create.post.dto.ts:106-108
+Vấn đề let startingDate = dayjs.utc(post.publishDate);
+while (dayjs.utc(endDate).isSameOrAfter(startingDate)) {
+ ...addMorePosts.push({...post, publishDate: startingDate.toDate(), ...});
+ startingDate = startingDate.add(post.intervalInDays, 'days');
+}
+DTO chỉ có @IsOptional() @IsNumber() inter?: number — không @Min(1) . Lưu raw: intervalInDays: inter ? +inter : null. inter âm làm startingDate đi ngược → điều kiện while không bao giờ sai.
+Khai thác POST /posts với inter: -1 rồi GET /posts?startDate=…&endDate=… → hang request, addMorePosts phình vô hạn → OOM backend . Reachable bởi mọi user đã đăng nhập và bởi public API. Ngay cả với inter: 1 hợp lệ, query vẫn fetch mọi recurring post bất kể ngày (OR: [{publishDate between}, {intervalInDays: {not: null}}], :145-158) và expand từ anchor gốc tới endDate — post daily tạo 2 năm trước sinh ~730 row ảo mỗi lần load calendar .
+Fix @IsInt() @Min(1) @Max(365) trên CreatePostDto.inter; trong getPosts clamp điểm bắt đầu expand về max(publishDate, startDate) và thêm cap số vòng lặp.
+
+
+
+
+B7 High N+1 query trên đường publish và trong thao tác bulk
+
+File posts.service.ts:294-315, 258-268, 780-786 · integration.repository.ts:735-748, 678-712 · notification.service.ts:91-98
+Vấn đề
+getPostsRecursively — một findUnique mỗi comment , đệ quy. Gọi từ post.activity.ts:170 trên mỗi lần đăng bài . Chính post.activity.ts:37 đã ghi nhận chi phí này trong comment.
+mapTypeToPost / validatePosts — await Promise.all(posts.map(p => getIntegrationById(...))): một query mỗi post thay vì một findMany({ id: { in: [...] } }).
+disableIntegrations — for (const channel of getChannels) { await update(...) }: một write mỗi kênh khi hạ gói, chạy trong Stripe webhook . Org enterprise 200 kênh = 200 write tuần tự.
+deleteIntegrationsForAccount, sendEmailsToOrg, createOrUpdatePost — cùng dạng tuần tự.
+
+Fix Thay getPostsRecursively bằng một findMany (root + toàn bộ descendant theo group); batch lookup integration bằng in:; đổi 2 vòng for thành updateMany.
+
+
+
+
+B5 High Thiếu index trên các cột lọc/sort nóng nhất
+
+File schema.prisma:281-320 (Integration) · :443-459 (Errors) · :194-215 (Media) · :418-429 · :487-497
+Vấn đề Đối chiếu với query thực tế:
+
+Cột Query dùng nó Hậu quả
+Integration.tokenExpirationintegration.repository.ts:396-405 needsToBeRefreshedFull scan toàn bảng mỗi lần sweep refresh
+Errors.postId, Errors.platformposts.repository.ts:452-458 , errors.repository.ts:52-58 (distinct không where, không take)Full scan + sort
+Media.createdAt, Media.deletedAtmedia.repository.ts:96-99 (phân trang 18)Mỗi trang là full scan + sort media của org
+PayoutProblems— 0 index (không orderId/userId/postId)
+Post compositeposts.repository.ts:127-183 (calendar), :216-300 (list)15 index đơn nhưng không composite cho organizationId+deletedAt+parentPostId+publishDate
+
+Hậu quả Đây đều là bảng phình vô hạn → thời gian query thoái hoá tuyến tính theo tổng số row. Calendar, media library và sweep refresh token càng lúc càng chậm mãi .
+Fix @@index([tokenExpiration]) trên Integration; @@index([postId]) + @@index([platform]) trên Errors; @@index([organizationId, deletedAt, createdAt]) trên Media; @@index([organizationId, deletedAt, parentPostId, publishDate]) trên Post. Cần có migration thật (C2) chứ không phải db push.
+
+
+
+
+B4 High Không có onDelete trên ~40 relation; bảng join không có soft-delete
+
+File schema.prisma — mọi @relation(...) đều bỏ trống onDelete
+Vấn đề Mặc định của Prisma cho required relation là Restrict. TagsPosts, ExisingPlugData, IntegrationsWebhooks, SocialMediaAgencyNiche, Comments, Errors, PayoutProblems, OrderItems không có cột deletedAt → chỉ xoá được bằng hard delete, mà parent Restrict thì cấm.
+Hậu quả Mọi hard delete Post/Tags/Integration/Webhooks/User trong tương lai sẽ throw P2003. Không có đường dọn orphan: deleteTag (posts.repository.ts:664-671 ) soft-delete row Tags và để lại row join TagsPosts trỏ vào nó.
+Fix Thêm onDelete: Cascade cho bảng join thuần; onDelete: SetNull ở chỗ FK đã nullable (Post.lastMessage, Post.submittedForOrder).
+
+
+
+3.2 Vi phạm rule kiến trúc của chính dự án
+
+
+C-C6 High Logic nghiệp vụ nằm trong controller; vòng lặp refresh→retry→disconnect bị viết 4 lần và đã phân kỳ
+
+File integrations.controller.ts:196-262, 141-178, 276-330, 334-397 · no.auth.integrations.controller.ts:99-336 · posts.controller.ts:172-206
+Vấn đề CLAUDE.md rule 2: "Most of the server logic should be inside libs/server. The backend repository is mostly used to write controllers." Nhưng các controller này gọi thẳng IntegrationManager, điều khiển ioRedis, gọi method provider, tự cài orchestration refresh/retry/disconnect. Toàn bộ OAuth callback state machine nằm trong controller, kèm new Promise<AuthTokenDetails>(async (res) => {...}) — async promise executor .
+Cùng một vòng lặp refresh→retry→disconnect được viết 4 lần riêng biệt : integrations.controller.ts:368-393 , public.integrations.controller.ts:596-628 , posts.service.ts:106-129 , posts.service.ts:196-224 .
+Hậu quả 4 bản đã phân kỳ: chỉ bản public có tool allowlist (→ C3), chỉ checkPostAnalytics ghi cache, chỉ getMissingContent đệ quy. Mỗi fix hành vi provider phải áp 4 lần và thường xuyên không được áp đủ.
+Fix Dồn vòng lặp vào một RefreshIntegrationService.callWithRefresh(integration, fn) trong libraries/, cho cả 4 nơi gọi; dời OAuth state machine ra oauth.callback.service.ts dưới libraries/nestjs-libraries/src/integrations/.
+
+
+
+
+C-C7 Medium Provider-specific logic trong file generic (vi phạm rule 5)
+
+File posts.service.ts:830, 1194 · helpers/src/utils/count.length.ts:9 · no.auth.integrations.controller.ts:271-274
+Vấn đề // posts.service.ts:829-836 — validatePosts (generic)
+const isX = integration.providerIdentifier === 'x';
+const length = isX ? weightedLength(strip) : strip.length;
+
+// posts.service.ts:1193-1194 — generatePostsDraft (generic)
+.filter((f) => !f.disabled && f.providerIdentifier !== 'reddit');
+
+// no.auth.integrations.controller.ts:271-274 — OAuth callback (generic)
+const fetchMethod = 'pages' in integrationProvider ? 'pages'
+ : 'companies' in integrationProvider ? 'companies' : null;
+count.length.ts:9 (if (integrationType !== 'x')) nằm ở libraries/helpers — tầng generic nhất.
+Hậu quả Đúng thứ rule 5 cấm, và mỗi chỗ là một bug chờ provider kế tiếp: isX nghĩa là provider có cách đếm ký tự weighted kiểu X (URL tính 23 ký tự) sẽ âm thầm nhận verdict tooLong sai; !== 'reddit' hardcode một provider; duck-typing 'pages' in provider khiến provider mới đặt tên method là channels/groups nhận pages: [] mà không báo lỗi (catch ở :277 chỉ console.log).
+Fix Thêm countLength(text) và supportsGeneratedDrafts() vào SocialProvider, default trong SocialAbstract, override trong x.provider.ts/reddit.provider.ts. Thêm fetchSelectableAccounts(accessToken) vào interface để callback hết duck-typing.
+
+
+
+3.3 Prisma schema & data layer
+
+ID Mức Vấn đề File:line
+B10 Medium createPopularPosts bỏ qua tham số , ghi literal: data: { category: 'category', topic: 'topic', content: 'content', hook: 'hook' }. Bảng PopularPosts — ngữ cảnh grounding cho AI agent (agent.graph.service.ts:209-214, 250 ) — toàn row rác giống hệt nhau.posts.repository.ts:771-785
+B11 Medium getPostByForWebhookId tên/được dùng như lookup 1 post nhưng gọi findMany → activity JSON.stringify(mảng). Mọi webhook của khách nhận [{...}] thay vì {...} — contract công khai, documented, sai âm thầm.posts.repository.ts:887-911 , post.activity.ts:501-506
+B9 Medium findMany không giới hạn trên bảng phình vô hạn: getOldPosts (expose ở GET /posts/old), getPostsSince, getNotificationsSince, listPlatforms, getIntegrationsList (không select → trả token), getPostsCountsByDates (fetch full row chỉ để so ngày). GET /posts/old?date=2099-01-01 trả về mọi post org từng tạo.posts.repository.ts:66-101, 913-936, 806-814 , notifications.repository.ts:49-58 , errors.repository.ts:52-58
+B12 Medium Tag đã soft-delete vẫn được trả về trên post: tags: { select: { tag: true } } thiếu where: { tag: { deletedAt: null } } ở cả 3 select. posts.repository.ts:170-174, 283-287, 322-333
+B13 Medium log: [{ emit: 'event', level: 'query' }] nhưng grep $on( toàn repo → 0 listener . Prisma format SQL + params và emit event cho mọi query ở production mà không ai tiêu thụ; trên các đường N+1 (B7) là lượng allocation lãng phí lớn.prisma.service.ts:7-13
+B14 Low Constraint trùng lặp: TagsPosts có cả @@id([postId, tagId]) lẫn @@unique([postId, tagId]) (cùng một constraint 2 lần); tương tự IntegrationsWebhooks; Subscription.organizationId @unique + @@index([organizationId]); SocialMediaAgency likewise → write amplification gấp đôi. JSON lưu trong cột String và re-parse mỗi lần đọc (Integration.postingTimes, Post.settings, Plugs.data, Errors.body); Media.type/Integration.type/Credits.type là String chỗ nên là enum. schema.prisma:65-73, 497-508, 250-256, 224-230
+
+
+3.4 Error handling, resilience, correctness
+
+ID Mức Vấn đề File:line
+D3 High finishTrial nuốt lỗi provider và báo thành công: try { await provider.finishTrial(org); } catch (err) {} return { finish: true };. Kết thúc trial là thứ bật tính tiền — khi Stripe/RevenueCat từ chối, API vẫn trả {finish:true}, UI hiện trial đã kết thúc, org ở lại trial vô hạn. Mất doanh thu âm thầm , không log, không Sentry.billing.controller.ts:72-81
+D7 High Đệ quy không chặn: findTime đệ quy tới khi rút được ngày tương lai, mà DTO chỉ validate @IsNumber() trên week/year (không range) → {week:1, year:2020} gây RangeError: Maximum call stack size exceeded. findFreeDateTimeRecursive đệ quy sang ngày kế khi getPostsCountsByDates trả [] (xảy ra khi org không có integration nào bật) → lặp vô hạn, một query DB mỗi ngày mô phỏng , expose cả ở public API GET /v1/integrations/:id/free-date. posts.service.ts:1198-1216, 1319-1341 , dtos/generator/create.generated.posts.dto.ts:35-41
+D8 Medium || chỗ cần &&: if (params.picture && (indexOf(CLOUDFLARE_BUCKET_URL) === -1 || indexOf(FRONTEND_URL) === -1)) — một URL không thể chứa cả hai, nên điều kiện luôn true . Mỗi lần reconnect kênh lại re-download + re-upload avatar → object mồ côi mới mỗi lần. Khi env var chưa set, indexOf(undefined!) coerce thành indexOf("undefined") → cũng luôn true.integration.repository.ts:151-156
+D9 Medium Lỗi ghi Errors và lỗi resolve media bị vứt: changeState là writer duy nhất của bảng Errors mà admin error console đọc, nhưng try { create(...) } catch (err) {} → post bị đánh ERROR mà không có record chẩn đoán. updateMedia trả về input thô khi có bất kỳ lỗi nào → post được publish với path tương đối chưa resolve. posts.repository.ts:432-445 , posts.service.ts:434-436
+D10 Medium runInConcurrent không giới hạn concurrency (tên sai — thân hàm chỉ là error classifier), và truyền status hardcode 200 vào handleErrors → classifier của mọi provider không bao giờ match, lỗi thật và identifier bị mất. async-mutex + bottleneck khai trong package.json nhưng không import ở đâu cả ; maxConcurrentJob chỉ được đọc ở đúng một chỗ (temporal.module.ts:55-59 ) nên không áp dụng cho 4 call site ngoài worker.social.abstract.ts:415-451, 125
+D11 Medium Cache analytics ghi nhưng không bao giờ đọc — dòng đọc bị comment, dòng ghi thì sống. Mọi request analytics đều bắn API provider (không có bảo vệ trước dashboard polling hay rate limit), trong khi Redis tích một key mỗi (org, post, date) không ai tiêu thụ. posts.service.ts:186-206
+D12 Medium posts[0].integrationId không guard (trong khi posts[0]?.integration?.picture thì có) → id sai hoặc khác tenant gây TypeError → 500 kèm stack trace thay vì 404.posts.service.ts:509, 547
+D13 Medium getIntegrationById không filter deletedAt/disabled (đối chiếu getPostingTimes cùng file thì có cả hai) → tạo và schedule post lên kênh đã xoá/tắt được. Workflow bắt disabled + refreshNeeded lúc publish nhưng không bắt deletedAt → post lên kênh đã soft-delete vẫn publish.integration.repository.ts:439-446
+D14 Medium getAllUsersOrgs không filter disabled trên join và deletedAt trên User → thành viên đã bị xoá khỏi org vẫn nhận email chứa tên kênh + nội dung post của org. Rò rỉ dữ liệu khi offboarding, kèm vấn đề deliverability (bounce).organization.repository.ts:443-464 , notification.service.ts:88-99
+D15 Medium Timezone không nhất quán giữa 2 tiến trình và trong cùng một file: backend set TZ='UTC', orchestrator không set ; posts.repository.ts trộn dayjs.utc() (:129,521) với dayjs() local (:399,522); updatePostSettings format UTC không offset rồi parse lại bằng local. Chỉ đúng khi mọi tiến trình chạy TZ=UTC — container khác UTC là mọi post lệch giờ. orchestrator/src/main.ts , posts.repository.ts , posts.service.ts:1080
+C-C8 Medium throw new Error(...) cho validation hướng người dùng ở ~13 chỗ trong khi mọi controller lân cận dùng HttpException/BadRequestException → id sai thành 500 kèm stack trace thay vì 400 kèm thông điệp. deletePost trả { error: true } khi thành công .integrations.controller.ts:136,151,159,210,222,284,288,341,347,399 , posts.service.ts:684
+A5 Medium JSON.parse(post.settings) không guard trong workflow (activity thì guard: JSON.parse(p.settings || '{}')). Post.settings là String? → chuỗi rỗng throw SyntaxError bên trong workflow, tại điểm post đã publish xong : user thấy trạng thái ERROR trên bài đã lên sóng, và repeat-post child không bao giờ được start (post định kỳ âm thầm ngừng lặp).post.workflow.v1.1.2.ts:605 (và 11 version trước)
+
+
+3.5 Dead code & legacy
+
+ID Mức Vấn đề File:line
+E1 Medium Feature GitHub-trending legacy: 5 model Prisma (GitHub, Trending, TrendingLog, ItemUser, Star) + 2 relation trên model sống + 6 index — 0 code backend (grep model.gitHub/model.trending/… → 0 kết quả). Nhưng frontend vẫn ship 4 component analytics và gọi useSWR('/analytics/trending'), bị @Get('/:integration') bắt với integration='trending' → render rác. ~110 dòng schema mà db push liên tục reconcile ở prod + ~400 dòng frontend. calendar.context.tsx:45,146,340 mang state trendings chết không có setter. Lưu ý: GithubProvider (đăng nhập GitHub) là feature sống riêng biệt. schema.prisma:141-186 , analytics.component.tsx:18-28
+E2 Low mastodon.custom.provider.ts (138 dòng) bị comment khỏi socialIntegrationList và import cũng bị xoá → class không được tham chiếu ở đâu, nhưng vẫn được biên dịch và ship. File vẫn gọi AuthService.fixedDecryption(...) và khai override maxConcurrentJob = 5. Nửa vời là phương án tệ nhất trong 3 lựa chọn.integrations/social/mastodon.custom.provider.ts , integration.manager.ts:78
+E3 Low getInternalPlugs và getSocialIntegration dùng ! trên find có thể miss → identifier lạ (typo, provider bị HIDDEN_PROVIDERS ẩn, hoặc mastodon-custom chết) gây TypeError → 500. Reachable qua GET /integrations/:identifier/internal-plugs với @Param không validate.integration.manager.ts:194-208
+
+
+
+4. Frontend · UX · Accessibility
+Next.js 16 App Router + React 19.2 + Tailwind 3.4.17 · 268 file .tsx trong apps/frontend/src/components.
+
+4.0 Số liệu định lượng
+
+Chỉ số Giá trị Ghi chú
+eslint-disable trên react-hooks/rules-of-hooks0 Rule 4 của CLAUDE.md được tôn trọng
+Hàm trả về object chứa các lời gọi useSWR() 0 Pattern "invalid" trong CLAUDE.md không xuất hiện
+<SWRConfig> provider toàn cục0 15 useSWRConfig() consumer , không provider
+next/image0 ~60 thẻ <img> thô
+next/dynamic / React.lazy8 file trên tổng 268 component
+Token deprecated --color-custom* 234 109 trong .tsx, ~34 file
+Hex hardcode dạng arbitrary value 142 phần lớn trùng token đã có
+Thuộc tính ARIA / role / tabIndex 10 (8 hợp lệ)2 cái là role="Handle" — không phải role
+aria-modal / role="dialog"0 —
+Dependency có 0 import trong source 7 nhóm xem F-D6
+Locale file en 2026-09-04 15 locale khác 2026-08-10~1 tháng drift + 2 file mồ côi
+File > 800 dòng 5 calendar.tsx 1383 · impersonate.tsx 1148 · editor.tsx 1048 · media.component.tsx 998 · public.component.tsx 876
+
+
+4.1 Accessibility — 2 blocker nằm ở shared primitive nên nhân bản ra toàn app
+
+
+F-C1 Critical Checkbox dùng chung là một <div> — không checkbox nào trong sản phẩm dùng được bằng bàn phím
+
+File libraries/react-shared-libraries/src/form/checkbox.tsx:47-77
+Vấn đề <div ref={ref} {...disableForm ? {} : form.register(props.name!)}
+ onClick={changeStatus}
+ className={clsx('cursor-pointer rounded-[4px] select-none w-[24px] h-[24px] …')}>
+ {val && (<div><svg …><polyline points="20 6 9 17 4 12"/></svg></div>)}
+</div>
+Không <input type="checkbox">, không role="checkbox", không aria-checked, không tabIndex, không onKeyDown. Đây là primitive của shared library mà mọi form trong app dùng.
+Hậu quả User chỉ dùng bàn phím không toggle được một checkbox nào. Screen reader đọc ra một container generic không nhãn. Blocker cứng WCAG 2.1.1 / 4.1.2 trên các luồng cốt lõi (post settings, notification preferences, team permissions).
+Fix Render một <input type="checkbox"> visually-hidden với id/checked/onChange/{...register}, giữ <div> hiện tại làm <label htmlFor>; hoặc thêm role="checkbox" aria-checked tabIndex={0} onKeyDown (Space/Enter).
+
+
+
+
+F-C2 Critical Modal không có role="dialog", aria-modal, và không quản lý focus
+
+File apps/frontend/src/components/layout/new-modal.tsx:126-160, 162-244
+Vấn đề Cả hai nhánh render một <div className="fixed flex left-0 top-0 …"> trần. Grep aria-modal/role="dialog" toàn repo → 0 . Không focus trap, không initial focus, không restore-focus khi đóng. ModalManagerInner chỉ set body.overflow-hidden và thêm .blur-xs .pointer-events-none cho phần tử .blurMe (:270-283) — đó là blur thị giác, không phải rào cản accessibility.
+Hậu quả Khi mở, focus ở lại trang phía sau; Tab đi xuyên qua tài liệu bị che. Screen reader không bao giờ thông báo dialog hay tiêu đề của nó, và đọc phần nền bị blur như nội dung bình thường. Ảnh hưởng mọi modal trong app , kể cả post composer nơi askClose đang canh giữ mất mát dữ liệu.
+Fix Thêm role="dialog" aria-modal="true" aria-labelledby={titleId} vào panel, focus panel (hoặc phần tử focusable đầu tiên) khi mount, trap Tab, restore focus về trigger khi đóng.
+
+
+
+
+ID Mức Vấn đề File:line
+F-C3 High Form primitive render label thành <div> không liên kết: không <label>, không htmlFor, không id trên control, không aria-label. Mọi text input / select / textarea trong app không có nhãn lập trình được. Một thay đổi trong library sửa cho toàn app. form/input.tsx:53-79 , select.tsx:45-62 , textarea.tsx, custom.select.tsx, multi.select.tsx, color.picker.tsx, canonical.tsx
+F-C4 High {...rest} spread sau form.register() → onChange/onBlur/ref của caller ghi đè handler của react-hook-form. Field validate thành rỗng và submit blank. Hiện đang latent (caller duy nhất dùng onChange cũng truyền disableForm) nhưng là cái bẫy đã cài sẵn trong primitive được dùng nhiều nhất.form/input.tsx:74-77
+F-C5 High 17+ <div onClick> không role/tabIndex/key handler: theme toggle (icon-only, không text alternative), logout, org switcher, notification bell, heading picker, media select/expand, "+ Show more". tabIndex không xuất hiện ở đâu trong cây component. 2 chỗ dùng role="Handle" — assistive tech loại bỏ vì là role không xác định. mode.component.tsx:22 , logout.component.tsx:37 , organization.selector.tsx:162 , notification.component.tsx:127 , heading.component.tsx:37,51,65 , media.component.tsx:550,566 , calendar.tsx:893 , launches.component.tsx:328
+F-C7 Medium Text lỗi validate dùng text-red-400 (#f87171) trên nền --color-primary: 6.3:1 trong dark (pass) nhưng 2.9:1 trong light — trượt WCAG AA ở 12px. Thông báo lỗi, thứ duy nhất user phải đọc, lại là chữ khó đọc nhất ở light mode. form/input.tsx:80 , select.tsx:64
+F-C6 Medium jsx-a11y bật nhờ kế thừa eslint-config-next nhưng không có block a11y tường minh, không nâng severity, không CI gate. click-events-have-key-events và no-static-element-interactions chỉ là warn; label-has-associated-control không fire trên <div>. Grep eslint-disable.*jsx-a11y → 0 : không ai suppress, chỉ là rule không trigger.apps/frontend/eslint.config.mjs:1-17
+F-C8 Medium alt thiếu hoặc không mô tả: alt="media" cho mọi thumbnail (screen reader đọc "media, media, media…") dù media.originalName đã được render ngay cạnh; thiếu hẳn alt ở 3 chỗ khác.media.component.tsx:592 , plugs.tsx:99 , platform.analytics.tsx:153 , add.provider.component.tsx:722
+
+
+4.2 Bug correctness đã xác minh
+
+ID Mức Vấn đề File:line
+F-A2 High closeOnEscape và closeOnClickOutside được khai trong interface nhưng không bao giờ được đọc . Handler Escape vô điều kiện, backdrop luôn đóng. 44 call site truyền false (composer, AI chat, generator, sets, calendar) đều là no-op → bấm Escape mất bài đang soạn . Mất dữ liệu chỉ bằng một phím.new-modal.tsx:24-26, 118-125, 163
+F-A1 High Stale closure trong timer refresh của CalendarColumn: useCallback deps [isBeforeNow] thiếu num → setNum(num+1) luôn tính 0+1 → React bail out → isBeforeNow chỉ recompute đúng một lần ~2 phút sau mount. Thêm random(120000,150000) inline làm delay đổi mỗi render, timer liên tục bị reset trong grid 42 ô. Slot thời gian đã qua vẫn drop được mãi mãi. launches/calendar.tsx:642-659
+F-A3 High SSR hardcode 'dark' trên <body>, còn ModeComponent (thứ thực sự đổi class) load với {ssr:false} → flash dark theme mỗi navigation với user đã chọn light, rồi reflow khi client chunk về. colors.scss có block .light đầy đủ nhưng gần như không tới được mà không bị flash. (app)/layout.tsx:56 , new-layout/layout.component.tsx:6-11 , layout/mode.component.tsx:17-20
+F-A4 High Light mode vỡ do surface dark hardcode ở 4 layout: bg-[#0E0E0E], bg-[#1A1919], bg-[#0B0A0A], bg-[#121212] + text-white (arbitrary hex, không token). layout.standalone.tsx còn inject html body.dark, html { background: transparent !important; } — chỉ nhắm body.dark, không bao giờ body.light. Login / OAuth authorize / integration callback / extension embed vẫn gần-đen trong light mode. auth/layout.tsx:22,27 , oauth/authorize/layout.tsx:14 , integrations/social/layout.tsx:9 , launches/layout.standalone.tsx:25-27
+F-A7 Medium Org switcher: (a) useMemo deps [data] thiếu user?.orgId → current stale khi đổi org mà data không đổi (SWR cache với revalidateIfStale:false); (b) dropdown là hidden … group-hover:flex không có onClick toggle → không dùng được trên touch/keyboard; (c) changeOrg kết thúc bằng window.location.reload() → xoá sạch SWR cache, tải lại app shell. layout/organization.selector.tsx:66-73, 128-136, 74-84
+F-A5/A6 Medium selectedIntegration là kết quả find có thể undefined, dereference tại :155-156 không guard → trắng màn hình khi kênh bị ngắt/xoá trong lúc draft đang mở. Và useImperativeHandle memo với deps [value] → getValues() trả identifier/maximumCharacters của kênh cũ sau khi user đổi kênh; parent lưu post với setting sai.new-launch/providers/high.order.provider.tsx:101-105, 155-156, 184-225
+F-A8 Medium CreateOrganization không check res.ok, không try/catch: lỗi trùng tên → body không có id → POST change-org với {id: undefined} → đóng modal → reload. Tên user vừa gõ mất, không có thông báo nào.organization.selector.tsx:19-33
+F-A10 Medium Một biến NEXT_PUBLIC_GTM_ID được truyền cho cả gtmId lẫn googleAdsId. send_to cần Ads conversion ID (AW-XXXXX/label), không phải container ID GTM-XXXXX → tracking conversion trial bắn vào hư không , không lỗi nào hiện ra. (app)/layout.tsx:96 , layout/gtm.component.tsx:28-31,43
+F-A11 Medium GTM được snapshot lúc install và serve từ /g.js: scripts/fetch-gtm.mjs chạy ở postinstall, mọi nhánh lỗi đều process.exit(0) → install offline/Docker/CI không ghi file → <Script src="/g.js"> 404 lúc runtime mà build không fail. Container GTM bị đóng băng tại thời điểm install ; thay đổi tag trong GTM UI không tới user cho tới khi rebuild. Script còn tự viết parser .env. apps/frontend/scripts/fetch-gtm.mjs , gtm.component.tsx:43,46-51
+F-A13 Medium proxy.ts coi query param + request header là bằng chứng xác thực (chi tiết ở S9 ).apps/frontend/src/proxy.ts:63-66
+F-D4 Medium #add-edit-modal là key của zustand store, không phải DOM id — openModal dùng params.id || makeId(20) chỉ làm định danh store, không bao giờ render thành attribute id. Vậy mà CSS và querySelector vẫn nhắm vào nó → match nothing vĩnh viễn. Thêm nữa 7 feature cùng dùng id cố định và openModal dedupe theo nó → modal thứ hai bị âm thầm bỏ qua : bấm "Create post" khi đã có một add-edit modal thì không có gì xảy ra, không lỗi.new-modal.tsx:49-58 , layout.standalone.tsx:22 , click.outside.tsx:4
+F-C10 Medium DecisionEverywhere đăng ký listener không có cleanup → mỗi lần remount thêm một listener vĩnh viễn; một lời gọi areYouSure() mở N modal chồng nhau và resolve promise N lần. useDecisionModal.open lại là useCallback(…,[modals]) mà modals là object literal mới mỗi lần gọi → open có identity mới mỗi render.new-modal.tsx:388-393, 74-93
+F-A12 Low Tên class Tailwind dựng bằng nội suy chuỗi: min-w-[${size}px] min-h-[${size}px] → scanner không thấy → 2 utility không bao giờ được emit . Guard chống méo logo trong flex row âm thầm vô tác dụng. new-layout/logo.tsx:15-22
+F-A14/A15 Low (a) Detect provider bằng nextUrl.href.indexOf(p) trên ['google','settings'] — href gồm cả query, nên vào /settings khi chưa đăng nhập sẽ bị append provider=GITHUB vào redirect login. (b) Cả 3 chỗ ghi cookie auth đều dùng sameSite: false (= không emit attribute → browser mặc định Lax), trong khi domain= cross-subdomain ngụ ý cần None. proxy.ts:136-150, 113-120, 161-172, 186-197
+
+
+4.3 Hiệu năng
+
+ID Mức Vấn đề File:line
+F-B1 High Không có <SWRConfig> toàn cục — grep ra 15 useSWRConfig() consumer , 0 provider . Mặc định SWR áp dụng: revalidateOnFocus:true, revalidateOnReconnect:true, dedupingInterval:2000. Mỗi lần refocus tab là refetch media library, notifications, integrations list, sets, signatures… Cùng một khối 6 option bị copy-paste nguyên văn ở ~12 nơi và đang phân kỳ.thiếu ở (app)/layout.tsx và new-layout/layout.component.tsx
+F-B2 High Uppy (@uppy/core+react+compressor) + react-sortablejs + AiVideo + ThirdPartyMediaLibrary vào initial bundle của mọi page đã đăng nhập qua shell, cho một modal phần lớn user không mở trong phiên. media.component.tsx có code-split Polotno — nhưng mọi thứ khác trong module 998 dòng đó là import static. media/media.component.tsx:33,38,26 , new-layout/layout.component.tsx:21,91
+F-B3 High 11 package TipTap + emoji-picker-react (chỉ render sau state emojiPickerOpen) + @uppy/react Dashboard + @copilotkit/react-core bundle eager vào /launches qua chuỗi static không đứt: launches/page.tsx → launches.component.tsx → add.edit.modal.tsx → manage.modal.tsx → editor.tsx. CopilotKit còn bọc toàn shell. new-launch/editor.tsx:16,32,35,42-58 , layout.component.tsx:30,82-86
+F-B4 High 0 lần dùng next/image , ~60 thẻ <img> thô; next.config.js không có block images. width="100%" height="100%" — attribute HTML phải là số nguyên không âm nên bị bỏ qua → không có aspect ratio nội tại → CLS trên mọi thumbnail media . Không srcset, không AVIF/WebP, không lazy-loading: ảnh gốc user upload được serve nguyên kích thước vào grid nhỏ.media.component.tsx:586-593
+F-B5/B6 High Context value là object literal không useMemo: UserContext bọc toàn bộ cây đã đăng nhập → mọi useUser() re-render mỗi lần ContextWrapper render. CalendarContext truyền object 17 key gồm posts: loading ? [] : internalData (mảng mới mỗi lần) → 42 CalendarColumn + Week/Month/ListView re-render, kể cả theo refreshInterval hằng giờ. IntegrationContext map ra mảng object mới mỗi phím bấm trong editor. layout/user.context.tsx:31-38 , launches/calendar.context.tsx:317-341 , high.order.provider.tsx:228-238
+F-B7 Medium memo(CalendarColumn) bị vô hiệu vì call site truyền getDate={newDayjs(date.day).endOf('day')} — object Dayjs mới mỗi render, shallow compare luôn fail → 42 cột memoised vẫn re-render, memo chỉ là overhead. Và useRef(customFetch(...)): argument của useRef evaluate eagerly nên customFetch chạy mỗi render rồi bị vứt, đồng nghĩa params/isSecured của render đầu thắng mãi mãi.calendar.tsx:585-587, 476-479 , helpers/src/utils/custom.fetch.tsx:31-34
+F-B8 Medium Plus_Jakarta_Sans được khởi tạo 2 lần với weight set khác nhau (['600','500'] trên <body>, ['600','500','700'] trên <div> bên trong) → 2 class hash, 2 bộ @font-face, 2 lần tải WOFF2 chồng lấn. Trong khi component dùng font-[400] ở nhiều nơi mà 400 không được load ở instance nào → browser synthesize hoặc fallback.(app)/layout.tsx:28-32 , layout.component.tsx:47-51,100
+F-B9 Medium reactStrictMode: false (tắt chính cơ chế double-render giúp phát hiện hook-order bug — đúng lớp bug mà rule 4 của CLAUDE.md tồn tại để ngăn); productionBrowserSourceMaps: true mâu thuẫn với devtool: 'hidden-source-map'; header Document-Policy: js-profiling trên /:path*; không có optimizePackageImports dù dùng toàn barrel-export package.next.config.js:22, 25, 9-21, 31-33
+F-B10/B11 Medium Hai thư viện DnD cùng sống: react-dnd (calendar/launches) + react-sortablejs (chỉ 1 file media grid) — hai mô hình event, hai bộ hành vi touch/keyboard (cả hai đều không keyboard-accessible). Uppy Dashboard import static ở 3 nơi; emoji-picker-react không lazy cho một popover mở bằng click. calendar.tsx:37 , media.component.tsx:33 , new.uploader.tsx:7 , editor.tsx:16,35
+F-C9 Medium Toàn bộ app shell không render gì cho tới khi /user/self resolve (if (!user) return null;) — không skeleton, không spinner, không <Suspense>. Cộng với export const dynamic = 'force-dynamic' trên cả shell lẫn trang launches → viewport trắng trọn một round-trip mỗi hard navigation. layout.component.tsx:76 , (app)/layout.tsx:3 , (site)/launches/page.tsx:1
+F-A4b Low Keyframe fadeDown khai marginTop: -30 / 10 không đơn vị → invalid CSS (chỉ 0 hợp lệ), bị drop → thành phần slide không bao giờ chạy, chỉ còn fade opacity. fadeDown đang sống: mọi toast dùng nó. newMessages animate fontWeight → reflow text mỗi notification. gridTemplateColumns.13 có ; thừa trong value và 0 lần dùng . tailwind.config.cjs:196-217, 229-241, 118 , toaster.tsx:36
+
+
+4.4 Design system, i18n, dead code
+
+
+F-D1 High Token deprecated --color-custom* vẫn đang được thêm mới bởi commit fork-local
+
+File organization.selector.tsx:147 · impersonate.tsx:992, 997, 1128, 1133
+Vấn đề CLAUDE.md: "All the --color-custom* are deprecated, don't use them." git blame cho thấy đây không phải nợ kế thừa từ upstream :
+06e19ffdc (2026-07-31) <span className="text-customColor18">
+651e5bc10 (2026-07-31) <div className="… border border-customColor6 …">
+f5adddf28 (2026-07-01) admin "Switch User" feature
+git log -G customColor --since=2026-06-01 trả 6 commit, 3 trong đó fork-local. 4f296fc0 "feat: better onboarding" (2026-09-03) cũng chạm dòng customColor.
+Hậu quả 234 kết quả, 109 trong .tsx trên ~34 file. Tệ nhất: developer.component.tsx (21), onboarding.modal.tsx (18), public.component.tsx (11), global.scss (14). Các token này không chỉ là "tên cũ" — vài cái đảo ngữ nghĩa giữa theme : --color-custom30 là #5826c2 (tím) trong .dark nhưng #efefef (xám nhạt) trong .light, và được dùng làm border-customColor30 cạnh bg-customColor29 (pick.platform.component.tsx:281 ) → viền tím-trên-tím ở dark thành tím-trên-xám ở light.
+Fix Thêm rule ESLint no-restricted-syntax cấm /customColor\d+/ (và Stylelint cho .scss); sửa 3 commit fork-local trước, rồi đốt dần theo từng file. Đây là cách duy nhất để deprecation thực sự có hiệu lực.
+
+
+
+
+F-D2 High 142 giá trị hex hardcode, phần lớn trùng token đã tồn tại — brand màu tím có 5 cách viết
+
+File ~40 file trong apps/frontend/src/**/*.tsx
+Vấn đề Tội phạm lớn nhất là #612BD3 — chính là --new-btn-primary và đã expose thành token Tailwind btnPrimary. Nó bị hardcode ở public.component.tsx (×10), developer.component.tsx (×5), media.component.tsx (×4), manage.modal.tsx , delay.component.tsx , tags.component.tsx , time.table.tsx , oauth/authorize/page.tsx (×5), calendar.tsx:852 …
+Tương tự #D82D7E (= token ai), #FC69FF (= newTableTextFocused), #1A1919 (= newBgColorInner). Và các biến thể suýt-soát: #612BD3 / #612bd3 / #612AD5 / #622FF6 / #5520CB / #7640e0 / #7B3FF2 — năm cách viết của cùng một màu brand .
+Hậu quả Đổi màu brand (đúng thứ mà UI/UX rework plan đề xuất) phải sửa 142 giá trị trên ~40 file thay vì một dòng trong colors.scss. Các biến thể khiến trạng thái hover đã không nhất quán. Tất cả đều dark-mode-only nên không thích ứng ở light mode.
+Fix Codemod bg-[#612BD3]→bg-btnPrimary, bg-[#D82D7E]→bg-ai, text-[#FC69FF]→text-newTableTextFocused, bg-[#1A1919]→bg-newBgColorInner; rồi cấm -\[# bằng ESLint trên chuỗi className.
+
+
+
+
+ID Mức Vấn đề File:line
+F-D7 Medium 3 danh sách locale mâu thuẫn nhau : i18n.config.ts (14 ngôn ngữ — nuôi supportedLngs và preload), i18n.json (14, có bn, thiếu ka_ge), thư mục locales/ (16). Kết quả: bn/translation.json (71.937 byte — file locale lớn nhất repo ) được machine-translate mỗi lần chạy nhưng không chọn được ; ka_ge (46.828 byte) không có trong config nào → 119 KB JSON không bao giờ load được. en sửa 2026-09-04, 15 locale khác 2026-08-10; offset dòng xác nhận en đã thêm ~24 key mà không ai khác có → ~1 tháng copy mới chỉ hiện tiếng Anh.translation/i18n.config.ts:2-16 , i18n.json:11-27 , translation/locales/
+F-D8 Medium Key i18n mới chỉ sống trong default của code nên vô hình với pipeline dịch : 11 lời gọi t('agent_media_*', '…') mà grep agent_media trong locales/en/translation.json → 0 kết quả . Signature t(key, fallback) làm key thiếu vô hình lúc runtime, và i18n.json chỉ trỏ vào locales/[locale]/translation.json nên vendor không bao giờ thấy. Feature mới ship vĩnh viễn English-only ở 14 locale, không có tín hiệu nào báo thiếu. layout/agent.media.modal.tsx:34-97
+F-D9 Medium String tiếng Anh hardcode trong code fork-local mới , nơi key dịch đã tồn tại sẵn : <div className="bg-btnPrimary …">Select Organization</div> trong khi locales/en/translation.json:435 đã có "label_select_organization". Kèm 'Super-Admin'/'Admin'/'User' hardcode, và layout.context.tsx truyền tiếng Anh thô vào deleteDialog (trial/payment dialog) — bỏ qua delete.dialog.tsx vốn có chạy default qua i18next.t. Độ phủ i18n là một phần và không nhất quán : component mới viết tốt thì dịch đủ, còn org-switcher và fetch interceptor thì không. organization.selector.tsx:105,151-155 , layout.context.tsx:118-122,131-135 , metric.component.tsx:35
+F-D3 Medium CSS chết và class cargo-cult: .swal2-* còn trong global.scss dù sweetalert2 có 0 import . 10 nút close mang chuỗi mantine-UnstyledButton-root … mantine-Modal-close mantine-1dcetaa — hash Emotion từ Mantine v5 <Modal>, nhưng MantineWrapper không còn render component Mantine nào nên các class đó không tồn tại trong DOM . Hệ quả thật: click.outside.tsx:17,22 querySelector('.mantine-Modal-root') luôn ra null → hook vô tác dụng hoàn toàn ; mention.component.tsx:200 cũng query một class ma. global.scss:76-95 , new-modal.tsx:221 + 9 file khác, helpers/mantine.wrapper.tsx:8-15
+F-D5 Medium File và component chết : wallet.provider.tsx (183 dòng, khởi tạo 23 Solana wallet adapter , là importer duy nhất của @solana/wallet-adapter-* + @postiz/wallets, và giữ eslint-disable react-hooks/exhaustive-deps duy nhất trong codebase) — không ai import . ui/translated-label.tsx (38 dòng) là bản sao byte-for-byte của bản trong react-shared-libraries, 0 importer. click.outside.tsx (26 dòng) 0 importer, useEffect không có dep array. Tính năng timezone ở trạng thái nửa vời: SetTimezone bị comment tại call site duy nhất nên dayjs.tz.setDefault() không bao giờ chạy, <Select> timezone bị comment nhưng vẫn import timezones-list và giữ console.log(value) — localStorage.getItem('timezone') vẫn được đọc/ghi nên user từng đặt timezone sẽ bị bỏ qua âm thầm.auth/providers/wallet.provider.tsx , ui/translated-label.tsx , layout/click.outside.tsx , layout/set.timezone.tsx , settings/metric.component.tsx:18-62 , (app)/layout.tsx:103
+F-D6 Medium 7 nhóm dependency có 0 import trong source (verify bằng grep toàn repo, chỉ khớp trong package.json/pnpm-lock.yaml): @meronex/icons, @pigment-css/react (đây là đường duy nhất dẫn tới critical happy-dom RCE), sweetalert2 + @sweetalert2/theme-dark, @mantine/modals, và chuỗi @solana/*+@postiz/wallets+viem (chỉ reachable từ file chết ở F-D5). lucide-react chỉ được apps/web dùng — 0 lần trong apps/frontend — và lockfile mang 2 version của nó.package.json:72, 85, 238, 97, 67
+F-D12 Medium Ba hệ UI cạnh tranh , phân chia thực tế đã xác minh: form primitive (11 file trong libraries/…/form/) là chuẩn de-facto và dùng nhất quán — phần khoẻ mạnh; modal đã hợp nhất xong về new-modal.tsx (Mantine modals + SweetAlert2 đã chết, chỉ chưa dọn); Mantine v5 sống sót như một túi utility chứ không phải UI kit — useClickOutside (6 file), useInterval, useLocalStorage, cộng đúng 3 component thật : Calendar+TimeInput, Autocomplete, List/Box/Group/Text. Tức @mantine/core+@mantine/dates+@emotion/react (EOL, chưa test React 19) được giữ sống bởi một date picker, một autocomplete và một dropdown ngôn ngữ . components/ui/ không phải design system — nó là barrel SVG inline 800+ dòng cộng 4 file one-off.date.picker.tsx:3-4 , customer.modal.tsx:6 , language.component.tsx:12
+F-D10 Low --new-small-strips: #191818 giống hệt nhau ở cả hai theme → dải gần-đen nằm trên nền light #f0f2f4. (Diff 2 block: cả 42 key --new-* và 56 key --color-* đều có đủ ở cả hai — vấn đề là bypass, không phải thiếu token.) Và TranslatedLabel suy ra key từ copy : label_${label.toLowerCase().replace(/\s+/g,'_').replace(/[^\w]/g,'')} → 'E-mail' và 'Email' cùng về label_email, và mọi lần sửa văn bản âm thầm đổi key, mồ côi hoá 14 bản dịch.colors.scss:22, 73 , translation/translated-label.tsx:29-31
+F-D13 Low 5 console.log trong code ship, gồm console.log(date) ở module render scope (log mỗi lần render preview ngày post, thấy được trong DevTools production). JSX bị comment: analytics.component.tsx:44-108 có năm cặp {/* <img src="https://via.placeholder.com/32x32"/> */} + "See Tweet" — toàn bộ thân tweet-card bị comment nhưng file vẫn đọc như đã implement. preview/render.preview.date.tsx:9 , metric.component.tsx:29 , new.uploader.tsx:205 , pick.platform.component.tsx:145,148 , analytics.component.tsx:44-108
+F-C12 Low modeEmitter.removeAllListeners() gọi không có tên event ở 2 component → cái nào unmount sau sẽ xoá listener theme của cả hai : icons unmount là embedded billing iframe ngừng theo dark/light toggle.ui/icons/index.tsx:781 , billing/embedded.billing.tsx:36
+
+
+4.5 Đánh giá docs/ui-ux-rework-plan.md
+
+F-E Medium Plan UI/UX: đúng hướng về token, nhưng thiếu accessibility, performance, i18n và migration
+
+Plan đề xuất 4 module (Workspace Switcher kiểu DOS ID, Composer Studio split-view kiểu Typefully 55/45, Calendar drag-drop + filter kiểu Publer, Analytics KPI + heatmap) trên nền "Obsidian Dark", cùng 3 luật merge: không sửa apps/orchestrator và integrations/; chỉ theme qua CSS variable trong colors.scss/tailwind.config.cjs; cách ly component Crove ở outermost layout wrapper.
+Đã nhắm đúng F-A7 (đổi workspace qua SWR mutate, không reload); F-D1/F-D2 (luật token chính là rule 2 của CLAUDE.md — luật đúng); F-D9 (role badge là redesign của chính đoạn string hardcode đó). Filter trạng thái post của Module 3 đã tồn tại một phần (calendar.context.tsx:28,154-158 có listState) — plan nên ghi nhận thay vì dựng lại.
+Thiếu
+Accessibility vắng mặt hoàn toàn — không một lần nhắc WCAG/ARIA/keyboard/focus/contrast, trong khi có 2 blocker CRITICAL ở primitive (F-C1, F-C2). Redesign 4 module mà tái dùng primitive cũ sẽ tái tạo nguyên xi chúng. Đây là khoảng trống lớn nhất.
+Không có bundle/perf budget dù đặt mục tiêu "<100ms": F-B1 (không SWRConfig), F-B2/B3 (Uppy/TipTap/CopilotKit eager), F-B4 (0 next/image → CLS), F-B5/B6 (context không memo → 42 cột re-render), F-B8 (font trùng), F-B9 (reactStrictMode:false). Module 2 và 3 sẽ được xây trên F-B6 và F-A1 nếu không sửa trước.
+Không có bước migration token cho 234 reference deprecated + 142 hex hardcode; không nhắc việc commit fork-local vẫn đang thêm token deprecated. Đổi --new-btn-primary sang emerald vẫn để 40+ literal bg-[#612BD3] màu tím.
+Light mode không được nhắc — canvas chỉ là "Obsidian Dark", trong khi F-A3/F-A4 nghĩa là light mode đang vỡ sẵn . Hoặc commit dark-only và xoá .light + mode.component.tsx, hoặc sửa — plan không chọn phía nào.
+i18n không được nhắc — F-D7/F-D8 nghĩa là copy mới cho Module 1-4 sẽ mặc định ship English-only ở 14 locale. Role badge của Module 1 (F-D9) là đúng failure mode đó, đã được chứng minh.
+Không nhắc vấn đề kích thước file — Module 2 và 3 đề xuất viết lại đúng 2 file lớn nhất (editor.tsx 1048, calendar.tsx 1383) mà không có bước phân rã → viết lại tại chỗ đảm bảo ra lại cùng những monolith.
+next.config.js không được đụng tới; Module 4 sẽ cần code-split chart.js (cả hai file chart đều import DrawChart from 'chart.js/auto' — entry auto đăng ký mọi controller/scale/element).
+
+Xung đột
+Palette của §1.2 đã bị hardcode trước khi plan được viết 2 ngày , vi phạm chính §4.2 của nó: onboarding.modal.tsx:747 (commit 4f296fc0, 2026-09-03) dùng from-[#10b981] to-[#059669] — đúng giá trị emerald của plan — làm arbitrary value không có token tương ứng . Brand giờ bị chẻ đôi: emerald ở onboarding, tím #612BD3 ở ~40 file khác.
+border-zinc-800/70 và shadow-emerald-500/25 dựa vào palette mặc định của Tailwind — hoạt động được chỉ vì tailwind.config.cjs:5 dùng theme.extend.colors. Nhưng mọi utility palette mặc định đều theme-invariant , không phản ứng với chuyển .dark/.light — ngược với §4.2. Plan nên cấm tường minh utility palette mặc định, không chỉ "hex lạ trong JSX".
+"Obsidian Dark #08080f" đụng giá trị token hiện có (--new-bgColor và --color-primary đều là #0e0e0e). Chỉ đổi colors.scss sẽ không với tới 142 hex hardcode, gồm bg-[#0E0E0E] ở auth/layout.tsx:22 → cần bước migration token , không chỉ giá trị token mới.
+Popover của Module 1 và split-view của Module 2 ngụ ý component npm mới, mà CLAUDE.md rule 1 cấm. Với F-D12 (Mantine v5 EOL sống nhờ 3 component), câu trả lời nên tường minh: dựng popover native trên primitive form/ + new-modal.tsx sẵn có, và coi Module 1 là cơ hội loại bỏ Mantine chứ không phải thêm dependency headless-UI.
+Module 3 "drag giữa ngày và slot giờ" giả định mô hình thời gian của calendar là lành mạnh — nhưng F-A1 (slot đã qua vẫn drop được), key={index} trên grid 42 ô (calendar.tsx:474 — index-as-key trên grid có nội dung dịch mỗi lần đổi tháng, giữ state showAll/num/useDrop sai ô) và F-B7 phải sửa trước , nếu không hành vi mới kế thừa cả ba bug.
+Tailwind 3 hay 4 chưa được giải quyết : root package.json mang cả @tailwindcss/postcss + @tailwindcss/vite v4 cạnh tailwindcss@3.4.17, và postcss.config.mjs quyết định cái nào chạy. Plan chỉ định tailwind.config.cjs (cấu trúc v3 — v4 là CSS-first qua @theme) làm bề mặt theming mà không ghi nhận sự mơ hồ này.
+§4.3 "chỉ import ở outermost layout wrapper" đã bị vi phạm bởi chính thứ Module 1 thay thế : organization.selector.tsx được import ở layout.component.tsx:39 và render ở :130 — sâu bên trong shell, không phải ở biên wrapper.
+
+
+
+
+
+Verified Frontend — những gì đang làm tốt
+
+Rule 4 của CLAUDE.md được tôn trọng thật sự : 0 eslint-disable trên react-hooks/rules-of-hooks; 0 hàm trả về object chứa các lời gọi useSWR(); 0 useSWR có điều kiện hoặc trong vòng lặp. Toàn bộ ~70 call site đều unconditional và top-level. Các HOC withProvider/withContinueProvider đặt hook đúng vào component được trả về , không phải trong factory. Đây là hạng mục rủi ro cao nhất trong brief và nó sạch.
+Selector zustand đúng nhất quán : cả 15 selector trả object đều bọc useShallow → không có vòng lặp getSnapshot should be cached.
+useFetch + SWR là pattern dữ liệu gần như phổ quát (rule 3): idiom const fetch = useFetch() shadow được dùng đúng, nên ~198 kết quả fetch( là wrapper có auth chứ không phải global fetch thô.
+Polotno được code-split đúng (media.component.tsx:56-58 ) — dependency nặng nhất trong cây đã nằm sau next/dynamic. Chỉ là cách xử lý đó không được mở rộng cho Uppy/TipTap/emoji-picker.
+colors.scss đầy đủ về cấu trúc : cả 42 key --new-* và 56 key --color-* đều có ở cả hai theme. Vấn đề là bypass (F-D2) và vài giá trị bất biến (F-D10), không phải thiếu token.
+Nhánh ticket-consume của proxy.ts:14-61 viết cẩn thận : validate format /^fpt_[a-f0-9]{64}$/, đặt redirect: 'error' và cache: 'no-store', và kiểm cả origin lẫn pathname chính xác của redirect_to trước khi 303 — phòng thủ open-redirect thật sự. Kèm Referrer-Policy: no-referrer.
+params/searchParams async của Next 15+ được xử lý đúng ở mọi nơi xuất hiện (6 route): đều type là Promise<…> và await. Không tìm thấy bug sync-params. Client component dùng useSearchParams() đúng.
+agent.media.modal.tsx là khuôn mẫu cho component mới : dịch đủ 11 chuỗi bằng t(), có state loading thật trên button, finally { setLoading(false) }, dùng token chứ không hex. Nó chứng minh convention hoạt động khi được tuân theo.
+Modal stacking của new-modal.tsx được xây có suy nghĩ : zIndex={200+index}, gate Escape bằng isLast, askClose → DecisionModal cho form bẩn, CurrentModalContext cho closeCurrent(). Nó là thay thế đủ năng lực cho Mantine modals — chỉ cần thêm lớp a11y (F-C2) và tôn trọng option đã khai (F-A2).
+
+
+
+
+5. CI/CD · Hạ tầng · Dependencies
+Ngoài C2, C4, C7, C9, S15, S16 ở trên.
+
+5.1 Bảy nghi vấn ban đầu — kết luận
+
+# Nghi vấn Kết luận Bằng chứng
+1 jest.config.ts dùng @nx/jest mà không có Nx project → pnpm test chạy 0 testCONFIRMED (tệ hơn dự đoán)@nx/jest không phải dependency ở đâu cả; glob nx.json/project.json → 0 file. pnpm test chết ngay lúc load config với MODULE_NOT_FOUND, trước cả khi tới "0 project". jest.config.ts:1 , jest.preset.js:1
+2 Đúng 8 file test; CI chỉ chạy 3 file vitest CONFIRMED Glob trả đúng 8; chỉ deploy-sso.yml:44-46 chạy test; 5 spec jest không workflow nào chạy
+3 Node version lệch giữa engines / volta / @types/node / Docker / CI / Jenkins CONFIRMED >=22.12 <23 vs 20.17.0 vs @types/node 18.16.9; Jenkins + cả 2 workflow extension pin Node 20 ; Jenkins cài pnpm 8 trên lockfileVersion 9; .npmrc không có engine-strict
+4 Tailwind v3 và v4 cùng được cài PARTIAL — dead weight, không phải migration dởCả 2 package v4 chỉ xuất hiện trong package.json; cả 2 app đều wire khoá plugin v3 tailwindcss: {} với config dạng v3; vite config của extension không dùng cái nào
+5 Không có thư mục Prisma migrations; db push --accept-data-loss tới được prod CONFIRMED + mở rộng**/migrations/** → 0 file. Đã verify độc lập phát hiện prod/beta/validator (→ C2). Mở rộng: root docker-compose.yaml chạy image upstream với cùng CMD; MASTRA_DISABLE_STORAGE_INIT xuất hiện trong 0 file .ts
+6 apps/sdk publish @postiz/node; rủi ro publish nhầmCONFIRMED (rủi ro rộng hơn SDK)Không có publishConfig ở đâu trong repo; không có "private": true ở root và ở 5/8 package workspace; không workflow nào gọi publish-sdk
+7 .gitmodules mồ côi → CI clone fail với contributorPARTIAL — tác động REFUTED .gitmodules có khai submodule nhưng git submodule status, git ls-files libraries/plugins và git ls-files -s | findstr 160000 đều rỗng → không có gitlink, nên git bỏ qua file và clone/submodule update --init là no-op im lặng. Tác hại còn lại chỉ là path alias @gitroom/plugins/* chết
+
+
+5.2 Docker & image production
+
+ID Mức Vấn đề File:line
+I-B1 High Không có Dockerfile production. build-containers.yml build ghcr.io/dos/crove-post — image mà prod chạy — từ chính Dockerfile.dev. File đó: tạo user www và chown nhưng không bao giờ ra lệnh USER → nginx master + pm2 + Node backend + frontend + Temporal worker đều chạy root ; pnpm install không --frozen-lockfile → image không tái lập được từ một commit (hai lần build cùng SHA có thể ship cây dependency khác nhau); không HEALTHCHECK; giữ g++ make python3-pip trong runtime stage. Mọi RCE (xem C4: sharp, nodemailer, protobufjs) land ở UID 0 với đầy đủ toolchain và crove-server.env được mount vào.Dockerfile.dev:1-28 , build-containers.yml:106 , scripts/docker-compose.prod.yaml:8
+I-B2 High · ✅ đã sửa 08/09 docker-compose.yaml ở root — file mà docker compose up chọn mặc định — pull ghcr.io/gitroomhq/postiz-app:latest (image upstream, tag mutable), không build từ repo này, với JWT_SECRET là một câu tiếng Anh literal : 'random string that is unique to every install - just type random characters here!'. Chuỗi đó pass được checkNonEmpty('JWT_SECRET') → stack boot vui vẻ với khoá ký JWT công khai trong repo → forge được cookie phiên của bất kỳ user nào. docker-compose.override.yaml:29 có harden việc này, nhưng base file vẫn là khẩu súng đã lên đạn cho ai chạy -f docker-compose.yaml đơn lẻ. Đã sửa: image → ghcr.io/dos/crove-post:latest, JWT → ${JWT_SECRET:?…} fail-fast kèm lệnh generate.docker-compose.yaml:3, 12
+I-B4 High Prod chạy tag :latest mutable, và CI validator ép buộc điều đó : validate-beta-compose.mjs:301 assert cứng image === 'ghcr.io/dos/crove-post:latest' → pin digest hay version tag sẽ làm fail pnpm run validate:beta-deploy (chạy trong build.yml:39-40). Guard đã code hoá anti-pattern . Container restart: always crash-restart sẽ pull build khác với build đang chạy → sự cố biến đổi ngay giữa lúc điều tra. build-containers.yml:112-113 còn đặt --provenance=false --sbom=false → GHCR không mang attestation nào nối image với commit, nên vấn đề rollback cũng không có audit trail. scripts/docker-compose.prod.yaml:8,31,44 , validate-beta-compose.mjs:300-303
+I-B5 Medium Temporal production chạy dynamic config dành cho development : DYNAMIC_CONFIG_FILE_PATH=config/dynamicconfig/development-sql.yaml. Không có file production dynamic config nào trong repo (dynamicconfig/ chỉ có development-cass.yaml và development-sql.yaml). Elasticsearch chạy xpack.security.enabled=false. Config dev thường mang limit dễ dãi (history size, retention, blob size) → giá trị chưa kiểm thử chi phối execution thật; và file development-* là thứ upstream đổi đầu tiên → một merge từ sync-upstream.yml có thể âm thầm đổi hành vi Temporal production mà không qua review. scripts/docker-compose.prod.yaml:101, 152-160
+I-B6 Medium cloudflared prod mount thư mục không tồn tại : volumes: - ./tunnel:/etc/cloudflared:ro nhưng scripts/tunnel/ không có (file được commit là scripts/tunnel-config.yml, không mount ở path mà command trông đợi). Docker sẽ tự tạo thư mục rỗng → /etc/cloudflared/config.yml vắng → cloudflared thoát ngay, loop dưới restart: always. Kèm: crove-post không có healthcheck trong khi cloudflared depends_on nó không điều kiện; và main.ts:82-84 bọc app.listen trong try/catch chỉ log → backend fail bind vẫn để pm2 báo online, container "healthy", nginx trả 502 vô hạn.scripts/docker-compose.prod.yaml:45-51, 7-28 , main.ts:82-84
+
+Ghi chú về nghi vấn override: docker-compose.override.yaml không làm rò rỉ cấu hình dev vào prod. Compose resolve file override theo project directory — là thư mục cha của file -f đầu tiên — nên docker compose -f scripts/docker-compose.prod.yaml sẽ tìm scripts/docker-compose.override.yaml, vốn không tồn tại. Thực ra override là file được harden tốt nhất trong repo (port bind loopback, JWT secret bắt buộc, healthcheck thật, cloudflared pin 2026.7.3 + --no-autoupdate, token mount read-only). Vấn đề ngược lại: phần harden đó chỉ áp dụng cho dev stack ở root.
+
+5.3 CI/CD
+
+ID Mức Vấn đề File:line
+I-C1 High Chi tiết đầy đủ ở C9 . Bổ sung: root eslint.config.mjs là flat config dùng FlatCompat từ @eslint/eslintrc — package không được khai báo ở đâu; package.json:293 pin eslint 8.57.0 (EOL từ 10/2024 ) chỉ tự nhận eslint.config.js, không phải .mjs; .eslintignore:1 chỉ chứa node_modules — file mà flat config bỏ qua hoàn toàn. Root config còn áp next/core-web-vitals cho cả NestJS backend và Cloudflare Worker. eslint.config.mjs:1-28 , package.json:293 , .eslintignore:1
+I-C2 High Không test step nào gate deploy ứng dụng, và SSO production deploy không có environment protection : build.yml chỉ chạy validate:beta-deploy + pnpm run build — không test, không lint, không typecheck, không branding guard. deploy-sso.yml:22-24 deploy production mỗi lần push main chạm apps/crove-sso/** mà không có block environment: → không approval gate, không protection rule, không deployment record. 5/12 workflow không có block permissions: (build.yml, branding-guard.yml, build-extension.yaml, publish-extension.yml, deploy-sso.yml) — và deploy-sso.yml là cái đang giữ CLOUDFLARE_API_TOKEN. Mọi third-party action pin theo mutable tag (@v4, @v3, @v5.0.0) thay vì commit SHA. build.yml:36-42 , build-containers.yml:83-88 , deploy-sso.yml:22-24, 53-77
+I-C3 High Extension publish được build bởi sai Vite config và trỏ về postiz.com. build.mjs:21 gọi build() không truyền config → Vite load vite.config.ts mặc định (chỉ emit background.js, minify: false), bỏ qua toàn bộ pipeline crxjs ở vite.config.chrome.ts (thứ tiêu thụ baseManifest: override BRAND_NAME/BRAND_DESCRIPTION, host_permissions tính từ FRONTEND_URL, providers.map(p => p.hostPermission), stripDevIcons, crxI18n) — config đó chỉ được script dev dùng. Zip upload lên Chrome Web Store vì vậy không minify (lộ source + nặng), không có host_permissions được tính toán , bỏ qua BRAND_*. Cả 2 workflow build với FRONTEND_URL=https://platform.postiz.com — domain upstream . Và manifest.json:29-36 khai externally_connectable.matches gồm "http://localhost/*" và "https://*.postiz.com/*" cạnh *.crove.com/*.crove.io/*.dos.me — với quyền cookies (:11), postiz.com và bất kỳ tiến trình local nào cũng message được extension để xin cookie nền tảng đã xác thực . build.mjs:47-49 bắt lỗi zip bằng console.warn rồi vẫn in "✅ Extension build completed successfully".apps/extension/package.json:6 , build.mjs:21,24-27,47 , vite.config.ts:1-27 , vite.config.chrome.ts:10-28 , manifest.json:11,29-36 , publish-extension.yml:23
+I-C4 Medium branding-guard.ts không scan repository — nó import getBrandConfig, sanitizeUrl, sanitizeHexColor, applyBrandToString và assert hành vi của chúng trong bộ nhớ . Không mở một file source nào, không grep, không đi cây thư mục. Về cấu trúc nó không thể bắt bất kỳ leak branding thật nào đang tồn tại: ghcr.io/gitroomhq/postiz-app:latest, FRONTEND_URL=https://platform.postiz.com (2 workflow), display_name: "Postiz", author: "Nevo David", sonar.projectName=Postiz App, name: "Postiz Dev Container", name: `Postiz ${capitalize(appName)}` trong Sentry context, và issue-label-triggers.yml:24 bảo user "contact Nevo David". Trigger đúng (PR + push main/dev) nhưng gate chỉ mang tính trang trí. Phụ: script được gọi bằng 3 cách khác nhau (npx tsx, pnpm dlx tsx, pnpm exec tsx), hai cách đầu fetch tsx từ network mỗi lần chạy.scripts/branding-guard.ts:8-14, 31-33, 96-102 , build-containers.yml:86-88
+I-C5 Medium build-all.ps1 check $LASTEXITCODE sau bước 1 và 2 nhưng không sau prisma-generate (:33) và build (:37) — rồi in vô điều kiện ALL APPLICATIONS BUILT SUCCESSFULLY!. $ErrorActionPreference = "Stop" không cứu được: trên Windows PowerShell 5.1, exit code khác 0 từ native command không phải terminating error (cần PS 7.4+ và $PSNativeCommandUseErrorActionPreference). Tức hai bước quan trọng nhất có thể fail trong im lặng trong khi operator được báo mọi thứ đã pass. dev.ps1:35 filter @crove/frontend nhưng package tên postiz-frontend ; :39 filter @crove/sso run dev nhưng package không có script dev → cả hai mode fail ngay với ai làm theo ví dụ trong chính file. (deploy-beta.ps1/deploy-prod.ps1 thì kỷ luật exit-code đúng — vấn đề khu trú ở build-all.ps1.)scripts/build-all.ps1:17,33,37,40 , scripts/dev.ps1:35,39
+I-C6 Medium Automation kế thừa từ upstream đã chết, mâu thuẫn, hoặc trỏ sai tenant. (a) staging-conflicts.yml:26 guard if: github.repository == 'gitroomhq/postiz-app' trong khi origin là DOS/Crove-Post → không bao giờ chạy . May, vì thiết kế của nó cấp cho một LLM (anthropics/claude-code-action@v1) SSH deploy key write-enabled chạy cron 10 phút và cho phép push merge commit vào staging — chỉ một lần sửa guard là kích hoạt vòng lặp autonomous đó. (b) sonar-project.properties:1 là sonar.projectKey=gitroomhq_postiz-app_bd4cd369-… → Jenkins sẽ đẩy phân tích của fork này vào SonarQube project của upstream . Jenkins còn curl | sudo -E bash - script NodeSource, cài pnpm@8, tải Sonar Scanner 4.7.0.2747 (2022) không checksum , tất cả trên agent any với sudo không mật khẩu. (c) issue-label-triggers.yml:24 tự đóng issue và bảo người báo lỗi liên hệ "Nevo David" về "the open source Postiz project". (d) sync-upstream.yml:8-13 khai input auto_merge mà không step nào đọc → mời operator tick một hộp không làm gì.staging-conflicts.yml:26,41-52,105 , sonar-project.properties:1-3 , Jenkins/Build.Jenkinsfile:26,31,57-59 , issue-label-triggers.yml:14-27 , sync-upstream.yml:8-13
+
+
+5.4 Dependencies & supply chain
+
+ID Mức Vấn đề File:line
+I-D3 High onlyBuiltDependencies: ["bcrypt"] chặn build native, và postinstall tải lại Prisma từ network mỗi lần install. Với pnpm 10, script install/postinstall của dependency bị chặn trừ khi được liệt kê. Hệ quả theo package: bcrypt@5.1.1 — được phép , nên @mapbox/node-pre-gyp chạy và giải nén tarball tải về bằng chính tar dính lỗ hổng (C4); canvas@2.11.2 — bị chặn , không có binary .node nên require('canvas') throw (grep thấy 0 import trực tiếp → rủi ro là require bắc cầu từ polotno/image-to-pdf fail lúc runtime trên đường không CI nào chạy); bufferutil/utf-8-validate — bị chặn, ws rơi về JS fallback (thoái hoá hiệu năng); sharp — không bị ảnh hưởng (≥0.33 dùng optionalDependencies prebuilt, không cần install script); esbuild — postinstall bị chặn, là điểm dễ vỡ đầu tiên khi lệch kiến trúc; @swc/core — không ảnh hưởng. Vì postinstall của @prisma/client bị chặn, repo workaround bằng root postinstall → prisma-generate → pnpm dlx prisma@6.5.0 generate , tức tải prisma + engine binary theo nền tảng từ registry mỗi lần install , bỏ qua prisma: 6.5.0 đã khai ở :311. Client vì vậy được sinh bởi binary không nằm trong lockfile và không được integrity-check ; mọi pnpm install trong CI đều fetch network không pin → outage registry/CDN là fail mọi build; không thể cài offline.package.json:36,38,40,43,311,336-338 , .npmrc:1-5
+I-D4 High Root-only package.json + node-linker=hoisted = không có ranh giới dependency nào. Grep "dependencies" trong apps/*/package.json chỉ khớp apps/sdk và apps/crove-sso (devDeps). backend, frontend, orchestrator, web, extension, commands khai 0 dependency — resolve ~250 package runtime từ manifest gốc qua node_modules hoisted. (Nghi vấn về pnpm.overrides xung đột với range của app: REFUTED — vì không app nào khai range riêng cho next/react, không thể xung đột.) Nhưng override vẫn ép react@19.2.4 và next@16.3.1 lên mọi consumer bắc cầu, gồm @mantine/* v5 (EOL, Emotion, chưa từng test trên React 19), @pigment-css/react, @copilotkit/* (pin 1.10.6), @neynar/react.Hệ quả: (a) Không có biên giới server/client — với hoisting, apps/frontend import 'stripe' hay bcrypt là bundler resolve được; thứ duy nhất ngăn module chứa secret lọt vào client bundle là sự chú ý của reviewer, không phải package manager. (b) deploy-sso.yml:41-43 chạy pnpm install với working-directory: apps/crove-sso → đi ngược lên và cài cả monorepo (~250 runtime + 60 dev package, cộng lượt tải Prisma ở I-D3) để deploy một Cloudflare Worker mà manifest của nó khai 5 devDeps. (c) Xung đột license : repo là AGPL-3.0 nhưng 4 app khai "license": "ISC" — mâu thuẫn với cùng một tác phẩm AGPL, và là mìn compliance trong một fork AGPL của người khác. (d) Khả năng publish : root và 5 app thiếu "private": true → một lệnh pnpm -r publish trần sẽ cố đẩy gitroom@1.0.0, postiz-backend, postiz-frontend, postiz-orchestrator, postiz-command, postiz-extension, @postiz/node lên npm công khai. Chỉ apps/web và apps/crove-sso được bảo vệ.package.json , .npmrc:2 , apps/backend/package.json:13 , apps/frontend/package.json:17 , apps/orchestrator/package.json:13 , apps/commands/package.json:12 , deploy-sso.yml:41-43
+I-D5 Medium apps/sdk publish code AGPL mang thương hiệu upstream lên npm công khai, không rào chắn : tên "@postiz/node", author: "Nevo David", script "publish": "tsup && pnpm publish --access public", không publishConfig, không --provenance, không pin registry. Root package.json:17 expose nó thành pnpm publish-sdk. Không workflow nào gọi, và nó là script run chứ không phải lifecycle hook nên không tự fire khi install — nhưng một lệnh gõ sai là publish một package AGPL do Crove build dưới npm scope của gitroomhq. Cùng loại: chatgpt-app-submission.json:5 display_name: "Postiz" với test case tham chiếu https://platform.postiz.com/auth/*.png — một bản nộp OpenAI App Store dưới danh tính upstream. Không workflow nào nộp nó, nhưng cũng là súng đã lên đạn.apps/sdk/package.json:2,7,18,19 , package.json:17 , chatgpt-app-submission.json:5
+I-D6 Medium Node 22 runtime nhưng target es2015 : tsconfig.base.json:16 khiến async/await bị hạ xuống generator — giảm throughput, và quan trọng hơn cho việc debug C2/E2 là làm sập stack frame . :22-25 bật strict: true rồi tắt strictNullChecks, strictPropertyInitialization, strictBindCallApply — tức phần lớn giá trị của strict bị vô hiệu. :32 vẫn map "@gitroom/plugins/*" tới thư mục không tồn tại. Hệ quả cụ thể của việc lệch Node: custom.upload.validation.ts:6 dùng require('file-type') trong khi file-type@^22 là ESM-only — chỉ hoạt động nhờ require(esm) của Node ≥22.12, và sẽ throw trên Node 20 mà CI/Jenkins đang pin.tsconfig.base.json:16, 22-25, 32 , upload/custom.upload.validation.ts:6
+
+
+5.5 Observability, vận hành, docs drift
+
+ID Mức Vấn đề File:line
+I-E2 Medium Health endpoint là stub liveness và không gì probe chúng. /health của backend trả hardcode { status:'ok', timestamp, service:'crove-post' } — không chạm Postgres, Redis hay Temporal . /health/status của orchestrator thì tốt hơn (mở Connection.connect() thật, race describeNamespace với timeout 10s, trả 500 khi fail) nhưng chỉ kiểm Temporal, và port 3002 không qua được nginx (chỉ proxy 3000 và 4200). Grep xác nhận không compose healthcheck và không Railway probe nào nhắm vào cả hai : docker-compose.prod.yaml không có healthcheck: trên crove-post/crove-web; railway.toml chỉ có [phases.setup]. Healthcheck thật duy nhất trong repo (docker-compose.override.yaml:30-38) lại fetch http://localhost:5000/auth — trang login của frontend , không phải health endpoint. Kết hợp I-B6 và main.ts:82-84 nuốt lỗi listen → docker compose ps báo healthy trong khi Postgres/Redis/Temporal chết: đúng failure mode của sự cố 2026-08-12.api/routes/root.controller.ts:9-16 , orchestrator/src/health.controller.ts:8-33 , scripts/docker-compose.prod.yaml:7-28 , railway.toml:1-3
+I-E3 Medium docs/cicd.md và docs/beta-environment.md mô tả một pipeline không tồn tại — 10 mismatch cụ thể. (1) cicd.md:12 document workflow build-deploy-crove.yml — file không tồn tại . (2) :25 claim push dev chạy "Branding Guard & Test suites" — không có test nào. (3) :34-38 claim merge main / release sẽ "Updates Production environment: post.crove.com" — không workflow nào deploy app ; deploy-prod.ps1:53-60 chỉ in ra lệnh docker compose … up -d như hướng dẫn → cập nhật prod hoàn toàn thủ công. (4) :46 document GCP_SSH_KEY "(for automated push deployment)" — không workflow nào tham chiếu. (5) :41 nói CLOUDFLARE_API_TOKEN có thể "update DNS" — không workflow nào làm DNS. (6) Bảng liệt kê 5 workflow nhưng 7/12 workflow thật không được document , và gate thật duy nhất (validate:beta-deploy) không được nhắc ở đâu. (7) beta-environment.md:47-53 document service crove-web-beta và :103 route beta.crove.com vào nó — compose beta không có service đó → route tunnel (tunnel-config.yml:66-69) chết, beta.crove.com không resolve vào container nào. (8) :38-39,57-64 nêu tên volume sai so với file thật (crove-post-beta-config vs postiz-beta-config…) mà validate-beta-compose.mjs:243-256 assert cứng theo tên thật → làm theo docs là fail CI. (9) upstream-sync.md:26 nói branding-guard trigger trên main hoặc master ; thật là main hoặc dev — không có nhánh master. (10) .env.example:170-176 document IdP là POSTIZ_OAUTH_URL="https://api.dos.me" trong khi thứ thực sự deploy (wrangler.jsonc:32-34,66-68) trỏ vào 2 project *.supabase.co; beta-environment.md:14 lại nói Supabase — config tham chiếu và thực tế triển khai bất đồng về identity provider .docs/cicd.md:12,25,29-38,41,46 , docs/beta-environment.md:14,36-39,47-53,57-64,83-105 , docs/upstream-sync.md:26 , scripts/tunnel-config.yml:66-69
+I-E4 Medium Sáu target deploy chồng lấn, năm cái chết hoặc không thể chạy như đã commit. (a) docker-build.sh:5-6 chạy docker build --target dist và --target devcontainer trên Dockerfile.dev vốn chỉ có một stage không tên → cả hai fail ngay; tức pnpm run docker-build và docker-create đều hỏng. (b) .devcontainer/devcontainer.json:2 đặt "image": "localhost/postiz-devcontainer" — chính là image mà (a) đáng lẽ phải tạo; :9 "forwardPorts": ["4200:4200","3000:3000"] dùng cú pháp host:container mà forwardPorts không chấp nhận; :10 mount source=/apps — đường dẫn gốc host tuyệt đối; và .dockerignore:14 loại trừ .devcontainer khỏi chính build meant để tạo image của nó. (c) railway.toml chỉ có [phases.setup] nixPkgs = ['nodejs','python3'] — Node không pin (nixpkgs có thể cấp 22 hoặc 24, mâu thuẫn engines), không [deploy], không startCommand, không healthcheck; libudev-dev gợi ý dependency serial/USB không có trong package.json. (d) scripts/nginx-crove.conf chỉ listen port 80 — không TLS, không redirect HTTPS, không HSTS, không security header — cho phép client_max_body_size 250M trong khi nginx trong container cho 2G , và không được compose/script/workflow nào tham chiếu. (e) Jenkins — sai tenant, sai Node, sai pnpm. Pipeline có thẩm quyền thực tế theo docs + script: GitHub Actions build-containers.yml → GHCR, rồi người SSH tay vào GCP VM chạy docker compose -f scripts/docker-compose.{prod,beta}.yaml up -d; deploy-sso.yml/deploy-sso.ps1 → Cloudflare Workers; vercel.json → apps/web — nhưng docker-compose.prod.yaml:29-42 cũng chạy crove-web như một container, nên landing page có hai đường deploy cạnh tranh . Mỗi artifact chết là một nơi operator tương lai sẽ tự tin làm sai.var/docker/docker-build.sh:5-6 , .devcontainer/devcontainer.json:2,9,10 , railway.toml:1-3 , scripts/nginx-crove.conf:1-20 , Jenkins/ , vercel.json , scripts/docker-compose.prod.yaml:29-42
+I-E6 Medium var/docker/nginx.conf:41-49 forward các header do client cung cấp — Impersonate, Auth, Reload, Onboarding, Showorg — tới backend mà không strip , và main.ts:29-42 liệt kê auth/showorg/impersonate trong allowedHeaders. Backend có check super-admin server-side (đã xác minh ở mục 2) nên hiện chưa khai thác được — nêu ra vì edge proxy là đúng chỗ để strip header đặc quyền một cách vô điều kiện , không nên dựa vào tầng application. scripts/nginx-crove.conf thì không forward chúng: hai file nginx mâu thuẫn nhau.var/docker/nginx.conf:41-49 , main.ts:29-42
+I-E7 Low · ✅ đã sửa 08/09 .cloudflared/ chỉ được ignore qua .git/info/exclude:10 (verify bằng git check-ignore -v), không phải .gitignore. .git/info/exclude là local của riêng clone này → contributor khác tạo .cloudflared/crove-dashboard.token (thứ mà docker-compose.override.yaml:76-80 bind-mount) sẽ không có bảo vệ ignore nào và có thể commit Cloudflare tunnel token thật. Đối chiếu scripts/*.env thì được .gitignore:68 phủ đúng. Đã sửa: thêm .cloudflared/ vào .gitignore..git/info/exclude:10 , docker-compose.override.yaml:76-80
+
+
+
+Verified Hạ tầng — những gì đang làm tốt
+
+Không có pull_request_target trong cả 12 workflow — vector "chạy code fork với secret" thực sự vắng mặt. Không secret nào bị echo hay ghi ra disk. Không self-hosted runner.
+docker-compose.override.yaml:1-24 và :52-61 là tài liệu vận hành xuất sắc : post-mortem có ngày tháng, cụ thể, giải thích tại sao bắt buộc restart: always thay vì unless-stopped, gồm sự cố 10 tiếng ngày 2026-08-12 và failure mode "pm2 báo online trong khi process chết". Tri thức tổ chức được ghi đúng chỗ sẽ được đọc.
+Override còn harden đúng: port bind loopback (127.0.0.1:4007, :8969, :7233, :8080), guard secret bắt buộc (JWT_SECRET: "${CROVE_POSTIZ_JWT_SECRET:?…}"), cloudflared pin 2026.7.3 + --no-autoupdate, token mount read-only, healthcheck thật.
+scripts/validate-beta-compose.mjs là một ý tưởng thực sự tốt — 390 dòng contract thực thi được trên compose file, chạy cả trong build.yml:39-40 lẫn deploy-beta.ps1:51-56, có guard regex chống tag injection vào CROVE_POST_BETA_IMAGE (:376-384). Chỉ là assertion của nó đang chĩa sai invariant ở hai chỗ (C2, I-B4).
+scripts/docker-compose.beta.yaml:12-14 — override command: tường minh kèm comment "Never introspect/drop dynamic Mastra tables or run db push --accept-data-loss at app startup" cho thấy có người đã hiểu chính xác mối nguy. Chỉ là chưa bao giờ được áp cho prod.
+deploy-beta.ps1 / deploy-prod.ps1 kỷ luật tốt : $ErrorActionPreference = "Stop", check $LASTEXITCODE sau mọi bước, Write-Error + exit 1 khi fail, không hardcode credential, không SSH, không StrictHostKeyChecking=no, không Invoke-Expression. deploy-sso.ps1:60-77 pipe secret vào wrangler secret put qua stdin thay vì argument dòng lệnh.
+.dockerignore kỹ lưỡng : .env*, **/*.env, .secrets, **/.git, .github, tests, reports, node_modules — token và env thật sự không vào image.
+var/docker/nginx.conf có vệ sinh proxy trên mức trung bình : log format safe_request cố ý bỏ query string, referrer, cookie và auth header; access_log off + error_log /dev/null trên /oauth/authorize; CSP khoá chặt kèm sandbox trên /uploads/.
+scripts/test-integration.ps1 thiết kế tốt : container dùng một lần trên port cao bind 127.0.0.1, poll readiness, teardown trong block finally, và đúng đắn bypass root jest config hỏng bằng --config tests/bootstrap.jest.cjs. Nó chỉ là không được wire vào CI.
+apps/crove-sso là package khoẻ nhất repo : "private": true, có script typecheck riêng, 3 file vitest với @cloudflare/vitest-pool-workers + Miniflare binding, logging cấu trúc duy nhất trong repo (src/index.ts:213 — console.log(JSON.stringify(logEntry))), và deploy có test-gate duy nhất.
+
+
+
+
+6. Đánh giá tổng thể về kiến trúc
+Các mục "Đã làm đúng" chi tiết nằm ở cuối mục 2, 4 và 5. Đây là nhận định tổng hợp.
+
+
+Nhận định Đây không phải codebase yếu — đây là codebase có kỷ luật không đều
+Bằng chứng quan trọng nhất: ở hầu hết mọi hạng mục, repo đều chứa ít nhất một implementation đúng chuẩn . Vấn đề không phải thiếu kiến thức mà là kiến thức không được áp dụng nhất quán, và không có gate nào bắt buộc áp dụng.
+
+Hạng mục Bản đúng (đã tồn tại trong repo) Bản sai
+Sinh số ngẫu nhiên mật mã crove-sso/src/oauth.ts:randomToken() — crypto.getRandomValues(32); kick/whop/vk provider dùng randomBytes(64); bootstrap.service.tsmakeId() — Math.random(), và nó là hàm sinh mọi token OAuth (C1)
+Tool allowlist cho provider dispatch public.integrations.controller.ts:560-590 — kiểm tra getAllTools() đầy đủintegrations.controller.ts:334-397 — bỏ trống hoàn toàn (C3)
+Sửa workflow Temporal đang chạy streak.workflow.ts — bọc patched('reminder') đúng chuẩnv1.0.1/v1.0.4/v1.0.6 — sửa in-place (C10)
+Chặn db push lúc khởi động docker-compose.beta.yaml:12-14 — override kèm comment giải thích chính xác mối nguydocker-compose.prod.yaml — không override, và CI ép không được override (C2)
+Multi-statement write users.repository.ts:53 — $transaction([...]) với thứ tự park-then-fill có document4 chuỗi write khác non-atomic (B8)
+Chống double-post post.workflow.v1.1.2.ts — maximumAttempts:1 trên mutation bất khả hồi kèm comment giải thích, flag posted/updated, resolvePending commit trước finalizePostđường start workflow thì fire-and-forget + nuốt lỗi (B-D6)
+Hardening compose docker-compose.override.yaml — loopback, secret bắt buộc, healthcheck, pin version, post-mortem có ngày thángdocker-compose.dev.yaml — bind 0.0.0.0, và dev:docker không load override (S16)
+Contract test cho hạ tầng validate-beta-compose.mjs — 390 dòng, chạy trong CI lẫn deploy script2 assertion đang chĩa sai invariant; branding-guard.ts thì không scan repo (I-C4)
+Package có ranh giới apps/crove-sso — "private":true, manifest riêng, script typecheck, 3 test, deploy có test-gate, structured logging6 app còn lại khai 0 dependency, resolve qua root manifest hoisted (I-D4)
+Component frontend mới agent.media.modal.tsx — dịch đủ 11 chuỗi, state loading thật, finally, dùng tokenorganization.selector.tsx — hardcode English dù key dịch đã có sẵn (F-D9)
+Rules-of-hooks toàn bộ ~70 call site SWR — 0 eslint-disable, 0 hook có điều kiện— (hạng mục này sạch hoàn toàn)
+
+Hàm ý cho thứ tự khắc phục: phần lớn fix không cần thiết kế mới — chỉ cần copy bản đúng đã tồn tại sang bản sai . C1, C3, C10, B8, C2 đều thuộc dạng này, và đó là lý do chúng đứng đầu lộ trình ở mục 7: rủi ro thấp, không cần quyết định kiến trúc nào.
+
+
+
+G1 Medium Nguyên nhân gốc: không có gate nào trong CI thực sự chạy
+Test gate, lint gate, typecheck gate đều tồn tại trên danh nghĩa nhưng cả ba đều hỏng theo cách im lặng (C9). Branding gate thì chạy nhưng không scan repo (I-C4). Compose contract gate chạy tốt nhưng assert sai invariant (C2, I-B4).
+Kết quả là mọi quy tắc trong CLAUDE.md — 3 tầng, không raw SQL, code generic, workflow bất biến, không token deprecated — chỉ được thực thi bằng sự chú ý của reviewer . Những quy tắc nào dễ kiểm tra bằng mắt thì được tôn trọng tuyệt đối (không raw SQL: 0 vi phạm; rules-of-hooks: 0 vi phạm). Những quy tắc nào cần công cụ để bắt thì đang bị vi phạm và tích luỹ (workflow bất biến: 3 file; token deprecated: 234 lần và đang tăng; provider-specific trong file generic: 4 chỗ).
+Đây là đòn bẩy lớn nhất trong toàn bộ báo cáo : sửa C9 không tự nó vá lỗ hổng nào, nhưng nó là điều kiện để mọi fix khác không bị hồi quy . Nên làm trước hoặc song song với các fix khác, không phải sau.
+
+
+
+
+7. Lộ trình khắc phục đề xuất — cập nhật sau phiên 08/09
+
+
+✅ Đã hoàn tất phiên 08/09 (7 finding — chi tiết ở mục 0): C5 · S4 · S15 (đánh giá lại: xoá crove-postgres dead weight khỏi prod + beta) · S16 · I-B2 · I-E7 · C2 phần 1 (override + validator đảo chiều). Validator PASS. Prod VM đã dọn: 2 zombie postgres container, 3 volume chết, 1 container exited 8 ngày. Còn nợ từ S15: set CROVE_TEMPORAL_POSTGRES_PASSWORD trên VM + xoá 2 IP stale khỏi docs/nginx conf.
+
+
+Giai đoạn 1 — Ngay lập tức (giờ → vài ngày)
+Rủi ro đang tồn tại hoặc mất mát dữ liệu. Hầu hết là thay đổi nhỏ, không cần quyết định kiến trúc.
+
+C1 — makeId → crypto.randomBytes. Một file ~10 dòng. Kèm rotate mọi pos_/pcs_/authorization code đang sống.
+C2 (còn lại) — dựng pg_dump cron → R2 và diễn tập restore . (Phần command override đã xong ngày 08/09.)
+C7 · S15 (dư) — move DOWNSTREAM_CLIENT_SECRET khỏi vars: + rotate; set CROVE_TEMPORAL_POSTGRES_PASSWORD trên VM, xoá 2 IP thật khỏi docs và nginx conf.
+C3 · C8 · S6 — thêm allowlist check vào /integrations/function; validate name trong saveMedia; thêm org + credit check vào /media/video/function.
+C4 — pnpm.overrides cho 8 package + xoá @pigment-css/react (một dòng, hết 1 critical RCE) + bump sharp/nodemailer/vitest.
+S16 (dư) — bỏ hẳn mapping Temporal UI và pgAdmin khỏi dev compose (phần bind loopback đã xong ngày 08/09).
+
+
+Giai đoạn 2 — Tuần 1–2 (đường publish + quality gate)
+
+C6 — đang chờ upstream merge (fix/refresh-transient-errors); nếu cần gấp thì cherry-pick có kiểm soát. Đồng thời vẫn cần tự thêm lock Redis + set inBetweenSteps trước khi gọi provider (upstream fix chưa cover phần idempotency).
+B-D6 · B-D4 — await startWorkflow và surface lỗi; log + Sentry cho infinite.workflow.register.ts; sửa thiếu await ở autopost.service.ts:110.
+B6 · D7 — @Min(1) @Max(365) cho inter; bound week/year; cap findTime và findFreeDateTimeRecursive.
+C9 — sửa eslint.yml (bỏ --config, bỏ || true, bỏ continue-on-error); thêm script typecheck; thêm job test chạy tests/bootstrap.jest.cjs; gate build-containers.yml bằng needs:; thêm pnpm audit --prod --audit-level=high.
+S1 · S4 · S10 · S11 — expiresIn + pin algorithms cho JWT; secret chuyên dụng cho dos-org-sync + timestamp/nonce; gate Swagger theo NODE_ENV; ConfigurationChecker chạy trước listen và exit(1) ở prod.
+S2 · S3 — mã hoá Integration.token/refreshToken, nhưng chuyển oauth.service.ts:299 sang decrypt-then-compare trước .
+F-C1 · F-C2 · F-C3 · F-A2 — sửa 2 primitive a11y + thêm <label htmlFor>/useId + tôn trọng closeOnEscape/closeOnClickOutside. Làm trước khi bật jsx-a11y ở mức error, vì 4 fix này xoá phần lớn violation.
+D3 — bỏ catch {} ở finishTrial (đang gây mất doanh thu âm thầm).
+
+
+Giai đoạn 3 — Tháng 1 (nền tảng)
+
+C2 tiếp — baseline Prisma migrations/, chuyển deploy sang migrate deploy như bước riêng có review.
+B5 · B4 · B8 — thêm index thiếu + onDelete; bọc 4 chuỗi multi-write vào $transaction.
+B7 · B9 · B13 — khử N+1 trên đường publish; thêm take/select cho query không giới hạn; tắt Prisma query logging ở prod.
+I-B1 · I-B4 — Dockerfile production thật (multi-stage, --frozen-lockfile, USER www, HEALTHCHECK); pin image prod thay :latest và sửa validator tương ứng.
+I-E2 — health endpoint kiểm tra DB/Redis/Temporal thật; thêm healthcheck vào compose prod.
+C10 — CI guard cho bất biến Temporal: fail nếu diff chạm apps/orchestrator/src/workflows/ đã có trên origin/main mà không thêm patched().
+F-B1 → F-B6 — <SWRConfig> toàn cục; dynamic() cho ShowMediaBoxModal/AddEditModal/EmojiPicker/CopilotKit; useMemo cho 3 context value; next/image cho media grid.
+F-D1 · F-D2 — codemod 142 hex → token; rule ESLint cấm /customColor\d+/ và -\[#; sửa 3 commit fork-local trước.
+I-D4 — thêm "private": true cho root + 5 app; đổi 4 chỗ "license": "ISC" → "AGPL-3.0"; bắt đầu tách dependency per-app từ apps/crove-sso (đã isolated sẵn).
+Dọn nợ rẻ — xoá 7 nhóm dependency 0 import, wallet.provider.tsx (183 dòng) + @solana/*/viem, ui/translated-label.tsx, click.outside.tsx, Jenkins/, sonar-project.properties, railway.toml, var/docker/docker-*.sh, scripts/nginx-crove.conf, 5 model Prisma legacy (E1), mastodon.custom.provider.ts (E2).
+I-E3 — viết lại docs/cicd.md + docs/beta-environment.md, sinh ra từ compose file thật thay vì chép tay (10 mismatch).
+F-D7 · F-D8 — thống nhất 3 danh sách locale; thêm bước extraction key từ code vào en/translation.json trong CI.
+
+
+Ghi chú cho UI/UX rework (docs/ui-ux-rework-plan.md ): bổ sung 4 mục trước khi bắt đầu Module 1–4 — (1) một module accessibility, vì plan hiện không nhắc WCAG/ARIA/keyboard/focus lần nào và sẽ tái tạo nguyên xi F-C1/F-C2 nếu dùng lại primitive cũ; (2) bundle/perf budget, vì mục tiêu "<100ms" bị gate bởi F-B1→F-B6; (3) bước migration token cho 234 reference deprecated + 142 hex hardcode, nếu không đổi --new-btn-primary vẫn để 40+ literal màu tím; (4) quyết định dứt khoát về light mode — hoặc fix F-A3/F-A4, hoặc xoá .light + mode.component.tsx. Đồng thời resolve onboarding.modal.tsx:747 đang hardcode palette emerald không token (brand đang bị chẻ đôi), và xác nhận Tailwind 3 hay 4 thực sự có hiệu lực trước khi viết token mới vào tailwind.config.cjs.
+
+
+
+8. Giới hạn của audit này
+
+Chỉ là static analysis — không build, không chạy app, không truy cập DB/Temporal. node_modules chưa cài nên không đọc được nội bộ thư viện; mọi kết luận về hành vi của package bên thứ ba đều được ghi rõ là chưa xác minh.
+Không xác minh được blast radius vận hành của C10. Chứng minh NonDeterministicError đã thực sự xảy ra cần history của Temporal (tctl workflow list --query 'WorkflowType="postWorkflowV101"' hoặc Temporal UI) và Sentry. Phần code-level determinism break là chắc chắn; phạm vi ảnh hưởng thực tế thì không quan sát được từ repo.
+C1 là rủi ro lý thuyết đã được chứng minh về nguyên tắc , không phải exploit dựng sẵn. Khôi phục trạng thái xorshift128+ từ output quan sát được là kỹ thuật đã biết, nhưng audit này không dựng PoC. Điều chắc chắn không cần tranh cãi: Math.random() cho bearer token là sai về phân loại, và fix chỉ một dòng.
+C8 phụ thuộc cấu hình prod thật — cụ thể là CLOUDFLARE_BUCKET_URL có được set trong scripts/crove-server.env hay không; file này gitignored nên không đọc được. docker-compose.yaml document STORAGE_PROVIDER: 'local' làm mặc định, nên đường local-storage là mặc định được ship.
+S17 (DOMPurify ALLOWED_ATTR pollution) chưa xác minh được vì cần đọc node_modules/isomorphic-dompurify. Được nêu là "cần kiểm tra" kèm fix phòng thủ gần như miễn phí, không phải khẳng định có lỗ hổng.
+Không đo được bundle size thật. Các chuỗi import static ở F-B2/F-B3/F-B8 được xác minh từ source, nhưng chưa chạy build nên không có số KB, và chưa xác định được dev server dùng Turbopack hay webpack (Next 16 có thể mặc định Turbopack dù next.config.js có hàm webpack tuỳ chỉnh).
+Chưa dựng được import graph đầy đủ cho 268 component frontend — các file chết ở F-D5 được xác minh bằng grep có chủ đích, có thể còn file không reachable khác. (standalone-modal/ và launches/web3/ đã kiểm tra và đều đang sống .)
+Tỷ lệ contrast chỉ tính tay cho F-C7 từ hai giá trị hex trong source; chưa chạy contrast checker trên toàn palette, nên có thể còn chỗ khác trượt AA (đáng ngờ: --color-custom18 = #aaaaaa dùng làm body text ở ~40 chỗ).
+Phạm vi IDOR chưa rà hết từng route một. Ba agent security chạy song song bị lỗi thực thi (Subagent execution failed) cả ba lần; phần bảo mật do tôi tự đọc code trực tiếp, nên độ phủ các endpoint :id đối chiếu với query scoping của repository tương ứng thấp hơn kế hoạch ban đầu. Các khu vực đã rà: auth middleware, impersonation/showorg, OAuth DCR + redirect_uri, crove-sso worker, webhook HMAC, upload validation, CORS/CSRF, rate limiting. Khu vực chưa rà hệ thống: 31 controller còn lại đối chiếu từng query org-scoping.
+Một số mốc thời gian suy ra từ ngày trong file chứ không phải git log (vài lệnh git bị chặn bởi shell guard cục bộ): docker-compose.override.yaml trích 2026-07-21 và 2026-08-12; wrangler.jsonc:5 có compatibility_date: "2026-08-13"; reports/junit.xml:3 ghi 2025-03-24.
+
+Việc nên làm tiếp nếu muốn đóng khoảng trống cuối: một lượt rà IDOR có hệ thống — với mỗi route nhận :id trong apps/backend/src/api/routes/ và public-api/, đối chiếu xem query repository tương ứng có lọc theo organizationId/userId hay không, và route có @CheckPolicies hay không. Đây là phần duy nhất trong brief bảo mật ban đầu chưa được phủ đầy đủ.
+
+
+
+Rev 2 — 2026-09-08 (phiên khắc phục): sửa 7 quick wins (C5, S4, S15-đánh-giá-lại, S16, I-B2, I-E7, C2-phần-1) trên 8 files (−69/+24 dòng); dọn prod VM crove-server (project crove-os): 2 zombie postgres container + 3 volume chết + 1 container exited; đánh giá lại S15 (DB chính là Supabase, crove-postgres là dead weight); review 9 upstream fix branch (chờ merge, không cherry-pick); thêm S18/S19/S20; hạ mức C3/C5/C7 Critical→High; viết lại phần khai thác C1; thêm caveat exploitability cho hono/nodemailer. Validator compose PASS sau mỗi batch.
+Rev 1 — 2026-09-08 (audit gốc): thực hiện trên nhánh dev (HEAD 3aae08cd). Phạm vi: apps/* (8 app), libraries/* (3 library), scripts/, .github/workflows/, Jenkins/, compose file, docs/. Static analysis, chỉ đọc.
+
+
+
+
+
+
+
diff --git a/docs/cicd.md b/docs/cicd.md
index 79a5263731..5acdcd86f2 100644
--- a/docs/cicd.md
+++ b/docs/cicd.md
@@ -41,10 +41,10 @@ The Crove Post CI/CD system provides end-to-end automation: source code verifica
| Secret | Description | Required |
| :--- | :--- | :--- |
| `CLOUDFLARE_API_TOKEN` | Token with permissions to deploy Cloudflare Workers and update DNS | Yes |
-| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare Account ID (`3368ff98a4c956164b7bbdc8fb950163` or `5f2a58925e790423dfafa0e6bee46b28`) | Optional |
+| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare Account ID (set trong GitHub Secrets — **không commit giá trị thật**) | Optional |
| `GITHUB_TOKEN` | Automatically provisioned by GitHub Actions with package write permissions | Automatic |
| `GCP_SSH_KEY` | SSH Private Key for connecting to GCP `crove-server` | Optional (for automated push deployment) |
-| `GCP_HOST` | GCP Server IP address (`34.87.89.118`) | Optional |
+| `GCP_HOST` | GCP Server IP address (set trong GitHub Secrets — **không commit IP thật**) | Optional |
---
diff --git a/dynamicconfig/production-sql.yaml b/dynamicconfig/production-sql.yaml
new file mode 100644
index 0000000000..9f65050064
--- /dev/null
+++ b/dynamicconfig/production-sql.yaml
@@ -0,0 +1,12 @@
+# Production Temporal dynamic config (scripts/docker-compose.prod.yaml
+# mounts dynamicconfig/ as /etc/temporal/config/dynamicconfig and points
+# DYNAMIC_CONFIG_FILE_PATH at this file).
+#
+# The development file (development-sql.yaml) sets
+# system.forceSearchAttributesCacheRefreshOnRead, whose own comment marks it
+# as dev-only. Production uses safe defaults only.
+#
+# Any change here affects live workflow execution — review before merging.
+limit.maxIDLength:
+ - value: 255
+ constraints: {}
diff --git a/jest.config.ts b/jest.config.ts
index d0dbd1b889..383ef8f4f0 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -1,5 +1,6 @@
-import { getJestProjects } from '@nx/jest';
-
-export default {
- projects: getJestProjects(),
-};
+// Real Jest entrypoint. The previous config used @nx/jest's getJestProjects(),
+// but this repo has no nx.json / project.json (and @nx/jest was never a
+// dependency), so `pnpm test` loaded nothing and silently ran zero tests.
+// The 5 bootstrap specs live in tests/ with their real config in
+// tests/bootstrap.jest.cjs — reuse it instead of duplicating.
+module.exports = require('./tests/bootstrap.jest.cjs');
diff --git a/package.json b/package.json
index aa691f3385..7743c39366 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
"description": "",
"main": "index.js",
"license": "AGPL-3.0",
+ "private": true,
"keywords": [],
"author": "",
"engines": {
@@ -33,15 +34,14 @@
"start:prod:web": "pnpm --filter ./apps/web run start",
"dev:docker": "docker compose -f ./docker-compose.dev.yaml up -d",
"validate:beta-deploy": "node scripts/validate-beta-compose.mjs",
+ "typecheck:sso": "pnpm --filter @crove/sso run typecheck",
"commands:build:development": "pnpm --filter ./apps/commands run build",
- "prisma-generate": "pnpm dlx prisma@6.5.0 generate --schema ./libraries/nestjs-libraries/src/database/prisma/schema.prisma",
- "prisma-db-push": "pnpm dlx prisma@6.5.0 db push --accept-data-loss --schema ./libraries/nestjs-libraries/src/database/prisma/schema.prisma",
- "prisma-db-pull": "pnpm dlx prisma@6.5.0 db pull --schema ./libraries/nestjs-libraries/src/database/prisma/schema.prisma",
- "prisma-reset": "cd ./libraries/nestjs-libraries/src/database/prisma && pnpm dlx prisma@6.5.0 db push --force-reset && pnpx prisma@6.5.0 db push",
- "docker-build": "./var/docker/docker-build.sh",
- "docker-create": "./var/docker/docker-create.sh",
+ "prisma-generate": "pnpm exec prisma generate --schema ./libraries/nestjs-libraries/src/database/prisma/schema.prisma",
+ "prisma-db-push": "pnpm exec prisma db push --accept-data-loss --schema ./libraries/nestjs-libraries/src/database/prisma/schema.prisma",
+ "prisma-db-pull": "pnpm exec prisma db pull --schema ./libraries/nestjs-libraries/src/database/prisma/schema.prisma",
"postinstall": "pnpm run prisma-generate",
- "test": "jest --coverage --detectOpenHandles --reporters=default --reporters=jest-junit"
+ "test": "jest --coverage --detectOpenHandles --reporters=default --reporters=jest-junit",
+ "test:sso": "pnpm --filter @crove/sso test"
},
"dependencies": {
"@ag-ui/mastra": "^1.0.1",
@@ -334,7 +334,10 @@
"@types/react-dom": "19.1.6"
},
"onlyBuiltDependencies": [
- "bcrypt"
+ "bcrypt",
+ "@prisma/client",
+ "prisma",
+ "esbuild"
]
}
}
diff --git a/railway.toml b/railway.toml
deleted file mode 100644
index 9030d13a11..0000000000
--- a/railway.toml
+++ /dev/null
@@ -1,3 +0,0 @@
-[phases.setup]
- nixPkgs = ['nodejs', 'python3']
- aptPkgs = ['build-essential', 'libudev-dev']
\ No newline at end of file
diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh
new file mode 100644
index 0000000000..a454f4ec59
--- /dev/null
+++ b/scripts/backup-db.sh
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+# Database backup for the production VM (cron → R2 / local rotation).
+#
+# The app database lives in Supabase (managed, has its own PITR) — this script
+# backs up the two databases that live on the VM and have NO backup:
+# 1. temporal-postgresql (Temporal DB: workflow history)
+# 2. Any dynamic tables written by the app outside Supabase (none today,
+# but harmless to include if that changes).
+#
+# Supabase backups are handled by Supabase itself — verify PITR is enabled.
+#
+# Install on the VM (as root):
+# scp scripts/backup-db.sh crove-server:/opt/crove/backup-db.sh
+# chmod +x /opt/crove/backup-db.sh
+# crontab -e
+# 17 2 * * * /opt/crove/backup-db.sh >> /opt/crove/backups/backup.log 2>&1
+#
+# Restore drill (run quarterly — audit C2): restore the latest dump into a
+# throwaway container and `SELECT 1` against it before trusting the backup.
+set -euo pipefail
+
+BACKUP_DIR="${BACKUP_DIR:-/opt/crove/backups}"
+RETAIN_DAYS="${RETAIN_DAYS:-14}"
+STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
+mkdir -p "$BACKUP_DIR"
+
+echo "[$STAMP] checking temporal stack health before backup..."
+UNHEALTHY="$(docker ps --filter name=temporal --filter health=unhealthy --format '{{.Names}}')"
+if [ -n "$UNHEALTHY" ]; then
+ echo "[$STAMP] ERROR: temporal containers UNHEALTHY: $UNHEALTHY — refusing to back up a wedged state-store. Investigate with 'docker inspect --format \"{{json .State.Health}}\" $UNHEALTHY'" >&2
+ exit 1
+fi
+
+echo "[$STAMP] backing up temporal-postgresql..."
+docker exec temporal-postgresql \
+ pg_dump -U temporal -d temporal \
+ | gzip > "$BACKUP_DIR/temporal-$STAMP.sql.gz"
+
+# Verify the dump is not empty before pruning anything.
+if [ ! -s "$BACKUP_DIR/temporal-$STAMP.sql.gz" ]; then
+ echo "[$STAMP] ERROR: temporal dump is empty — aborting" >&2
+ exit 1
+fi
+
+echo "[$STAMP] pruning dumps older than $RETAIN_DAYS days..."
+find "$BACKUP_DIR" -name 'temporal-*.sql.gz' -mtime "+$RETAIN_DAYS" -delete
+
+# Optional: copy off-box to R2 if rclone is configured.
+if command -v rclone >/dev/null 2>&1 && rclone listremotes | grep -q .; then
+ REMOTE="$(rclone listremotes | head -1)"
+ rclone copy "$BACKUP_DIR/temporal-$STAMP.sql.gz" "$REMOTE/crove-backups/" || \
+ echo "[$STAMP] WARN: rclone upload failed" >&2
+fi
+
+echo "[$STAMP] done."
diff --git a/scripts/branding-guard.ts b/scripts/branding-guard.ts
index 172f909601..d36c82fe07 100644
--- a/scripts/branding-guard.ts
+++ b/scripts/branding-guard.ts
@@ -11,6 +11,8 @@ import {
applyBrandToString,
DEFAULT_BRAND_CONFIG,
} from '../libraries/helpers/src/utils/brand.config';
+import { readdirSync, readFileSync, statSync } from 'fs';
+import { join, extname } from 'path';
let failed = false;
@@ -95,6 +97,130 @@ console.log('=== Running Branding Guard Validations ===\n');
);
}
+// 6. Repository scanner — catches actual branding leaks in tracked files.
+// The contract tests above only exercise the branding engine in memory;
+// they can never see a leaked image reference or a wrong FRONTEND_URL.
+// STRICT hits fail the run. INHERITED hits are upstream files that arrive
+// on every sync — reported loudly but tolerated until deliberately
+// deleted/rewritten (ratchet by moving a file from INHERITED to strict
+// once its cleanup lands).
+{
+ console.log('\n=== Repository Branding Scan ===\n');
+
+ const SKIP_DIRS = new Set([
+ 'node_modules', '.git', '.next', 'dist', 'build', 'reports',
+ 'coverage', '.artifacts', '.codex-artifacts', '.vercel',
+ '.playwright-mcp', 'dynamicconfig', 'var',
+ // local tooling/worktree copies of the repo — not source of truth
+ '.codex', '.claude', '.cursor', '.qwen', 'tests',
+ ]);
+ // Upstream attribution files: legal/inherited content where the
+ // upstream name is expected to appear.
+ const INHERITED = [
+ 'LICENSE', 'ICLA.md', 'CCLA.md', 'README.md', 'SECURITY.md',
+ 'docs/', '.github/workflows/sync-upstream.yml',
+ '.github/workflows/staging-conflicts.yml',
+ 'scripts/branding-guard.ts', // this file asserts the defaults
+ 'libraries/helpers/src/utils/brand.config.ts', // DEFAULT_BRAND_CONFIG
+ // 17 locale files carry the upstream support link, arrive on every sync
+ 'libraries/react-shared-libraries/src/translation/locales/',
+ 'chatgpt-app-submission.json', // upstream submission, pending rewrite
+ 'sonar-project.properties', // upstream tenant key, pending deletion
+ 'Jenkins/', 'railway.toml', '.devcontainer/', // dead upstream infra
+ '.github/workflows/issue-label-triggers.yml', // upstream automation
+ 'apps/sdk/package.json', // upstream author field, pending rewrite
+ 'libraries/nestjs-libraries/src/sentry/initialize.sentry.ts',
+ 'CHANGELOG.md', 'ROADMAP.md',
+ ];
+ // Crove-owned files where a leak is always a bug.
+ const STRICT_EXTRA = [
+ 'docker-compose.yaml',
+ '.github/workflows/build-extension.yaml',
+ '.github/workflows/publish-extension.yml',
+ 'apps/extension/manifest.json',
+ ];
+ // Directories we own and actively edit — leaks here are strict.
+ const STRICT_PREFIXES = [
+ 'apps/backend/src/', 'apps/frontend/src/', 'apps/crove-sso/',
+ 'libraries/nestjs-libraries/src/', 'libraries/helpers/src/',
+ 'libraries/react-shared-libraries/src/',
+ 'scripts/',
+ ];
+ const PATTERNS: Array<[RegExp, string]> = [
+ [/platform\.postiz\.com/gi, 'upstream platform domain'],
+ [/gitroomhq\/postiz-app/gi, 'upstream container image'],
+ [/github\.com\/gitroomhq/gi, 'upstream repository URL'],
+ [/\bNevo David\b/g, 'upstream author name'],
+ [/\bpostiz-app\b/gi, 'upstream repository name'],
+ ];
+ const TEXT_EXTS = new Set([
+ '.ts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.jsonc', '.yaml',
+ '.yml', '.md', '.html', '.scss', '.css', '.sh', '.ps1', '.conf',
+ '.toml', '.properties', '', // extensionless (Dockerfile, LICENSE…)
+ ]);
+
+ function* walk(dir: string): Generator {
+ for (const entry of readdirSync(dir)) {
+ const full = join(dir, entry);
+ let st;
+ try {
+ st = statSync(full);
+ } catch {
+ continue;
+ }
+ if (st.isDirectory()) {
+ if (!SKIP_DIRS.has(entry)) yield* walk(full);
+ } else {
+ yield full;
+ }
+ }
+ }
+
+ const repoRoot = join(__dirname, '..');
+ let strictHits = 0;
+ let inheritedHits = 0;
+ for (const file of walk(repoRoot)) {
+ const rel = file.slice(repoRoot.length + 1).replace(/\\/g, '/');
+ const ext = extname(file);
+ if (!TEXT_EXTS.has(ext)) continue;
+ if (INHERITED.some((p) => rel === p || rel.startsWith(p))) continue;
+ let content;
+ try {
+ content = readFileSync(file, 'utf8');
+ } catch {
+ continue;
+ }
+ for (const [pattern, label] of PATTERNS) {
+ const matches = content.match(pattern);
+ if (!matches) continue;
+ const strict =
+ STRICT_EXTRA.includes(rel) ||
+ STRICT_PREFIXES.some((p) => rel.startsWith(p));
+ const line = content
+ .slice(0, content.search(pattern))
+ .split('\n').length;
+ // Commented-out examples of BRAND_* attribution are documentation of
+ // the AGPL knob, not shipped config — never a strict leak.
+ const matchedLine = content.split('\n')[line - 1] ?? '';
+ if (/^\s*(#|\/\/|\/\*|\{\/\*)/.test(matchedLine)) continue;
+ if (strict) {
+ strictHits += matches.length;
+ console.error(
+ `[FAIL] ${rel}:${line} — ${label} (${matches.length}×)`
+ );
+ failed = true;
+ } else {
+ inheritedHits += matches.length;
+ console.warn(`[INHERITED] ${rel} — ${label} (${matches.length}×)`);
+ }
+ }
+ }
+ assert(strictHits === 0, `Repo scan: 0 strict branding leaks (found ${strictHits})`);
+ console.log(
+ `Repo scan: ${inheritedHits} inherited upstream mentions (tolerated, see INHERITED list)`
+ );
+}
+
console.log('\n=== Branding Guard Summary ===');
if (failed) {
console.error('\nBranding Guard validations FAILED! Check logs above.\n');
diff --git a/scripts/build-all.ps1 b/scripts/build-all.ps1
index 5c707e8a90..138ecffc39 100644
--- a/scripts/build-all.ps1
+++ b/scripts/build-all.ps1
@@ -31,10 +31,12 @@ if ($LASTEXITCODE -ne 0) { Write-Error "Web Build failed!"; exit 1 }
# 3. Prisma Generate
Write-Host "`n[3/4] Generate Prisma Client..." -ForegroundColor Green
pnpm run prisma-generate
+if ($LASTEXITCODE -ne 0) { Write-Error "Prisma generate failed!"; exit 1 }
# 4. Build Core Apps
Write-Host "`n[4/4] Build Core Backend & Frontend..." -ForegroundColor Green
pnpm run build
+if ($LASTEXITCODE -ne 0) { Write-Error "Core build failed!"; exit 1 }
Write-Host "`n==========================================================" -ForegroundColor Green
Write-Host " ALL APPLICATIONS BUILT SUCCESSFULLY!" -ForegroundColor Green
diff --git a/scripts/docker-compose.beta.yaml b/scripts/docker-compose.beta.yaml
index 39ec9b04d6..611448a603 100644
--- a/scripts/docker-compose.beta.yaml
+++ b/scripts/docker-compose.beta.yaml
@@ -29,29 +29,9 @@ services:
- postiz-beta
temporal-network:
depends_on:
- crove-postgres-beta:
- condition: service_healthy
crove-redis-beta:
condition: service_healthy
- crove-postgres-beta:
- image: postgres:17-alpine
- container_name: crove-postgres-beta
- restart: always
- environment:
- POSTGRES_PASSWORD: postiz-password
- POSTGRES_USER: postiz-user
- POSTGRES_DB: postiz-db-local
- volumes:
- - postgres-beta-volume:/var/lib/postgresql/data
- networks:
- - crove-post-beta-network
- healthcheck:
- test: pg_isready -U postiz-user -d postiz-db-local
- interval: 10s
- timeout: 3s
- retries: 3
-
crove-redis-beta:
image: redis:7.2
container_name: crove-redis-beta
@@ -67,8 +47,6 @@ services:
- crove-post-beta-network
volumes:
- postgres-beta-volume:
- name: crove_postgres-beta-volume
postiz-redis-beta-data:
name: crove_postiz-redis-beta-data
postiz-beta-config:
diff --git a/scripts/docker-compose.prod.yaml b/scripts/docker-compose.prod.yaml
index 735682734d..5a56c067db 100644
--- a/scripts/docker-compose.prod.yaml
+++ b/scripts/docker-compose.prod.yaml
@@ -5,12 +5,33 @@
services:
crove-post:
- image: ghcr.io/dos/crove-post:latest
+ # Overridable so operators can pin a version tag or digest without editing
+ # this file (audit I-B4: :latest is mutable; set CROVE_POST_IMAGE to pin).
+ image: ${CROVE_POST_IMAGE:-ghcr.io/dos/crove-post:latest}
container_name: crove-post
restart: always
+ pull_policy: always
+ # Schema changes are a separate reviewed operation. Never introspect/drop
+ # dynamic Mastra tables or run db push --accept-data-loss at app startup.
+ command: ["sh", "-c", "nginx && pnpm exec pm2 ping && pnpm run --parallel pm2 && pnpm exec pm2 logs"]
+ # Proven healthcheck copied from docker-compose.override.yaml — catches the
+ # 2026-08-12 outage class (pm2 reports online while nginx serves 502).
+ healthcheck:
+ test:
+ - CMD
+ - node
+ - -e
+ - "fetch('http://localhost:5000/auth').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"
+ interval: 10s
+ timeout: 5s
+ retries: 12
+ start_period: 20s
env_file:
- crove-server.env
volumes:
+ # These names match the LIVE volumes on the VM (crove_postiz-*). Renaming
+ # them would orphan the running app's config and uploaded media on the
+ # next deploy — do not "rebrand" them without a data-migration plan.
- postiz-config:/config/
- postiz-uploads:/uploads/
ports:
@@ -22,13 +43,16 @@ services:
- postiz
temporal-network:
depends_on:
- crove-postgres:
- condition: service_healthy
crove-redis:
condition: service_healthy
crove-web:
- image: ghcr.io/dos/crove-web:latest
+ # NOTE (audit I-B6/I-E4): CI does not build this image (build-containers.yml
+ # only builds crove-post) and no crove-web container runs on the VM — the
+ # production landing page is served by Vercel (apps/web, vercel.json).
+ # Keep this service only if the crove.com tunnel route is actually used;
+ # verify DNS before deleting. Set CROVE_WEB_IMAGE to pin a tag.
+ image: ${CROVE_WEB_IMAGE:-ghcr.io/dos/crove-web:latest}
container_name: crove-web
restart: always
environment:
@@ -41,35 +65,21 @@ services:
- postiz-network
cloudflared:
- image: cloudflare/cloudflared:latest
+ # Pinned + --no-autoupdate, same as docker-compose.override.yaml. A mutable
+ # :latest edge tunnel can change behaviour mid-incident.
+ image: cloudflare/cloudflared:2026.7.3
container_name: crove-cloudflared
restart: always
- command: tunnel --config /etc/cloudflared/config.yml run
+ command: tunnel --no-autoupdate --config /etc/cloudflared/config.yml run
volumes:
+ # Populated on the VM at /opt/crove/tunnel (config.yml + credentials.json).
+ # The repo intentionally does not contain the credentials file.
- ./tunnel:/etc/cloudflared:ro
networks:
- postiz-network
depends_on:
- crove-post
- crove-postgres:
- image: postgres:17-alpine
- container_name: crove-postgres
- restart: always
- environment:
- POSTGRES_PASSWORD: postiz-password
- POSTGRES_USER: postiz-user
- POSTGRES_DB: postiz-db-local
- volumes:
- - postgres-volume:/var/lib/postgresql/data
- networks:
- - postiz-network
- healthcheck:
- test: pg_isready -U postiz-user -d postiz-db-local
- interval: 10s
- timeout: 3s
- retries: 3
-
crove-redis:
image: redis:7.2
container_name: crove-redis
@@ -90,15 +100,28 @@ services:
restart: always
ports:
- '127.0.0.1:7233:7233'
+ # Audit S18: temporal-postgresql (the state-store) had no alarm — if
+ # Postgres dies or wedges, Temporal fails silently and Docker never
+ # restarts it (restart:always only fires on process exit). Healthchecks
+ # below turn a silent wedge into a detected, restarted failure.
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - 'bash -c "