openvibers/openvibe-git Commits

Add official npm registry spike to forge (#40)

OP OpenVibers committed 2 days ago · cae0e3ea4c · 7 files, +183 −6

* Add official npm registry spike to OpenVibe Git

* Pin Git shared package to forge archive

Changes

ddiff --git a/docs/npm-registry.md b/docs/npm-registry.md
nnew file mode 100644
iindex 0000000..05c9086
---- /dev/null
++++ b/docs/npm-registry.md
@@@ -0,0 +1,9 @@
++# Official npm packages on OpenVibe Git
++
++The forge serves public, immutable versions of `openvibe-contracts`, `openvibe-sdk`, `openvibe-shared`, and `openvibe-publishing` at `https://git.openvibe.codes/api/packages/openvibers/npm/`. Reads and tarballs are public. Publishing requires the forge runner credential as a Bearer token. The registry accepts npm's single-version `PUT` publish body; a version cannot be replaced.
++
++The release owner publishes an existing library release with `npm publish --registry=https://git.openvibe.codes/api/packages/openvibers/npm/` after configuring npm authentication through the approved credential channel. Do not put the credential in a repository or a command argument. Check `npm view <name>@<version> dist --registry=https://git.openvibe.codes/api/packages/openvibers/npm/` and install in a clean project before changing a dependency pin.
++
++The first consumer should pin one of these libraries by semver and set its registry to the forge URL for that install. Run `npm ci` with npm 10 and npm 12 before moving another repository. Existing codeload pins stay valid for old releases.
++
++This is the Release 2 registry spike. It supports publish, metadata, tarball download, and install. Dist-tag editing, deletion, and arbitrary packages are outside the spike.
ddiff --git a/migrations/0009_npm_packages.sql b/migrations/0009_npm_packages.sql
nnew file mode 100644
iindex 0000000..48f7ce2
---- /dev/null
++++ b/migrations/0009_npm_packages.sql
@@@ -0,0 +1,21 @@
++-- phase: expand
++-- Immutable npm versions for the four official OpenVibe libraries. The existing
++-- forge runner credential may publish; reads and tarballs are public.
++CREATE TABLE git_npm_versions (
++ owner text NOT NULL,
++ name text NOT NULL,
++ version text NOT NULL,
++ manifest jsonb NOT NULL,
++ tarball bytea NOT NULL,
++ integrity text NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (owner, name, version)
++);
++CREATE TABLE git_npm_tags (
++ owner text NOT NULL,
++ name text NOT NULL,
++ tag text NOT NULL,
++ version text NOT NULL,
++ PRIMARY KEY (owner, name, tag),
++ FOREIGN KEY (owner, name, version) REFERENCES git_npm_versions (owner, name, version)
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 3a67d76..ed66ad5 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"highlight.js": "^11.10.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
++ "openvibe-shared": "https://git.openvibe.codes/api/v1/repos/openvibers/openvibe.shared/archive/v4.206.0.tar.gz",
"pg": "^8.23.1"
},
"devDependencies": {
@@@ -1213,8 +1213,8 @@
},
"node_modules/openvibe-shared": {
"version": "4.206.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
-- "integrity": "sha512-mvkdGKmdYXDDV380k0DvdSVmBkg5tjYrw1La9BwNwp87NnR3mNlUCDDgAZTbyP1KyVGuK/zLodiM7bNV61zXfg==",
++ "resolved": "https://git.openvibe.codes/api/v1/repos/openvibers/openvibe.shared/archive/v4.206.0.tar.gz",
++ "integrity": "sha512-3XftFlUOx0nmVgoFJL8ZOaAyCx8A0ijS9NRKsIG4vY/lb0/Tyfe4GuWu422FXL3xjdnw7WZUiTzj8JaszTL9qw==",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 01d5565..717fe4f 100644
---- a/package.json
++++ b/package.json
@@@ -23,11 +23,11 @@
"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.399.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
-- "pg": "^8.23.1",
-- "highlight.js": "^11.10.0"
++ "openvibe-shared": "https://git.openvibe.codes/api/v1/repos/openvibers/openvibe.shared/archive/v4.206.0.tar.gz",
++ "pg": "^8.23.1"
},
"engines": {
"node": ">=22.0.0"
ddiff --git a/server/app.js b/server/app.js
iindex 33e8595..08c8d61 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -42,6 +42,7 @@ const { createArchive } = require('./archive');
const { createReviewServices } = require('./review-services');
const { createLinkRelay } = require('./link-relay');
const { createLinkRoutes } = require('./http/link');
++const { createNpmRegistry } = require('./npm-registry');
const { setRelease } = require('./render/layout');
const VERSION = require('../package.json').version;
@@@ -150,6 +151,8 @@ function createApp(opts = {}) {
// ── Bodies: the GitHub webhook reads raw, everything else JSON ──
app.use('/api/v1/webhooks/github', express.raw({ type: '*/*', limit: '5mb' }));
++ app.use('/api/packages', rateLimit({ windowMs: 60_000, limit: config.apiRateLimitPerMinute }),
++ createNpmRegistry({ store: s, config, ciAuth: ctx.ciAuth, log }));
app.use(express.json({ limit: '4mb' }));
// ── Static ──────────────────────────────────────────────
ddiff --git a/server/npm-registry.js b/server/npm-registry.js
nnew file mode 100644
iindex 0000000..17b2a4d
---- /dev/null
++++ b/server/npm-registry.js
@@@ -0,0 +1,102 @@
++'use strict';
++
++// The npm publish and install protocol for our four public, unscoped libraries.
++// Package bytes live in PostgreSQL with immutable versions. A writer must hold
++// the forge runner credential, which is already checked with a constant-time
++// comparison; a package response never returns that credential.
++const crypto = require('node:crypto');
++const express = require('express');
++
++const NAMES = new Set(['openvibe-contracts', 'openvibe-sdk', 'openvibe-shared', 'openvibe-publishing']);
++const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/;
++const TAG = /^[a-z][a-z0-9._-]{0,39}$/;
++const MAX_TARBALL = 12 * 1024 * 1024;
++const route = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
++
++function createNpmRegistry({ store, config, ciAuth, log = console }) {
++ const r = express.Router();
++ const owner = 'openvibers';
++ const base = () => `${config.baseUrl}/api/packages/${owner}/npm`;
++ const bad = (res, status, error) => res.status(status).json({ error });
++ const named = (req, res) => {
++ if (req.params.owner !== owner || !NAMES.has(req.params.name)) {
++ bad(res, 404, 'package not found');
++ return false;
++ }
++ return true;
++ };
++ const manifestFor = (row) => typeof row.manifest === 'string' ? JSON.parse(row.manifest) : row.manifest;
++ const tarballUrl = (name, version) => `${base()}/${name}/-/${name}-${version}.tgz`;
++
++ r.get('/:owner/npm/:name', route(async (req, res) => {
++ if (!named(req, res)) return;
++ const rows = await store.many('SELECT version, manifest, integrity, created_at FROM git_npm_versions WHERE owner = ? AND name = ? ORDER BY created_at', [owner, req.params.name]);
++ if (!rows.length) return bad(res, 404, 'package not found');
++ const tags = await store.many('SELECT tag, version FROM git_npm_tags WHERE owner = ? AND name = ?', [owner, req.params.name]);
++ const versions = {};
++ const time = {};
++ for (const row of rows) {
++ versions[row.version] = { ...manifestFor(row), name: req.params.name, version: row.version,
++ dist: { tarball: tarballUrl(req.params.name, row.version), integrity: row.integrity } };
++ time[row.version] = new Date(row.created_at).toISOString();
++ }
++ const distTags = Object.fromEntries(tags.map((x) => [x.tag, x.version]));
++ if (!distTags.latest) distTags.latest = rows.at(-1).version;
++ res.set('Cache-Control', 'public, max-age=60').json({ name: req.params.name, 'dist-tags': distTags, versions, time });
++ }));
++
++ r.get('/:owner/npm/:name/-/:file', route(async (req, res) => {
++ if (!named(req, res)) return;
++ const prefix = `${req.params.name}-`;
++ const file = req.params.file;
++ if (!file.startsWith(prefix) || !file.endsWith('.tgz')) return bad(res, 404, 'tarball not found');
++ const version = file.slice(prefix.length, -4);
++ if (!VERSION.test(version)) return bad(res, 404, 'tarball not found');
++ const row = await store.maybe('SELECT tarball, integrity FROM git_npm_versions WHERE owner = ? AND name = ? AND version = ?', [owner, req.params.name, version]);
++ if (!row) return bad(res, 404, 'tarball not found');
++ res.set('Cache-Control', 'public, max-age=31536000, immutable').set('Content-Type', 'application/octet-stream')
++ .set('ETag', `"${row.integrity}"`).send(Buffer.from(row.tarball));
++ }));
++
++ // npm publish sends a JSON packument with one version and one base64 attachment.
++ r.put('/:owner/npm/:name', express.json({ limit: '20mb' }), route(async (req, res) => {
++ if (!named(req, res)) return;
++ if (!ciAuth.ok(req)) return bad(res, 401, 'publisher credential required');
++ const body = req.body;
++ if (!body || typeof body !== 'object' || Array.isArray(body) || body.name !== req.params.name) return bad(res, 422, 'invalid package');
++ const entries = Object.entries(body.versions || {});
++ const attachments = Object.entries(body._attachments || {});
++ if (entries.length !== 1 || attachments.length !== 1) return bad(res, 422, 'publish one version and one tarball');
++ const [version, manifest] = entries[0];
++ if (!VERSION.test(version) || !manifest || manifest.name !== req.params.name || manifest.version !== version) return bad(res, 422, 'invalid version');
++ const [filename, attachment] = attachments[0];
++ if (filename !== `${req.params.name}-${version}.tgz` || !attachment || typeof attachment.data !== 'string' || !/^[A-Za-z0-9+/]*={0,2}$/.test(attachment.data)) return bad(res, 422, 'invalid tarball');
++ const tarball = Buffer.from(attachment.data, 'base64');
++ if (!tarball.length || tarball.length > MAX_TARBALL || tarball[0] !== 0x1f || tarball[1] !== 0x8b ||
++ tarball.toString('base64') !== attachment.data) return bad(res, 422, 'invalid tarball');
++ const integrity = `sha512-${crypto.createHash('sha512').update(tarball).digest('base64')}`;
++ const saved = { ...manifest };
++ delete saved._attachments;
++ saved.dist = { tarball: tarballUrl(req.params.name, version), integrity };
++ try {
++ await store.execute('INSERT INTO git_npm_versions (owner, name, version, manifest, tarball, integrity) VALUES (?, ?, ?, ?, ?, ?)',
++ [owner, req.params.name, version, JSON.stringify(saved), tarball, integrity]);
++ } catch (err) {
++ if (/duplicate key|unique/i.test(err.message)) return bad(res, 409, 'version already published');
++ throw err;
++ }
++ const desired = body['dist-tags'] || {};
++ const tags = Object.entries(desired).filter(([tag, v]) => TAG.test(tag) && v === version);
++ if (!tags.length) tags.push(['latest', version]);
++ for (const [tag] of tags) {
++ await store.execute('INSERT INTO git_npm_tags (owner, name, tag, version) VALUES (?, ?, ?, ?) ON CONFLICT (owner, name, tag) DO UPDATE SET version = EXCLUDED.version',
++ [owner, req.params.name, tag, version]);
++ }
++ log.info('[OpenVibe.Git] npm package published:', req.params.name, version);
++ res.status(201).json({ ok: true, name: req.params.name, version, integrity });
++ }));
++
++ return r;
++}
++
++module.exports = { createNpmRegistry, NAMES, MAX_TARBALL };
ddiff --git a/test/npm-registry.test.js b/test/npm-registry.test.js
nnew file mode 100644
iindex 0000000..15eb1a8
---- /dev/null
++++ b/test/npm-registry.test.js
@@@ -0,0 +1,42 @@
++'use strict';
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const { execFileSync } = require('node:child_process');
++const { execFile } = require('node:child_process');
++const { promisify } = require('node:util');
++const { boot } = require('./helpers/boot');
++
++(async () => {
++ const token = 'test-registry-publisher';
++ const t = await boot({ env: { GIT_CI_RUNNER_TOKEN: token } });
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-git-npm-'));
++ try {
++ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'openvibe-shared', version: '9.8.7', main: 'index.js' }));
++ fs.writeFileSync(path.join(dir, 'index.js'), 'module.exports = 42;\n');
++ execFileSync('npm', ['pack', '--ignore-scripts', '--silent'], { cwd: dir, env: { ...process.env, npm_config_cache: path.join(dir, 'cache') } });
++ const tarball = fs.readFileSync(path.join(dir, 'openvibe-shared-9.8.7.tgz'));
++ const root = '/api/packages/openvibers/npm/openvibe-shared';
++ const publish = { name: 'openvibe-shared', versions: { '9.8.7': { name: 'openvibe-shared', version: '9.8.7', main: 'index.js' } },
++ 'dist-tags': { latest: '9.8.7' }, _attachments: { 'openvibe-shared-9.8.7.tgz': { data: tarball.toString('base64') } } };
++ assert.equal((await t.get(root, { method: 'PUT', json: publish })).status, 401, 'publishing requires the runner credential');
++ assert.equal((await t.get(root, { method: 'PUT', json: publish, bearer: token })).status, 201);
++ assert.equal((await t.get(root, { method: 'PUT', json: publish, bearer: token })).status, 409, 'published versions are immutable');
++ const metadata = (await t.get(root)).json();
++ assert.equal(metadata['dist-tags'].latest, '9.8.7');
++ assert.match(metadata.versions['9.8.7'].dist.integrity, /^sha512-/);
++ assert.deepEqual((await t.get(`${root}/-/openvibe-shared-9.8.7.tgz`)).buffer, tarball);
++ assert.equal((await t.get('/api/packages/stranger/npm/openvibe-shared')).status, 404);
++ const install = path.join(dir, 'install');
++ fs.mkdirSync(install);
++ fs.writeFileSync(path.join(install, 'package.json'), JSON.stringify({ name: 'registry-spike-test', version: '1.0.0', private: true }));
++ await promisify(execFile)('npm', ['install', '[email protected]', '--ignore-scripts', '--no-audit', '--no-fund', '--registry', `${t.base}/api/packages/openvibers/npm/`],
++ { cwd: install, env: { ...process.env, npm_config_cache: path.join(dir, 'install-cache'), npm_config_userconfig: '/dev/null' }, timeout: 30000 });
++ assert.equal(require(path.join(install, 'node_modules/openvibe-shared')), 42);
++ console.log('npm registry: publish, immutable version, metadata, tarball and npm install passed');
++ } finally {
++ await t.close();
++ fs.rmSync(dir, { recursive: true, force: true });
++ }
++})().catch((err) => { console.error(err); process.exitCode = 1; });