feat(auth): add middleware tests + E2E flow + migrate tracker-service to @bytelyst/auth

- Upgraded @bytelyst/auth middleware to throw ServiceError types (UnauthorizedError, ForbiddenError)
- Added @bytelyst/errors as dependency to auth package
- 11 new middleware tests (extractAuth + requireRole)
- 4 new E2E tests (full login → JWT → auth → role check flow)
- Refactored tracker-service lib/auth.ts from 48-line local impl to 1-line re-export
- Added @bytelyst/auth as tracker-service dependency
- All 277 tests pass, 0 regressions
This commit is contained in:
saravanakumardb1 2026-02-12 23:41:46 -08:00
parent 33a646c0fe
commit 99cbdf582c
8 changed files with 748 additions and 51 deletions

View File

@ -0,0 +1,514 @@
---
description: Intelligent workspace sync - check, review, and commit changes across all repos
date: 2025-02-12
---
# Workspace Sync Workflow
> Intelligently manages uncommitted changes across all 3 workspace repos with interactive approval.
> Preserves developer knowledge and prevents data loss through careful review and conflict resolution.
## Overview
This workflow helps maintain a clean workspace by:
1. Scanning all repos for uncommitted changes
2. Displaying changes in an organized, reviewable format
3. Suggesting logical commit groups based on change types
4. Interactive approval before each commit
5. Safe conflict resolution with rebase
6. Pushing in dependency order (common_plat → voice_agent → mindlyst)
## Prerequisites
- Git CLI installed
- All repos cloned and accessible
- Proper Git credentials configured
## Workflow Steps
### Step 1: Scan Workspace for Changes
```bash
# Create workspace sync script
cat > ~/workspace-sync.sh << 'EOF'
#!/bin/bash
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Workspace paths
WORKSPACE="$HOME/code/mygh"
REPOS=(
"learning_ai_common_plat:Common Platform:First"
"learning_voice_ai_agent:LysnrAI:Second"
"learning_multimodal_memory_agents:MindLyst:Third"
)
echo -e "${BLUE}🔍 Scanning workspace for uncommitted changes...${NC}"
echo
# Track repos with changes
REPOS_WITH_CHANGES=()
for repo_info in "${REPOS[@]}"; do
IFS=':' read -r repo_name display_name order <<< "$repo_info"
repo_path="$WORKSPACE/$repo_name"
if [ -d "$repo_path" ]; then
cd "$repo_path"
# Check for uncommitted changes
if ! git diff --quiet || ! git diff --cached --quiet || [ -n "$(git ls-files --others --exclude-standard)" ]; then
REPOS_WITH_CHANGES+=("$repo_name:$display_name:$order")
echo -e "${CYAN}📁 $display_name${NC} (${YELLOW}$repo_name${NC})"
# Staged changes
if [ -n "$(git diff --cached --name-only)" ]; then
echo -e " ${GREEN}Staged changes:${NC}"
git diff --cached --stat
fi
# Unstaged changes
if [ -n "$(git diff --name-only)" ]; then
echo -e " ${YELLOW}Unstaged changes:${NC}"
git diff --stat
fi
# Untracked files
if [ -n "$(git ls-files --others --exclude-standard)" ]; then
echo -e " ${RED}Untracked files:${NC}"
git ls-files --others --exclude-standard | head -10
count=$(git ls-files --others --exclude-standard | wc -l)
[ $count -gt 10 ] && echo " ... and $((count - 10)) more"
fi
# Branch status
branch_status=$(git status --porcelain -b | head -1)
if [[ $branch_status == *"ahead"* ]]; then
echo -e " ${YELLOW}⚠️ Branch is ahead of origin${NC}"
fi
if [[ $branch_status == *"behind"* ]]; then
echo -e " ${YELLOW}⚠️ Branch is behind origin${NC}"
fi
echo
fi
fi
done
if [ ${#REPOS_WITH_CHANGES[@]} -eq 0 ]; then
echo -e "${GREEN}✅ All repos are clean!${NC}"
exit 0
fi
echo -e "${BLUE}📊 Summary: Found changes in ${#REPOS_WITH_CHANGES[@]} repo(s)${NC}"
echo
EOF
chmod +x ~/workspace-sync.sh
```
### Step 2: Interactive Review and Commit
```bash
# Add interactive commit functionality
cat >> ~/workspace-sync.sh << 'EOF'
# Function to suggest commit message based on changes
suggest_commit_message() {
local repo_path="$1"
cd "$repo_path"
# Analyze changes
has_ci_changes=$(git diff --cached --name-only | grep -E "\.github|ci|workflow" || echo "")
has_docs_changes=$(git diff --cached --name-only | grep -E "README|\.md$" || echo "")
has_code_changes=$(git diff --cached --name-only | grep -E "\.(ts|js|py|kt|swift)$" || echo "")
has_test_changes=$(git diff --cached --name-only | grep -E "test|spec" || echo "")
has_config_changes=$(git diff --cached --name-only | grep -E "package\.json|requirements|gradle|\.env" || echo "")
# Suggest message
if [ -n "$has_ci_changes" ]; then
echo "ci: update CI/CD configuration"
elif [ -n "$has_docs_changes" ] && [ -z "$has_code_changes" ]; then
echo "docs: update documentation"
elif [ -n "$has_test_changes" ] && [ -z "$has_code_changes" ]; then
echo "test: update test suite"
elif [ -n "$has_config_changes" ]; then
echo "chore: update dependencies and configuration"
else
echo "feat: implement new features and improvements"
fi
}
# Function to safely commit with review
safe_commit() {
local repo_name="$1"
local display_name="$2"
local repo_path="$WORKSPACE/$repo_name"
cd "$repo_path"
echo -e "${BLUE}📋 Reviewing changes for $display_name${NC}"
echo
# Show detailed diff
echo -e "${CYAN}Press Enter to see diff summary, 's' to skip, 'q' to quit${NC}"
read -r response
if [[ "$response" == "q" ]]; then
echo -e "${RED}Quitting workflow${NC}"
exit 0
elif [[ "$response" != "s" ]]; then
git diff --cached --stat 2>/dev/null || true
git diff --stat 2>/dev/null || true
echo
fi
# Ask what to do with changes
echo -e "${YELLOW}Choose action:${NC}"
echo " 1) Stage all changes and commit"
echo " 2) Review and stage specific files"
echo " 3) Skip this repo"
echo " q) Quit workflow"
read -r action
case $action in
1)
# Stage all changes
git add -A
;;
2)
# Interactive staging
echo -e "${CYAN}Files to stage:${NC}"
git status --porcelain
echo -e "${YELLOW}Enter files to stage (space-separated, or 'all'):${NC}"
read -r files_to_stage
if [[ "$files_to_stage" == "all" ]]; then
git add -A
else
git add $files_to_stage
fi
;;
3)
echo -e "${YELLOW}Skipping $display_name${NC}"
return
;;
q)
echo -e "${RED}Quitting workflow${NC}"
exit 0
;;
esac
# Check if there's anything to commit
if git diff --cached --quiet; then
echo -e "${YELLOW}No changes staged, skipping${NC}"
return
fi
# Suggest commit message
suggested_msg=$(suggest_commit_message "$repo_path")
echo -e "${CYAN}Suggested commit message: $suggested_msg${NC}"
echo -e "${YELLOW}Enter commit message (or press Enter to accept suggestion):${NC}"
read -r commit_msg
if [[ -z "$commit_msg" ]]; then
commit_msg="$suggested_msg"
fi
# Confirm commit
echo -e "${YELLOW}Commit with message: '$commit_msg'? (y/N)${NC}"
read -r confirm
if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then
git commit -m "$commit_msg"
echo -e "${GREEN}✅ Committed changes in $display_name${NC}"
else
echo -e "${YELLOW}Commit cancelled${NC}"
fi
}
EOF
```
### Step 3: Safe Push with Conflict Resolution
```bash
# Add push and conflict resolution
cat >> ~/workspace-sync.sh << 'EOF'
# Function to safely push with conflict resolution
safe_push() {
local repo_name="$1"
local display_name="$2"
local repo_path="$WORKSPACE/$repo_name"
cd "$repo_path"
# Check if we're ahead of origin
if git log --oneline origin/main..main 2>/dev/null | grep -q .; then
echo -e "${BLUE}📤 Pushing $display_name...${NC}"
# Try to push
if git push 2>/dev/null; then
echo -e "${GREEN}✅ Pushed $display_name successfully${NC}"
else
echo -e "${YELLOW}⚠️ Push failed, attempting to pull and rebase...${NC}"
# Fetch latest changes
git fetch
# Check if we have diverged
if git log --oneline main..origin/main | grep -q .; then
echo -e "${YELLOW}Local and remote have diverged. Options:${NC}"
echo " 1) Pull --rebase (rebase your changes on top of remote)"
echo " 2) Pull --no-rebase (merge remote changes)"
echo " 3) Skip pushing and handle manually"
read -r choice
case $choice in
1)
git pull --rebase
if git push; then
echo -e "${GREEN}✅ Pushed after rebase${NC}"
else
echo -e "${RED}❌ Still failed to push after rebase${NC}"
echo -e "${CYAN}Please resolve conflicts manually in $repo_path${NC}"
fi
;;
2)
git pull --no-rebase
if git push; then
echo -e "${GREEN}✅ Pushed after merge${NC}"
else
echo -e "${RED}❌ Still failed to push after merge${NC}"
fi
;;
3)
echo -e "${YELLOW}Skipping push. Please handle manually.${NC}"
;;
esac
else
# Just need to push
git push
echo -e "${GREEN}✅ Pushed $display_name${NC}"
fi
fi
fi
}
# Main execution
main() {
# Sort repos by dependency order
sorted_repos=($(printf '%s\n' "${REPOS_WITH_CHANGES[@]}" | sort -k3 -t':'))
echo -e "${BLUE}🚀 Starting interactive sync process...${NC}"
echo
for repo_info in "${sorted_repos[@]}"; do
IFS=':' read -r repo_name display_name order <<< "$repo_info"
safe_commit "$repo_name" "$display_name"
echo
done
# Ask about pushing
echo -e "${YELLOW}Push all committed changes to origin? (y/N)${NC}"
read -r push_confirm
if [[ "$push_confirm" == "y" || "$push_confirm" == "Y" ]]; then
echo -e "${BLUE}📤 Pushing in dependency order...${NC}"
echo
for repo_info in "${sorted_repos[@]}"; do
IFS=':' read -r repo_name display_name order <<< "$repo_info"
safe_push "$repo_name" "$display_name"
echo
done
else
echo -e "${YELLOW}Skipping push. Changes are committed locally.${NC}"
fi
echo -e "${GREEN}✨ Workspace sync complete!${NC}"
}
# Run main function
main
EOF
echo "Created workspace-sync.sh at ~/workspace-sync.sh"
```
### Step 4: Create VS Code Task for Easy Access
```bash
# Create .vscode/tasks.json in each repo
cat > /Users/sd9235/code/mygh/learning_ai_common_plat/.vscode/tasks.json << 'EOF'
{
"version": "2.0.0",
"tasks": [
{
"label": "Workspace Sync",
"type": "shell",
"command": "~/workspace-sync.sh",
"group": "build",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "new"
},
"problemMatcher": []
}
]
}
EOF
# Copy to other repos
cp /Users/sd9235/code/mygh/learning_ai_common_plat/.vscode/tasks.json \
/Users/sd9235/code/mygh/learning_voice_ai_agent/.vscode/tasks.json
cp /Users/sd9235/code/mygh/learning_ai_common_plat/.vscode/tasks.json \
/Users/sd9235/code/mygh/learning_multimodal_memory_agents/.vscode/tasks.json
```
## Usage
### Option 1: Run from Terminal
```bash
~/workspace-sync.sh
```
### Option 2: Run from VS Code
1. Open any workspace repo
2. Press `Cmd+Shift+P` (macOS) or `Ctrl+Shift+P` (Windows/Linux)
3. Type "Tasks: Run Task"
4. Select "Workspace Sync"
### Option 3: Create Git Alias
```bash
git config --global alias.ws '!~/workspace-sync.sh'
# Then run: git ws
```
## Features
### 🔍 Smart Detection
- Identifies staged, unstaged, and untracked files
- Detects branch divergence (ahead/behind)
- Shows change statistics
### 📝 Intelligent Suggestions
- Suggests commit messages based on changed file types
- Follows conventional commit format
- Recognizes CI, docs, test, and feature changes
### 🛡️ Safety First
- Interactive review before each commit
- Confirmation before destructive operations
- Preserves developer knowledge and consent
### 🔄 Conflict Resolution
- Automatic fetch before push
- Interactive choice between rebase/merge
- Clear guidance for manual resolution
### 📊 Dependency Ordering
- Automatically pushes in correct order
- Common Platform → LysnrAI → MindLyst
- Prevents dependency issues
## Example Session
```
🔍 Scanning workspace for uncommitted changes...
📁 Common Platform (learning_ai_common_plat)
Staged changes:
src/index.ts | 2 +-
README.md | 5 +++++
Untracked files:
new-feature.ts
📁 LysnrAI (learning_voice_ai_agent)
Unstaged changes:
src/app.py | 10 ++++++++++
requirements.txt | 2 +-
📊 Summary: Found changes in 2 repo(s)
🚀 Starting interactive sync process...
📋 Reviewing changes for Common Platform
Press Enter to see diff summary, 's' to skip, 'q' to quit
Choose action:
1) Stage all changes and commit
2) Review and stage specific files
3) Skip this repo
q) Quit workflow
1
Suggested commit message: feat: implement new features and improvements
Enter commit message (or press Enter to accept suggestion): feat: add new feature and update docs
Commit with message: 'feat: add new feature and update docs'? (y/N) y
✅ Committed changes in Common Platform
...
```
## Best Practices
1. **Run before switching contexts**: Always run workspace sync before moving to another task
2. **Review carefully**: The interactive review prevents accidental commits
3. **Handle conflicts manually**: If rebase/merge fails, resolve conflicts to understand what changed
4. **Commit frequently**: Small, focused commits are easier to review and sync
5. **Use descriptive messages**: Good commit messages help track changes across repos
## Troubleshooting
### Permission Issues
```bash
chmod +x ~/workspace-sync.sh
```
### Git Credentials
```bash
git config --global credential.helper store
```
### SSH Key Issues
```bash
ssh-add ~/.ssh/id_rsa
```
## Customization
You can customize the script by:
- Adding more repos to the `REPOS` array
- Modifying commit message suggestions
- Adding custom conflict resolution strategies
- Integrating with your team's workflow
---
**Remember**: This workflow preserves your knowledge and control over what gets committed. Never skip the review steps!

