Videonim is a personal board for saving and tagging X (Twitter) videos. Each user has a private board and can:
- Save an X/Twitter status/video link — Videonim validates it, parses the author and tweet id out of the URL, and stores it.
- Embed & watch the video inline, and copy the canonical post link.
- Tag links (Unicode incl. Hebrew, spaces,
_/-; up to 20 tags / 30 chars), then search the board by author or tag (case-insensitive). - Edit tags and delete links.
Boards are private (JWT auth), duplicates are prevented per user (keyed on
tweet_id), and only valid X/Twitter links are accepted.
npm-workspaces monorepo. Two apps (server, web) and two shared libraries
(x-url, contracts) that are published to JFrog Fly as standalone artifacts:
.
├── package.json # workspaces: packages/* + build/test/typecheck scripts
├── docker-compose.yml # local stack: db + server + web
├── db/init.sql # MySQL schema bootstrap (+ isolated videonim_test DB)
├── .github/workflows/ci.yml # CI: test → build/scan/push images → publish npm to Fly
└── packages/
├── x-url/ # @videonim/x-url — pure X-URL parsing → {author, tweet_id}, zero deps
├── contracts/ # @videonim/contracts — zod request schemas + tag rules (zod only)
├── server/ # @videonim/server — Node + TS API (Express, MySQL); bundles the libs
│ ├── src/ # config, db, security, routes, serializers, app, index (bin)
│ ├── config/ # per-env YAML: local | test | preprod | prod
│ ├── Dockerfile # multi-stage; builds from the REPO ROOT (monorepo bundling)
│ └── test/ # Vitest + supertest (needs MySQL)
└── web/ # @videonim/web — React (Vite) SPA served by nginx
├── src/ # components, auth context, api wrapper, twitter widget loader
├── Dockerfile # node build stage → nginx-unprivileged runtime
└── test/ # Vitest + React Testing Library (no services needed)
| Component | Tech | Role |
|---|---|---|
web |
React (Vite) + nginx | SPA UI; nginx serves it and reverse-proxies /api → server |
server |
Node + TypeScript (Express) | REST API, JWT auth, validates/parses X links, MySQL access |
db |
MySQL 8 | Stores users and links (local container; external in deployed envs) |
@videonim/x-url |
TypeScript (tsup) | Pure X/Twitter URL parser — reused by server |
@videonim/contracts |
TypeScript + zod | Shared validation schemas, tag normalization, request types |
browser ──▶ web (nginx :8080) ──/api──▶ server (:8000) ──▶ db (MySQL :3306)
Shared libs are a future-split artifact, not a runtime dependency. The server
imports @videonim/x-url / @videonim/contracts from local workspace source
and tsup bundles them into its image — it declares no @videonim/* deps and
never pulls the published packages at build/run time. They're published to Fly so
the monorepo can later split into separate services.
The project is wired to JFrog Fly as its artifact registry, release tracker, and deployment-visibility layer (Fly does not replace Git or run deployments itself):
- Registry — CI publishes the Docker images (
videonim-server,videonim-web) and the npm packages (@videonim/x-url,@videonim/contracts,@videonim/server) to the Fly registry. Base images can also be pulled through Fly (seeFLY_REGISTRY). - CI auth —
jfrog/fly-action@v1authenticates with GitHub OIDC (no token in the repo) and points Docker/npm at the Fly registry. - Releases — every
mainbuild becomes a traceable Fly release (commits, PRs, artifacts, AI summary), versioned<major>.<minor>.<run_number>. - Runtime environments — deployments are tracked per environment (
test,prod, …) when a cluster pulls images using a Fly environment-scoped pull secret (fly-<env>-registry-secret), so Fly shows what's running where. - Decision records (ACT) — agent decisions are captured into the gitignored
.fly/agent-trace-decision-records.mdas shared, repo-scoped project memory. - Security gate — images are CVE-scanned in CI with Trivy (fails on HIGH/CRITICAL).
The data is saved in a regular MySQL database, in these two tables:
- users:
id,username(unique),password_hash,created_at - links:
id,user_id→ FK tousers(ON DELETE CASCADE),url,author(own indexed column),tweet_id,tags(JSON array),created_at. A(user_id, tweet_id)unique key enforces per-board de-duplication.
Active environment comes from ENVIRONMENT (empty → local); non-secret settings
live in packages/server/config/<env>.yaml. Secrets come only from env vars — the
server fails fast if a required one is missing.
The exact variables you set depend on how you run the project — see the per-setup
tables in Running it locally: one for Docker Compose and one
for local builds & tests. (Deployed environments additionally accept
DB_HOST / DB_PORT overrides to point the server at an external database.)
Prerequisites: Docker + Docker Compose.
cp .env.example .env # fill in secrets — e.g. JWT_SECRET via: openssl rand -hex 32
docker compose up --build # builds & starts db → server → web
# open http://localhost:8080
curl http://localhost:8080/api/healthz # {"status":"ok","environment":"local"}
docker compose down # stop (keeps data)
docker compose down -v # stop and wipe the MySQL volumeVariables — all set in .env. The first three are required (compose refuses to
start without them); the rest are optional. Note the Type column: every variable
here is a runtime environment variable read by the containers, except FLY_REGISTRY,
which only affects the image build.
| Variable | Type | Required | Default | Purpose |
|---|---|---|---|---|
DB_PASSWORD |
runtime env 🔒 | yes | — | App DB user password (server + MySQL) |
DB_ROOT_PASSWORD |
runtime env 🔒 | yes | — | MySQL root password (db container) |
JWT_SECRET |
runtime env 🔒 | yes | — | Signs / verifies JWT auth tokens (server) |
ENVIRONMENT |
runtime env | no | local |
Selects config/<env>.yaml |
DB_NAME |
runtime env | no | videonim |
Database name |
DB_USER |
runtime env | no | videonim |
Database user |
FLY_REGISTRY |
build-time | no | empty → Docker Hub | Base-image registry prefix (trailing slash), e.g. <host>/docker/; used for ${…} substitution + the REGISTRY build arg |
🔒 = secret. (DB_HOST/DB_PORT are fixed to db/3306 by compose, so you don't set them here.)
Note: the server image builds from the repo root (it bundles the workspace
libs), which docker-compose.yml handles via context: . + dockerfile: packages/server/Dockerfile.
npm install # installs all workspaces
npm run build # builds libs first, then server (tsup) + web (vite)
npm run typecheck # libs + server
npm test # x-url + contracts + server + web (Vitest)Variables — npm install / build / typecheck and the x-url / contracts / web
test suites need none. Only the server test suite needs MySQL + the vars below
(all runtime environment variables — no build-time variables in this path):
| Variable | Type | Required | Default | Purpose |
|---|---|---|---|---|
DB_PASSWORD |
runtime env 🔒 | yes | — | Password for your local MySQL |
DB_HOST |
runtime env | recommended | per-env yaml | MySQL host — set 127.0.0.1 for host runs |
DB_USER |
runtime env | recommended | videonim |
MySQL user — set root if connecting as root |
DB_NAME |
runtime env | no | videonim_test (forced by the test harness) |
Isolated test database |
JWT_SECRET |
runtime env | no | throwaway test default | Signs JWTs during tests |
ENVIRONMENT |
runtime env | no | local |
Selects config/<env>.yaml |
🔒 = secret. Start MySQL first, then run with those vars:
docker compose up -d db
DB_HOST=127.0.0.1 DB_USER=root DB_PASSWORD=<root-pw> DB_NAME=videonim_test \
JWT_SECRET=test-secret ENVIRONMENT=local npm run test -w @videonim/serverNo Node on your machine? Run the whole thing in a node:24 container, with a
MySQL container on the same Docker network — npm install && npm test.
All endpoints under /api; protected ones require Authorization: Bearer <token>.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /healthz |
no | Liveness probe |
| POST | /auth/register |
no | Create a user |
| POST | /auth/login |
no | Get a JWT |
| GET | /auth/me |
yes | Current user |
| GET | /links?q=<term> |
yes | List your links; q filters author/tag |
| POST | /links |
yes | Save a link ({url, tags[]}) |
| PATCH | /links/{id} |
yes | Update a link's tags |
| DELETE | /links/{id} |
yes | Delete one of your links |
CI is GitHub Actions (.github/workflows/ci.yml), integrated with Fly.
It builds, scans, and publishes artifacts to JFrog Fly — it does not
deploy to any cluster.
Authentication is OIDC via jfrog/fly-action@v1 (the workflow has
permissions: id-token: write); no Fly token is stored in the repo.
Four jobs:
changes(always) — detects whether a non-README file under the watched paths changed. If onlyREADME.mdfiles (or nothing relevant) changed, the jobs below are skipped (so a skipped-but-requiredtestcheck still lets README-only PRs merge).test(when relevant files changed) — MySQL service container;npm ci,npm run typecheck,npm test(all four packages).build_scan_push(main only) — builds thevideonim-server(root context) andvideonim-webimages, scans them with Trivy (fails on HIGH/CRITICAL,--ignore-unfixed), then pushes both to the Fly Docker registry tagged with the commit SHA andlatest.publish_package(main only) — builds the libs + server and publishes@videonim/x-url,@videonim/contracts,@videonim/serverto the Fly npm registry.jfrog/fly-action@v1configures the runner's.npmrc(registry + OIDC token), sonpm publishresolves to Fly. Versions are CI-derived:<major>.<minor>.<run_number>(bump major/minor inpackage.json).
Do not remove
jfrog/fly-action@v1— it is the registry + auth setup for both Docker and npm. Without it, pushes/publishes go nowhere (or to public npm).
CI only publishes artifacts; ArgoCD would be the deployer and Fly would track the result. For a chart driven by ArgoCD to run and be tracked by Fly:
- Images reference the Fly registry —
<your-fly-subdomain>.jfrog.io/docker/videonim-server:<tag>and…/videonim-web:<tag>(pin to the commit-SHA tag for prod;:latestis fine fortest). The registry host isn't hardcoded in the repo — base images use aREGISTRYbuild arg (FLY_REGISTRYin compose), defaulting to public Docker Hub. - A Fly environment-scoped image pull secret exists in the target namespace and
is referenced via
imagePullSecrets. Create it out-of-band per environment (fly-<env>-registry-secret, e.g.fly-test-registry-secret) — ArgoCD/Fly do not create it for you, and the matching Fly runtime environment must already exist. When the cluster makes the authenticated pull with that token, Fly records the deployment under that environment. (imagePullPolicy: Alwaysis recommended so each rollout triggers a fresh, tracked pull that Fly records.) - Config & secrets: a
ConfigMapfor non-secret env (ENVIRONMENT,DB_*), and aSecretforJWT_SECRET/DB_PASSWORDcreated out-of-band (never committed). - Stable service names
serverandweb— the web nginx proxies/api/tohttp://server:8000, so the backend Service must be namedserver. - A database — an in-cluster MySQL (local/test) or an external DB via the
DB_HOST/DB_PASSWORDoverrides. - ArgoCD specifics: ArgoCD reads the chart from Git, so the chart must be
committed and ArgoCD given repo credentials (this repo is private). An
ArgoCD
Applicationpoints at the chart path, target revision (branch/tag), and destination namespace, and syncs it.
- Passwords hashed with bcrypt (bcryptjs); generic auth errors (no user enumeration).
- JWT (HS256), configurable expiry, secret from env only.
- Parameterized queries only (mysql2) — no SQL injection, including tag search.
- Strict zod input validation (URL pattern, length caps, tag count/length).
- Every link query is scoped to the current user; create/update/delete check ownership (no IDOR).
- nginx sends security headers / CSP (allows only the X embed widget's hosts).
- Containers run non-root; base images are security-patched;
.env/secrets stay out of images and git.
- No Terraform / cloud infra — local-first; CI publishes to Fly but does not deploy.
- Auth is username + password; linking X/OAuth accounts is planned.