diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b9667dd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: NoteMate Demo CI + +on: + pull_request: + branches: [ main, master ] + push: + branches: [ main, master ] + workflow_dispatch: + +jobs: + build: + name: Build demo + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Verify server build + run: npm run build --workspace=server + + smoke: + name: Live smoke test + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run smoke test + env: + KEYMINT_TEST_ADMIN_API_KEY: ${{ secrets.KEYMINT_TEST_ADMIN_API_KEY }} + KEYMINT_TEST_CLIENT_API_KEY: ${{ secrets.KEYMINT_TEST_CLIENT_API_KEY }} + KEYMINT_TEST_PRODUCT_ID: ${{ vars.KEYMINT_TEST_PRODUCT_ID }} + run: node scripts/smoke.mjs diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..e5978c9 --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,113 @@ +// Smoke test: boots the demo server, runs the licensing flow against the +// test workspace (enter -> state PRO -> gated feature -> clear), then cleans +// up. Required env: KEYMINT_TEST_ADMIN_API_KEY, KEYMINT_TEST_CLIENT_API_KEY, +// KEYMINT_TEST_PRODUCT_ID. Skips quietly when absent (PR runs). +import { spawn } from 'child_process'; +import { randomUUID } from 'crypto'; + +const adminKey = process.env.KEYMINT_TEST_ADMIN_API_KEY; +const clientKey = process.env.KEYMINT_TEST_CLIENT_API_KEY; +const productId = process.env.KEYMINT_TEST_PRODUCT_ID; +const keymintBase = process.env.KEYMINT_TEST_BASE_URL || 'https://api.keymint.dev'; + +if (!adminKey || !clientKey || !productId) { + console.log('smoke: credentials not set, skipping'); + process.exit(0); +} + +const PORT = '4101'; +const runId = randomUUID().replaceAll('-', ''); +let licenseKey = null; +let server = null; + +async function api(path, { method = 'GET', body, key } = {}) { + const res = await fetch(`${keymintBase}${path}`, { + method, + headers: { + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }); + return { status: res.status, json: await res.json().catch(() => ({})) }; +} + +async function local(path, opts = {}) { + const res = await fetch(`http://localhost:${PORT}${path}`, { + method: opts.method || 'GET', + headers: { 'Content-Type': 'application/json' }, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + return { status: res.status, json: await res.json().catch(() => ({})) }; +} + +function assert(cond, label, extra = '') { + console.log(`${cond ? 'PASS' : 'FAIL'} ${label} ${extra}`); + if (!cond) process.exitCode = 1; +} + +async function waitHealth(tries = 30) { + for (let i = 0; i < tries; i++) { + try { + const r = await local('/health'); + if (r.status === 200) return; + } catch {} + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error('server never became healthy'); +} + +try { + const created = await api('/key', { + method: 'POST', + key: adminKey, + body: { productId, maxActivations: '3', metadata: { purpose: 'notemate-smoke', runId } }, + }); + assert(created.status === 200, 'create', created.status); + licenseKey = created.json.key; + + server = spawn('npx', ['tsx', 'src/index.ts'], { + cwd: 'server', + env: { + ...process.env, + PORT, + KEYMINT_ACCESS_TOKEN: clientKey, + KEYMINT_PRODUCT_ID: productId, + }, + stdio: 'ignore', + }); + await waitHealth(); + console.log('PASS boot'); + + const entered = await local('/api/enter-license', { + method: 'POST', + body: { licenseKey, deviceTag: 'smoke' }, + }); + assert(entered.status === 200 && entered.json.tier === 'PRO', 'enter-license', entered.status); + + const state = await local('/api/license-state'); + assert(state.json.tier === 'PRO', 'license-state'); + + const feat = await local('/api/feature/exportPDF'); + assert(feat.status === 200, 'gated feature'); + + const cleared = await local('/api/clear-license', { method: 'POST' }); + assert(cleared.json.tier === 'FREE', 'clear-license'); + + const gated = await local('/api/feature/exportPDF'); + assert(gated.status === 402, 'gate after clear'); +} catch (e) { + console.log('FAIL exception', e?.message || e); + process.exitCode = 1; +} finally { + if (server) server.kill(); + if (licenseKey) { + const blocked = await api('/key/block', { + method: 'POST', + key: adminKey, + body: { productId, licenseKey }, + }); + console.log(`${blocked.status === 200 ? 'PASS' : 'FAIL'} cleanup-block`); + if (blocked.status !== 200) process.exitCode = 1; + } +} diff --git a/server/src/index.ts b/server/src/index.ts index e36e80e..e3f1aa4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2,6 +2,7 @@ import express from 'express'; import cors from 'cors'; import dotenv from 'dotenv'; import axios from 'axios'; +import { createHash } from 'crypto'; import { z } from 'zod'; dotenv.config(); @@ -42,7 +43,9 @@ app.post('/api/enter-license', async (req: express.Request, res: express.Respons try { const schema = z.object({ licenseKey: z.string(), deviceTag: z.string().optional(), hostId: z.string().optional() }); const { licenseKey, deviceTag, hostId } = schema.parse(req.body); - const activationHost = hostId || `host-${Math.random().toString(36).slice(2)}`; + // Stable fallback: a random hostId per call would burn one activation + // slot per launch. Derive it from the key so repeat launches reuse it. + const activationHost = hostId || `demo-${createHash('sha256').update(licenseKey).digest('hex').slice(0, 16)}`; const payload = { productId, licenseKey, hostId: activationHost, deviceTag }; // Use new endpoint per docs const actResp = await api.post('/key/activate', payload);