View File

@ -17,6 +17,9 @@
"build": "tsc",
"test": "vitest run"
},
"dependencies": {
"@bytelyst/errors": "workspace:*"
},
"peerDependencies": {
"jose": ">=5.0.0",
"bcryptjs": ">=2.4.0"

View File

@ -0,0 +1,101 @@
/**
* End-to-end auth flow test: create token extract auth require role.
* Exercises the full JWT lifecycle without network calls.
*/
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
import {
createJwtUtils,
extractAuth,
requireRole,
hashPassword,
verifyPassword,
} from '../index.js';
const SECRET = 'e2e-test-jwt-secret-at-least-32-chars!!';
describe('E2E auth flow', () => {
beforeAll(() => {
process.env.JWT_SECRET = SECRET;
});
afterAll(() => {
delete process.env.JWT_SECRET;
});
it('full flow: login credentials → JWT → authenticated request → role check', async () => {
// 1. Simulate password verification (login step)
const storedHash = await hashPassword('SecretPass123!');
const passwordValid = await verifyPassword('SecretPass123!', storedHash);
expect(passwordValid).toBe(true);
// 2. Issue access token (platform-service would do this)
const jwt = createJwtUtils({ issuer: 'lysnrai' });
const accessToken = await jwt.createAccessToken({
sub: 'user-admin-001',
email: 'admin@lysnrai.com',
role: 'super_admin',
productId: 'lysnrai',
});
expect(typeof accessToken).toBe('string');
// 3. Simulate authenticated request (any service receives this)
const req = { headers: { authorization: `Bearer ${accessToken}` } };
const auth = await extractAuth(req);
expect(auth.sub).toBe('user-admin-001');
expect(auth.email).toBe('admin@lysnrai.com');
expect(auth.role).toBe('super_admin');
expect(auth.productId).toBe('lysnrai');
// 4. Role-gated endpoint check
const adminAuth = await requireRole(req, 'super_admin', 'admin');
expect(adminAuth.sub).toBe('user-admin-001');
// 5. Role rejection for wrong role
await expect(requireRole(req, 'viewer')).rejects.toMatchObject({
statusCode: 403,
});
});
it('refresh token cannot be used for authenticated requests', async () => {
const jwt = createJwtUtils({ issuer: 'lysnrai' });
const refreshToken = await jwt.createRefreshToken({
sub: 'user-001',
productId: 'lysnrai',
});
const req = { headers: { authorization: `Bearer ${refreshToken}` } };
await expect(extractAuth(req)).rejects.toMatchObject({
statusCode: 401,
message: 'Invalid or expired token',
});
});
it('cross-issuer tokens are rejected by verifyToken but pass extractAuth (no issuer check)', async () => {
// extractAuth only checks type=access via jwtVerify without issuer
// But verifyToken checks issuer — this is the cross-service security model
const jwtA = createJwtUtils({ issuer: 'lysnrai' });
const jwtB = createJwtUtils({ issuer: 'mindlyst' });
const tokenA = await jwtA.createAccessToken({
sub: 'u1',
email: 'a@b.com',
role: 'user',
});
// verifyToken with wrong issuer rejects
const resultB = await jwtB.verifyToken(tokenA);
expect(resultB).toBeNull();
// verifyToken with correct issuer passes
const resultA = await jwtA.verifyToken(tokenA);
expect(resultA).not.toBeNull();
expect(resultA!.sub).toBe('u1');
});
it('wrong password fails login flow before token issuance', async () => {
const storedHash = await hashPassword('CorrectPassword');
const passwordValid = await verifyPassword('WrongPassword', storedHash);
expect(passwordValid).toBe(false);
// No token should be issued — the flow stops here
});
});

View File

@ -0,0 +1,117 @@
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
import { createJwtUtils, extractAuth, requireRole } from '../index.js';
const SECRET = 'test-jwt-secret-at-least-32-chars-long!!';
let validAccessToken: string;
let refreshToken: string;
describe('extractAuth', () => {
beforeAll(async () => {
process.env.JWT_SECRET = SECRET;
const jwt = createJwtUtils({ issuer: 'test-issuer' });
validAccessToken = await jwt.createAccessToken({
sub: 'user-1',
email: 'test@example.com',
role: 'admin',
productId: 'lysnrai',
});
refreshToken = await jwt.createRefreshToken({ sub: 'user-1' });
});
afterAll(() => {
delete process.env.JWT_SECRET;
});
it('extracts auth from valid Bearer token', async () => {
const req = { headers: { authorization: `Bearer ${validAccessToken}` } };
const payload = await extractAuth(req);
expect(payload.sub).toBe('user-1');
expect(payload.email).toBe('test@example.com');
expect(payload.role).toBe('admin');
expect(payload.productId).toBe('lysnrai');
expect(payload.type).toBe('access');
});
it('throws 401 when no authorization header', async () => {
const req = { headers: {} };
await expect(extractAuth(req)).rejects.toMatchObject({
statusCode: 401,
message: 'Unauthorized',
});
});
it('throws 401 when authorization header is not Bearer', async () => {
const req = { headers: { authorization: 'Basic abc123' } };
await expect(extractAuth(req)).rejects.toMatchObject({
statusCode: 401,
message: 'Unauthorized',
});
});
it('throws 401 for invalid token', async () => {
const req = { headers: { authorization: 'Bearer garbage.not.valid' } };
await expect(extractAuth(req)).rejects.toMatchObject({
statusCode: 401,
message: 'Invalid or expired token',
});
});
it('throws 401 for refresh token (requires access type)', async () => {
const req = { headers: { authorization: `Bearer ${refreshToken}` } };
await expect(extractAuth(req)).rejects.toMatchObject({
statusCode: 401,
message: 'Invalid or expired token',
});
});
it('throws 401 for empty Bearer value', async () => {
const req = { headers: { authorization: 'Bearer ' } };
await expect(extractAuth(req)).rejects.toMatchObject({
statusCode: 401,
});
});
});
describe('requireRole', () => {
beforeAll(() => {
process.env.JWT_SECRET = SECRET;
});
afterAll(() => {
delete process.env.JWT_SECRET;
});
it('passes when role matches', async () => {
const req = { headers: { authorization: `Bearer ${validAccessToken}` } };
const payload = await requireRole(req, 'admin');
expect(payload.sub).toBe('user-1');
expect(payload.role).toBe('admin');
});
it('passes when role is in allowed list', async () => {
const req = { headers: { authorization: `Bearer ${validAccessToken}` } };
const payload = await requireRole(req, 'viewer', 'admin', 'super_admin');
expect(payload.role).toBe('admin');
});
it('throws 403 when role does not match', async () => {
const req = { headers: { authorization: `Bearer ${validAccessToken}` } };
await expect(requireRole(req, 'super_admin')).rejects.toMatchObject({
statusCode: 403,
message: 'Insufficient permissions',
});
});
it('passes with no roles specified (any authenticated user)', async () => {
const req = { headers: { authorization: `Bearer ${validAccessToken}` } };
const payload = await requireRole(req);
expect(payload.sub).toBe('user-1');
});
it('throws 401 when no auth header (before checking role)', async () => {
const req = { headers: {} };
await expect(requireRole(req, 'admin')).rejects.toMatchObject({
statusCode: 401,
});
});
});

View File

@ -3,6 +3,7 @@
*/
import { jwtVerify } from 'jose';
import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors';
import type { AuthPayload } from './types.js';
function getSecret(): Uint8Array {
@ -23,7 +24,7 @@ export async function extractAuth(req: {
}): Promise<AuthPayload> {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
throw Object.assign(new Error('Unauthorized'), { statusCode: 401 });
throw new UnauthorizedError();
}
const token = auth.slice(7);
try {
@ -32,9 +33,7 @@ export async function extractAuth(req: {
if (p.type !== 'access') throw new Error('Not an access token');
return p;
} catch {
throw Object.assign(new Error('Invalid or expired token'), {
statusCode: 401,
});
throw new UnauthorizedError('Invalid or expired token');
}
}
@ -49,9 +48,7 @@ export async function requireRole(
): Promise<AuthPayload> {
const payload = await extractAuth(req);
if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) {
throw Object.assign(new Error('Insufficient permissions'), {
statusCode: 403,
});
throw new ForbiddenError('Insufficient permissions');
}
return payload;
}

6
pnpm-lock.yaml generated
View File

@ -67,6 +67,9 @@ importers:
packages/auth:
dependencies:
'@bytelyst/errors':
specifier: workspace:*
version: link:../errors
bcryptjs:
specifier: '>=2.4.0'
version: 3.0.3
@ -335,6 +338,9 @@ importers:
'@azure/cosmos':
specifier: ^4.2.0
version: 4.9.1(@azure/core-client@1.10.1)
'@bytelyst/auth':
specifier: workspace:*
version: link:../../packages/auth
'@bytelyst/config':
specifier: workspace:*
version: link:../../packages/config

View File

@ -13,6 +13,7 @@
"lint": "eslint src/"
},
"dependencies": {
"@bytelyst/auth": "workspace:*",
"@bytelyst/config": "workspace:*",
"@bytelyst/cosmos": "workspace:*",
"@bytelyst/errors": "workspace:*",

View File

@ -1,48 +1,6 @@
/**
* Re-export from @bytelyst/auth shared across all services.
* JWT auth middleware validates tokens issued by platform-service.
* Shares the same JWT_SECRET so it can verify without network calls.
*/
import { jwtVerify } from 'jose';
import type { FastifyRequest } from 'fastify';
import { UnauthorizedError, ForbiddenError } from './errors.js';
export interface AuthPayload {
sub: string;
email?: string;
role?: string;
productId?: string;
type?: string;
}
function getSecret(): Uint8Array {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET must be set');
return new TextEncoder().encode(secret);
}
export async function verifyToken(token: string): Promise<AuthPayload> {
const { payload } = await jwtVerify(token, getSecret());
return payload as AuthPayload;
}
export async function extractAuth(req: FastifyRequest): Promise<AuthPayload> {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) throw new UnauthorizedError();
const token = auth.slice(7);
try {
const payload = await verifyToken(token);
if (payload.type !== 'access') throw new Error('Not an access token');
return payload;
} catch {
throw new UnauthorizedError('Invalid or expired token');
}
}
export async function requireRole(req: FastifyRequest, ...roles: string[]): Promise<AuthPayload> {
const payload = await extractAuth(req);
if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) {
throw new ForbiddenError('Insufficient permissions');
}
return payload;
}
export { extractAuth, requireRole, type AuthPayload } from '@bytelyst/auth';