openvibers/openvibe-git Commits
OpenVibe.Git G1: hosting, browsing, GitHub mirrors and insights (#2)
Changes
nnew file mode 100644@@@ -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@@@ -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.nnew file mode 100644@@@ -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"++ ]++ }++}nnew file mode 100644@@@ -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;++ }++}nnew file mode 100644@@@ -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.targetnnew file mode 100644@@@ -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.nnew file mode 100644BBinary files /dev/null and b/docs/screens/explore-1280-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/explore-1280-light.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/explore-390-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/explore-390-light.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/file-1280-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/file-1280-light.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/file-390-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/file-390-light.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/home-1280-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/home-1280-light.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/home-390-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/home-390-light.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/insights-1280-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/insights-1280-light.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/insights-390-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/insights-390-light.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/repository-1280-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/repository-1280-light.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/repository-390-dark.jpg differnnew file mode 100644BBinary files /dev/null and b/docs/screens/repository-390-light.jpg differnnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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"++ }++ }++ }++}nnew file mode 100644@@@ -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"++ }++}nnew file mode 100644@@@ -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; }nnew file mode 100644@@@ -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');++ });++})();nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };nnew file mode 100644@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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`);nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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
@@ -28,5 +28,10 @@ Production reads `/etc/openvibe/git.env`; see `.env.example`. `DATABASE_URL` ser
@@ -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).
- `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`.
- `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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -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"
+ ]
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -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;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -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.
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/explore-1280-dark.jpg differ
Binary files /dev/null and b/docs/screens/explore-1280-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/explore-1280-light.jpg differ
Binary files /dev/null and b/docs/screens/explore-1280-light.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/explore-390-dark.jpg differ
Binary files /dev/null and b/docs/screens/explore-390-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/explore-390-light.jpg differ
Binary files /dev/null and b/docs/screens/explore-390-light.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/file-1280-dark.jpg differ
Binary files /dev/null and b/docs/screens/file-1280-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/file-1280-light.jpg differ
Binary files /dev/null and b/docs/screens/file-1280-light.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/file-390-dark.jpg differ
Binary files /dev/null and b/docs/screens/file-390-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/file-390-light.jpg differ
Binary files /dev/null and b/docs/screens/file-390-light.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/home-1280-dark.jpg differ
Binary files /dev/null and b/docs/screens/home-1280-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/home-1280-light.jpg differ
Binary files /dev/null and b/docs/screens/home-1280-light.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/home-390-dark.jpg differ
Binary files /dev/null and b/docs/screens/home-390-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/home-390-light.jpg differ
Binary files /dev/null and b/docs/screens/home-390-light.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/insights-1280-dark.jpg differ
Binary files /dev/null and b/docs/screens/insights-1280-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/insights-1280-light.jpg differ
Binary files /dev/null and b/docs/screens/insights-1280-light.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/insights-390-dark.jpg differ
Binary files /dev/null and b/docs/screens/insights-390-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/insights-390-light.jpg differ
Binary files /dev/null and b/docs/screens/insights-390-light.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/repository-1280-dark.jpg differ
Binary files /dev/null and b/docs/screens/repository-1280-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/repository-1280-light.jpg differ
Binary files /dev/null and b/docs/screens/repository-1280-light.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/repository-390-dark.jpg differ
Binary files /dev/null and b/docs/screens/repository-390-dark.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens/repository-390-light.jpg differ
Binary files /dev/null and b/docs/screens/repository-390-light.jpg differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1599 @@
@@ -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"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -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"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,372 @@
@@ -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; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -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');
+ });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,169 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,166 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,457 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,316 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,363 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,226 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,359 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,441 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -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`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -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); });