mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat: move Blueprint orchestration and Federation placement to Community tier (#1555)
* feat: move core Blueprint orchestration to Community tier Blueprints CRUD, reconciliation, and drift modes are now available on Community. Pin remains Admiral-only via Federation placement controls. * feat: move Federation placement controls to Community tier Remove requirePaid from cordon, uncordon, and blueprint pin routes. Ungate the Federation tab and gate cordon UI on node:manage only. Update licensing and fleet docs for the new tier split.
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
/**
|
||||
* Authorization parity tests for /api/blueprints.
|
||||
*
|
||||
* The Blueprints UI gates affordances on the paid tier and admin role; these
|
||||
* tests pin the matching server-side guards so a UI gate and a route guard
|
||||
* cannot silently drift apart. Specifically:
|
||||
* - PUT /:id/pin requires the paid tier AND admin role (the admin-role half is
|
||||
* the parity gap the Federation pin control was hardened to match).
|
||||
* - The mutation routes require admin role.
|
||||
* - The read routes require paid tier but NOT admin role.
|
||||
* The Blueprints UI gates edit affordances on admin role; these tests pin the
|
||||
* matching server-side guards so a UI gate and a route guard cannot silently
|
||||
* drift apart. Specifically:
|
||||
* - PUT /:id/pin requires admin role on every tier (Federation placement).
|
||||
* - Core mutation routes require admin role on every tier.
|
||||
* - Read routes require auth but NOT admin role.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
@@ -119,7 +118,7 @@ describe('PUT /api/blueprints/:id/pin authorization', () => {
|
||||
expect(res.body.pinned_node_id).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an admin on a Community license with PAID_REQUIRED', async () => {
|
||||
it('lets a Community admin pin a blueprint', async () => {
|
||||
setLicense('community');
|
||||
const node = seedNode();
|
||||
const bp = seedBlueprint([node.id]);
|
||||
@@ -129,8 +128,8 @@ describe('PUT /api/blueprints/:id/pin authorization', () => {
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ nodeId: node.id });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.pinned_node_id).toBe(node.id);
|
||||
});
|
||||
|
||||
it('rejects a non-admin on a paid license with ADMIN_REQUIRED', async () => {
|
||||
@@ -148,9 +147,8 @@ describe('PUT /api/blueprints/:id/pin authorization', () => {
|
||||
});
|
||||
|
||||
describe('Blueprint mutation routes require admin role', () => {
|
||||
// Tier is paid in beforeEach, so requirePaid passes and the admin guard is
|
||||
// what rejects. The gate short-circuits before id parsing, so dummy ids are
|
||||
// sufficient to prove the role boundary.
|
||||
// The gate short-circuits before id parsing, so dummy ids are sufficient
|
||||
// to prove the role boundary.
|
||||
const mutations: Array<{ name: string; method: 'post' | 'put' | 'delete'; path: string }> = [
|
||||
{ name: 'create', method: 'post', path: '/api/blueprints' },
|
||||
{ name: 'update', method: 'put', path: '/api/blueprints/1' },
|
||||
@@ -166,18 +164,25 @@ describe('Blueprint mutation routes require admin role', () => {
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects an admin on a Community license from creating with PAID_REQUIRED', async () => {
|
||||
it('lets a Community admin create when the body is valid (not PAID_REQUIRED)', async () => {
|
||||
setLicense('community');
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
.send({
|
||||
name: 'community-bp',
|
||||
compose_content: 'services:\n app:\n image: nginx\n',
|
||||
selector: { type: 'nodes', ids: [node.id] },
|
||||
drift_mode: 'enforce',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.name).toBe('community-bp');
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Blueprint read routes require paid tier but not admin role', () => {
|
||||
describe('Blueprint read routes require auth but not admin role', () => {
|
||||
it('lets a non-admin paid user list blueprints', async () => {
|
||||
seedBlueprint([]);
|
||||
const res = await request(app).get('/api/blueprints').set('Cookie', viewerCookie);
|
||||
@@ -209,10 +214,12 @@ describe('Blueprint read routes require paid tier but not admin role', () => {
|
||||
expect(res.body.classification).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects an admin on a Community license from listing with PAID_REQUIRED', async () => {
|
||||
it('lets a Community admin list blueprints (not PAID_REQUIRED)', async () => {
|
||||
setLicense('community');
|
||||
seedBlueprint([]);
|
||||
const res = await request(app).get('/api/blueprints').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Confirms core Blueprint CRUD, reconciliation, and Federation pin routes are
|
||||
* reachable on the Community tier.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let adminCookie: string;
|
||||
let viewerAuthHeader: string;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let BlueprintReconciler: typeof import('../services/BlueprintReconciler').BlueprintReconciler;
|
||||
let counter = 0;
|
||||
|
||||
function mockTier(tier: 'paid' | 'community') {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
|
||||
}
|
||||
|
||||
function seedNode(): { id: number; name: string } {
|
||||
counter += 1;
|
||||
const name = `bp-community-node-${counter}`;
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
const result = db.prepare(
|
||||
`INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at)
|
||||
VALUES (?, 'local', 'proxy', '/tmp/compose', 0, 'online', ?)`,
|
||||
).run(name, Date.now());
|
||||
return { id: result.lastInsertRowid as number, name };
|
||||
}
|
||||
|
||||
function validBlueprintBody(nodeId: number) {
|
||||
return {
|
||||
name: `bp-community-${counter + 1}`,
|
||||
compose_content: 'services:\n app:\n image: nginx\n',
|
||||
selector: { type: 'nodes', ids: [nodeId] },
|
||||
drift_mode: 'enforce',
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ BlueprintReconciler } = await import('../services/BlueprintReconciler'));
|
||||
|
||||
DatabaseService.getInstance().addUser({ username: 'bp-community-viewer', password_hash: 'hash', role: 'viewer' });
|
||||
const viewerToken = jwt.sign({ username: 'bp-community-viewer' }, TEST_JWT_SECRET, { expiresIn: '1h' });
|
||||
viewerAuthHeader = `Bearer ${viewerToken}`;
|
||||
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockTier('community');
|
||||
vi.spyOn(BlueprintReconciler.getInstance(), 'reconcileOne').mockResolvedValue(undefined);
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
db.prepare('DELETE FROM blueprint_deployments').run();
|
||||
db.prepare('DELETE FROM blueprints').run();
|
||||
db.prepare('DELETE FROM nodes WHERE is_default = 0').run();
|
||||
});
|
||||
|
||||
describe('Blueprints on Community tier', () => {
|
||||
it('GET /api/blueprints returns 200 for an admin', async () => {
|
||||
const res = await request(app).get('/api/blueprints').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('POST /api/blueprints creates a blueprint for an admin', async () => {
|
||||
const node = seedNode();
|
||||
counter += 1;
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Cookie', adminCookie)
|
||||
.send(validBlueprintBody(node.id));
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.drift_mode).toBe('enforce');
|
||||
});
|
||||
|
||||
it('GET /api/blueprints/:id returns detail for a viewer', async () => {
|
||||
const node = seedNode();
|
||||
counter += 1;
|
||||
const created = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Cookie', adminCookie)
|
||||
.send(validBlueprintBody(node.id));
|
||||
expect(created.status).toBe(201);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/blueprints/${created.body.id}`)
|
||||
.set('Authorization', viewerAuthHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.blueprint.id).toBe(created.body.id);
|
||||
});
|
||||
|
||||
it('POST /api/blueprints/:id/apply triggers reconciliation for an admin', async () => {
|
||||
const node = seedNode();
|
||||
counter += 1;
|
||||
const created = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Cookie', adminCookie)
|
||||
.send(validBlueprintBody(node.id));
|
||||
expect(created.status).toBe(201);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/blueprints/${created.body.id}/apply`)
|
||||
.set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects blueprint mutations for a viewer with ADMIN_REQUIRED', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Authorization', viewerAuthHeader)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
|
||||
it('lets a Community viewer list blueprints', async () => {
|
||||
const res = await request(app).get('/api/blueprints').set('Authorization', viewerAuthHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('node_proxy with community tier header can reach apply-local (not PAID_REQUIRED)', async () => {
|
||||
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints/apply-local')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'community')
|
||||
.send({});
|
||||
expect(res.status).not.toBe(403);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('node_proxy with community tier header can list blueprints', async () => {
|
||||
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.get('/api/blueprints')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'community');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('PUT /api/blueprints/:id/pin succeeds for a Community admin', async () => {
|
||||
const node = seedNode();
|
||||
counter += 1;
|
||||
const created = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Cookie', adminCookie)
|
||||
.send(validBlueprintBody(node.id));
|
||||
expect(created.status).toBe(201);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/blueprints/${created.body.id}/pin`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ nodeId: node.id });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.pinned_node_id).toBe(node.id);
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,10 @@
|
||||
*
|
||||
* These guard the same boundary the NodeCard cordon control renders against, so
|
||||
* a UI gate and a route guard cannot silently drift apart. Cordon/uncordon
|
||||
* require the paid tier AND the node:manage permission (held by admin and
|
||||
* require the node:manage permission on every tier (held by admin and
|
||||
* node-admin roles). The guard order is:
|
||||
* rejectApiTokenScope (SCOPE_DENIED) -> requirePermission (PERMISSION_DENIED)
|
||||
* -> requirePaid (PAID_REQUIRED) -> invalid-id 400
|
||||
* -> invalid-id 400
|
||||
* -> reason 400 (cordon only) -> 404.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
@@ -115,15 +115,15 @@ describe('POST /api/nodes/:id/cordon authorization', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an admin on a Community license with PAID_REQUIRED', async () => {
|
||||
it('lets a Community admin cordon a node', async () => {
|
||||
setLicense('community');
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/cordon`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.cordoned).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a full-admin API token with SCOPE_DENIED (API tokens cannot manage nodes)', async () => {
|
||||
@@ -190,7 +190,7 @@ describe('POST /api/nodes/:id/cordon authorization', () => {
|
||||
expect(res.body.cordoned_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('checks node:manage before the tier gate (Community viewer gets PERMISSION_DENIED, not PAID_REQUIRED)', async () => {
|
||||
it('checks node:manage before route validation (Community viewer gets PERMISSION_DENIED, not PAID_REQUIRED)', async () => {
|
||||
setLicense('community');
|
||||
const node = seedNode();
|
||||
const res = await request(app)
|
||||
@@ -243,15 +243,15 @@ describe('POST /api/nodes/:id/uncordon authorization', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an admin on a Community license with PAID_REQUIRED', async () => {
|
||||
it('lets a Community admin uncordon a node', async () => {
|
||||
setLicense('community');
|
||||
const node = seedCordonedNode();
|
||||
const res = await request(app)
|
||||
.post(`/api/nodes/${node.id}/uncordon`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.cordoned).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a full-admin API token with SCOPE_DENIED', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePaid, requireAdmin, requireBody } from '../middleware/tierGates';
|
||||
import { requireAdmin, requireBody } from '../middleware/tierGates';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import {
|
||||
DatabaseService,
|
||||
@@ -114,7 +114,6 @@ function summarizeBlueprint(blueprintId: number) {
|
||||
}
|
||||
|
||||
blueprintsRouter.get('/', (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const blueprints = DatabaseService.getInstance().listBlueprints();
|
||||
const summaries = blueprints.map(b => {
|
||||
@@ -131,7 +130,6 @@ blueprintsRouter.get('/', (req: Request, res: Response): void => {
|
||||
});
|
||||
|
||||
blueprintsRouter.post('/', (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
const body = req.body as BlueprintBody;
|
||||
@@ -171,7 +169,6 @@ blueprintsRouter.post('/', (req: Request, res: Response): void => {
|
||||
});
|
||||
|
||||
blueprintsRouter.get('/:id', (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
try {
|
||||
@@ -185,7 +182,6 @@ blueprintsRouter.get('/:id', (req: Request, res: Response): void => {
|
||||
});
|
||||
|
||||
blueprintsRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
@@ -258,7 +254,6 @@ blueprintsRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
});
|
||||
|
||||
blueprintsRouter.delete('/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
@@ -314,11 +309,10 @@ blueprintsRouter.delete('/:id', async (req: Request, res: Response): Promise<voi
|
||||
// Node-to-node atomic blueprint apply. A hub posts here on the node that owns
|
||||
// the stack so the create / write compose+marker / deploy runs under that node's
|
||||
// per-stack lock (a remote node's lock is process-local and cannot be held by
|
||||
// the hub over separate HTTP calls). Gated by paid tier plus per-stack stack:edit
|
||||
// the hub over separate HTTP calls). Gated by per-stack stack:edit
|
||||
// and stack:deploy, the same permissions as the PUT-compose + deploy it bundles;
|
||||
// the node token the hub presents satisfies them.
|
||||
blueprintsRouter.post('/apply-local', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const body = (req.body ?? {}) as { stackName?: unknown; composeContent?: unknown; markerContent?: unknown };
|
||||
if (typeof body.stackName !== 'string' || !isValidStackName(body.stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
@@ -359,7 +353,6 @@ blueprintsRouter.post('/apply-local', async (req: Request, res: Response): Promi
|
||||
});
|
||||
|
||||
blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
@@ -379,7 +372,6 @@ blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise
|
||||
});
|
||||
|
||||
blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
@@ -457,7 +449,6 @@ blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Respons
|
||||
});
|
||||
|
||||
blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
@@ -487,7 +478,6 @@ blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response)
|
||||
});
|
||||
|
||||
blueprintsRouter.get('/:id/preview', (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
try {
|
||||
@@ -517,7 +507,6 @@ blueprintsRouter.get('/:id/preview', (req: Request, res: Response): void => {
|
||||
});
|
||||
|
||||
blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
@@ -558,7 +547,6 @@ blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise<vo
|
||||
});
|
||||
|
||||
blueprintsRouter.post('/analyze', (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
const composeContent = typeof req.body?.compose_content === 'string' ? req.body.compose_content : '';
|
||||
if (!composeContent.trim()) {
|
||||
|
||||
@@ -393,7 +393,6 @@ nodesRouter.post('/:id/cordon', (req: Request, res: Response) => {
|
||||
if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
|
||||
const nodeIdParam = req.params.id as string;
|
||||
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!/^[1-9]\d*$/.test(nodeIdParam)) {
|
||||
res.status(400).json({ error: 'Invalid node id' });
|
||||
return;
|
||||
@@ -432,7 +431,6 @@ nodesRouter.post('/:id/uncordon', (req: Request, res: Response) => {
|
||||
if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
|
||||
const nodeIdParam = req.params.id as string;
|
||||
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!/^[1-9]\d*$/.test(nodeIdParam)) {
|
||||
res.status(400).json({ error: 'Invalid node id' });
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user