Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.git
.github
.husky
.env
.env.*
!.env.example
node_modules
backend/node_modules
dist
coverage
playwright-report
test-results
docs
tests
supabase
*.log
.DS_Store
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ ADMIN_POSTGREST_URL=http://vezvision-admin-postgrest:3000
ADMIN_POSTGREST_API_KEY=

# Public API server settings belong in Coolify and must never use the VITE_ prefix.
# DATABASE_URL=postgresql://restricted_api_user:password@postgres:5432/vezvision
# API and PostgREST must use separate least-privilege database users.
# API_DATABASE_URL=postgresql://vezvision_api:password@postgres:5432/vezvision
# POSTGREST_DATABASE_URL=postgresql://vezvision_postgrest:password@postgres:5432/vezvision
# ALLOWED_ORIGINS=https://vezvision.com,https://www.vezvision.com
# TURNSTILE_SECRET_KEY=
# RESEND_API_KEY=
8 changes: 5 additions & 3 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
* @vezcode
/src/components/seo/ @vezcode
/supabase/ @vezcode
/scripts/ @vezcode

/.github/ @vezcode
/backend/ @vezcode
/deploy/ @vezcode
/supabase/migrations/ @vezcode
33 changes: 13 additions & 20 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
## Description
## What changed

Brief description of what this PR does and why.
<!-- Describe the user-visible and technical changes. -->

## Type of change
## Verification

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Performance improvement
- [ ] Refactor / code quality
- [ ] Documentation update
- [ ] `npm run lint`
- [ ] `npm run knip`
- [ ] `npm run check`
- [ ] `npm run build`
- [ ] `npm run size:check`
- [ ] `npm audit --audit-level=high`
- [ ] No secret or personal data was added
- [ ] Mobile and desktop behaviour was checked when UI changed

## Checklist
## Deployment and rollback

- [ ] Code follows the project's style guidelines (ESLint + Prettier)
- [ ] Self-review completed
- [ ] Comments added for complex logic
- [ ] No new TypeScript errors (`npm run check`)
- [ ] No new ESLint warnings (`npm run lint`)
- [ ] No new Knip dead-code findings
- [ ] npm audit passes with no moderate+ vulnerabilities
- [ ] No secrets, API keys, or credentials committed
- [ ] Accessibility implications considered (ARIA, keyboard nav, contrast)
- [ ] Mobile and desktop layouts verified
<!-- Note environment changes, migration order and the rollback path. -->
40 changes: 10 additions & 30 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,18 @@ jobs:
- name: Lint
run: npm run lint

- name: Knip (dead code detection — advisory)
run: npm run knip -- --no-exit-code
- name: Knip (dead code detection)
run: npm run knip

- name: Typecheck, parity, and unit tests
run: npm run check

- name: Build
run: npm run build

- name: Bundle size budget
run: npm run size:check

- name: Upload production dist
uses: actions/upload-artifact@v7
with:
Expand All @@ -51,34 +54,10 @@ jobs:
retention-days: 1

- name: Audit production dependencies
run: |
npm audit --omit=dev --json 2>/dev/null | node -e '
const data = JSON.parse(require("fs").readFileSync(0, "utf8"));
const SKIP = new Set(["GHSA-qwww-vcr4-c8h2"]);
const vulns = data.vulnerabilities || {};
const filtered = new Set();
let changed = true;
while (changed) {
changed = false;
for (const [name, v] of Object.entries(vulns)) {
if (filtered.has(name)) continue;
const vias = Array.isArray(v.via) ? v.via : [v.via];
const allSkipped = vias.length > 0 && vias.every(via => {
if (typeof via === "string") return filtered.has(via);
if (via.source && SKIP.has(via.source)) return true;
if (via.url && via.url.includes("GHSA-qwww-vcr4-c8h2")) return true;
return false;
});
if (allSkipped) { filtered.add(name); changed = true; }
}
}
const real = Object.entries(vulns).filter(([n]) => !filtered.has(n));
if (real.length > 0) {
for (const [name, v] of real) console.error(name + ": " + v.severity);
process.exit(1);
}
console.log("No production vulnerabilities");
'
run: npm audit --omit=dev --audit-level=high

- name: Audit all dependencies
run: npm audit --audit-level=high

