Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .mocharc.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@
"node-option": [
"experimental-vm-modules",
"no-warnings"
],
"require": [
"./tests/setup.mjs"
]
}
}
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,4 +224,49 @@ npm run test

## Contributing

Issues and feature requests are managed via Github and pull requests are welcomed.
Issues and feature requests are managed via Github and pull requests are welcomed.

### Deploy edge rules with a project-scoped portal token

```sh
# QUANT_API_TOKEN: separate project-scoped token with projects:read,
# rules:read and rules:write. QUANT_BASE_URL: the owning portal's /api/v2 URL.
quant rules edge-rules.json --functions edge-functions.json -c CUSTOMER -p PROJECT --dry-run
quant functions edge-functions.json -c CUSTOMER -p PROJECT
quant rules edge-rules.json --functions edge-functions.json -c CUSTOMER -p PROJECT
```

`quant rules` never uses your content write token or upload endpoint. It requires
an explicit portal URL and will not follow redirects. `--dry-run` validates
permissions and previews changes without writing rules.

Rules JSON uses `version: 1`, a stable `namespace`, and a `rules` array. Each rule
has a stable `id`, `type` (`function`, `auth`, or `filter`), `urls`, an explicit
numeric `weight` (lower runs first), and either `function_ref` or `function_uuid`.
Optional fields are `name`, `domains`, `methods`, and `disabled`.
`function_ref` resolves an `id` in the functions manifest, with matching type.
Functions may provide an explicit UUID; otherwise an `id` produces a stable UUID
per customer and project. Existing manifests with UUIDs remain supported.

```json
{
"version": 1,
"namespace": "orbit",
"rules": [{"id":"api","type":"function","urls":["/api/*"],"weight":10,"function_ref":"orbit-api-v1"}]
}
```

Deploys update only entries with matching namespace/ID and preserve other rules.
Omitted entries are preserved; disable a rule explicitly with `disabled: true`.
Place authentication rules before the functions they protect using lower weights;
weights from -100000 to 100000 are allowed, and a negative weight runs before any
rule added in the dashboard or API.

Rules deployed this way are managed in code: the dashboard and the rules API show
them read-only. If one was changed outside code anyway, the deploy writes nothing,
exits non-zero and lists each changed field (values only for matchers and weight).
Re-run with `--force` to overwrite those rules with the manifest. `--force` never
takes over a rule this manifest did not create (a rule ID collision).

