@happier-dev/relay-server 0.1.2-preview.68.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ untrusted comment: minisign public key 91AE28177BF6E43C
2
+ RWQ85PZ7FyiukYbL3qv/bKnwgbT68wLVzotapeMFIb8n+c7pBQ7U8W2t
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from 'node:crypto';
3
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { homedir, platform, arch, tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+
8
+ import { resolveServerReleaseAssets } from '../src/releaseAssets.mjs';
9
+ import { lookupSha256 } from '../src/checksums.mjs';
10
+ import { verifyMinisign } from '../src/minisign.mjs';
11
+
12
+ const OWNER = 'happier-dev';
13
+ const REPO = 'happier';
14
+
15
+ function fail(msg) {
16
+ process.stderr.write(`[happier-server] ${msg}\n`);
17
+ process.exit(1);
18
+ }
19
+
20
+ function parseArgs(argv) {
21
+ const kv = new Map();
22
+ const positionals = [];
23
+ for (let i = 0; i < argv.length; i += 1) {
24
+ const a = argv[i];
25
+ if (a === '--') {
26
+ positionals.push(...argv.slice(i + 1));
27
+ break;
28
+ }
29
+ if (!a.startsWith('--')) {
30
+ positionals.push(a);
31
+ continue;
32
+ }
33
+ const v = argv[i + 1];
34
+ if (v && !v.startsWith('--')) {
35
+ kv.set(a, v);
36
+ i += 1;
37
+ } else {
38
+ kv.set(a, 'true');
39
+ }
40
+ }
41
+ return { kv, positionals };
42
+ }
43
+
44
+ function resolveTarget() {
45
+ const os = platform();
46
+ const cpu = arch();
47
+ // Server binaries are currently built for Linux only in CI.
48
+ if (os !== 'linux') {
49
+ fail(`Unsupported platform '${os}'. Server runner currently supports linux only.`);
50
+ }
51
+ if (cpu !== 'x64' && cpu !== 'arm64') {
52
+ fail(`Unsupported architecture '${cpu}'. Expected x64 or arm64.`);
53
+ }
54
+ return { os, arch: cpu };
55
+ }
56
+
57
+ function cacheRoot() {
58
+ const xdg = String(process.env.XDG_CACHE_HOME ?? '').trim();
59
+ if (xdg) return xdg;
60
+ return join(homedir(), '.cache');
61
+ }
62
+
63
+ async function fetchJson(url) {
64
+ const res = await fetch(url, {
65
+ headers: {
66
+ 'user-agent': 'happier-server-runner',
67
+ accept: 'application/vnd.github+json',
68
+ },
69
+ });
70
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
71
+ return res.json();
72
+ }
73
+
74
+ async function fetchText(url) {
75
+ const res = await fetch(url, { headers: { 'user-agent': 'happier-server-runner' } });
76
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
77
+ return res.text();
78
+ }
79
+
80
+ async function downloadFile(url, destPath) {
81
+ const res = await fetch(url, { headers: { 'user-agent': 'happier-server-runner' } });
82
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
83
+ const ab = await res.arrayBuffer();
84
+ await writeFile(destPath, Buffer.from(ab));
85
+ }
86
+
87
+ async function sha256File(path) {
88
+ const bytes = await readFile(path);
89
+ return createHash('sha256').update(bytes).digest('hex');
90
+ }
91
+
92
+ async function pathExists(p) {
93
+ try {
94
+ await stat(p);
95
+ return true;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ async function main() {
102
+ const { kv, positionals } = parseArgs(process.argv.slice(2));
103
+ const channel = String(kv.get('--channel') ?? '').trim() || 'stable';
104
+ if (channel !== 'stable' && channel !== 'preview') {
105
+ fail(`Invalid --channel '${channel}'. Expected stable|preview.`);
106
+ }
107
+ const tag = String(kv.get('--tag') ?? '').trim() || (channel === 'preview' ? 'server-preview' : 'server-stable');
108
+
109
+ const target = resolveTarget();
110
+
111
+ const releaseUrl = `https://api.github.com/repos/${OWNER}/${REPO}/releases/tags/${encodeURIComponent(tag)}`;
112
+ const release = await fetchJson(releaseUrl);
113
+ const assets = resolveServerReleaseAssets({ release, os: target.os, arch: target.arch });
114
+
115
+ const pubkeyPath = new URL('../assets/happier-release.pub', import.meta.url);
116
+ const pubkeyFile = await readFile(pubkeyPath, 'utf-8');
117
+
118
+ const tmp = join(tmpdir(), `happier-server-${process.pid}-${Date.now()}`);
119
+ await mkdir(tmp, { recursive: true });
120
+ try {
121
+ const checksumsPath = join(tmp, assets.checksums.name);
122
+ const checksumsSigPath = join(tmp, assets.checksumsSig.name);
123
+ await downloadFile(assets.checksums.url, checksumsPath);
124
+ await downloadFile(assets.checksumsSig.url, checksumsSigPath);
125
+
126
+ const checksumsText = await readFile(checksumsPath, 'utf-8');
127
+ const sigFile = await readFile(checksumsSigPath, 'utf-8');
128
+ const ok = verifyMinisign({ message: Buffer.from(checksumsText, 'utf-8'), pubkeyFile, sigFile });
129
+ if (!ok) fail('Signature verification failed for checksums file.');
130
+
131
+ const expected = lookupSha256({ checksumsText, filename: assets.tarball.name });
132
+
133
+ const cacheDir = join(cacheRoot(), 'happier', 'server', tag, assets.version, `${target.os}-${target.arch}`);
134
+ await mkdir(cacheDir, { recursive: true });
135
+ const artifactStem = `happier-server-v${assets.version}-${target.os}-${target.arch}`;
136
+ const serverDir = join(cacheDir, artifactStem);
137
+ const serverBin = join(serverDir, 'happier-server');
138
+
139
+ if (!(await pathExists(serverBin))) {
140
+ const tarballPath = join(tmp, assets.tarball.name);
141
+ await downloadFile(assets.tarball.url, tarballPath);
142
+ const actual = await sha256File(tarballPath);
143
+ if (actual !== expected) {
144
+ fail(`SHA256 mismatch for ${assets.tarball.name}: expected ${expected}, got ${actual}`);
145
+ }
146
+
147
+ // Extract archive into cache (archive root contains the artifactStem folder).
148
+ const extract = spawn('tar', ['-xzf', tarballPath, '-C', cacheDir], { stdio: 'inherit' });
149
+ await new Promise((resolve, reject) => {
150
+ extract.on('error', reject);
151
+ extract.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`tar exited with ${code}`))));
152
+ });
153
+ }
154
+
155
+ if (!(await pathExists(serverBin))) {
156
+ fail(`Extracted server binary not found at ${serverBin}`);
157
+ }
158
+
159
+ const child = spawn(serverBin, positionals, { stdio: 'inherit' });
160
+ child.on('exit', (code, signal) => {
161
+ if (signal) process.kill(process.pid, signal);
162
+ process.exit(code ?? 1);
163
+ });
164
+ } finally {
165
+ await rm(tmp, { recursive: true, force: true });
166
+ }
167
+ }
168
+
169
+ main().catch((err) => {
170
+ fail(err instanceof Error ? err.message : String(err));
171
+ });
172
+
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@happier-dev/relay-server",
3
+ "version": "0.1.2-preview.68.1",
4
+ "description": "Happier server runner (downloads and verifies the correct server binary for your platform).",
5
+ "repository": "https://github.com/happier-dev/happier.git",
6
+ "author": "Leeroy Brun <leeroy.brun@gmail.com>",
7
+ "license": "MIT",
8
+ "type": "module",
9
+ "bin": {
10
+ "relay-server": "bin/happier-server.mjs",
11
+ "happier-server": "bin/happier-server.mjs"
12
+ },
13
+ "files": [
14
+ "assets",
15
+ "bin",
16
+ "src"
17
+ ],
18
+ "engines": {
19
+ "node": ">=22"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "provenance": true
24
+ },
25
+ "scripts": {
26
+ "test": "node --test"
27
+ }
28
+ }
@@ -0,0 +1,17 @@
1
+ export function lookupSha256({ checksumsText, filename }) {
2
+ const target = String(filename ?? '').trim();
3
+ if (!target) throw new Error('[checksums] filename is required');
4
+
5
+ const text = String(checksumsText ?? '');
6
+ const lines = text.split('\n');
7
+ for (const line of lines) {
8
+ const trimmed = line.trim();
9
+ if (!trimmed) continue;
10
+ const m = /^([0-9a-fA-F]{8,})\s+(.+)$/.exec(trimmed);
11
+ if (!m) continue;
12
+ const hash = m[1].toLowerCase();
13
+ const file = m[2].trim();
14
+ if (file === target) return hash;
15
+ }
16
+ throw new Error(`[checksums] sha256 not found for ${target}`);
17
+ }
@@ -0,0 +1,18 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { lookupSha256 } from './checksums.mjs';
5
+
6
+ test('lookupSha256 returns sha256 for matching filename', () => {
7
+ const text = [
8
+ 'aaaabbbb file-one.tar.gz',
9
+ '1234abcd happier-server-v0.1.0-linux-x64.tar.gz',
10
+ '',
11
+ ].join('\n');
12
+ assert.equal(lookupSha256({ checksumsText: text, filename: 'happier-server-v0.1.0-linux-x64.tar.gz' }), '1234abcd');
13
+ });
14
+
15
+ test('lookupSha256 throws when filename not present', () => {
16
+ assert.throws(() => lookupSha256({ checksumsText: 'aaaa other', filename: 'missing' }));
17
+ });
18
+
@@ -0,0 +1,94 @@
1
+ import { createHash, createPublicKey, verify } from 'node:crypto';
2
+
3
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
4
+
5
+ function decodeBase64Line(line, expectedBytes) {
6
+ const bytes = Buffer.from(String(line ?? '').trim(), 'base64');
7
+ if (expectedBytes != null && bytes.length !== expectedBytes) {
8
+ throw new Error(`[minisign] expected ${expectedBytes} bytes, got ${bytes.length}`);
9
+ }
10
+ return bytes;
11
+ }
12
+
13
+ function parseMinisignPublicKeyFile(pubkeyFile) {
14
+ const lines = String(pubkeyFile ?? '')
15
+ .split('\n')
16
+ .map((l) => l.trim())
17
+ .filter(Boolean);
18
+ if (lines.length < 2) throw new Error('[minisign] invalid public key file');
19
+ const payload = lines.at(-1);
20
+ const bytes = decodeBase64Line(payload, 42);
21
+ const signatureAlgorithm = bytes.subarray(0, 2);
22
+ const keyId = bytes.subarray(2, 10);
23
+ const rawPublicKey = bytes.subarray(10, 42);
24
+ return { signatureAlgorithm, keyId, rawPublicKey };
25
+ }
26
+
27
+ function parseMinisignSignatureFile(sigFile) {
28
+ const lines = String(sigFile ?? '').split('\n');
29
+ if (lines.length < 4) throw new Error('[minisign] invalid signature file');
30
+
31
+ const untrustedPayload = String(lines[1] ?? '').trim();
32
+ const trustedComment = String(lines[2] ?? '');
33
+ const globalPayload = String(lines[3] ?? '').trim();
34
+
35
+ const untrustedBytes = decodeBase64Line(untrustedPayload, 74);
36
+ const signatureAlgorithm = untrustedBytes.subarray(0, 2);
37
+ const keyId = untrustedBytes.subarray(2, 10);
38
+ const signature = untrustedBytes.subarray(10, 74);
39
+
40
+ const globalSignature = decodeBase64Line(globalPayload, 64);
41
+
42
+ if (!trustedComment.startsWith('trusted comment: ')) {
43
+ throw new Error('[minisign] unexpected trusted comment format');
44
+ }
45
+ const trustedSuffix = Buffer.from(trustedComment.slice('trusted comment: '.length), 'utf-8');
46
+
47
+ return { signatureAlgorithm, keyId, signature, trustedSuffix, globalSignature };
48
+ }
49
+
50
+ function createEd25519PublicKey(rawPublicKey) {
51
+ if (!Buffer.isBuffer(rawPublicKey) || rawPublicKey.length !== 32) {
52
+ throw new Error('[minisign] invalid Ed25519 public key length');
53
+ }
54
+ const spki = Buffer.concat([ED25519_SPKI_PREFIX, rawPublicKey]);
55
+ return createPublicKey({ key: spki, format: 'der', type: 'spki' });
56
+ }
57
+
58
+ function bytesEqual(a, b) {
59
+ if (!a || !b) return false;
60
+ if (a.length !== b.length) return false;
61
+ // constant-time compare not required here (public data), but keep it simple.
62
+ return Buffer.compare(a, b) === 0;
63
+ }
64
+
65
+ export function verifyMinisign({ message, pubkeyFile, sigFile }) {
66
+ const bin = Buffer.isBuffer(message) ? message : Buffer.from(message ?? '');
67
+ const pubkey = parseMinisignPublicKeyFile(pubkeyFile);
68
+ const sig = parseMinisignSignatureFile(sigFile);
69
+
70
+ if (!bytesEqual(pubkey.signatureAlgorithm, Buffer.from('Ed'))) {
71
+ throw new Error('[minisign] incompatible public key signature algorithm');
72
+ }
73
+ if (!bytesEqual(pubkey.keyId, sig.keyId)) {
74
+ throw new Error('[minisign] incompatible key identifiers');
75
+ }
76
+
77
+ let prehashed = false;
78
+ if (bytesEqual(sig.signatureAlgorithm, Buffer.from('Ed'))) {
79
+ prehashed = false;
80
+ } else if (bytesEqual(sig.signatureAlgorithm, Buffer.from('ED'))) {
81
+ prehashed = true;
82
+ } else {
83
+ throw new Error('[minisign] unsupported signature algorithm');
84
+ }
85
+
86
+ const publicKey = createEd25519PublicKey(pubkey.rawPublicKey);
87
+ const payload = prehashed ? createHash('blake2b512').update(bin).digest() : bin;
88
+
89
+ const okSig = verify(null, payload, publicKey, sig.signature);
90
+ if (!okSig) return false;
91
+
92
+ const okGlobal = verify(null, Buffer.concat([sig.signature, sig.trustedSuffix]), publicKey, sig.globalSignature);
93
+ return okGlobal;
94
+ }
@@ -0,0 +1,75 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { generateKeyPairSync, sign } from 'node:crypto';
4
+
5
+ import { verifyMinisign } from './minisign.mjs';
6
+
7
+ function b64(buf) {
8
+ return Buffer.from(buf).toString('base64');
9
+ }
10
+
11
+ function base64UrlToBuffer(value) {
12
+ const s = String(value ?? '')
13
+ .replace(/-/g, '+')
14
+ .replace(/_/g, '/')
15
+ .padEnd(Math.ceil(String(value ?? '').length / 4) * 4, '=');
16
+ return Buffer.from(s, 'base64');
17
+ }
18
+
19
+ test('verifyMinisign validates Ed25519 minisign signatures (Ed)', () => {
20
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
21
+ const jwk = publicKey.export({ format: 'jwk' });
22
+ const rawPublicKey = base64UrlToBuffer(jwk.x);
23
+ assert.equal(rawPublicKey.length, 32);
24
+
25
+ const keyId = Buffer.from('0123456789abcdef', 'hex'); // 8 bytes
26
+ const publicKeyBytes = Buffer.concat([Buffer.from('Ed'), keyId, rawPublicKey]);
27
+
28
+ const pubkeyFile = `untrusted comment: minisign public key\n${b64(publicKeyBytes)}\n`;
29
+
30
+ const message = Buffer.from('hello from happier', 'utf-8');
31
+ const signature = sign(null, message, privateKey);
32
+ assert.equal(signature.length, 64);
33
+
34
+ const sigLineBytes = Buffer.concat([Buffer.from('Ed'), keyId, signature]);
35
+ const trustedComment = 'trusted comment: test';
36
+ const trustedSuffix = Buffer.from(trustedComment.slice('trusted comment: '.length), 'utf-8');
37
+ const globalSignature = sign(null, Buffer.concat([signature, trustedSuffix]), privateKey);
38
+ assert.equal(globalSignature.length, 64);
39
+
40
+ const sigFile = [
41
+ 'untrusted comment: signature from happier test',
42
+ b64(sigLineBytes),
43
+ trustedComment,
44
+ b64(globalSignature),
45
+ '',
46
+ ].join('\n');
47
+
48
+ assert.equal(verifyMinisign({ message, pubkeyFile, sigFile }), true);
49
+ });
50
+
51
+ test('verifyMinisign rejects invalid signatures', () => {
52
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
53
+ const jwk = publicKey.export({ format: 'jwk' });
54
+ const rawPublicKey = base64UrlToBuffer(jwk.x);
55
+ const keyId = Buffer.from('0123456789abcdef', 'hex');
56
+ const publicKeyBytes = Buffer.concat([Buffer.from('Ed'), keyId, rawPublicKey]);
57
+ const pubkeyFile = `untrusted comment: minisign public key\n${b64(publicKeyBytes)}\n`;
58
+
59
+ const message = Buffer.from('hello', 'utf-8');
60
+ const signature = sign(null, message, privateKey);
61
+ const sigLineBytes = Buffer.concat([Buffer.from('Ed'), keyId, signature]);
62
+ const trustedComment = 'trusted comment: test';
63
+ const trustedSuffix = Buffer.from(trustedComment.slice('trusted comment: '.length), 'utf-8');
64
+ const globalSignature = sign(null, Buffer.concat([signature, trustedSuffix]), privateKey);
65
+ const sigFile = [
66
+ 'untrusted comment: sig',
67
+ b64(sigLineBytes),
68
+ trustedComment,
69
+ b64(globalSignature),
70
+ '',
71
+ ].join('\n');
72
+
73
+ assert.equal(verifyMinisign({ message: Buffer.from('tampered', 'utf-8'), pubkeyFile, sigFile }), false);
74
+ });
75
+
@@ -0,0 +1,33 @@
1
+ export function resolveServerReleaseAssets({ release, os, arch }) {
2
+ const assets = Array.isArray(release?.assets) ? release.assets : [];
3
+ const byName = new Map();
4
+ for (const asset of assets) {
5
+ const name = String(asset?.name ?? '');
6
+ const url = String(asset?.browser_download_url ?? '');
7
+ if (!name || !url) continue;
8
+ byName.set(name, { name, url });
9
+ }
10
+
11
+ const checksumsName = [...byName.keys()].find((name) => /^checksums-happier-server-v\d+\.\d+\.\d+\.txt$/.test(name));
12
+ if (!checksumsName) {
13
+ throw new Error('[server] missing checksums-happier-server-v<version>.txt asset');
14
+ }
15
+ const versionMatch = /^checksums-happier-server-v(\d+\.\d+\.\d+)\.txt$/.exec(checksumsName);
16
+ const version = versionMatch?.[1] ?? null;
17
+ if (!version) {
18
+ throw new Error('[server] unable to derive server version from checksums filename');
19
+ }
20
+
21
+ const checksumsSigName = `${checksumsName}.minisig`;
22
+ const tarballName = `happier-server-v${version}-${os}-${arch}.tar.gz`;
23
+
24
+ const checksums = byName.get(checksumsName) ?? null;
25
+ const checksumsSig = byName.get(checksumsSigName) ?? null;
26
+ const tarball = byName.get(tarballName) ?? null;
27
+
28
+ if (!checksums) throw new Error(`[server] missing release asset: ${checksumsName}`);
29
+ if (!checksumsSig) throw new Error(`[server] missing release asset: ${checksumsSigName}`);
30
+ if (!tarball) throw new Error(`[server] missing release asset: ${tarballName}`);
31
+
32
+ return { version, tarball, checksums, checksumsSig };
33
+ }
@@ -0,0 +1,28 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { resolveServerReleaseAssets } from './releaseAssets.mjs';
5
+
6
+ test('resolveServerReleaseAssets picks tarball + checksums + minisig for linux-x64', () => {
7
+ const release = {
8
+ tag_name: 'server-preview',
9
+ assets: [
10
+ { name: 'checksums-happier-server-v0.1.0.txt', browser_download_url: 'https://example/checksums.txt' },
11
+ { name: 'checksums-happier-server-v0.1.0.txt.minisig', browser_download_url: 'https://example/checksums.txt.minisig' },
12
+ { name: 'happier-server-v0.1.0-linux-x64.tar.gz', browser_download_url: 'https://example/server.tgz' },
13
+ { name: 'happier-server-v0.1.0-linux-arm64.tar.gz', browser_download_url: 'https://example/server-arm.tgz' },
14
+ ],
15
+ };
16
+
17
+ const resolved = resolveServerReleaseAssets({ release, os: 'linux', arch: 'x64' });
18
+ assert.equal(resolved.version, '0.1.0');
19
+ assert.equal(resolved.tarball.name, 'happier-server-v0.1.0-linux-x64.tar.gz');
20
+ assert.equal(resolved.checksums.name, 'checksums-happier-server-v0.1.0.txt');
21
+ assert.equal(resolved.checksumsSig.name, 'checksums-happier-server-v0.1.0.txt.minisig');
22
+ });
23
+
24
+ test('resolveServerReleaseAssets throws when required assets are missing', () => {
25
+ const release = { tag_name: 'server-preview', assets: [{ name: 'nope', browser_download_url: 'x' }] };
26
+ assert.throws(() => resolveServerReleaseAssets({ release, os: 'linux', arch: 'x64' }));
27
+ });
28
+