e2e:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -146,6 +125,7 @@ jobs:
run: npm run build
env:
VITE_API_URL: https://api.example.test
SKIP_PRERENDER: "1"

- name: Run Lighthouse CI
run: npx --yes @lhci/cli@0.15.1 autorun --config=./.github/lighthouserc.json
1 change: 0 additions & 1 deletion .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,3 @@ jobs:
uses: actions/dependency-review-action@v4
with:
fail-on-severity: moderate
continue-on-error: true
14 changes: 14 additions & 0 deletions .github/workflows/deploy-coolify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ jobs:
steps:
- name: Checkout deployment scripts
uses: actions/checkout@v6
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Verify tested develop commit is still the branch tip
if: github.event_name == 'workflow_run'
env:
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha }}
run: test "$(git ls-remote origin refs/heads/develop | cut -f1)" = "$EXPECTED_SHA"
- name: Deploy develop API
env:
WEBHOOK_URL: ${{ secrets.COOLIFY_DEVELOP_API_WEBHOOK_URL }}
Expand All @@ -57,6 +64,13 @@ jobs:
steps:
- name: Checkout deployment scripts
uses: actions/checkout@v6
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Verify tested production commit is still the branch tip
if: github.event_name == 'workflow_run'
env:
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha }}
run: test "$(git ls-remote origin refs/heads/main | cut -f1)" = "$EXPECTED_SHA"
- name: Deploy production API
env:
WEBHOOK_URL: ${{ secrets.COOLIFY_PRODUCTION_API_WEBHOOK_URL }}
Expand Down
5 changes: 5 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Copyright (c) 2026 POLIFORM SPÓŁKA Z OGRANICZONĄ ODPOWIEDZIALNOŚCIĄ.

All rights reserved. This repository and its contents are proprietary. No permission
is granted to copy, modify, distribute, sublicense, deploy or create derivative works
without prior written authorization from the copyright holder.
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,14 @@ It runs install, typecheck, lint, unit tests, production build, npm audit, and C

These endpoints are served by the self-hosted Node API in `backend/`; public reads are served by PostgREST through a GET-only gateway. The browser only needs `VITE_API_URL`.

### Optional Turnstile (contact + newsletter)
### Turnstile (contact + newsletter)

| Env (client) | `VITE_TURNSTILE_SITE_KEY` |
| Env (server) | `TURNSTILE_SECRET_KEY` |

When the site key is unset, widgets are hidden. When set, configure the secret in
the self-hosted API environment.
Turnstile may be omitted for local development. Production starts fail closed unless
the site key is built into the frontend and both `TURNSTILE_SECRET_KEY` and
`TURNSTILE_EXPECTED_HOSTNAMES` are configured in the API environment.

Before every production build, `npm run build` verifies CSP sources (`verify:security`) and `dist/` artifacts (`verify-production-build.mjs`).

