mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
feat(api-tokens): make API tokens available on every tier (#1136)
API tokens are credential management, not a tier-gated capability. Remove the Admiral gate from the POST/GET/DELETE handlers, drop the AdmiralGate wrapper from the settings UI, set the registry entry's tier to null so the tab renders on every tier, and update the docs Note to state availability plainly. The three permission scopes (read-only, deploy-only, full-admin), the 25-token-per-user cap, the per-token 200 req/min rate limit, the sen_sk_ prefix format, and the SHA-256 hashed storage are all unchanged. The Vitest suite now runs at Community tier to prove every code path works without a paid license. A new "API token tier accessibility" describe block mints all three scopes via POST /api/api-tokens to lock the behavior.
This commit is contained in:
@@ -39,10 +39,10 @@ beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
// Mock LicenseService to return paid/admiral for Admiral-gated routes
|
||||
// Suite runs at Community tier to prove token routes work without a paid license.
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue(null);
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
@@ -274,6 +274,28 @@ describe('API token creation validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Tier Accessibility (Community) ---
|
||||
|
||||
describe('API token tier accessibility', () => {
|
||||
it('Community tier mints all three scopes via POST /api/api-tokens', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername('testadmin')!;
|
||||
// Reset to avoid hitting the 25-token cap from prior suites.
|
||||
for (const t of db.getApiTokensByUser(user.id)) {
|
||||
if (!t.revoked_at) db.revokeApiToken(t.id);
|
||||
}
|
||||
|
||||
for (const scope of ['read-only', 'deploy-only', 'full-admin'] as const) {
|
||||
const res = await request(app)
|
||||
.post('/api/api-tokens')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ name: `community-${scope}-${Date.now()}`, scope });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.token).toMatch(/^sen_sk_/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Token Count Limit ---
|
||||
|
||||
describe('API token count limit', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { DatabaseService, type ApiTokenScope } from '../services/DatabaseService';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requireAdmin, requireAdmiral } from '../middleware/tierGates';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { parseIntParam } from '../utils/parseIntParam';
|
||||
@@ -17,7 +17,6 @@ export const apiTokensRouter = Router();
|
||||
apiTokensRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, API_TOKEN_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const { name, scope, expires_in } = req.body;
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
@@ -81,7 +80,6 @@ apiTokensRouter.post('/', authMiddleware, async (req: Request, res: Response): P
|
||||
apiTokensRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, API_TOKEN_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const user = DatabaseService.getInstance().getUserByUsername(req.user!.username);
|
||||
if (!user) { res.status(500).json({ error: 'User not found.' }); return; }
|
||||
@@ -98,7 +96,6 @@ apiTokensRouter.get('/', authMiddleware, async (req: Request, res: Response): Pr
|
||||
apiTokensRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, API_TOKEN_SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'token ID');
|
||||
if (id === null) return;
|
||||
|
||||
Reference in New Issue
Block a user