diff --git a/backend/src/__tests__/api-tokens.test.ts b/backend/src/__tests__/api-tokens.test.ts index 68c3d163..e3ae8c4f 100644 --- a/backend/src/__tests__/api-tokens.test.ts +++ b/backend/src/__tests__/api-tokens.test.ts @@ -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', () => { diff --git a/backend/src/routes/apiTokens.ts b/backend/src/routes/apiTokens.ts index 92472d10..3267ec67 100644 --- a/backend/src/routes/apiTokens.ts +++ b/backend/src/routes/apiTokens.ts @@ -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 => { 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 => { 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 => { 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; diff --git a/docs/features/api-tokens.mdx b/docs/features/api-tokens.mdx index fe9dba50..a0b38457 100644 --- a/docs/features/api-tokens.mdx +++ b/docs/features/api-tokens.mdx @@ -4,7 +4,7 @@ description: Generate scoped API tokens for CI/CD pipelines, scripts, and automa --- - API Tokens require a Sencho **Admiral** license. Skipper and Community Edition do not include this feature. + API tokens are available on every Sencho tier. Each user can create up to 25 active tokens. API tokens let you authenticate external tools (CI/CD pipelines, deployment scripts, monitoring integrations) without sharing user credentials. Each token is scoped to a specific permission level so you can follow the principle of least privilege. @@ -40,7 +40,7 @@ These restrictions ensure that API tokens cannot escalate privileges or modify t ## Creating a token -1. Open **Settings Hub** and navigate to the **API Tokens** tab (visible to Admiral admins only). +1. Open **Settings Hub** and navigate to the **API Tokens** tab (admin-only). 2. Click **Create Token**. 3. Fill in the creation form: - **Name**: A descriptive label (e.g., "GitHub Actions deploy") @@ -49,7 +49,7 @@ These restrictions ensure that API tokens cannot escalate privileges or modify t 4. Click **Create**. A green banner appears with the raw token value. Copy it immediately using the copy button. - Each user can have up to **25 active API tokens**. Token names must be unique among your active tokens. If you need to reuse a name, revoke the existing token first. + Token names must be unique among your active tokens. If you need to reuse a name, revoke the existing token first. diff --git a/frontend/src/components/ApiTokensSection.tsx b/frontend/src/components/ApiTokensSection.tsx index 5e87f080..007b9ba1 100644 --- a/frontend/src/components/ApiTokensSection.tsx +++ b/frontend/src/components/ApiTokensSection.tsx @@ -9,7 +9,6 @@ import { ConfirmModal } from '@/components/ui/modal'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { copyToClipboard } from '@/lib/clipboard'; -import { AdmiralGate } from './AdmiralGate'; import { CapabilityGate } from './CapabilityGate'; import { Zap, Plus, Copy, Trash2, CheckCircle, RefreshCw, Clock } from 'lucide-react'; import { SettingsPrimaryButton } from './settings/SettingsActions'; @@ -142,8 +141,7 @@ export function ApiTokensSection() { }; return ( - - +
setShowForm(!showForm)}> @@ -291,7 +289,6 @@ export function ApiTokensSection() {

- - + ); } diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 864b3257..2b748324 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -89,7 +89,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ label: 'API Tokens', description: 'Long-lived bearer tokens for CI and scripts.', keywords: ['bearer', 'automation', 'ci', 'scripts', 'scopes'], - tier: 'admiral', + tier: null, scope: 'global', adminOnly: true, hiddenOnRemote: true,