Portal-issued template tokens expire after one year; rotate the token and update
`QUANT_API_TOKEN` before expiry.
3 changes: 2 additions & 1 deletion cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ function cliMode() {
builder: command.builder,
handler: async (argv) => {
try {
await showActiveConfig();
// Rules have a separate portal endpoint and credential.
if (_name !== 'rules') await showActiveConfig();
const result = await command.handler(argv);
if (result) {
console.log(result);
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@quantcdn/quant-cli",
"version": "6.2.0",
"version": "6.3.0",
"description": "Deploy tools for QuantCDN",
"type": "module",
"main": "cli.js",
Expand Down
3 changes: 3 additions & 0 deletions src/commandLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import functionCommand from './commands/function.js';
import filterCommand from './commands/function_filter.js';
import authCommand from './commands/function_auth.js';
import functionsCommand from './commands/functions.js';
import rulesCommand from './commands/rules.js';
import unpublishCommand from './commands/unpublish.js';
import deleteCommand from './commands/delete.js';
import infoCommand from './commands/info.js';
Expand All @@ -33,6 +34,7 @@ export function loadCommands() {
'filter': filterCommand,
'auth': authCommand,
'functions': functionsCommand,
'rules': rulesCommand,

// Destructive operations
'unpublish': unpublishCommand,
Expand Down Expand Up @@ -67,6 +69,7 @@ export function getCommandOptions() {
{ value: 'filter', label: 'Deploy an edge filter' },
{ value: 'auth', label: 'Deploy an edge auth function' },
{ value: 'functions', label: 'Deploy multiple edge functions from JSON' },
{ value: 'rules', label: 'Deploy managed rules from JSON' },

// Visual separator
{ value: 'separator2', label: '───────────────────────', disabled: true },
Expand Down
15 changes: 14 additions & 1 deletion src/commands/functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* quant functions <file>
*/
import fs from 'fs';
import { functionUuid } from '../helper/function-identity.js';
import config from '../config.js';
import client from '../quant-client.js';
import color from 'picocolors';
Expand Down Expand Up @@ -49,9 +50,21 @@ const command = {
throw new Error(`Failed to read functions file: ${err.message}`);
}

// Validate identities before uploading anything; duplicate IDs would overwrite
// the same function and make declarative rule references ambiguous.
if (!Array.isArray(functions)) throw new Error('Functions manifest must be an array');
const ids = new Set();
for (const func of functions) {
if (func.id === undefined) continue;
functionUuid(context.config.get('clientid'), context.config.get('project'), func.id);
if (ids.has(func.id)) throw new Error(`Duplicate function id: ${func.id}`);
ids.add(func.id);
}

// Process each function
for (const func of functions) {
const { type, path, description, uuid } = func;
const { type, path, description } = func;
const uuid = func.uuid || (func.id ? functionUuid(context.config.get('clientid'), context.config.get('project'), func.id) : undefined);

// Validate required fields
if (!type || !path || !description) {
Expand Down
134 changes: 134 additions & 0 deletions src/commands/rules.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import fs from 'node:fs';
import axios from 'axios';
import { text, isCancel } from '@clack/prompts';
import { functionUuid } from '../helper/function-identity.js';

export function resolveRules(manifest, functions, customer, project) {
if (!manifest || manifest.version !== 1 || !/^[a-z][a-z0-9-]{0,47}$/.test(manifest.namespace) || !Array.isArray(manifest.rules)) {
throw new Error('Rules manifest requires version: 1, namespace and a rules array');
}
if (Buffer.byteLength(JSON.stringify(manifest)) > 65536 || manifest.rules.length > 100) throw new Error('Rules manifest exceeds size limits');
const ids = new Map();
for (const fn of functions) {
if (!fn.id) continue;
if (ids.has(fn.id)) throw new Error(`Duplicate function id: ${fn.id}`);
ids.set(fn.id, fn);
}
const seen = new Set();
return { ...manifest, rules: manifest.rules.map(rule => {
if (!rule || !/^[a-z][a-z0-9-]{0,47}$/.test(rule.id) || seen.has(rule.id)) throw new Error('Rules require unique lowercase ids');
seen.add(rule.id);
if (!['function', 'auth', 'filter'].includes(rule.type)) throw new Error(`Invalid rule type: ${rule.type}`);
const result = { ...rule };
if (rule.function_ref !== undefined) {
if (rule.function_uuid !== undefined) throw new Error('Use function_ref or function_uuid, not both');
const fn = ids.get(rule.function_ref);
if (!fn) throw new Error(`Unknown function reference: ${rule.function_ref}`);
const type = fn.type === 'edge' ? 'function' : fn.type;
if (type !== rule.type) throw new Error(`Function type does not match rule: ${rule.id}`);
result.function_uuid = fn.uuid || functionUuid(customer, project, fn.id);
delete result.function_ref;
}
if (!/^[a-f\d]{8}-[a-f\d]{4}-[1-5][a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}$/i.test(result.function_uuid || '')) throw new Error(`Invalid function UUID for rule: ${rule.id}`);
return result;
}) };
}

export function rulesOptions(args, env = process.env) {
let saved = {};
try { saved = JSON.parse(fs.readFileSync('quant.json', 'utf8')); } catch { /* Optional project selection only. */ }
const customer = args.clientid || args.c || env.QUANT_CLIENT_ID || env.QUANT_CUSTOMER || saved.clientid;
const project = args.project || args.p || env.QUANT_PROJECT || saved.project;
// Deliberately never read QUANT_TOKEN, -t, saved.token, or upload endpoint.
const token = args['api-token'] || env.QUANT_API_TOKEN;
const base = args['api-base-url'] || env.QUANT_BASE_URL;
if (!customer || !project || !token || !base) throw new Error('Rules require customer, project, QUANT_API_TOKEN and QUANT_BASE_URL (portal /api/v2 URL)');
const url = new URL(base);
if (url.username || url.password || url.search || url.hash || !/\/api\/v2\/?$/.test(url.pathname) ||
(url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) {
throw new Error('Rules require an HTTPS portal /api/v2 URL (HTTP is allowed only for localhost)');
}
return { customer, project, token, base: base.replace(/\/$/, '') };
}

// Drift values printed in CI logs: matchers and placement only. Anything else
// (action_config in particular) can hold credentials, so only its name is shown.
const SAFE_DIFF_FIELDS = ['name', 'disabled', 'url', 'domain', 'method', 'method_is', 'weight', 'action', 'country', 'ip'];
const STATUSES = ['created', 'updated', 'unchanged', 'forced', 'drift', 'collision'];

function deployParams(args) {
const params = {};
if (args['dry-run']) params.dry_run = 1;
if (args.force) params.force = 1;
return params;
}

function shortJson(value) {
const json = JSON.stringify(value) ?? 'null';
return json.length > 200 ? `${json.slice(0, 197)}...` : json;
}

function describeRefusal(rule) {
const id = /^[a-z][a-z0-9-]{0,47}$/.test(rule?.id) ? rule.id : '(unknown rule)';
const status = STATUSES.includes(rule?.status) ? rule.status : 'unknown';
const lines = [`${id}: ${status}`];
for (const [field, change] of Object.entries(status === 'drift' ? rule.diff || {} : {})) {
if (!/^[a-z_]{1,64}$/.test(field)) continue;
lines.push(SAFE_DIFF_FIELDS.includes(field) ? ` ${field}: live ${shortJson(change?.live)} -> code ${shortJson(change?.code)}` : ` ${field}: changed`);
}
return lines.join('\n');
}

function refusalError(rules) {
for (const rule of rules) console.log(describeRefusal(rule));
if (rules.some(rule => rule?.status === 'collision')) {
return new Error('A rule id collides with a rule this manifest does not own; nothing was written. Rename the rule id or remove the other rule (--force never takes over a rule).');
}
return new Error('Managed rules were changed outside code; nothing was written. Re-run with --force to overwrite them with this manifest.');
}

// Never print Axios config/headers or an arbitrary upstream body containing credentials.
function deployError(error) {
const status = error.response?.status;
if (status === 409 && Array.isArray(error.response?.data?.rules)) return refusalError(error.response.data.rules);
if (status === 409) return new Error('Rules are being changed by another request (409). Retry shortly.');
return new Error(`Rules deployment failed (${status || error.code || 'network error'}). Check portal URL, token scopes and project access.`);
}

export default {
command: 'rules <file>',
describe: 'Deploy managed rules with a separate project-scoped portal API token',
builder: yargs => yargs.positional('file', { type: 'string', describe: 'Rules JSON manifest' })
.option('functions', { type: 'string', describe: 'Functions manifest for resolving function_ref' })
.option('api-token', { type: 'string', describe: 'Scoped portal token (prefer QUANT_API_TOKEN)' })
.option('api-base-url', { type: 'string', describe: 'Portal /api/v2 URL (or QUANT_BASE_URL)' })
.option('dry-run', { type: 'boolean', default: false, describe: 'Validate permissions and preview changes without saving' })
.option('force', { type: 'boolean', default: false, describe: 'Overwrite managed rules that were changed outside code (never takes over a rule this manifest does not own)' }),
promptArgs: async () => {
const file = await text({ message: 'Path to rules manifest', placeholder: 'edge-rules.json' });
return isCancel(file) ? null : { file };
},
async handler(args) {
const options = rulesOptions(args);
const manifest = JSON.parse(fs.readFileSync(args.file, 'utf8'));
const functions = args.functions ? JSON.parse(fs.readFileSync(args.functions, 'utf8')) : [];
if (!Array.isArray(functions)) throw new Error('Functions manifest must be an array');
const body = resolveRules(manifest, functions, options.customer, options.project);
const url = `${options.base}/organizations/${encodeURIComponent(options.customer)}/projects/${encodeURIComponent(options.project)}/rules/deploy`;
let response;
try {
response = await axios.post(url, body, {
headers: { Authorization: `Bearer ${options.token}`, Accept: 'application/json' },
params: deployParams(args), timeout: 60000, maxRedirects: 0
});
} catch (error) {
throw deployError(error);
}
if (!Array.isArray(response.data?.rules) || response.data.namespace !== manifest.namespace || response.data.rules.length !== manifest.rules.length ||
response.data.rules.some((rule, i) => rule.id !== manifest.rules[i].id || !['created', 'updated', 'unchanged', 'forced'].includes(rule.status))) {
throw new Error('Rules API returned an invalid acknowledgement');
}
for (const rule of response.data.rules) console.log(`${rule.id}: ${rule.status}`);
return args['dry-run'] ? 'Rules validated; no changes saved' : 'Rules deployed successfully';
}
};
6 changes: 6 additions & 0 deletions src/helper/function-identity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createHash } from 'node:crypto';
export function functionUuid(customer, project, id) {
if (!/^[a-z][a-z0-9-]{0,47}$/.test(id)) throw new Error('Function id must be a lowercase slug (1–48 characters)');
const hex = createHash('sha256').update(JSON.stringify([customer, project, id])).digest('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
}
16 changes: 16 additions & 0 deletions tests/setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,19 @@ use(sinonChai);
global.expect = expect;
global.assert = assert;
global.sinon = sinon;

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

const originalHome = os.homedir;
const testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'quant-cli-tests-'));
// config.save() writes a user-level file as well as quant.json. Tests must
// never overwrite a developer's saved credentials.
os.homedir = () => testHome;
export const mochaHooks = {
afterAll() {
os.homedir = originalHome;
fs.rmSync(testHome, { recursive: true, force: true });
}
};
19 changes: 19 additions & 0 deletions tests/unit/commands/functions.test.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect } from 'chai';
import sinon from 'sinon';
import { functionUuid } from '../../../src/helper/function-identity.js';
import _fs from 'fs';
import _path from 'path';
import mockClient from '../../mocks/quant-client.mjs';
Expand Down Expand Up @@ -46,6 +47,24 @@ describe('Functions Command', () => {
});

describe('handler', () => {
it('uses the same stable identity as rules references when uploading', async () => {
readFileSync.returns(JSON.stringify([{ id: 'api', type: 'function', path: './api.js', description: 'API' }]));
const edgeFunction = sinon.stub().resolves({});
await functions.handler.call({ config: mockConfig, client: () => ({ edgeFunction }) }, { file: 'functions.json' });
expect(edgeFunction.firstCall.args[2]).to.equal(functionUuid('test-client', 'test-project', 'api'));
});

it('rejects duplicate function ids before the first upload', async () => {
const fn = { id: 'api', type: 'function', path: './api.js', description: 'API' };
readFileSync.returns(JSON.stringify([fn, fn]));
const edgeFunction = sinon.stub();
try {
await functions.handler.call({ config: mockConfig, client: () => ({ edgeFunction }) }, { file: 'functions.json' });
expect.fail('duplicate must fail');
} catch (error) { expect(error.message).to.include('Duplicate function id'); }
expect(edgeFunction.called).to.equal(false);
});

it('should deploy auth functions', async () => {
const mockJson = [{
type: 'auth',
Expand Down
Loading
Loading