// Hub 2.0 step 2: protocol v2 next to v1. The contract samples (@crosscode/contracts/samples) go // through the real install API with signed requests, and every v2 reply is checked against the // contract's response schema. In-memory database. const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const mockC1 = '0b8f3c2a-5d4e-4f6a-9b7c-1d2e3f4a5b6c'; const mockC2 = '1c9a4d3b-6e5f-4a7b-8c8d-2e3f4a5b6c7d'; const mockL_EVENTS = '2d0b5e4c-7f6a-4b8c-9d9e-3f4a5b6c7d8e'; const mockL_WEBSITE = '3e1c6f5d-8a7b-4c9d-8eaf-4a5b6c7d8e9f'; const mockL_OTHER = '4f2d7a6e-9b8c-4dae-9fb0-5b6c7d8e9fa0'; const mockDb = (() => { const t = { licences: [], installs: [], events: [], heartbeats: [], releases: [] }; const id = p => `${p}_${crypto.randomUUID()}`; const customers = { [mockC1]: { id: mockC1, name: 'Hope Family Church' }, [mockC2]: { id: mockC2, name: 'Grace Church' } }; const withLicence = l => l && { ...l, install: t.installs.find(i => i.licenceId === l.id) || null, customer: customers[l.customerId] }; return { t, licence: { findUnique: jest.fn(async ({ where }) => withLicence(t.licences.find(l => (where.key ? l.key === where.key : l.id === where.id)))), }, install: { findUnique: jest.fn(async ({ where }) => t.installs.find(i => i.id === where.id) || null), create: jest.fn(async ({ data }) => { const row = { id: crypto.randomUUID(), registeredAt: new Date(Date.now() + t.installs.length), releasedAt: null, ...data }; t.installs.push(row); return row; }), update: jest.fn(async ({ where, data }) => Object.assign(t.installs.find(i => i.id === where.id), Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined)))), findMany: jest.fn(async ({ where, select }) => t.installs .filter(i => i.customerId === where.customerId && i.id !== where.id.not && i.releasedAt === null) .filter(i => !where.publicKey || i.publicKey != null) .filter(i => !where.licence || t.licences.find(l => l.id === i.licenceId)?.status !== 'revoked') .sort((a, b) => a.registeredAt - b.registeredAt) .map(i => Object.fromEntries(Object.keys(select).map(k => [k, i[k]])))), }, installEvent: { create: jest.fn(async ({ data }) => { const row = { id: id('ev'), createdAt: new Date(), ...data }; t.events.push(row); return row; }), updateMany: jest.fn(async () => ({ count: 0 })), findFirst: jest.fn(async () => null), findMany: jest.fn(async () => []), }, heartbeat: { create: jest.fn(async ({ data }) => { t.heartbeats.push(data); return data; }) }, release: { findMany: jest.fn(async ({ where } = {}) => t.releases.filter(r => !where?.product || r.product === where.product)) }, customer: { findUnique: jest.fn(async ({ where }) => customers[where.id]) }, $transaction: jest.fn(async ops => Promise.all(ops)), }; })(); jest.mock('../src/config/db', () => mockDb); // Every test registers: the hourly register limit would cut in part-way through the file. jest.mock('express-rate-limit', () => () => (req, res, next) => next()); jest.mock('../src/utils/settings', () => ({ getSettings: jest.fn(async () => ({})), setSettings: jest.fn(async () => ({})) })); jest.mock('../src/utils/notices', () => ({ noticesForInstall: jest.fn(async () => []) })); process.env.HUB_ENCRYPTION_KEY = 'test-encryption-key-that-is-long-enough-123'; const hubKey = crypto.generateKeyPairSync('ed25519'); process.env.HUB_SIGNING_KEY = hubKey.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); const express = require('express'); const { hub } = require('@crosscode/contracts'); const licenceToken = require('../src/utils/licenceToken'); const { signature } = require('../src/utils/signing'); const SAMPLES = path.join(path.dirname(require.resolve('@crosscode/contracts/package.json')), 'samples'); const sample = name => JSON.parse(fs.readFileSync(path.join(SAMPLES, name), 'utf8')); const pem = () => crypto.generateKeyPairSync('ed25519').publicKey.export({ type: 'spki', format: 'pem' }).toString(); const KEYS = { events: 'CEH-7K2M-9QXA-P4RT-WD8C', website: 'CCW-AC3D-E4FG-H6JK-M7NP', events2: 'CEH-QRTU-VWXY-2346-79AC' }; let server; let base; beforeAll(async () => { const app = express(); app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf.toString('utf8'); } })); app.use('/api', require('../src/routes/installApi')); await new Promise(resolve => { server = app.listen(0, '127.0.0.1', resolve); }); base = `http://127.0.0.1:${server.address().port}/api`; }); afterAll(() => new Promise(resolve => server.close(resolve))); beforeEach(() => { const { t } = mockDb; t.installs.length = 0; t.events.length = 0; t.heartbeats.length = 0; t.releases.splice(0, t.releases.length, { product: 'events', version: '2.4.0', releasedAt: new Date('2026-11-20T08:00:00Z'), urgent: false, notes: '- Event feed', requires: [] }, { product: 'events', version: '3.0.0', releasedAt: new Date('2026-12-01T08:00:00Z'), urgent: false, notes: '- Sign in with your church account', requires: [{ product: 'accounts', version: '1.2.0' }] }, { product: 'website', version: '1.0.0', releasedAt: new Date('2026-11-25T08:00:00Z'), urgent: false, notes: '- First release', requires: [] }, { product: 'website', version: '1.1.0', releasedAt: new Date('2026-12-05T08:00:00Z'), urgent: false, notes: '- Sermon series', requires: [] }, ); t.licences.splice(0, t.licences.length, { id: mockL_EVENTS, customerId: mockC1, product: 'events', key: KEYS.events, status: 'active', plan: 'Grow', expiresAt: null, graceDays: 14, aiMonthlyCapCents: 0 }, { id: mockL_WEBSITE, customerId: mockC1, product: 'website', key: KEYS.website, status: 'active', plan: 'Website', expiresAt: null, graceDays: 14, aiMonthlyCapCents: 0 }, { id: mockL_OTHER, customerId: mockC2, product: 'events', key: KEYS.events2, status: 'active', plan: 'Seed', expiresAt: null, graceDays: 14, aiMonthlyCapCents: 0 }, ); }); const post = (url, body, headers = {}) => fetch(`${base}${url}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body) }); /** A signed request from an install (ยง3). */ function signed(url, reg, body) { const raw = JSON.stringify(body); const timestamp = new Date().toISOString(); return post(url, raw, { 'X-Hub-Install': reg.installId, 'X-Hub-Timestamp': timestamp, 'X-Hub-Signature': signature(reg.installSecret, timestamp, raw) }); } const eventsRegistration = () => sample('hub-register-request.json'); const websiteRegistration = () => { const r = sample('hub-register-request.json'); return { ...r, licenceKey: KEYS.website, product: 'website', install: { ...r.install, url: 'https://www.hopefamily.co.za', version: '1.0.0', features: {}, publicKey: pem() } }; }; async function register(body) { const res = await post('/installs/register', body); return { status: res.status, json: await res.json() }; } describe('register', () => { test('the v2 sample registers, and the reply fits the contract', async () => { const { status, json } = await register(eventsRegistration()); expect(status).toBe(201); expect(hub.registerResponseSchema.safeParse(json).error?.issues ?? []).toEqual([]); expect(json.linkedProducts).toEqual([]); expect(mockDb.t.installs[0]).toMatchObject({ product: 'events', protocolVersion: 2, publicKey: expect.stringContaining('BEGIN PUBLIC KEY') }); }); test('the licence token carries the customer and product, and fits the contract', async () => { const { json } = await register(eventsRegistration()); const payload = licenceToken.verify(json.licence, hubKey.publicKey); expect(hub.licencePayloadSchema.safeParse(payload).error?.issues ?? []).toEqual([]); expect(payload).toMatchObject({ customerId: mockC1, product: 'events', installId: json.installId }); }); test('a second product lists the first in linkedProducts (and nothing more)', async () => { const events = await register(eventsRegistration()); const { status, json } = await register(websiteRegistration()); expect(status).toBe(201); expect(json.linkedProducts).toEqual([{ product: 'events', installId: events.json.installId, url: 'https://events.hopefamily.co.za', publicKey: eventsRegistration().install.publicKey }]); }); test('another church\'s installs are never listed', async () => { await register({ ...eventsRegistration(), licenceKey: KEYS.events2, install: { ...eventsRegistration().install, url: 'https://events.grace.test' } }); const { json } = await register(eventsRegistration()); expect(json.linkedProducts).toEqual([]); }); test('a product that doesn\'t match the licence is refused', async () => { const { status, json } = await register({ ...websiteRegistration(), product: 'events' }); expect(status).toBe(409); expect(json.code).toBe('WRONG_PRODUCT'); }); test('a key typed in lower case with spaces still registers', async () => { const { status } = await register({ ...eventsRegistration(), licenceKey: ' ceh-7k2m-9qxa-p4rt-wd8c ' }); expect(status).toBe(201); }); test('a v2 body that breaks the contract is refused with what is wrong', async () => { const body = eventsRegistration(); delete body.install.publicKey; const { status, json } = await register(body); expect(status).toBe(400); expect(json).toMatchObject({ code: 'INVALID_REQUEST', message: expect.stringContaining('install.publicKey') }); }); test('v1 registers as before: Events, no linkedProducts, no public key', async () => { await register(websiteRegistration()); const r = eventsRegistration(); const { status, json } = await register({ protocolVersion: 1, licenceKey: r.licenceKey, install: { name: r.install.name, url: r.install.url, version: '2.1.1' } }); expect(status).toBe(201); expect(json).not.toHaveProperty('linkedProducts'); expect(mockDb.t.installs.find(i => i.product === 'events')).toMatchObject({ protocolVersion: 1, publicKey: undefined }); }); test('protocol 3 is refused', async () => { const { status, json } = await register({ ...eventsRegistration(), protocolVersion: 3 }); expect(status).toBe(400); expect(json.code).toBe('UNSUPPORTED_PROTOCOL'); }); }); describe('heartbeat', () => { test.each([ ['events', eventsRegistration, 'hub-heartbeat-events.json'], ['website', websiteRegistration, 'hub-heartbeat-website.json'], ])('the %s sample is recorded, and the reply fits the contract', async (product, registration, file) => { const { json: reg } = await register(registration()); const res = await signed('/installs/heartbeat', reg, sample(file)); const json = await res.json(); expect(res.status).toBe(200); expect(hub.heartbeatResponseSchema.safeParse(json).error?.issues ?? []).toEqual([]); expect(mockDb.t.installs.find(i => i.id === reg.installId).stats).toEqual(sample(file).stats); }); test('linkedProducts in the heartbeat reply', async () => { const { json: events } = await register(eventsRegistration()); const { json: website } = await register(websiteRegistration()); const json = await (await signed('/installs/heartbeat', events, sample('hub-heartbeat-events.json'))).json(); expect(json.linkedProducts.map(l => [l.product, l.installId])).toEqual([['website', website.installId]]); }); test('each product is told its own releases', async () => { const { json: website } = await register(websiteRegistration()); expect(website.latestVersion).toBe('1.1.0'); expect(website.releases.map(r => r.version)).toEqual(['1.1.0']); const { json: events } = await register(eventsRegistration()); expect(events.latestVersion).toBe('3.0.0'); expect(events.releases.map(r => r.version)).toEqual(['3.0.0', '2.4.0']); }); test('v2 replies carry requirements; v1 replies are as before', async () => { const { json: v2 } = await register(eventsRegistration()); expect(v2.releases[0].requires).toEqual([{ product: 'accounts', version: '1.2.0' }]); expect(v2.releases[1]).not.toHaveProperty('requires'); const r = eventsRegistration(); const { json: v1 } = await register({ protocolVersion: 1, licenceKey: r.licenceKey, install: { name: r.install.name, url: r.install.url, version: '2.1.1' } }); expect(v1.releases.every(x => !('requires' in x))).toBe(true); }); test('a heartbeat for another product is refused', async () => { const { json: reg } = await register(eventsRegistration()); const res = await signed('/installs/heartbeat', reg, sample('hub-heartbeat-website.json')); expect(res.status).toBe(409); expect((await res.json()).code).toBe('WRONG_PRODUCT'); }); test('a v2 heartbeat that breaks the contract is refused', async () => { const { json: reg } = await register(eventsRegistration()); const body = sample('hub-heartbeat-events.json'); delete body.health; const res = await signed('/installs/heartbeat', reg, body); expect(res.status).toBe(400); expect((await res.json()).code).toBe('INVALID_REQUEST'); }); test('a v1 heartbeat is answered as before (no linkedProducts)', async () => { const r = eventsRegistration(); const { json: reg } = await register({ protocolVersion: 1, licenceKey: r.licenceKey, install: { name: r.install.name, url: r.install.url, version: '2.1.1' } }); const res = await signed('/installs/heartbeat', reg, { protocolVersion: 1, version: '2.1.1', health: { db: true }, stats: { users: 3 } }); const json = await res.json(); expect(res.status).toBe(200); expect(Object.keys(json).sort()).toEqual(['latestVersion', 'licence', 'notices', 'releases', 'serverTime']); }); test('an unsigned heartbeat is refused', async () => { const { json: reg } = await register(eventsRegistration()); const res = await post('/installs/heartbeat', sample('hub-heartbeat-events.json'), { 'X-Hub-Install': reg.installId, 'X-Hub-Timestamp': new Date().toISOString(), 'X-Hub-Signature': 'nope' }); expect(res.status).toBe(401); }); }); describe('update requests (v2)', () => { test('a v2 request without its version is refused', async () => { const { json: reg } = await register(eventsRegistration()); const res = await signed('/installs/update-request', reg, { protocolVersion: 2 }); expect(res.status).toBe(400); expect((await res.json()).code).toBe('INVALID_REQUEST'); }); test('an update that needs Accounts first is refused until Accounts is new enough', async () => { const { json: reg } = await register(eventsRegistration()); let res = await signed('/installs/update-request', reg, { protocolVersion: 2, version: '2.3.0' }); let json = await res.json(); expect(res.status).toBe(409); expect(json).toMatchObject({ code: 'REQUIREMENT_NOT_MET', requirements: [{ product: 'accounts', version: '1.2.0', current: null }] }); expect(json.message).toContain('Accounts 1.2.0 or later must be installed first'); // The church's Accounts install, on 1.1.0: still refused, and the message says what it runs. mockDb.t.installs.push({ id: crypto.randomUUID(), customerId: mockC1, product: 'accounts', version: '1.1.0', releasedAt: null, registeredAt: new Date() }); json = await (await signed('/installs/update-request', reg, { protocolVersion: 2, version: '2.3.0' })).json(); expect(json.message).toContain('Accounts must be updated to 1.2.0 first (it runs 1.1.0)'); // On 1.2.0: the request goes through, and fits the contract. mockDb.t.installs.find(i => i.product === 'accounts').version = '1.2.0'; res = await signed('/installs/update-request', reg, { protocolVersion: 2, version: '2.3.0' }); json = await res.json(); expect(res.status).toBe(201); expect(json.version).toBe('3.0.0'); expect(hub.updateRequestResponseSchema.safeParse(json).error?.issues ?? []).toEqual([]); }); test('another church\'s Accounts doesn\'t count', async () => { mockDb.t.installs.push({ id: crypto.randomUUID(), customerId: mockC2, product: 'accounts', version: '9.0.0', releasedAt: null, registeredAt: new Date() }); const { json: reg } = await register(eventsRegistration()); const res = await signed('/installs/update-request', reg, { protocolVersion: 2, version: '2.3.0' }); expect(res.status).toBe(409); }); test('a product with no requirements updates as before', async () => { const { json: reg } = await register(websiteRegistration()); const res = await signed('/installs/update-request', reg, { protocolVersion: 2, version: '1.0.0' }); expect(res.status).toBe(201); }); });