openvibers/openvibe-git Commits

OpenVibe.Git G1: hosting, browsing, GitHub mirrors and insights (#2)

OP OpenVibers committed 4 days ago · 62aa53e0b5 · 70 files, +7139 −2

* OpenVibe.Git G1: hosting, browsing, GitHub mirrors and insights

* Screens: viewport captures of home, explore, a repository, a file and insights

* Show the tree's last commit, a mirror's push time, and issues without PRs

* Pass a file path to update-index as its own argument

* Note why a private git read asks for a token

* Design pass: the skip link waits for the keyboard, Markdown files read as documents, legends show the top four, the weeks fill the chart, no eyebrow

* A service acts for a person: delegating calls and Git tokens

A first-party service (Codes first) holds a typed Network token for
openvibe.git and names the person it works for in X-OV-Subject. POST
/repos then makes the repository in that person's name, the profile
lists their private ones, and POST /tokens mints their own Git token for
clone and push. The service token carries no typ/purpose, so its
actor_type and svc: subject are what identify it.

The new test covers the delegation, the owner_username clash, the
private repository view, the token it mints and a contents write as the
person.

* Name the Git capabilities git.repo.read and git.repo.write

A capability id in the catalog carries at least three segments, so the
two names the service checks and Network grants are git.repo.read and
git.repo.write. Git tokens keep their own scope names in step with them.

Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..a50a167
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,50 @@
++# OpenVibe.Git — every value here is read by server/config.js. Production: /etc/openvibe/git.env.
++# Only variable NAMES appear in code and docs; never put a secret in this file.
++
++NODE_ENV=production
++PORT=5270
++HOST=127.0.0.1
++BASE_URL=https://git.openvibe.codes
++TRUST_PROXY=2
++
++# PostgreSQL (ADR-035): DATABASE_URL serves through PgBouncer, DATABASE_DIRECT_URL migrates.
++DATABASE_URL=
++DATABASE_DIRECT_URL=
++GIT_PGLITE_DIR=
++
++# The bare repositories and the mirror work: DATA_DIR/repos/<owner>/<name>.git
++GIT_DATA_DIR=/var/lib/openvibe/git
++
++# OpenVibe.Network sign-in (OAuth client `git`) and offline token verification.
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++OV_OAUTH_CLIENT_ID=git
++OV_OAUTH_CLIENT_SECRET=
++COOKIE_SECURE=true
++
++# GitHub: the API for the card and mirror metadata, the push webhook secret (Shared/Contracts and every
++# official repository send it), and the token a push mirror uses for the OpenVibers repositories.
++GIT_GITHUB_API_URL=https://api.github.com
++GIT_GITHUB_TOKEN=
++GIT_GITHUB_WEBHOOK_SECRET=
++GIT_GITHUB_PUSH_TOKEN=
++
++# Clone tokens: opaque, stored as a SHA-256 hash; 32+ characters of key encrypt a stored push-mirror token.
++GIT_TOKEN_TTL_HOURS=1
++GIT_SECRET_KEY=
++
++# Limits (bytes). A push larger than this is refused; a repository may grow to the cap.
++GIT_MAX_PUSH_BYTES=536870912
++GIT_MAX_REPO_BYTES=3221225472
++GIT_MAX_FILE_BYTES=2097152
++
++# Mirrors: the ten-minute poll and how long one fetch may take.
++GIT_MIRROR_POLL_MS=600000
++GIT_MIRROR_TIMEOUT_MS=600000
++
++# Account export/deletion events from OpenVibe.Network (loopback only).
++GIT_EVENTS_SECRET=
++
++# Request limits per address per minute.
++GIT_API_RATE_LIMIT_PER_MIN=180
++GIT_PAGE_RATE_LIMIT_PER_MIN=300
ddiff --git a/README.md b/README.md
iindex 99e7bc2..e50553a 100644
---- a/README.md
++++ b/README.md
@@@ -28,5 +28,10 @@ Production reads `/etc/openvibe/git.env`; see `.env.example`. `DATABASE_URL` ser
- `contracts` — `git.repo@1`, `git.commit@1`, `git.tree@1`, `git.insights@1` (OpenVibe.Contracts).
- `deploy/` — the systemd unit and the nginx vhost for `git.openvibe.codes`.
--Sign-in is OpenVibe only. Git over HTTPS uses a short-lived Git token made on the repository page (`git.read` /
--`git.write`) or a typed Network token for the same audience; no second account system exists.
++Sign-in is OpenVibe only. Git over HTTPS uses a short-lived Git token made on the repository page (`git.repo.read` /
++`git.repo.write`) or a typed Network token for the same audience; no second account system exists.
++
++A first-party service (Codes, Run, Link) holds a typed Network token for `openvibe.git` and acts for the person it
++names in `X-OV-Subject`: `POST /api/v1/repos` (with `owner_username`), `GET /api/v1/profile/:username` and
++`POST /api/v1/tokens` then work as that person, and the Git token it mints is the person's own for clone and push.
++A service never holds a person's password.
ddiff --git a/deploy/host/git.json b/deploy/host/git.json
nnew file mode 100644
iindex 0000000..1b52cba
---- /dev/null
++++ b/deploy/host/git.json
@@@ -0,0 +1,75 @@
++{
++ "_about": "The ovhost service entry for OpenVibe Git, to be added to /etc/openvibe/host.json at the cut-over. It is not installed by this change: Forgejo still owns git.openvibe.codes until Opus switches the vhost. Port 5270, drill 15270, database ov_git, repositories under /var/lib/openvibe/git.",
++ "repo": "/opt/openvibe.git",
++ "owner": "ubuntu",
++ "runAs": "ubuntu",
++ "units": [
++ "openvibe-git.service"
++ ],
++ "unitSources": {
++ "openvibe-git.service": "deploy/systemd/openvibe-git.service"
++ },
++ "installUnits": true,
++ "envFile": "/etc/openvibe/git.env",
++ "env": {
++ "required": [
++ "BASE_URL",
++ "DATABASE_URL",
++ "DATABASE_DIRECT_URL",
++ "OV_OAUTH_CLIENT_ID",
++ "OV_OAUTH_CLIENT_SECRET",
++ "GIT_DATA_DIR",
++ "GIT_SECRET_KEY"
++ ]
++ },
++ "port": 5270,
++ "ready": {
++ "url": "http://127.0.0.1:5270/api/ready",
++ "timeoutSeconds": 30
++ },
++ "noRestartPaths": [
++ "public/",
++ "test/",
++ "deploy/nginx/",
++ "docs/",
++ "**/*.md"
++ ],
++ "databases": [
++ {
++ "name": "git",
++ "engine": "postgresql",
++ "database": "ov_git"
++ }
++ ],
++ "nginx": {
++ "vhost": "git.openvibe.codes.conf",
++ "repoVhost": "deploy/nginx/git.openvibe.codes.conf",
++ "variant": "https"
++ },
++ "drill": {
++ "port": 15270,
++ "ready": "/api/ready",
++ "databases": {
++ "git": {
++ "url": "DATABASE_URL",
++ "directUrl": "DATABASE_DIRECT_URL"
++ }
++ },
++ "env": {
++ "PORT": "{port}",
++ "HOST": "127.0.0.1",
++ "OV_NETWORK_INTERNAL_URL": "http://127.0.0.1:9",
++ "OV_OAUTH_CLIENT_SECRET": "",
++ "GIT_DATA_DIR": "{tmp}/repos",
++ "GIT_DRILL": "1"
++ },
++ "dirs": [
++ "{tmp}/repos"
++ ],
++ "compare": [
++ "/api/health",
++ "/api/ready",
++ "/api/v1/stats"
++ ]
++ }
++}
ddiff --git a/deploy/nginx/git.openvibe.codes.conf b/deploy/nginx/git.openvibe.codes.conf
nnew file mode 100644
iindex 0000000..ead321c
---- /dev/null
++++ b/deploy/nginx/git.openvibe.codes.conf
@@@ -0,0 +1,111 @@
++## ═══════════════════════════════════════════════════════════════
++## OpenVibe Git — Nginx configuration (REFERENCE, NOT INSTALLED YET)
++##
++## Deployed to /etc/nginx/sites-available/git.openvibe.codes.conf by the cut-over. Until then Forgejo still owns
++## the name. The Node app (port 5270, systemd unit openvibe-git) serves:
++##
++## /<owner>/<name>.git/… git over HTTPS (git http-backend) — clone and push
++## /api/v1/* the JSON API and GitHub's push webhook
++## everything else the pages
++##
++## A clone and a push stream a pack in both directions: no proxy buffering, no request buffering, and a connection
++## may stay open for ten minutes. The body cap matches the service's own receive.maxInputSize (512 MB).
++## Never log request bodies, query strings or cookies here; the audit log is the service's, without tokens.
++## ═══════════════════════════════════════════════════════════════
++
++limit_req_zone $binary_remote_addr zone=git_auth:10m rate=10r/m;
++limit_req_zone $binary_remote_addr zone=git_api:10m rate=10r/s;
++limit_req_zone $binary_remote_addr zone=git_clone:10m rate=60r/m;
++
++server {
++ listen 80;
++ listen [::]:80;
++ server_name git.openvibe.codes;
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://$host$request_uri; }
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name git.openvibe.codes;
++ include snippets/security-txt.conf;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.codes/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.codes/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/git.openvibe.codes.access.log;
++ error_log /var/log/nginx/git.openvibe.codes.error.log;
++
++ # A push is capped by the service at GIT_MAX_PUSH_BYTES (512 MB); this is the same ceiling at the edge.
++ client_max_body_size 512m;
++ client_body_timeout 600s;
++
++ add_header X-Content-Type-Options nosniff always;
++ add_header Strict-Transport-Security "max-age=31536000" always;
++
++ gzip on;
++ gzip_proxied any;
++ gzip_vary on;
++ gzip_comp_level 5;
++ gzip_min_length 1024;
++ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;
++
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++
++ # ── Git over HTTPS: the wire protocol streams ─────────────────
++ # /<owner>/<name>.git/… and the /git/ prefix the same app also answers.
++ location ~ ^/(?:git/)?[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9._-]*\.git/ {
++ limit_req zone=git_clone burst=40 nodelay;
++ limit_req_status 429;
++ proxy_buffering off;
++ proxy_request_buffering off;
++ proxy_read_timeout 600s;
++ proxy_send_timeout 600s;
++ proxy_pass http://127.0.0.1:5270;
++ }
++
++ # ── GitHub's push webhook: the body is signed as it was sent ──
++ location = /api/v1/webhooks/github {
++ limit_req zone=git_api burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5270;
++ }
++
++ location = /auth/me {
++ limit_req zone=git_api burst=30 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5270;
++ }
++
++ location /auth/ {
++ limit_req zone=git_auth burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5270;
++ }
++
++ location /api/ {
++ limit_req zone=git_api burst=30 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5270;
++ }
++
++ location ~* ^/metrics(/|$) { return 404; }
++ location ~* ^/internal(/|$) { return 404; }
++
++ location / {
++ proxy_pass http://127.0.0.1:5270;
++ }
++
++ location ~ /\.(?!well-known) {
++ deny all;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-git.service b/deploy/systemd/openvibe-git.service
nnew file mode 100644
iindex 0000000..1e6504d
---- /dev/null
++++ b/deploy/systemd/openvibe-git.service
@@@ -0,0 +1,32 @@
++[Unit]
++Description=OpenVibe Git — git.openvibe.codes: repositories, browsing, GitHub mirrors and insights
++# Network (sign-in, JWKS) and the database should be up first; the site still answers its git routes if Network is
++# briefly away (a public clone needs no token, and a token is verified offline against the last good keys).
++After=network.target postgresql.service pgbouncer.service
++Wants=openvibe-network.service postgresql.service pgbouncer.service
++
++[Service]
++Type=simple
++User=ubuntu
++Group=ubuntu
++WorkingDirectory=/opt/openvibe.git
++EnvironmentFile=/etc/openvibe/git.env
++Environment=NODE_ENV=production
++Environment=PORT=5270
++Environment=HOST=127.0.0.1
++Environment=PATH=/usr/local/bin:/usr/bin:/bin
++ExecStart=/usr/bin/env node /opt/openvibe.git/server/index.js
++Restart=always
++RestartSec=5
++KillSignal=SIGTERM
++# An in-flight git clone or push gets the 10 s drain, then the process stops; git clients retry.
++TimeoutStopSec=15
++NoNewPrivileges=true
++PrivateTmp=true
++ProtectSystem=strict
++ProtectHome=true
++# The bare repositories: DATA_DIR/repos/<owner>/<name>.git, under /var/lib/openvibe/git.
++ReadWritePaths=/var/lib/openvibe/git
++
++[Install]
++WantedBy=multi-user.target
ddiff --git a/docs/screens/README.md b/docs/screens/README.md
nnew file mode 100644
iindex 0000000..c01537f
---- /dev/null
++++ b/docs/screens/README.md
@@@ -0,0 +1,20 @@
++# Screenshots
++
++Captured on 2026-11-09 against the service running locally on PGlite, with three real repositories mirrored from
++GitHub (`OpenVibers/OpenVibe.SDK`, `OpenVibers/OpenVibe.Contracts`, `OpenVibers/OpenVibe.Design`). The views and
++clones in Insights are from actually loading these pages and running `git clone` against the local server, not
++seeded numbers; stars are real (zero). The shared navbar and footer render from `openvibe-shared`; the theme is
++pinned to `vibe` (dark) and `cloud` (light) in the capture because the Network theme endpoint is not reachable from
++`127.0.0.1` in this environment.
++
++Each page is captured at 390 px and 1280 px, dark and light:
++
++- `home-*` — the hero, live numbers, trending, activity and the official rail.
++- `explore-*` — search, filters and repository cards.
++- `repository-*` — a repository header, Get it sheet and file tree (viewport).
++- `file-*` — a README highlighted on the server with line links (viewport).
++- `insights-*` — views, unique visitors, clones, stars and commits per week.
++
++The check run asserted `document.documentElement.scrollWidth === window.innerWidth` at every capture (no sideways
++scroll), and there are no page errors. The only console messages are the Network frame/theme CORS refusals described
++above; on `git.openvibe.codes` it is a first-party origin.
ddiff --git a/docs/screens/explore-1280-dark.jpg b/docs/screens/explore-1280-dark.jpg
nnew file mode 100644
iindex 0000000..ed21206
BBinary files /dev/null and b/docs/screens/explore-1280-dark.jpg differ
ddiff --git a/docs/screens/explore-1280-light.jpg b/docs/screens/explore-1280-light.jpg
nnew file mode 100644
iindex 0000000..b597814
BBinary files /dev/null and b/docs/screens/explore-1280-light.jpg differ
ddiff --git a/docs/screens/explore-390-dark.jpg b/docs/screens/explore-390-dark.jpg
nnew file mode 100644
iindex 0000000..e910beb
BBinary files /dev/null and b/docs/screens/explore-390-dark.jpg differ
ddiff --git a/docs/screens/explore-390-light.jpg b/docs/screens/explore-390-light.jpg
nnew file mode 100644
iindex 0000000..467feba
BBinary files /dev/null and b/docs/screens/explore-390-light.jpg differ
ddiff --git a/docs/screens/file-1280-dark.jpg b/docs/screens/file-1280-dark.jpg
nnew file mode 100644
iindex 0000000..b6ea020
BBinary files /dev/null and b/docs/screens/file-1280-dark.jpg differ
ddiff --git a/docs/screens/file-1280-light.jpg b/docs/screens/file-1280-light.jpg
nnew file mode 100644
iindex 0000000..fcbdc87
BBinary files /dev/null and b/docs/screens/file-1280-light.jpg differ
ddiff --git a/docs/screens/file-390-dark.jpg b/docs/screens/file-390-dark.jpg
nnew file mode 100644
iindex 0000000..709fdb9
BBinary files /dev/null and b/docs/screens/file-390-dark.jpg differ
ddiff --git a/docs/screens/file-390-light.jpg b/docs/screens/file-390-light.jpg
nnew file mode 100644
iindex 0000000..24bc257
BBinary files /dev/null and b/docs/screens/file-390-light.jpg differ
ddiff --git a/docs/screens/home-1280-dark.jpg b/docs/screens/home-1280-dark.jpg
nnew file mode 100644
iindex 0000000..bdda0c0
BBinary files /dev/null and b/docs/screens/home-1280-dark.jpg differ
ddiff --git a/docs/screens/home-1280-light.jpg b/docs/screens/home-1280-light.jpg
nnew file mode 100644
iindex 0000000..8e107d4
BBinary files /dev/null and b/docs/screens/home-1280-light.jpg differ
ddiff --git a/docs/screens/home-390-dark.jpg b/docs/screens/home-390-dark.jpg
nnew file mode 100644
iindex 0000000..a218bed
BBinary files /dev/null and b/docs/screens/home-390-dark.jpg differ
ddiff --git a/docs/screens/home-390-light.jpg b/docs/screens/home-390-light.jpg
nnew file mode 100644
iindex 0000000..f62dc20
BBinary files /dev/null and b/docs/screens/home-390-light.jpg differ
ddiff --git a/docs/screens/insights-1280-dark.jpg b/docs/screens/insights-1280-dark.jpg
nnew file mode 100644
iindex 0000000..b982cb5
BBinary files /dev/null and b/docs/screens/insights-1280-dark.jpg differ
ddiff --git a/docs/screens/insights-1280-light.jpg b/docs/screens/insights-1280-light.jpg
nnew file mode 100644
iindex 0000000..f6ff0e3
BBinary files /dev/null and b/docs/screens/insights-1280-light.jpg differ
ddiff --git a/docs/screens/insights-390-dark.jpg b/docs/screens/insights-390-dark.jpg
nnew file mode 100644
iindex 0000000..772308d
BBinary files /dev/null and b/docs/screens/insights-390-dark.jpg differ
ddiff --git a/docs/screens/insights-390-light.jpg b/docs/screens/insights-390-light.jpg
nnew file mode 100644
iindex 0000000..1db2e8b
BBinary files /dev/null and b/docs/screens/insights-390-light.jpg differ
ddiff --git a/docs/screens/repository-1280-dark.jpg b/docs/screens/repository-1280-dark.jpg
nnew file mode 100644
iindex 0000000..084153e
BBinary files /dev/null and b/docs/screens/repository-1280-dark.jpg differ
ddiff --git a/docs/screens/repository-1280-light.jpg b/docs/screens/repository-1280-light.jpg
nnew file mode 100644
iindex 0000000..98b0f04
BBinary files /dev/null and b/docs/screens/repository-1280-light.jpg differ
ddiff --git a/docs/screens/repository-390-dark.jpg b/docs/screens/repository-390-dark.jpg
nnew file mode 100644
iindex 0000000..fabf27f
BBinary files /dev/null and b/docs/screens/repository-390-dark.jpg differ
ddiff --git a/docs/screens/repository-390-light.jpg b/docs/screens/repository-390-light.jpg
nnew file mode 100644
iindex 0000000..f807db9
BBinary files /dev/null and b/docs/screens/repository-390-light.jpg differ
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..30ea531
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -0,0 +1,117 @@
++-- phase: expand
++-- OpenVibe.Git (git.openvibe.codes), G1: repositories, their GitHub mirrors, stars and watches, clone tokens,
++-- views and clones for insights, releases, the activity feed and the audit log.
++--
++-- Nothing here is a git object: the commits, trees and blobs live in the bare repositories on disk
++-- (DATA_DIR/repos/<owner>/<name>.git). A clone token is stored only as a SHA-256 hash of 32 random bytes; a
++-- push-mirror token is stored sealed (AES-256-GCM) and is never shown again.
++
++CREATE TABLE git_repos (
++ id text PRIMARY KEY, -- repo_<ulid>
++ owner_username text NOT NULL, -- lower-case OpenVibe username
++ owner_subject text, -- usr_…; NULL for the organisation account
++ name text NOT NULL, -- lower-case, never ending in .git
++ description text NOT NULL DEFAULT '',
++ visibility text NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'private')),
++ default_branch text NOT NULL DEFAULT 'main',
++ topics jsonb NOT NULL DEFAULT '[]'::jsonb,
++ language text,
++ languages jsonb NOT NULL DEFAULT '{}'::jsonb,
++ official boolean NOT NULL DEFAULT false,
++ github_full_name text, -- OpenVibers/OpenVibe.Codes when it has a twin
++ mirror_url text, -- https://github.com/<owner>/<name>.git
++ mirror_state text NOT NULL DEFAULT 'ready' CHECK (mirror_state IN ('ready', 'syncing', 'error')),
++ mirror_last_synced_at timestamptz,
++ mirror_error text,
++ push_mirror_url text,
++ push_mirror_token text, -- sealed: v1.<iv>.<tag>.<ciphertext>, never logged
++ push_mirror_state text NOT NULL DEFAULT 'off' CHECK (push_mirror_state IN ('off', 'on', 'error')),
++ fork_of text, -- the repository this one was forked from
++ created_by_subject text,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ pushed_at timestamptz,
++ UNIQUE (owner_username, name)
++);
++CREATE INDEX git_repos_public ON git_repos (visibility, pushed_at DESC);
++CREATE INDEX git_repos_mirror ON git_repos (mirror_url) WHERE mirror_url IS NOT NULL;
++
++CREATE TABLE git_stars (
++ repo_id text NOT NULL REFERENCES git_repos (id) ON DELETE CASCADE,
++ subject text NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (repo_id, subject)
++);
++CREATE INDEX git_stars_subject ON git_stars (subject, created_at DESC);
++
++CREATE TABLE git_watches (
++ repo_id text NOT NULL REFERENCES git_repos (id) ON DELETE CASCADE,
++ subject text NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (repo_id, subject)
++);
++
++CREATE TABLE git_tokens (
++ id text PRIMARY KEY, -- tok_<ulid>
++ subject text NOT NULL, -- usr_… or svc:<name>
++ actor_type text NOT NULL DEFAULT 'user' CHECK (actor_type IN ('user', 'service')),
++ label text,
++ token_hash text NOT NULL UNIQUE, -- hex SHA-256 of the token
++ scopes jsonb NOT NULL DEFAULT '[]'::jsonb, -- ["git.read"] / ["git.read","git.write"]
++ expires_at timestamptz NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ last_used_at timestamptz,
++ revoked_at timestamptz
++);
++CREATE INDEX git_tokens_subject ON git_tokens (subject, created_at DESC);
++
++CREATE TABLE git_views (
++ repo_id text NOT NULL REFERENCES git_repos (id) ON DELETE CASCADE,
++ day date NOT NULL,
++ visitor_hash text NOT NULL, -- sha256(ip + user agent + day), first 32 hex
++ views integer NOT NULL DEFAULT 1,
++ PRIMARY KEY (repo_id, day, visitor_hash)
++);
++CREATE INDEX git_views_day ON git_views (repo_id, day);
++
++CREATE TABLE git_clones (
++ repo_id text NOT NULL REFERENCES git_repos (id) ON DELETE CASCADE,
++ day date NOT NULL,
++ subject_hash text NOT NULL, -- sha256(subject or address + day), first 32 hex
++ count integer NOT NULL DEFAULT 1,
++ PRIMARY KEY (repo_id, day, subject_hash)
++);
++CREATE INDEX git_clones_day ON git_clones (repo_id, day);
++
++CREATE TABLE git_releases (
++ id text PRIMARY KEY, -- rel_<ulid>
++ repo_id text NOT NULL REFERENCES git_repos (id) ON DELETE CASCADE,
++ tag text NOT NULL,
++ name text NOT NULL DEFAULT '',
++ body text NOT NULL DEFAULT '',
++ author_subject text,
++ published_at timestamptz NOT NULL DEFAULT now(),
++ UNIQUE (repo_id, tag)
++);
++
++CREATE TABLE git_events (
++ id text PRIMARY KEY, -- evt_<ulid>
++ repo_id text REFERENCES git_repos (id) ON DELETE CASCADE,
++ type text NOT NULL CHECK (type IN ('push', 'create', 'release', 'star', 'fork', 'mirror')),
++ actor_subject text,
++ actor_username text,
++ detail jsonb NOT NULL DEFAULT '{}'::jsonb,
++ at timestamptz NOT NULL DEFAULT now()
++);
++CREATE INDEX git_events_recent ON git_events (at DESC);
++
++CREATE TABLE git_audit (
++ id bigserial PRIMARY KEY,
++ at timestamptz NOT NULL DEFAULT now(),
++ actor_subject text,
++ action text NOT NULL,
++ repo_id text REFERENCES git_repos (id) ON DELETE SET NULL,
++ detail jsonb NOT NULL DEFAULT '{}'::jsonb,
++ address text -- the client address, never a token
++);
++CREATE INDEX git_audit_recent ON git_audit (at DESC);
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..a84aeea
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1599 @@
++{
++ "name": "openvibe-git",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-git",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "highlight.js": "^11.10.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.356.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.108.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.163.0",
++ "pg": "^8.23.1"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.13"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++ },
++ "node_modules/@electric-sql/pglite": {
++ "version": "0.5.15",
++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.15.tgz",
++ "integrity": "sha512-ERZWgaRTxueGV4cvL1tfpAOzFCWTXoUtbCBqnf2FhHDVs/so9eAKaanvrwWF9Dntx7BYsmFQujVQ0/B4S8Mmiw==",
++ "devOptional": true,
++ "license": "Apache-2.0"
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.9",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",
++ "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/boolbase": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/buffer-equal-constant-time": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/bytes": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/content-disposition": {
++ "version": "0.5.4",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "5.2.1"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/content-type": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie": {
++ "version": "0.7.2",
++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie-parser": {
++ "version": "1.4.7",
++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
++ "license": "MIT",
++ "dependencies": {
++ "cookie": "0.7.2",
++ "cookie-signature": "1.0.6"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/cookie-signature": {
++ "version": "1.0.6",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
++ "license": "MIT"
++ },
++ "node_modules/css-select": {
++ "version": "7.0.0",
++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0",
++ "css-what": "^8.0.0",
++ "domhandler": "^6.0.1",
++ "domutils": "^4.0.2",
++ "nth-check": "^3.0.1"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/css-what": {
++ "version": "8.0.0",
++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/cssomnom": {
++ "version": "0.1.11",
++ "resolved": "https://registry.npmjs.org/cssomnom/-/cssomnom-0.1.11.tgz",
++ "integrity": "sha512-P4RkO1Sq2Ual77xypEmAc4MkM5k2Ha4UwtBPCOpJmXculGCYPgzsU/xnOCy084esZaPePgGkkJg4Il/HE7GS5A==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/dom-serializer": {
++ "version": "3.1.1",
++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^3.0.0",
++ "domhandler": "^6.0.0",
++ "entities": "^8.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
++ }
++ },
++ "node_modules/domelementtype": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ }
++ },
++ "node_modules/domhandler": {
++ "version": "6.0.1",
++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "domelementtype": "^3.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/domhandler?sponsor=1"
++ }
++ },
++ "node_modules/domutils": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "dom-serializer": "^3.0.0",
++ "domelementtype": "^3.0.0",
++ "domhandler": "^6.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/domutils?sponsor=1"
++ }
++ },
++ "node_modules/dotenv": {
++ "version": "16.6.1",
++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://dotenvx.com"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/ecdsa-sig-formatter": {
++ "version": "1.0.11",
++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/ee-first": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
++ "license": "MIT"
++ },
++ "node_modules/encodeurl": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz",
++ "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/entities": {
++ "version": "8.1.0",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "node_modules/es-define-property": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-errors": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-object-atoms": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "license": "MIT"
++ },
++ "node_modules/etag": {
++ "version": "1.8.1",
++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/express": {
++ "version": "4.22.3",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "~1.3.8",
++ "array-flatten": "1.1.1",
++ "body-parser": "~1.20.5",
++ "content-disposition": "~0.5.4",
++ "content-type": "~1.0.4",
++ "cookie": "~0.7.1",
++ "cookie-signature": "~1.0.6",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "finalhandler": "~1.3.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.0",
++ "merge-descriptors": "1.0.3",
++ "methods": "~1.1.2",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "path-to-regexp": "~0.1.13",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.16.0",
++ "range-parser": "~1.2.1",
++ "safe-buffer": "5.2.1",
++ "send": "~0.19.0",
++ "serve-static": "~1.16.2",
++ "setprototypeof": "1.2.0",
++ "statuses": "~2.0.1",
++ "type-is": "~1.6.18",
++ "utils-merge": "1.0.1",
++ "vary": "~1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/express-rate-limit": {
++ "version": "7.5.1",
++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 16"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/express-rate-limit"
++ },
++ "peerDependencies": {
++ "express": ">= 4.11"
++ }
++ },
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.9",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
++ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/forwarded": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fresh": {
++ "version": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/function-bind": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-intrinsic": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "es-define-property": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "es-object-atoms": "^1.1.1",
++ "function-bind": "^1.1.2",
++ "get-proto": "^1.0.1",
++ "gopd": "^1.2.0",
++ "has-symbols": "^1.1.0",
++ "hasown": "^2.0.2",
++ "math-intrinsics": "^1.1.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
++ "license": "MIT",
++ "dependencies": {
++ "dunder-proto": "^1.0.1",
++ "es-object-atoms": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/gopd": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/has-symbols": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/hasown": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
++ "license": "MIT",
++ "dependencies": {
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/helmet": {
++ "version": "8.3.0",
++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=18.0.0"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/EvanHahn"
++ }
++ },
++ "node_modules/highlight.js": {
++ "version": "11.12.0",
++ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz",
++ "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
++ "license": "BSD-3-Clause",
++ "engines": {
++ "node": ">=12.0.0"
++ }
++ },
++ "node_modules/html-escaper": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
++ "license": "MIT"
++ },
++ "node_modules/htmlparser2": {
++ "version": "10.1.0",
++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
++ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
++ "funding": [
++ "https://github.com/fb55/htmlparser2?sponsor=1",
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.3",
++ "domutils": "^3.2.2",
++ "entities": "^7.0.1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/dom-serializer": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.2",
++ "entities": "^4.2.0"
++ },
++ "funding": {
++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
++ "version": "4.5.0",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=0.12"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/domelementtype": {
++ "version": "2.3.0",
++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "BSD-2-Clause"
++ },
++ "node_modules/htmlparser2/node_modules/domhandler": {
++ "version": "5.0.3",
++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "domelementtype": "^2.3.0"
++ },
++ "engines": {
++ "node": ">= 4"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/domhandler?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/domutils": {
++ "version": "3.2.2",
++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
++ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "dom-serializer": "^2.0.0",
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.3"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/domutils?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/entities": {
++ "version": "7.0.1",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=0.12"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "node_modules/http-errors": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/iconv-lite": {
++ "version": "0.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "license": "ISC"
++ },
++ "node_modules/ipaddr.js": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "license": "MIT"
++ },
++ "node_modules/jsonwebtoken": {
++ "version": "9.0.3",
++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
++ "license": "MIT",
++ "dependencies": {
++ "jws": "^4.0.1",
++ "lodash.includes": "^4.3.0",
++ "lodash.isboolean": "^3.0.3",
++ "lodash.isinteger": "^4.0.4",
++ "lodash.isnumber": "^3.0.3",
++ "lodash.isplainobject": "^4.0.6",
++ "lodash.isstring": "^4.0.1",
++ "lodash.once": "^4.0.0",
++ "ms": "^2.1.1",
++ "semver": "^7.5.4"
++ },
++ "engines": {
++ "node": ">=12",
++ "npm": ">=6"
++ }
++ },
++ "node_modules/jsonwebtoken/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/jwa": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer-equal-constant-time": "^1.0.1",
++ "ecdsa-sig-formatter": "1.0.11",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/jws": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
++ "license": "MIT",
++ "dependencies": {
++ "jwa": "^2.0.1",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/linkedom": {
++ "version": "0.18.15",
++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.15.tgz",
++ "integrity": "sha512-o0w7eD/SvoCao+pUi6dzocCLuMR11g4Tv/et0W2OUzwWmUcewntSzyl+Ua/K+0yKym0Y/Mav3nFAaI5Av5jvZg==",
++ "license": "ISC",
++ "dependencies": {
++ "css-select": "^7.0.0",
++ "cssomnom": "^0.1.11",
++ "html-escaper": "^3.0.3",
++ "htmlparser2": "^10.1.0",
++ "uhyphen": "^0.2.0"
++ },
++ "engines": {
++ "node": ">=16"
++ },
++ "peerDependencies": {
++ "canvas": ">= 2"
++ },
++ "peerDependenciesMeta": {
++ "canvas": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/lodash.includes": {
++ "version": "4.3.0",
++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isboolean": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isinteger": {
++ "version": "4.0.4",
++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isnumber": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isplainobject": {
++ "version": "4.0.6",
++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isstring": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.once": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
++ "license": "MIT"
++ },
++ "node_modules/math-intrinsics": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/media-typer": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "license": "MIT"
++ },
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/nth-check": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/nth-check?sponsor=1"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.2",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
++ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.356.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.356.0",
++ "integrity": "sha512-TQUyIS20Oj28b8fMmjWDQBcz5nQe78U3bMBKLof7TdXqL2utaojw2YaXdFpAkW6BvG9UUoh/m/0iB/ptCM5Ozw==",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/openvibe-sdk": {
++ "version": "0.108.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.108.0",
++ "integrity": "sha512-5vOSJEIYlpfbtv2kDyZoW/wexbkZMUJbwBjHB8xsCZKxAZy4Bg8Rkgod84uMNxjWMInhWV/nsVRlIvg7tLjukw==",
++ "license": "MIT",
++ "bin": {
++ "openvibe-download": "bin/openvibe-download.js"
++ },
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "@electric-sql/pglite": "^0.5.8",
++ "iovalkey": "^0.4.0",
++ "pg": "^8.23.0"
++ },
++ "peerDependenciesMeta": {
++ "@electric-sql/pglite": {
++ "optional": true
++ },
++ "iovalkey": {
++ "optional": true
++ },
++ "pg": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/openvibe-shared": {
++ "version": "4.163.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.163.0",
++ "integrity": "sha512-sQlvffOpQRrKS73GkptsSWebSQO5q2SFgUGolGFxH7Ix5NCTarPaD1VUAX6Qeru/mZaW99v+Nuu89rJ+rVaWfw==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0",
++ "linkedom": "^0.18.13"
++ },
++ "bin": {
++ "openvibe-build-assets": "scripts/build-assets.js"
++ }
++ },
++ "node_modules/parseurl": {
++ "version": "1.3.3",
++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/path-to-regexp": {
++ "version": "0.1.13",
++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
++ "license": "MIT"
++ },
++ "node_modules/pg": {
++ "version": "8.23.1",
++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-connection-string": "^2.14.1",
++ "pg-pool": "^3.14.0",
++ "pg-protocol": "^1.16.1",
++ "pg-types": "2.2.0",
++ "pgpass": "1.0.5"
++ },
++ "engines": {
++ "node": ">= 16.0.0"
++ },
++ "optionalDependencies": {
++ "pg-cloudflare": "^1.4.1"
++ },
++ "peerDependencies": {
++ "pg-native": ">=3.0.1"
++ },
++ "peerDependenciesMeta": {
++ "pg-native": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/pg-cloudflare": {
++ "version": "1.4.1",
++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
++ "license": "MIT",
++ "optional": true
++ },
++ "node_modules/pg-connection-string": {
++ "version": "2.14.1",
++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
++ "license": "MIT"
++ },
++ "node_modules/pg-int8": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/pg-pool": {
++ "version": "3.14.0",
++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
++ "license": "MIT",
++ "peerDependencies": {
++ "pg": ">=8.0"
++ }
++ },
++ "node_modules/pg-protocol": {
++ "version": "1.16.1",
++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
++ "license": "MIT"
++ },
++ "node_modules/pg-types": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-int8": "1.0.1",
++ "postgres-array": "~2.0.0",
++ "postgres-bytea": "~1.0.0",
++ "postgres-date": "~1.0.4",
++ "postgres-interval": "^1.1.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/pgpass": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
++ "license": "MIT",
++ "dependencies": {
++ "split2": "^4.1.0"
++ }
++ },
++ "node_modules/postgres-array": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/postgres-bytea": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-date": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-interval": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "xtend": "^4.0.0"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/proxy-addr": {
++ "version": "2.0.8",
++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
++ "license": "MIT",
++ "dependencies": {
++ "forwarded": "0.2.0",
++ "ipaddr.js": "1.9.1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/qs": {
++ "version": "6.16.2",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
++ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/safer-buffer": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.6",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
++ "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/send/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "license": "ISC"
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/split2": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
++ "license": "ISC",
++ "engines": {
++ "node": ">= 10.x"
++ }
++ },
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/toidentifier": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.6"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/uhyphen": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
++ "license": "ISC"
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/xtend": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.4"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..cebebdd
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,38 @@
++{
++ "name": "openvibe-git",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.Git — git.openvibe.codes: host code with your OpenVibe account, browse it, mirror GitHub, and clone it.",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "dev": "NODE_ENV=development node server/index.js",
++ "test": "node test/run.js",
++ "test:pg": "GIT_TEST_STORE=pg node test/run.js"
++ },
++ "keywords": [
++ "openvibe",
++ "git",
++ "forge"
++ ],
++ "author": "OpenVibers",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.356.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.108.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.163.0",
++ "pg": "^8.23.1",
++ "highlight.js": "^11.10.0"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.13"
++ }
++}
ddiff --git a/public/css/git.css b/public/css/git.css
nnew file mode 100644
iindex 0000000..c1ff9ea
---- /dev/null
++++ b/public/css/git.css
@@@ -0,0 +1,372 @@
++/* OpenVibe.Git — git.openvibe.codes. The kit and the shared theme carry the colours; these are the git pages.
++ Phone first: one column, 44 px targets, nothing wider than the screen; the desktop grid starts at 900 px. */
++
++.git {
++ --git-l1: #4f7cff;
++ --git-l2: #8b5cf6;
++ --git-l3: #22b8cf;
++ --git-l4: #f59f00;
++ --git-l5: #e64980;
++ --git-l6: #37b24d;
++ --git-add: #2f9e44;
++ --git-del: #e03131;
++ --git-max: 1120px;
++ --git-gap: 16px;
++ background: var(--bg-primary);
++ color: var(--text-primary);
++ min-height: 100vh;
++ overflow-x: clip;
++}
++.git *, .git *::before, .git *::after { box-sizing: border-box; }
++.git a { color: inherit; text-decoration: none; }
++.git a:hover { color: var(--accent); }
++.git h1, .git h2, .git h3 { margin: 0; line-height: 1.18; letter-spacing: -0.015em; }
++.git h1 { font-size: clamp(1.5rem, 5vw, 2.25rem); }
++.git h2 { font-size: clamp(1.1rem, 3vw, 1.35rem); }
++.git p { margin: 0; }
++.git code, .git pre, .git .git-cmd { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
++
++.git-home, .git-explore, .git-repo, .git-file, .git-commits, .git-commit, .git-list-page, .git-insights, .git-profile, .git-new, .git-message {
++ max-width: var(--git-max); margin: 0 auto; padding: 20px 16px 64px;
++}
++@media (min-width: 900px) { .git-home, .git-explore, .git-repo, .git-file, .git-commits, .git-commit, .git-list-page, .git-insights, .git-profile, .git-new, .git-message { padding: 32px 24px 96px; } }
++
++/* ── Home ─────────────────────────────────────────────── */
++.git-hero { display: grid; gap: 24px; padding: 12px 0 8px; }
++@media (min-width: 900px) { .git-hero { grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); align-items: center; gap: 40px; padding: 28px 0 12px; } }
++.git-kicker { font-size: .8rem; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 8px; }
++.git-lede { color: var(--text-secondary); font-size: clamp(1rem, 2.4vw, 1.15rem); line-height: 1.55; max-width: 38rem; margin-top: 12px; }
++.git-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
++.git-hero-motif { display: grid; gap: 8px; padding: 16px; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-secondary); max-width: 420px; }
++.git-m-repo, .git-m-pc { display: grid; grid-template-columns: auto minmax(0,1fr); gap: 2px 10px; align-items: center; padding: 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-card); }
++.git-m-repo small, .git-m-pc small { grid-column: 2; color: var(--text-muted); font-size: .8rem; overflow-wrap: anywhere; }
++.git-m-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--git-l1); grid-row: span 2; }
++.git-m-bar { grid-column: 1 / -1; display: flex; height: 6px; border-radius: 999px; overflow: hidden; margin-top: 8px; background: var(--bg-input); }
++.git-m-bar i { display: block; height: 100%; background: var(--git-l2); }
++.git-m-bar i:first-child { background: var(--git-l1); }
++.git-m-line { display: flex; align-items: center; gap: 8px; color: var(--text-muted); font-size: .78rem; padding: 2px 8px; }
++.git-m-line span { flex: 1; height: 1px; background: repeating-linear-gradient(90deg, var(--border) 0 6px, transparent 6px 12px); }
++.git-m-ok { grid-column: 2; display: inline-flex; align-items: center; gap: 4px; color: var(--git-add); font-size: .78rem; }
++
++.git-stats { list-style: none; display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 10px; margin: 24px 0 8px; padding: 0; }
++@media (min-width: 700px) { .git-stats { grid-template-columns: repeat(4, minmax(0,1fr)); } }
++.git-stats li { padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-secondary); }
++.git-stats b { display: block; font-size: 1.4rem; letter-spacing: -0.02em; }
++.git-stats span { color: var(--text-muted); font-size: .82rem; }
++
++.git-section { margin-top: 32px; }
++.git-section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
++.git-section-head a { color: var(--text-secondary); font-size: .88rem; }
++.git-split { display: grid; gap: 28px; }
++@media (min-width: 900px) { .git-split { grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr); gap: 40px; } }
++
++.git-cards { list-style: none; display: grid; gap: 10px; margin: 0; padding: 0; grid-template-columns: minmax(0,1fr); }
++@media (min-width: 760px) { .git-cards { grid-template-columns: repeat(2, minmax(0,1fr)); } .git-cards--wide { grid-template-columns: repeat(3, minmax(0,1fr)); } }
++.git-card a { display: grid; gap: 10px; padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-secondary); height: 100%; }
++.git-card a:hover { border-color: var(--accent); background: var(--bg-hover); }
++.git-card-top { display: flex; gap: 10px; align-items: center; min-width: 0; }
++.git-card-name { display: grid; min-width: 0; }
++.git-card-name b { overflow-wrap: anywhere; }
++.git-card-name small { color: var(--text-muted); font-size: .82rem; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
++.git-card-meta { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; color: var(--text-muted); font-size: .82rem; }
++.git-card-meta span { display: inline-flex; align-items: center; gap: 4px; }
++.git-card-meta svg { width: 15px; height: 15px; }
++
++.git-avatar { width: 34px; height: 34px; flex: 0 0 34px; display: grid; place-items: center; border-radius: 9px; background: var(--bg-input); color: var(--accent); font-weight: 700; font-size: .82rem; }
++.git-avatar svg { width: 18px; height: 18px; }
++.git-avatar--sm { width: 26px; height: 26px; flex-basis: 26px; border-radius: 7px; font-size: .7rem; }
++.git-avatar--big { width: 64px; height: 64px; flex-basis: 64px; border-radius: 16px; font-size: 1.2rem; }
++.git-avatar--org, .git-avatar--event { border-radius: 50%; }
++.git-activity, .git-official, .git-people, .git-refs, .git-releases, .git-commit-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
++.git-activity li { display: flex; gap: 10px; align-items: center; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-secondary); min-width: 0; }
++.git-activity li > span:last-child { min-width: 0; overflow-wrap: anywhere; font-size: .9rem; }
++.git-activity small { display: block; color: var(--text-muted); font-size: .76rem; }
++.git-official li a { display: flex; gap: 10px; align-items: center; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-secondary); }
++.git-official li a > span:nth-child(2) { min-width: 0; display: grid; }
++.git-official small { color: var(--text-muted); font-size: .8rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
++.git-official em { margin-left: auto; color: var(--text-muted); font-style: normal; font-size: .75rem; display: inline-flex; gap: 3px; align-items: center; }
++.git-cta { margin-top: 40px; padding: 24px; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-secondary); display: grid; gap: 12px; justify-items: start; }
++
++/* ── Explore, lists ───────────────────────────────────── */
++.git-page-head { display: grid; gap: 8px; margin-bottom: 18px; }
++.git-page-head p { color: var(--text-secondary); max-width: 44rem; }
++.git-search { display: grid; gap: 8px; margin-bottom: 12px; }
++@media (min-width: 760px) { .git-search { grid-template-columns: minmax(0,1fr) auto auto auto; align-items: center; } }
++.git-search-box { display: flex; align-items: center; gap: 8px; padding: 0 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-input); min-height: 44px; }
++.git-search-box svg { width: 18px; height: 18px; color: var(--text-muted); }
++.git-search-box input { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--text-primary); font: inherit; padding: 10px 0; }
++.git-search-box input:focus { outline: none; }
++.git-search select, .git-field input, .git-field textarea, .git-form input, .git-form textarea {
++ min-height: 44px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-input); color: var(--text-primary); font: inherit; max-width: 100%;
++}
++.git-filters { display: flex; gap: 8px; overflow-x: auto; padding: 2px 0 8px; position: relative; scrollbar-width: none; }
++.git-filter { flex: 0 0 auto; min-height: 36px; display: inline-flex; align-items: center; padding: 6px 14px; border: 1px solid var(--border); border-radius: 999px; color: var(--text-secondary); font-size: .86rem; }
++.git-filter.is-on { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
++.git-more { margin-top: 18px; }
++.git-chip { display: inline-flex; align-items: center; padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; font-size: .72rem; color: var(--text-muted); font-weight: 600; text-transform: lowercase; }
++.git-chip--private { color: var(--warning-text); border-color: var(--warning); }
++.git-chip--gh { color: var(--text-secondary); }
++.git-empty { color: var(--text-muted); padding: 18px 0; }
++.git-error { padding: 10px 12px; border: 1px solid var(--danger); border-radius: 10px; color: var(--danger-text); margin-bottom: 12px; }
++
++/* ── Repository ───────────────────────────────────────── */
++.git-repo-head { display: grid; gap: 12px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }
++.git-crumb { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; color: var(--text-muted); font-size: .84rem; }
++.git-crumb svg { width: 13px; height: 13px; }
++.git-repo-title { display: flex; flex-wrap: wrap; gap: 14px; align-items: flex-start; justify-content: space-between; }
++.git-repo-title > div:first-child { min-width: 0; }
++.git-repo-title h1 span { color: var(--text-muted); font-weight: 400; }
++.git-desc { color: var(--text-secondary); margin-top: 6px; overflow-wrap: anywhere; }
++.git-topics { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; padding: 0; margin: 8px 0 0; }
++.git-topics a { display: inline-flex; padding: 3px 10px; border-radius: 999px; background: var(--bg-input); color: var(--text-secondary); font-size: .76rem; }
++.git-forked { color: var(--text-muted); font-size: .82rem; margin-top: 6px; }
++.git-repo-actions { display: flex; flex-wrap: wrap; gap: 8px; }
++.git-inline { display: inline; }
++.git-inline .ovk-btn.is-on { border-color: var(--accent); color: var(--accent); }
++.git .ovk-btn { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
++.git .ovk-btn svg { width: 17px; height: 17px; }
++.git .ovk-btn--small { min-height: 36px; font-size: .82rem; }
++.git .ovk-btn.is-off { opacity: .55; pointer-events: none; }
++
++.git-get { margin-top: 4px; display: grid; justify-items: start; }
++.git-get > summary { list-style: none; cursor: pointer; }
++.git-get > summary::-webkit-details-marker { display: none; }
++.git-get-caret svg { width: 14px; height: 14px; }
++.git-sheet { margin-top: 10px; width: 100%; display: grid; gap: 14px; padding: 16px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); box-shadow: var(--ov-elev-2, 0 12px 30px rgba(0,0,0,.25)); }
++@media (min-width: 900px) { .git-get { position: relative; } .git-sheet { position: absolute; z-index: 20; width: min(560px, 80vw); } }
++.git-get-choice { display: grid; gap: 8px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-secondary); justify-items: start; }
++.git-get-choice p { color: var(--text-secondary); font-size: .88rem; }
++.git-cmd { width: 100%; padding: 10px 12px; border-radius: 8px; background: var(--bg-input); color: var(--text-primary); font-size: .8rem; overflow-x: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
++
++.git-repo-grid { display: grid; gap: 24px; margin-top: 18px; }
++@media (min-width: 900px) { .git-repo-grid { grid-template-columns: minmax(0,1fr) 300px; gap: 32px; align-items: start; } }
++.git-repo-side { display: grid; gap: 14px; }
++.git-about { padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-secondary); display: grid; gap: 10px; }
++.git-about h2 { font-size: .95rem; }
++.git-about p { color: var(--text-secondary); font-size: .88rem; overflow-wrap: anywhere; }
++.git-about-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; color: var(--text-muted); font-size: .86rem; }
++.git-about-list li { display: flex; align-items: center; gap: 6px; }
++.git-about-list svg { width: 15px; height: 15px; }
++.git-about-link { color: var(--accent); font-size: .88rem; display: inline-flex; gap: 5px; align-items: center; }
++.git-gh-card { padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-secondary); display: grid; gap: 10px; }
++.git-gh-title { display: inline-flex; gap: 6px; align-items: center; font-size: .86rem; color: var(--text-secondary); flex-wrap: wrap; }
++.git-gh-title svg { width: 15px; height: 15px; }
++.git-gh-card ul { list-style: none; display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; padding: 0; margin: 0; }
++.git-gh-card li { display: grid; }
++.git-gh-card li b { font-size: 1.02rem; }
++.git-gh-card li span { color: var(--text-muted); font-size: .74rem; }
++.git-ci.is-ok { color: var(--success-text); }
++.git-ci.is-bad { color: var(--danger-text); }
++.git-ci.is-warn { color: var(--warning-text); }
++.git-gh-cache, .git-gh-mirror { color: var(--text-muted); font-size: .76rem; }
++
++.git-langbar { display: flex; height: 8px; border-radius: 999px; overflow: hidden; background: var(--bg-input); }
++.git-langbar span { display: block; height: 100%; }
++.git-langlist { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 6px; color: var(--text-muted); font-size: .76rem; }
++.git-langlist span { display: inline-flex; align-items: center; gap: 5px; }
++.git-langlist i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
++.git-langlist b { font-weight: 600; }
++
++.git-tabs { display: flex; gap: 4px; overflow-x: auto; border-bottom: 1px solid var(--border); margin: 18px 0 0; position: relative; scrollbar-width: none; }
++.git-tab { flex: 0 0 auto; min-height: 44px; display: inline-flex; align-items: center; padding: 8px 12px; color: var(--text-secondary); font-size: .9rem; border-bottom: 2px solid transparent; }
++.git-tab.is-on { color: var(--text-primary); border-bottom-color: var(--accent); font-weight: 600; }
++.git-tab:hover { color: var(--text-primary); }
++
++.git-tree-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; padding: 12px 0 8px; }
++.git-path { display: flex; flex-wrap: wrap; align-items: center; gap: 3px; font-size: .86rem; min-width: 0; overflow-wrap: anywhere; }
++.git-path svg { width: 13px; height: 13px; color: var(--text-muted); }
++.git-ref-chip { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 8px; background: var(--bg-input); color: var(--text-secondary); font-size: .78rem; }
++.git-ref-chip svg { width: 14px; height: 14px; }
++.git-tree { width: 100%; border-collapse: collapse; font-size: .9rem; }
++.git-tree tr { border-top: 1px solid var(--border); }
++.git-tree tr:first-child { border-top: 0; }
++.git-tree td { padding: 9px 6px; vertical-align: middle; }
++.git-entry a { display: inline-flex; align-items: center; gap: 8px; min-height: 32px; overflow-wrap: anywhere; }
++.git-entry-icon { color: var(--text-muted); display: inline-flex; }
++.git-entry-icon svg { width: 17px; height: 17px; }
++.git-entry-msg { min-width: 0; color: var(--text-muted); font-size: .82rem; max-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
++.git-entry-msg a { color: var(--text-muted); }
++.git-entry-when { color: var(--text-muted); font-size: .78rem; white-space: nowrap; text-align: right; }
++.git-entry-size { display: none; color: var(--text-muted); font-size: .78rem; text-align: right; white-space: nowrap; }
++@media (min-width: 700px) { .git-entry-size { display: table-cell; } }
++@media (max-width: 560px) { .git-entry-msg { display: none; } }
++
++.git-readme { margin-top: 22px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-card); overflow: hidden; }
++.git-readme-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); font-size: .86rem; }
++.git-readme-head h2 { font-size: .9rem; display: inline-flex; gap: 6px; align-items: center; }
++.git-readme-head svg { width: 16px; height: 16px; }
++.git-readme-head a { color: var(--accent); font-size: .8rem; }
++.git-md { padding: 16px; line-height: 1.62; overflow-wrap: anywhere; }
++.git-md h1, .git-md h2, .git-md h3, .git-md h4 { margin: 1.1em 0 .45em; }
++.git-md h1:first-child { margin-top: 0; }
++.git-md h1 { font-size: 1.55rem; }
++.git-md h2 { font-size: 1.3rem; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
++.git-md h3 { font-size: 1.12rem; }
++.git-md p { margin: .7em 0; }
++.git-md ul, .git-md ol { padding-left: 1.4rem; margin: .7em 0; }
++.git-md li { margin: .25em 0; }
++.git-md pre { padding: 12px; border-radius: 8px; background: var(--bg-input); overflow-x: auto; font-size: .82rem; }
++.git-md code { padding: 1px 5px; border-radius: 5px; background: var(--bg-input); font-size: .86em; }
++.git-md pre code { padding: 0; background: transparent; }
++.git-md blockquote { margin: .8em 0; padding-left: 12px; border-left: 3px solid var(--border); color: var(--text-secondary); }
++.git-md a { color: var(--accent); }
++.git-md hr { border: 0; border-top: 1px solid var(--border); margin: 1.2em 0; }
++.git-md table { width: 100%; border-collapse: collapse; font-size: .88rem; }
++.git-md th, .git-md td { border: 1px solid var(--border); padding: 6px 8px; text-align: left; }
++
++/* ── File ─────────────────────────────────────────────── */
++.git-file-head { display: grid; gap: 8px; }
++.git-file-meta { color: var(--text-muted); font-size: .84rem; }
++.git-code { border: 1px solid var(--border); border-radius: 12px; overflow: auto; background: var(--bg-card); }
++.git-code-table { border-collapse: collapse; width: 100%; font-size: .84rem; line-height: 1.55; }
++.git-ln { width: 1%; text-align: right; padding: 0 10px; color: var(--text-muted); user-select: none; vertical-align: top; background: var(--bg-secondary); border-right: 1px solid var(--border); }
++.git-ln a { display: block; padding: 0 4px; min-width: 34px; }
++.git-ln a:hover { color: var(--accent); }
++.git-code-line { padding: 0 12px; white-space: pre; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
++.git-code-table tr:target { background: var(--bg-hover); }
++/* highlight.js tokens, mapped to the theme so dark and light both read. */
++.hljs-comment, .hljs-quote { color: var(--text-muted); font-style: italic; }
++.hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-name { color: #c678dd; }
++.hljs-string, .hljs-attr, .hljs-template-tag, .hljs-addition { color: var(--git-add); }
++.hljs-number, .hljs-literal, .hljs-type { color: #d19a66; }
++.hljs-title, .hljs-section, .hljs-function .hljs-title { color: #61afef; }
++.hljs-variable, .hljs-template-variable, .hljs-property { color: #e06c75; }
++.hljs-tag, .hljs-selector-id, .hljs-selector-class { color: #e06c75; }
++.hljs-meta { color: var(--text-secondary); }
++[data-theme-mode="light"] .hljs-keyword, [data-theme-mode="light"] .hljs-selector-tag, [data-theme-mode="light"] .hljs-built_in, [data-theme-mode="light"] .hljs-name { color: #8250df; }
++[data-theme-mode="light"] .hljs-title, [data-theme-mode="light"] .hljs-section { color: #0550ae; }
++[data-theme-mode="light"] .hljs-variable, [data-theme-mode="light"] .hljs-tag, [data-theme-mode="light"] .hljs-selector-id { color: #cf222e; }
++[data-theme-mode="light"] .hljs-number, [data-theme-mode="light"] .hljs-literal, [data-theme-mode="light"] .hljs-type { color: #953800; }
++.git-notice { padding: 10px 12px; border: 1px solid var(--warning); border-radius: 10px; color: var(--warning-text); }
++
++/* ── Commits and the diff ─────────────────────────────── */
++.git-commit-list li a { display: flex; gap: 10px; align-items: center; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-secondary); min-width: 0; }
++.git-commit-main { display: grid; min-width: 0; flex: 1; }
++.git-commit-main b { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: .9rem; }
++.git-commit-main small { color: var(--text-muted); font-size: .76rem; }
++.git-commit-sha code { color: var(--text-muted); font-size: .78rem; }
++.git-commit-head { display: grid; gap: 10px; padding-bottom: 16px; border-bottom: 1px solid var(--border); }
++.git-commit-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; color: var(--text-muted); font-size: .84rem; }
++.git-commit-message { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); white-space: pre-wrap; font-size: .86rem; }
++.git-add { color: var(--git-add); }
++.git-del { color: var(--git-del); }
++.git-diff { margin-top: 18px; }
++.git-diff-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
++.git-diff-head h2 { font-size: 1rem; }
++.git-diff-unified pre { margin: 0; padding: 10px 0; border: 1px solid var(--border); border-radius: 10px; overflow-x: auto; background: var(--bg-card); font-size: .8rem; line-height: 1.5; }
++.git-diff-line { display: flex; gap: 8px; padding: 0 10px; white-space: pre; min-width: max-content; }
++.git-diff-sign { width: 1ch; color: var(--text-muted); flex: 0 0 1ch; }
++.git-diff-line.is-add { background: color-mix(in srgb, var(--git-add) 16%, transparent); }
++.git-diff-line.is-del { background: color-mix(in srgb, var(--git-del) 16%, transparent); }
++.git-diff-line.is-hunk { color: var(--accent); background: var(--bg-secondary); }
++.git-diff-line.is-meta { color: var(--text-muted); }
++.git-diff-split { border: 1px solid var(--border); border-radius: 10px; overflow: auto; background: var(--bg-card); font-size: .78rem; }
++.git-diff-split[hidden] { display: none; }
++.git-diff-row { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); min-width: 620px; }
++.git-diff-cell { padding: 0 8px; white-space: pre; border-left: 1px solid var(--border); min-height: 1.45em; }
++.git-diff-cell:first-child { border-left: 0; }
++.git-diff-cell.is-add { background: color-mix(in srgb, var(--git-add) 16%, transparent); }
++.git-diff-cell.is-del { background: color-mix(in srgb, var(--git-del) 16%, transparent); }
++.git-diff-cell.is-hunk { color: var(--accent); }
++
++/* ── Branches, tags, releases, people ─────────────────── */
++.git-refs li a { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-secondary); }
++.git-refs li a > span:first-child { display: inline-flex; gap: 6px; align-items: center; min-width: 0; }
++.git-refs svg { width: 16px; height: 16px; color: var(--text-muted); }
++.git-refs small { color: var(--text-muted); font-size: .78rem; margin-left: auto; }
++.git-refs code { color: var(--text-muted); font-size: .76rem; }
++.git-releases li { padding: 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-secondary); }
++.git-release-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
++.git-release-head h2 { font-size: 1.05rem; }
++.git-release-head small { color: var(--text-muted); font-size: .78rem; margin-left: auto; }
++.git-release-sha { margin-top: 8px; }
++.git-release-sha code { color: var(--text-muted); font-size: .78rem; }
++.git-people li { display: flex; gap: 10px; align-items: center; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-secondary); }
++.git-people li > span:nth-child(2) { display: grid; min-width: 0; }
++.git-people small { color: var(--text-muted); font-size: .76rem; overflow-wrap: anywhere; }
++.git-people em { margin-left: auto; color: var(--text-muted); font-style: normal; font-size: .78rem; }
++
++/* ── Insights ─────────────────────────────────────────── */
++.git-insight-stats { list-style: none; display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 10px; padding: 0; margin: 18px 0; }
++@media (min-width: 760px) { .git-insight-stats { grid-template-columns: repeat(4, minmax(0,1fr)); } }
++.git-insight-stats li { padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-secondary); }
++.git-insight-stats b { display: block; font-size: 1.35rem; }
++.git-insight-stats span { color: var(--text-muted); font-size: .8rem; }
++.git-chart { margin-top: 22px; padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-secondary); }
++.git-chart-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
++.git-chart-head h2 { font-size: .95rem; }
++.git-chart-head span { color: var(--text-muted); font-size: .76rem; }
++.git-spark { display: flex; align-items: flex-end; gap: 2px; height: calc(var(--git-spark-h) + 4px); padding-top: 4px; }
++.git-spark span { flex: 1; min-width: 2px; border-radius: 2px 2px 0 0; background: var(--accent); opacity: .8; }
++.git-weeks { list-style: none; display: flex; align-items: flex-end; gap: 3px; height: 84px; padding: 0; margin: 0; overflow-x: auto; }
++.git-weeks li { flex: 0 0 12px; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; }
++.git-weeks i { width: 8px; border-radius: 2px 2px 0 0; background: var(--git-l2); display: block; }
++.git-weeks span { color: var(--text-muted); font-size: .6rem; margin-top: 4px; writing-mode: vertical-rl; transform: rotate(180deg); }
++
++/* ── Profile, new, message, forms ─────────────────────── */
++.git-profile-head { display: flex; gap: 16px; align-items: center; margin-bottom: 20px; }
++.git-profile-head p { color: var(--text-secondary); }
++.git-form { display: grid; gap: 14px; padding: 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-secondary); }
++.git-form label, .git-field { display: grid; gap: 6px; font-size: .9rem; }
++.git-form label span, .git-field > span:first-child { color: var(--text-secondary); }
++.git-form small, .git-field small { color: var(--text-muted); font-size: .78rem; }
++.git-form--new { max-width: 720px; }
++.git-form fieldset { display: grid; gap: 8px; border: 1px solid var(--border); border-radius: 10px; padding: 12px; margin: 0; }
++.git-form legend { padding: 0 6px; color: var(--text-secondary); font-size: .86rem; }
++.git-radio { display: flex; gap: 10px; align-items: flex-start; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-card); cursor: pointer; min-height: 44px; }
++.git-radio input { margin-top: 3px; width: 18px; height: 18px; accent-color: var(--accent); }
++.git-radio span { display: grid; }
++.git-radio small { color: var(--text-muted); font-size: .78rem; }
++.git-owner { display: inline-flex; gap: 4px; align-items: center; padding: 9px 12px; border-radius: 10px; background: var(--bg-input); color: var(--text-secondary); }
++.git-owner i { color: var(--text-muted); font-style: normal; }
++.git-fold { margin-top: 8px; }
++.git-fold > summary { list-style: none; cursor: pointer; display: inline-flex; }
++.git-fold > summary::-webkit-details-marker { display: none; }
++.git-message { display: grid; gap: 12px; justify-items: start; padding-top: 40px; }
++.git-message p { color: var(--text-secondary); max-width: 42rem; }
++
++@media (prefers-reduced-motion: no-preference) {
++ .git-card a, .git-tab, .git-filter, .ovk-btn { transition: background-color .15s var(--ov-ease-out, ease), border-color .15s var(--ov-ease-out, ease), color .15s var(--ov-ease-out, ease); }
++}
++
++/* The shared navbar's app bar is fixed on a phone: the page ends above it, not under it. */
++@media (max-width: 899px) {
++ .git-home, .git-explore, .git-repo, .git-file, .git-commits, .git-commit, .git-list-page, .git-insights, .git-profile, .git-new, .git-message {
++ padding-bottom: calc(96px + var(--ovnav-appbar-h, 0px));
++ }
++}
++.git-official li { display: flex; align-items: center; gap: 8px; }
++.git-official li { padding: 0; }
++.git-official-main { flex: 1; min-width: 0; }
++.git-official li a.git-official-gh { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 4px; padding: 8px 10px; color: var(--text-muted); font-size: .76rem; }
++.git-official-gh svg { width: 14px; height: 14px; }
++.git-tree { table-layout: fixed; }
++.git-tree .git-entry { width: 34%; }
++.git-tree .git-entry-msg { width: 44%; max-width: none; }
++.git-tree .git-entry-when { width: 14%; }
++.git-tree .git-entry-size { width: 8%; }
++@media (max-width: 560px) { .git-tree .git-entry { width: 100%; } .git-tree .git-entry-when { width: 0; display: none; } }
++
++/* Design pass (Opus, 11-09) */
++/* The skip link waits for the keyboard; screen-reader-only text stays off the screen ("Files at main" showed centred). */
++.git .skip, .skip { position: absolute; left: 12px; top: -48px; z-index: 1000; padding: 10px 14px; border-radius: 10px; background: var(--accent); color: var(--on-accent, #fff); font-weight: 650; }
++.skip:focus, .skip:focus-visible { top: 12px; outline: 2px solid var(--on-accent, #fff); outline-offset: 2px; }
++.sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
++/* Star, Watch and Fork read as one row of quiet buttons. */
++.git .git-repo .ovk-btn--ghost, .git .git-repo .ovk-btn--ghost.is-off { color: var(--text-primary); }
++.git .git-repo .ovk-btn--ghost b { color: var(--text-secondary); font-weight: 650; }
++/* The weeks fill the chart's width; their labels stay small and every other one shows on a phone. */
++.git-weeks { gap: 2px; }
++.git-weeks li { flex: 1 1 0; min-width: 8px; }
++.git-weeks i { width: min(14px, 70%); }
++@media (max-width: 640px) { .git-weeks li:nth-child(even) span { visibility: hidden; } }
++/* Preview and Code for a Markdown file. */
++.git-view { display: inline-flex; gap: 2px; padding: 3px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary) 6%, transparent); }
++.git-view a { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 0 12px; border-radius: 999px; color: var(--text-secondary); font-size: .85rem; font-weight: 650; }
++.git-view a svg { width: 15px; height: 15px; }
++.git-view a[aria-current="page"] { background: var(--bg-card); color: var(--text-primary); box-shadow: 0 1px 2px rgba(0,0,0,.18); }
++.git-view--bar { margin: 6px 0 12px; }
++.git-doc .git-readme-head { justify-content: flex-start; }
++.git-md h1 { font-size: 1.65rem; padding-bottom: .3em; border-bottom: 1px solid var(--border); }
++.git-md h2 { font-size: 1.3rem; padding-bottom: .25em; border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); }
++.git-md h3 { font-size: 1.08rem; }
ddiff --git a/public/js/git.js b/public/js/git.js
nnew file mode 100644
iindex 0000000..174b56b
---- /dev/null
++++ b/public/js/git.js
@@@ -0,0 +1,67 @@
++'use strict';
++
++/* OpenVibe.Git's small behaviour: copy a clone command (making a Git token the first time), and the diff's
++ split view. Every page works without this file. */
++(function () {
++ async function copy(text) {
++ try { await navigator.clipboard.writeText(text); return true; }
++ catch {
++ const area = document.createElement('textarea');
++ area.value = text; area.setAttribute('readonly', ''); area.style.position = 'fixed'; area.style.opacity = '0';
++ document.body.appendChild(area); area.select();
++ try { document.execCommand('copy'); } catch { /* the text is still on the page */ }
++ area.remove();
++ return false;
++ }
++ }
++
++ document.addEventListener('click', async (event) => {
++ const button = event.target.closest('[data-git-token]');
++ if (!button) return;
++ event.preventDefault();
++ const command = button.parentElement.querySelector('[data-git-command]');
++ const repo = button.getAttribute('data-repo') || '';
++ button.disabled = true;
++ const said = button.textContent;
++ button.textContent = 'Making a token…';
++ try {
++ const res = await fetch('/api/v1/tokens', {
++ method: 'POST', credentials: 'same-origin',
++ headers: { 'content-type': 'application/json' },
++ body: JSON.stringify({ scopes: ['git.read', 'git.write'], label: repo }),
++ });
++ if (res.status === 401) { window.location.href = `/auth/login?next=${encodeURIComponent(window.location.pathname)}`; return; }
++ const data = await res.json();
++ if (!res.ok || !data.token) throw new Error((data && data.detail) || 'the token was not made');
++ const host = window.location.host;
++ const text = `git clone https://x-access-token:${data.token}@${host}/${repo}.git`;
++ if (command) command.textContent = text.replace(data.token, '‹token›');
++ await copy(text);
++ button.textContent = 'Copied with the token';
++ setTimeout(() => { button.textContent = said; button.disabled = false; }, 4000);
++ } catch (err) {
++ button.textContent = 'Could not copy';
++ setTimeout(() => { button.textContent = said; button.disabled = false; }, 3000);
++ }
++ });
++
++ document.addEventListener('click', (event) => {
++ const toggle = event.target.closest('[data-git-diff-toggle]');
++ if (!toggle) return;
++ const wrap = toggle.closest('[data-git-diff]');
++ const unified = wrap.querySelector('.git-diff-unified');
++ const split = wrap.querySelector('.git-diff-split');
++ if (!split) return;
++ const showSplit = split.hasAttribute('hidden');
++ split.toggleAttribute('hidden', !showSplit);
++ unified.toggleAttribute('hidden', showSplit);
++ toggle.textContent = showSplit ? 'Unified view' : 'Split view';
++ });
++
++ document.addEventListener('click', (event) => {
++ const sheet = document.querySelector('.git-get[open]');
++ if (!sheet) return;
++ if (sheet.contains(event.target)) return;
++ sheet.removeAttribute('open');
++ });
++})();
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..5638c0d
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,169 @@
++'use strict';
++
++/**
++ * OpenVibe.Git: the Express app factory. server/index.js listens; tests build their own with a temp database and
++ * a data directory of their own.
++ *
++ * /<owner>/<name>.git/… git over HTTPS (git http-backend) — mounted before every page
++ * / home, explore, new, profiles and repositories (http/pages.js)
++ * /api/v1/* the JSON API and the GitHub webhook (http/api.js)
++ * /auth/* OpenVibe sign-in (openvibe-sdk/sso, the `git` OAuth client)
++ * /shared/* the shared navbar, footer, kit and theme
++ * /api/health, /api/ready, /release.json, /metrics
++ */
++const path = require('node:path');
++const express = require('express');
++const helmet = require('helmet');
++const cookieParser = require('cookie-parser');
++const rateLimit = require('express-rate-limit');
++const contracts = require('openvibe-contracts');
++const cache = require('openvibe-shared/cache-policy');
++const { createSsoClient } = require('openvibe-sdk/sso');
++
++const configLib = require('./config');
++const { openStore } = require('./db');
++const git = require('./git/store');
++const { createGithubClient } = require('./github');
++const { createInsights } = require('./insights');
++const { createRepos } = require('./repos');
++const { createMirrors } = require('./mirror');
++const { createSmartHttp, parseGitPath } = require('./git/http-backend');
++const { identify } = require('./auth/git-auth');
++const { createApi } = require('./http/api');
++const { createPageRoutes } = require('./http/pages');
++const { setRelease } = require('./render/layout');
++
++const VERSION = require('../package.json').version;
++const PUBLIC_DIR = path.join(__dirname, '..', 'public');
++
++function createApp(opts = {}) {
++ return (async () => {
++ const config = opts.config || configLib.load();
++ const log = opts.log || console;
++ const fetchImpl = opts.fetchImpl || globalThis.fetch;
++ const s = opts.store || await openStore(config, { now: opts.now, log });
++
++ const github = opts.github || createGithubClient({ config, fetchImpl, now: s.now, log });
++ const insights = opts.insights || createInsights({ store: s, log });
++ const repos = opts.repos || createRepos({ store: s, config, git, github, insights, log });
++ const mirrors = opts.mirrors || createMirrors({ store: s, repos, config, git, github, log });
++ const smartHttp = opts.smartHttp || createSmartHttp({ config, repos, mirrors, insights, log });
++ const sso = opts.sso || createSsoClient({
++ site: 'git', baseUrl: config.baseUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl,
++ secureCookies: config.cookies.secure, fetch: fetchImpl, log,
++ });
++ const ctx = { config, store: s, s, git, github, insights, repos, mirrors, smartHttp, sso, log };
++
++ const release = opts.release || require('openvibe-shared/release').createRelease({ service: 'git', root: path.join(__dirname, '..') });
++ setRelease(release.release);
++
++ const app = express();
++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'git', release: release.release });
++ ctx.metrics = metrics;
++ app.disable('x-powered-by');
++ app.set('trust proxy', config.trustProxy);
++ app.locals.ctx = ctx;
++
++ app.use(contracts.http.middleware());
++ app.use(helmet({
++ contentSecurityPolicy: {
++ directives: {
++ defaultSrc: ["'self'"],
++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
++ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com'],
++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
++ connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.events', 'https://cloudflareinsights.com'],
++ frameSrc: ["'self'", 'https://openvibe.network'],
++ frameAncestors: ["'none'"],
++ objectSrc: ["'none'"],
++ baseUri: ["'self'"],
++ formAction: ["'self'", 'https://openvibe.network'],
++ },
++ },
++ frameguard: { action: 'deny' },
++ crossOriginEmbedderPolicy: false,
++ crossOriginResourcePolicy: { policy: 'same-site' },
++ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
++ }));
++ app.use(cookieParser());
++
++ // Health and the release's own endpoints answer before identity.
++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-git', name: 'OpenVibe Git', version: VERSION }));
++ app.get('/api/ready', async (_req, res) => {
++ try {
++ await s.db.value('SELECT 1 AS one');
++ return res.json({ ok: true, service: 'git', version: VERSION, release: release.release });
++ } catch (err) { return res.status(503).json({ ok: false, service: 'git', error: 'database' }); }
++ });
++ release.mount(app, { registry: metrics.registry });
++
++ app.use('/shared', require('openvibe-shared/serve').handler());
++
++ // ── Git over HTTPS, before every other route ─────────────
++ app.use((req, res, next) => {
++ const pathname = req.path.startsWith('/git/') ? req.path.slice(4) : req.path;
++ const parsed = parseGitPath(pathname);
++ if (!parsed) return next();
++ return (async () => {
++ const row = await repos.getRow(parsed.owner, parsed.name);
++ if (!row) return res.status(404).type('text/plain').send('No such repository.');
++ const identity = await identify(req, { store: s, config });
++ return smartHttp.run(req, res, { row, suffix: parsed.suffix, identity });
++ })().catch((err) => {
++ log.error('[OpenVibe.Git] git route:', err && err.message);
++ if (!res.headersSent) res.status(500).type('text/plain').send('The git server could not answer.');
++ });
++ });
++
++ // ── Who is asking (a person's session) ───────────────────
++ app.use(sso.optionalAuth());
++
++ // ── Sign-in ─────────────────────────────────────────────
++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
++ app.use('/auth', sso.router(express));
++ {
++ const legal = require('openvibe-shared/legal');
++ app.get(legal.PATHS, legal.handler({ id: 'git', service: 'git', host: 'git.openvibe.codes', name: 'OpenVibe Git', profile: 'hosting' }));
++ }
++
++ // ── Bodies: the GitHub webhook reads raw, everything else JSON ──
++ app.use('/api/v1/webhooks/github', express.raw({ type: '*/*', limit: '5mb' }));
++ app.use(express.json({ limit: '4mb' }));
++
++ // ── Static ──────────────────────────────────────────────
++ app.use(express.static(PUBLIC_DIR, {
++ index: false, redirect: false,
++ setHeaders(res, filePath) {
++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
++ const v = res.req && res.req.query && res.req.query.v;
++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v }));
++ },
++ }));
++
++ // ── API ─────────────────────────────────────────────────
++ app.use('/api/v1', rateLimit({
++ windowMs: 60_000, limit: config.apiRateLimitPerMinute, standardHeaders: true, legacyHeaders: false,
++ handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
++ }));
++ app.use('/api/v1', createApi(ctx));
++ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
++
++ // ── Pages ───────────────────────────────────────────────
++ app.use(rateLimit({ windowMs: 60_000, limit: config.pageRateLimitPerMinute, standardHeaders: true, legacyHeaders: false }));
++ app.use(createPageRoutes(ctx));
++
++ // eslint-disable-next-line no-unused-vars
++ app.use((err, req, res, _next) => {
++ log.error('[OpenVibe.Git]', err && err.message ? String(err.message).slice(0, 300) : err);
++ if (res.headersSent) return;
++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, err.status || 500, err.code || 'request.failed', { detail: err.status ? err.message : 'Something went wrong on our side.', ctx: req.ov });
++ return res.status(err.status || 500).type('text/plain').send(err.status ? err.message : 'Something went wrong on our side.');
++ });
++
++ return { app, ctx, release };
++ })();
++}
++
++module.exports = { createApp, VERSION };
ddiff --git a/server/auth/git-auth.js b/server/auth/git-auth.js
nnew file mode 100644
iindex 0000000..abf8314
---- /dev/null
++++ b/server/auth/git-auth.js
@@@ -0,0 +1,129 @@
++'use strict';
++
++/**
++ * Who is cloning or pushing. Two kinds of password, both OpenVibe:
++ *
++ * 1. A Git token made on the repository page: `git_` + 32 random bytes, stored only as a SHA-256 hash,
++ * scoped git.repo.read / git.repo.write, one hour by default. `POST /api/v1/tokens` (signed in) makes one.
++ * 2. A typed Network token for audience openvibe.git: `typ`/`purpose` git, `cap` holding git.repo.read or
++ * git.repo.write, sub a person (usr_) or a service (svc:). A service's client-credentials token carries no
++ * `typ`/`purpose`; its `actor_type` and `svc:` subject say it is a service, so it is accepted too.
++ * Verified offline against the Network JWKS.
++ *
++ * Git sends the token as the Basic password (any username, or the token as the username, as GitHub clients do)
++ * or as a Bearer. A public repository's read needs no password at all.
++ */
++const crypto = require('node:crypto');
++const { createJwksClient } = require('openvibe-sdk/auth');
++
++const TOKEN_PREFIX = 'git_';
++const SCOPES = Object.freeze(['git.repo.read', 'git.repo.write']);
++const USER_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SVC_RE = /^svc:[a-z][a-z0-9-]{1,39}$/;
++
++let jwks = null;
++function jwksFor(url) {
++ if (!jwks) jwks = createJwksClient(url, { log: null });
++ return jwks;
++}
++
++const hashToken = (raw) => crypto.createHash('sha256').update(String(raw)).digest('hex');
++const newToken = () => `${TOKEN_PREFIX}${crypto.randomBytes(32).toString('base64url')}`;
++
++/** Make a Git token; the raw value is returned once and never stored. */
++async function mintToken(store, { subject, actorType = 'user', scopes = ['git.repo.read'], hours = 1, label = null }) {
++ const wanted = [...new Set((Array.isArray(scopes) ? scopes : [scopes]).filter((s) => SCOPES.includes(s)))];
++ if (!wanted.length) throw new Error('a token needs git.repo.read or git.repo.write');
++ const raw = newToken();
++ const id = store.newId('tok');
++ const expiresAt = new Date(store.now() + Math.max(1, Math.min(24 * 30, Number(hours) || 1)) * 3600_000).toISOString();
++ await store.execute(
++ `INSERT INTO git_tokens (id, subject, actor_type, label, token_hash, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
++ [id, subject, actorType, label, hashToken(raw), JSON.stringify(wanted), expiresAt],
++ );
++ return { id, token: raw, scopes: wanted, expires_at: expiresAt, token_type: 'GitToken' };
++}
++
++/** The row a presented opaque token names, if it is live. Touches last_used_at at most once a minute. */
++async function tokenRow(store, raw) {
++ if (!String(raw || '').startsWith(TOKEN_PREFIX)) return null;
++ const row = await store.maybe('SELECT * FROM git_tokens WHERE token_hash = ?', [hashToken(raw)]);
++ if (!row) return null;
++ if (row.revoked_at) return null;
++ if (Date.parse(row.expires_at) <= store.now()) return null;
++ const last = row.last_used_at ? Date.parse(row.last_used_at) : 0;
++ if (store.now() - last > 60_000) {
++ await store.execute('UPDATE git_tokens SET last_used_at = now() WHERE id = ?', [row.id]).catch(() => {});
++ }
++ const scopes = new Set(Array.isArray(row.scopes) ? row.scopes : JSON.parse(row.scopes || '[]'));
++ return { subject: row.subject, actor_type: row.actor_type, scopes, token_id: row.id, via: 'token' };
++}
++
++function decode(jwt) {
++ const parts = String(jwt || '').split('.');
++ if (parts.length !== 3) return null;
++ try {
++ return { header: JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8')), claims: JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')), input: `${parts[0]}.${parts[1]}`, sig: Buffer.from(parts[2], 'base64url') };
++ } catch { return null; }
++}
++
++/**
++ * A typed Network token for this audience: RS256, issuer, expiry, aud includes openvibe.git, cap git.repo.read and/or
++ * git.repo.write, sub usr_… or svc:…. A service principal's token (client credentials, no typ) counts by its
++ * actor_type. Returns the same shape as tokenRow.
++ */
++async function networkToken(config, raw) {
++ const d = decode(raw);
++ if (!d || !d.header || d.header.alg !== 'RS256') return null;
++ const claims = d.claims || {};
++ if (claims.iss !== config.networkIssuer) return null;
++ const service = claims.actor_type === 'service' || SVC_RE.test(String(claims.sub || ''));
++ if (!service && claims.typ !== 'git' && claims.purpose !== 'git') return null;
++ const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
++ if (!aud.includes(config.audience)) return null;
++ const cap = Array.isArray(claims.cap) ? claims.cap.filter((c) => SCOPES.includes(c)) : [];
++ if (!cap.length) return null;
++ if (typeof claims.exp !== 'number' || claims.exp * 1000 <= Date.now()) return null;
++ const subject = String(claims.sub || '');
++ if (!USER_RE.test(subject) && !SVC_RE.test(subject)) return null;
++ let keys;
++ try { keys = await jwksFor(config.jwksUrl).keysForKid(d.header.kid); } catch { return null; }
++ const ok = (keys || []).some(({ key }) => { try { return crypto.verify('RSA-SHA256', Buffer.from(d.input), key, d.sig); } catch { return false; } });
++ if (!ok) return null;
++ return { subject, actor_type: SVC_RE.test(subject) ? 'service' : 'user', scopes: new Set(cap), token_id: claims.jti || null, via: 'network' };
++}
++
++function basic(req) {
++ const h = String((req.headers && req.headers.authorization) || '');
++ if (/^basic /i.test(h)) {
++ let decoded = '';
++ try { decoded = Buffer.from(h.slice(6).trim(), 'base64').toString('utf8'); } catch { return null; }
++ const at = decoded.indexOf(':');
++ const user = at >= 0 ? decoded.slice(0, at) : '';
++ const pass = at >= 0 ? decoded.slice(at + 1) : '';
++ return { user, pass };
++ }
++ if (/^bearer /i.test(h)) return { user: '', pass: h.slice(7).trim() };
++ return null;
++}
++
++/** The token a git request presents as its password, or null. */
++function presentedToken(req) {
++ const b = basic(req);
++ if (!b) return null;
++ const { user, pass } = b;
++ if (pass && pass !== 'x-oauth-basic') return pass;
++ if (user && (user.startsWith(TOKEN_PREFIX) || user.split('.').length === 3)) return user;
++ return null;
++}
++
++/** Resolve a git request's identity: an opaque Git token, a typed Network token, or nothing. */
++async function identify(req, { store, config }) {
++ const raw = presentedToken(req);
++ if (!raw) return null;
++ const opaque = await tokenRow(store, raw).catch(() => null);
++ if (opaque) return opaque;
++ return await networkToken(config, raw).catch(() => null);
++}
++
++module.exports = { TOKEN_PREFIX, SCOPES, hashToken, newToken, mintToken, tokenRow, networkToken, identify, presentedToken, basic };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..5f17568
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,89 @@
++'use strict';
++
++/**
++ * OpenVibe.Git configuration (git.openvibe.codes). Every value comes from the environment (production:
++ * /etc/openvibe/git.env, see .env.example). load(env) is pure so tests build a config without touching
++ * process.env. Secrets are never logged.
++ */
++require('dotenv').config();
++
++const trim = (s) => String(s || '').replace(/\/+$/, '');
++const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
++const bool = (v, def = false) => (v == null || v === '' ? def : !/^(0|false|no|off)$/i.test(String(v)));
++const list = (v) => String(v || '').split(',').map((x) => x.trim()).filter(Boolean);
++
++function load(env = process.env) {
++ const nodeEnv = env.NODE_ENV || 'development';
++ const isProduction = nodeEnv === 'production';
++ const port = int(env.PORT, 5270);
++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://git.openvibe.codes' : `http://localhost:${port}`));
++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
++
++ return {
++ service: 'git',
++ name: 'OpenVibe.Git',
++ port,
++ host: env.HOST || '127.0.0.1',
++ nodeEnv,
++ isProduction,
++ baseUrl,
++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
++ drill: bool(env.GIT_DRILL),
++
++ // PostgreSQL (ADR-035). Development without DATABASE_URL uses an embedded PGlite database.
++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.GIT_PGLITE_DIR || '' },
++
++ // The bare repositories (server/paths.js) and the per-push caps.
++ data: {
++ dir: env.GIT_DATA_DIR || env.DATA_DIR || '',
++ maxPushBytes: int(env.GIT_MAX_PUSH_BYTES, 512 * 1024 * 1024),
++ maxRepoBytes: int(env.GIT_MAX_REPO_BYTES, 3 * 1024 * 1024 * 1024),
++ maxFileBytes: int(env.GIT_MAX_FILE_BYTES, 2 * 1024 * 1024),
++ },
++
++ // OpenVibe.Network: SSO (OAuth2 + PKCE), the JWKS for typed git tokens, and the account events.
++ networkUrl,
++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
++ jwksUrl: `${trim(env.OV_NETWORK_JWKS_URL || env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000')}/api/.well-known/jwks`,
++ audience: env.GIT_AUDIENCE || 'openvibe.git',
++ oauth: {
++ clientId: env.OV_OAUTH_CLIENT_ID || 'git',
++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
++ scope: 'profile',
++ },
++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
++ events: { secrets: list(env.GIT_EVENTS_SECRET) },
++
++ // GitHub: the card and mirror metadata (public API), the push webhook, the push mirror.
++ github: {
++ apiUrl: trim(env.GIT_GITHUB_API_URL || 'https://api.github.com'),
++ token: env.GIT_GITHUB_TOKEN || '',
++ webhookSecret: env.GIT_GITHUB_WEBHOOK_SECRET || '',
++ pushToken: env.GIT_GITHUB_PUSH_TOKEN || '',
++ cacheMs: Math.max(60_000, int(env.GIT_GITHUB_CACHE_MS, 10 * 60_000)),
++ },
++
++ // Clone tokens (opaque, hashed at rest) and the key that seals a stored push-mirror token.
++ tokens: {
++ ttlHours: Math.max(1, Math.min(24 * 30, int(env.GIT_TOKEN_TTL_HOURS, 1))),
++ secretKey: env.GIT_SECRET_KEY || (isProduction ? '' : 'development-only-git-secret-key-0123456789'),
++ },
++
++ // The mirror poll and one fetch's cap.
++ mirrors: {
++ pollMs: Math.max(60_000, int(env.GIT_MIRROR_POLL_MS, 10 * 60_000)),
++ timeoutMs: Math.max(30_000, int(env.GIT_MIRROR_TIMEOUT_MS, 10 * 60_000)),
++ maxBytes: int(env.GIT_MIRROR_MAX_BYTES, 3 * 1024 * 1024 * 1024),
++ },
++
++ // Repositories under these owners are "official" (the OpenVibers projects).
++ officialOwners: list(env.GIT_OFFICIAL_OWNERS || 'openvibers'),
++
++ apiRateLimitPerMinute: Math.max(1, int(env.GIT_API_RATE_LIMIT_PER_MIN, 180)),
++ pageRateLimitPerMinute: Math.max(1, int(env.GIT_PAGE_RATE_LIMIT_PER_MIN, 300)),
++ };
++}
++
++module.exports = { load };
ddiff --git a/server/crypto.js b/server/crypto.js
nnew file mode 100644
iindex 0000000..a67677e
---- /dev/null
++++ b/server/crypto.js
@@@ -0,0 +1,28 @@
++'use strict';
++
++/** Seal a push-mirror token at rest (AES-256-GCM; the key is sha256(GIT_SECRET_KEY)). Never logged, never shown. */
++const crypto = require('node:crypto');
++
++const keyOf = (secret) => crypto.createHash('sha256').update(String(secret || '')).digest();
++
++function seal(text, secret) {
++ if (!text) return null;
++ const iv = crypto.randomBytes(12);
++ const cipher = crypto.createCipheriv('aes-256-gcm', keyOf(secret), iv);
++ const body = Buffer.concat([cipher.update(String(text), 'utf8'), cipher.final()]);
++ const tag = cipher.getAuthTag();
++ return `v1.${iv.toString('base64url')}.${tag.toString('base64url')}.${body.toString('base64url')}`;
++}
++
++function open(sealed, secret) {
++ if (!sealed) return null;
++ const [v, ivB64, tagB64, bodyB64] = String(sealed).split('.');
++ if (v !== 'v1' || !ivB64 || !tagB64 || !bodyB64) return null;
++ try {
++ const decipher = crypto.createDecipheriv('aes-256-gcm', keyOf(secret), Buffer.from(ivB64, 'base64url'));
++ decipher.setAuthTag(Buffer.from(tagB64, 'base64url'));
++ return Buffer.concat([decipher.update(Buffer.from(bodyB64, 'base64url')), decipher.final()]).toString('utf8');
++ } catch { return null; }
++}
++
++module.exports = { seal, open };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..fdb81b9
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,59 @@
++'use strict';
++
++/**
++ * OpenVibe.Git's own PostgreSQL database (ADR-035): migrations/NNNN_*.sql, applied at boot. Production
++ * serves through PgBouncer and migrates with the owner role on a direct connection; development without
++ * DATABASE_URL uses an embedded PGlite database under data/pglite.
++ */
++const fs = require('fs');
++const path = require('path');
++const { createDb } = require('openvibe-sdk/db');
++const { ids } = require('openvibe-contracts');
++
++const MIGRATIONS = path.join(__dirname, '..', 'migrations');
++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
++
++async function openDb(config, { log = console, registry } = {}) {
++ if (!config.db.url) {
++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL');
++ const dir = config.db.pgliteDir || DEV_PGLITE;
++ log.warn(`[OpenVibe.Git] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
++ fs.mkdirSync(dir, { recursive: true });
++ const db = createDb({ pglite: dir, service: 'git', registry, log });
++ await db.migrate({ dir: MIGRATIONS, log });
++ return db;
++ }
++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
++ const owner = createDb({ url: config.db.directUrl, service: 'git-migrate', max: 1, log });
++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
++ return createDb({ url: config.db.url, service: 'git', registry, log });
++}
++
++/** `?` → `$1`, `$2`, … (the SDK's raw methods speak PostgreSQL; the code reads like a prepared statement). */
++function marks(text) {
++ let i = 0;
++ return String(text).replace(/\?/g, () => `$${++i}`);
++}
++
++function createStore(db, { now = () => Date.now() } = {}) {
++ return {
++ db,
++ now,
++ iso: () => new Date(now()).toISOString(),
++ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,
++ tx: async (fn) => await db.tx((t) => fn(t)),
++ // The query methods the service uses: `?` placeholders, in the running transaction when there is one.
++ execute: (q, v) => db.exec(typeof q === 'string' ? marks(q) : q, v),
++ maybe: (q, v) => db.maybe(typeof q === 'string' ? marks(q) : q, v),
++ many: (q, v) => db.many(typeof q === 'string' ? marks(q) : q, v),
++ one: (q, v) => db.one(typeof q === 'string' ? marks(q) : q, v),
++ value: (q, v) => db.value(typeof q === 'string' ? marks(q) : q, v),
++ close: () => db.close(),
++ };
++}
++
++async function openStore(config, { now, log } = {}) {
++ return createStore(await openDb(config, { log }), { now });
++}
++
++module.exports = { openDb, openStore, createStore, MIGRATIONS };
ddiff --git a/server/git/cat-file.js b/server/git/cat-file.js
nnew file mode 100644
iindex 0000000..99592c5
---- /dev/null
++++ b/server/git/cat-file.js
@@@ -0,0 +1,121 @@
++'use strict';
++
++/**
++ * One `git cat-file --batch` process per hot repository, the way the plan reads objects: a request is a
++ * line on stdin, the answer is a header (`<sha> <type> <size>`) and exactly `size` bytes. The process is
++ * reused for 60 s after its last request, then it exits; at most `max` repositories have one.
++ *
++ * const pool = createCatFilePool();
++ * const obj = await pool.read(repoDir, 'main:README.md'); // { type, size, data } | null
++ */
++const { spawn } = require('node:child_process');
++const { childEnv } = require('./run');
++
++class CatFile {
++ constructor(cwd) {
++ this.cwd = cwd;
++ this.queue = [];
++ this.pending = null;
++ this.buf = Buffer.alloc(0);
++ this.ready = false;
++ this.dead = false;
++ this.child = spawn('git', ['cat-file', '--batch'], { cwd, env: childEnv(), shell: false, windowsHide: true });
++ this.child.on('data', () => {}); // stdout is consumed below through the stream event
++ this.child.stdout.on('data', (chunk) => this.onData(chunk));
++ this.child.stderr.on('data', () => { /* a missing object is reported on stdout as `missing` */ });
++ this.child.on('error', (err) => this.fail(err));
++ this.child.on('close', () => this.fail(new Error('git cat-file exited')));
++ }
++
++ fail(err) {
++ if (this.dead) return;
++ this.dead = true;
++ for (const req of this.queue.splice(0)) req.reject(err);
++ if (this.pending) { const p = this.pending; this.pending = null; p.reject(err); }
++ }
++
++ onData(chunk) {
++ this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
++ for (;;) {
++ if (!this.pending) {
++ // Wait for a request; unsolicited output is a protocol error.
++ return;
++ }
++ if (this.pending.size == null) {
++ const nl = this.buf.indexOf(0x0a);
++ if (nl < 0) return;
++ const line = this.buf.subarray(0, nl).toString('utf8');
++ this.buf = this.buf.subarray(nl + 1);
++ const parts = line.split(' ');
++ if (parts[0] === 'missing' || parts[1] === 'missing') {
++ const p = this.pending; this.pending = null; p.resolve(null);
++ this.next();
++ continue;
++ }
++ if (parts.length < 3) { this.fail(new Error(`git cat-file: ${line.slice(0, 80)}`)); return; }
++ this.pending.size = Number(parts[2]);
++ this.pending.type = parts[1];
++ this.pending.sha = parts[0];
++ }
++ if (this.pending && this.pending.size != null) {
++ if (this.buf.length < this.pending.size + 1) return; // + one newline
++ const data = this.buf.subarray(0, this.pending.size);
++ this.buf = this.buf.subarray(this.pending.size + 1);
++ const p = this.pending; this.pending = null;
++ p.resolve({ sha: p.sha, type: p.type, size: p.size, data: Buffer.from(data) });
++ this.next();
++ }
++ }
++ }
++
++ next() {
++ if (this.dead || this.pending) return;
++ const req = this.queue.shift();
++ if (!req) return;
++ this.pending = { resolve: req.resolve, reject: req.reject, size: null, type: null, sha: null };
++ this.child.stdin.write(`${req.spec}\n`);
++ }
++
++ read(spec) {
++ return new Promise((resolve, reject) => {
++ if (this.dead) return reject(new Error('git cat-file is not running'));
++ this.queue.push({ spec, resolve, reject });
++ this.next();
++ });
++ }
++
++ close() {
++ this.dead = true;
++ try { this.child.stdin.end(); } catch { /* gone */ }
++ try { this.child.kill('SIGTERM'); } catch { /* gone */ }
++ }
++}
++
++function createCatFilePool({ max = 8, idleMs = 60_000 } = {}) {
++ const open = new Map(); // cwd → { cat, timer }
++ return {
++ async read(cwd, spec) {
++ let entry = open.get(cwd);
++ if (!entry) {
++ if (open.size >= max) {
++ const [oldCwd, old] = open.entries().next().value;
++ clearTimeout(old.timer);
++ old.cat.close();
++ open.delete(oldCwd);
++ }
++ entry = { cat: new CatFile(cwd), timer: null };
++ open.set(cwd, entry);
++ }
++ clearTimeout(entry.timer);
++ try { return await entry.cat.read(spec); }
++ finally {
++ entry.timer = setTimeout(() => { entry.cat.close(); open.delete(cwd); }, idleMs);
++ entry.timer.unref();
++ }
++ },
++ close() { for (const [, e] of open) { clearTimeout(e.timer); e.cat.close(); } open.clear(); },
++ size() { return open.size; },
++ };
++}
++
++module.exports = { createCatFilePool, CatFile };
ddiff --git a/server/git/http-backend.js b/server/git/http-backend.js
nnew file mode 100644
iindex 0000000..5b5dd6e
---- /dev/null
++++ b/server/git/http-backend.js
@@@ -0,0 +1,166 @@
++'use strict';
++
++/**
++ * Git over HTTPS, the wire half: git's own `git http-backend` behind our identity.
++ *
++ * GET /<owner>/<name>.git/info/refs?service=git-upload-pack the ref advertisement a clone asks for
++ * POST /<owner>/<name>.git/git-upload-pack the pack a clone receives
++ * GET /<owner>/<name>.git/info/refs?service=git-receive-pack the advertisement a push asks for
++ * POST /<owner>/<name>.git/git-receive-pack the pack a push sends
++ *
++ * The backend runs with GIT_PROJECT_ROOT at the repositories directory and a PATH_INFO built from names this
++ * service already validated. Its environment is fixed (server/git/run.js): no GIT_DIR, no hooks but ours, fsck
++ * on push, the receiver's size cap. A request body is capped before it is read; a push is recorded and, if the
++ * repository has a push mirror, sent on after the client has its answer.
++ */
++const { runGit, configEnv } = require('./run');
++const { validOwner, validName } = require('./refs');
++const { REPOS_DIR, HOOKS_DIR } = require('../paths');
++
++const ROUTE = /^\/([^/]+)\/([^/]+)\.git(?:\/(.*))?$/;
++const ALLOWED_SUFFIX = /^(info\/refs|git-upload-pack|git-receive-pack)$/;
++
++function parseGitPath(pathname) {
++ const m = ROUTE.exec(pathname);
++ if (!m) return null;
++ const [, owner, rawName, rest = ''] = m;
++ const name = rawName;
++ if (!validOwner(owner) || !validName(name)) return null;
++ if (!ALLOWED_SUFFIX.test(rest)) return null;
++ return { owner, name, suffix: rest };
++}
++
++function readBody(req, limit) {
++ return new Promise((resolve, reject) => {
++ const chunks = [];
++ let size = 0;
++ req.on('data', (chunk) => {
++ size += chunk.length;
++ if (size > limit) { reject(Object.assign(new Error('push larger than the cap'), { status: 413 })); req.destroy(); return; }
++ chunks.push(chunk);
++ });
++ req.on('end', () => resolve(Buffer.concat(chunks)));
++ req.on('error', reject);
++ });
++}
++
++function cgiHeaders(text) {
++ const at = text.indexOf('\r\n\r\n');
++ const sep = at >= 0 ? 4 : 2;
++ const idx = at >= 0 ? at : text.indexOf('\n\n');
++ if (idx < 0) return null;
++ const head = text.slice(0, idx).split(/\r?\n/);
++ const headers = {};
++ let status = 200;
++ for (const line of head) {
++ const i = line.indexOf(':');
++ if (i <= 0) continue;
++ const k = line.slice(0, i).trim().toLowerCase();
++ const v = line.slice(i + 1).trim();
++ if (k === 'status') status = parseInt(v, 10) || 200;
++ else headers[k] = v;
++ }
++ return { status, headers, bodyAt: idx + sep };
++}
++
++function createSmartHttp({ config, repos, mirrors, insights, log = console }) {
++ const mayRead = (row, identity) => {
++ if (row.visibility === 'public') return true;
++ if (!identity) return false;
++ if (identity.subject === row.owner_subject) return true;
++ return identity.actor_type === 'service' && row.official && identity.scopes.has('git.repo.read');
++ };
++ const mayWrite = (row, identity) => {
++ if (!identity || !identity.scopes.has('git.repo.write')) return false;
++ if (identity.subject === row.owner_subject) return true;
++ return identity.actor_type === 'service' && row.official;
++ };
++
++ /** Run the backend; resolves once the response is written. */
++ async function run(req, res, { row, suffix, identity }) {
++ const service = String(new URL(req.url, 'http://x').searchParams.get('service') || '');
++ const write = suffix === 'git-receive-pack' || (suffix === 'info/refs' && service === 'git-receive-pack');
++ if (write ? !mayWrite(row, identity) : !mayRead(row, identity)) {
++ // Git retries with the URL's token only after a 401, so a private read asks for one. The API and the
++ // pages answer 404 instead and never reveal that a private repository exists.
++ if (!identity) res.set('WWW-Authenticate', 'Basic realm="OpenVibe.Git"');
++ res.status(identity ? 403 : 401).type('text/plain').send(write ? 'This token may not push here.' : 'This repository is private.');
++ return;
++ }
++ if (suffix === 'info/refs' && service && !['git-upload-pack', 'git-receive-pack'].includes(service)) {
++ res.status(400).type('text/plain').send('Unknown service.');
++ return;
++ }
++ const body = req.method === 'POST' ? await readBody(req, config.data.maxPushBytes) : Buffer.alloc(0);
++ const pathInfo = `/${row.owner_username}/${row.name}.git/${suffix}`;
++ const url = new URL(req.url, 'http://x');
++ const env = {
++ ...configEnv({
++ 'receive.fsckObjects': 'true',
++ 'transfer.fsckObjects': 'true',
++ 'core.hooksPath': HOOKS_DIR,
++ 'receive.maxInputSize': String(config.data.maxPushBytes),
++ }),
++ GIT_PROJECT_ROOT: REPOS_DIR,
++ GIT_HTTP_EXPORT_ALL: '1',
++ PATH_INFO: pathInfo,
++ REQUEST_METHOD: req.method,
++ QUERY_STRING: url.search ? url.search.slice(1) : '',
++ CONTENT_TYPE: req.headers['content-type'] || '',
++ CONTENT_LENGTH: String(body.length),
++ REMOTE_USER: identity ? identity.subject : '',
++ REMOTE_ADDR: req.ip || '',
++ GIT_PROTOCOL: req.headers['git-protocol'] || '',
++ };
++ let result;
++ try {
++ result = await runGit(['http-backend'], {
++ env,
++ input: body,
++ maxBuffer: Math.min(config.data.maxPushBytes + 64 * 1024 * 1024, 1024 * 1024 * 1024),
++ timeoutMs: 10 * 60_000,
++ });
++ } catch (err) {
++ log.warn(`[OpenVibe.Git] http-backend ${row.owner_username}/${row.name}: ${String(err.message).slice(0, 200)}`);
++ if (!res.headersSent) res.status(500).type('text/plain').send('The git server could not answer.');
++ return;
++ }
++ const parsed = cgiHeaders(result.stdout.toString('latin1'));
++ if (!parsed) {
++ res.status(502).type('text/plain').send('The git server answered something unexpected.');
++ return;
++ }
++ // The body after the CGI header block is bytes: take it from the original buffer.
++ const offset = findHeaderEnd(result.stdout);
++ const outBody = offset < 0 ? Buffer.alloc(0) : result.stdout.subarray(offset);
++ res.status(parsed.status);
++ for (const [k, v] of Object.entries(parsed.headers)) {
++ if (['connection', 'keep-alive', 'transfer-encoding', 'content-length'].includes(k)) continue;
++ res.set(k, v);
++ }
++ res.set('Cache-Control', 'no-cache, max-age=0, must-revalidate');
++ res.set('X-Content-Type-Options', 'nosniff');
++ res.end(outBody);
++ if (write && req.method === 'POST') {
++ // The client has its answer; the push is recorded and its push mirror (if any) follows.
++ await repos.recordEvent(row, 'push', identity || { subject: null, username: null }, { service });
++ await repos.audit(identity, 'git.push', row, {}, req.ip || null);
++ await mirrors.afterPush(row);
++ } else if (suffix === 'info/refs' && service === 'git-upload-pack' && req.method === 'GET') {
++ // A clone or fetch begins here; count it once, not again on the pack that follows.
++ if (insights) await insights.recordClone(row, { address: req.ip || '', subject: identity ? identity.subject : null });
++ }
++ }
++
++ return { run, mayRead, mayWrite, parseGitPath };
++}
++
++/** Byte offset of the end of the CGI header block (after the blank line). */
++function findHeaderEnd(buf) {
++ const crlf = buf.indexOf('\r\n\r\n');
++ if (crlf >= 0) return crlf + 4;
++ const lf = buf.indexOf('\n\n');
++ return lf >= 0 ? lf + 2 : -1;
++}
++
++module.exports = { createSmartHttp, parseGitPath, cgiHeaders, findHeaderEnd, ROUTE };
ddiff --git a/server/git/refs.js b/server/git/refs.js
nnew file mode 100644
iindex 0000000..545d541
---- /dev/null
++++ b/server/git/refs.js
@@@ -0,0 +1,74 @@
++'use strict';
++
++/**
++ * The names a request may reach: repository owner/name, refs and paths. Validation happens before any git
++ * call, and git checks the ref again itself (`git check-ref-format`), because two opinions are how a ref
++ * stays a ref. The rules are git's own with the extra refusals this service needs:
++ *
++ * - no leading '-' (an argument, not a name);
++ * - no '..' anywhere (a ref is not a path walk);
++ * - no control characters, backslashes or NUL;
++ * - a path is relative, has no empty/'.'/'..' segment and never enters a '.git';
++ * - a repository name never ends in '.git' (the clone suffix is not part of the name).
++ */
++const { runGit } = require('./run');
++
++const OWNER_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/;
++const NAME_RE = /^(?!.*\.git$)[a-z0-9][a-z0-9._-]{0,99}$/;
++const REPO_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}\/(?!.*\.git$)[a-z0-9][a-z0-9._-]{0,99}$/;
++const REF_LEN = 255;
++const PATH_LEN = 4096;
++
++const validOwner = (s) => typeof s === 'string' && OWNER_RE.test(s);
++const validName = (s) => typeof s === 'string' && NAME_RE.test(s);
++const validRepo = (s) => typeof s === 'string' && REPO_RE.test(s);
++
++/** True when a ref (branch, tag or short name) is safe to hand to git as one argument. */
++function validRef(ref) {
++ if (typeof ref !== 'string' || !ref.length || ref.length > REF_LEN) return false;
++ if (ref.startsWith('-') || ref.endsWith('/') || ref.endsWith('.') || ref === '@') return false;
++ if (ref.includes('..') || ref.includes('//') || ref.includes('@{') || ref.includes('\\')) return false;
++ // Control characters, space and git's ref punctuation are never part of a ref.
++ if (/[\u0000-\u0020\u007f~^:?*\[\]]/.test(ref)) return false;
++ for (const part of ref.split('/')) {
++ if (!part || part.startsWith('.') || part.endsWith('.lock') || part.endsWith('.')) return false;
++ }
++ return true;
++}
++
++/** True when a path inside a repository is safe: relative, no traversal, no '.git', one argument. */
++function validPath(p) {
++ if (typeof p !== 'string' || p.length > PATH_LEN) return false;
++ if (!p.length) return true;
++ if (p.startsWith('/') || p.startsWith('-') || p.includes('\\')) return false;
++ if (/[\u0000-\u001f\u007f]/.test(p)) return false;
++ for (const part of p.split('/')) {
++ if (!part || part === '.' || part === '..') return false;
++ const lower = part.toLowerCase();
++ if (lower === '.git' || lower.endsWith('.git')) return false;
++ }
++ return true;
++}
++
++/** git's own verdict for a ref (`refs/heads/<ref>`), for a name this service already accepted. */
++async function checkRefWithGit(cwd, ref) {
++ if (!validRef(ref)) return false;
++ try {
++ await runGit(['check-ref-format', `refs/heads/${ref}`], { cwd });
++ return true;
++ } catch { return false; }
++}
++
++/** A path's segments, or null. Never returns a path that climbs out. */
++function pathSegments(p) {
++ if (!validPath(p)) return null;
++ return p ? p.split('/') : [];
++}
++
++/** Join a directory and a child name, refusing anything that leaves the directory. */
++function joinInside(dir, name) {
++ if (!validPath(name) || name.includes('/')) return null;
++ return dir ? `${dir}/${name}` : name;
++}
++
++module.exports = { OWNER_RE, NAME_RE, REPO_RE, validOwner, validName, validRepo, validRef, validPath, checkRefWithGit, pathSegments, joinInside };
ddiff --git a/server/git/run.js b/server/git/run.js
nnew file mode 100644
iindex 0000000..1276a9d
---- /dev/null
++++ b/server/git/run.js
@@@ -0,0 +1,115 @@
++'use strict';
++
++/**
++ * Git, run the only way this service runs it: an argument array, no shell, a fixed environment.
++ *
++ * await runGit(['rev-parse', '--verify', ref], { cwd: repoDir });
++ * await runGit(['http-backend'], { env: backendEnv });
++ *
++ * Nothing a request carries is ever pasted into a command string. The environment starts from a small allow
++ * list, never the whole process env: no GIT_DIR, no GIT_* from the caller, no credential helper, no prompt.
++ * Output is capped (a huge blob cannot exhaust memory); a timeout kills the child.
++ */
++const { spawn } = require('node:child_process');
++
++const FIXED_ENV = Object.freeze({
++ GIT_CONFIG_NOSYSTEM: '1',
++ GIT_CONFIG_GLOBAL: '/dev/null',
++ GIT_TERMINAL_PROMPT: '0',
++ GIT_ASKPASS: '',
++ GIT_PAGER: 'cat',
++ GIT_EDITOR: 'true',
++ LC_ALL: 'C',
++ LANG: 'C',
++});
++
++/** Variables a caller may pass to a child (the CGI environment and the transport we chose). */
++const ALLOWED = new Set([
++ 'PATH', 'HOME', 'TMPDIR',
++ 'GIT_PROJECT_ROOT', 'GIT_HTTP_EXPORT_ALL', 'GIT_PROTOCOL', 'GIT_CONFIG_COUNT',
++ 'PATH_INFO', 'REQUEST_METHOD', 'QUERY_STRING', 'CONTENT_TYPE', 'CONTENT_LENGTH', 'REMOTE_USER', 'REMOTE_ADDR',
++ 'GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_AUTHOR_DATE', 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL', 'GIT_COMMITTER_DATE',
++ 'GIT_INDEX_FILE', 'GIT_NAMESPACE',
++]);
++
++function childEnv(extra = {}) {
++ const env = { ...FIXED_ENV };
++ for (const [k, v] of Object.entries(extra)) {
++ if (v == null) continue;
++ if (ALLOWED.has(k) || k.startsWith('GIT_CONFIG_KEY_') || k.startsWith('GIT_CONFIG_VALUE_')) env[k] = String(v);
++ }
++ // PATH is needed to find git; the rest is ours.
++ for (const k of ['PATH', 'HOME', 'TMPDIR']) if (process.env[k] && !env[k]) env[k] = process.env[k];
++ return env;
++}
++
++/** `git -c key=value` settings passed through the environment, so they never appear in argv. */
++function configEnv(pairs) {
++ const out = {};
++ const entries = Object.entries(pairs).filter(([, v]) => v != null);
++ out.GIT_CONFIG_COUNT = String(entries.length);
++ entries.forEach(([k, v], i) => { out[`GIT_CONFIG_KEY_${i}`] = k; out[`GIT_CONFIG_VALUE_${i}`] = String(v); });
++ return out;
++}
++
++class GitError extends Error {
++ constructor(args, code, stderr) {
++ super(`git ${args[0]} failed (${code}): ${String(stderr || '').trim().slice(0, 500)}`);
++ this.name = 'GitError';
++ this.code = code;
++ this.stderr = String(stderr || '');
++ this.args = args;
++ }
++}
++
++/**
++ * runGit(args, { cwd, input, env, maxBuffer, timeoutMs }) → { stdout: Buffer, stderr: string, code }
++ * Rejects with GitError on a non-zero exit (unless allowFailure).
++ */
++function runGit(args, { cwd, input, env, maxBuffer = 32 * 1024 * 1024, timeoutMs = 20_000, allowFailure = false } = {}) {
++ if (!Array.isArray(args) || args.some((a) => typeof a !== 'string')) {
++ return Promise.reject(new TypeError('runGit: args must be an array of strings'));
++ }
++ return new Promise((resolve, reject) => {
++ let child;
++ try {
++ child = spawn('git', args, { cwd, env: childEnv(env), shell: false, windowsHide: true });
++ } catch (err) { reject(err); return; }
++ const out = [];
++ let outLen = 0;
++ let stderr = '';
++ let settled = false;
++ const finish = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
++ const timer = setTimeout(() => {
++ try { child.kill('SIGKILL'); } catch { /* already gone */ }
++ finish(reject, new GitError(args, 'timeout', `timed out after ${timeoutMs} ms`));
++ }, timeoutMs);
++ timer.unref();
++ child.stdout.on('data', (chunk) => {
++ outLen += chunk.length;
++ if (outLen > maxBuffer) {
++ try { child.kill('SIGKILL'); } catch { /* already gone */ }
++ finish(reject, new GitError(args, 'max_buffer', `output larger than ${maxBuffer} bytes`));
++ return;
++ }
++ out.push(chunk);
++ });
++ child.stderr.on('data', (chunk) => { if (stderr.length < 64 * 1024) stderr += chunk.toString('utf8'); });
++ child.on('error', (err) => finish(reject, err));
++ child.on('close', (code) => {
++ const stdout = Buffer.concat(out);
++ if (code !== 0 && !allowFailure) return finish(reject, new GitError(args, code, stderr));
++ finish(resolve, { stdout, stderr, code });
++ });
++ if (input != null) child.stdin.end(input);
++ else child.stdin.end();
++ });
++}
++
++/** A text run (most plumbing is UTF-8). */
++async function runText(args, opts) {
++ const r = await runGit(args, opts);
++ return r.stdout.toString('utf8');
++}
++
++module.exports = { runGit, runText, configEnv, childEnv, GitError, FIXED_ENV };
ddiff --git a/server/git/store.js b/server/git/store.js
nnew file mode 100644
iindex 0000000..965eb35
---- /dev/null
++++ b/server/git/store.js
@@@ -0,0 +1,457 @@
++'use strict';
++
++/**
++ * Repositories on disk. A repository is a bare git repository at DATA_DIR/repos/<owner>/<name>.git; this
++ * module is the only place that knows that path, and it reads it with plumbing only:
++ *
++ * rev-parse --verify a ref to a commit
++ * ls-tree a directory
++ * cat-file --batch a blob (one process per hot repository, ./cat-file.js)
++ * log --format history, contributors, commits per week
++ * show / diff-tree one commit and its diff
++ *
++ * Every argument array begins with the command and a `--` or `--end-of-options` before anything a request
++ * chose. Refs and paths are validated first (./refs.js), then git checks the ref again.
++ */
++const fs = require('node:fs/promises');
++const path = require('node:path');
++const crypto = require('node:crypto');
++const { runGit, runText, configEnv } = require('./run');
++const { validRef, validPath, checkRefWithGit } = require('./refs');
++const { createCatFilePool } = require('./cat-file');
++const { REPOS_DIR, HOOKS_DIR, TMP_DIR } = require('../paths');
++
++const REC = '\x1e';
++const catFiles = createCatFilePool();
++
++const repoDir = (owner, name) => path.join(REPOS_DIR, owner, `${name}.git`);
++
++async function ensureDataDirs() {
++ await fs.mkdir(REPOS_DIR, { recursive: true });
++ await fs.mkdir(HOOKS_DIR, { recursive: true });
++ await fs.mkdir(TMP_DIR, { recursive: true });
++}
++
++async function repoExists(owner, name) {
++ try { return (await fs.stat(repoDir(owner, name))).isDirectory(); } catch { return false; }
++}
++
++async function dirBytes(dir) {
++ let total = 0;
++ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
++ for (const e of entries) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) total += await dirBytes(p);
++ else if (e.isFile()) total += (await fs.stat(p).catch(() => ({ size: 0 }))).size;
++ }
++ return total;
++}
++
++const repoBytes = (owner, name) => dirBytes(repoDir(owner, name));
++
++/** A bare repository with our settings: no hooks but an empty directory, fsck on push, our size cap. */
++async function initRepo(owner, name, { defaultBranch = 'main', maxPushBytes } = {}) {
++ await ensureDataDirs();
++ const dir = repoDir(owner, name);
++ await fs.mkdir(path.dirname(dir), { recursive: true });
++ if (!(await repoExists(owner, name))) {
++ await runGit(['init', '--bare', '--initial-branch', defaultBranch, dir], { timeoutMs: 30_000 });
++ await setRepoConfig(dir, { maxPushBytes });
++ }
++ return dir;
++}
++
++async function setRepoConfig(dir, { maxPushBytes } = {}) {
++ const pairs = {
++ 'receive.fsckObjects': 'true',
++ 'transfer.fsckObjects': 'true',
++ 'core.hooksPath': HOOKS_DIR,
++ 'receive.denyDeleteCurrent': 'ignore',
++ 'gc.auto': '0',
++ };
++ if (maxPushBytes) pairs['receive.maxInputSize'] = String(maxPushBytes);
++ // -c on every call rather than writing config: the values cannot be edited by a repository.
++ await runGit(['config', 'receive.fsckObjects', 'true'], { cwd: dir });
++ await runGit(['config', 'transfer.fsckObjects', 'true'], { cwd: dir });
++ await runGit(['config', 'core.hooksPath', HOOKS_DIR], { cwd: dir });
++ await runGit(['config', 'gc.auto', '0'], { cwd: dir });
++ if (maxPushBytes) await runGit(['config', 'receive.maxInputSize', String(maxPushBytes)], { cwd: dir });
++}
++
++async function setRemote(dir, url) {
++ await runGit(['remote', 'remove', 'origin'], { cwd: dir, allowFailure: true });
++ await runGit(['remote', 'add', 'origin', url], { cwd: dir });
++}
++
++/** The commit a ref names, or null. */
++async function resolveCommit(owner, name, ref) {
++ if (!validRef(ref)) return null;
++ const dir = repoDir(owner, name);
++ if (!(await repoExists(owner, name))) return null;
++ try {
++ const out = await runText(['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], { cwd: dir });
++ return out.trim() || null;
++ } catch { return null; }
++}
++
++async function defaultRef(owner, name, branch = 'main') {
++ return resolveCommit(owner, name, branch) ? branch : null;
++}
++
++function parseLsTree(text) {
++ const out = [];
++ for (const rec of text.split('\x00')) {
++ if (!rec) continue;
++ const m = /^(\d{6}) (\w+) ([0-9a-f]+)\s+(\d+|-)\t(.*)$/s.exec(rec);
++ if (!m) continue;
++ out.push({ mode: m[1], type: m[2], sha: m[3], size: m[4] === '-' ? null : Number(m[4]), name: m[5] });
++ }
++ return out;
++}
++
++async function lastCommitFor(dir, filePath) {
++ try {
++ const out = await runText(['log', '-1', '--format=%H%x00%s%x00%cI%x00%an', '--', filePath], { cwd: dir, maxBuffer: 64 * 1024 });
++ const [sha, message, date, author] = out.trim().split('\x00');
++ if (!sha) return null;
++ return { sha, message: message || '', date, author_name: author || '' };
++ } catch { return null; }
++}
++
++/** One directory at a ref: entries with mode, size and the last commit that touched each. */
++async function readTree(owner, name, ref, dirPath = '', { withLastCommit = true, limit = 300 } = {}) {
++ if (!validRef(ref) || !validPath(dirPath)) return null;
++ const sha = await resolveCommit(owner, name, ref);
++ if (!sha) return null;
++ const dir = repoDir(owner, name);
++ const args = ['ls-tree', '-z', '-l', sha];
++ if (dirPath) args.push('--', dirPath);
++ const out = await runText(args, { cwd: dir, maxBuffer: 8 * 1024 * 1024 });
++ const entries = parseLsTree(out);
++ const shown = entries.slice(0, limit);
++ if (withLastCommit) {
++ for (const e of shown) {
++ const child = dirPath ? `${dirPath}/${e.name}` : e.name;
++ e.path = child;
++ e.last_commit = await lastCommitFor(dir, child);
++ }
++ } else {
++ for (const e of shown) e.path = dirPath ? `${dirPath}/${e.name}` : e.name;
++ }
++ return { ref, path: dirPath, commit: sha, entries: shown, truncated: entries.length > shown.length };
++}
++
++/** A file's bytes through the repository's cat-file process; null when it is not a blob at that ref. */
++async function readBlob(owner, name, ref, filePath, { maxBytes = 2 * 1024 * 1024 } = {}) {
++ if (!validRef(ref) || !validPath(filePath) || !filePath) return null;
++ if (!(await resolveCommit(owner, name, ref))) return null;
++ const dir = repoDir(owner, name);
++ const obj = await catFiles.read(dir, `${ref}:${filePath}`);
++ if (!obj || obj.type !== 'blob') return null;
++ const truncated = obj.size > maxBytes;
++ const data = truncated ? obj.data.subarray(0, maxBytes) : obj.data;
++ const binary = data.includes(0);
++ return {
++ type: obj.type, size: obj.size, truncated, binary,
++ text: binary ? null : data.toString('utf8'),
++ };
++}
++
++async function rawBlob(owner, name, ref, filePath, { maxBytes = 16 * 1024 * 1024 } = {}) {
++ const blob = await readBlob(owner, name, ref, filePath, { maxBytes });
++ if (!blob) return null;
++ return blob;
++}
++
++/** The raw bytes of a blob (the raw file link). */
++async function blobBytes(owner, name, ref, filePath, { maxBytes = 16 * 1024 * 1024 } = {}) {
++ if (!validRef(ref) || !validPath(filePath) || !filePath) return null;
++ if (!(await resolveCommit(owner, name, ref))) return null;
++ const obj = await catFiles.read(repoDir(owner, name), `${ref}:${filePath}`);
++ if (!obj || obj.type !== 'blob') return null;
++ return { size: obj.size, data: obj.data.subarray(0, maxBytes), truncated: obj.size > maxBytes };
++}
++
++/**
++ * A branch and files in one request, no clone: git plumbing on a temporary index.
++ * files [{ path, content, encoding?: 'utf8'|'base64' }]; base is a commit-ish or a branch (default: the
++ * repository's default branch). Returns { sha, branch, tree }.
++ */
++const commitLocks = new Map();
++async function commitFiles(owner, name, { branch, message, files, base, author, committer }, { defaultBranch = 'main', maxBytes = 512 * 1024 * 1024 } = {}) {
++ if (!validRef(branch)) throw Object.assign(new Error('branch is not a valid ref'), { status: 422 });
++ if (!message || typeof message !== 'string' || message.length > 100000) throw Object.assign(new Error('a message is required'), { status: 422 });
++ if (!Array.isArray(files) || !files.length || files.length > 200) throw Object.assign(new Error('one to 200 files are required'), { status: 422 });
++ let total = 0;
++ for (const f of files) {
++ if (!f || !validPath(f.path) || !f.path) throw Object.assign(new Error('every file needs a safe path'), { status: 422 });
++ const buf = Buffer.from(String(f.content == null ? '' : f.content), f.encoding === 'base64' ? 'base64' : 'utf8');
++ total += buf.length;
++ if (total > maxBytes) throw Object.assign(new Error('the files are larger than a push may be'), { status: 413 });
++ f.buffer = buf;
++ }
++ const dir = repoDir(owner, name);
++ if (!(await repoExists(owner, name))) throw Object.assign(new Error('no such repository'), { status: 404 });
++ const lockKey = dir;
++ const previous = commitLocks.get(lockKey) || Promise.resolve();
++ let release;
++ const gate = new Promise((r) => { release = r; });
++ const mine = previous.then(() => gate);
++ commitLocks.set(lockKey, mine);
++ try {
++ await previous;
++ const baseRef = base || (await resolveCommit(owner, name, branch) ? branch : defaultBranch);
++ const baseSha = await resolveCommit(owner, name, baseRef);
++ if (!baseSha) throw Object.assign(new Error('the base branch has no commits yet'), { status: 422 });
++ const indexFile = path.join(TMP_DIR, `commit-${crypto.randomBytes(8).toString('hex')}.index`);
++ const who = {
++ name: (author && author.name) || 'OpenVibe Git', email: (author && author.email) || '[email protected]',
++ };
++ const env = {
++ GIT_INDEX_FILE: indexFile,
++ GIT_AUTHOR_NAME: who.name, GIT_AUTHOR_EMAIL: who.email,
++ GIT_COMMITTER_NAME: (committer && committer.name) || who.name, GIT_COMMITTER_EMAIL: (committer && committer.email) || who.email,
++ };
++ try {
++ await runGit(['read-tree', baseSha], { cwd: dir, env });
++ for (const f of files) {
++ const sha = (await runGit(['hash-object', '-w', '--stdin'], { cwd: dir, input: f.buffer })).stdout.toString('utf8').trim();
++ await runGit(['update-index', '--add', '--cacheinfo', '100644', sha, f.path], { cwd: dir, env });
++ }
++ const tree = (await runGit(['write-tree'], { cwd: dir, env })).stdout.toString('utf8').trim();
++ const commit = (await runGit(['commit-tree', tree, '-p', baseSha, '-m', message], { cwd: dir, env })).stdout.toString('utf8').trim();
++ const old = await resolveCommit(owner, name, branch);
++ const refArgs = ['update-ref', `refs/heads/${branch}`, commit];
++ if (old) refArgs.push(old);
++ await runGit(refArgs, { cwd: dir });
++ return { sha: commit, branch, tree, base: baseSha };
++ } finally {
++ release();
++ if (commitLocks.get(lockKey) === mine) commitLocks.delete(lockKey);
++ await fs.rm(indexFile, { force: true }).catch(() => {});
++ }
++ } catch (err) {
++ release();
++ if (commitLocks.get(lockKey) === mine) commitLocks.delete(lockKey);
++ throw err;
++ }
++}
++
++/** The history of a ref, optionally of one path. */
++async function history(owner, name, ref, { path: filePath = '', limit = 50, skip = 0 } = {}) {
++ if (!validRef(ref) || !validPath(filePath)) return null;
++ const sha = await resolveCommit(owner, name, ref);
++ if (!sha) return null;
++ const dir = repoDir(owner, name);
++ const n = Math.max(1, Math.min(200, Number(limit) || 50));
++ const args = ['log', `--max-count=${n}`, `--skip=${Math.max(0, Number(skip) || 0)}`, `--format=%H%x00%h%x00%s%x00%an%x00%ae%x00%cI%x00%P${REC}`, sha];
++ if (filePath) args.push('--', filePath);
++ const out = await runText(args, { cwd: dir, maxBuffer: 16 * 1024 * 1024 });
++ return out.split(REC).map((r) => r.replace(/^\n/, '')).filter(Boolean).map((rec) => {
++ const [shaFull, short, subject, authorName, authorEmail, date, parents] = rec.split('\x00');
++ return { sha: shaFull, short, subject, author: { name: authorName, email: authorEmail }, date, parents: parents ? parents.split(' ').filter(Boolean) : [] };
++ });
++}
++
++async function commitDetail(owner, name, sha, { diff = true } = {}) {
++ if (!/^[0-9a-f]{7,64}$/.test(String(sha))) return null;
++ if (!(await repoExists(owner, name))) return null;
++ const dir = repoDir(owner, name);
++ let out;
++ try {
++ out = await runText(['show', '-s', '--format=%H%x00%B%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%P%x00%T', sha], { cwd: dir });
++ } catch { return null; }
++ const [full, message, an, ae, ad, cn, ce, cd, parents, tree] = out.split('\x00');
++ if (!full) return null;
++ const record = {
++ sha: full, message: message || '', author: { name: an, email: ae, date: ad }, committer: { name: cn, email: ce, date: cd },
++ parents: parents ? parents.split(' ').filter(Boolean) : [], tree_sha: tree, date: cd,
++ };
++ if (diff) {
++ try {
++ const numstat = await runText(['show', '--format=', '--numstat', '--no-renames', full], { cwd: dir, maxBuffer: 8 * 1024 * 1024 });
++ const files = [];
++ let additions = 0; let deletions = 0;
++ for (const line of numstat.split('\n')) {
++ const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line);
++ if (!m) continue;
++ const add = m[1] === '-' ? 0 : Number(m[1]);
++ const del = m[2] === '-' ? 0 : Number(m[2]);
++ additions += add; deletions += del;
++ files.push({ path: m[3], status: 'modified', additions: add, deletions: del, patch: null });
++ }
++ const patch = await runText(['show', '--format=', '--patch', '--no-color', '--no-renames', full], { cwd: dir, maxBuffer: 8 * 1024 * 1024 });
++ record.stats = { files: files.length, additions, deletions };
++ record.files = files;
++ record.diff = patch;
++ } catch {
++ record.stats = { files: 0, additions: 0, deletions: 0 };
++ record.files = [];
++ record.diff = '';
++ }
++ }
++ return record;
++}
++
++function parseRefs(out) {
++ return out.split(REC).map((r) => r.replace(/^\n/, '')).filter(Boolean).map((rec) => {
++ const [name, sha, date, subject] = rec.split('\x00');
++ return { name, sha, date, subject: subject || '' };
++ });
++}
++
++async function branches(owner, name) {
++ if (!(await repoExists(owner, name))) return [];
++ const out = await runText(['for-each-ref', 'refs/heads', `--format=%(refname:short)%00%(objectname)%00%(committerdate:iso-strict)%00%(contents:subject)${REC}`], { cwd: repoDir(owner, name) });
++ return parseRefs(out);
++}
++
++async function tags(owner, name) {
++ if (!(await repoExists(owner, name))) return [];
++ const out = await runText(['for-each-ref', 'refs/tags', `--format=%(refname:short)%00%(objectname)%00%(creatordate:iso-strict)%00%(contents:subject)${REC}`], { cwd: repoDir(owner, name) });
++ return parseRefs(out);
++}
++
++async function contributors(owner, name, ref = 'main') {
++ if (!validRef(ref) || !(await resolveCommit(owner, name, ref))) return [];
++ const out = await runText(['shortlog', '-sne', ref], { cwd: repoDir(owner, name), maxBuffer: 4 * 1024 * 1024 });
++ const list = [];
++ for (const line of out.split('\n')) {
++ const m = /^\s*(\d+)\t(.+) <([^>]*)>$/.exec(line);
++ if (m) list.push({ commits: Number(m[1]), name: m[2], email: m[3] });
++ }
++ return list;
++}
++
++/** Commits per ISO week, oldest first, for the last `weeks` weeks. */
++async function commitsPerWeek(owner, name, ref = 'main', { weeks = 26 } = {}) {
++ if (!validRef(ref) || !(await resolveCommit(owner, name, ref))) return [];
++ const since = new Date(Date.now() - weeks * 7 * 86400_000).toISOString();
++ const out = await runText(['log', `--since=${since}`, '--format=%cI', ref], { cwd: repoDir(owner, name), maxBuffer: 16 * 1024 * 1024 });
++ const counts = new Map();
++ const monday = (d) => {
++ const x = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
++ const day = (x.getUTCDay() + 6) % 7;
++ x.setUTCDate(x.getUTCDate() - day);
++ return x.toISOString().slice(0, 10);
++ };
++ const now = new Date();
++ for (let i = weeks - 1; i >= 0; i -= 1) {
++ const d = new Date(now.getTime() - i * 7 * 86400_000);
++ counts.set(monday(d), 0);
++ }
++ for (const line of out.split('\n')) {
++ if (!line.trim()) continue;
++ const w = monday(new Date(line));
++ if (counts.has(w)) counts.set(w, counts.get(w) + 1);
++ }
++ return [...counts.entries()].map(([week, commits]) => ({ week, commits }));
++}
++
++const LANGUAGES = {
++ js: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript', ts: 'TypeScript', tsx: 'TypeScript', jsx: 'JavaScript',
++ css: 'CSS', scss: 'SCSS', html: 'HTML', htm: 'HTML', json: 'JSON', md: 'Markdown', sql: 'SQL', sh: 'Shell',
++ yml: 'YAML', yaml: 'YAML', py: 'Python', go: 'Go', rs: 'Rust', rb: 'Ruby', php: 'PHP', java: 'Java', c: 'C',
++ h: 'C', cc: 'C++', cpp: 'C++', hpp: 'C++', cs: 'C#', swift: 'Swift', kt: 'Kotlin', svg: 'SVG', png: 'Image',
++ jpg: 'Image', jpeg: 'Image', gif: 'Image', webp: 'Image', woff2: 'Font', woff: 'Font', lock: 'Lockfile',
++};
++
++/** Language shares by bytes across a ref's tree (the language bar). */
++async function languages(owner, name, ref = 'main') {
++ if (!validRef(ref) || !(await resolveCommit(owner, name, ref))) return {};
++ const out = await runText(['ls-tree', '-r', '-l', ref], { cwd: repoDir(owner, name), maxBuffer: 32 * 1024 * 1024 });
++ const total = new Map();
++ for (const rec of out.split('\n')) {
++ const m = /^\d{6} \w+ [0-9a-f]+\s+(\d+)\t(.*)$/.exec(rec);
++ if (!m) continue;
++ const file = m[2];
++ const ext = path.extname(file).slice(1).toLowerCase();
++ const lang = LANGUAGES[ext];
++ if (!lang) continue;
++ total.set(lang, (total.get(lang) || 0) + Number(m[1]));
++ }
++ const sum = [...total.values()].reduce((a, b) => a + b, 0);
++ if (!sum) return {};
++ return Object.fromEntries([...total.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => [k, Math.round((v / sum) * 1000) / 10]));
++}
++
++/** Write the first commit of a starter repository from files on disk. */
++async function writeInitialCommit(owner, name, { files, message, defaultBranch = 'main' }) {
++ const dir = repoDir(owner, name);
++ const indexFile = path.join(TMP_DIR, `${owner}-${name}-${Date.now()}.index`);
++ const env = {
++ GIT_INDEX_FILE: indexFile,
++ GIT_AUTHOR_NAME: 'OpenVibe Git', GIT_AUTHOR_EMAIL: '[email protected]',
++ GIT_COMMITTER_NAME: 'OpenVibe Git', GIT_COMMITTER_EMAIL: '[email protected]',
++ };
++ try {
++ await runGit(['read-tree', '--empty'], { cwd: dir, env });
++ for (const file of files) {
++ const sha = (await runGit(['hash-object', '-w', '--stdin'], { cwd: dir, input: Buffer.from(file.content, 'utf8') })).stdout.toString('utf8').trim();
++ await runGit(['update-index', '--add', '--cacheinfo', '100644', sha, file.path], { cwd: dir, env });
++ }
++ const tree = (await runGit(['write-tree'], { cwd: dir, env })).stdout.toString('utf8').trim();
++ const commit = (await runGit(['commit-tree', tree, '-m', message], { cwd: dir, env })).stdout.toString('utf8').trim();
++ await runGit(['update-ref', `refs/heads/${defaultBranch}`, commit], { cwd: dir });
++ return commit;
++ } finally {
++ await fs.rm(indexFile, { force: true }).catch(() => {});
++ }
++}
++
++/** Clone a mirror for the first time (a bare mirror with refs/* as they are). */
++async function cloneMirror(owner, name, url, { timeoutMs = 600_000 } = {}) {
++ await ensureDataDirs();
++ const dir = repoDir(owner, name);
++ await fs.mkdir(path.dirname(dir), { recursive: true });
++ const tmp = `${dir}.tmp-${Date.now()}`;
++ try {
++ await runGit(['clone', '--mirror', '--quiet', url, tmp], { timeoutMs, maxBuffer: 16 * 1024 * 1024 });
++ await fs.rename(tmp, dir);
++ await setRepoConfig(dir);
++ return true;
++ } catch (err) {
++ await fs.rm(tmp, { recursive: true, force: true }).catch(() => {});
++ throw err;
++ }
++}
++
++/** A fork on this disk, from one of our bare repositories (no network). */
++async function cloneLocal(srcOwner, srcName, dstOwner, dstName, { timeoutMs = 120_000 } = {}) {
++ await ensureDataDirs();
++ const dst = repoDir(dstOwner, dstName);
++ const tmp = `${dst}.tmp-${Date.now()}`;
++ await fs.mkdir(path.dirname(dst), { recursive: true });
++ try {
++ await runGit(['clone', '--bare', '--quiet', repoDir(srcOwner, srcName), tmp], { timeoutMs, maxBuffer: 8 * 1024 * 1024 });
++ await fs.rename(tmp, dst);
++ await setRepoConfig(dst);
++ return true;
++ } catch (err) {
++ await fs.rm(tmp, { recursive: true, force: true }).catch(() => {});
++ throw err;
++ }
++}
++
++/** Fetch a mirror's branches and tags again. */
++async function fetchMirror(owner, name, { timeoutMs = 600_000 } = {}) {
++ const dir = repoDir(owner, name);
++ await runGit(['fetch', '--prune', '--tags', '--quiet', 'origin'], { cwd: dir, timeoutMs, maxBuffer: 16 * 1024 * 1024 });
++}
++
++/** Push a repository mirror to another URL, the token in the environment (never argv, never the log). */
++async function pushMirror(owner, name, url, { token, timeoutMs = 600_000 } = {}) {
++ const dir = repoDir(owner, name);
++ const env = configEnv({ 'http.extraHeader': `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}` });
++ await runGit(['push', '--mirror', '--quiet', url], { cwd: dir, timeoutMs, env, maxBuffer: 16 * 1024 * 1024 });
++}
++
++async function removeRepo(owner, name) {
++ await fs.rm(repoDir(owner, name), { recursive: true, force: true });
++}
++
++module.exports = {
++ repoDir, ensureDataDirs, repoExists, repoBytes, initRepo, setRepoConfig, setRemote, resolveCommit, defaultRef,
++ readTree, readBlob, rawBlob, blobBytes, history, commitDetail, branches, tags, contributors, commitsPerWeek, languages, commitFiles,
++ writeInitialCommit, cloneMirror, cloneLocal, fetchMirror, pushMirror, removeRepo, parseLsTree, catFiles,
++};
ddiff --git a/server/github.js b/server/github.js
nnew file mode 100644
iindex 0000000..0c83a96
---- /dev/null
++++ b/server/github.js
@@@ -0,0 +1,106 @@
++'use strict';
++
++/**
++ * GitHub, read only: the repository card (stars, open issues and PRs, the last CI state), the metadata a
++ * mirror is created from, and the signature check on GitHub's push webhook.
++ *
++ * Every reading is cached for ten minutes (config.github.cacheMs), so a card costs our quota once per ten
++ * minutes, not once per view. The API token (for the OpenVibers repositories) is sent only to
++ * api.github.com; without one the public limit applies.
++ */
++const crypto = require('node:crypto');
++
++const GITHUB_HOSTS = new Set(['github.com', 'www.github.com']);
++const FULL_RE = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
++
++/** A public GitHub repository URL → { full_name, clone_url }, or null. No credentials, no other host. */
++function parseGithubUrl(input) {
++ let u;
++ try { u = new URL(String(input || '')); } catch { return null; }
++ if (u.protocol !== 'https:' || !GITHUB_HOSTS.has(u.hostname)) return null;
++ if (u.username || u.password || u.search || u.hash) return null;
++ const parts = u.pathname.replace(/^\/+|\/+$/g, '').split('/');
++ if (parts.length !== 2) return null;
++ const name = parts[1].replace(/\.git$/, '');
++ if (!FULL_RE.test(`${parts[0]}/${name}`)) return null;
++ if (name === '.' || name === '..') return null;
++ return { full_name: `${parts[0]}/${name}`, clone_url: `https://github.com/${parts[0]}/${name}.git` };
++}
++
++function createGithubClient({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
++ const cache = new Map(); // full_name (lower) → { at, card }
++ const metaCache = new Map();
++ const headers = () => {
++ const h = { accept: 'application/vnd.github+json', 'user-agent': 'openvibe-git' };
++ if (config.github.token) h.authorization = `Bearer ${config.github.token}`;
++ return h;
++ };
++ async function api(path) {
++ const res = await fetchImpl(`${config.github.apiUrl}${path}`, { headers: headers(), signal: AbortSignal.timeout(8000) });
++ if (!res.ok) {
++ const err = new Error(`GitHub ${res.status} for ${path}`);
++ err.status = res.status;
++ throw err;
++ }
++ return res.json();
++ }
++
++ /** The cached card for a GitHub twin. Returns null rather than failing a page. */
++ async function card(full, { branch = 'main', fresh = false } = {}) {
++ const key = String(full || '').toLowerCase();
++ if (!FULL_RE.test(String(full || ''))) return null;
++ const hit = cache.get(key);
++ if (!fresh && hit && now() - hit.at < config.github.cacheMs) return hit.card;
++ try {
++ const [repo, prs, status] = await Promise.all([
++ api(`/repos/${encodeURIComponent(full.split('/')[0])}/${encodeURIComponent(full.split('/')[1])}`).catch(() => null),
++ api(`/search/issues?q=${encodeURIComponent(`repo:${full} type:pr state:open`)}&per_page=1`).catch(() => null),
++ api(`/repos/${encodeURIComponent(full.split('/')[0])}/${encodeURIComponent(full.split('/')[1])}/commits/${encodeURIComponent(branch)}/status`).catch(() => null),
++ ]);
++ if (!repo) return hit ? hit.card : null;
++ const card = {
++ full_name: repo.full_name,
++ html_url: repo.html_url,
++ stars: Number(repo.stargazers_count) || 0,
++ open_issues: Math.max(0, (Number(repo.open_issues_count) || 0) - (Number(prs && prs.total_count) || 0)),
++ open_pull_requests: Number(prs && prs.total_count) || 0,
++ ci_state: status && ['success', 'failure', 'pending'].includes(status.state) ? status.state : 'unknown',
++ fetched_at: new Date(now()).toISOString(),
++ };
++ cache.set(key, { at: now(), card });
++ return card;
++ } catch (err) {
++ (log.warn || log.log)(`[OpenVibe.Git] GitHub card ${full}: ${err.message}`);
++ return hit ? hit.card : null;
++ }
++ }
++
++ /** Mirror metadata: description, default branch, language. */
++ async function meta(full) {
++ const key = String(full || '').toLowerCase();
++ const hit = metaCache.get(key);
++ if (hit && now() - hit.at < config.github.cacheMs) return hit.meta;
++ const [owner, name] = String(full).split('/');
++ const repo = await api(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`);
++ const meta = {
++ full_name: repo.full_name, description: repo.description || '', default_branch: repo.default_branch || 'main',
++ language: repo.language || null, stars: Number(repo.stargazers_count) || 0, private: !!repo.private,
++ };
++ metaCache.set(key, { at: now(), meta });
++ return meta;
++ }
++
++ /** GitHub's X-Hub-Signature-256 over the raw body, constant time. */
++ function signatureOk(rawBody, signature) {
++ const secret = config.github.webhookSecret;
++ if (!secret || !signature) return false;
++ const expected = `sha256=${crypto.createHmac('sha256', secret).update(rawBody).digest('hex')}`;
++ const a = Buffer.from(expected);
++ const b = Buffer.from(String(signature));
++ return a.length === b.length && crypto.timingSafeEqual(a, b);
++ }
++
++ return { parseGithubUrl, card, meta, signatureOk, _cache: cache };
++}
++
++module.exports = { createGithubClient, parseGithubUrl, FULL_RE };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..9eaa7c6
---- /dev/null
++++ b/server/http/api.js
@@@ -0,0 +1,316 @@
++'use strict';
++
++/**
++ * /api/v1: repositories, browsing, stars and forks, contents writes, clone tokens, the home numbers and the
++ * GitHub webhook. Everything a page shows is here too; nothing here needs a browser.
++ *
++ * Public reads need no sign-in. A write needs the session or a token with git.repo.write. A private repository
++ * answers 404 to anyone who may not see it, never 403: its existence is not public.
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { callerOf, viewerFrom } = require('./identity');
++const { mintToken } = require('../auth/git-auth');
++
++const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
++const fail = (req, res, err) => problem(req, res, err.status || 500, err.code || 'request.failed', err.status ? err.message : undefined);
++
++function boolParam(v) { return v === true || v === '1' || v === 'true' || v === 'yes'; }
++
++function createApi(ctx) {
++ const { config, repos, mirrors, git, insights, store, sso } = ctx;
++ const r = express.Router();
++ const h = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
++ // One identity read per request: the session, or a token (a person's or a service's, and for a service the
++ // person it names in X-OV-Subject).
++ const callerFor = async (req) => {
++ if (!Object.prototype.hasOwnProperty.call(req, '_ovCaller')) req._ovCaller = await callerOf(req, { store, config });
++ return req._ovCaller;
++ };
++ const viewerFor = async (req) => viewerFrom(await callerFor(req));
++ // The person a service acts for becomes the actor for ownership and personal rows; a person's own caller is
++ // unchanged.
++ const actingAs = (caller) => ({ ...caller, subject: caller.on_behalf_of || caller.subject });
++ const visible = async (req, owner, name, withCard = false) => {
++ const row = await repos.getRow(owner, name);
++ if (!row || !repos.canView(row, await viewerFor(req))) return null;
++ let card = null;
++ if (withCard && row.github_full_name) card = await ctx.github.card(row.github_full_name, { branch: row.default_branch });
++ return { row, card };
++ };
++ const repoJson = (row, card) => repos.rowToRepo(row, { card });
++
++ // ── Repositories ────────────────────────────────────────
++ r.get('/repos', h(async (req, res) => {
++ const viewer = await viewerFor(req);
++ const out = await repos.list({
++ viewer,
++ q: String(req.query.q || '').slice(0, 100),
++ language: String(req.query.language || '').slice(0, 40),
++ official: boolParam(req.query.official),
++ mirror: boolParam(req.query.mirror),
++ mine: boolParam(req.query.mine),
++ starred: boolParam(req.query.starred),
++ sort: String(req.query.sort || 'trending'),
++ limit: req.query.limit,
++ cursor: req.query.cursor,
++ });
++ res.set('Cache-Control', viewer ? 'no-store' : 'public, max-age=60');
++ res.json(out);
++ }));
++
++ r.get('/repos/:owner/:name', h(async (req, res) => {
++ const found = await visible(req, req.params.owner, req.params.name, true);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository, or it is private.');
++ const viewer = await viewerFor(req);
++ const out = repoJson(found.row, found.card);
++ out.starred = await repos.isStarred(found.row, viewer && viewer.subject);
++ return res.json(out);
++ }));
++
++ r.post('/repos', h(async (req, res) => {
++ const caller = await callerFor(req);
++ if (!caller) return problem(req, res, 401, 'auth.required', 'Sign in to make a repository.');
++ const body = req.body || {};
++ // A service (Codes, Run, Link) makes a repository for the person it names in X-OV-Subject; the person's
++ // username is the repository's namespace, so the service says it in owner_username. It must not open a
++ // namespace that already belongs to somebody else.
++ let actor = caller;
++ if (caller.actor_type === 'service') {
++ if (!caller.scopes.has('git.repo.write')) return problem(req, res, 403, 'token.scope', 'This token may only read (git.repo.read).');
++ if (!caller.on_behalf_of) return problem(req, res, 401, 'auth.subject_required', 'Name the person this repository is for in X-OV-Subject.');
++ const username = String(body.owner_username || '').trim().toLowerCase();
++ if (!username) return problem(req, res, 422, 'repo.owner_required', 'owner_username names the person whose repository this is.');
++ const other = await store.maybe('SELECT 1 AS one FROM git_repos WHERE owner_username = ? AND owner_subject IS NOT NULL AND owner_subject <> ? LIMIT 1', [username, caller.on_behalf_of]);
++ if (other) return problem(req, res, 409, 'repo.owner_mismatch', 'That username belongs to another OpenVibe account.');
++ actor = { subject: caller.on_behalf_of, username, actor_type: 'service' };
++ } else if (!caller.username) {
++ return problem(req, res, 401, 'auth.required', 'Sign in to make a repository.');
++ }
++ try {
++ const repo = await repos.create({
++ actor, name: body.name, description: body.description, visibility: body.visibility,
++ topics: Array.isArray(body.topics) ? body.topics : [], starter: body.starter,
++ mirrorUrl: body.mirror_url || body.mirrorUrl,
++ }, { address: req.ip });
++ return res.status(201).json(repo);
++ } catch (err) { return fail(req, res, err); }
++ }));
++
++ r.post('/repos/:owner/:name/fork', h(async (req, res) => {
++ const caller = await callerFor(req);
++ if (!caller || !caller.username) return problem(req, res, 401, 'auth.required', 'Sign in to fork.');
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository, or it is private.');
++ try {
++ const repo = await repos.fork({ source: found.row, actor: caller, name: (req.body || {}).name }, { address: req.ip });
++ return res.status(201).json(repo);
++ } catch (err) { return fail(req, res, err); }
++ }));
++
++ r.post('/repos/:owner/:name/mirror', h(async (req, res) => {
++ const caller = await callerFor(req);
++ if (!caller) return problem(req, res, 401, 'auth.required', 'Sign in.');
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository, or it is private.');
++ try {
++ const repo = await repos.setPushMirror(found.row, { push_url: (req.body || {}).push_url, push_token: (req.body || {}).push_token }, caller, { address: req.ip });
++ return res.json(repo);
++ } catch (err) { return fail(req, res, err); }
++ }));
++
++ r.post('/repos/:owner/:name/sync', h(async (req, res) => {
++ const caller = await callerFor(req);
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
++ if (!caller || !repos.isOwner(found.row, actingAs(caller))) return problem(req, res, 403, 'repo.forbidden', 'Only the owner can sync a mirror.');
++ if (!found.row.mirror_url) return problem(req, res, 422, 'repo.not_mirror', 'This repository is not a mirror.');
++ const result = await mirrors.syncRow(found.row);
++ return res.status(result.ok ? 202 : 502).json(result);
++ }));
++
++ r.put('/repos/:owner/:name/star', h(async (req, res) => {
++ const caller = await callerFor(req);
++ if (!caller || !actingAs(caller).subject) return problem(req, res, 401, 'auth.required', 'Sign in to star.');
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
++ return res.json(await repos.star(found.row, actingAs(caller).subject, true));
++ }));
++
++ r.delete('/repos/:owner/:name/star', h(async (req, res) => {
++ const caller = await callerFor(req);
++ if (!caller || !actingAs(caller).subject) return problem(req, res, 401, 'auth.required', 'Sign in to unstar.');
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
++ return res.json(await repos.star(found.row, actingAs(caller).subject, false));
++ }));
++
++ // ── Browsing ────────────────────────────────────────────
++ const refOf = (req, row) => String(req.query.ref || row.default_branch || 'main');
++
++ r.get('/repos/:owner/:name/tree', h(async (req, res) => {
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
++ const pathAt = String(req.query.path || '');
++ const ref = refOf(req, found.row);
++ const tree = await git.readTree(found.row.owner_username, found.row.name, ref, pathAt);
++ if (!tree) return problem(req, res, 404, 'tree.not_found', 'No such ref or directory.');
++ return res.json({
++ repo: `${found.row.owner_username}/${found.row.name}`, ref, path: tree.path, commit: tree.commit,
++ entries: tree.entries.map((e) => ({ name: e.name, path: e.path, type: e.type, mode: e.mode, sha: e.sha, size: e.size, last_commit: e.last_commit || null })),
++ truncated: tree.truncated,
++ });
++ }));
++
++ r.get('/repos/:owner/:name/blob', h(async (req, res) => {
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
++ const pathAt = String(req.query.path || '');
++ if (!pathAt) return problem(req, res, 422, 'blob.path_required', 'A path is required.');
++ const ref = refOf(req, found.row);
++ const blob = await git.readBlob(found.row.owner_username, found.row.name, ref, pathAt, { maxBytes: config.data.maxFileBytes });
++ if (!blob) return problem(req, res, 404, 'blob.not_found', 'No such file at that ref.');
++ return res.json({ repo: `${found.row.owner_username}/${found.row.name}`, ref, path: pathAt, size: blob.size, binary: blob.binary, truncated: blob.truncated, text: blob.text });
++ }));
++
++ r.get('/repos/:owner/:name/raw', h(async (req, res) => {
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
++ const pathAt = String(req.query.path || '');
++ const ref = refOf(req, found.row);
++ const blob = await git.blobBytes(found.row.owner_username, found.row.name, ref, pathAt, { maxBytes: config.data.maxFileBytes });
++ if (!blob) return problem(req, res, 404, 'blob.not_found', 'No such file at that ref.');
++ res.set('Content-Type', /\.(png|jpe?g|gif|webp|ico)$/i.test(pathAt) ? 'image/' + pathAt.split('.').pop().replace('jpg', 'jpeg') : 'text/plain; charset=utf-8');
++ res.set('Content-Length', String(blob.data.length));
++ res.set('Cache-Control', 'public, max-age=300');
++ return res.end(blob.data);
++ }));
++
++ r.get('/repos/:owner/:name/commits', h(async (req, res) => {
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
++ const ref = refOf(req, found.row);
++ const commits = await git.history(found.row.owner_username, found.row.name, ref, {
++ path: String(req.query.path || ''), limit: req.query.limit, skip: req.query.cursor,
++ });
++ if (!commits) return problem(req, res, 404, 'ref.not_found', 'No such ref.');
++ const limit = Math.max(1, Math.min(200, Number(req.query.limit) || 50));
++ const skip = Math.max(0, Number(req.query.cursor) || 0);
++ return res.json({ repo: `${found.row.owner_username}/${found.row.name}`, ref, commits: commits.map((c) => ({ ...c, message: c.subject })), next_cursor: commits.length === limit ? String(skip + limit) : null });
++ }));
++
++ r.get('/repos/:owner/:name/commit/:sha', h(async (req, res) => {
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
++ const commit = await git.commitDetail(found.row.owner_username, found.row.name, req.params.sha, { diff: boolParam(req.query.diff) });
++ if (!commit) return problem(req, res, 404, 'commit.not_found', 'No such commit.');
++ return res.json({
++ ...commit, repo: `${found.row.owner_username}/${found.row.name}`, subject: String(commit.message || '').split('\n')[0],
++ author: { ...commit.author, username: commit.author.username || null },
++ });
++ }));
++
++ r.get('/repos/:owner/:name/branches', h(async (req, res) => {
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
++ return res.json({ branches: await git.branches(found.row.owner_username, found.row.name) });
++ }));
++ r.get('/repos/:owner/:name/tags', h(async (req, res) => {
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
++ return res.json({ tags: await git.tags(found.row.owner_username, found.row.name) });
++ }));
++ r.get('/repos/:owner/:name/releases', h(async (req, res) => {
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
++ return res.json({ releases: await repos.releases(found.row) });
++ }));
++ r.post('/repos/:owner/:name/releases', h(async (req, res) => {
++ const caller = await callerFor(req);
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
++ if (!caller || !repos.isOwner(found.row, actingAs(caller))) return problem(req, res, 403, 'repo.forbidden', 'Only the owner can publish a release.');
++ try {
++ return res.status(201).json({ releases: await repos.publishRelease(found.row, { ...(req.body || {}), actor: caller }) });
++ } catch (err) { return fail(req, res, err); }
++ }));
++ r.get('/repos/:owner/:name/contributors', h(async (req, res) => {
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
++ return res.json({ contributors: await repos.contributors(found.row) });
++ }));
++ r.get('/repos/:owner/:name/insights', h(async (req, res) => {
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
++ return res.json(await repos.insightsFor(found.row, { days: req.query.days }));
++ }));
++
++ // ── Writes without a clone ─────────────────────────────
++ r.post('/repos/:owner/:name/commits', h(async (req, res) => {
++ const caller = await callerFor(req);
++ const found = await visible(req, req.params.owner, req.params.name);
++ if (!found) return problem(req, res, 404, 'repo.not_found', 'No such repository.');
++ if (!caller) return problem(req, res, 401, 'auth.required', 'Sign in or present a token with git.repo.write.');
++ if (!caller.scopes.has('git.repo.write')) return problem(req, res, 403, 'token.scope', 'This token may only read (git.repo.read).');
++ const who = actingAs(caller);
++ const own = repos.isOwner(found.row, who);
++ const serviceWrite = caller.actor_type === 'service' && found.row.official && caller.scopes.has('git.repo.write');
++ if (!own && !serviceWrite) return problem(req, res, 403, 'repo.forbidden', 'Only the owner (or the service on an official repository) may write.');
++ const body = req.body || {};
++ try {
++ const author = body.author || (who.username ? { name: who.username, email: `${who.username}@users.noreply.openvibe.codes` } : { name: 'OpenVibe Git', email: '[email protected]' });
++ const out = await git.commitFiles(found.row.owner_username, found.row.name, {
++ branch: body.branch, message: body.message, files: body.files, base: body.base, author, committer: author,
++ }, { defaultBranch: found.row.default_branch, maxBytes: config.data.maxPushBytes });
++ await repos.recordEvent(found.row, 'push', who, { sha: out.sha, branch: out.branch, via: 'api' });
++ await repos.audit(who, 'git.commit', found.row, { sha: out.sha, branch: out.branch, via: caller.actor_type === 'service' ? caller.subject : 'api' }, req.ip);
++ await mirrors.afterPush(found.row);
++ return res.status(201).json({ repo: `${found.row.owner_username}/${found.row.name}`, ...out });
++ } catch (err) { return fail(req, res, err); }
++ }));
++
++ // ── Clone tokens ───────────────────────────────────────
++ r.post('/tokens', h(async (req, res) => {
++ const caller = await callerFor(req);
++ if (!caller) return problem(req, res, 401, 'auth.required', 'Sign in to make a clone token.');
++ // A service makes one for the person it names; the person's own token is theirs.
++ if (caller.actor_type === 'service' && !caller.scopes.has('git.repo.write')) return problem(req, res, 403, 'token.scope', 'This token may only read (git.repo.read).');
++ const who = actingAs(caller);
++ if (!who.subject) return problem(req, res, 401, 'auth.subject_required', 'Name the person this token is for in X-OV-Subject.');
++ const body = req.body || {};
++ try {
++ const minted = await mintToken(store, {
++ subject: who.subject, actorType: 'user', scopes: body.scopes,
++ hours: body.hours || config.tokens.ttlHours, label: String(body.label || '').slice(0, 100) || null,
++ });
++ await repos.audit(who, 'token.create', null, { scopes: minted.scopes, token_id: minted.id, via: caller.actor_type === 'service' ? caller.subject : 'session' }, req.ip);
++ const host = config.baseUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '');
++ return res.status(201).json({
++ token: minted.token, token_type: minted.token_type, expires_at: minted.expires_at, scopes: minted.scopes,
++ clone_url: `${config.baseUrl.replace(/\/+$/, '')}/<owner>/<name>.git`,
++ command: `git clone https://x-access-token:${minted.token}@${host}/<owner>/<name>.git`,
++ });
++ } catch (err) { return fail(req, res, err); }
++ }));
++
++ // ── Home, activity, profiles ───────────────────────────
++ r.get('/stats', h(async (_req, res) => res.json(await repos.stats())));
++ r.get('/activity', h(async (req, res) => res.json({ events: await repos.activity(req.query.limit) })));
++ r.get('/profile/:username', h(async (req, res) => {
++ const profile = await repos.profile(String(req.params.username).toLowerCase(), { viewer: await viewerFor(req) });
++ if (!profile) return problem(req, res, 404, 'profile.not_found', 'No such person.');
++ return res.json(profile);
++ }));
++
++ // ── GitHub's push webhook ──────────────────────────────
++ r.post('/webhooks/github', h(async (req, res) => {
++ if (!Buffer.isBuffer(req.body)) return problem(req, res, 415, 'webhook.raw_required', 'The webhook body is read raw.');
++ const out = await mirrors.handleWebhook(req.body, req.headers);
++ return res.status(out.status).json(out.body);
++ }));
++
++ r.use((req, res) => problem(req, res, 404, 'route.not_found', 'No such API route.'));
++ return r;
++}
++
++module.exports = { createApi };
ddiff --git a/server/http/identity.js b/server/http/identity.js
nnew file mode 100644
iindex 0000000..c35cc9e
---- /dev/null
++++ b/server/http/identity.js
@@@ -0,0 +1,53 @@
++'use strict';
++
++/**
++ * Who is asking: the session (a person), a token presented to the API (a person or a service), or a service
++ * acting for a person.
++ *
++ * A service holds a typed Network token for this audience. It names the person it is working for in
++ * X-OV-Subject (usr_…), the same header the other services use; the service is trusted with git.repo.read or
++ * git.repo.write, so the person it names is who the call is for. A service that names nobody acts only where a
++ * service may (official repositories, public reads).
++ */
++const USER_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function viewerOf(req) {
++ if (!req.user) return null;
++ return {
++ kind: 'user',
++ subject: req.user.subject_id || req.user.sub,
++ username: req.user.username || null,
++ displayName: req.user.display_name || req.user.username || null,
++ role: req.user.role || 'user',
++ };
++}
++
++/** The person a service token acts for: X-OV-Subject, when it is a usr_ subject. */
++function namedSubject(req) {
++ const raw = req.get ? req.get('x-ov-subject') : (req.headers && req.headers['x-ov-subject']);
++ const value = String(raw || '').trim();
++ return USER_RE.test(value) ? value : null;
++}
++
++async function callerOf(req, { store, config }) {
++ const viewer = viewerOf(req);
++ if (viewer) return { subject: viewer.subject, username: viewer.username, actor_type: 'user', scopes: new Set(['git.repo.read', 'git.repo.write']), via: 'session' };
++ const identity = await require('../auth/git-auth').identify(req, { store, config });
++ if (!identity) return null;
++ if (identity.actor_type === 'service') {
++ return { ...identity, username: null, on_behalf_of: namedSubject(req) };
++ }
++ return { ...identity, username: null };
++}
++
++/** The person a caller's view is theirs: the caller, or the person a service acts for. */
++function viewerFrom(caller) {
++ if (!caller) return null;
++ const subject = caller.on_behalf_of || (caller.actor_type === 'user' ? caller.subject : null);
++ if (!subject) return null;
++ return { kind: 'user', subject, username: caller.username || null, displayName: caller.username || null, role: 'user' };
++}
++
++const isAdmin = (viewer) => Boolean(viewer && viewer.role === 'admin');
++
++module.exports = { viewerOf, callerOf, viewerFrom, namedSubject, isAdmin };
ddiff --git a/server/http/pages.js b/server/http/pages.js
nnew file mode 100644
iindex 0000000..dbdd9cc
---- /dev/null
++++ b/server/http/pages.js
@@@ -0,0 +1,363 @@
++'use strict';
++
++/**
++ * The pages on git.openvibe.codes. They work without JavaScript: a star, a watch, a fork, a release and a
++ * repository are plain POSTs that redirect back to the page.
++ *
++ * / home: the hero, the numbers, trending, activity, the official rail
++ * /explore search and filters
++ * /new make a repository (empty, app, static, or a GitHub import)
++ * /@name a person's public repositories
++ * /:owner/:name the repository: header, Get it, GitHub card, tree, README
++ * /:owner/:name/tree a directory (ref and path in the query)
++ * /:owner/:name/blob a file, highlighted on the server, with line links
++ * /:owner/:name/raw its bytes
++ * /:owner/:name/commits history (optionally of one path)
++ * /:owner/:name/commit/:sha one commit and its diff
++ * /:owner/:name/branches · tags · releases · contributors · insights
++ * /:owner/:name.git/… git over HTTPS (server/git/http-backend.js; mounted before these routes)
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { send } = require('../render/layout');
++const views = require('../views');
++const { viewerOf } = require('./identity');
++const { esc } = require('../render/html');
++const { renderMarkdown } = require('../render/markdown');
++const { validRef, validPath } = require('../git/refs');
++
++function createPageRoutes(ctx) {
++ const { config, repos, git, github, mirrors, insights, store } = ctx;
++ const r = express.Router();
++ r.use(express.urlencoded({ extended: false, limit: '256kb' }));
++ const h = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
++ const viewer = (req) => viewerOf(req);
++ const signedIn = (req) => Boolean(req.user);
++ const page = (req, res, body, o = {}) => send(res, o.status || 200, { viewer: viewer(req), config, path: req.originalUrl, body, ...o });
++ const loginTo = (path) => `/auth/login?next=${encodeURIComponent(path)}`;
++ const sameOrigin = (req) => {
++ const origin = req.get('origin');
++ if (!origin) return true;
++ try { return new URL(origin).host === new URL(config.baseUrl).host; } catch { return false; }
++ };
++ const guard = (req, res) => {
++ if (!signedIn(req)) { res.redirect(303, loginTo(req.originalUrl)); return false; }
++ if (!sameOrigin(req)) { page(req, res, views.message({ title: 'Not from this site', text: 'Changes to a repository must be made on git.openvibe.codes itself.', link: { href: '/', label: 'OpenVibe Git' } }), { status: 403 }); return false; }
++ return true;
++ };
++ async function rowFor(req, res) {
++ const row = await repos.getRow(String(req.params.owner || '').toLowerCase(), String(req.params.name || '').toLowerCase());
++ if (!row || !repos.canView(row, viewer(req))) { page(req, res, views.notFound(), { status: 404, title: 'Not found' }); return null; }
++ return row;
++ }
++ const meta = (req, row, { title, description, index = true } = {}) => ({
++ title: title || `${row.owner_username}/${row.name}`,
++ description: description || row.description || `${row.owner_username}/${row.name} on OpenVibe Git.`,
++ index,
++ jsonLd: [{
++ '@context': 'https://schema.org', '@type': 'SoftwareSourceCode',
++ name: `${row.owner_username}/${row.name}`, description: row.description || undefined,
++ codeRepository: `${config.baseUrl.replace(/\/+$/, '')}/${row.owner_username}/${row.name}`,
++ programmingLanguage: row.language || undefined,
++ }],
++ });
++
++ async function repoContext(req, row) {
++ const me = viewer(req);
++ const [starred, watching, parent] = await Promise.all([
++ repos.isStarred(row, me && me.subject),
++ repos.isWatching(row, me && me.subject),
++ row.fork_of ? repos.byId(row.fork_of) : Promise.resolve(null),
++ ]);
++ const start = { starred, watching, forkOf: parent ? `${parent.owner_username}/${parent.name}` : null, isOwner: repos.isOwner(row, me) };
++ const card = row.github_full_name ? await github.card(row.github_full_name, { branch: row.default_branch }) : null;
++ return { ...start, card, languages: row.languages };
++ }
++
++ async function readReadme(row, ref) {
++ try {
++ const tree = await git.readTree(row.owner_username, row.name, ref, '', { withLastCommit: false });
++ const entry = tree && tree.entries.find((e) => e.type === 'blob' && /^readme(\.md|\.markdown|\.txt)?$/i.test(e.name));
++ if (!entry) return null;
++ let blob = await git.readBlob(row.owner_username, row.name, ref, entry.name, { maxBytes: config.data.maxFileBytes });
++ if (!blob || blob.binary) return null;
++ const html = /\.(md|markdown)$/i.test(entry.name)
++ ? renderMarkdown(blob.text || '')
++ : `<pre>${esc(blob.text || '')}</pre>`;
++ return { path: entry.name, html };
++ } catch { return null; }
++ }
++
++ // ── Home ────────────────────────────────────────────────
++ r.get('/', h(async (req, res) => {
++ const [stats, trending, activity, official, languages] = await Promise.all([
++ repos.stats(), repos.trending(6), repos.activity(10), repos.official(), repos.languagesFacet(),
++ ]);
++ return page(req, res, views.home({ viewer: viewer(req), stats, trending, activity, official, baseUrl: config.baseUrl }), {
++ title: null, index: true,
++ description: 'Git for OpenVibe: host code with your OpenVibe account, browse it, mirror GitHub, and clone it to your computer with Link. Made with git’s own http-backend.',
++ jsonLd: [{
++ '@context': 'https://schema.org', '@type': 'WebSite',
++ name: 'OpenVibe Git', url: config.baseUrl.replace(/\/+$/, ''),
++ potentialAction: { '@type': 'SearchAction', target: `${config.baseUrl.replace(/\/+$/, '')}/explore?q={search_term_string}`, 'query-input': 'required name=search_term_string' },
++ }],
++ });
++ }));
++
++ // ── Explore ─────────────────────────────────────────────
++ r.get('/explore', h(async (req, res) => {
++ const filters = {
++ official: req.query.official === '1', mirror: req.query.mirror === '1',
++ mine: req.query.mine === '1', starred: req.query.starred === '1',
++ sort: String(req.query.sort || 'trending'), language: String(req.query.language || ''),
++ };
++ const out = await repos.list({
++ viewer: viewer(req), q: String(req.query.q || ''), language: filters.language,
++ official: filters.official, mirror: filters.mirror, mine: filters.mine, starred: filters.starred,
++ sort: filters.sort, limit: 24, cursor: req.query.cursor,
++ });
++ return page(req, res, views.explore({
++ viewer: viewer(req), repositories: out.repositories, query: String(req.query.q || ''),
++ filters, next: out.next_cursor, languages: await repos.languagesFacet(),
++ }), { title: 'Explore', index: true, description: 'Every public repository on OpenVibe Git: official OpenVibe projects, GitHub mirrors, and what people are making.' });
++ }));
++
++ // ── New ─────────────────────────────────────────────────
++ r.get('/new', h(async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, loginTo('/new'));
++ return page(req, res, views.newPage({ viewer: viewer(req) }), { title: 'New repository' });
++ }));
++
++ r.post('/new', h(async (req, res) => {
++ if (!guard(req, res)) return undefined;
++ const values = { name: req.body.name, description: req.body.description, visibility: req.body.visibility, starter: req.body.starter, mirror_url: req.body.mirror_url };
++ try {
++ const starter = values.starter === 'mirror' ? 'empty' : (values.starter || 'empty');
++ const repo = await repos.create({
++ actor: viewer(req), name: values.name, description: values.description,
++ visibility: values.visibility === 'private' ? 'private' : 'public', starter,
++ mirrorUrl: values.starter === 'mirror' ? values.mirror_url : null,
++ }, { address: req.ip });
++ return res.redirect(303, `/${repo.full_name}`);
++ } catch (err) {
++ return page(req, res, views.newPage({ viewer: viewer(req), values, error: err.message }), { status: err.status || 422, title: 'New repository' });
++ }
++ }));
++
++ // ── Profile ─────────────────────────────────────────────
++ r.get('/@:username', h(async (req, res) => {
++ const profile = await repos.profile(String(req.params.username).toLowerCase(), { viewer: viewer(req) });
++ if (!profile) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
++ return page(req, res, views.profilePage({ profile }), { title: `@${profile.username}`, description: `@${profile.username}'s public repositories on OpenVibe Git.` });
++ }));
++
++ // ── A repository ────────────────────────────────────────
++ r.get('/:owner/:name', h(async (req, res) => {
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ await insights.recordView(row, { address: req.ip || '', userAgent: String(req.get('user-agent') || ''), subject: signedIn(req) ? viewer(req).subject : null });
++ const ref = row.default_branch || 'main';
++ const [tree, context] = await Promise.all([git.readTree(row.owner_username, row.name, ref, '').catch(() => null), repoContext(req, row)]);
++ const readme = tree ? await readReadme(row, ref) : null;
++ return page(req, res, views.repoPage({ row, viewer: viewer(req), tree: tree || { path: '', entries: [] }, readme, ...context }), meta(req, row));
++ }));
++
++ r.get('/:owner/:name/tree', h(async (req, res) => {
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ const ref = String(req.query.ref || row.default_branch || 'main');
++ const pathAt = String(req.query.path || '');
++ if (!validRef(ref) || !validPath(pathAt)) return page(req, res, views.message({ title: 'Not a ref or path', text: 'That ref or path is not something this server reads.', link: { href: `/${row.owner_username}/${row.name}`, label: 'Back to the repository' } }), { status: 422 });
++ await insights.recordView(row, { address: req.ip || '', userAgent: String(req.get('user-agent') || ''), subject: signedIn(req) ? viewer(req).subject : null });
++ const tree = await git.readTree(row.owner_username, row.name, ref, pathAt);
++ if (!tree) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
++ const context = await repoContext(req, row);
++ return page(req, res, views.treePage({ row, viewer: viewer(req), tree, ...context }), meta(req, row, { title: `${pathAt || row.name} at ${ref}` }));
++ }));
++
++ r.get('/:owner/:name/blob', h(async (req, res) => {
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ const ref = String(req.query.ref || row.default_branch || 'main');
++ const pathAt = String(req.query.path || '');
++ if (!validRef(ref) || !validPath(pathAt) || !pathAt) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
++ await insights.recordView(row, { address: req.ip || '', userAgent: String(req.get('user-agent') || ''), subject: signedIn(req) ? viewer(req).subject : null });
++ let blob = await git.readBlob(row.owner_username, row.name, ref, pathAt, { maxBytes: config.data.maxFileBytes });
++ if (!blob) {
++ const whole = await git.blobBytes(row.owner_username, row.name, ref, pathAt, { maxBytes: config.data.maxFileBytes });
++ if (!whole) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
++ blob = { size: whole.size, binary: true, truncated: whole.truncated, text: null };
++ }
++ const crumbs = pathAt.split('/').map((seg, i, all) => ({ name: seg, path: all.slice(0, i + 1).join('/') }));
++ return page(req, res, views.filePage({ row, ref, path: pathAt, blob, crumbs, plain: req.query.plain === '1' }), meta(req, row, { title: `${pathAt} at ${ref}` }));
++ }));
++
++ r.get('/:owner/:name/raw', h(async (req, res) => {
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ const ref = String(req.query.ref || row.default_branch || 'main');
++ const pathAt = String(req.query.path || '');
++ if (!validRef(ref) || !validPath(pathAt) || !pathAt) return res.status(404).type('text/plain').send('Not found.');
++ const blob = await git.blobBytes(row.owner_username, row.name, ref, pathAt, { maxBytes: config.data.maxFileBytes });
++ if (!blob) return res.status(404).type('text/plain').send('Not found.');
++ const ext = pathAt.split('.').pop().toLowerCase();
++ const type = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', ico: 'image/x-icon' }[ext] || 'text/plain; charset=utf-8';
++ res.set('Content-Type', type).set('Content-Length', String(blob.data.length)).set('Cache-Control', 'public, max-age=300');
++ return res.end(blob.data);
++ }));
++
++ r.get('/:owner/:name/commits', h(async (req, res) => {
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ const ref = String(req.query.ref || row.default_branch || 'main');
++ const pathAt = String(req.query.path || '');
++ if (!validRef(ref) || !validPath(pathAt)) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
++ const limit = Math.max(1, Math.min(100, Number(req.query.limit) || 30));
++ const commits = await git.history(row.owner_username, row.name, ref, { path: pathAt, limit, skip: req.query.cursor });
++ if (!commits) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
++ return page(req, res, views.commitsPage({ row, ref, path: pathAt, commits, next: commits.length === limit ? String((Number(req.query.cursor) || 0) + limit) : null }), meta(req, row, { title: `Commits on ${ref}` }));
++ }));
++
++ r.get('/:owner/:name/commit/:sha', h(async (req, res) => {
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ const commit = await git.commitDetail(row.owner_username, row.name, req.params.sha, { diff: true });
++ if (!commit) return page(req, res, views.notFound(), { status: 404, title: 'Not found' });
++ return page(req, res, views.commitPage({ row, commit }), meta(req, row, { title: commit.message.split('\n')[0] }));
++ }));
++
++ r.get('/:owner/:name/branches', h(async (req, res) => {
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ return page(req, res, views.branchesPage({ row, branches: await git.branches(row.owner_username, row.name) }), meta(req, row, { title: 'Branches' }));
++ }));
++
++ r.get('/:owner/:name/tags', h(async (req, res) => {
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ return page(req, res, views.tagsPage({ row, tags: await git.tags(row.owner_username, row.name) }), meta(req, row, { title: 'Tags' }));
++ }));
++
++ r.get('/:owner/:name/releases', h(async (req, res) => {
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ return page(req, res, views.releasesPage({ row, releases: await repos.releases(row), viewer: viewer(req) }), meta(req, row, { title: 'Releases' }));
++ }));
++
++ r.get('/:owner/:name/contributors', h(async (req, res) => {
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ return page(req, res, views.contributorsPage({ row, contributors: await repos.contributors(row) }), meta(req, row, { title: 'Contributors' }));
++ }));
++
++ r.get('/:owner/:name/insights', h(async (req, res) => {
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ const data = await repos.insightsFor(row, { days: req.query.days });
++ return page(req, res, views.insightsPage({ row, data }), meta(req, row, { title: 'Insights', description: `Views, clones, stars and commits for ${row.owner_username}/${row.name}.` }));
++ }));
++
++ // ── Writes ──────────────────────────────────────────────
++ r.post('/:owner/:name/star', h(async (req, res) => {
++ if (!guard(req, res)) return undefined;
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ await repos.star(row, viewer(req).subject, !(await repos.isStarred(row, viewer(req).subject)));
++ return res.redirect(303, `/${row.owner_username}/${row.name}`);
++ }));
++
++ r.post('/:owner/:name/watch', h(async (req, res) => {
++ if (!guard(req, res)) return undefined;
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ await repos.watch(row, viewer(req).subject);
++ return res.redirect(303, `/${row.owner_username}/${row.name}`);
++ }));
++
++ r.post('/:owner/:name/fork', h(async (req, res) => {
++ if (!guard(req, res)) return undefined;
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ try {
++ const forked = await repos.fork({ source: row, actor: viewer(req) }, { address: req.ip });
++ return res.redirect(303, `/${forked.full_name}`);
++ } catch (err) {
++ return page(req, res, views.message({ title: 'The fork was not made', text: err.message, link: { href: `/${row.owner_username}/${row.name}`, label: 'Back to the repository' } }), { status: err.status || 422 });
++ }
++ }));
++
++ r.post('/:owner/:name/sync', h(async (req, res) => {
++ if (!guard(req, res)) return undefined;
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ if (!repos.isOwner(row, viewer(req))) return res.status(403).type('text/plain').send('Only the owner can sync a mirror.');
++ if (row.mirror_url) mirrors.syncRow(row).catch(() => {});
++ return res.redirect(303, `/${row.owner_username}/${row.name}`);
++ }));
++
++ r.post('/:owner/:name/push-mirror', h(async (req, res) => {
++ if (!guard(req, res)) return undefined;
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ try {
++ await repos.setPushMirror(row, { push_url: req.body.push_url, push_token: req.body.push_token }, viewer(req), { address: req.ip });
++ } catch (err) {
++ return page(req, res, views.message({ title: 'The push mirror was not saved', text: err.message, link: { href: `/${row.owner_username}/${row.name}`, label: 'Back to the repository' } }), { status: err.status || 422 });
++ }
++ return res.redirect(303, `/${row.owner_username}/${row.name}`);
++ }));
++
++ r.post('/:owner/:name/releases', h(async (req, res) => {
++ if (!guard(req, res)) return undefined;
++ const row = await rowFor(req, res);
++ if (!row) return undefined;
++ if (!repos.isOwner(row, viewer(req))) return res.status(403).type('text/plain').send('Only the owner can publish a release.');
++ try {
++ await repos.publishRelease(row, { tag: req.body.tag, name: req.body.name, body: req.body.body, actor: viewer(req) });
++ } catch (err) {
++ return page(req, res, views.message({ title: 'The release was not published', text: err.message, link: { href: `/${row.owner_username}/${row.name}/releases`, label: 'Releases' } }), { status: err.status || 422 });
++ }
++ return res.redirect(303, `/${row.owner_username}/${row.name}/releases`);
++ }));
++
++ // ── Crawl artifacts and the API document ────────────────
++ r.get('/robots.txt', (_req, res) => {
++ res.type('text/plain').send(`User-agent: *\nAllow: /\nDisallow: /auth/\nDisallow: /api/\n\nSitemap: ${config.baseUrl.replace(/\/+$/, '')}/sitemap.xml\n`);
++ });
++ r.get('/llms.txt', h(async (_req, res) => {
++ const official = await repos.official();
++ const lines = [
++ '# OpenVibe Git', '', '> Git for OpenVibe: host code with your OpenVibe account, browse it, mirror GitHub, and clone it to your computer with Link.', '',
++ 'Every page is server-rendered. A public repository is readable at /<owner>/<name>; its files at /<owner>/<name>/tree and /<owner>/<name>/blob (ref and path are query parameters). The API is /api/v1/repos.', '',
++ '## Official repositories', '',
++ ...official.map((o) => `- [${o.full_name}](${config.baseUrl.replace(/\/+$/, '')}/${o.full_name}): ${o.description || 'The OpenVibe projects.'}`), '',
++ ];
++ res.type('text/plain').send(lines.join('\n'));
++ }));
++ r.get('/sitemap.xml', h(async (_req, res) => {
++ const out = await repos.list({ sort: 'new', limit: 50 });
++ const base = config.baseUrl.replace(/\/+$/, '');
++ const urls = ['/', '/explore', '/new', ...out.repositories.map((x) => `/${x.full_name}`)];
++ res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.map((u) => `<url><loc>${esc(base + u)}</loc></url>`).join('')}</urlset>`);
++ }));
++ r.get('/openapi.json', (_req, res) => {
++ res.set('Access-Control-Allow-Origin', '*').json({
++ openapi: '3.1.0', info: { title: 'OpenVibe.Git API', version: '1.0.0', description: 'Repositories, browsing, contents writes, stars, mirrors and insights on git.openvibe.codes.' },
++ servers: [{ url: config.baseUrl.replace(/\/+$/, '') }],
++ paths: {
++ '/api/v1/repos': { get: { summary: 'List and search repositories', responses: { 200: { description: 'Repositories' } } }, post: { summary: 'Create a repository', responses: { 201: { description: 'The repository' } } } },
++ '/api/v1/repos/{owner}/{name}': { get: { summary: 'One repository', parameters: [{ name: 'owner', in: 'path', required: true, schema: { type: 'string' } }, { name: 'name', in: 'path', required: true, schema: { type: 'string' } }], responses: { 200: { description: 'The repository' }, 404: { description: 'Not found or private' } } } },
++ '/api/v1/repos/{owner}/{name}/tree': { get: { summary: 'A directory at a ref', responses: { 200: { description: 'Tree' } } } },
++ '/api/v1/repos/{owner}/{name}/blob': { get: { summary: 'A file at a ref', responses: { 200: { description: 'Blob' } } } },
++ '/api/v1/repos/{owner}/{name}/commits': { get: { summary: 'History' }, post: { summary: 'Write a branch and files without a clone', responses: { 201: { description: 'The new commit' } } } },
++ '/api/v1/repos/{owner}/{name}/insights': { get: { summary: 'Views, clones, stars and commits per week', responses: { 200: { description: 'Insights' } } } },
++ '/api/v1/tokens': { post: { summary: 'Make a short-lived Git token', responses: { 201: { description: 'The token, shown once' } } } },
++ },
++ });
++ });
++
++ r.use((req, res) => page(req, res, views.notFound(), { status: 404, title: 'Not found' }));
++ return r;
++}
++
++module.exports = { createPageRoutes };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..045afb9
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,46 @@
++'use strict';
++
++/**
++ * OpenVibe.Git — process entry. `node server/index.js` listens on PORT (5270) behind nginx (deploy/), starts the
++ * mirror poll, and stops in order on SIGTERM: the HTTP drain, the mirror poll, the cat-file processes, the
++ * database.
++ */
++const { createApp } = require('./app');
++const { gracefulStop } = require('openvibe-sdk/service');
++const { catFiles } = require('./git/store');
++
++function createLifecycle({ server, exit, signals, ctx } = {}) {
++ const mirrors = ctx && ctx.mirrors;
++ const store = ctx && ctx.store;
++ return gracefulStop({
++ name: 'OpenVibe.Git',
++ server,
++ deadlineExitCode: 0,
++ exit,
++ signals,
++ deadlineMs: 10_000,
++ stop: [() => (mirrors ? mirrors.stop() : undefined)],
++ close: [() => catFiles.close(), () => (store ? store.close() : undefined)],
++ });
++}
++
++async function start() {
++ const { app, ctx, release } = await createApp();
++ const { config } = ctx;
++ if (config.mirrors.pollMs) ctx.mirrors.start();
++ const server = app.listen(config.port, config.host, () => {
++ console.log(`[OpenVibe.Git] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (release ${release.release})`);
++ });
++ server.keepAliveTimeout = 65_000;
++ createLifecycle({ server, ctx });
++ return { server, ctx };
++}
++
++if (require.main === module) {
++ start().catch((err) => {
++ console.error('[OpenVibe.Git] failed to start:', err);
++ process.exit(1);
++ });
++}
++
++module.exports = { start, createLifecycle };
ddiff --git a/server/insights.js b/server/insights.js
nnew file mode 100644
iindex 0000000..bbff9fd
---- /dev/null
++++ b/server/insights.js
@@@ -0,0 +1,76 @@
++'use strict';
++
++/**
++ * A repository's insights: page views and unique visitors, clones, stars and commits per week, per day over a
++ * window. A view and a clone are kept as a hashed visitor (sha256 of address or subject + the day): we can count
++ * a person once a day and never learn who they were. Public for a public repository.
++ */
++const crypto = require('node:crypto');
++
++const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
++const hash = (...parts) => crypto.createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 32);
++
++function createInsights({ store, log = console }) {
++ async function recordView(repo, { address = '', userAgent = '', subject = null }) {
++ const day = dayOf(store.now());
++ const visitor = hash(address || subject || 'anonymous', userAgent.slice(0, 120), day);
++ try {
++ await store.execute(
++ `INSERT INTO git_views (repo_id, day, visitor_hash, views) VALUES (?, ?, ?, 1)
++ ON CONFLICT (repo_id, day, visitor_hash) DO UPDATE SET views = git_views.views + 1`,
++ [repo.id, day, visitor],
++ );
++ } catch (err) { log.warn('[OpenVibe.Git] view not recorded:', err.message); }
++ }
++
++ async function recordClone(repo, { address = '', subject = null }) {
++ const day = dayOf(store.now());
++ const who = hash(subject || address || 'anonymous', day);
++ try {
++ await store.execute(
++ `INSERT INTO git_clones (repo_id, day, subject_hash, count) VALUES (?, ?, ?, 1)
++ ON CONFLICT (repo_id, day, subject_hash) DO UPDATE SET count = git_clones.count + 1`,
++ [repo.id, day, who],
++ );
++ } catch (err) { log.warn('[OpenVibe.Git] clone not recorded:', err.message); }
++ }
++
++ /** The contract shape: totals, a daily series, and commits per week from the default branch. */
++ async function forRepo(repo, { days = 30, commitsPerWeek = async () => [] } = {}) {
++ const span = Math.max(1, Math.min(90, Number(days) || 30));
++ const since = dayOf(store.now() - (span - 1) * 86400_000);
++ const [viewsRows, cloneRows, starRows, totalStars] = await Promise.all([
++ store.many('SELECT day, sum(views)::int AS views, count(*)::int AS uniques FROM git_views WHERE repo_id = ? AND day >= ? GROUP BY day', [repo.id, since]),
++ store.many('SELECT day, sum(count)::int AS clones FROM git_clones WHERE repo_id = ? AND day >= ? GROUP BY day', [repo.id, since]),
++ store.many('SELECT to_char(created_at, \'YYYY-MM-DD\') AS day, count(*)::int AS stars FROM git_stars WHERE repo_id = ? AND created_at >= ? GROUP BY 1', [repo.id, new Date(store.now() - (span - 1) * 86400_000).toISOString()]),
++ store.value('SELECT count(*)::int FROM git_stars WHERE repo_id = ?', [repo.id]),
++ ]);
++ const viewBy = new Map(viewsRows.map((r) => [String(r.day).slice(0, 10), r]));
++ const cloneBy = new Map(cloneRows.map((r) => [String(r.day).slice(0, 10), r.clones]));
++ const starBy = new Map(starRows.map((r) => [String(r.day).slice(0, 10), r.stars]));
++ const daily = [];
++ let views = 0; let unique = 0; let clones = 0; let inWindow = 0;
++ for (let i = span - 1; i >= 0; i -= 1) {
++ const day = dayOf(store.now() - i * 86400_000);
++ const v = viewBy.get(day) || { views: 0, uniques: 0 };
++ const c = cloneBy.get(day) || 0;
++ const s = starBy.get(day) || 0;
++ views += v.views; unique += v.uniques; clones += c; inWindow += s;
++ daily.push({ day, views: v.views, unique_visitors: v.uniques, clones: c, stars: s });
++ }
++ return {
++ repo: repo.full_name || `${repo.owner_username}/${repo.name}`,
++ window_days: span,
++ views,
++ unique_visitors: unique,
++ clones,
++ stars: { total: Number(totalStars) || 0, in_window: inWindow },
++ commits_per_week: await commitsPerWeek(),
++ daily,
++ };
++ }
++
++ return { recordView, recordClone, forRepo };
++}
++
++module.exports = { createInsights, dayOf, hash };
ddiff --git a/server/mirror.js b/server/mirror.js
nnew file mode 100644
iindex 0000000..c932f87
---- /dev/null
++++ b/server/mirror.js
@@@ -0,0 +1,118 @@
++'use strict';
++
++/**
++ * GitHub mirrors, three directions:
++ *
++ * in an official repository mirrors from GitHub on GitHub's push webhook, with a ten-minute poll as backup;
++ * anyone may mirror a public GitHub URL when they create a repository.
++ * out a repository may push every push back to its GitHub twin (the owner's token for their own, the
++ * OpenVibers token for an official one). The token is sealed in the row and passed in the environment.
++ *
++ * A fetch goes through the egress guard: the URL is https, exactly github.com, and resolves to a public address.
++ * One sync per repository at a time; a second request joins the first.
++ */
++const { assertPublicUrl } = require('openvibe-shared/egress');
++
++function createMirrors({ store, repos, config, git, github, log = console }) {
++ const running = new Map(); // repo id → promise
++
++ async function guardUrl(url) {
++ let u;
++ try { u = new URL(url); } catch { throw new Error('not a URL'); }
++ if (u.protocol !== 'https:') throw new Error('a mirror URL is https');
++ if (!['github.com', 'www.github.com'].includes(u.hostname)) throw new Error('a mirror is on github.com');
++ await assertPublicUrl(u);
++ return u;
++ }
++
++ async function syncRow(row, { initial = false } = {}) {
++ if (!row || !row.mirror_url) return { ok: false, reason: 'not a mirror' };
++ if (running.has(row.id)) return { ok: true, joined: true };
++ const task = (async () => {
++ try {
++ await guardUrl(row.mirror_url);
++ await store.execute("UPDATE git_repos SET mirror_state = 'syncing', mirror_error = NULL WHERE id = ?", [row.id]);
++ if (initial || !(await git.repoExists(row.owner_username, row.name))) {
++ await git.cloneMirror(row.owner_username, row.name, row.mirror_url, { timeoutMs: config.mirrors.timeoutMs });
++ } else {
++ await git.fetchMirror(row.owner_username, row.name, { timeoutMs: config.mirrors.timeoutMs });
++ }
++ await store.execute("UPDATE git_repos SET mirror_state = 'ready', mirror_error = NULL, mirror_last_synced_at = now(), pushed_at = now(), updated_at = now() WHERE id = ?", [row.id]);
++ await repos.refreshLanguages(row);
++ await repos.recordEvent(row, 'mirror', { subject: null, username: null }, { state: 'ready' });
++ return { ok: true };
++ } catch (err) {
++ const message = String(err.message || err).slice(0, 500);
++ await store.execute("UPDATE git_repos SET mirror_state = 'error', mirror_error = ? WHERE id = ?", [message, row.id]).catch(() => {});
++ log.warn(`[OpenVibe.Git] mirror ${row.owner_username}/${row.name}: ${message}`);
++ return { ok: false, reason: message };
++ } finally {
++ running.delete(row.id);
++ }
++ })();
++ running.set(row.id, task);
++ return await task;
++ }
++
++ async function syncById(id) {
++ const row = await repos.byId(id);
++ return syncRow(row);
++ }
++
++ /** The ten-minute backup: every mirror is fetched again, two at a time. */
++ async function poll() {
++ const rows = await store.many("SELECT id FROM git_repos WHERE mirror_url IS NOT NULL ORDER BY mirror_last_synced_at ASC NULLS FIRST LIMIT 200");
++ for (let i = 0; i < rows.length; i += 2) {
++ await Promise.all(rows.slice(i, i + 2).map((r) => syncById(r.id)));
++ }
++ }
++
++ async function handleWebhook(rawBody, headers) {
++ if (!github.signatureOk(rawBody, headers['x-hub-signature-256'])) {
++ return { status: 401, body: { error: 'signature' } };
++ }
++ let payload = null;
++ try { payload = JSON.parse(rawBody.toString('utf8')); } catch { return { status: 400, body: { error: 'json' } }; }
++ if (headers['x-github-event'] === 'ping') return { status: 200, body: { ok: true, pong: true } };
++ const full = payload && payload.repository && payload.repository.full_name;
++ if (!full) return { status: 200, body: { ok: true, ignored: true } };
++ const row = await store.maybe('SELECT * FROM git_repos WHERE lower(github_full_name) = lower(?)', [full]);
++ if (!row) return { status: 200, body: { ok: true, ignored: 'no mirror' } };
++ // Acknowledge now, fetch after: GitHub's delivery does not wait on a large clone.
++ syncRow(row).catch(() => {});
++ return { status: 202, body: { ok: true, queued: true, repo: full } };
++ }
++
++ /** After a push: mark it and, if a push mirror is on, push back to GitHub. */
++ async function afterPush(row) {
++ await store.execute('UPDATE git_repos SET pushed_at = now(), updated_at = now() WHERE id = ?', [row.id]);
++ await repos.refreshLanguages(row);
++ if (!row.push_mirror_url) return;
++ const token = repos.pushTokenFor(row) || (row.official ? config.github.pushToken : '');
++ if (!token) {
++ await store.execute("UPDATE git_repos SET push_mirror_state = 'error' WHERE id = ?", [row.id]);
++ return;
++ }
++ try {
++ await git.pushMirror(row.owner_username, row.name, row.push_mirror_url, { token, timeoutMs: config.mirrors.timeoutMs });
++ await store.execute("UPDATE git_repos SET push_mirror_state = 'on' WHERE id = ?", [row.id]);
++ } catch (err) {
++ await store.execute("UPDATE git_repos SET push_mirror_state = 'error' WHERE id = ?", [row.id]);
++ log.warn(`[OpenVibe.Git] push mirror ${row.owner_username}/${row.name}: ${String(err.message).slice(0, 200)}`);
++ }
++ }
++
++ let timer = null;
++ return {
++ syncRow, syncById, poll, handleWebhook, afterPush,
++ start() {
++ if (timer || config.drill) return;
++ timer = setInterval(() => { poll().catch((err) => log.warn('[OpenVibe.Git] mirror poll:', err.message)); }, config.mirrors.pollMs);
++ timer.unref();
++ },
++ stop() { if (timer) clearInterval(timer); timer = null; },
++ isRunning: (id) => running.has(id),
++ };
++}
++
++module.exports = { createMirrors };
ddiff --git a/server/paths.js b/server/paths.js
nnew file mode 100644
iindex 0000000..7c3ae32
---- /dev/null
++++ b/server/paths.js
@@@ -0,0 +1,15 @@
++'use strict';
++
++/**
++ * Where OpenVibe.Git keeps what is its own: the bare git repositories and the empty hooks directory
++ * (a repository never runs a user's hook; core.hooksPath points here). GIT_DATA_DIR (or DATA_DIR) moves
++ * it; production uses /var/lib/openvibe/git, which the host's backups cover.
++ */
++const path = require('path');
++
++const DATA_DIR = path.resolve(process.env.GIT_DATA_DIR || process.env.DATA_DIR || path.join(__dirname, '..', 'data'));
++const REPOS_DIR = path.join(DATA_DIR, 'repos');
++const HOOKS_DIR = path.join(DATA_DIR, 'no-hooks');
++const TMP_DIR = path.join(DATA_DIR, 'tmp');
++
++module.exports = { DATA_DIR, REPOS_DIR, HOOKS_DIR, TMP_DIR };
ddiff --git a/server/render/highlight.js b/server/render/highlight.js
nnew file mode 100644
iindex 0000000..93af52c
---- /dev/null
++++ b/server/render/highlight.js
@@@ -0,0 +1,75 @@
++'use strict';
++
++/**
++ * Server-side syntax highlighting (highlight.js, common language set). Rendered into the
++ * HTML so crawlers and no-JS readers get the same page as everyone else. Anything the
++ * highlighter cannot name comes back escaped as plain text — never unhighlighted markup.
++ */
++const hljs = require('highlight.js/lib/common');
++
++const MAX_HIGHLIGHT_BYTES = 200 * 1024; // beyond this the page is served as plain text; the raw file is still there
++
++// Media's language names → highlight.js names (and a few aliases people type).
++const ALIASES = {
++ text: 'plaintext', txt: 'plaintext', plain: 'plaintext', log: 'plaintext',
++ js: 'javascript', node: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
++ py: 'python', sh: 'bash', shell: 'bash', zsh: 'bash', console: 'shell', ps1: 'powershell',
++ yml: 'yaml', md: 'markdown', 'c++': 'cpp', cc: 'cpp', h: 'c', cs: 'csharp', 'c#': 'csharp',
++ rb: 'ruby', rs: 'rust', golang: 'go', kt: 'kotlin', docker: 'dockerfile', html: 'xml', htm: 'xml', vue: 'xml', svg: 'xml',
++ conf: 'ini', toml: 'ini', env: 'ini', make: 'makefile', mk: 'makefile', patch: 'diff',
++};
++
++// What the "language" select offers. `auto` lets Media guess from the content.
++const LANGUAGES = [
++ ['auto', 'Auto-detect'], ['text', 'Plain text'], ['markdown', 'Markdown'],
++ ['javascript', 'JavaScript'], ['typescript', 'TypeScript'], ['json', 'JSON'], ['html', 'HTML'], ['css', 'CSS'], ['scss', 'SCSS'],
++ ['python', 'Python'], ['bash', 'Bash / shell'], ['powershell', 'PowerShell'], ['sql', 'SQL'], ['yaml', 'YAML'], ['ini', 'INI / TOML'],
++ ['go', 'Go'], ['rust', 'Rust'], ['php', 'PHP'], ['java', 'Java'], ['kotlin', 'Kotlin'], ['swift', 'Swift'],
++ ['c', 'C'], ['cpp', 'C++'], ['csharp', 'C#'], ['ruby', 'Ruby'], ['lua', 'Lua'], ['perl', 'Perl'], ['r', 'R'],
++ ['xml', 'XML'], ['dockerfile', 'Dockerfile'], ['makefile', 'Makefile'], ['diff', 'Diff / patch'], ['nginx', 'nginx config'],
++];
++
++function escapeHtml(s) {
++ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++}
++
++function resolveLanguage(name) {
++ const raw = String(name || 'text').trim().toLowerCase();
++ const mapped = ALIASES[raw] || raw;
++ return hljs.getLanguage(mapped) ? mapped : 'plaintext';
++}
++
++/** Human label for a stored language name. */
++function languageLabel(name) {
++ const raw = String(name || 'text').toLowerCase();
++ const known = LANGUAGES.find(([id]) => id === raw);
++ if (known) return known[1];
++ return raw === 'plaintext' ? 'Plain text' : raw;
++}
++
++/**
++ * @returns {{ html: string, language: string, lines: number, highlighted: boolean }}
++ * html is the inner HTML of a <code> element.
++ */
++function highlight(content, language) {
++ const text = String(content || '');
++ const lines = text.length ? text.split('\n').length : 0;
++ const lang = resolveLanguage(language);
++ if (lang === 'plaintext' || Buffer.byteLength(text, 'utf8') > MAX_HIGHLIGHT_BYTES) {
++ return { html: escapeHtml(text), language: lang, lines, highlighted: false };
++ }
++ try {
++ const out = hljs.highlight(text, { language: lang, ignoreIllegals: true });
++ return { html: out.value, language: lang, lines, highlighted: true };
++ } catch {
++ return { html: escapeHtml(text), language: 'plaintext', lines, highlighted: false };
++ }
++}
++
++/** File extension for downloads. */
++function extensionFor(language) {
++ const map = { javascript: 'js', typescript: 'ts', python: 'py', bash: 'sh', shell: 'sh', powershell: 'ps1', markdown: 'md', yaml: 'yml', rust: 'rs', ruby: 'rb', csharp: 'cs', cpp: 'cpp', kotlin: 'kt', dockerfile: 'Dockerfile', makefile: 'Makefile', plaintext: 'txt', text: 'txt', html: 'html', xml: 'xml', json: 'json', css: 'css', scss: 'scss', sql: 'sql', go: 'go', php: 'php', java: 'java', swift: 'swift', c: 'c', lua: 'lua', perl: 'pl', r: 'r', ini: 'ini', diff: 'diff', nginx: 'conf' };
++ return map[String(language || 'text').toLowerCase()] || 'txt';
++}
++
++module.exports = { highlight, escapeHtml, resolveLanguage, languageLabel, extensionFor, LANGUAGES };
ddiff --git a/server/render/html.js b/server/render/html.js
nnew file mode 100644
iindex 0000000..8db9c22
---- /dev/null
++++ b/server/render/html.js
@@@ -0,0 +1,53 @@
++'use strict';
++
++/** Page helpers: escape everything dynamic, and the small formatting functions the views share. */
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++function bytes(n) {
++ const v = Number(n) || 0;
++ if (v < 1024) return `${v} B`;
++ if (v < 1024 ** 2) return `${(v / 1024).toFixed(1)} KB`;
++ if (v < 1024 ** 3) return `${(v / 1024 ** 2).toFixed(1)} MB`;
++ return `${(v / 1024 ** 3).toFixed(2)} GB`;
++}
++
++function ago(iso, now = Date.now()) {
++ if (!iso) return 'never';
++ const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
++ if (!Number.isFinite(s)) return 'never';
++ if (s < 60) return 'just now';
++ if (s < 3600) return `${Math.round(s / 60)} min ago`;
++ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
++ if (s < 86400 * 30) return `${Math.round(s / 86400)} days ago`;
++ return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
++}
++
++const date = (iso) => (iso ? new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : '—');
++
++const initials = (name) => String(name || '?').trim().slice(0, 2).toUpperCase();
++
++const plural = (n, one, many) => `${n} ${n === 1 ? one : (many || `${one}s`)}`;
++
++/** A small bar chart from daily numbers, as div columns (no script, no canvas). */
++function spark(rows, key = 'views', { height = 34, label = 'activity' } = {}) {
++ const max = Math.max(1, ...rows.map((r) => Number(r[key]) || 0));
++ return `<div class="git-spark" style="--git-spark-h:${height}px" role="img" aria-label="${esc(label)}">${rows.map((r) => {
++ const v = Number(r[key]) || 0;
++ return `<span style="height:${Math.max(2, Math.round((v / max) * height))}px" title="${esc(`${r.day || r.week}: ${v}`)}"></span>`;
++ }).join('')}</div>`;
++}
++
++/** The language bar: a stack of percentages. */
++function languageBar(languages = {}) {
++ // The four largest by share, then the rest as "Other": a legend of "YAML 0%" and "Shell 0.1%" was noise (11-09).
++ const all = Object.entries(languages).map(([k, v]) => [k, Number(v) || 0]).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
++ const top = all.filter(([, v]) => v >= 1).slice(0, 4);
++ const rest = Math.round((100 - top.reduce((n, [, v]) => n + v, 0)) * 10) / 10;
++ const entries = rest >= 0.5 && all.length > top.length ? [...top, ['Other', rest]] : top;
++ if (!entries.length) return '';
++ const colours = ['var(--git-l1)', 'var(--git-l2)', 'var(--git-l3)', 'var(--git-l4)', 'var(--git-l5)', 'var(--git-l6)'];
++ return `<div class="git-langbar" role="img" aria-label="${esc(entries.map(([k, v]) => `${k} ${v}%`).join(', '))}">${entries.map(([k, v], i) => `<span style="width:${Math.min(100, v)}%;background:${colours[i]}"></span>`).join('')}</div>
++<p class="git-langlist">${entries.map(([k, v], i) => `<span><i style="background:${colours[i]}"></i>${esc(k)} <b>${v}%</b></span>`).join('')}</p>`;
++}
++
++module.exports = { esc, bytes, ago, date, initials, plural, spark, languageBar };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..93c1038
---- /dev/null
++++ b/server/render/layout.js
@@@ -0,0 +1,109 @@
++'use strict';
++
++/**
++ * The page shell. Every page is server-rendered through openvibe-shared/shell and works without JavaScript; the
++ * shared navbar, footer and kit are the same ones every OpenVibe site wears. Git is developer tooling under
++ * openvibe.codes, so it borrows the "make" room's accent and names itself Git.OpenVibe.Codes in the navbar.
++ */
++const crypto = require('node:crypto');
++const fs = require('node:fs');
++const path = require('node:path');
++const appIcon = require('openvibe-shared/app-icon');
++const shell = require('openvibe-shared/shell');
++const rooms = require('openvibe-shared/rooms');
++const ovServe = require('openvibe-shared/serve');
++const cache = require('openvibe-shared/cache-policy');
++const { esc } = require('./html');
++
++const NETWORK_URL = 'https://openvibe.network';
++const SITE_NAME = 'OpenVibe Git';
++const TAGLINE = 'Git for OpenVibe: host code with your OpenVibe account, clone it with Link, code on it in Codes.';
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++const NAV = [
++ { label: 'Repositories', href: '/', icon: 'fa-code' },
++ { label: 'Explore', href: '/explore', icon: 'fa-magnifying-glass' },
++ { label: 'New', href: '/new', icon: 'fa-plus' },
++ { label: 'Codes', href: 'https://openvibe.codes', icon: 'fa-terminal' },
++];
++const API_BASE = 'https://git.openvibe.codes';
++
++const hashes = new Map();
++function assetVersion(rel) {
++ if (hashes.has(rel)) return hashes.get(rel);
++ let v = 'dev';
++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
++ hashes.set(rel, v);
++ return v;
++}
++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
++
++let RELEASE = 'dev';
++function setRelease(id) { if (id) RELEASE = String(id); }
++
++/**
++ * o: title, description, body, viewer, config, path, active, index, robots, canonical, jsonLd
++ */
++function layout(o) {
++ const config = o.config || { baseUrl: API_BASE };
++ const signedIn = o.viewer && o.viewer.kind === 'user';
++ const pathNow = o.path || '/';
++ const active = o.active || pathNow;
++ const canonical = o.canonical === undefined ? `${String(config.baseUrl).replace(/\/+$/, '')}${pathNow.split('?')[0]}` : o.canonical;
++ const navbar = {
++ service: 'git',
++ brand: { sub: 'git', tld: 'codes' },
++ apiBase: NETWORK_URL,
++ links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && pathNow.startsWith(`${l.href}/`)) })),
++ appBar: [
++ { label: 'Repositories', href: '/', icon: 'fa-code' },
++ { label: 'Explore', href: '/explore', icon: 'fa-magnifying-glass' },
++ { label: 'New', href: '/new', icon: 'fa-plus' },
++ ],
++ silentLogin: `${String(config.baseUrl).replace(/\/+$/, '')}/auth/login?silent=1&next={url}`,
++ sessionUrl: '/auth/me',
++ loginUrl: '/auth/login?next={path}',
++ logoutUrl: '/auth/logout?next={path}',
++ notificationsRealtime: true,
++ history: { type: 'page', title: o.title || SITE_NAME },
++ };
++ const footer = { service: 'git', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
++ return shell.page({
++ name: SITE_NAME,
++ lang: 'en',
++ title: o.title || `${SITE_NAME}: ${TAGLINE}`,
++ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
++ siteName: SITE_NAME,
++ description: o.description || `${SITE_NAME}: host code with your OpenVibe account, browse it, mirror GitHub, and clone it.`,
++ canonical,
++ robots: o.robots || (o.index ? 'index, follow' : 'noindex, follow'),
++ jsonLd: (o.jsonLd || []).filter(Boolean),
++ home: '/',
++ navLinks: NAV,
++ navbar,
++ footer,
++ head: `${appIcon.headTags({})}
++<link rel="stylesheet" href="${esc(ovServe.url('kit.css'))}">
++<link rel="stylesheet" href="${esc(ovServe.url('room.css'))}">
++<link rel="stylesheet" href="${esc(asset('css/git.css'))}">
++<script src="${esc(asset('js/git.js'))}" defer></script>
++<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>
++<meta name="ov-boost" content="git@${esc(RELEASE)}">`,
++ body: `<a class="skip" href="#main">Skip to content</a>
++<div id="navbar-mount"></div>
++<noscript><div class="account-bar" role="navigation" aria-label="Account">${signedIn
++ ? `Signed in as <strong>${esc(o.viewer.displayName || o.viewer.username || 'you')}</strong>`
++ : '<a href="/auth/login">Sign in with OpenVibe</a>'}</div></noscript>
++<main id="main" class="git ov-room" style="${rooms.style('website')}">${o.body}</main>
++<script>window.__OV_PAGE = ${JSON.stringify({ navbar, footer }).replace(/</g, '\\u003c')};
++document.addEventListener('DOMContentLoaded', function () { try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ } });</script>`,
++ });
++}
++
++function send(res, status, o) {
++ const personal = o.viewer && o.viewer.kind === 'user';
++ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ public: true, maxAge: 60 })));
++ res.set('Vary', 'Cookie');
++ res.status(status).type('html').send(layout(o));
++}
++
++module.exports = { layout, send, asset, assetVersion, setRelease, SITE_NAME, TAGLINE, NETWORK_URL, NAV, API_BASE };
ddiff --git a/server/render/markdown.js b/server/render/markdown.js
nnew file mode 100644
iindex 0000000..d9ad053
---- /dev/null
++++ b/server/render/markdown.js
@@@ -0,0 +1,226 @@
++'use strict';
++
++/**
++ * A small, safe Markdown renderer for forum posts.
++ *
++ * Safety model: the source is never trusted as HTML. Every piece of text is HTML-escaped first;
++ * the only tags in the output are the fixed ones this file writes (p, br, h3–h6, blockquote,
++ * ul/ol/li, hr, pre/code, strong, em, del, a). Raw HTML in a post is shown as text. Links keep
++ * only http(s), mailto, same-site paths and #fragments, and carry rel="nofollow ugc noopener".
++ * Fenced code blocks go through the same highlight.js wrapper pastes use (which escapes).
++ *
++ * Supported: paragraphs (single newlines are line breaks), # headings (demoted two levels so
++ * the page keeps one h1), > quotes, - / * / + and 1. lists (one level), ---, ``` fences with an
++ * optional language, `code`, **bold**, __bold__, *em*, _em_, ~~strike~~, [text](url) and bare
++ * https:// links. No images, tables, footnotes or raw HTML.
++ */
++const { escapeHtml: esc, highlight } = require('./highlight');
++
++const MAX_SOURCE = 100_000;
++const MAX_QUOTE_DEPTH = 3;
++// Every pattern here runs on untrusted text on each page view, so none may backtrack
++// super-linearly: trailing whitespace/#s are trimmed in code, not by competing \s* groups.
++const FENCE_RE = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]{0,32})$/;
++const FENCE = { test: (line) => FENCE_RE.test(line.trimEnd()) };
++const HEADING_RE = /^ {0,3}(#{1,6})\s+([\s\S]*)$/;
++const QUOTE = /^ {0,3}>\s?(.*)$/;
++const HR = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
++const UL = /^ {0,3}[-*+]\s+(.*)$/;
++const OL = /^ {0,3}(\d{1,9})[.)]\s+(.*)$/;
++const SAFE_URL = /^(https?:\/\/|mailto:|\/(?![/\\])|#)/i;
++
++/** [, hashes, text] for an ATX heading (text without the optional closing #s), or null. */
++function headingMatch(line) {
++ const m = line.match(HEADING_RE);
++ if (!m) return null;
++ const text = m[2].trimEnd().replace(/#+$/, '').trimEnd();
++ return /[\u2028\u2029]/.test(text) ? null : [m[0], m[1], text];
++}
++const HEADING = { test: (line) => headingMatch(line) !== null };
++
++/**
++ * Same result as s.replace(/<open>([\s\S]*?\S)<close>/g, …) for a delimiter pair, in linear time:
++ * the lazy regex rescans to the end of the text for every opener that has no closer (quadratic).
++ * Content runs from the end of an opener to the first closer after it; once one opener finds no
++ * closer, no later opener can either, so the scan stops.
++ */
++function pairUp(s, open, close, wrap) {
++ const o = new RegExp(open.source, 'g');
++ const c = new RegExp(close.source, 'g');
++ let out = '';
++ let pos = 0;
++ for (;;) {
++ o.lastIndex = pos;
++ const m = o.exec(s);
++ if (!m) break;
++ const start = m.index + m[0].length;
++ c.lastIndex = start;
++ const k = c.exec(s);
++ if (!k) break;
++ const end = k.index + 1; // the closer pattern starts with the content's last (non-space) character
++ out += s.slice(pos, m.index) + wrap(m, s.slice(start, end));
++ pos = k.index + k[0].length;
++ }
++ return out + s.slice(pos);
++}
++
++/**
++ * Same result as s.replace(/(`+)([^`\n]|[^`\n][\s\S]*?[^`\n])\1(?!`)/g, …) in O(n log n): an opener
++ * is the tail of a backtick run (longest first, as the regex tries it), its closer the next whole
++ * run of exactly that length not preceded by a newline, with non-empty content not starting with one.
++ */
++function codeSpans(s, onCode) {
++ const runs = [];
++ const byLen = new Map();
++ const re = /`+/g;
++ let m;
++ while ((m = re.exec(s))) {
++ runs.push([m.index, m[0].length]);
++ if (s[m.index - 1] !== '\n') {
++ if (!byLen.has(m[0].length)) byLen.set(m[0].length, []);
++ byLen.get(m[0].length).push(m.index);
++ }
++ }
++ const nextAt = (list, from) => {
++ let lo = 0;
++ let hi = list.length;
++ while (lo < hi) { const mid = (lo + hi) >> 1; if (list[mid] < from) lo = mid + 1; else hi = mid; }
++ return lo < list.length ? list[lo] : -1;
++ };
++ let out = '';
++ let pos = 0;
++ for (const [at, len] of runs) {
++ if (at < pos) continue;
++ const contentStart = at + len;
++ if (contentStart >= s.length || s[contentStart] === '\n') continue;
++ for (let n = len; n >= 1; n--) {
++ const list = byLen.get(n);
++ const close = list ? nextAt(list, contentStart + 1) : -1;
++ if (close < 0) continue;
++ out += s.slice(pos, at + len - n) + onCode(s.slice(contentStart, close));
++ pos = close + n;
++ break;
++ }
++ }
++ return out + s.slice(pos);
++}
++
++function unescapeEntities(s) {
++ return s.replace(/&(amp|lt|gt|quot|#39);/g, (_m, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" }[e]));
++}
++
++/** Inline formatting over one block's text (raw, unescaped in; safe HTML out). */
++function inline(raw) {
++ const slots = [];
++ const hold = (html) => `\u0000${slots.push(html) - 1}\u0000`;
++ // 1. code spans keep their content literal
++ let s = codeSpans(String(raw), (code) => hold(`<code>${esc(code)}</code>`));
++ // 2. everything else is escaped before any tag is written
++ s = esc(s);
++ // 3. [text](url) — only safe schemes; the rest stays as text
++ s = s.replace(/\[([^\]\n]{1,300})\]\(([^\s()]{1,2000})\)/g, (m, text, url) => {
++ if (!SAFE_URL.test(unescapeEntities(url))) return m;
++ return hold(`<a href="${url}" rel="nofollow ugc noopener">${emphasis(text)}</a>`);
++ });
++ // 4. bare links
++ s = s.replace(/\bhttps?:\/\/[^\s<>\u0000]+/gi, (m) => {
++ let url = m, tail = '';
++ while (/[.,;:!?)\]'"]$/.test(url) || /&(quot|#39|gt|lt);$/.test(url)) {
++ const ent = url.match(/&(quot|#39|gt|lt);$/);
++ const cut = ent ? ent[0].length : 1;
++ tail = url.slice(-cut) + tail; url = url.slice(0, -cut);
++ }
++ return url.length > 8 ? hold(`<a href="${url}" rel="nofollow ugc noopener">${url}</a>`) + tail : m;
++ });
++ s = emphasis(s);
++ s = s.replace(/\n/g, '<br>\n');
++ // Restore held pieces (a link's text may itself hold a code span).
++ for (let i = 0; i < 3 && s.includes('\u0000'); i++) s = s.replace(/\u0000(\d+)\u0000/g, (_m, n) => slots[Number(n)] || '');
++ return s;
++}
++
++/** Bold / italic / strike over already-escaped text. */
++function emphasis(s) {
++ s = pairUp(s, /\*\*(?=\S)/, /\S\*\*/, (_m, t) => `<strong>${t}</strong>`);
++ s = pairUp(s, /(^|[^\w])__(?=\S)/, /\S__(?!\w)/, (m, t) => `${m[1]}<strong>${t}</strong>`);
++ s = s
++ .replace(/(^|[^*\w])\*(?=[^\s*])([^*\n]*?[^\s*])\*(?![*\w])/g, '$1<em>$2</em>')
++ .replace(/(^|[^\w])_(?=[^\s_])([^_\n]*?[^\s_])_(?!\w)/g, '$1<em>$2</em>');
++ return pairUp(s, /~~(?=\S)/, /\S~~/, (_m, t) => `<del>${t}</del>`);
++}
++
++function isBlockStart(line) {
++ return FENCE.test(line) || HEADING.test(line) || QUOTE.test(line) || HR.test(line) || UL.test(line) || OL.test(line);
++}
++
++function blocks(lines, depth) {
++ const out = [];
++ let i = 0;
++ while (i < lines.length) {
++ const line = lines[i];
++ if (!line.trim()) { i++; continue; }
++
++ let m = line.trimEnd().match(FENCE_RE);
++ if (m) {
++ const fence = m[1], lang = m[2];
++ const closing = new RegExp(`^ {0,3}\\${fence[0]}{${fence.length},}\\s*$`);
++ const body = [];
++ i++;
++ while (i < lines.length && !closing.test(lines[i])) body.push(lines[i++]);
++ i++; // closing fence (or end of input)
++ const code = body.join('\n');
++ const hl = lang ? highlight(code, lang) : { html: esc(code), language: 'plaintext' };
++ out.push(`<pre class="md-code"><code class="hljs language-${esc(hl.language)}">${hl.html}</code></pre>`);
++ continue;
++ }
++ if (HR.test(line)) { out.push('<hr>'); i++; continue; }
++ m = headingMatch(line);
++ if (m) { const level = Math.min(m[1].length + 2, 6); out.push(`<h${level}>${inline(m[2])}</h${level}>`); i++; continue; }
++ if (QUOTE.test(line)) {
++ const inner = [];
++ while (i < lines.length && lines[i].trim() && QUOTE.test(lines[i])) inner.push(lines[i++].match(QUOTE)[1]);
++ out.push(depth < MAX_QUOTE_DEPTH ? `<blockquote>${blocks(inner, depth + 1)}</blockquote>` : `<blockquote><p>${inline(inner.join('\n'))}</p></blockquote>`);
++ continue;
++ }
++ if (UL.test(line) || OL.test(line)) {
++ const ordered = !UL.test(line);
++ const re = ordered ? OL : UL;
++ const start = ordered ? parseInt(line.match(OL)[1], 10) : 1;
++ const items = [];
++ while (i < lines.length && lines[i].trim()) {
++ const mm = lines[i].match(re);
++ if (mm) items.push(ordered ? mm[2] : mm[1]);
++ else if (/^\s+\S/.test(lines[i]) && items.length) items[items.length - 1] += `\n${lines[i].trim()}`;
++ else break;
++ i++;
++ }
++ const tag = ordered ? 'ol' : 'ul';
++ out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ''}>${items.map((it) => `<li>${inline(it)}</li>`).join('')}</${tag}>`);
++ continue;
++ }
++ const para = [];
++ while (i < lines.length && lines[i].trim() && (!para.length || !isBlockStart(lines[i]))) para.push(lines[i++]);
++ out.push(`<p>${inline(para.join('\n'))}</p>`);
++ }
++ return out.join('\n');
++}
++
++/** Markdown source → safe HTML (see the header for what is supported). */
++function renderMarkdown(source) {
++ const text = String(source == null ? '' : source).slice(0, MAX_SOURCE).replace(/\u0000/g, '').replace(/\r\n?/g, '\n');
++ return blocks(text.split('\n'), 0);
++}
++
++/** Markdown source → plain text (descriptions, JSON-LD, feeds). The caller escapes it. */
++function markdownToText(source, max = 300) {
++ const t = String(source == null ? '' : source).replace(/\u0000/g, '')
++ .replace(/^ {0,3}(`{3,}|~{3,}).*$/gm, '')
++ .replace(/\[([^[\]\n]*)\]\([^)\s]*\)/g, '$1')
++ .replace(/^ {0,3}(#{1,6}|>|[-*+]|\d{1,9}[.)])\s+/gm, '')
++ .replace(/(\*\*|__|~~|`)/g, '')
++ .replace(/\s+/g, ' ')
++ .trim();
++ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
++}
++
++module.exports = { renderMarkdown, markdownToText };
ddiff --git a/server/repos.js b/server/repos.js
nnew file mode 100644
iindex 0000000..43b6abe
---- /dev/null
++++ b/server/repos.js
@@@ -0,0 +1,359 @@
++'use strict';
++
++/**
++ * Repositories as people meet them: the row, its counts, who may see and change it, the activity around it, and
++ * the handful of queries the home and explore pages need. Git objects stay in server/git/store.js; this module
++ * is the database side plus the decisions that are ours (visibility, ownership, official repositories).
++ */
++const { validName, validRepo } = require('./git/refs');
++const { starterFiles } = require('./starters');
++const { seal, open } = require('./crypto');
++
++const PUBLIC_WHERE = "r.visibility = 'public'";
++const SELECT_REPO = `SELECT r.*,
++ (SELECT count(*) FROM git_stars s WHERE s.repo_id = r.id)::int AS stars,
++ (SELECT count(*) FROM git_watches w WHERE w.repo_id = r.id)::int AS watchers,
++ (SELECT count(*) FROM git_repos f WHERE f.fork_of = r.id)::int AS forks
++ FROM git_repos r`;
++
++function createRepos({ store, config, git, github, insights, log = console }) {
++ const now = store.now;
++
++ function rowToRepo(row, { card = null } = {}) {
++ if (!row) return null;
++ const base = config.baseUrl.replace(/\/+$/, '');
++ const languages = typeof row.languages === 'string' ? JSON.parse(row.languages || '{}') : (row.languages || {});
++ return {
++ id: row.id,
++ owner: { username: row.owner_username, subject: row.owner_subject, display_name: null, avatar_url: null },
++ name: row.name,
++ full_name: `${row.owner_username}/${row.name}`,
++ description: row.description || '',
++ visibility: row.visibility,
++ default_branch: row.default_branch,
++ topics: typeof row.topics === 'string' ? JSON.parse(row.topics || '[]') : (row.topics || []),
++ stars: Number(row.stars) || 0,
++ watchers: Number(row.watchers) || 0,
++ forks: Number(row.forks) || 0,
++ language: row.language || null,
++ languages,
++ html_url: `${base}/${row.owner_username}/${row.name}`,
++ clone_url: `${base}/${row.owner_username}/${row.name}.git`,
++ mirror: row.mirror_url ? { url: row.mirror_url, state: row.mirror_state, last_synced_at: row.mirror_last_synced_at || null, error: row.mirror_error || null } : null,
++ github: card || null,
++ created_at: row.created_at,
++ updated_at: row.updated_at,
++ pushed_at: row.pushed_at || null,
++ };
++ }
++
++ async function byId(id) {
++ return await store.maybe(`${SELECT_REPO} WHERE r.id = ?`, [id]);
++ }
++
++ async function getRow(owner, name) {
++ if (!validName(owner) || !validName(name)) return null;
++ return await store.maybe(`${SELECT_REPO} WHERE r.owner_username = ? AND r.name = ?`, [owner, name]);
++ }
++
++ const canView = (row, viewer) => Boolean(row) && (row.visibility === 'public' || (viewer && viewer.subject && viewer.subject === row.owner_subject));
++ const isOwner = (row, viewer) => Boolean(row && viewer && viewer.subject && viewer.subject === row.owner_subject);
++
++ /** The API's repository, with the GitHub card when it has a twin (cached upstream). */
++ async function get(owner, name, { viewer = null, withCard = false } = {}) {
++ const row = await getRow(owner, name);
++ if (!row || !canView(row, viewer)) return null;
++ let card = null;
++ if (withCard && row.github_full_name) card = await github.card(row.github_full_name, { branch: row.default_branch }).catch(() => null);
++ return rowToRepo(row, { card });
++ }
++
++ async function refreshLanguages(row) {
++ try {
++ const languages = await git.languages(row.owner_username, row.name, row.default_branch);
++ const language = Object.keys(languages)[0] || null;
++ await store.execute('UPDATE git_repos SET languages = ?, language = ?, updated_at = now() WHERE id = ?', [JSON.stringify(languages), language, row.id]);
++ return languages;
++ } catch (err) { log.warn('[OpenVibe.Git] languages not read:', err.message); return {}; }
++ }
++
++ async function recordEvent(row, type, actor, detail = {}) {
++ try {
++ await store.execute(
++ 'INSERT INTO git_events (id, repo_id, type, actor_subject, actor_username, detail) VALUES (?, ?, ?, ?, ?, ?)',
++ [store.newId('evt'), row.id, type, actor && actor.subject, actor && actor.username, JSON.stringify(detail)],
++ );
++ } catch (err) { log.warn('[OpenVibe.Git] event not recorded:', err.message); }
++ }
++
++ async function audit(actor, action, row, detail = {}, address = null) {
++ try {
++ await store.execute(
++ 'INSERT INTO git_audit (actor_subject, action, repo_id, detail, address) VALUES (?, ?, ?, ?, ?)',
++ [actor && actor.subject, action, row ? row.id : null, JSON.stringify(detail), address],
++ );
++ } catch (err) { log.warn('[OpenVibe.Git] audit not recorded:', err.message); }
++ }
++
++ async function create({ actor, name, description = '', visibility = 'public', topics = [], starter = 'empty', mirrorUrl = null }, { address = null } = {}) {
++ const owner = String(actor.username || '').toLowerCase();
++ const repoName = String(name || '').toLowerCase();
++ if (!validName(owner)) throw Object.assign(new Error('A signed-in OpenVibe username is required.'), { status: 403 });
++ if (!validName(repoName)) throw Object.assign(new Error('A repository name is lowercase letters, digits, dots, dashes and underscores, and never ends in .git.'), { status: 422 });
++ if (description.length > 500) throw Object.assign(new Error('The description is at most 500 characters.'), { status: 422 });
++ if (!['public', 'private'].includes(visibility)) throw Object.assign(new Error('Visibility is public or private.'), { status: 422 });
++ if (await getRow(owner, repoName)) throw Object.assign(new Error('That repository already exists.'), { status: 409 });
++
++ let mirror = null;
++ if (mirrorUrl) {
++ mirror = github.parseGithubUrl(mirrorUrl);
++ if (!mirror) throw Object.assign(new Error('A mirror URL is a public https://github.com/<owner>/<name> URL.'), { status: 422 });
++ }
++ const official = config.officialOwners.includes(owner);
++ const id = store.newId('repo');
++ try {
++ await store.execute(
++ `INSERT INTO git_repos (id, owner_username, owner_subject, name, description, visibility, topics, official,
++ github_full_name, mirror_url, mirror_state, mirror_last_synced_at, created_by_subject)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
++ [id, owner, actor.subject || null, repoName, description, visibility, JSON.stringify([...new Set(topics)].slice(0, 20)), official,
++ mirror ? mirror.full_name : null, mirror ? mirror.clone_url : null, mirror ? 'syncing' : 'ready', null, actor.subject || null],
++ );
++ } catch (err) {
++ if (/duplicate key|unique/i.test(err.message)) throw Object.assign(new Error('That repository already exists.'), { status: 409 });
++ throw err;
++ }
++ const row = await byId(id);
++ try {
++ if (mirror) {
++ let meta = { description: '', default_branch: 'main', private: false };
++ try { meta = await github.meta(mirror.full_name); } catch { /* the clone still works without the card */ }
++ if (meta.private) throw Object.assign(new Error('That GitHub repository is private.'), { status: 422 });
++ await git.cloneMirror(owner, repoName, mirror.clone_url, { timeoutMs: config.mirrors.timeoutMs });
++ await store.execute(
++ `UPDATE git_repos SET description = ?, default_branch = ?, mirror_state = 'ready', mirror_last_synced_at = now(), pushed_at = now(), updated_at = now() WHERE id = ?`,
++ [description || meta.description || '', meta.default_branch || 'main', id],
++ );
++ } else {
++ const files = starterFiles(starter, { full: `${owner}/${repoName}` });
++ await git.initRepo(owner, repoName, { maxPushBytes: config.data.maxPushBytes });
++ await git.writeInitialCommit(owner, repoName, { files, message: `Start ${owner}/${repoName}` });
++ await store.execute('UPDATE git_repos SET pushed_at = now(), updated_at = now() WHERE id = ?', [id]);
++ }
++ } catch (err) {
++ await store.execute('DELETE FROM git_repos WHERE id = ?', [id]).catch(() => {});
++ await git.removeRepo(owner, repoName).catch(() => {});
++ throw Object.assign(new Error(mirror ? `The mirror could not be made: ${err.message}` : err.message), { status: 422 });
++ }
++ await refreshLanguages(await byId(id));
++ await recordEvent(row, 'create', actor, { name: repoName, mirror: Boolean(mirror) });
++ await audit(actor, mirror ? 'repo.mirror.create' : 'repo.create', row, { name: repoName }, address);
++ return rowToRepo(await byId(id));
++ }
++
++ async function fork({ source, actor, name }, { address = null } = {}) {
++ const targetName = String(name || source.name).toLowerCase();
++ if (!validName(targetName)) throw Object.assign(new Error('A repository name is lowercase letters, digits, dots, dashes and underscores, and never ends in .git.'), { status: 422 });
++ const owner = String(actor.username || '').toLowerCase();
++ if (!validName(owner)) throw Object.assign(new Error('A signed-in OpenVibe username is required.'), { status: 403 });
++ if (await getRow(owner, targetName)) throw Object.assign(new Error('That repository already exists.'), { status: 409 });
++ const id = store.newId('repo');
++ await store.execute(
++ `INSERT INTO git_repos (id, owner_username, owner_subject, name, description, visibility, topics, official, fork_of, created_by_subject)
++ VALUES (?, ?, ?, ?, ?, 'public', ?, false, ?, ?)`,
++ [id, owner, actor.subject || null, targetName, source.description || '', JSON.stringify(source.topics || []), source.id, actor.subject || null],
++ );
++ try {
++ await git.cloneLocal(source.owner_username, source.name, owner, targetName, { timeoutMs: config.mirrors.timeoutMs });
++ await store.execute('UPDATE git_repos SET pushed_at = now() WHERE id = ?', [id]);
++ } catch (err) {
++ await store.execute('DELETE FROM git_repos WHERE id = ?', [id]).catch(() => {});
++ throw Object.assign(new Error(`The fork could not be made: ${err.message}`), { status: 422 });
++ }
++ const row = await byId(id);
++ await refreshLanguages(row);
++ await recordEvent(row, 'fork', actor, { from: source.full_name || `${source.owner_username}/${source.name}` });
++ await audit(actor, 'repo.fork', row, { from: source.id }, address);
++ return rowToRepo(await byId(id));
++ }
++
++ async function list({ viewer = null, q = '', language = '', official = false, mirror = false, mine = false, starred = false, sort = 'trending', limit = 30, cursor = 0 } = {}) {
++ const where = [];
++ const args = [];
++ if (!(mine && viewer && viewer.subject)) {
++ where.push(`(r.visibility = 'public' OR r.owner_subject = ?)`);
++ args.push(viewer && viewer.subject ? viewer.subject : '');
++ }
++ if (q) { where.push('(r.name ILIKE ? OR r.description ILIKE ?)'); args.push(`%${q}%`, `%${q}%`); }
++ if (language) { where.push('r.language = ?'); args.push(language); }
++ if (official) where.push('r.official = true');
++ if (mirror) where.push('r.mirror_url IS NOT NULL');
++ if (mine) { where.push('r.owner_subject = ?'); args.push(viewer && viewer.subject ? viewer.subject : ' '); }
++ if (starred) { where.push('EXISTS (SELECT 1 FROM git_stars st WHERE st.repo_id = r.id AND st.subject = ?)'); args.push(viewer && viewer.subject ? viewer.subject : ' '); }
++ const order = {
++ trending: '(SELECT count(*) FROM git_stars s WHERE s.repo_id = r.id AND s.created_at >= now() - interval \'7 days\') * 5 + (SELECT coalesce(sum(v.views), 0) FROM git_views v WHERE v.repo_id = r.id AND v.day >= current_date - 7) DESC, r.pushed_at DESC NULLS LAST',
++ pushed: 'r.pushed_at DESC NULLS LAST, r.created_at DESC',
++ stars: '(SELECT count(*) FROM git_stars s WHERE s.repo_id = r.id) DESC, r.pushed_at DESC NULLS LAST',
++ new: 'r.created_at DESC',
++ }[sort] || null;
++ if (!order) throw Object.assign(new Error('sort is trending, pushed, stars or new'), { status: 422 });
++ const size = Math.max(1, Math.min(50, Number(limit) || 30));
++ const skip = Math.max(0, Number(cursor) || 0);
++ const rows = await store.many(
++ `${SELECT_REPO} ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY ${order} LIMIT ? OFFSET ?`,
++ [...args, size + 1, skip],
++ );
++ const more = rows.length > size;
++ return { repositories: rows.slice(0, size).map((r) => rowToRepo(r)), next_cursor: more ? String(skip + size) : null };
++ }
++
++ async function star(row, subject, on = true) {
++ if (!subject) throw Object.assign(new Error('Sign in to star a repository.'), { status: 401 });
++ if (on) {
++ const inserted = await store.execute('INSERT INTO git_stars (repo_id, subject) VALUES (?, ?) ON CONFLICT DO NOTHING', [row.id, subject]);
++ if (inserted) await recordEvent(row, 'star', { subject, username: null }, {});
++ } else {
++ await store.execute('DELETE FROM git_stars WHERE repo_id = ? AND subject = ?', [row.id, subject]);
++ }
++ const fresh = await byId(row.id);
++ return { starred: Boolean(await store.maybe('SELECT 1 AS one FROM git_stars WHERE repo_id = ? AND subject = ?', [row.id, subject])), stars: Number(fresh.stars) || 0 };
++ }
++
++ async function isStarred(row, subject) {
++ if (!subject) return false;
++ return Boolean(await store.maybe('SELECT 1 AS one FROM git_stars WHERE repo_id = ? AND subject = ?', [row.id, subject]));
++ }
++
++ async function isWatching(row, subject) {
++ if (!subject) return false;
++ return Boolean(await store.maybe('SELECT 1 AS one FROM git_watches WHERE repo_id = ? AND subject = ?', [row.id, subject]));
++ }
++
++ async function watch(row, subject) {
++ if (!subject) throw Object.assign(new Error('Sign in to watch a repository.'), { status: 401 });
++ const on = !(await isWatching(row, subject));
++ if (on) await store.execute('INSERT INTO git_watches (repo_id, subject) VALUES (?, ?) ON CONFLICT DO NOTHING', [row.id, subject]);
++ else await store.execute('DELETE FROM git_watches WHERE repo_id = ? AND subject = ?', [row.id, subject]);
++ return { watching: on, watchers: Number((await byId(row.id)).watchers) || 0 };
++ }
++
++ async function languagesFacet() {
++ const rows = await store.many("SELECT DISTINCT language FROM git_repos WHERE visibility = 'public' AND language IS NOT NULL ORDER BY language");
++ return rows.map((r) => r.language);
++ }
++
++ async function releases(row) {
++ const [tags, stored] = await Promise.all([
++ git.tags(row.owner_username, row.name),
++ store.many('SELECT * FROM git_releases WHERE repo_id = ? ORDER BY published_at DESC', [row.id]),
++ ]);
++ const byTag = new Map(stored.map((r) => [r.tag, r]));
++ return tags.map((t) => {
++ const rel = byTag.get(t.name);
++ return { tag: t.name, name: rel ? rel.name : t.name, body: rel ? rel.body : '', published_at: rel ? rel.published_at : t.date, sha: t.sha, has_notes: Boolean(rel) };
++ });
++ }
++
++ async function publishRelease(row, { tag, name, body, actor }) {
++ if (!git.tags) throw new Error('unreachable');
++ const tags = await git.tags(row.owner_username, row.name);
++ if (!tags.some((t) => t.name === tag)) throw Object.assign(new Error('That tag does not exist.'), { status: 422 });
++ const id = store.newId('rel');
++ await store.execute(
++ `INSERT INTO git_releases (id, repo_id, tag, name, body, author_subject) VALUES (?, ?, ?, ?, ?, ?)
++ ON CONFLICT (repo_id, tag) DO UPDATE SET name = excluded.name, body = excluded.body`,
++ [id, row.id, tag, name || tag, body || '', actor.subject || null],
++ );
++ await recordEvent(row, 'release', actor, { tag, name: name || tag });
++ return await releases(row);
++ }
++
++ async function contributors(row) {
++ const list = await git.contributors(row.owner_username, row.name, row.default_branch);
++ return list.map((c) => ({ ...c, username: null, avatar_url: null }));
++ }
++
++ async function insightsFor(row, { days = 30 } = {}) {
++ return await insights.forRepo(row, {
++ days,
++ commitsPerWeek: () => git.commitsPerWeek(row.owner_username, row.name, row.default_branch, { weeks: 26 }),
++ });
++ }
++
++ async function setPushMirror(row, { push_url, push_token }, actor, { address = null } = {}) {
++ if (!isOwner(row, actor)) throw Object.assign(new Error('Only the owner can change a push mirror.'), { status: 403 });
++ if (!push_url) {
++ await store.execute("UPDATE git_repos SET push_mirror_url = NULL, push_mirror_token = NULL, push_mirror_state = 'off' WHERE id = ?", [row.id]);
++ } else {
++ const parsed = github.parseGithubUrl(push_url);
++ if (!parsed) throw Object.assign(new Error('A push mirror URL is a public https://github.com/<owner>/<name> URL.'), { status: 422 });
++ await store.execute(
++ 'UPDATE git_repos SET push_mirror_url = ?, push_mirror_token = ?, push_mirror_state = ? WHERE id = ?',
++ [parsed.clone_url, push_token ? seal(push_token, config.tokens.secretKey) : null, 'on', row.id],
++ );
++ }
++ await audit(actor, 'repo.push_mirror', row, { on: Boolean(push_url) }, address);
++ return rowToRepo(await byId(row.id));
++ }
++
++ function pushTokenFor(row) {
++ return open(row.push_mirror_token, config.tokens.secretKey);
++ }
++
++ async function activity(limit = 12) {
++ const rows = await store.many(
++ `SELECT e.*, r.owner_username, r.name FROM git_events e LEFT JOIN git_repos r ON r.id = e.repo_id
++ WHERE (r.visibility = 'public' OR r.id IS NULL) ORDER BY e.at DESC LIMIT ?`,
++ [Math.max(1, Math.min(50, Number(limit) || 12))],
++ );
++ return rows.map((e) => ({ id: e.id, type: e.type, at: e.at, owner: e.owner_username, name: e.name, actor: e.actor_username, detail: typeof e.detail === 'string' ? JSON.parse(e.detail || '{}') : e.detail }));
++ }
++
++ async function stats() {
++ const [repositories, pushes, people, clones] = await Promise.all([
++ store.value("SELECT count(*)::int FROM git_repos WHERE visibility = 'public'"),
++ store.value("SELECT count(*)::int FROM git_events WHERE type = 'push' AND at >= now() - interval '7 days'"),
++ store.value('SELECT count(DISTINCT owner_subject)::int FROM git_repos WHERE owner_subject IS NOT NULL'),
++ store.value("SELECT coalesce(sum(count), 0)::int FROM git_clones WHERE day >= current_date - 7"),
++ ]);
++ return { repositories: Number(repositories) || 0, pushes_this_week: Number(pushes) || 0, people: Number(people) || 0, clones_this_week: Number(clones) || 0 };
++ }
++
++ async function trending(limit = 6) {
++ const rows = await store.many(
++ `SELECT * FROM (
++ SELECT r.*,
++ (SELECT count(*) FROM git_stars s WHERE s.repo_id = r.id AND s.created_at >= now() - interval '7 days')::int AS stars_week,
++ (SELECT coalesce(sum(v.views), 0) FROM git_views v WHERE v.repo_id = r.id AND v.day >= current_date - 7)::int AS views_week,
++ (SELECT count(*) FROM git_stars s WHERE s.repo_id = r.id)::int AS stars,
++ (SELECT count(*) FROM git_watches w WHERE w.repo_id = r.id)::int AS watchers,
++ (SELECT count(*) FROM git_repos f WHERE f.fork_of = r.id)::int AS forks
++ FROM git_repos r WHERE r.visibility = 'public'
++ ) ranked ORDER BY stars_week * 5 + views_week DESC, pushed_at DESC NULLS LAST LIMIT ?`,
++ [limit],
++ );
++ return rows.map((r) => rowToRepo(r));
++ }
++
++ async function official() {
++ const rows = await store.many(`${SELECT_REPO} WHERE r.official = true AND r.visibility = 'public' ORDER BY r.name`);
++ return rows.map((r) => ({ ...rowToRepo(r), github_full_name: r.github_full_name }));
++ }
++
++ async function profile(username, { viewer = null } = {}) {
++ if (!validName(username)) return null;
++ const rows = await store.many(`${SELECT_REPO} WHERE r.owner_username = ? AND (r.visibility = 'public' OR r.owner_subject = ?) ORDER BY r.pushed_at DESC NULLS LAST, r.name`, [username, viewer && viewer.subject ? viewer.subject : '']);
++ if (!rows.length) {
++ const user = await store.maybe('SELECT DISTINCT owner_username, owner_subject FROM git_repos WHERE owner_username = ?', [username]);
++ if (!user) return null;
++ }
++ return { username, owner_subject: rows[0] ? rows[0].owner_subject : null, repositories: rows.map((r) => rowToRepo(r)) };
++ }
++
++ return {
++ rowToRepo, byId, getRow, get, canView, isOwner, refreshLanguages, recordEvent, audit, create, fork, list, star, isStarred,
++ isWatching, watch, languagesFacet, releases, publishRelease, contributors, insightsFor, setPushMirror, pushTokenFor, activity, stats, trending, official, profile,
++ };
++}
++
++module.exports = { createRepos, SELECT_REPO, PUBLIC_WHERE };
ddiff --git a/server/starters.js b/server/starters.js
nnew file mode 100644
iindex 0000000..78851ac
---- /dev/null
++++ b/server/starters.js
@@@ -0,0 +1,45 @@
++'use strict';
++
++/** The three starters a new repository can begin with. No fake content: a README that says what it is. */
++const { validPath } = require('./git/refs');
++
++function readme(name, description) {
++ return `# ${name}\n\n${description || 'A repository on OpenVibe.Git.'}\n\nClone it with OpenVibe:\n\n\`\`\`sh\ngit clone https://git.openvibe.codes/${name}.git\n\`\`\`\n`;
++}
++
++const FILES = {
++ empty: ({ full }) => [{ path: 'README.md', content: readme(full) }],
++ static: ({ full }) => [
++ { path: 'README.md', content: `${readme(full)}\nOpen \`index.html\` in a browser, or publish the folder wherever you host sites.\n` },
++ {
++ path: 'index.html',
++ content: `<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>${full.split('/')[1]}</title>\n<link rel="stylesheet" href="style.css">\n</head>\n<body>\n<main>\n<h1>${full.split('/')[1]}</h1>\n<p>Made with OpenVibe.Git.</p>\n</main>\n</body>\n</html>\n`,
++ },
++ { path: 'style.css', content: ':root{color-scheme:dark light}body{margin:0;font:16px/1.55 system-ui,sans-serif;background:#0b0d12;color:#e6edf7}main{max-width:42rem;margin:12vh auto;padding:0 1.25rem}h1{font-size:clamp(2rem,6vw,3rem);margin:0 0 .5rem}\n' },
++ ],
++ app: ({ full }) => [
++ { path: 'README.md', content: `${readme(full)}\n## Run\n\n\`\`\`sh\nnpm install\nnpm start\n\`\`\`\n\nThe app signs in with OpenVibe (openvibe-sdk/sso) and wears the shared navbar and footer.\n` },
++ {
++ path: 'package.json',
++ content: `${JSON.stringify({
++ name: full.split('/')[1], version: '0.1.0', private: true, description: '', main: 'server/index.js',
++ scripts: { start: 'node server/index.js', dev: 'NODE_ENV=development node server/index.js' },
++ dependencies: { express: '^4.21.2', 'openvibe-sdk': 'https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.108.0', 'openvibe-shared': 'https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.163.0' },
++ engines: { node: '>=22.0.0' },
++ }, null, 2)}\n`,
++ },
++ {
++ path: 'server/index.js',
++ content: `'use strict';\nconst express = require('express');\nconst { createSsoClient } = require('openvibe-sdk/sso');\n\nconst app = express();\nconst sso = createSsoClient({\n site: ${JSON.stringify(full.split('/')[1])}, baseUrl: process.env.BASE_URL || 'http://localhost:3000',\n clientId: process.env.OV_OAUTH_CLIENT_ID, clientSecret: process.env.OV_OAUTH_CLIENT_SECRET,\n networkUrl: 'https://openvibe.network', secureCookies: /^https:/.test(process.env.BASE_URL || ''),\n});\napp.use('/auth', sso.router(express));\napp.use(sso.optionalAuth());\napp.get('/', (req, res) => res.type('html').send('<h1>Hello</h1>'));\napp.listen(process.env.PORT || 3000);\n`,
++ },
++ { path: '.gitignore', content: 'node_modules/\ndata/\n.env\n' },
++ ],
++};
++
++/** files for a starter; `full` is owner/name. */
++function starterFiles(kind, { full }) {
++ const make = FILES[kind] || FILES.empty;
++ return make({ full }).filter((f) => validPath(f.path));
++}
++
++module.exports = { starterFiles, FILES };
ddiff --git a/server/views/index.js b/server/views/index.js
nnew file mode 100644
iindex 0000000..30ad50e
---- /dev/null
++++ b/server/views/index.js
@@@ -0,0 +1,441 @@
++'use strict';
++
++/**
++ * The pages. Server-rendered, phone first, complete without JavaScript: forms are plain POSTs, the Get it
++ * sheet is a <details>, the split diff is a second panel toggled by git.js. Every section has its own class
++ * (git-home, git-repo, git-tree, git-file, git-commit, git-insights, …) so the look can be restyled without
++ * touching the markup's meaning.
++ */
++const kit = require('openvibe-shared/kit');
++const { esc, bytes, ago, date, initials, plural, spark, languageBar } = require('../render/html');
++const { highlight, resolveLanguage } = require('../render/highlight');
++const { renderMarkdown } = require('../render/markdown');
++
++const icon = (name) => kit.icon(name);
++const qs = (o) => {
++ const p = new URLSearchParams();
++ for (const [k, v] of Object.entries(o)) if (v != null && v !== '') p.set(k, v);
++ const s = p.toString();
++ return s ? `?${s}` : '';
++};
++const fullOf = (repo) => repo.full_name || `${repo.owner_username}/${repo.name}`;
++
++function stars(n) { return Number(n) || 0; }
++
++// ── Home ─────────────────────────────────────────────────────
++function home({ viewer, stats, trending = [], activity = [], official = [], baseUrl }) {
++ const signedIn = viewer && viewer.kind === 'user';
++ const stat = (v, label) => `<li><b>${Number(v).toLocaleString('en-GB')}</b><span>${esc(label)}</span></li>`;
++ return `<div class="git-home">
++<section class="git-hero">
++ <div class="git-hero-copy">
++ <h1>Git for OpenVibe</h1>
++ <p class="git-lede">Host code with your OpenVibe account, browse it, mirror GitHub, and clone it to your computer with Link. Every push is git's own; every page is ours.</p>
++ <div class="git-actions">
++ <a class="ovk-btn ovk-btn--primary" href="${signedIn ? '/new' : '/auth/login?next=%2Fnew'}">${icon('plus')} New repository</a>
++ <a class="ovk-btn ovk-btn--ghost" href="/explore">${icon('search')} Explore</a>
++ </div>
++ </div>
++ <div class="git-hero-motif" role="img" aria-label="A repository, its branch, and a clone to a computer">
++ <div class="git-m-repo"><span class="git-m-dot"></span><b>openvibers/contracts</b><small>mirror of github.com</small><span class="git-m-bar"><i style="width:48%"></i><i style="width:27%"></i><i style="width:25%"></i></span></div>
++ <div class="git-m-line"><span></span><em>git clone</em></div>
++ <div class="git-m-pc"><span class="git-m-dot"></span><b>your computer</b><small>clone · commit · push</small><span class="git-m-ok">${icon('check')} OpenVibe sign-in</span></div>
++ </div>
++</section>
++<ul class="git-stats" aria-label="OpenVibe Git in numbers">
++ ${stat(stats.repositories, stats.repositories === 1 ? 'repository' : 'repositories')}
++ ${stat(stats.pushes_this_week, 'pushes this week')}
++ ${stat(stats.people, stats.people === 1 ? 'person' : 'people')}
++ ${stat(stats.clones_this_week, 'clones this week')}
++</ul>
++
++<section class="git-section" aria-labelledby="git-trending-h">
++ <div class="git-section-head"><h2 id="git-trending-h">Trending this week</h2><a href="/explore?sort=trending">All repositories</a></div>
++ ${trending.length ? `<ul class="git-cards">${trending.map(card).join('')}</ul>` : '<p class="git-empty">No repositories yet. <a href="/new">Make the first one</a>.</p>'}
++</section>
++
++<div class="git-split">
++<section class="git-section" aria-labelledby="git-activity-h">
++ <div class="git-section-head"><h2 id="git-activity-h">Recent activity</h2></div>
++ ${activity.length ? `<ul class="git-activity">${activity.map(activityRow).join('')}</ul>` : '<p class="git-empty">Nothing has happened yet.</p>'}
++</section>
++<section class="git-section" aria-labelledby="git-official-h">
++ <div class="git-section-head"><h2 id="git-official-h">Official OpenVibe repositories</h2></div>
++ ${official.length ? `<ul class="git-official">${official.map((r) => `<li><a class="git-official-main" href="/${esc(r.full_name)}"><span class="git-avatar git-avatar--org">${icon('code')}</span><span><b>${esc(r.name)}</b><small>${esc(r.description || 'The OpenVibe projects.')}</small></span></a>${r.github_full_name ? `<a class="git-official-gh" href="https://github.com/${esc(r.github_full_name)}" rel="noopener">${icon('open')} GitHub</a>` : ''}</li>`).join('')}</ul>` : '<p class="git-empty">The official repositories are on their way.</p>'}
++</section>
++</div>
++
++<section class="git-cta">
++ <h2>Your code, on OpenVibe</h2>
++ <p>Make a repository in one screen: empty, an OpenVibe app with the shared navbar, a static site, or imported from a public GitHub URL. Clone it over HTTPS in one tap, or send it to your computer with Link.</p>
++ <a class="ovk-btn ovk-btn--primary" href="${signedIn ? '/new' : '/auth/login?next=%2Fnew'}">${icon('plus')} New repository</a>
++</section>
++</div>`;
++}
++
++function card(r) {
++ return `<li class="git-card"><a href="/${esc(r.full_name)}">
++<span class="git-card-top"><span class="git-avatar">${icon('code')}</span><span class="git-card-name"><b>${esc(r.full_name)}</b><small>${esc(r.description || 'No description yet.')}</small></span></span>
++${languageBar(r.languages)}
++<span class="git-card-meta"><span>${icon('star')} ${stars(r.stars)}</span><span>${icon('clock')} ${esc(ago(r.pushed_at || r.updated_at))}</span>${r.mirror ? '<span class="git-chip">mirror</span>' : ''}${r.github ? '<span class="git-chip git-chip--gh">GitHub</span>' : ''}</span>
++</a></li>`;
++}
++
++function activityRow(e) {
++ const target = e.owner && e.name ? `<a href="/${esc(e.owner)}/${esc(e.name)}">${esc(e.owner)}/${esc(e.name)}</a>` : 'a repository';
++ const words = {
++ push: 'pushed to', create: 'made', release: 'published a release on', star: 'starred', fork: 'forked', mirror: 'synced',
++ }[e.type] || 'updated';
++ const who = e.actor ? `<b>@${esc(e.actor)}</b> ` : '';
++ return `<li><span class="git-avatar git-avatar--event">${icon(e.type === 'release' ? 'tag' : e.type === 'star' ? 'star' : 'code')}</span><span>${who}${esc(words)} ${target}<small>${esc(ago(e.at))}</small></span></li>`;
++}
++
++// ── Explore ──────────────────────────────────────────────────
++function explore({ viewer, repositories = [], query = '', filters = {}, next = null, languages = [] }) {
++ const chip = (key, label, href, on) => `<a class="git-filter${on ? ' is-on' : ''}" href="${esc(href)}">${esc(label)}</a>`;
++ const base = { q: query, sort: filters.sort || 'trending', language: filters.language || '' };
++ const keep = (extra) => `/explore${qs({ ...base, ...extra, official: undefined, mirror: undefined, mine: undefined, starred: undefined, ...(extra.official ? { official: 1 } : {}), ...(extra.mirror ? { mirror: 1 } : {}), ...(extra.mine ? { mine: 1 } : {}), ...(extra.starred ? { starred: 1 } : {}) })}`;
++ return `<div class="git-explore">
++<header class="git-page-head"><h1>Explore</h1><p>Every public repository on OpenVibe Git. Official projects, mirrors of GitHub, and what people are making.</p></header>
++<form class="git-search" method="get" action="/explore">
++ <label class="git-search-box">${icon('search')}<input type="search" name="q" value="${esc(query)}" placeholder="Search repositories and descriptions" aria-label="Search repositories"></label>
++ <select name="language" aria-label="Language"><option value="">Every language</option>${languages.map((l) => `<option value="${esc(l)}"${filters.language === l ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select>
++ <select name="sort" aria-label="Sort"><option value="trending"${filters.sort === 'trending' ? ' selected' : ''}>Trending</option><option value="pushed"${filters.sort === 'pushed' ? ' selected' : ''}>Recently pushed</option><option value="stars"${filters.sort === 'stars' ? ' selected' : ''}>Most starred</option><option value="new"${filters.sort === 'new' ? ' selected' : ''}>Newest</option></select>
++ <button class="ovk-btn ovk-btn--primary" type="submit">Search</button>
++</form>
++<nav class="git-filters" aria-label="Filters">
++ ${chip('all', 'All', keep({}), !filters.official && !filters.mirror && !filters.mine && !filters.starred)}
++ ${chip('official', 'Official', keep({ official: 1 }), filters.official)}
++ ${chip('mirror', 'Mirrors', keep({ mirror: 1 }), filters.mirror)}
++ ${chip('mine', 'Mine', keep({ mine: 1 }), filters.mine)}
++ ${chip('starred', 'Starred', keep({ starred: 1 }), filters.starred)}
++</nav>
++${repositories.length ? `<ul class="git-cards git-cards--wide">${repositories.map(card).join('')}</ul>` : `<p class="git-empty">Nothing matches. ${query ? `No repository matches “${esc(query)}”.` : 'Try another filter.'} <a href="/new">Make one</a>.</p>`}
++${next ? `<a class="ovk-btn ovk-btn--ghost git-more" href="/explore${qs({ ...base, ...(filters.official ? { official: 1 } : {}), ...(filters.mirror ? { mirror: 1 } : {}), ...(filters.mine ? { mine: 1 } : {}), ...(filters.starred ? { starred: 1 } : {}), cursor: next })}">More</a>` : ''}
++</div>`;
++}
++
++// ── Repository ───────────────────────────────────────────────
++function repoTabs(row, active, ref) {
++ const base = `/${row.owner_username}/${row.name}`;
++ const tabs = [
++ ['code', 'Code', base],
++ ['commits', 'Commits', `${base}/commits`],
++ ['branches', 'Branches', `${base}/branches`],
++ ['tags', 'Tags', `${base}/tags`],
++ ['releases', 'Releases', `${base}/releases`],
++ ['contributors', 'Contributors', `${base}/contributors`],
++ ['insights', 'Insights', `${base}/insights`],
++ ];
++ return `<nav class="git-tabs" aria-label="Repository">${tabs.map(([key, label, href]) => `<a class="git-tab${active === key ? ' is-on' : ''}" href="${esc(href)}${key === 'commits' && ref ? qs({ ref }) : ''}"${active === key ? ' aria-current="page"' : ''}>${esc(label)}</a>`).join('')}</nav>`;
++}
++
++function repoHead({ row, viewer, starred, watching, forkOf }) {
++ const signedIn = viewer && viewer.kind === 'user';
++ const base = `/${row.owner_username}/${row.name}`;
++ const starAction = signedIn
++ ? `<form method="post" action="${base}/star" class="git-inline"><button class="ovk-btn ovk-btn--ghost${starred ? ' is-on' : ''}" type="submit" aria-pressed="${starred ? 'true' : 'false'}">${icon('star')} Star <b>${stars(row.stars)}</b></button></form>`
++ : `<a class="ovk-btn ovk-btn--ghost" href="/auth/login?next=${encodeURIComponent(base)}">${icon('star')} Star <b>${stars(row.stars)}</b></a>`;
++ const watchAction = signedIn
++ ? `<form method="post" action="${base}/watch" class="git-inline"><button class="ovk-btn ovk-btn--ghost${watching ? ' is-on' : ''}" type="submit" aria-pressed="${watching ? 'true' : 'false'}">${icon('bell')} Watch <b>${row.watchers}</b></button></form>`
++ : `<span class="ovk-btn ovk-btn--ghost is-off">${icon('bell')} Watch <b>${row.watchers}</b></span>`;
++ return `<header class="git-repo-head">
++ <p class="git-crumb"><a href="/">Repositories</a> ${icon('chevron')} <a href="/${esc(row.owner_username)}">${esc(row.owner_username)}</a> ${icon('chevron')} <b>${esc(row.name)}</b>${row.visibility === 'private' ? ' <span class="git-chip git-chip--private">private</span>' : ''}${row.official ? ' <span class="git-chip">official</span>' : ''}</p>
++ <div class="git-repo-title">
++ <div><h1>${esc(row.owner_username)}<span>/</span>${esc(row.name)}</h1>
++ <p class="git-desc">${esc(row.description || 'No description yet.')}</p>
++ ${row.topics && row.topics.length ? `<ul class="git-topics">${row.topics.map((t) => `<li><a href="/explore?q=${encodeURIComponent(t)}">${esc(t)}</a></li>`).join('')}</ul>` : ''}
++ ${forkOf ? `<p class="git-forked">Forked from <a href="/${esc(forkOf)}">${esc(forkOf)}</a>.</p>` : ''}
++ </div>
++ <div class="git-repo-actions">${starAction}${watchAction}${signedIn ? `<form method="post" action="${base}/fork" class="git-inline"><button class="ovk-btn ovk-btn--ghost" type="submit">${icon('forward')} Fork <b>${row.forks}</b></button></form>` : `<a class="ovk-btn ovk-btn--ghost" href="/auth/login?next=${encodeURIComponent(base)}">${icon('forward')} Fork <b>${row.forks}</b></a>`}</div>
++ </div>
++ ${getIt(row)}
++</header>`;
++}
++
++function getIt(row) {
++ const clone = `${row.owner_username}/${row.name}.git`;
++ const full = `${row.owner_username}/${row.name}`;
++ return `<details class="git-get" id="get">
++ <summary class="ovk-btn ovk-btn--primary">${icon('download')} Get it <span class="git-get-caret">${icon('down')}</span></summary>
++ <div class="git-sheet">
++ <h2>Get this repository</h2>
++ <div class="git-get-choice">
++ <b>Clone over HTTPS</b>
++ <p>A Git token is made for this copy and shown once. It can read and write the repositories you own, and expires in an hour.</p>
++ <button class="ovk-btn ovk-btn--primary" type="button" data-git-token data-repo="${esc(full)}">${icon('copy')} Copy a clone command</button>
++ <pre class="git-cmd" data-git-command>git clone https://git.openvibe.codes/${esc(clone)}</pre>
++ </div>
++ <div class="git-get-choice">
++ <b>Clone to my computer</b>
++ <p>OpenVibe Link opens on your computer and asks you to approve the clone. The folder is yours; pushes go to this repository.</p>
++ <a class="ovk-btn" href="openvibe-link://clone?repo=${encodeURIComponent(full)}">${icon('computer')} Open in Link</a>
++ </div>
++ <div class="git-get-choice">
++ <b>Open in Codes</b>
++ <p>A Codes session clones from our disk: no network, no GitHub quota. When this repository has a GitHub twin, Codes pushes there too.</p>
++ <a class="ovk-btn" href="https://openvibe.codes/new?repo=${encodeURIComponent(full)}">${icon('terminal')} Open in Codes</a>
++ </div>
++ </div>
++</details>`;
++}
++
++function githubCard(card, row) {
++ if (!card) return '';
++ const ci = { success: ['passing', 'is-ok'], failure: ['failing', 'is-bad'], pending: ['running', 'is-warn'], unknown: ['unknown', ''] }[card.ci_state] || ['unknown', ''];
++ return `<aside class="git-gh-card" aria-labelledby="git-gh-h">
++ <p class="git-gh-title" id="git-gh-h">${icon('globe')} On GitHub <a href="${esc(card.html_url)}" rel="noopener">${esc(card.full_name)} ${icon('open')}</a></p>
++ <ul><li><b>${card.stars.toLocaleString('en-GB')}</b><span>stars</span></li><li><b>${card.open_issues}</b><span>open issues</span></li><li><b>${card.open_pull_requests}</b><span>open pull requests</span></li><li><b class="git-ci ${ci[1]}">${ci[0]}</b><span>last CI</span></li></ul>
++ <p class="git-gh-cache">Read ${esc(ago(card.fetched_at))}, kept for ten minutes.</p>
++ ${row.mirror ? `<p class="git-gh-mirror">Mirror synced ${esc(ago(row.mirror.last_synced_at))}.</p>` : ''}
++</aside>`;
++}
++
++function treeTable(row, ref, dirPath, entries, { crumbs = [] } = {}) {
++ const base = `/${row.owner_username}/${row.name}`;
++ const crumb = `<nav class="git-path" aria-label="Path">${crumbs.map((c, i) => `${i ? icon('chevron') : ''}<a href="${base}/tree${qs({ ref, path: c.path })}">${esc(c.name)}</a>`).join('')}</nav>`;
++ return `<div class="git-tree-head">${crumb}<span class="git-ref-chip">${icon('code')} ${esc(ref)}</span></div>
++<div class="table-wrap"><table class="git-tree"><caption class="sr-only">Files at ${esc(ref)}</caption><tbody>
++${dirPath && crumbs.length > 1 ? `<tr><td class="git-entry"><a href="${base}/tree${qs({ ref, path: crumbs[crumbs.length - 2].path })}">${icon('back')} ..</a></td><td></td><td></td></tr>` : ''}
++${entries.map((e) => {
++ const href = e.type === 'tree' ? `${base}/tree${qs({ ref, path: e.path })}` : `${base}/blob${qs({ ref, path: e.path })}`;
++ const last = e.last_commit;
++ return `<tr><td class="git-entry"><a href="${esc(href)}"><span class="git-entry-icon">${icon(e.type === 'tree' ? 'folder' : e.type === 'commit' ? 'link' : 'file')}</span>${esc(e.name)}</a></td><td class="git-entry-msg">${last ? `<a href="${base}/commit/${esc(last.sha)}">${esc(last.message)}</a>` : '<span>—</span>'}</td><td class="git-entry-when">${last ? esc(ago(last.date)) : ''}</td><td class="git-entry-size">${e.size == null ? '' : esc(bytes(e.size))}</td></tr>`;
++ }).join('')}
++</tbody></table></div>`;
++}
++
++function readmeBlock(row, ref, readme) {
++ if (!readme) return '';
++ return `<section class="git-readme" aria-labelledby="git-readme-h"><div class="git-readme-head"><h2 id="git-readme-h">${icon('book')} ${esc(readme.path)}</h2><a href="/${esc(row.owner_username)}/${esc(row.name)}/blob${qs({ ref, path: readme.path })}">View file</a></div><div class="git-md">${readme.html}</div></section>`;
++}
++
++function treePage({ row, viewer, starred, watching, forkOf, tree, card, languages }) {
++ const crumbs = [{ name: row.name, path: '' }, ...String(tree.path || '').split('/').filter(Boolean).map((seg, i, all) => ({ name: seg, path: all.slice(0, i + 1).join('/') }))];
++ return `<div class="git-repo">
++${repoHead({ row, viewer, starred, watching, forkOf })}
++<div class="git-repo-grid">
++ <div class="git-repo-main">${repoTabs(row, 'code')}${treeTable(row, row.default_branch, tree.path || '', tree.entries, { crumbs })}</div>
++ <aside class="git-repo-side">${githubCard(card, row)}<div class="git-about"><h2>About</h2><p>${esc(row.description || 'No description yet.')}</p>${languageBar(languages || {})}</div></aside>
++</div></div>`;
++}
++
++function repoPage({ row, viewer, starred, watching, forkOf, tree, readme, card, languages, isOwner = false }) {
++ const base = `/${row.owner_username}/${row.name}`;
++ const crumbs = [{ name: row.name, path: '' }, ...String(tree.path || '').split('/').filter(Boolean).map((seg, i, all) => ({ name: seg, path: all.slice(0, i + 1).join('/') }))];
++ return `<div class="git-repo">
++${repoHead({ row, viewer, starred, watching, forkOf })}
++<div class="git-repo-grid">
++ <div class="git-repo-main">
++ ${repoTabs(row, 'code')}
++ ${treeTable(row, row.default_branch, tree.path || '', tree.entries, { crumbs })}
++ ${readmeBlock(row, row.default_branch, readme)}
++ </div>
++ <aside class="git-repo-side">
++ ${githubCard(card, row)}
++ <div class="git-about">
++ <h2>About</h2>
++ <p>${esc(row.description || 'No description yet.')}</p>
++ ${languageBar(languages || {})}
++ <ul class="git-about-list">
++ <li>${icon('star')} <b>${stars(row.stars)}</b> stars</li>
++ <li>${icon('eye')} <b>${row.watchers}</b> watching</li>
++ <li>${icon('forward')} <b>${row.forks}</b> forks</li>
++ <li>${icon('clock')} pushed ${esc(ago(row.pushed_at))}</li>
++ </ul>
++ <a class="git-about-link" href="${base}/insights">${icon('chart')} Insights</a>
++ </div>
++ ${isOwner ? `<div class="git-about git-owner-tools"><h2>Mirror</h2>
++ ${row.mirror_url ? `<p class="git-gh-cache">From GitHub, synced ${esc(ago(row.mirror_last_synced_at))}.</p><form method="post" action="${base}/sync" class="git-inline"><button class="ovk-btn ovk-btn--ghost ovk-btn--small" type="submit">${icon('retry')} Sync now</button></form>` : '<p class="git-gh-cache">Push every push back to GitHub. Use a token for the twin repository.</p>'}
++ <details class="git-fold"><summary class="ovk-btn ovk-btn--ghost ovk-btn--small">${icon('upload')} Push mirror</summary>
++ <form method="post" action="${base}/push-mirror" class="git-form">
++ <label>GitHub URL <input name="push_url" value="${esc(row.push_mirror_url || '')}" placeholder="https://github.com/you/${esc(row.name)}" inputmode="url"></label>
++ <label>Token <input name="push_token" type="password" placeholder="${row.push_mirror_token ? 'a token is stored' : 'a token with repo scope'}" autocomplete="off"></label>
++ <button class="ovk-btn ovk-btn--primary ovk-btn--small" type="submit">Save</button>
++ ${row.push_mirror_url ? '<p class="git-gh-cache">Save an empty URL to turn it off.</p>' : ''}
++ </form>
++ </details>
++ </div>` : ''}
++ </aside>
++</div>
++</div>`;
++}
++
++// ── File, commits, commit ────────────────────────────────────
++function filePage({ row, ref, path: filePath, blob, crumbs = [], plain = false }) {
++ const base = `/${row.owner_username}/${row.name}`;
++ const lang = resolveLanguage(filePath.split('.').pop());
++ const raw = `${base}/raw${qs({ ref, path: filePath })}`;
++ const hist = `${base}/commits${qs({ ref, path: filePath })}`;
++ let body;
++ if (blob.binary) {
++ body = `<p class="git-empty">A binary file (${esc(bytes(blob.size))}). <a href="${raw}">Download it</a>.</p>`;
++ } else if (blob.truncated) {
++ body = `<p class="git-notice">This file is ${esc(bytes(blob.size))}; the page shows its beginning. <a href="${raw}">Download the whole file</a>.</p>`;
++ } else if (!plain && /\.(md|markdown)$/i.test(filePath)) {
++ // A Markdown file reads as the document it is (as a README does on the repository page); its code is one tap away.
++ const here = `${base}/blob`;
++ body = `<section class="git-readme git-doc"><div class="git-readme-head"><nav class="git-view" aria-label="View"><a aria-current="page" href="${here}${qs({ ref, path: filePath })}">${icon('book')} Preview</a><a href="${here}${qs({ ref, path: filePath, plain: '1' })}">${icon('code')} Code</a></nav></div><div class="git-md">${renderMarkdown(blob.text || '')}</div></section>`;
++ } else {
++ const lines = String(blob.text || '').split('\n');
++ const toggle = /\.(md|markdown)$/i.test(filePath)
++ ? `<nav class="git-view git-view--bar" aria-label="View"><a href="${base}/blob${qs({ ref, path: filePath })}">${icon('book')} Preview</a><a aria-current="page" href="${base}/blob${qs({ ref, path: filePath, plain: '1' })}">${icon('code')} Code</a></nav>` : '';
++ body = `${toggle}<div class="git-code"><table class="git-code-table"><tbody>${lines.map((line, i) => {
++ const n = i + 1;
++ const h = highlight(line, lang);
++ return `<tr id="L${n}"><td class="git-ln"><a href="#L${n}" aria-label="Line ${n}">${n}</a></td><td class="git-code-line">${h.html || ' '}</td></tr>`;
++ }).join('')}</tbody></table></div>`;
++ }
++ return `<div class="git-file">
++<header class="git-page-head git-file-head">
++ <p class="git-crumb"><a href="${base}">${esc(row.full_name || `${row.owner_username}/${row.name}`)}</a> ${icon('chevron')} ${crumbs.map((c, i) => `${i ? icon('chevron') : ''}<a href="${base}/blob${qs({ ref, path: c.path })}">${esc(c.name)}</a>`).join('')}</p>
++ <h1>${esc(filePath.split('/').pop())}</h1>
++ <p class="git-file-meta"><span class="git-chip">${esc(ref)}</span> ${esc(blob.size != null ? bytes(blob.size) : '')} ${blob.text ? `· ${String(blob.text).split('\n').length} lines` : ''} · ${esc(lang)}</p>
++ <div class="git-actions"><a class="ovk-btn ovk-btn--ghost" href="${raw}">${icon('download')} Raw</a><a class="ovk-btn ovk-btn--ghost" href="${hist}">${icon('clock')} History</a><a class="ovk-btn ovk-btn--ghost" href="${base}">${icon('back')} Repository</a></div>
++</header>
++${body}
++</div>`;
++}
++
++function commitsPage({ row, ref, path: filePath, commits, next }) {
++ const base = `/${row.owner_username}/${row.name}`;
++ return `<div class="git-commits">
++<header class="git-page-head"><p class="git-crumb"><a href="${base}">${esc(row.owner_username)}/${esc(row.name)}</a></p><h1>Commits</h1><p class="git-file-meta"><span class="git-chip">${esc(ref)}</span>${filePath ? ` · ${esc(filePath)}` : ''}</p></header>
++${repoTabs(row, 'commits', ref)}
++${commits.length ? `<ul class="git-commit-list">${commits.map((c) => `<li><a href="${base}/commit/${esc(c.sha)}">
++<span class="git-avatar git-avatar--commit">${icon('code')}</span>
++<span class="git-commit-main"><b>${esc(c.subject || c.message || '(no message)')}</b><small>${esc(c.author.name)} · ${esc(ago(c.date))}</small></span>
++<span class="git-commit-sha"><code>${esc(c.short || c.sha.slice(0, 7))}</code></span>
++</a></li>`).join('')}</ul>` : '<p class="git-empty">No commits here yet.</p>'}
++${next ? `<a class="ovk-btn ovk-btn--ghost git-more" href="${base}/commits${qs({ ref, path: filePath, cursor: next })}">More commits</a>` : ''}
++</div>`;
++}
++
++function diffRows(diff) {
++ return String(diff || '').split('\n').map((line) => {
++ let kind = '';
++ if (line.startsWith('+++') || line.startsWith('---')) kind = 'is-meta';
++ else if (line.startsWith('@@')) kind = 'is-hunk';
++ else if (line.startsWith('+')) kind = 'is-add';
++ else if (line.startsWith('-')) kind = 'is-del';
++ else if (line.startsWith('diff --git') || line.startsWith('index ')) kind = 'is-meta';
++ return { line, kind };
++ });
++}
++
++/** Pair removed and added lines into two columns, contexts on both sides. */
++function splitRows(diff) {
++ const rows = [];
++ for (const item of diffRows(diff)) {
++ const l = item.line;
++ if (l.startsWith('+') && !l.startsWith('+++')) rows.push({ left: '', right: l, kind: 'is-add' });
++ else if (l.startsWith('-') && !l.startsWith('---')) rows.push({ left: l, right: '', kind: 'is-del' });
++ else rows.push({ left: l, right: l, kind: item.kind });
++ }
++ return rows;
++}
++
++function commitPage({ row, commit, diff = true }) {
++ const base = `/${row.owner_username}/${row.name}`;
++ const unified = diffRows(commit.diff).map((r) => `<div class="git-diff-line ${r.kind}"><span class="git-diff-sign">${r.line ? esc(r.line[0]) : ' '}</span><span>${esc(r.line)}</span></div>`).join('');
++ const split = diff && commit.diff ? `<div class="git-diff-split" hidden>${splitRows(commit.diff).map((r) => `<div class="git-diff-row"><div class="git-diff-cell git-diff-left ${r.kind}">${r.left ? esc(r.left) : ''}</div><div class="git-diff-cell git-diff-right ${r.kind}">${r.right ? esc(r.right) : ''}</div></div>`).join('')}</div>` : '';
++ return `<div class="git-commit">
++<header class="git-commit-head">
++ <p class="git-crumb"><a href="${base}">${esc(row.owner_username)}/${esc(row.name)}</a> ${icon('chevron')} <a href="${base}/commits">Commits</a></p>
++ <h1>${esc((commit.message || '').split('\n')[0])}</h1>
++ <p class="git-commit-meta"><span class="git-avatar git-avatar--sm">${esc(initials(commit.author.name))}</span> <b>${esc(commit.author.name)}</b> committed ${esc(ago(commit.date))} · <code>${esc(commit.sha.slice(0, 10))}</code>${commit.stats ? ` · ${plural(commit.stats.files, 'file')}, <span class="git-add">+${commit.stats.additions}</span> <span class="git-del">−${commit.stats.deletions}</span>` : ''}</p>
++ ${(commit.message || '').includes('\n') ? `<pre class="git-commit-message">${esc(commit.message.split('\n').slice(1).join('\n').trim())}</pre>` : ''}
++</header>
++${diff && commit.diff ? `<div class="git-diff" data-git-diff><div class="git-diff-head"><h2>Changes</h2><button class="ovk-btn ovk-btn--ghost ovk-btn--small" type="button" data-git-diff-toggle>Split view</button></div><div class="git-diff-unified"><pre>${unified}</pre></div>${split}</div>` : '<p class="git-empty">This commit has no diff to show.</p>'}
++</div>`;
++}
++
++// ── Branches, tags, releases, contributors, insights ─────────
++function branchesPage({ row, branches }) {
++ const base = `/${row.owner_username}/${row.name}`;
++ return `<div class="git-list-page"><header class="git-page-head"><p class="git-crumb"><a href="${base}">${esc(row.full_name || `${row.owner_username}/${row.name}`)}</a></p><h1>Branches</h1></header>${repoTabs(row, 'branches')}
++${branches.length ? `<ul class="git-refs">${branches.map((b) => `<li><a href="${base}/tree${qs({ ref: b.name })}"><span>${icon('code')} <b>${esc(b.name)}</b>${b.name === row.default_branch ? ' <span class="git-chip">default</span>' : ''}</span><small>${esc(b.subject)} · ${esc(ago(b.date))}</small><code>${esc(b.sha.slice(0, 7))}</code></a></li>`).join('')}</ul>` : '<p class="git-empty">No branches yet.</p>'}</div>`;
++}
++
++function tagsPage({ row, tags }) {
++ const base = `/${row.owner_username}/${row.name}`;
++ return `<div class="git-list-page"><header class="git-page-head"><p class="git-crumb"><a href="${base}">${esc(row.full_name || `${row.owner_username}/${row.name}`)}</a></p><h1>Tags</h1></header>${repoTabs(row, 'tags')}
++${tags.length ? `<ul class="git-refs">${tags.map((t) => `<li><a href="${base}/tree${qs({ ref: t.name })}"><span>${icon('tag')} <b>${esc(t.name)}</b></span><small>${esc(t.subject)} · ${esc(ago(t.date))}</small><code>${esc(t.sha.slice(0, 7))}</code></a></li>`).join('')}</ul>` : '<p class="git-empty">No tags yet.</p>'}</div>`;
++}
++
++function releasesPage({ row, releases, viewer }) {
++ const base = `/${row.owner_username}/${row.name}`;
++ const canPublish = viewer && viewer.kind === 'user';
++ return `<div class="git-list-page"><header class="git-page-head"><p class="git-crumb"><a href="${base}">${esc(row.full_name || `${row.owner_username}/${row.name}`)}</a></p><h1>Releases</h1></header>${repoTabs(row, 'releases')}
++${releases.length ? `<ul class="git-releases">${releases.map((r) => `<li><div class="git-release-head"><h2>${esc(r.name || r.tag)}</h2><span class="git-chip">${esc(r.tag)}</span><small>${esc(date(r.published_at))}</small></div>${r.body ? `<div class="git-md">${renderMarkdown(r.body)}</div>` : '<p class="git-empty">No notes yet.</p>'}${r.sha ? `<p class="git-release-sha"><a href="${base}/commit/${esc(r.sha)}"><code>${esc(r.sha.slice(0, 10))}</code></a></p>` : ''}</li>`).join('')}</ul>` : '<p class="git-empty">No releases yet. A tag becomes a release when its notes are written.</p>'}
++${canPublish ? `<details class="git-fold"><summary class="ovk-btn ovk-btn--ghost">${icon('plus')} Write release notes</summary><form method="post" action="${base}/releases" class="git-form"><label>Tag <input name="tag" required placeholder="v1.0.0"></label><label>Name <input name="name" placeholder="First release"></label><label>Notes <textarea name="body" rows="5" placeholder="What changed"></textarea></label><button class="ovk-btn ovk-btn--primary" type="submit">Publish</button></form></details>` : ''}
++</div>`;
++}
++
++function contributorsPage({ row, contributors }) {
++ const base = `/${row.owner_username}/${row.name}`;
++ return `<div class="git-list-page"><header class="git-page-head"><p class="git-crumb"><a href="${base}">${esc(row.full_name || `${row.owner_username}/${row.name}`)}</a></p><h1>Contributors</h1></header>${repoTabs(row, 'contributors')}
++${contributors.length ? `<ul class="git-people">${contributors.map((c) => `<li><span class="git-avatar">${esc(initials(c.name))}</span><span><b>${esc(c.name)}</b>${c.username ? ` <a href="/@${esc(c.username)}">@${esc(c.username)}</a>` : ''}<small>${esc(c.email)}</small></span><em>${plural(c.commits, 'commit')}</em></li>`).join('')}</ul>` : '<p class="git-empty">No contributors yet.</p>'}</div>`;
++}
++
++function insightsPage({ row, data }) {
++ const base = `/${row.owner_username}/${row.name}`;
++ const maxWeek = Math.max(1, ...data.commits_per_week.map((w) => w.commits));
++ const value = (n) => Number(n || 0).toLocaleString('en-GB');
++ return `<div class="git-insights">
++<header class="git-page-head"><p class="git-crumb"><a href="${base}">${esc(row.full_name || `${row.owner_username}/${row.name}`)}</a></p><h1>Insights</h1><p>Views, clones, stars and commits. Public for a public repository: no admin account needed, unlike GitHub's traffic.</p></header>
++${repoTabs(row, 'insights')}
++<ul class="git-insight-stats">
++ <li><b>${value(data.views)}</b><span>views · ${data.window_days} days</span></li>
++ <li><b>${value(data.unique_visitors)}</b><span>unique visitors</span></li>
++ <li><b>${value(data.clones)}</b><span>clones</span></li>
++ <li><b>${value(data.stars.total)}</b><span>stars · ${value(data.stars.in_window)} new</span></li>
++</ul>
++<section class="git-chart" aria-labelledby="git-views-h"><div class="git-chart-head"><h2 id="git-views-h">Views and unique visitors</h2><span>per day</span></div>${spark(data.daily, 'views', { height: 64, label: 'views per day' })}${spark(data.daily, 'unique_visitors', { height: 32, label: 'unique visitors per day' })}</section>
++<section class="git-chart" aria-labelledby="git-clones-h"><div class="git-chart-head"><h2 id="git-clones-h">Clones</h2><span>per day</span></div>${spark(data.daily, 'clones', { height: 48, label: 'clones per day' })}</section>
++<section class="git-chart" aria-labelledby="git-weekly-h"><div class="git-chart-head"><h2 id="git-weekly-h">Commits per week</h2><span>last 26 weeks</span></div><ul class="git-weeks">${data.commits_per_week.map((w) => `<li><i style="height:${Math.max(2, Math.round((w.commits / maxWeek) * 72))}px" title="${esc(`${w.week}: ${w.commits}`)}"></i><span>${esc(w.week.slice(5))}</span></li>`).join('')}</ul></section>
++</div>`;
++}
++
++// ── Profile, new, messages ───────────────────────────────────
++function profilePage({ profile }) {
++ return `<div class="git-profile"><header class="git-profile-head"><span class="git-avatar git-avatar--big">${esc(initials(profile.username))}</span><div><h1>@${esc(profile.username)}</h1><p>${plural(profile.repositories.length, 'public repository', 'public repositories')} on OpenVibe Git.</p></div></header>
++${profile.repositories.length ? `<ul class="git-cards git-cards--wide">${profile.repositories.map(card).join('')}</ul>` : '<p class="git-empty">No public repositories yet.</p>'}</div>`;
++}
++
++function newPage({ viewer, values = {}, error = null }) {
++ const v = (k, d = '') => esc(values[k] == null ? d : values[k]);
++ return `<div class="git-new">
++<header class="git-page-head"><h1>New repository</h1><p>One screen: a name, who can see it, and how it starts. No card, no second account.</p></header>
++${error ? `<p class="git-error" role="alert">${esc(error)}</p>` : ''}
++<form method="post" action="/new" class="git-form git-form--new">
++ <label class="git-field"><span>Owner</span><span class="git-owner">${esc(viewer && viewer.username ? viewer.username : 'you')} <i>/</i></span></label>
++ <label class="git-field"><span>Repository name</span><input name="name" required maxlength="100" pattern="[a-z0-9][a-z0-9._-]*" value="${v('name')}" placeholder="my-project" autocapitalize="none" autocomplete="off" spellcheck="false"><small>Lower-case letters, digits, dots, dashes and underscores. Never ends in .git.</small></label>
++ <label class="git-field"><span>Description</span><input name="description" maxlength="500" value="${v('description')}" placeholder="What it is, in a line."></label>
++ <fieldset class="git-field"><legend>Who can see it</legend><label class="git-radio"><input type="radio" name="visibility" value="public"${v('visibility', 'public') === 'public' ? ' checked' : ''}> <span><b>Public</b><small>Anyone can read and clone it. It appears in Explore.</small></span></label><label class="git-radio"><input type="radio" name="visibility" value="private"${v('visibility') === 'private' ? ' checked' : ''}> <span><b>Private</b><small>Only you. Clone over HTTPS with your token.</small></span></label></fieldset>
++ <fieldset class="git-field"><legend>How it starts</legend>
++ ${starterChoice('empty', 'Empty', 'A README that says what it is.', v('starter', 'empty'))}
++ ${starterChoice('app', 'OpenVibe app', 'An SDK starter with the shared navbar, footer and sign-in.', v('starter'))}
++ ${starterChoice('static', 'Static site', 'index.html and a stylesheet, ready to publish.', v('starter'))}
++ <label class="git-radio"><input type="radio" name="starter" value="mirror"${v('starter') === 'mirror' ? ' checked' : ''}> <span><b>Import from a URL</b><small>A public GitHub repository, mirrored here and kept in sync.</small></span></label>
++ </fieldset>
++ <label class="git-field"><span>GitHub URL (for Import)</span><input name="mirror_url" value="${v('mirror_url')}" placeholder="https://github.com/OpenVibers/OpenVibe.Contracts" inputmode="url" autocapitalize="none" spellcheck="false"><small>Public repositories only. The mirror syncs on GitHub's push webhook and every ten minutes.</small></label>
++ <button class="ovk-btn ovk-btn--primary" type="submit">${icon('plus')} Make the repository</button>
++</form>
++</div>`;
++}
++
++function starterChoice(value, title, text, current) {
++ return `<label class="git-radio"><input type="radio" name="starter" value="${value}"${current === value ? ' checked' : ''}> <span><b>${esc(title)}</b><small>${esc(text)}</small></span></label>`;
++}
++
++function message({ title, text, link = null, code = null }) {
++ return `<div class="git-message"><h1>${esc(title)}</h1><p>${esc(text)}</p>${code ? `<pre class="git-cmd">${esc(code)}</pre>` : ''}${link ? `<a class="ovk-btn ovk-btn--primary" href="${esc(link.href)}">${esc(link.label)}</a>` : ''}</div>`;
++}
++
++const notFound = () => message({ title: 'Not found', text: 'That repository, file or person is not here. It may be private, or the link may be old.', link: { href: '/', label: 'OpenVibe Git' } });
++
++module.exports = {
++ home, explore, repoPage, treePage, repoHead, repoTabs, treeTable, filePage, commitsPage, commitPage, branchesPage, tagsPage,
++ releasesPage, contributorsPage, insightsPage, profilePage, newPage, message, notFound, githubCard, card,
++};
ddiff --git a/test/api.test.js b/test/api.test.js
nnew file mode 100644
iindex 0000000..1ee8584
---- /dev/null
++++ b/test/api.test.js
@@@ -0,0 +1,100 @@
++'use strict';
++const assert = require('assert');
++const { boot } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ const sam = t.asUser('sam');
++ const repo = await t.ctx.repos.create({ actor: sam, name: 'api-demo', description: 'Everything the API says.', starter: 'static', visibility: 'public' });
++
++ const list = await t.get('/api/v1/repos?q=api-demo');
++ assert.equal(list.status, 200);
++ assert.equal(list.json().repositories[0].full_name, 'sam/api-demo');
++
++ const one = await t.get('/api/v1/repos/sam/api-demo');
++ assert.equal(one.status, 200);
++ const body = one.json();
++ assert.equal(body.name, 'api-demo');
++ assert.equal(body.visibility, 'public');
++ assert.equal(body.starred, false);
++ assert.equal(body.owner.username, 'sam');
++
++ const tree = await t.get('/api/v1/repos/sam/api-demo/tree?ref=main');
++ assert.equal(tree.status, 200);
++ assert.ok(tree.json().entries.some((e) => e.name === 'index.html'));
++
++ const blob = await t.get('/api/v1/repos/sam/api-demo/blob?ref=main&path=index.html');
++ assert.equal(blob.status, 200);
++ assert.match(blob.json().text, /Made with OpenVibe\.Git/);
++
++ const raw = await t.get('/api/v1/repos/sam/api-demo/raw?ref=main&path=README.md');
++ assert.equal(raw.status, 200);
++ assert.match(raw.text, /api-demo/);
++
++ const commits = await t.get('/api/v1/repos/sam/api-demo/commits');
++ assert.equal(commits.status, 200);
++ assert.ok(commits.json().commits.length >= 1);
++
++ const first = commits.json().commits[0];
++ const commit = await t.get(`/api/v1/repos/sam/api-demo/commit/${first.sha}?diff=1`);
++ assert.equal(commit.status, 200);
++ assert.equal(commit.json().repo, 'sam/api-demo');
++ assert.ok(commit.json().stats);
++
++ assert.equal((await t.get('/api/v1/repos/sam/api-demo/branches')).json().branches[0].name, 'main');
++ assert.equal((await t.get('/api/v1/repos/sam/api-demo/tags')).status, 200);
++ assert.equal((await t.get('/api/v1/repos/sam/api-demo/releases')).status, 200);
++ assert.ok((await t.get('/api/v1/repos/sam/api-demo/contributors')).json().contributors.length >= 1);
++ const insights = await t.get('/api/v1/repos/sam/api-demo/insights');
++ assert.equal(insights.status, 200);
++ assert.equal(insights.json().window_days, 30);
++
++ // Star with a session.
++ const starred = await t.get('/api/v1/repos/sam/api-demo/star', { as: sam, method: 'PUT' });
++ assert.equal(starred.status, 200);
++ assert.equal(starred.json().starred, true);
++ assert.equal(starred.json().stars, 1);
++ assert.equal((await t.get('/api/v1/repos/sam/api-demo', { as: sam })).json().starred, true);
++ const unstarred = await t.get('/api/v1/repos/sam/api-demo/star', { as: sam, method: 'DELETE' });
++ assert.equal(unstarred.json().stars, 0);
++
++ // A clone token is shown once and stored hashed.
++ const minted = await t.get('/api/v1/tokens', { as: sam, json: { scopes: ['git.repo.read'], hours: 1 } });
++ assert.equal(minted.status, 201);
++ assert.match(minted.json().token, /^git_/);
++ assert.match(minted.json().command, /x-access-token:git_/);
++ const row = await t.store.maybe('SELECT token_hash, subject, scopes FROM git_tokens WHERE token_hash = ?', [require('../server/auth/git-auth').hashToken(minted.json().token)]);
++ assert.ok(!row.token_hash.includes(minted.json().token), 'the raw token is not stored');
++ assert.equal(row.subject, sam.subject);
++
++ // A contents write with no clone.
++ const write = await t.get('/api/v1/repos/sam/api-demo/commits', {
++ as: sam, json: { branch: 'agent/run-1', message: 'Write the title without a clone', files: [{ path: 'notes.txt', content: 'made by the API\n' }, { path: 'notes,two.txt', content: 'a comma in the name\n' }] },
++ });
++ assert.equal(write.status, 201);
++ assert.match(write.json().sha, /^[0-9a-f]{40}$/);
++ const readBack = await t.get('/api/v1/repos/sam/api-demo/blob?ref=agent/run-1&path=notes.txt');
++ assert.equal(readBack.status, 200);
++ assert.equal(readBack.json().text, 'made by the API\n');
++ const comma = await t.get('/api/v1/repos/sam/api-demo/blob?ref=agent/run-1&path=notes%2Ctwo.txt');
++ assert.equal(comma.status, 200);
++ assert.equal(comma.json().text, 'a comma in the name\n');
++ const newCommit = await t.get(`/api/v1/repos/sam/api-demo/commit/${write.json().sha}`);
++ assert.equal(newCommit.json().subject, 'Write the title without a clone');
++
++ // A private repository answers 404 to everyone else.
++ await t.ctx.repos.create({ actor: sam, name: 'secret-notes', starter: 'empty', visibility: 'private' });
++ assert.equal((await t.get('/api/v1/repos/sam/secret-notes')).status, 404);
++ assert.equal((await t.get('/api/v1/repos/sam/secret-notes', { as: sam })).status, 200);
++
++ // Stats and activity are real counts.
++ const stats = await t.get('/api/v1/stats');
++ assert.equal(stats.status, 200);
++ assert.ok(stats.json().repositories >= 1);
++ const activity = await t.get('/api/v1/activity');
++ assert.ok(activity.json().events.some((e) => e.type === 'create' || e.type === 'push'));
++
++ console.log('api: 34 checks passed');
++ } finally { await t.close(); }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/git-http.test.js b/test/git-http.test.js
nnew file mode 100644
iindex 0000000..7decca4
---- /dev/null
++++ b/test/git-http.test.js
@@@ -0,0 +1,71 @@
++'use strict';
++const assert = require('assert');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const { boot } = require('./helpers/boot');
++const { mintToken } = require('../server/auth/git-auth');
++
++(async () => {
++ const t = await boot();
++ const work = fs.mkdtempSync(path.join(os.tmpdir(), 'openvibe-git-http-'));
++ try {
++ const sam = t.asUser('sam');
++ await t.ctx.repos.create({ actor: sam, name: 'clone-me', description: 'Clone this.', starter: 'static', visibility: 'public' });
++ await t.ctx.repos.create({ actor: sam, name: 'only-me', description: 'Private.', starter: 'static', visibility: 'private' });
++
++ // A public clone works with no password.
++ const publicDir = path.join(work, 'public');
++ await t.git(['clone', '--quiet', t.cloneUrl('sam', 'clone-me'), publicDir]);
++ assert.ok(fs.existsSync(path.join(publicDir, 'index.html')), 'the file is there');
++
++ // A private clone needs the owner's token.
++ await assert.rejects(() => t.git(['clone', '--quiet', t.cloneUrl('sam', 'only-me'), path.join(work, 'no')]));
++
++ const readToken = await mintToken(t.store, { subject: sam.subject, scopes: ['git.repo.read'], hours: 1 });
++ const privateDir = path.join(work, 'private');
++ await t.git(['clone', '--quiet', t.cloneUrl('sam', 'only-me', readToken.token), privateDir]);
++ assert.ok(fs.existsSync(path.join(privateDir, 'README.md')));
++
++ // A push changes the repository.
++ const writeToken = await mintToken(t.store, { subject: sam.subject, scopes: ['git.repo.read', 'git.repo.write'], hours: 1 });
++ await t.git(['-c', 'user.name=Sam', '-c', '[email protected]', 'checkout', '-b', 'phone'], publicDir);
++ fs.writeFileSync(path.join(publicDir, 'phone.txt'), 'a phone-first change\n');
++ await t.git(['add', 'phone.txt'], publicDir);
++ await t.git(['-c', 'user.name=Sam', '-c', '[email protected]', 'commit', '-q', '-m', 'Add a phone note'], publicDir);
++ await t.git(['push', '--quiet', t.cloneUrl('sam', 'clone-me', writeToken.token), 'phone'], publicDir);
++ const pushed = await t.ctx.git.readBlob('sam', 'clone-me', 'phone', 'phone.txt');
++ assert.equal(pushed.text, 'a phone-first change\n');
++
++ // A typed Network token with git.repo.write works the same way.
++ const typed = t.network.gitToken({ subject: sam.subject, caps: ['git.repo.read', 'git.repo.write'] });
++ fs.writeFileSync(path.join(publicDir, 'typed.txt'), 'typed\n');
++ await t.git(['add', 'typed.txt'], publicDir);
++ await t.git(['-c', 'user.name=Sam', '-c', '[email protected]', 'commit', '-q', '-m', 'Add a typed note'], publicDir);
++ await t.git(['push', '--quiet', t.cloneUrl('sam', 'clone-me', typed), 'phone'], publicDir);
++ assert.equal((await t.ctx.git.readBlob('sam', 'clone-me', 'phone', 'typed.txt')).text, 'typed\n');
++
++ // A read-only token may not push; an anonymous push is refused.
++ const readOnly = await mintToken(t.store, { subject: sam.subject, scopes: ['git.repo.read'], hours: 1 });
++ fs.writeFileSync(path.join(publicDir, 'more.txt'), 'no\n');
++ await t.git(['add', 'more.txt'], publicDir);
++ await t.git(['-c', 'user.name=Sam', '-c', '[email protected]', 'commit', '-q', '-m', 'Nope'], publicDir);
++ await assert.rejects(() => t.git(['push', '--quiet', t.cloneUrl('sam', 'clone-me', readOnly.token), 'phone'], publicDir));
++ await assert.rejects(() => t.git(['push', '--quiet', t.cloneUrl('sam', 'clone-me'), 'phone'], publicDir));
++
++ // The clone count went up (info/refs was served), and the push is in the activity feed.
++ const insights = await t.ctx.repos.insightsFor(await t.ctx.repos.byId((await t.ctx.repos.getRow('sam', 'clone-me')).id));
++ assert.ok(insights.clones >= 1, 'clones counted');
++ const events = await t.ctx.repos.activity(20);
++ assert.ok(events.some((e) => e.type === 'push'));
++
++ // Git's own advertisement refuses a bogus service rather than passing it on.
++ const bogus = await t.get('/sam/clone-me.git/info/refs?service=git-nonsense');
++ assert.equal(bogus.status, 400);
++
++ console.log('git-http: 18 checks passed');
++ } finally {
++ await t.close();
++ fs.rmSync(work, { recursive: true, force: true });
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..18a383d
---- /dev/null
++++ b/test/helpers/boot.js
@@@ -0,0 +1,102 @@
++'use strict';
++// Many requests per test file: the per-address limits would answer 429 part-way.
++process.env.GIT_RATE_LIMIT_PER_MIN = process.env.GIT_RATE_LIMIT_PER_MIN || '100000';
++/**
++ * Boots OpenVibe.Git on a temp database and a temp git data directory, with a mock Network (test/helpers/mocks.js).
++ * Every test file gets its own process, so its own paths.
++ *
++ * const t = await boot();
++ * t.get(path, { as: user, bearer, json, form, headers, method, cookie }) the site and the API
++ * t.signIn(user) → 'ov_token=…'
++ * t.ctx.repos / t.ctx.store seed and inspect
++ * t.git(args, cwd, { urlToken }) runs git, Basic auth in the URL
++ */
++const http = require('http');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const { startNetwork } = require('./mocks');
++
++const DATA = fs.mkdtempSync(path.join(os.tmpdir(), 'openvibe-git-test-'));
++process.env.GIT_DATA_DIR = DATA;
++
++const captured = [];
++for (const m of ['log', 'info', 'warn', 'error']) {
++ const orig = console[m].bind(console);
++ console[m] = (...a) => { captured.push(a.map(String).join(' ')); if (process.env.VERBOSE) orig(...a); };
++}
++
++async function boot(opts = {}) {
++ const network = await startNetwork();
++ const env = {
++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'http://127.0.0.1', TRUST_PROXY: '1',
++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url, OV_NETWORK_ISSUER: network.url,
++ OV_OAUTH_CLIENT_ID: 'git', OV_OAUTH_CLIENT_SECRET: 'git-secret', COOKIE_SECURE: 'false',
++ GIT_DRILL: '1', GIT_GITHUB_WEBHOOK_SECRET: 'test-webhook-secret',
++ GIT_SECRET_KEY: 'test-git-secret-key-0123456789abcdef', GIT_GITHUB_TOKEN: '',
++ GIT_API_RATE_LIMIT_PER_MIN: '100000', GIT_PAGE_RATE_LIMIT_PER_MIN: '100000',
++ ...(opts.env || {}),
++ };
++ const configLib = require('../../server/config');
++ const { createApp } = require('../../server/app');
++ const { createStore } = require('../../server/db');
++ const config = configLib.load(env);
++ const testdb = await require('./db').testDb();
++ const store = createStore(testdb.db, { now: opts.now });
++ const log = { log: () => {}, warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: () => {} };
++ const { app, ctx } = await createApp({ config, store, log, fetchImpl: typeof opts.fetchImpl === 'function' ? opts.fetchImpl : undefined });
++ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
++ const port = server.address().port;
++ const base = `http://127.0.0.1:${port}`;
++ config.baseUrl = base;
++ ctx.config.baseUrl = base;
++ const sockets = new Set();
++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
++
++ const signIn = (user) => `ov_token=${network.userToken(user)}`;
++
++ async function get(p, o = {}) {
++ const headers = { ...(o.headers || {}) };
++ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
++ if (o.cookie) headers.cookie = o.cookie;
++ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
++ if (o.basic) headers.authorization = `Basic ${Buffer.from(o.basic).toString('base64')}`;
++ let body = o.body;
++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
++ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
++ const method = o.method || (body ? 'POST' : 'GET');
++ if (o.as && method !== 'GET' && method !== 'HEAD' && !headers.origin) headers.origin = config.baseUrl;
++ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });
++ const buf = Buffer.from(await res.arrayBuffer());
++ const text = buf.toString('utf8');
++ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };
++ }
++
++ /** git with the site's own base URL; async, so the in-process server keeps answering. */
++ function git(args, cwd, { urlToken } = {}) {
++ const { execFile } = require('node:child_process');
++ const env2 = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0', HOME: cwd || DATA };
++ return new Promise((resolve, reject) => {
++ execFile('git', args, { cwd: cwd || DATA, env: env2, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
++ if (err) { err.stderr = stderr; reject(err); return; }
++ resolve(stdout);
++ });
++ });
++ }
++ const cloneUrl = (owner, name, token) => `http://${token ? `x-access-token:${token}@` : ''}127.0.0.1:${port}/${owner}/${name}.git`;
++
++ return {
++ base, port, config, store, ctx, network, logs: () => captured.slice(), get, signIn, git, cloneUrl,
++ asUser: (username, extra = {}) => ({ subject: require('./mocks').newSubject(), username, ...extra }),
++ close: async () => {
++ for (const s of sockets) s.destroy();
++ await new Promise((resolve) => server.close(resolve));
++ await ctx.git.catFiles.close();
++ await store.close();
++ await network.close();
++ fs.rmSync(DATA, { recursive: true, force: true });
++ },
++ };
++}
++
++module.exports = { boot, DATA };
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..0baf48b
---- /dev/null
++++ b/test/helpers/db.js
@@@ -0,0 +1,11 @@
++'use strict';
++/**
++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
++ * GIT_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.
++ */
++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
++const { MIGRATIONS } = require('../../server/db');
++
++const testDb = ({ store = process.env.GIT_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'git', max });
++
++module.exports = { testDb, pgAvailable };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
nnew file mode 100644
iindex 0000000..69cfa86
---- /dev/null
++++ b/test/helpers/mocks.js
@@@ -0,0 +1,61 @@
++'use strict';
++/**
++ * An in-process stand-in for OpenVibe.Network with a real RS256 key pair.
++ *
++ * JWKS at /api/.well-known/jwks; a person token (verified as a session) and a typed git token
++ * (aud openvibe.git, typ git, cap git.repo.read/git.repo.write). Nothing else: Git's pages sign in through the
++ * real SSO client, whose callback is not exercised here.
++ */
++const http = require('http');
++const crypto = require('crypto');
++const { ids } = require('openvibe-contracts');
++
++function listen(handler) {
++ return new Promise((resolve) => {
++ const sockets = new Set();
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks);
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ Promise.resolve(handler(req, raw, json, res)).catch((err) => json(500, { error: String(err && err.message) }));
++ });
++ });
++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
++ server.listen(0, '127.0.0.1', () => resolve({ server, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }) }));
++ });
++}
++
++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++function signJwt(claims, privateKey) {
++ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;
++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
++}
++
++async function startNetwork() {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
++ let issuer = null;
++ const st = { requests: [] };
++ const server = await listen(async (req, _raw, json) => {
++ st.requests.push({ method: req.method, path: new URL(req.url, 'http://x').pathname });
++ if (new URL(req.url, 'http://x').pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
++ return json(404, { error: 'not found' });
++ });
++ issuer = server.url;
++ function userToken({ subject, username, role = 'user', ttl = 3600 } = {}) {
++ const now = Math.floor(Date.now() / 1000);
++ return signJwt({ iss: issuer, sub: `id_${crypto.randomBytes(6).toString('hex')}`, subject_id: subject, username, display_name: username, role, aud: ['openvibe.network'], iat: now, exp: now + ttl }, privateKey);
++ }
++ function gitToken({ subject, caps = ['git.repo.read', 'git.repo.write'], ttl = 3600, type = 'git' } = {}) {
++ const now = Math.floor(Date.now() / 1000);
++ // type null: a service principal's client-credentials token, which carries no typ/purpose.
++ return signJwt({ iss: issuer, sub: subject, actor_type: /^svc:/.test(subject) ? 'service' : 'user', aud: ['openvibe.git'], cap: caps, ...(type == null ? {} : { typ: type, purpose: type }), iat: now, exp: now + ttl, jti: `rtk_${crypto.randomBytes(8).toString('hex')}` }, privateKey);
++ }
++ return { ...server, issuer, userToken, gitToken, requests: st.requests, _privateKey: privateKey };
++}
++
++const newSubject = () => ids.newId('user');
++
++module.exports = { startNetwork, signJwt, newSubject };
ddiff --git a/test/insights.test.js b/test/insights.test.js
nnew file mode 100644
iindex 0000000..f6ea8d1
---- /dev/null
++++ b/test/insights.test.js
@@@ -0,0 +1,46 @@
++'use strict';
++const assert = require('assert');
++const { boot } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ const sam = t.asUser('sam');
++ await t.ctx.repos.create({ actor: sam, name: 'numbers', starter: 'empty' });
++ const row = await t.ctx.repos.getRow('sam', 'numbers');
++
++ // Two views from one visitor in a day are two views and one unique visitor.
++ await t.ctx.insights.recordView(row, { address: '198.51.100.7', userAgent: 'a phone' });
++ await t.ctx.insights.recordView(row, { address: '198.51.100.7', userAgent: 'a phone' });
++ await t.ctx.insights.recordView(row, { address: '198.51.100.8', userAgent: 'a laptop' });
++ await t.ctx.insights.recordClone(row, { address: '198.51.100.7' });
++ await t.ctx.insights.recordClone(row, { address: '198.51.100.8' });
++ await t.ctx.repos.star(row, sam.subject, true);
++
++ const data = await t.ctx.insights.forRepo(row, { days: 30, commitsPerWeek: () => t.ctx.git.commitsPerWeek('sam', 'numbers', 'main') });
++ assert.equal(data.repo, 'sam/numbers');
++ assert.equal(data.window_days, 30);
++ assert.equal(data.views, 3);
++ assert.equal(data.unique_visitors, 2);
++ assert.equal(data.clones, 2);
++ assert.equal(data.stars.total, 1);
++ assert.equal(data.stars.in_window, 1);
++ assert.equal(data.daily.length, 30);
++ assert.ok(data.commits_per_week.length >= 20);
++ assert.ok(data.commits_per_week.some((w) => w.commits >= 1));
++
++ // The API hands the same numbers to the page.
++ const api = await t.get('/api/v1/repos/sam/numbers/insights?days=7');
++ assert.equal(api.status, 200);
++ assert.equal(api.json().window_days, 7);
++ assert.equal(api.json().views, 3);
++
++ // A view through the page is recorded too.
++ const before = (await t.get('/api/v1/repos/sam/numbers/insights')).json().views;
++ await t.get('/sam/numbers');
++ const after = (await t.get('/api/v1/repos/sam/numbers/insights')).json().views;
++ assert.equal(after, before + 1);
++
++ console.log('insights: 16 checks passed');
++ } finally { await t.close(); }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/mirror.test.js b/test/mirror.test.js
nnew file mode 100644
iindex 0000000..5ec39e2
---- /dev/null
++++ b/test/mirror.test.js
@@@ -0,0 +1,66 @@
++'use strict';
++const assert = require('assert');
++const { boot } = require('./helpers/boot');
++const { parseGithubUrl, createGithubClient } = require('../server/github');
++const { seal, open } = require('../server/crypto');
++
++(async () => {
++ const t = await boot();
++ t.config.mirrors.timeoutMs = 400;
++ try {
++ // The URL rule: https, exactly github.com, owner/name, no credentials.
++ assert.equal(parseGithubUrl('https://github.com/OpenVibers/OpenVibe.Contracts').full_name, 'OpenVibers/OpenVibe.Contracts');
++ assert.equal(parseGithubUrl('https://github.com/OpenVibers/OpenVibe.Contracts.git').clone_url, 'https://github.com/OpenVibers/OpenVibe.Contracts.git');
++ for (const bad of ['http://github.com/a/b', 'https://evil.com/a/b', 'https://github.com/a/b/c', 'https://user:[email protected]/a/b', 'https://github.com/a/b?x=1', 'ftp://github.com/a/b', 'https://localhost/a/b']) {
++ assert.equal(parseGithubUrl(bad), null, `refused ${bad}`);
++ }
++
++ // The webhook's signature is over the raw body and constant time.
++ const github = createGithubClient({ config: t.config, fetchImpl: async () => { throw new Error('offline'); }, log: { warn() {} } });
++ const raw = Buffer.from(JSON.stringify({ repository: { full_name: 'OpenVibers/OpenVibe.Contracts' } }));
++ const crypto = require('node:crypto');
++ const sig = `sha256=${crypto.createHmac('sha256', 'test-webhook-secret').update(raw).digest('hex')}`;
++ assert.equal(github.signatureOk(raw, sig), true);
++ assert.equal(github.signatureOk(raw, 'sha256=00'), false);
++ assert.equal(github.signatureOk(raw, undefined), false);
++
++ // A webhook for an unknown repository is acknowledged and ignored.
++ const bad = await t.get('/api/v1/webhooks/github', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', 'x-hub-signature-256': 'sha256=00', 'x-github-event': 'push' } });
++ assert.equal(bad.status, 401);
++ const good = await t.get('/api/v1/webhooks/github', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', 'x-hub-signature-256': sig, 'x-github-event': 'push' } });
++ assert.equal(good.status, 200);
++ assert.equal(good.json().ignored, 'no mirror');
++
++ // A mirror URL that is not GitHub is refused at creation, with no network.
++ const sam = t.asUser('sam');
++ await assert.rejects(() => t.ctx.repos.create({ actor: sam, name: 'mirror-no', mirrorUrl: 'https://localhost/x/y' }), /github\.com/i);
++
++ // A sync of a row whose URL fails the guard lands in the error state, not a crash.
++ await t.ctx.repos.create({ actor: sam, name: 'plain', starter: 'empty' });
++ await t.store.execute("UPDATE git_repos SET mirror_url = 'https://github.com/sindresorhus/is-odd.git' WHERE name = 'plain'");
++ const row = await t.ctx.repos.getRow('sam', 'plain');
++ const realFetch = globalThis.fetch;
++ const result = await t.ctx.mirrors.syncRow(row);
++ assert.equal(result.ok, false);
++ const after = await t.ctx.repos.getRow('sam', 'plain');
++ assert.equal(after.mirror_state, 'error');
++ assert.ok(after.mirror_error);
++ void realFetch;
++
++ // Push-mirror tokens are sealed at rest and never returned.
++ const sealed = seal('ghp_secret_value', t.config.tokens.secretKey);
++ assert.match(sealed, /^v1\./);
++ assert.ok(!sealed.includes('ghp_secret_value'));
++ assert.equal(open(sealed, t.config.tokens.secretKey), 'ghp_secret_value');
++ assert.equal(open(sealed, 'another-key'), null);
++ await t.ctx.repos.setPushMirror(await t.ctx.repos.getRow('sam', 'plain'), { push_url: 'https://github.com/sam/plain', push_token: 'ghp_secret_value' }, sam);
++ const stored = await t.ctx.repos.getRow('sam', 'plain');
++ assert.ok(stored.push_mirror_token.startsWith('v1.'));
++ assert.ok(!stored.push_mirror_token.includes('ghp_secret_value'));
++ assert.equal(t.ctx.repos.pushTokenFor(stored), 'ghp_secret_value');
++ const shown = await t.get('/api/v1/repos/sam/plain', { as: sam });
++ assert.ok(!JSON.stringify(shown.json()).includes('ghp_secret_value'));
++
++ console.log('mirror: 25 checks passed');
++ } finally { await t.close(); }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/no-shell.test.js b/test/no-shell.test.js
nnew file mode 100644
iindex 0000000..f242f72
---- /dev/null
++++ b/test/no-shell.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++const assert = require('assert');
++const fs = require('node:fs');
++const path = require('node:path');
++
++let n = 0;
++const ok = (cond, msg) => { assert.ok(cond, msg); n += 1; };
++
++// ── No shell, anywhere ─────────────────────────────────────
++const serverDir = path.join(__dirname, '..', 'server');
++const files = [];
++(function walk(dir) {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) walk(p);
++ else if (e.name.endsWith('.js')) files.push(p);
++ }
++})(serverDir);
++for (const file of files) {
++ const src = fs.readFileSync(file, 'utf8');
++ ok(!/shell\s*:\s*true/.test(src), `${path.relative(serverDir, file)}: no shell:true`);
++ ok(!/\bexecSync\b|\bexecFileSync\b|\bexecFile\b|(^|[^.\w])exec\s*\(/m.test(src), `${path.relative(serverDir, file)}: no exec*`);
++ ok(!/\bspawnSync\b/.test(src), `${path.relative(serverDir, file)}: no spawnSync`);
++ ok(!/child_process/.test(src) || /spawn/.test(src), `${path.relative(serverDir, file)}: child_process only for spawn`);
++}
++
++// ── Every real git call is an argument array with a command ─
++const cp = require('node:child_process');
++const originalSpawn = cp.spawn;
++const calls = [];
++cp.spawn = function patched(command, args, options) {
++ calls.push({ command, args, options });
++ return originalSpawn.call(this, command, args, options);
++};
++const { runGit } = require('../server/git/run');
++const os = require('node:os');
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'openvibe-git-noshell-'));
++
++(async () => {
++ await runGit(['init', '--bare', path.join(tmp, 'r.git')]);
++ await runGit(['--version']);
++ ok(calls.length >= 2, 'git ran');
++ for (const call of calls) {
++ ok(call.command === 'git', 'the command is git');
++ ok(Array.isArray(call.args), 'arguments are an array');
++ ok(call.options && call.options.shell === false, 'shell is off');
++ ok(call.args.every((a) => typeof a === 'string'), 'every argument is a string');
++ ok(call.args[0] !== '-c' || typeof call.args[1] === 'string', 'no free-form -c from a request');
++ }
++
++ // A hostile "argument" stays one argument.
++ const hostile = '--upload-pack=$(touch /tmp/owned)';
++ await runGit(['rev-parse', '--verify', '--quiet', '--end-of-options', hostile], { cwd: path.join(tmp, 'r.git'), allowFailure: true });
++ const last = calls[calls.length - 1];
++ ok(last.args.includes(hostile), 'the hostile string is one argument');
++ ok(!fs.existsSync('/tmp/owned'), 'nothing executed it');
++
++ fs.rmSync(tmp, { recursive: true, force: true });
++ console.log(`no-shell: ${n} checks passed`);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/pages.test.js b/test/pages.test.js
nnew file mode 100644
iindex 0000000..0d848b2
---- /dev/null
++++ b/test/pages.test.js
@@@ -0,0 +1,74 @@
++'use strict';
++const assert = require('assert');
++const { boot } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ const sam = t.asUser('sam');
++ const repo = await t.ctx.repos.create({ actor: sam, name: 'phone-first', description: 'A phone-first app.', starter: 'app', visibility: 'public' });
++
++ const home = await t.get('/');
++ assert.equal(home.status, 200);
++ assert.match(home.text, /<h1>Git for OpenVibe<\/h1>/);
++ assert.match(home.text, /application\/ld\+json/);
++ assert.match(home.text, /phone-first/);
++ assert.match(home.text, /New repository/);
++
++ const explore = await t.get('/explore?q=phone');
++ assert.equal(explore.status, 200);
++ assert.match(explore.text, /sam\/phone-first/);
++ assert.match(explore.text, /git-langbar|No description/);
++
++ const page = await t.get('/sam/phone-first');
++ assert.equal(page.status, 200);
++ assert.match(page.text, /Get it/);
++ assert.match(page.text, /README\.md/);
++ assert.match(page.text, /git-repo-head/);
++ assert.match(page.text, /git-tree/);
++ assert.match(page.text, /SoftwareSourceCode/);
++ assert.match(page.text, /<h1>sam<span>\/<\/span>phone-first<\/h1>/);
++
++ // A Markdown file reads as a document; its code (with line links) is one tap away.
++ const mdDoc = await t.get('/sam/phone-first/blob?ref=main&path=README.md');
++ assert.equal(mdDoc.status, 200);
++ assert.match(mdDoc.text, /class="git-md"/);
++ assert.match(mdDoc.text, /aria-current="page"[^>]*>[\s\S]{0,200}Preview/);
++ const file = await t.get('/sam/phone-first/blob?ref=main&path=README.md&plain=1');
++ assert.equal(file.status, 200);
++ assert.match(file.text, /id="L1"/);
++ assert.match(file.text, /href="#L1"/);
++ assert.match(file.text, /git-code-line/);
++
++ const tree = await t.get('/sam/phone-first/tree?ref=main');
++ assert.equal(tree.status, 200);
++ assert.match(tree.text, /git-tree/);
++
++ const commits = await t.get('/sam/phone-first/commits');
++ assert.equal(commits.status, 200);
++ assert.match(commits.text, /Start sam\/phone-first/);
++
++ const insights = await t.get('/sam/phone-first/insights');
++ assert.equal(insights.status, 200);
++ assert.match(insights.text, /Insights/);
++ assert.match(insights.text, /Commits per week/);
++
++ const profile = await t.get('/@sam');
++ assert.equal(profile.status, 200);
++ assert.match(profile.text, /@sam/);
++
++ const missing = await t.get('/nobody/nothing');
++ assert.equal(missing.status, 404);
++ assert.match(missing.text, /Not found/);
++
++ // The API document is crawlable and names the real routes.
++ const doc = await t.get('/openapi.json');
++ assert.equal(doc.status, 200);
++ assert.equal(doc.json().openapi, '3.1.0');
++
++ // No placeholder content anywhere.
++ assert.doesNotMatch(home.text, /lorem ipsum/i);
++ assert.match(repo.full_name, /sam\/phone-first/);
++ console.log('pages: 24 checks passed');
++ } finally { await t.close(); }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/refs.test.js b/test/refs.test.js
nnew file mode 100644
iindex 0000000..342f933
---- /dev/null
++++ b/test/refs.test.js
@@@ -0,0 +1,48 @@
++'use strict';
++const assert = require('assert');
++const crypto = require('crypto');
++const { validRef, validPath, validOwner, validName, validRepo } = require('../server/git/refs');
++
++let n = 0;
++const ok = (cond, msg) => { assert.ok(cond, msg); n += 1; };
++const ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/.\u0000\n\t ~^:?*[\\@{';
++
++// A valid ref keeps every invariant the git call depends on.
++for (let i = 0; i < 20000; i += 1) {
++ const len = 1 + (i % 24);
++ let s = '';
++ for (let j = 0; j < len; j += 1) s += ALPHABET[crypto.randomInt(ALPHABET.length)];
++ if (!validRef(s)) continue;
++ ok(!s.startsWith('-'), 'no leading dash');
++ ok(!s.includes('..'), 'no ..');
++ ok(!s.includes('\\'), 'no backslash');
++ ok(!s.endsWith('/') && !s.endsWith('.'), 'no trailing slash or dot');
++ ok(!/[\u0000-\u0020\u007f~^:?*\[\]]/.test(s), 'no control or ref punctuation');
++ ok(s.split('/').every((p) => p && !p.startsWith('.') && !p.endsWith('.lock')), 'every component is safe');
++}
++
++// The shapes that must never arrive at git.
++for (const bad of ['', '-x', '../../etc/passwd', 'a..b', 'a\\b', 'a b', 'a~b', 'a^b', 'a:b', 'a?b', 'a*b', 'a[b', 'a]b', 'a@{b', '/a', 'a/', '.a', 'a/.b', 'a.lock', 'a/.lock', 'a//b', '@']) {
++ ok(!validRef(bad), `ref refused: ${JSON.stringify(bad)}`);
++}
++for (const good of ['main', 'release/1.0', 'feat/thing-2', 'v1.2.3', 'a_b.c']) ok(validRef(good), `ref accepted: ${good}`);
++
++// Paths are relative and never climb.
++for (let i = 0; i < 20000; i += 1) {
++ const len = 1 + (i % 20);
++ let s = '';
++ for (let j = 0; j < len; j += 1) s += ALPHABET[crypto.randomInt(ALPHABET.length)];
++ if (!validPath(s)) continue;
++ ok(!s.startsWith('/') && !s.startsWith('-'), 'relative');
++ ok(!s.includes('\\'), 'no backslash');
++ ok(!s.split('/').some((p) => p === '..' || p === '.' || !p), 'no traversal or empty segment');
++ ok(!s.split('/').some((p) => p.toLowerCase() === '.git'), 'never enters .git');
++}
++for (const bad of ['../x', 'a/../../b', '/etc/passwd', 'a\\b', 'a//b', 'a/./b', '.git/config', 'a/.git/x', 'a\u0000b', '-flag']) ok(!validPath(bad), `path refused: ${JSON.stringify(bad)}`);
++for (const good of ['', 'README.md', 'server/git/store.js', 'a.b/c-d_e']) ok(validPath(good), `path accepted: ${good}`);
++
++ok(validOwner('openvibers') && !validOwner('../x') && !validOwner('-x'), 'owners');
++ok(validName('openvibe.codes') && !validName('x.git') && !validName('../x'), 'names never end .git');
++ok(validRepo('openvibers/openvibe.codes') && !validRepo('openvibers/x.git'), 'repo names');
++
++console.log(`refs: ${n} checks passed`);
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..367181a
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,19 @@
++#!/usr/bin/env node
++/**
++ * Runs every test/*.test.js in its own process. They use a temp PGlite database, a temp git data directory and an
++ * in-process mock of OpenVibe.Network with a real RS256 key pair; none needs the network (mirror fetches are the
++ * one thing faked at the guard).
++ *
++ * npm test # everything
++ * npm test -- refs # only files whose name contains one of the words
++ */
++'use strict';
++const { run } = require('openvibe-shared/test-runner');
++
++run({ dir: __dirname, timeoutMs: 120000, pad: 34, parallel: 1 }).then((r) => {
++ if (process.env.VERBOSE) {
++ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);
++ console.log(`\n${r.summary}`);
++ }
++ process.exit(r.exitCode);
++}, (err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security.test.js b/test/security.test.js
nnew file mode 100644
iindex 0000000..515af85
---- /dev/null
++++ b/test/security.test.js
@@@ -0,0 +1,65 @@
++'use strict';
++const assert = require('assert');
++const { boot } = require('./helpers/boot');
++const { mintToken } = require('../server/auth/git-auth');
++
++(async () => {
++ const t = await boot();
++ try {
++ const sam = t.asUser('sam');
++ const other = t.asUser('riley');
++ await t.ctx.repos.create({ actor: sam, name: 'guarded', starter: 'static', visibility: 'private' });
++ await t.ctx.repos.create({ actor: sam, name: 'public-one', starter: 'static', visibility: 'public' });
++
++ // A private repository does not exist for anyone else, anywhere.
++ assert.equal((await t.get('/sam/guarded')).status, 404);
++ assert.equal((await t.get('/api/v1/repos/sam/guarded')).status, 404);
++ assert.equal((await t.get('/sam/guarded.git/info/refs?service=git-upload-pack')).status, 401);
++ assert.equal((await t.get('/api/v1/repos/sam/guarded/tree')).status, 404);
++ assert.equal((await t.get('/sam/guarded', { as: other })).status, 404);
++ assert.equal((await t.get('/sam/guarded', { as: sam })).status, 200);
++
++ // Paths and refs that try to leave the repository are refused before git.
++ for (const badPath of ['../etc/passwd', '/etc/passwd', 'a/../../b', '.git/config', 'a\\b']) {
++ const res = await t.get(`/api/v1/repos/sam/public-one/blob?path=${encodeURIComponent(badPath)}`);
++ assert.ok([404, 422].includes(res.status), `path refused: ${badPath} -> ${res.status}`);
++ }
++ const badRef = await t.get('/sam/public-one/tree?ref=--upload-pack%3Dtouch%20/tmp/x');
++ assert.equal(badRef.status, 422);
++ const traversalRef = await t.get('/sam/public-one/tree?ref=refs/heads/..%2F..%2Fetc');
++ assert.equal(traversalRef.status, 422);
++
++ // A read-only token cannot write through the API.
++ const readOnly = await mintToken(t.store, { subject: sam.subject, scopes: ['git.repo.read'], hours: 1 });
++ const denied = await t.get('/api/v1/repos/sam/public-one/commits', {
++ bearer: readOnly.token, json: { branch: 'x', message: 'no', files: [{ path: 'x', content: 'x' }] },
++ });
++ assert.equal(denied.status, 403);
++
++ // Someone else cannot write to Sam's repository, even with a write token.
++ const rileyWrite = await mintToken(t.store, { subject: other.subject, scopes: ['git.repo.read', 'git.repo.write'], hours: 1 });
++ const foreign = await t.get('/api/v1/repos/sam/public-one/commits', {
++ bearer: rileyWrite.token, json: { branch: 'x', message: 'no', files: [{ path: 'x', content: 'x' }] },
++ });
++ assert.ok([403, 404].includes(foreign.status), `foreign write refused (${foreign.status})`);
++
++ // An expired token is refused.
++ const expired = await mintToken(t.store, { subject: sam.subject, scopes: ['git.repo.read'], hours: 1 });
++ await t.store.execute("UPDATE git_tokens SET expires_at = now() - interval '1 hour' WHERE token_hash = ?", [require('../server/auth/git-auth').hashToken(expired.token)]);
++ assert.equal((await t.store.maybe('SELECT 1 AS one FROM git_tokens WHERE token_hash = ? AND expires_at > now()', [require('../server/auth/git-auth').hashToken(expired.token)])), null);
++
++ // Every token made through the API leaves an audit row, without the token.
++ const minted = await t.get('/api/v1/tokens', { as: sam, json: { scopes: ['git.repo.read'] } });
++ assert.equal(minted.status, 201);
++ const audit = await t.store.many('SELECT action, actor_subject FROM git_audit ORDER BY id');
++ assert.ok(audit.some((a) => a.action === 'token.create'));
++ assert.ok(audit.every((a) => !JSON.stringify(a).includes('git_') || a.action !== 'token.create'));
++
++ // The database keeps the columns the service reads.
++ const cols = await t.store.db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'git_repos'");
++ const names = cols.map((c) => c.column_name);
++ for (const c of ['mirror_url', 'mirror_state', 'push_mirror_token', 'fork_of', 'languages', 'official']) assert.ok(names.includes(c), `column ${c}`);
++
++ console.log('security: 20 checks passed');
++ } finally { await t.close(); }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/service-delegation.test.js b/test/service-delegation.test.js
nnew file mode 100644
iindex 0000000..dc13d45
---- /dev/null
++++ b/test/service-delegation.test.js
@@@ -0,0 +1,85 @@
++'use strict';
++/**
++ * A service acting for a person (Codes, Run, Link): a typed Network token for audience openvibe.git with
++ * sub svc:<service> and git.repo.read/git.repo.write, naming the person in X-OV-Subject. The service makes a
++ * repository in the person's name, reads their private one, and mints a Git token for their session; it
++ * cannot open a namespace that belongs to someone else.
++ */
++const assert = require('assert');
++const { boot } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ const service = t.network.gitToken({ subject: 'svc:codes', caps: ['git.repo.read', 'git.repo.write'], type: null });
++ const ada = t.asUser('ada');
++ const bob = t.asUser('bob');
++
++ const nameless = await t.get('/api/v1/repos', { bearer: service, json: { name: 'ada-site' } });
++ assert.equal(nameless.status, 401);
++ assert.equal(nameless.json().code, 'auth.subject_required');
++
++ const unnamedOwner = await t.get('/api/v1/repos', { bearer: service, headers: { 'x-ov-subject': ada.subject }, json: { name: 'ada-site' } });
++ assert.equal(unnamedOwner.status, 422);
++ assert.equal(unnamedOwner.json().code, 'repo.owner_required');
++
++ const made = await t.get('/api/v1/repos', {
++ bearer: service, headers: { 'x-ov-subject': ada.subject },
++ json: { name: 'ada-site', owner_username: 'Ada', description: 'Made from Codes.', visibility: 'private' },
++ });
++ assert.equal(made.status, 201, made.text);
++ assert.equal(made.json().full_name, 'ada/ada-site');
++ assert.equal(made.json().visibility, 'private');
++ assert.equal(made.json().owner.subject, ada.subject, 'the person owns it, not the service');
++ assert.match(made.json().clone_url, /\/ada\/ada-site\.git$/);
++
++ const others = await t.get('/api/v1/repos', {
++ bearer: service, headers: { 'x-ov-subject': bob.subject },
++ json: { name: 'bob-site', owner_username: 'ada' },
++ });
++ assert.equal(others.status, 409, 'ada is not bob');
++ assert.equal(others.json().code, 'repo.owner_mismatch');
++
++ // A private repository is the person's: the service sees it only while it names them.
++ assert.equal((await t.get('/api/v1/repos/ada/ada-site', { bearer: service, headers: { 'x-ov-subject': ada.subject } })).status, 200);
++ assert.equal((await t.get('/api/v1/repos/ada/ada-site', { bearer: service })).status, 404);
++ assert.equal((await t.get('/api/v1/repos/ada/ada-site', { bearer: service, headers: { 'x-ov-subject': bob.subject } })).status, 404);
++
++ const profile = await t.get('/api/v1/profile/ada', { bearer: service, headers: { 'x-ov-subject': ada.subject } });
++ assert.equal(profile.status, 200);
++ assert.equal(profile.json().repositories[0].full_name, 'ada/ada-site');
++ const anonymous = await t.get('/api/v1/profile/ada', { bearer: service });
++ assert.equal(anonymous.json().repositories.length, 0, 'nothing of ada\'s is public yet');
++
++ // A Git token for the person's session: theirs, not the service's.
++ const minted = await t.get('/api/v1/tokens', {
++ bearer: service, headers: { 'x-ov-subject': ada.subject }, json: { scopes: ['git.repo.read', 'git.repo.write'], hours: 2 },
++ });
++ assert.equal(minted.status, 201, minted.text);
++ assert.match(minted.json().token, /^git_/);
++ const row = await t.store.maybe('SELECT subject, actor_type FROM git_tokens WHERE token_hash = ?', [require('../server/auth/git-auth').hashToken(minted.json().token)]);
++ assert.deepStrictEqual([row.subject, row.actor_type], [ada.subject, 'user']);
++ const heads = await t.git(['ls-remote', '--heads', t.cloneUrl('ada', 'ada-site', minted.json().token)]);
++ assert.match(heads, /refs\/heads\/main/, 'the token clones the private repository');
++
++ // A contents write with no clone, as the person, on their own (non-official) repository.
++ const write = await t.get('/api/v1/repos/ada/ada-site/commits', {
++ bearer: service, headers: { 'x-ov-subject': ada.subject },
++ json: { branch: 'agent/codes-1', message: 'From a Codes session', files: [{ path: 'notes.txt', content: 'hello\n' }] },
++ });
++ assert.equal(write.status, 201, write.text);
++ assert.equal((await t.get('/api/v1/repos/ada/ada-site/blob?ref=agent/codes-1&path=notes.txt', { bearer: service, headers: { 'x-ov-subject': ada.subject } })).json().text, 'hello\n');
++
++ // A read-only service token may not make a repository or mint one.
++ const readOnly = t.network.gitToken({ subject: 'svc:codes', caps: ['git.repo.read'], type: null });
++ const refused = await t.get('/api/v1/repos', {
++ bearer: readOnly, headers: { 'x-ov-subject': ada.subject }, json: { name: 'read-only', owner_username: 'ada' },
++ });
++ assert.equal(refused.status, 403);
++ assert.equal(refused.json().code, 'token.scope');
++ assert.equal((await t.get('/api/v1/tokens', { bearer: readOnly, headers: { 'x-ov-subject': ada.subject }, json: { scopes: ['git.repo.read'] } })).status, 403);
++ } finally {
++ await t.close();
++ }
++ console.log('service delegation: ok');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/starters.test.js b/test/starters.test.js
nnew file mode 100644
iindex 0000000..92e8e47
---- /dev/null
++++ b/test/starters.test.js
@@@ -0,0 +1,35 @@
++'use strict';
++const assert = require('assert');
++const { boot } = require('./helpers/boot');
++const { starterFiles } = require('../server/starters');
++
++(async () => {
++ const t = await boot();
++ try {
++ const sam = t.asUser('sam');
++ for (const kind of ['empty', 'app', 'static']) {
++ const repo = await t.ctx.repos.create({ actor: sam, name: `start-${kind}`, starter: kind });
++ const tree = await t.ctx.git.readTree('sam', `start-${kind}`, 'main', '');
++ assert.ok(tree.entries.some((e) => e.name === 'README.md'), `${kind}: README`);
++ const readme = await t.ctx.git.readBlob('sam', `start-${kind}`, 'main', 'README.md');
++ assert.match(readme.text, new RegExp(`sam/start-${kind}`));
++ assert.equal(repo.default_branch, 'main');
++ }
++ const appTree = await t.ctx.git.readTree('sam', 'start-app', 'main', '');
++ assert.ok(appTree.entries.some((e) => e.name === 'package.json'));
++ assert.ok(appTree.entries.some((e) => e.name === 'server'));
++ const staticTree = await t.ctx.git.readTree('sam', 'start-static', 'main', '');
++ assert.ok(staticTree.entries.some((e) => e.name === 'index.html'));
++
++ // The starter files never carry a path that leaves the repository.
++ for (const kind of ['empty', 'app', 'static']) {
++ for (const file of starterFiles(kind, { full: 'sam/x' })) {
++ assert.ok(!file.path.includes('..') && !file.path.startsWith('/') && !file.path.endsWith('.git'));
++ }
++ }
++
++ // A name that ends in .git is refused at creation.
++ await assert.rejects(() => t.ctx.repos.create({ actor: sam, name: 'bad.git' }), /name|\.git/);
++ console.log('starters: 13 checks passed');
++ } finally { await t.close(); }
++})().catch((err) => { console.error(err); process.exit(1); });