Expand All @@ -122,6 +123,9 @@ Before every production build, `npm run build` verifies CSP sources (`verify:sec
- Frontend and API are built by Coolify from `frontend.Dockerfile` and `coolify-compose.yml`.
- Production runs on Hetzner. The old Hostido deploy job is intentionally absent.
- Coolify environment values are the source of truth for production build and server secrets.
- Use separate `API_DATABASE_URL` and `POSTGREST_DATABASE_URL` values backed by
dedicated least-privilege roles; never give the public PostgREST container the API
write role or a database-owner credential.
- Use **npm** (`npm ci`) for installs; do not commit alternate lockfiles.
- Keep Browserslist data current with `npm update caniuse-lite browserslist` when build warnings appear.
- Do not commit local files such as `.env`, `.DS_Store`, Playwright traces, or reports.
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ If you discover a security vulnerability in the VEZvision website, please report

**DO NOT** open a public issue.

**Contact:** contact@vezvision.com
**Contact:** security@vezvision.com

Please include:

Expand Down
3 changes: 2 additions & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM node:22-alpine3.23@sha256:8516dce0483394d5708d4b2ee6cacb79fb1d617ea4e2787c2120bcca92ce372e
FROM node:22-alpine3.23@sha256:46825fbbd4e996a78b7a2cdc08d75e38a5a505bdab95dcda55605359bf124bc6
WORKDIR /app
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts \
Expand All @@ -9,6 +9,7 @@ COPY backend/email-templates.mjs ./email-templates.mjs
COPY backend/resend-email.mjs ./resend-email.mjs
COPY public/email-logo.png ./email-logo.png
ENV EMAIL_LOGO_PATH=/app/email-logo.png
ENV NODE_ENV=production
USER node
EXPOSE 3000
CMD ["node", "server.mjs"]
15 changes: 11 additions & 4 deletions backend/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { contactAutoReplyEmail, contactNotificationEmail, newsletterConfirmation
import { sendEmail as sendResendEmail } from './resend-email.mjs'

const databaseUrl = process.env.DATABASE_URL
const isProduction = process.env.NODE_ENV === 'production'
const allowedOrigins = String(process.env.ALLOWED_ORIGINS || process.env.ALLOWED_ORIGIN || '')
.split(',')
.map(origin => origin.trim())
Expand Down Expand Up @@ -37,7 +38,13 @@ const adminPostgrestApiKey = process.env.ADMIN_POSTGREST_API_KEY?.trim()
const publicSiteUrl = (process.env.PUBLIC_SITE_URL?.trim() || allowedOrigins[0] || '').replace(/\/$/, '')
const publicEmailSiteUrl = (process.env.PUBLIC_EMAIL_SITE_URL?.trim() || 'https://vezvision.com').replace(/\/$/, '')
if (!databaseUrl || allowedOrigins.length === 0) throw new Error('DATABASE_URL and ALLOWED_ORIGIN/ALLOWED_ORIGINS are required')
if (!turnstileSecret) console.warn('TURNSTILE_SECRET_KEY is not set; contact and newsletter captcha verification is disabled')
if (isProduction && (!turnstileSecret || turnstileExpectedHostnames.length === 0)) {
throw new Error('TURNSTILE_SECRET_KEY and TURNSTILE_EXPECTED_HOSTNAMES are required in production')
}
if (isProduction && (!resendApiKey || !contactNotificationFromEmail || !contactReplyFromEmail || !newsletterFromEmail)) {
throw new Error('Resend API key and sender addresses are required in production')
}
if (!turnstileSecret) console.warn('TURNSTILE_SECRET_KEY is not set; allowed only outside production')
if (turnstileTestMode) console.warn('TURNSTILE_TEST_MODE is enabled; use only in development')
if (!resendApiKey || !contactNotificationFromEmail || !contactReplyFromEmail || !newsletterFromEmail) console.warn('Resend API key or sender addresses are not fully configured; some emails are disabled')
if ([adminApiTokenSha256, adminPostgrestUrl, adminPostgrestApiKey].some(Boolean)
Expand Down Expand Up @@ -91,7 +98,7 @@ const cors = (req, res) => {
}
const hasAllowedBrowserOrigin = req => {
const origin = String(req.headers.origin || '').trim()
return !origin || allowedOrigins.includes(origin)
return origin.length > 0 && allowedOrigins.includes(origin)
}
const body = async req => {
let raw = ''
Expand Down Expand Up @@ -198,9 +205,9 @@ async function cleanupRateLimits() {
const { rows: [retention] } = await pool.query(
`SELECT * FROM public.cleanup_expired_private_data()`,
)
if (Number(retention?.expired_messages) > 0 || Number(retention?.expired_unconfirmed_subscribers) > 0) {
if (Number(retention?.expired_messages) > 0 || Number(retention?.expired_unconfirmed_subscribers) > 0 || Number(retention?.anonymized_unsubscribed) > 0) {
console.info(
`Applied data retention: ${retention.expired_messages} messages and ${retention.expired_unconfirmed_subscribers} unconfirmed subscribers removed`,
`Applied data retention: ${retention.expired_messages} messages removed, ${retention.expired_unconfirmed_subscribers} unconfirmed subscribers removed and ${retention.anonymized_unsubscribed} withdrawn subscriptions anonymized`,
)
}
} catch (error) {
Expand Down
35 changes: 26 additions & 9 deletions coolify-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ services:
dockerfile: backend/Dockerfile
restart: unless-stopped
environment:
DATABASE_URL: ${DATABASE_URL}
# API and PostgREST must use different least-privilege database roles.
# DATABASE_URL remains a temporary fallback for existing installations.
DATABASE_URL: ${API_DATABASE_URL:-${DATABASE_URL}}
NODE_ENV: production
ALLOWED_ORIGIN: ${ALLOWED_ORIGIN}
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY:-}
Expand All @@ -28,41 +31,55 @@ services:
networks:
vezvision_internal:
aliases: [vezvision-api]
coolify: {}
security_opt: [no-new-privileges:true]
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
read_only: true
tmpfs: [/tmp]
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/healthz >/dev/null || exit 1"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s

postgrest:
image: postgrest/postgrest:v14.1
image: postgrest/postgrest:v14.1@sha256:e9490aa503a5fb07d8e8c80da46e5c0c193894e583b59ed9077e74c4101ffae2
restart: unless-stopped
environment:
PGRST_DB_URI: ${DATABASE_URL}
PGRST_DB_URI: ${POSTGREST_DATABASE_URL:-${DATABASE_URL}}
PGRST_DB_SCHEMA: public
PGRST_DB_ANON_ROLE: anon
PGRST_SERVER_HOST: 0.0.0.0
PGRST_SERVER_PORT: 3001
networks:
vezvision_internal:
aliases: [vezvision-postgrest]
coolify: {}
security_opt: [no-new-privileges:true]
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
read_only: true
tmpfs: [/tmp]
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/ >/dev/null || exit 1"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s

gateway:
build:
context: .
dockerfile: backend/nginx.Dockerfile
restart: unless-stopped
depends_on: [api, postgrest]
depends_on:
api:
condition: service_healthy
postgrest:
condition: service_healthy
expose: ["8080"]
labels:
- traefik.docker.network=coolify
networks: [vezvision_internal, coolify]
security_opt: [no-new-privileges:true]
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
read_only: true
tmpfs: [/var/cache/nginx, /var/run, /tmp]
Expand Down
5 changes: 4 additions & 1 deletion deploy/hetzner/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Set in Coolify; do not commit real values.
DATABASE_URL=postgresql://vezvision_api:REPLACE_ME@DATABASE_HOST:5432/vezvision
API_DATABASE_URL=postgresql://vezvision_api:REPLACE_ME@DATABASE_HOST:5432/vezvision
POSTGREST_DATABASE_URL=postgresql://vezvision_postgrest:REPLACE_ME@DATABASE_HOST:5432/vezvision
# Temporary compatibility fallback. Remove after both dedicated URLs are configured.
DATABASE_URL=
ALLOWED_ORIGIN=https://REPLACE_WITH_VEZVISION_DOMAIN
# Optional comma-separated override for multiple website origins.
ALLOWED_ORIGINS=
Expand Down
15 changes: 10 additions & 5 deletions deploy/hetzner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@ API hostname.

1. Apply `schema.sql` to the dedicated `vezvision` database as its owner, then run
`seed-from-repository.sh` to restore the public CMS defaults versioned in this repo.
2. Generate a long, unique password and run `provision-postgrest-role.sql` with it.
3. Set `DATABASE_URL` in Coolify for the restricted `vezvision_api` role, not the
`postgres` owner.
Apply files in `migrations/` in timestamp order and record each applied filename
in the deployment log; every production run starts with a verified backup.
2. Generate two different long, unique passwords and run
`provision-postgrest-role.sql` with `api_password` and `postgrest_password`.
3. Set `API_DATABASE_URL` in Coolify for the restricted `vezvision_api` role and
`POSTGREST_DATABASE_URL` for the dedicated public-read authenticator. These must
be different credentials; neither may use the `postgres` owner.
4. Set `ALLOWED_ORIGIN` or comma-separated `ALLOWED_ORIGINS` to the website origin(s)
allowed to call `/functions/v1/*`.
5. If Cloudflare Turnstile is enabled in the frontend (`VITE_TURNSTILE_SITE_KEY`),
set the matching server-side `TURNSTILE_SECRET_KEY` in the API service.
5. In production, set `VITE_TURNSTILE_SITE_KEY`, the matching server-side
`TURNSTILE_SECRET_KEY`, and `TURNSTILE_EXPECTED_HOSTNAMES`. The API refuses to
start with missing or test credentials.
6. Point `VITE_API_URL` to the public gateway and `VITE_PUBLIC_ASSETS_URL` to the
public MinIO/CDN endpoint. Both values are build-time public configuration.

Expand Down
Loading
Loading