mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 07:13:05 +00:00
refactor(licensing): replace Pro branding with Community/Skipper/Admiral tiers (#375)
Eliminate all references to "Pro" across backend, frontend, and docs. Internal tier value renamed from 'pro' to 'paid'; user-facing text now uses the thematic tier names (Community, Skipper, Admiral). - Rename LicenseTier 'pro' to 'paid' in backend and frontend types - Rename requirePro guard to requirePaid, error code PRO_REQUIRED to PAID_REQUIRED - Rename ProGate.tsx to PaidGate.tsx with updated copy - Fix: trial users can now see upgrade/purchase cards in Settings - Update all docs and openapi.yaml to use correct tier names
This commit is contained in:
@@ -99,7 +99,7 @@ describe('POST /api/system/console-token', () => {
|
||||
// Console-token requires Admiral tier — mock LicenseService for the happy-path test
|
||||
beforeAll(async () => {
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('pro');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('team');
|
||||
});
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ afterAll(() => {
|
||||
const signToken = (payload: Record<string, unknown>, expiresIn: string | number = '1m') =>
|
||||
jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: expiresIn as jwt.SignOptions['expiresIn'] });
|
||||
|
||||
// We need a Pro-gated route that doesn't depend on Docker or remote nodes.
|
||||
// /api/webhooks is Pro-gated and just reads from the DB — returns an empty array
|
||||
// We need a Paid-gated route that doesn't depend on Docker or remote nodes.
|
||||
// /api/webhooks is Paid-gated and just reads from the DB; returns an empty array
|
||||
// if no webhooks exist.
|
||||
const PRO_ROUTE = '/api/webhooks';
|
||||
const PAID_ROUTE = '/api/webhooks';
|
||||
|
||||
// For Admiral routes, /api/audit-log is Admiral-gated and reads from the DB.
|
||||
const ADMIRAL_ROUTE = '/api/audit-log';
|
||||
@@ -36,14 +36,14 @@ const ADMIRAL_ROUTE = '/api/audit-log';
|
||||
describe('authMiddleware - distributed license headers', () => {
|
||||
it('sets proxyTier/proxyVariant for node_proxy tokens with valid tier headers', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
// Hit a Pro-gated route with tier assertion - should be allowed
|
||||
// Hit a Paid-gated route with tier assertion - should be allowed
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro')
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'personal');
|
||||
|
||||
// Should NOT get 403 PRO_REQUIRED — the proxy tier assertion grants access
|
||||
// Should NOT get 403 PAID_REQUIRED; the proxy tier assertion grants access
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
@@ -51,50 +51,50 @@ describe('authMiddleware - distributed license headers', () => {
|
||||
const token = signToken({ username: TEST_USERNAME, role: 'admin' });
|
||||
// Even with tier headers set, a user session should use local license (community)
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro')
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'team');
|
||||
|
||||
// Local license is community in test env → should get 403
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PRO_REQUIRED');
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('ignores tier headers for malformed values on node_proxy tokens', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'enterprise') // invalid value
|
||||
.set('x-sencho-variant', 'mega'); // invalid value
|
||||
|
||||
// Invalid tier header → proxyTier not set → falls back to local (community) → 403
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PRO_REQUIRED');
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('falls back to local tier when no tier headers on node_proxy token', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
// No tier headers → falls back to local (community) → 403
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PRO_REQUIRED');
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── requirePro guard ───────────────────────────────────────────────────────
|
||||
// ─── requirePaid guard ───────────────────────────────────────────────────────
|
||||
|
||||
describe('requirePro - distributed license', () => {
|
||||
it('allows access when proxy asserts pro tier', async () => {
|
||||
describe('requirePaid - distributed license', () => {
|
||||
it('allows access when proxy asserts paid tier', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro')
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', '');
|
||||
|
||||
expect(res.status).not.toBe(403);
|
||||
@@ -103,45 +103,45 @@ describe('requirePro - distributed license', () => {
|
||||
it('blocks access when proxy asserts community tier', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'community');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PRO_REQUIRED');
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('blocks access for direct user when local tier is community', async () => {
|
||||
const token = signToken({ username: TEST_USERNAME, role: 'admin' });
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PRO_REQUIRED');
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── requireAdmiral guard ───────────────────────────────────────────────────
|
||||
|
||||
describe('requireAdmiral - distributed license', () => {
|
||||
it('allows access when proxy asserts pro tier with team variant', async () => {
|
||||
it('allows access when proxy asserts paid tier with team variant', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro')
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'team');
|
||||
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it('blocks when proxy asserts pro tier with personal variant', async () => {
|
||||
it('blocks when proxy asserts paid tier with personal variant', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro')
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'personal');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
@@ -156,15 +156,15 @@ describe('requireAdmiral - distributed license', () => {
|
||||
.set('x-sencho-tier', 'community');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PRO_REQUIRED');
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('blocks when proxy asserts pro tier with empty variant', async () => {
|
||||
it('blocks when proxy asserts paid tier with empty variant', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro')
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', '');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
@@ -180,7 +180,7 @@ describe('Security - tier header injection', () => {
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro')
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'team');
|
||||
|
||||
// User session → tier headers ignored → local community tier → 403
|
||||
@@ -189,8 +189,8 @@ describe('Security - tier header injection', () => {
|
||||
|
||||
it('cannot elevate access via tier headers without any auth', async () => {
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.set('x-sencho-tier', 'pro')
|
||||
.get(PAID_ROUTE)
|
||||
.set('x-sencho-tier', 'paid')
|
||||
.set('x-sencho-variant', 'team');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
@@ -199,9 +199,9 @@ describe('Security - tier header injection', () => {
|
||||
it('cannot elevate access with expired node_proxy token', async () => {
|
||||
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '-1s' });
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro');
|
||||
.set('x-sencho-tier', 'paid');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
@@ -209,9 +209,9 @@ describe('Security - tier header injection', () => {
|
||||
it('cannot elevate access with token signed by wrong secret', async () => {
|
||||
const token = jwt.sign({ scope: 'node_proxy' }, 'wrong-secret', { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.get(PAID_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro');
|
||||
.set('x-sencho-tier', 'paid');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ const {
|
||||
mockCreateSnapshot: vi.fn().mockReturnValue(1),
|
||||
mockInsertSnapshotFiles: vi.fn(),
|
||||
mockClearStackUpdateStatus: vi.fn(),
|
||||
mockGetTier: vi.fn().mockReturnValue('pro'),
|
||||
mockGetTier: vi.fn().mockReturnValue('paid'),
|
||||
mockGetVariant: vi.fn().mockReturnValue('team'),
|
||||
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
|
||||
mockRestartContainer: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -171,7 +171,7 @@ describe('SchedulerService - license gating', () => {
|
||||
});
|
||||
|
||||
it('allows update tasks for non-admiral pro', async () => {
|
||||
mockGetTier.mockReturnValue('pro');
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('individual');
|
||||
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'update' })]);
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]);
|
||||
@@ -186,7 +186,7 @@ describe('SchedulerService - license gating', () => {
|
||||
});
|
||||
|
||||
it('skips non-update tasks for non-admiral pro', async () => {
|
||||
mockGetTier.mockReturnValue('pro');
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('individual');
|
||||
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'restart' })]);
|
||||
|
||||
@@ -197,7 +197,7 @@ describe('SchedulerService - license gating', () => {
|
||||
});
|
||||
|
||||
it('allows all actions for admiral (pro + team)', async () => {
|
||||
mockGetTier.mockReturnValue('pro');
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('team');
|
||||
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'restart' })]);
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
|
||||
@@ -214,7 +214,7 @@ describe('SchedulerService - license gating', () => {
|
||||
|
||||
describe('SchedulerService - concurrent task prevention', () => {
|
||||
it('does not execute a task that is already in runningTasks', async () => {
|
||||
mockGetTier.mockReturnValue('pro');
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('team');
|
||||
mockGetDueScheduledTasks.mockReturnValue([{
|
||||
id: 42,
|
||||
@@ -239,7 +239,7 @@ describe('SchedulerService - concurrent task prevention', () => {
|
||||
});
|
||||
|
||||
it('removes task from runningTasks after completion', async () => {
|
||||
mockGetTier.mockReturnValue('pro');
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('team');
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
|
||||
|
||||
@@ -611,7 +611,7 @@ describe('SchedulerService - error handling', () => {
|
||||
|
||||
describe('SchedulerService - cleanup', () => {
|
||||
it('calls cleanupOldTaskRuns(30) on every tick', async () => {
|
||||
mockGetTier.mockReturnValue('pro');
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('team');
|
||||
mockGetDueScheduledTasks.mockReturnValue([]);
|
||||
|
||||
@@ -626,7 +626,7 @@ describe('SchedulerService - cleanup', () => {
|
||||
|
||||
describe('SchedulerService - isProcessing guard', () => {
|
||||
it('skips tick if already processing', async () => {
|
||||
mockGetTier.mockReturnValue('pro');
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
(svc as any).isProcessing = true;
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('SSO Config Endpoints (Protected)', () => {
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
// Without an Admiral license, this should be 403
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PRO_REQUIRED');
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
|
||||
it('PUT /api/sso/config/:provider returns 401 without auth', async () => {
|
||||
|
||||
+61
-61
@@ -838,31 +838,31 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
|
||||
|
||||
// --- License Routes (local-only, never proxied) ---
|
||||
|
||||
// Pro feature guard: returns false and sends 403 if not Pro tier.
|
||||
// Paid feature guard: returns false and sends 403 if not on a paid tier (Skipper or Admiral).
|
||||
// Checks req.proxyTier first (set by authMiddleware for trusted node proxy requests),
|
||||
// falling back to the local LicenseService tier for direct access.
|
||||
const requirePro = (req: Request, res: Response): boolean => {
|
||||
const requirePaid = (req: Request, res: Response): boolean => {
|
||||
const tier = req.proxyTier !== undefined ? req.proxyTier : LicenseService.getInstance().getTier();
|
||||
if (tier !== 'pro') {
|
||||
res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' });
|
||||
if (tier !== 'paid') {
|
||||
res.status(403).json({ error: 'This feature requires a Skipper or Admiral license.', code: 'PAID_REQUIRED' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Admiral feature guard: requires Pro tier with team variant.
|
||||
// Admiral feature guard: requires paid tier with team variant.
|
||||
// Checks req.proxyTier/proxyVariant first (set by authMiddleware for trusted node proxy
|
||||
// requests), falling back to the local LicenseService for direct access.
|
||||
const requireAdmiral = (req: Request, res: Response): boolean => {
|
||||
const ls = LicenseService.getInstance();
|
||||
const tier = req.proxyTier !== undefined ? req.proxyTier : ls.getTier();
|
||||
const variant = req.proxyVariant !== undefined ? req.proxyVariant : ls.getVariant();
|
||||
if (tier !== 'pro') {
|
||||
res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' });
|
||||
if (tier !== 'paid') {
|
||||
res.status(403).json({ error: 'This feature requires a Skipper or Admiral license.', code: 'PAID_REQUIRED' });
|
||||
return false;
|
||||
}
|
||||
if (variant !== 'team') {
|
||||
res.status(403).json({ error: 'This feature requires Sencho Admiral.', code: 'ADMIRAL_REQUIRED' });
|
||||
res.status(403).json({ error: 'This feature requires a Sencho Admiral license.', code: 'ADMIRAL_REQUIRED' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -876,9 +876,9 @@ const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// Tier gate for scheduled tasks: 'update' action requires Pro, everything else requires Admiral.
|
||||
// Tier gate for scheduled tasks: 'update' action requires Skipper+, everything else requires Admiral.
|
||||
const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => {
|
||||
if (action === 'update') return requirePro(req, res);
|
||||
if (action === 'update') return requirePaid(req, res);
|
||||
return requireAdmiral(req, res);
|
||||
};
|
||||
|
||||
@@ -1161,9 +1161,9 @@ app.get('/api/fleet/overview', async (_req: Request, res: Response): Promise<voi
|
||||
}
|
||||
});
|
||||
|
||||
// Pro-gated: detailed stack info per node
|
||||
// Paid-gated: detailed stack info per node
|
||||
app.get('/api/fleet/node/:nodeId/stacks', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
|
||||
try {
|
||||
const nodeId = parseInt(req.params.nodeId as string, 10);
|
||||
@@ -1199,9 +1199,9 @@ app.get('/api/fleet/node/:nodeId/stacks', async (req: Request, res: Response): P
|
||||
}
|
||||
});
|
||||
|
||||
// Pro-gated: container details for a specific stack on a specific node
|
||||
// Paid-gated: container details for a specific stack on a specific node
|
||||
app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
|
||||
try {
|
||||
const nodeId = parseInt(req.params.nodeId as string, 10);
|
||||
@@ -1245,7 +1245,7 @@ app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', async (req: Requ
|
||||
|
||||
// Fleet Update Status — returns version comparison and active update status for all nodes
|
||||
app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(_req, res)) return;
|
||||
if (!requirePaid(_req, res)) return;
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodes = db.getNodes();
|
||||
@@ -1309,7 +1309,7 @@ app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promis
|
||||
|
||||
// Trigger update on a specific node
|
||||
app.post('/api/fleet/nodes/:nodeId/update', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const nodeId = parseInt(req.params.nodeId as string, 10);
|
||||
const db = DatabaseService.getInstance();
|
||||
@@ -1374,7 +1374,7 @@ app.post('/api/fleet/nodes/:nodeId/update', async (req: Request, res: Response):
|
||||
|
||||
// Trigger update on all outdated nodes
|
||||
app.post('/api/fleet/update-all', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodes = db.getNodes();
|
||||
@@ -1537,7 +1537,7 @@ async function fetchRemoteNodeOverview(node: Node): Promise<FleetNodeOverview> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fleet Snapshots (Pro) ───
|
||||
// ─── Fleet Snapshots (Skipper+) ───
|
||||
|
||||
interface SnapshotNodeData {
|
||||
nodeId: number;
|
||||
@@ -1629,7 +1629,7 @@ async function captureRemoteNodeFiles(node: Node): Promise<SnapshotNodeData> {
|
||||
// Create fleet snapshot
|
||||
app.post('/api/fleet/snapshots', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
|
||||
try {
|
||||
const { description = '' } = req.body;
|
||||
@@ -1702,7 +1702,7 @@ app.post('/api/fleet/snapshots', async (req: Request, res: Response): Promise<vo
|
||||
|
||||
// List fleet snapshots
|
||||
app.get('/api/fleet/snapshots', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
|
||||
@@ -1719,7 +1719,7 @@ app.get('/api/fleet/snapshots', async (req: Request, res: Response): Promise<voi
|
||||
|
||||
// Get snapshot detail
|
||||
app.get('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
@@ -1764,7 +1764,7 @@ app.get('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise
|
||||
// Restore a stack from snapshot
|
||||
app.post('/api/fleet/snapshots/:id/restore', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
|
||||
try {
|
||||
const snapshotId = parseInt(req.params.id as string, 10);
|
||||
@@ -1869,7 +1869,7 @@ app.post('/api/fleet/snapshots/:id/restore', async (req: Request, res: Response)
|
||||
// Delete snapshot
|
||||
app.delete('/api/fleet/snapshots/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
@@ -1887,11 +1887,11 @@ app.delete('/api/fleet/snapshots/:id', async (req: Request, res: Response): Prom
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Webhooks (Pro) ─── CRUD requires auth + Pro, trigger is public with HMAC ───
|
||||
// ─── Webhooks (Skipper+) ─── CRUD requires auth + paid tier, trigger is public with HMAC ───
|
||||
|
||||
// Webhook CRUD (auth + Pro required)
|
||||
// Webhook CRUD (auth + paid tier required)
|
||||
app.get('/api/webhooks', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(_req, res)) return;
|
||||
if (!requirePaid(_req, res)) return;
|
||||
try {
|
||||
const webhooks = DatabaseService.getInstance().getWebhooks();
|
||||
const svc = WebhookService.getInstance();
|
||||
@@ -1904,7 +1904,7 @@ app.get('/api/webhooks', authMiddleware, async (_req: Request, res: Response): P
|
||||
|
||||
app.post('/api/webhooks', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const { name, stack_name, action, enabled } = req.body;
|
||||
if (!name || !stack_name || !action) {
|
||||
@@ -1933,7 +1933,7 @@ app.post('/api/webhooks', authMiddleware, async (req: Request, res: Response): P
|
||||
|
||||
app.put('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const webhook = DatabaseService.getInstance().getWebhook(id);
|
||||
@@ -1956,7 +1956,7 @@ app.put('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response)
|
||||
|
||||
app.delete('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
DatabaseService.getInstance().deleteWebhook(id);
|
||||
@@ -1968,7 +1968,7 @@ app.delete('/api/webhooks/:id', authMiddleware, async (req: Request, res: Respon
|
||||
});
|
||||
|
||||
app.get('/api/webhooks/:id/history', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const executions = DatabaseService.getInstance().getWebhookExecutions(id);
|
||||
@@ -1991,9 +1991,9 @@ app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promi
|
||||
return;
|
||||
}
|
||||
|
||||
// Pro gate - trigger only works with an active Pro license
|
||||
if (LicenseService.getInstance().getTier() !== 'pro') {
|
||||
res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' });
|
||||
// Paid tier gate - trigger only works with an active Skipper or Admiral license
|
||||
if (LicenseService.getInstance().getTier() !== 'paid') {
|
||||
res.status(403).json({ error: 'This feature requires a Skipper or Admiral license.', code: 'PAID_REQUIRED' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2018,7 +2018,7 @@ app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promi
|
||||
// Execute asynchronously - return 202 immediately
|
||||
res.status(202).json({ message: 'Webhook accepted', action });
|
||||
|
||||
const atomic = LicenseService.getInstance().getTier() === 'pro';
|
||||
const atomic = LicenseService.getInstance().getTier() === 'paid';
|
||||
svc.execute(id, action, triggerSource, atomic).catch(err => {
|
||||
console.error(`[Webhooks] Execution error for webhook ${id}:`, err);
|
||||
});
|
||||
@@ -2028,7 +2028,7 @@ app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promi
|
||||
}
|
||||
});
|
||||
|
||||
// --- User Management (local-only, admin + Pro gated for creation) ---
|
||||
// --- User Management (local-only, admin + paid tier gated for creation) ---
|
||||
|
||||
app.get('/api/users', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (req.apiTokenScope) {
|
||||
@@ -2051,7 +2051,7 @@ app.post('/api/users', authMiddleware, async (req: Request, res: Response): Prom
|
||||
return;
|
||||
}
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const { username, password, role } = req.body;
|
||||
|
||||
@@ -2626,9 +2626,9 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
// Admiral license gate — host console requires Pro (team variant)
|
||||
// Admiral license gate: host console requires Admiral (paid + team variant)
|
||||
const ls = LicenseService.getInstance();
|
||||
if (ls.getTier() !== 'pro' || ls.getVariant() !== 'team') {
|
||||
if (ls.getTier() !== 'paid' || ls.getVariant() !== 'team') {
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
@@ -2736,10 +2736,10 @@ app.get('/api/containers', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Label Routes (Pro-gated) ---
|
||||
// --- Label Routes (Skipper+) ---
|
||||
|
||||
app.get('/api/labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const labels = DatabaseService.getInstance().getLabels(nodeId);
|
||||
@@ -2751,7 +2751,7 @@ app.get('/api/labels', authMiddleware, async (req: Request, res: Response): Prom
|
||||
});
|
||||
|
||||
app.post('/api/labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const { name, color } = req.body;
|
||||
@@ -2782,7 +2782,7 @@ app.post('/api/labels', authMiddleware, async (req: Request, res: Response): Pro
|
||||
});
|
||||
|
||||
app.get('/api/labels/assignments', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const assignments = DatabaseService.getInstance().getLabelsForStacks(nodeId);
|
||||
@@ -2794,7 +2794,7 @@ app.get('/api/labels/assignments', authMiddleware, async (req: Request, res: Res
|
||||
});
|
||||
|
||||
app.put('/api/labels/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid label ID' }); return; }
|
||||
@@ -2836,7 +2836,7 @@ app.put('/api/labels/:id', authMiddleware, async (req: Request, res: Response):
|
||||
});
|
||||
|
||||
app.delete('/api/labels/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid label ID' }); return; }
|
||||
@@ -2850,7 +2850,7 @@ app.delete('/api/labels/:id', authMiddleware, async (req: Request, res: Response
|
||||
});
|
||||
|
||||
app.put('/api/stacks/:stackName/labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
@@ -2874,7 +2874,7 @@ app.put('/api/stacks/:stackName/labels', authMiddleware, async (req: Request, re
|
||||
});
|
||||
|
||||
app.post('/api/labels/:id/action', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
@@ -3275,12 +3275,12 @@ app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) =>
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const atomic = LicenseService.getInstance().getTier() === 'pro';
|
||||
const atomic = LicenseService.getInstance().getTier() === 'paid';
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic);
|
||||
res.json({ message: 'Deployed successfully' });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to deploy stack:', error);
|
||||
const rolledBack = LicenseService.getInstance().getTier() === 'pro';
|
||||
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
|
||||
res.status(500).json({ error: error.message || 'Failed to deploy stack', rolledBack });
|
||||
}
|
||||
});
|
||||
@@ -3373,21 +3373,21 @@ app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) =>
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
try {
|
||||
const atomic = LicenseService.getInstance().getTier() === 'pro';
|
||||
const atomic = LicenseService.getInstance().getTier() === 'paid';
|
||||
await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined, atomic);
|
||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||
res.json({ status: 'Update completed' });
|
||||
} catch (error) {
|
||||
const rolledBack = LicenseService.getInstance().getTier() === 'pro';
|
||||
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
|
||||
res.status(500).json({ error: 'Failed to update', rolledBack });
|
||||
}
|
||||
});
|
||||
|
||||
// Manual rollback endpoint (Pro + Admin)
|
||||
// Manual rollback endpoint (Skipper+ and Admin)
|
||||
app.post('/api/stacks/:stackName/rollback', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
@@ -4377,7 +4377,7 @@ app.delete('/api/api-tokens/:id', authMiddleware, async (req: Request, res: Resp
|
||||
|
||||
app.get('/api/scheduled-tasks', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
let tasks = DatabaseService.getInstance().getScheduledTasks();
|
||||
// Skipper users only see 'update' tasks; Admiral sees all
|
||||
@@ -4489,7 +4489,7 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
|
||||
|
||||
app.get('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
|
||||
@@ -4505,7 +4505,7 @@ app.get('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
|
||||
|
||||
app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
|
||||
@@ -4604,7 +4604,7 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
|
||||
|
||||
app.delete('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
|
||||
@@ -4624,7 +4624,7 @@ app.delete('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
|
||||
|
||||
app.patch('/api/scheduled-tasks/:id/toggle', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
|
||||
@@ -4653,7 +4653,7 @@ app.patch('/api/scheduled-tasks/:id/toggle', (req: Request, res: Response): void
|
||||
|
||||
app.post('/api/scheduled-tasks/:id/run', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
|
||||
@@ -4676,7 +4676,7 @@ app.post('/api/scheduled-tasks/:id/run', async (req: Request, res: Response): Pr
|
||||
|
||||
app.get('/api/scheduled-tasks/:id/runs/export', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
|
||||
@@ -4719,7 +4719,7 @@ app.get('/api/scheduled-tasks/:id/runs/export', (req: Request, res: Response): v
|
||||
|
||||
app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePro(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
|
||||
@@ -5147,7 +5147,7 @@ app.post('/api/templates/deploy', async (req: Request, res: Response) => {
|
||||
|
||||
// 4. Deploy the stack with atomic rollback
|
||||
try {
|
||||
const atomic = LicenseService.getInstance().getTier() === 'pro';
|
||||
const atomic = LicenseService.getInstance().getTier() === 'paid';
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic);
|
||||
res.json({ success: true, message: 'Template deployed successfully' });
|
||||
} catch (deployError: any) {
|
||||
|
||||
@@ -2,12 +2,12 @@ import crypto from 'crypto';
|
||||
import axios from 'axios';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
|
||||
export type LicenseTier = 'community' | 'pro';
|
||||
export type LicenseTier = 'community' | 'paid';
|
||||
export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled';
|
||||
|
||||
export type LicenseVariant = 'personal' | 'team' | null;
|
||||
|
||||
const VALID_TIERS: readonly string[] = ['community', 'pro'] satisfies readonly LicenseTier[];
|
||||
const VALID_TIERS: readonly string[] = ['community', 'paid'] satisfies readonly LicenseTier[];
|
||||
const VALID_VARIANTS: readonly string[] = ['personal', 'team'] satisfies readonly LicenseVariant[];
|
||||
|
||||
export function isLicenseTier(value: unknown): value is LicenseTier {
|
||||
@@ -141,7 +141,7 @@ export class LicenseService {
|
||||
trialEnd.setDate(trialEnd.getDate() + TRIAL_DURATION_DAYS);
|
||||
db.setSystemState('license_status', 'trial');
|
||||
db.setSystemState('license_valid_until', trialEnd.toISOString());
|
||||
console.log(`[License] 14-day Pro trial started. Expires: ${trialEnd.toISOString()}`);
|
||||
console.log(`[License] 14-day Skipper trial started. Expires: ${trialEnd.toISOString()}`);
|
||||
}
|
||||
|
||||
this.startPeriodicValidation();
|
||||
@@ -160,7 +160,7 @@ export class LicenseService {
|
||||
if (status === 'trial') {
|
||||
const validUntil = db.getSystemState('license_valid_until');
|
||||
if (validUntil && new Date(validUntil) > new Date()) {
|
||||
return 'pro';
|
||||
return 'paid';
|
||||
}
|
||||
// Trial expired - update status
|
||||
db.setSystemState('license_status', 'community');
|
||||
@@ -186,7 +186,7 @@ export class LicenseService {
|
||||
return 'community';
|
||||
}
|
||||
|
||||
return 'pro';
|
||||
return 'paid';
|
||||
}
|
||||
|
||||
return 'community';
|
||||
|
||||
@@ -49,9 +49,9 @@ export class SchedulerService {
|
||||
this.isProcessing = true;
|
||||
try {
|
||||
const ls = LicenseService.getInstance();
|
||||
const isPro = ls.getTier() === 'pro';
|
||||
const isAdmiral = isPro && ls.getVariant() === 'team';
|
||||
if (!isPro) return; // No scheduled tasks for non-Pro tiers
|
||||
const isPaid = ls.getTier() === 'paid';
|
||||
const isAdmiral = isPaid && ls.getVariant() === 'team';
|
||||
if (!isPaid) return; // No scheduled tasks for unpaid tiers
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const now = Date.now();
|
||||
|
||||
Reference in New Issue
Block a user