feat: @crudy/pgmorbac ships the SQL of each signed pgmorbac release

prepack downloads the release whose version matches package.json, verifies the
Ed25519 manifest against the pinned release key and the zip against its sha256,
and only then extracts the control file and scripts into sql/. No SQL is
committed here.
This commit is contained in:
2026-10-03 09:59:59 +02:00
commit 05e5722a0e
11 changed files with 332 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
sql/
package-lock.json
+11
View File
@@ -0,0 +1,11 @@
# Changelog
The package version is the pgmorbac extension version it ships. Extension
changes are in the [pgmorbac changelog](https://github.com/crudylabs/pgmorbac/blob/master/CHANGELOG.md).
## [1.0.0] - 2026-10-03
### Added
- The install and upgrade scripts of the signed pgmorbac 1.0.0 release, verified
against the pinned release key when the package is built.
- `extensionSql()`, `upgradeScripts()`, `version`, `sqlDir` and `controlFile`.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 pgmorbac contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+48
View File
@@ -0,0 +1,48 @@
# @crudy/pgmorbac
The [pgmorbac](https://pgmorbac.crudy.fr) PostgreSQL extension (Multi-OrBAC access
control) for Node.js projects that apply SQL through a migration runner instead of
`CREATE EXTENSION`.
```bash
npm install @crudy/pgmorbac
```
```js
import { extensionSql, upgradeScripts, version } from '@crudy/pgmorbac';
await client.query(extensionSql()); // pgmorbac--<version>.sql
for (const u of upgradeScripts()) { // pgmorbac--<from>--<to>.sql, in order
await client.query(u.sql);
}
```
| Export | |
|---|---|
| `extensionSql()` | the install script, as `CREATE EXTENSION` would run it |
| `upgradeScripts()` | `{ from, to, filename, sql }` per upgrade script, in version order |
| `version` | the extension version |
| `sqlDir`, `controlFile` | paths to the shipped files, for tools that read them directly |
For Fastify applications, [`@crudy/pgmorbac-fastify`](https://www.npmjs.com/package/@crudy/pgmorbac-fastify)
adds permission checks, the RLS session context and management routes.
## Where the SQL comes from
The package version is the extension version. Building the package downloads
that version's release from
[crudy/pgmorbac](https://git.villains.fr/crudy/pgmorbac/releases), checks the
Ed25519-signed manifest against the pinned `release-key.pub.pem` and the zip
against the manifest's sha256, and only then extracts the SQL. The extension
source lives in [pgmorbac](https://github.com/crudylabs/pgmorbac); this repository
holds no SQL.
## Releasing
After pgmorbac `vX.Y.Z` is released on Gitea, set `version` to `X.Y.Z` here, then
`npm publish`. To build from a local release (the extension's `dist/`), set
`PGMORBAC_DIST_DIR`; it is verified the same way.
## License
MIT, like the extension. See [LICENSE](LICENSE).
Vendored
+16
View File
@@ -0,0 +1,16 @@
/** Absolute path to the directory holding pgmorbac.control and the SQL scripts. */
export declare const sqlDir: string;
/** Absolute path to pgmorbac.control. */
export declare const controlFile: string;
/** The extension version (default_version in pgmorbac.control). */
export declare const version: string;
/** The install script pgmorbac--<version>.sql, as CREATE EXTENSION would run it. */
export declare function extensionSql(): string;
export interface UpgradeScript {
from: string;
to: string;
filename: string;
sql: string;
}
/** Upgrade scripts pgmorbac--<from>--<to>.sql, in version order. */
export declare function upgradeScripts(): UpgradeScript[];
+30
View File
@@ -0,0 +1,30 @@
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
export const sqlDir = join(dirname(fileURLToPath(import.meta.url)), 'sql');
export const controlFile = join(sqlDir, 'pgmorbac.control');
const control = readFileSync(controlFile, 'utf8').match(/default_version\s*=\s*'([^']+)'/);
if (!control) throw new Error('[pgmorbac] pgmorbac.control has no default_version');
export const version = control[1];
/** The install script pgmorbac--<version>.sql, as CREATE EXTENSION would run it. */
export function extensionSql() {
return readFileSync(join(sqlDir, `pgmorbac--${version}.sql`), 'utf8');
}
const semver = (v) => v.split('.').map(Number);
const byFrom = (a, b) => {
const [x, y] = [semver(a.from), semver(b.from)];
return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
};
/** Upgrade scripts pgmorbac--<from>--<to>.sql, in version order. */
export function upgradeScripts() {
return readdirSync(sqlDir)
.map((filename) => ({ filename, m: /^pgmorbac--(\d+\.\d+\.\d+)--(\d+\.\d+\.\d+)\.sql$/.exec(filename) }))
.filter(({ m }) => m)
.map(({ filename, m }) => ({ from: m[1], to: m[2], filename, sql: readFileSync(join(sqlDir, filename), 'utf8') }))
.sort(byFrom);
}
+58
View File
@@ -0,0 +1,58 @@
{
"name": "@crudy/pgmorbac",
"version": "1.0.0",
"type": "module",
"description": "Multi-OrBAC for PostgreSQL: the pgmorbac extension SQL from each signed release, for Node.js projects and migration runners",
"keywords": [
"pgmorbac",
"morbac",
"orbac",
"rbac",
"authorization",
"access-control",
"postgresql",
"postgresql-extension",
"row-level-security",
"multi-tenant"
],
"author": "Marc Villain",
"license": "MIT",
"homepage": "https://pgmorbac.crudy.fr",
"repository": {
"type": "git",
"url": "git+https://github.com/crudylabs/pgmorbac-node.git"
},
"bugs": {
"url": "https://github.com/crudylabs/pgmorbac-node/issues"
},
"exports": {
".": {
"types": "./index.d.ts",
"default": "./index.js"
}
},
"main": "./index.js",
"types": "./index.d.ts",
"files": [
"index.js",
"index.d.ts",
"sql",
"README.md",
"CHANGELOG.md",
"LICENSE"
],
"engines": {
"node": ">=18"
},
"scripts": {
"prepack": "node scripts/fetch-release.mjs",
"test": "node --test test/*.test.mjs"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"fflate": "^0.8.2",
"jose": "^5"
}
}
+3
View File
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAO9kbnYgjWfgElzh3xDANSgwg/eKd5SfiNN9K9BPxEv0=
-----END PUBLIC KEY-----
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
// prepack: fills sql/ from the signed pgmorbac release whose version matches
// package.json. The release comes from the crudy/pgmorbac Gitea releases, or
// from a local directory of release files (PGMORBAC_DIST_DIR, the dist/ that
// the extension's build-dist.mjs writes). Either way it must verify against the
// pinned release-key.pub.pem before anything is written.
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { extractSql, releaseFiles, verifyRelease } from './release.mjs';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const RELEASES = 'https://git.villains.fr/crudy/pgmorbac/releases/download';
const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
const files = releaseFiles(version);
async function load(name) {
const dir = process.env.PGMORBAC_DIST_DIR;
if (dir) return readFileSync(join(dir, name));
const res = await fetch(`${RELEASES}/v${version}/${name}`);
if (!res.ok) throw new Error(`${name}: release v${version} answered ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
const zip = await load(files.zip);
const manifestJwt = (await load(files.manifest)).toString('utf8');
await verifyRelease({ zip, manifestJwt, publicKeyPem: readFileSync(join(root, 'release-key.pub.pem'), 'utf8'), version });
const sqlDir = join(root, 'sql');
rmSync(sqlDir, { recursive: true, force: true });
mkdirSync(sqlDir);
for (const [name, bytes] of Object.entries(extractSql(zip, version))) writeFileSync(join(sqlDir, name), bytes);
console.log(`[pgmorbac] sql/ filled from the verified v${version} release`);
+47
View File
@@ -0,0 +1,47 @@
// Verifies a signed pgmorbac release and extracts its SQL. Pure functions, so
// the checks are tested without a network or the real signing key.
import { createHash } from 'node:crypto';
import { importSPKI, jwtVerify } from 'jose';
import { unzipSync } from 'fflate';
export const AUDIENCE = 'pgmorbac-release';
export function releaseFiles(version) {
const stem = `pgmorbac-${version}`;
return { zip: `${stem}.zip`, manifest: `${stem}.manifest.jwt` };
}
/**
* Throws unless the manifest is signed by publicKeyPem for this audience and
* version, and names the zip with its exact sha256.
*/
export async function verifyRelease({ zip, manifestJwt, publicKeyPem, version }) {
const key = await importSPKI(publicKeyPem.trim(), 'EdDSA');
const { payload } = await jwtVerify(manifestJwt.trim(), key, { algorithms: ['EdDSA'], audience: AUDIENCE });
const manifest = payload.manifest;
if (manifest?.name !== 'pgmorbac' || manifest.version !== version) {
throw new Error(`manifest is for ${manifest?.name}@${manifest?.version}, expected pgmorbac@${version}`);
}
const expected = manifest.artifacts?.find((a) => a.name === releaseFiles(version).zip)?.sha256;
const actual = createHash('sha256').update(zip).digest('hex');
if (!expected || expected !== actual) throw new Error(`zip sha256 ${actual} does not match the signed manifest`);
}
/**
* The control file, install script and upgrade scripts of a verified release
* zip, as { name: bytes }.
*/
export function extractSql(zip, version) {
const prefix = `pgmorbac-${version}/`;
const wanted = new RegExp(`^(pgmorbac\\.control|pgmorbac--${version.replace(/\./g, '\\.')}\\.sql|pgmorbac--\\d+\\.\\d+\\.\\d+--\\d+\\.\\d+\\.\\d+\\.sql)$`);
const out = {};
for (const [path, bytes] of Object.entries(unzipSync(zip))) {
if (!path.startsWith(prefix)) continue;
const name = path.slice(prefix.length);
if (wanted.test(name)) out[name] = bytes;
}
if (!out['pgmorbac.control'] || !out[`pgmorbac--${version}.sql`]) {
throw new Error(`release zip lacks pgmorbac.control or pgmorbac--${version}.sql`);
}
return out;
}
+61
View File
@@ -0,0 +1,61 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash, generateKeyPairSync } from 'node:crypto';
import { SignJWT, importPKCS8 } from 'jose';
import { strToU8, zipSync } from 'fflate';
import { AUDIENCE, extractSql, verifyRelease } from '../scripts/release.mjs';
const version = '1.2.3';
const zip = Buffer.from(zipSync({
'pgmorbac-1.2.3/pgmorbac.control': strToU8("default_version = '1.2.3'\n"),
'pgmorbac-1.2.3/pgmorbac--1.2.3.sql': strToU8('CREATE SCHEMA morbac;\n'),
'pgmorbac-1.2.3/pgmorbac--1.2.2--1.2.3.sql': strToU8('SELECT 1;\n'),
'pgmorbac-1.2.3/Makefile': strToU8('EXTENSION = pgmorbac\n'),
}));
function keys() {
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
return {
privatePem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
};
}
async function sign(privatePem, manifest, audience = AUDIENCE) {
return new SignJWT({ manifest })
.setProtectedHeader({ alg: 'EdDSA', typ: 'JWT' })
.setAudience(audience)
.sign(await importPKCS8(privatePem, 'EdDSA'));
}
const sha = (b) => createHash('sha256').update(b).digest('hex');
const good = (z = zip, v = version) => ({ name: 'pgmorbac', version: v, artifacts: [{ name: `pgmorbac-${v}.zip`, sha256: sha(z) }] });
test('a release signed by the pinned key verifies and yields its SQL', async () => {
const k = keys();
await verifyRelease({ zip, manifestJwt: await sign(k.privatePem, good()), publicKeyPem: k.publicKeyPem, version });
assert.deepEqual(Object.keys(extractSql(zip, version)).sort(), ['pgmorbac--1.2.2--1.2.3.sql', 'pgmorbac--1.2.3.sql', 'pgmorbac.control']);
});
test('another key is refused', async () => {
const signer = keys();
const pinned = keys();
await assert.rejects(verifyRelease({ zip, manifestJwt: await sign(signer.privatePem, good()), publicKeyPem: pinned.publicKeyPem, version }));
});
test('a zip that is not the signed one is refused', async () => {
const k = keys();
const tampered = Buffer.from(zipSync({ 'pgmorbac-1.2.3/pgmorbac--1.2.3.sql': strToU8('DROP SCHEMA public;\n') }));
await assert.rejects(verifyRelease({ zip: tampered, manifestJwt: await sign(k.privatePem, good()), publicKeyPem: k.publicKeyPem, version }), /sha256/);
});
test('a manifest for another version or audience is refused', async () => {
const k = keys();
await assert.rejects(verifyRelease({ zip, manifestJwt: await sign(k.privatePem, good(zip, '1.2.4')), publicKeyPem: k.publicKeyPem, version }), /expected pgmorbac@1.2.3/);
await assert.rejects(verifyRelease({ zip, manifestJwt: await sign(k.privatePem, good(), 'other'), publicKeyPem: k.publicKeyPem, version }));
});
test('a zip without the install script is refused', () => {
const empty = Buffer.from(zipSync({ 'pgmorbac-1.2.3/README': strToU8('x') }));
assert.throws(() => extractSql(empty, version), /lacks/);
});