diff --git a/.gitea/workflows/sites.yaml b/.gitea/workflows/sites.yaml index 6298275..4a09ec5 100644 --- a/.gitea/workflows/sites.yaml +++ b/.gitea/workflows/sites.yaml @@ -113,6 +113,12 @@ jobs: - name: Build the browser-hosted site run: | + # The published sites ship a working licence so they are usable beyond + # the free tier. It is minted per deploy rather than committed: the + # pair is self-contained, and the CI estate stays unlicensed so the + # specs can exercise the free tier. + node sas/mocks/mint-licence.js "sasjs_root/drive/files$APP_LOC/mock-storage/licence.json" + # DC's data files exist only after makedata has run, so the drive is # captured first and shipped as the site's starting drive. services/web9 # is the streamed frontend, which is already the site itself. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e9c859a..fff3541 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -135,7 +135,7 @@ After changing mock services, re-run `sasjs cbd -t server-ci` from `sas/mocks/sa ### Demo and staging sites -A push to `release/**` publishes [staging.datacontroller.io](https://staging.datacontroller.io); a release tag (`v*`, created by the release job after its tests pass) publishes [demo.datacontroller.io](https://demo.datacontroller.io) - workflow file `.gitea/workflows/sites.yaml`, using the `DEMO_DATACONTROLLER_IO` Surfer app password (user `allan`). Both are sites on one multi-site Surfer instance; the demo is its default site. The job builds the mocks as a browser-hosted site (SASjs CLI >= 6.1, `streamConfig.mockWeb`), so the services run in a service worker and the drive lives in the visitor's IndexedDB - there is no server. It runs `makedata` on a throwaway SASjs Server first and ships that drive as `sasjsbuild/mock-snapshot.json`; without it the site opens with no data. The estate carries no licence key, so it runs on the free tier until a key is registered through the licensing screen (`admin/registerkey`); the Cypress setup does that for the specs that need one. +A push to `release/**` publishes [staging.datacontroller.io](https://staging.datacontroller.io); a release tag (`v*`, created by the release job after its tests pass) publishes [demo.datacontroller.io](https://demo.datacontroller.io) - workflow file `.gitea/workflows/sites.yaml`, using the `DEMO_DATACONTROLLER_IO` Surfer app password (user `allan`). Both are sites on one multi-site Surfer instance; the demo is its default site. The job builds the mocks as a browser-hosted site (SASjs CLI >= 6.1, `streamConfig.mockWeb`), so the services run in a service worker and the drive lives in the visitor's IndexedDB - there is no server. It runs `makedata` on a throwaway SASjs Server first and ships that drive as `sasjsbuild/mock-snapshot.json`; without it the site opens with no data. The estate carries no licence key, so CI runs on the free tier until a key is registered through the licensing screen (`admin/registerkey`) - the Cypress setup does that for the specs that need one. The Sites job mints a licence for the published sites, so demo and staging are fully usable. ## GUI Elements diff --git a/sas/mocks/mint-licence.js b/sas/mocks/mint-licence.js new file mode 100644 index 0000000..82c854e --- /dev/null +++ b/sas/mocks/mint-licence.js @@ -0,0 +1,92 @@ +// Mints a Data Controller licence key pair for a published mock site. +// +// The app decrypts `licenceKey` with `activationKey`: +// activationKey = base64(pkcs8 RSA-OAEP private key) +// licenceKey = base64(RSA-OAEP-SHA256(JSON licence data, public key)) +// +// The two values must differ: that is how the app tells an HTTPS-format key +// from an HTTP-format one (see detectLicenceKeyProtocolMismatch.ts). The +// payload's site_id must match the estate's SYSSITE, which the mock reports +// as 123. +// +// The Sites workflow runs this against the drive before the snapshot is taken, +// so demo.datacontroller.io and staging.datacontroller.io are licensed. The +// pair is minted per deploy rather than committed: it is self-contained, and +// the CI estate stays unlicensed so the specs can exercise the free tier. +// +// Usage: node sas/mocks/mint-licence.js +const fs = require('fs') + +const SITE_ID = '123' // the mock estate's SYSSITE +const OUT = process.argv[2] + +const b64 = (buf) => Buffer.from(buf).toString('base64') + +async function main() { + if (!OUT) { + console.error('Usage: node sas/mocks/mint-licence.js ') + process.exit(1) + } + + const keyPair = await crypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256' + }, + true, + ['encrypt', 'decrypt'] + ) + + const licenceData = { + valid_until: '2099-12-31', + users_allowed: 9999, + hot_license_key: 'non-commercial-and-evaluation', + site_id: SITE_ID, + demo: false + } + + const plaintext = new TextEncoder().encode(JSON.stringify(licenceData)) + const cipher = await crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + keyPair.publicKey, + plaintext + ) + const privateKeyBytes = await crypto.subtle.exportKey( + 'pkcs8', + keyPair.privateKey + ) + + const licenceKey = b64(cipher) + const activationKey = b64(privateKeyBytes) + + // Round-trip check with the same WebCrypto the app uses, so a minted pair is + // known to decrypt before it is published. + const imported = await crypto.subtle.importKey( + 'pkcs8', + privateKeyBytes, + { name: 'RSA-OAEP', hash: 'SHA-256' }, + true, + ['decrypt'] + ) + const back = await crypto.subtle.decrypt( + { name: 'RSA-OAEP', hash: 'SHA-256' }, + imported, + cipher + ) + const decoded = JSON.parse(new TextDecoder().decode(back)) + + if (JSON.stringify(decoded) !== JSON.stringify(licenceData)) + throw new Error('round trip mismatch') + if (licenceKey === activationKey) + throw new Error('keys must differ to be an HTTPS-format key') + + fs.writeFileSync(OUT, JSON.stringify({ licenceKey, activationKey }, null, 2)) + console.log(`minted licence for site ${SITE_ID} -> ${OUT}`) +} + +main().catch((err) => { + console.error('FAILED', err) + process.exit(1) +})