From d0e140444af4702e2d9b0c28d7ff2f2da60121f5 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 21 May 2026 11:45:37 -0400 Subject: [PATCH] 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. --- backend/src/__tests__/api-tokens.test.ts | 28 +++++++++++++++++--- backend/src/routes/apiTokens.ts | 5 +--- docs/features/api-tokens.mdx | 6 ++--- frontend/src/components/ApiTokensSection.tsx | 7 ++--- frontend/src/components/settings/registry.ts | 2 +- 5 files changed, 32 insertions(+), 16 deletions(-) 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,