Create CLI tool and SDK for programmatic access (#103)

* Initial plan

* feat: Implement CLI tool with init, upload, and verify commands

Co-authored-by: PatrickFanella <61631520+PatrickFanella@users.noreply.github.com>

* fix: Resolve linting issues and add CLI-specific eslint config

Co-authored-by: PatrickFanella <61631520+PatrickFanella@users.noreply.github.com>

* refactor: Address code review feedback - improve validation, memory efficiency, and type safety

Co-authored-by: PatrickFanella <61631520+PatrickFanella@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: PatrickFanella <61631520+PatrickFanella@users.noreply.github.com>
This commit was merged in pull request #103.
This commit is contained in:
Copilot
2025-10-31 10:33:58 -05:00
committed by GitHub
parent 8064bb1df1
commit 5b5e57e095
86 changed files with 6401 additions and 2013 deletions

View File

@@ -30,6 +30,7 @@
"typechain-types/",
"coverage/",
".nyc_output/",
"web/"
"web/",
"cli/"
]
}

View File

@@ -9,6 +9,7 @@ This document summarizes the WCAG 2.1 Level AA accessibility improvements implem
### 1. Semantic HTML Structure
#### Skip-to-Content Link
- **Location**: `web/app/layout.tsx`
- **Implementation**: Added keyboard-accessible skip link that appears on first Tab press
- **Functionality**: Jumps to main content area (#main-content)
@@ -16,12 +17,14 @@ This document summarizes the WCAG 2.1 Level AA accessibility improvements implem
- **Testing**: Verified in `web/e2e/07-accessibility.spec.ts`
#### ARIA Landmarks
- **Main Content**: Added `id="main-content"` and `role="main"` to main element
- **Navigation**: Tab navigation uses `<nav>` with `aria-label="Main navigation"`
- **Sections**: All major sections use `aria-labelledby` attributes
- **Testing**: Validated proper landmark structure in accessibility tests
#### Heading Hierarchy
- **H1**: Single h1 per page ("Internet-ID")
- **H2**: Section headings (Upload to IPFS, Create manifest, etc.)
- **H3**: Subsection headings (Result, Share, etc.)
@@ -32,46 +35,38 @@ This document summarizes the WCAG 2.1 Level AA accessibility improvements implem
#### Component Enhancements
**LoadingSpinner.tsx**
```tsx
// Added ARIA attributes
<div
role="status"
aria-live="polite"
aria-label={message || "Loading"}
aria-busy="true"
/>
<div role="status" aria-live="polite" aria-label={message || "Loading"} aria-busy="true" />
```
**Toast.tsx**
```tsx
// Toast notifications with proper ARIA
<div
role="alert"
aria-live={type === "error" ? "assertive" : "polite"}
aria-atomic="true"
/>
<div role="alert" aria-live={type === "error" ? "assertive" : "polite"} aria-atomic="true" />
```
**ErrorMessage.tsx**
```tsx
// Error messages announced immediately
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
/>
<div role="alert" aria-live="assertive" aria-atomic="true" />
```
**CopyButton Component**
```tsx
// Dynamic ARIA labels based on state
aria-label={copied
? `${label} copied to clipboard`
aria-label={copied
? `${label} copied to clipboard`
: `Copy ${label} to clipboard`}
aria-live="polite"
```
#### Form Accessibility
- All file inputs wrapped with descriptive labels
- Added `aria-required="true"` to required fields
- Input IDs linked to label `htmlFor` attributes
@@ -80,10 +75,11 @@ aria-live="polite"
### 3. Keyboard Navigation
#### Focus Management
- **Focus Indicators**: 3px solid blue outline (#1d4ed8) with 2px offset
- **Focus Shadow**: Additional subtle shadow for enhanced visibility
- **Contrast**: Focus indicators maintain 3:1 contrast ratio minimum
- **CSS Implementation**:
- **CSS Implementation**:
```css
*:focus-visible {
outline: 3px solid #1d4ed8;
@@ -93,12 +89,14 @@ aria-live="polite"
```
#### Keyboard Shortcuts
- **Tab/Shift+Tab**: Navigate through interactive elements
- **Enter/Space**: Activate buttons and links
- **Escape**: Close toast notifications (implemented in Toast component)
- **Skip Link**: Visible on Tab, activates with Enter
#### Tab Order
- Logical tab order maintained through proper HTML structure
- No `tabindex` manipulation (except for skip link)
- All interactive elements keyboard accessible
@@ -106,18 +104,21 @@ aria-live="polite"
### 4. Color Contrast (WCAG AA Compliance)
#### Updated Color Palette
- **Links**: `#1d4ed8` (darker blue for better contrast)
- **Link Hover**: `#1e40af` (even darker on hover)
- **Link Visited**: `#7c3aed` (purple with sufficient contrast)
- **Focus Outline**: `#1d4ed8` (3px solid)
#### Contrast Ratios
- Normal text: 4.5:1 minimum ✅
- Large text (18pt+): 3:1 minimum ✅
- Interactive elements: 3:1 minimum ✅
- Focus indicators: 3:1 minimum ✅
#### Button States
- **Default**: White background with gray border
- **Hover**: `#f3f4f6` background with darker border
- **Active**: `#e5e7eb` background
@@ -128,10 +129,13 @@ aria-live="polite"
#### Automated Testing
**Custom Audit Script** (`web/scripts/accessibility-audit.js`)
```bash
npm run audit:a11y
```
Checks:
- ARIA labels on buttons
- Alt text on images
- Form labels
@@ -139,10 +143,13 @@ Checks:
- Role attributes
**Playwright Tests** (`web/e2e/07-accessibility.spec.ts`)
```bash
npm run test:a11y
```
Covers:
- Document structure
- Skip-to-content functionality
- Keyboard navigation
@@ -151,10 +158,13 @@ Covers:
- Tab button states
**Lighthouse Integration**
```bash
npm run perf:audit
```
Validates:
- Accessibility score (target: 90+)
- Color contrast
- ARIA validity
@@ -164,6 +174,7 @@ Validates:
#### Documentation
**ACCESSIBILITY.md** (4.9 KB)
- WCAG 2.1 Level AA conformance statement
- Feature documentation
- Keyboard shortcuts reference
@@ -172,6 +183,7 @@ Validates:
- Issue reporting process
**ACCESSIBILITY_TESTING.md** (7.5 KB)
- Quick start guide
- Automated testing instructions
- Manual testing procedures
@@ -184,16 +196,19 @@ Validates:
### 6. Additional Improvements
#### Image Accessibility
- QR codes: Descriptive alt text (e.g., "QR code for youtube verification link")
- All images verified to have alt attributes
- Decorative images marked with `aria-hidden="true"`
#### Touch Targets
- Minimum 44x44px size for all interactive elements
- Consistent padding across form controls
- Mobile-friendly design maintained
#### Error Handling
- Error messages use `role="alert"`
- Assertive announcement for critical errors
- Clear error messages with suggestions
@@ -202,6 +217,7 @@ Validates:
## 📊 Testing Results
### Automated Audit Results
```
✅ All accessibility checks passed!
- 45 checks completed
@@ -212,6 +228,7 @@ Validates:
```
### Build & Lint Status
```
✅ Build: Successful
✅ Lint: Passed (only unrelated warnings)
@@ -219,6 +236,7 @@ Validates:
```
### Test Coverage
- ✅ Skip-to-content link
- ✅ Document structure and landmarks
- ✅ Heading hierarchy
@@ -232,6 +250,7 @@ Validates:
## 🎯 WCAG 2.1 Level AA Conformance
### Principle 1: Perceivable
- ✅ 1.1.1 Non-text Content (Level A)
- ✅ 1.3.1 Info and Relationships (Level A)
- ✅ 1.3.2 Meaningful Sequence (Level A)
@@ -239,6 +258,7 @@ Validates:
- ✅ 1.4.11 Non-text Contrast (Level AA)
### Principle 2: Operable
- ✅ 2.1.1 Keyboard (Level A)
- ✅ 2.1.2 No Keyboard Trap (Level A)
- ✅ 2.4.1 Bypass Blocks (Level A)
@@ -247,6 +267,7 @@ Validates:
- ✅ 2.4.7 Focus Visible (Level AA)
### Principle 3: Understandable
- ✅ 3.1.1 Language of Page (Level A)
- ✅ 3.2.3 Consistent Navigation (Level AA)
- ✅ 3.2.4 Consistent Identification (Level AA)
@@ -254,32 +275,39 @@ Validates:
- ✅ 3.3.2 Labels or Instructions (Level A)
### Principle 4: Robust
- ✅ 4.1.2 Name, Role, Value (Level A)
- ✅ 4.1.3 Status Messages (Level AA)
## 📝 Files Modified
### Components
- `web/app/components/LoadingSpinner.tsx` - Added ARIA attributes
- `web/app/components/Toast.tsx` - Added ARIA live regions, keyboard support
- `web/app/components/ErrorMessage.tsx` - Added role="alert"
### Pages
- `web/app/layout.tsx` - Added skip-to-content link
- `web/app/page.tsx` - Added ARIA landmarks, labels, improved form accessibility
### Styles
- `web/app/globals.css` - Enhanced focus indicators, improved color contrast
### Tests
- `web/e2e/07-accessibility.spec.ts` - Comprehensive accessibility test suite
### Documentation
- `web/ACCESSIBILITY.md` - User-facing accessibility documentation
- `web/ACCESSIBILITY_TESTING.md` - Developer testing guide
- `README.md` - Added accessibility documentation links
### Tooling
- `web/scripts/accessibility-audit.js` - Automated audit script
- `web/package.json` - Added `audit:a11y` and `test:a11y` scripts
@@ -297,6 +325,7 @@ While WCAG 2.1 Level AA compliance has been achieved, these enhancements could b
## 📞 Support
For accessibility questions or issues:
- Email: support@subculture.io
- GitHub Issues: https://github.com/subculture-collective/internet-id/issues

View File

@@ -12,16 +12,19 @@ Implemented a comprehensive Redis-based caching layer to improve API performance
## Files Added
### Core Implementation
- `scripts/services/cache.service.ts` - Cache service with Redis client, connection pooling, metrics
- `test/services/cache.test.ts` - Comprehensive test suite for cache operations
### Documentation
- `docs/CACHING_ARCHITECTURE.md` - Complete architecture guide with usage examples
- `CACHING_SECURITY_SUMMARY.md` - Security analysis and recommendations
- Updated `README.md` - Setup instructions and performance section
- Updated `.env.example` - Configuration documentation
### Integration Points
- `scripts/api.ts` - Initialize cache service on startup
- `scripts/app.ts` - Initialize cache in modular app
- `scripts/routes/content.routes.ts` - Cache content metadata and verifications
@@ -33,12 +36,14 @@ Implemented a comprehensive Redis-based caching layer to improve API performance
## Acceptance Criteria Status
**Integrate Redis with connection pooling**
- Redis client with automatic reconnection
- Connection pooling via redis@5.9.0
- Exponential backoff retry strategy
- 5-second connection timeout
**Implement caching strategy**
- Content metadata: 10 minute TTL
- Manifest lookups: 15 minute TTL
- Verification status: 5 minute TTL
@@ -47,29 +52,34 @@ Implemented a comprehensive Redis-based caching layer to improve API performance
- User sessions: Handled by NextAuth (no change needed)
**Cache invalidation on writes**
- Content registration → invalidate content cache
- New binding → invalidate binding cache
- New verification → invalidate verification cache
- Pattern-based deletion for related keys
**Cache-aside pattern with DB fallback**
- `cacheService.getOrSet()` implements cache-aside
- Automatic fallback on cache miss
- Graceful degradation when Redis unavailable
**Cache hit/miss metrics**
- Endpoint: `GET /api/cache/metrics`
- Tracks: hits, misses, sets, deletes, errors
- Calculates hit rate percentage
- Ready for Prometheus/Grafana integration
**Configure eviction policies**
- LRU (Least Recently Used) eviction policy
- 256MB max memory limit
- Automatic configuration on startup
- Prevents memory exhaustion
**Document architecture**
- Complete architecture guide
- Usage examples and best practices
- Troubleshooting section
@@ -79,6 +89,7 @@ Implemented a comprehensive Redis-based caching layer to improve API performance
## Technical Highlights
### Cache Service Features
```typescript
// Connection pooling and resilience
- Automatic reconnection with exponential backoff
@@ -100,6 +111,7 @@ Implemented a comprehensive Redis-based caching layer to improve API performance
```
### API Integration Pattern
```typescript
// Before (no caching)
const item = await prisma.content.findUnique({ where: { contentHash } });
@@ -128,6 +140,7 @@ await cacheService.delete(`content:${hash}`);
## Testing
### Test Coverage
- ✅ Basic cache operations (get/set/delete)
- ✅ Cache-aside pattern
- ✅ Metrics collection
@@ -137,6 +150,7 @@ await cacheService.delete(`content:${hash}`);
- ✅ Error handling
### Test Results
```
278 passing (3s)
13 pending (Redis tests skip when unavailable - expected)
@@ -144,6 +158,7 @@ await cacheService.delete(`content:${hash}`);
```
### Linting & Formatting
- ✅ ESLint passed (warnings are pre-existing)
- ✅ Prettier formatting applied
- ✅ TypeScript compilation successful
@@ -151,15 +166,18 @@ await cacheService.delete(`content:${hash}`);
## Security Analysis
### Code Review
- ✅ No issues found (GitHub Copilot Code Review)
### CodeQL Analysis
- ⚠️ 4 informational alerts (false positives)
- All alerts related to logging with sanitized keys
- No actual vulnerabilities introduced
- Documented in `CACHING_SECURITY_SUMMARY.md`
### Security Best Practices
- ✅ Input validation (keys are validated hashes/platform names)
- ✅ Sanitized logging (regex removes unsafe characters)
- ✅ No sensitive data cached
@@ -170,14 +188,16 @@ await cacheService.delete(`content:${hash}`);
## Performance Impact
### Expected Improvements
| Metric | Before | After | Improvement |
|--------|--------|-------|-------------|
| Database read queries | 100% | 20-40% | 60-80% reduction |
| Metric | Before | After | Improvement |
| -------------------------- | -------- | ------------- | ------------------ |
| Database read queries | 100% | 20-40% | 60-80% reduction |
| API response time (cached) | Baseline | 30-50% faster | 30-50% improvement |
| Requests/second capacity | Baseline | 2-3x | 100-200% increase |
| Cache hit rate | N/A | 70%+ (target) | New metric |
| Requests/second capacity | Baseline | 2-3x | 100-200% increase |
| Cache hit rate | N/A | 70%+ (target) | New metric |
### Resource Usage
- **Memory**: 256MB Redis (configurable)
- **Network**: Minimal (local Redis or private network)
- **CPU**: Negligible overhead
@@ -186,6 +206,7 @@ await cacheService.delete(`content:${hash}`);
## Deployment Guide
### Development Setup
```bash
# 1. Start Redis
docker compose up -d redis
@@ -201,6 +222,7 @@ curl http://localhost:3001/api/cache/metrics
```
### Production Checklist
- [ ] Use Redis with TLS (rediss://)
- [ ] Enable Redis AUTH password
- [ ] Configure firewall/VPC restrictions
@@ -213,15 +235,19 @@ curl http://localhost:3001/api/cache/metrics
## Integration with Roadmap
### Issue #10 (Optimization Bucket)
✅ Caching layer reduces database load and improves performance
### Issue #13 (Observability)
✅ Cache metrics endpoint ready for dashboard integration
- Endpoint: `/api/cache/metrics`
- Returns JSON with hits, misses, hit rate, errors
- Can be polled by Prometheus/Grafana
### Future Enhancements
- Cache warming on startup
- Multi-tier caching (L1 in-memory + L2 Redis)
- Cache stampede protection
@@ -231,6 +257,7 @@ curl http://localhost:3001/api/cache/metrics
## Lessons Learned
### What Went Well
- Cache-aside pattern simplified implementation
- TypeScript generics provided excellent type safety
- Graceful degradation made Redis optional
@@ -238,12 +265,14 @@ curl http://localhost:3001/api/cache/metrics
- Documentation upfront saved time later
### Challenges Overcome
- CodeQL false positives required sanitization
- Testing without Redis needed skip logic
- Balancing TTLs for different data types
- Ensuring cache invalidation completeness
### Best Practices Applied
- Small, focused commits with clear messages
- Test-driven development approach
- Security analysis throughout process
@@ -253,6 +282,7 @@ curl http://localhost:3001/api/cache/metrics
## Validation
### Manual Testing
- [x] API starts successfully with Redis
- [x] API starts successfully without Redis
- [x] Cache hit/miss metrics update correctly
@@ -261,6 +291,7 @@ curl http://localhost:3001/api/cache/metrics
- [x] No performance regression without Redis
### Automated Testing
- [x] All unit tests pass
- [x] Cache service tests comprehensive
- [x] Integration tests unaffected
@@ -268,6 +299,7 @@ curl http://localhost:3001/api/cache/metrics
- [x] Formatting correct
### Security Validation
- [x] Code review passed
- [x] CodeQL analysis completed
- [x] No new vulnerabilities
@@ -278,14 +310,16 @@ curl http://localhost:3001/api/cache/metrics
The caching layer implementation is **complete and production-ready**. All acceptance criteria have been met, comprehensive testing has been performed, and security has been validated. The implementation follows best practices, includes thorough documentation, and is designed for easy deployment and monitoring.
### Key Achievements
✅ 60-80% reduction in database load
✅ 30-50% faster response times
✅ Zero downtime deployment (optional feature)
✅ Comprehensive observability
✅ Production-ready security
✅ Full documentation suite
✅ Full documentation suite
### Recommended Next Steps
1. **Merge PR** to main branch
2. **Deploy to staging** with Redis
3. **Monitor metrics** for 1-2 weeks

View File

@@ -1,16 +1,19 @@
# Security Summary: Caching Layer Implementation
## Overview
This document summarizes the security considerations and findings related to the Redis caching layer implementation.
## Security Analysis Performed
### 1. Code Review
- **Status**: ✅ Passed
- **Result**: No issues found
- **Tool**: GitHub Copilot Code Review
### 2. CodeQL Static Analysis
- **Status**: ⚠️ 4 Informational Alerts (False Positives)
- **Tool**: CodeQL for JavaScript/TypeScript
@@ -25,10 +28,12 @@ This document summarizes the security considerations and findings related to the
**Analysis**: These are **false positives** for the following reasons:
1. **Input Sanitization**: All cache keys are sanitized before logging:
```typescript
const safeKey = String(key).replace(/[^\w:.-]/g, "_");
console.error(`[Cache] Error getting key ${safeKey}:`, error);
```
The regex removes all characters except word characters, colons, dots, and hyphens.
2. **Usage Context**: These are console logging statements, not actual format string operations (like printf). JavaScript template literals don't have the same format string vulnerabilities as C-style format strings.
@@ -48,35 +53,41 @@ This document summarizes the security considerations and findings related to the
## Security Best Practices Implemented
### 1. Input Validation
- All cache keys are constructed from validated inputs
- Platform names must match `^[a-z0-9_-]+$` pattern
- Content hashes must be valid hex with 0x prefix
- Platform IDs are length-limited and validated
### 2. Cache Key Sanitization
- Keys sanitized before logging: `/[^\w:.-]/g` removed
- Prevents injection of control characters
- Limits character set to alphanumeric + `:.-`
### 3. Connection Security
- Redis connection uses authenticated URL
- Configurable via environment variable (not hardcoded)
- Automatic reconnection with exponential backoff
- Connection timeouts configured (5 seconds)
### 4. Error Handling
- All cache operations wrapped in try-catch
- Graceful degradation on Redis unavailability
- No sensitive data in error messages
- Errors logged securely with sanitized keys
### 5. Memory Safety
- LRU eviction policy prevents memory exhaustion
- Max memory limit enforced (256MB default)
- TTLs ensure automatic expiration
- No unbounded growth possible
### 6. Data Integrity
- JSON serialization/deserialization with error handling
- Type-safe TypeScript interfaces
- No eval() or dynamic code execution
@@ -92,20 +103,21 @@ This document summarizes the security considerations and findings related to the
✅ **Sensitive Data Exposure**: No secrets logged or cached
✅ **Broken Authentication**: Not applicable (cache doesn't handle auth)
✅ **Insecure Deserialization**: JSON.parse with proper error handling
✅ **Known Vulnerable Dependencies**: Using redis@5.9.0 (no known vulnerabilities)
✅ **Known Vulnerable Dependencies**: Using redis@5.9.0 (no known vulnerabilities)
## Recommendations
### For Production Deployment
1. **Redis Configuration**
```bash
# Use TLS for Redis connection in production
REDIS_URL=rediss://username:password@host:6380
# Enable Redis AUTH
requirepass your-strong-password
# Bind to specific interface (not 0.0.0.0)
bind 127.0.0.1
```
@@ -142,6 +154,7 @@ Security-related tests in `test/services/cache.test.ts`:
The caching layer implementation introduces **no new security vulnerabilities**. The CodeQL alerts are false positives related to logging statements with sanitized input. All security best practices have been followed, including input validation, secure error handling, and graceful degradation.
### Risk Assessment
- **Critical**: 0
- **High**: 0
- **Medium**: 0

View File

@@ -149,6 +149,7 @@ web/
### Configuration
**playwright.config.ts** features:
- Parallel test execution
- Automatic retries on CI (2 retries)
- HTML, GitHub, and list reporters
@@ -162,6 +163,7 @@ web/
### NPM Scripts
Added to `web/package.json`:
- `test:e2e` - Run all E2E tests
- `test:e2e:ui` - Interactive UI mode
- `test:e2e:headed` - Run with visible browser
@@ -175,6 +177,7 @@ Added to `web/package.json`:
### CI/CD Integration
Created `.github/workflows/e2e-tests.yml`:
- Manual workflow trigger
- Configurable base URL for preview deployments
- PostgreSQL test database
@@ -188,6 +191,7 @@ Created `.github/workflows/e2e-tests.yml`:
### Test Utilities
**test-helpers.ts** provides:
- Navigation helpers
- Form interaction utilities
- API response waiters
@@ -200,6 +204,7 @@ Created `.github/workflows/e2e-tests.yml`:
### Documentation
**E2E_TESTING.md** (13KB) covers:
- Overview and test coverage
- Setup and installation
- Running tests (all modes)
@@ -216,16 +221,19 @@ Created `.github/workflows/e2e-tests.yml`:
### Features Implemented
**Multi-Browser Testing**
- Chromium (Chrome/Edge)
- Firefox
- WebKit (Safari)
**Multi-Viewport Testing**
- Desktop (1280x720)
- Mobile Chrome (Pixel 5)
- Mobile Safari (iPhone 12)
**Test Categories**
- Functional testing (navigation, forms, interactions)
- Integration testing (API calls, database)
- UI testing (visual elements, responsiveness)
@@ -233,6 +241,7 @@ Created `.github/workflows/e2e-tests.yml`:
- Visual regression testing (screenshot comparison)
**Quality Assurance**
- Automatic screenshot capture on failure
- Video recording on retry
- Trace viewer for debugging
@@ -240,6 +249,7 @@ Created `.github/workflows/e2e-tests.yml`:
- CI-friendly reporting (GitHub Actions)
**Developer Experience**
- Interactive UI mode for test development
- Debug mode with step-by-step execution
- Hot reload with dev server
@@ -250,12 +260,14 @@ Created `.github/workflows/e2e-tests.yml`:
### Test Results
**Local Testing (Chromium)**:
- ✅ 85 tests passing
- ⏭️ 18 tests skipped (OAuth tests requiring credentials)
- ⏱️ Execution time: ~1.5 minutes
- 📊 Test coverage: All major user flows
**Cross-Browser Verification**:
- ✅ Chromium: All tests passing
- ✅ Firefox: Tests verified
- ✅ WebKit: Compatible (requires macOS for full testing)
@@ -361,16 +373,17 @@ The test suite is designed for maintainability:
✅ Set up E2E testing framework (Playwright with TypeScript)
✅ Write E2E tests for core user flows:
- ✅ Sign up / sign in with NextAuth providers
-Upload content and view manifest/proof
-Register content on blockchain and track transaction status
-Bind platform account and verify ownership
-View profile with content history
✅ Test across major browsers (Chrome, Firefox, Safari)
✅ Test across viewports (desktop, mobile)
✅ Add visual regression testing (baseline screenshots)
⚠️ Run E2E tests in CI (workflow created, requires staging environment)
✅ Document how to run E2E tests locally and debug failures
-Sign up / sign in with NextAuth providers
-Upload content and view manifest/proof
-Register content on blockchain and track transaction status
-Bind platform account and verify ownership
- ✅ View profile with content history
✅ Test across major browsers (Chrome, Firefox, Safari)
✅ Test across viewports (desktop, mobile)
✅ Add visual regression testing (baseline screenshots)
⚠️ Run E2E tests in CI (workflow created, requires staging environment)
✅ Document how to run E2E tests locally and debug failures
### Conclusion

View File

@@ -5,18 +5,21 @@ This document outlines the improvements made to error handling and loading state
## Components Added
### 1. LoadingSpinner (`web/app/components/LoadingSpinner.tsx`)
- Provides consistent loading indicators across the app
- Supports 3 sizes: `sm`, `md`, `lg`
- Can be used inline or as a centered display
- Includes animated spinner with customizable message
### 2. ErrorBoundary (`web/app/components/ErrorBoundary.tsx`)
- React error boundary to catch and display graceful errors
- Prevents white screen of death
- Provides "Try again" button to recover from errors
- Added to root layout to protect entire application
### 3. Toast Notifications (`web/app/components/Toast.tsx`)
- Auto-dismissing notifications for success/error/warning/info states
- Positioned in top-right corner
- Animated slide-in effect
@@ -24,6 +27,7 @@ This document outlines the improvements made to error handling and loading state
- Managed via `useToast` hook
### 4. ErrorMessage (`web/app/components/ErrorMessage.tsx`)
- Smart error parsing with contextual messages
- Detects common error types:
- Network errors
@@ -35,12 +39,14 @@ This document outlines the improvements made to error handling and loading state
- Optional retry button
### 5. SkeletonLoader (`web/app/components/SkeletonLoader.tsx`)
- Placeholder loading state for content-heavy sections
- Animated pulse effect
- Configurable width, height, and count
- Used in Browse section during initial load
### 6. useToast Hook (`web/app/hooks/useToast.ts`)
- React hook for managing toast notifications
- Methods: `success()`, `error()`, `warning()`, `info()`
- Handles toast state and auto-removal
@@ -48,12 +54,14 @@ This document outlines the improvements made to error handling and loading state
## Forms Updated with Loading States
All async operations now include:
- Loading spinner during operation
- Error messages with retry capability
- Success toast notifications
- Disabled buttons during loading
### Updated Forms:
1. **Upload Form** - File upload to IPFS
2. **One-shot Form** - Complete upload → manifest → register flow
3. **Manifest Form** - Create and upload manifest
@@ -67,30 +75,34 @@ All async operations now include:
## Error Messages Implemented
### Network Errors
- **Detected**: "fetch", "network", "failed to fetch"
- **Title**: "Network Error"
- **Suggestion**: "Please check your internet connection and try again."
### Transaction Errors
- **User Rejection**: "user rejected", "user denied"
- **Title**: "Transaction Rejected"
- **Suggestion**: "Please approve the transaction in your wallet to continue."
- **Insufficient Funds**: "insufficient funds", "gas"
- **Title**: "Insufficient Funds"
- **Suggestion**: "Please add funds to your wallet or reduce the transaction amount."
### IPFS Errors
- **Detected**: "ipfs", "upload"
- **Title**: "Upload Failed"
- **Suggestion**: "Please check your file and try again."
### Validation Errors
- **Detected**: "invalid", "validation"
- **Title**: "Invalid Input"
- **Suggestion**: "Please check your input and try again."
### Authorization Errors
- **Detected**: "unauthorized", "403", "401"
- **Title**: "Unauthorized"
- **Suggestion**: "Please sign in or check your account permissions."
@@ -100,17 +112,20 @@ All async operations now include:
### Manual Testing Scenarios
#### 1. Network Error Testing
- [ ] Disconnect network and try uploading a file
- [ ] Expected: "Network Error" message with connection suggestion
- [ ] Reconnect and click "Try Again" button
#### 2. Loading State Testing
- [ ] Upload a large file and observe loading spinner
- [ ] Submit one-shot form and observe "Processing..." message
- [ ] Browse contents and observe skeleton loaders on initial load
- [ ] Verify buttons are disabled during loading
#### 3. Toast Notification Testing
- [ ] Successfully upload a file → green success toast appears
- [ ] Trigger an error → red error toast appears
- [ ] Multiple toasts stack vertically
@@ -118,11 +133,13 @@ All async operations now include:
- [ ] Can manually close toast with X button
#### 4. Error Recovery Testing
- [ ] Trigger upload error
- [ ] Click "Try Again" button
- [ ] Verify form retries the operation
#### 5. Error Boundary Testing
- [ ] Application wrapped in ErrorBoundary
- [ ] React errors caught and displayed gracefully
- [ ] "Try again" button resets error state
@@ -130,21 +147,25 @@ All async operations now include:
#### 6. Form-Specific Testing
**Upload Form:**
- [ ] Loading spinner appears during upload
- [ ] Success toast on completion
- [ ] Error message on failure with retry option
**One-Shot Form:**
- [ ] Loading spinner with "Processing..." message
- [ ] Success toast on completion
- [ ] Error handling for all sub-operations
**Verify Form:**
- [ ] Loading spinner during verification
- [ ] Success toast if verification passes
- [ ] Warning toast if verification status is not OK
**Browse Contents:**
- [ ] Skeleton loaders appear on initial load
- [ ] Loading spinner on refresh button
- [ ] Empty state when no items
@@ -152,6 +173,7 @@ All async operations now include:
## User Experience Improvements
### Before:
- ❌ Generic error messages
- ❌ No loading indicators
- ❌ Unclear async operation status
@@ -160,6 +182,7 @@ All async operations now include:
- ❌ Application crashes show white screen
### After:
- ✅ Contextual, actionable error messages
- ✅ Clear loading indicators everywhere
- ✅ Real-time status updates via toasts
@@ -171,6 +194,7 @@ All async operations now include:
## Technical Implementation
### Architecture:
- **Components**: Reusable, composable UI components
- **Hooks**: Custom React hooks for state management
- **Error Handling**: Try-catch blocks with typed error responses
@@ -178,6 +202,7 @@ All async operations now include:
- **Toast System**: Event-based with auto-cleanup
### Code Quality:
- TypeScript for type safety
- Consistent error handling patterns
- Accessible UI (ARIA labels on loaders)
@@ -187,6 +212,7 @@ All async operations now include:
## Future Enhancements
Potential improvements for future iterations:
- [ ] Add optimistic UI updates for mutations
- [ ] Implement retry with exponential backoff
- [ ] Add progress bars for long-running operations

View File

@@ -3,6 +3,7 @@
## Key UI Components Implemented
### 1. Loading Spinners
**Component:** `LoadingSpinner.tsx`
```
@@ -19,6 +20,7 @@
```
**Usage:** Appears on all buttons during async operations
- Upload, Register, Verify, Proof buttons
- One-shot form processing
- Browse/Refresh operations
@@ -26,6 +28,7 @@
---
### 2. Error Messages
**Component:** `ErrorMessage.tsx`
```
@@ -58,6 +61,7 @@
```
**Features:**
- Contextual error detection
- Helpful suggestions
- Retry capability
@@ -66,21 +70,22 @@
---
### 3. Toast Notifications
**Component:** `Toast.tsx`
```
┌─────────────────────────┐
│ File uploaded! ✓ × │ ← Success (green)
└─────────────────────────┘
┌─────────────────────────┐
│ Upload failed ✗ × │ ← Error (red)
└─────────────────────────┘
┌─────────────────────────┐
│ Verification OK ⚠ × │ ← Warning (yellow)
└─────────────────────────┘
┌─────────────────────────┐
│ Copied to clipboard × │ ← Info (blue)
└─────────────────────────┘
@@ -94,6 +99,7 @@
---
### 4. Skeleton Loaders
**Component:** `SkeletonLoader.tsx`
```
@@ -113,6 +119,7 @@
---
### 5. Error Boundary
**Component:** `ErrorBoundary.tsx`
```
@@ -139,6 +146,7 @@
### Upload Form
**Before:**
```
┌─────────────────────────┐
│ Upload to IPFS │
@@ -151,6 +159,7 @@
```
**After:**
```
┌─────────────────────────────────────┐
│ Upload to IPFS │
@@ -174,6 +183,7 @@
### One-Shot Form
**Before:**
```
┌────────────────────────────┐
│ [ Run one-shot ] │
@@ -183,6 +193,7 @@
```
**After:**
```
┌────────────────────────────────────┐
│ [ ⟳ Processing... ] │ ← Clear loading state
@@ -194,6 +205,7 @@
### Browse Contents
**Before:**
```
┌─────────────────────────────┐
│ Browse Contents │
@@ -204,6 +216,7 @@
```
**After:**
```
┌─────────────────────────────────────┐
│ Browse Contents │
@@ -275,16 +288,19 @@ Step 5: Success
## Accessibility Features
### ARIA Labels
- Loading spinners: `role="status"` with text alternatives
- Error messages: Proper heading hierarchy
- Toast notifications: `role="alert"` for screen readers
### Keyboard Navigation
- Error retry buttons: Focusable and keyboard accessible
- Toast close buttons: Keyboard dismissible
- All interactive elements maintain focus states
### Color Contrast
- Error messages: Red (#dc2626) on light background (#fef2f2)
- Success toasts: Green (#1a7f37) on light background (#e6ffed)
- Loading spinners: Blue (#3b82f6) visible on all backgrounds
@@ -294,20 +310,23 @@ Step 5: Success
## Performance Metrics
### Bundle Size Impact
- LoadingSpinner: ~1KB
- ErrorMessage: ~2KB
- Toast: ~2KB
- SkeletonLoader: ~1KB
- ErrorBoundary: ~1KB
- useToast hook: ~1KB
**Total:** ~8KB additional (minified)
**Total:** ~8KB additional (minified)
### Animation Performance
- All animations use CSS transforms (GPU accelerated)
- No layout thrashing
- 60fps on all devices
### User Experience Improvements
-**Reduced confusion**: Clear loading states
-**Faster perceived performance**: Skeleton loaders
-**Better error recovery**: Retry buttons

View File

@@ -1,11 +1,13 @@
# Web Performance Optimization Summary
## Overview
This document summarizes the comprehensive web performance optimizations implemented for the Internet-ID application to achieve optimal Core Web Vitals scores and provide an excellent user experience.
## Implementation Status
### ✅ Completed
1. **Performance Monitoring Infrastructure**
- Bundle analyzer integration
- Lighthouse CI configuration
@@ -43,6 +45,7 @@ This document summarizes the comprehensive web performance optimizations impleme
- Baseline measurements recorded
### 📋 Documented for Future Implementation
1. **Code Splitting**
- Extract 9 form components from main page.tsx (1683 lines)
- Implement dynamic imports with next/dynamic
@@ -56,21 +59,24 @@ This document summarizes the comprehensive web performance optimizations impleme
## Performance Metrics
### Baseline (October 30, 2025)
- **Total Build Size**: 10.22 MB
- **JavaScript Bundle**: 2.57 MB
- **Static Assets**: 736 KB
- **File Count**: 133 JS files, 1 CSS file
### Performance Budgets
| Metric | Baseline | Budget | Target | Status |
|--------|----------|--------|--------|--------|
| JavaScript | 2.57 MB | 3 MB | 1.5 MB | ✅ PASS |
| Total Build | 10.22 MB | 12 MB | 8 MB | ✅ PASS |
| LCP | TBD | < 2.5s | < 2.0s | 🔄 Monitoring |
| FID | TBD | < 100ms | < 50ms | 🔄 Monitoring |
| CLS | TBD | < 0.1 | < 0.05 | 🔄 Monitoring |
| Metric | Baseline | Budget | Target | Status |
| ----------- | -------- | ------- | ------ | ------------- |
| JavaScript | 2.57 MB | 3 MB | 1.5 MB | ✅ PASS |
| Total Build | 10.22 MB | 12 MB | 8 MB | ✅ PASS |
| LCP | TBD | < 2.5s | < 2.0s | 🔄 Monitoring |
| FID | TBD | < 100ms | < 50ms | 🔄 Monitoring |
| CLS | TBD | < 0.1 | < 0.05 | 🔄 Monitoring |
### Core Web Vitals Targets
- **Largest Contentful Paint (LCP)**: < 2.5s
- **First Input Delay (FID)**: < 100ms
- **Cumulative Layout Shift (CLS)**: < 0.1
@@ -78,6 +84,7 @@ This document summarizes the comprehensive web performance optimizations impleme
## Files Changed
### Configuration
- `web/next.config.mjs` - Enhanced with performance and security settings
- `web/package.json` - Added performance testing scripts
- `web/lighthouserc.json` - Lighthouse CI configuration
@@ -86,6 +93,7 @@ This document summarizes the comprehensive web performance optimizations impleme
- `.gitignore` - Added performance report exclusions
### Source Code
- `web/app/layout.tsx` - Added Web Vitals component and resource hints
- `web/app/globals.css` - Font rendering optimizations
- `web/app/web-vitals.tsx` - Web Vitals tracking component
@@ -94,50 +102,63 @@ This document summarizes the comprehensive web performance optimizations impleme
- `web/scripts/performance-report.js` - Performance report generator
### Documentation
- `web/PERFORMANCE_OPTIMIZATIONS.md` - Detailed optimization guide
- `PERFORMANCE_SUMMARY.md` - This summary document
## Key Features
### 1. Bundle Analysis
```bash
npm run build:analyze
```
Generates interactive bundle analysis reports to identify optimization opportunities.
### 2. Performance Reporting
```bash
npm run perf:report
```
Generates a performance report with budget validation. Fails if budgets are exceeded.
### 3. Lighthouse CI
```bash
npm run perf:audit
```
Runs Lighthouse audits with automated assertions for performance, accessibility, and best practices.
### 4. CI/CD Integration
Performance checks run automatically on every pull request:
- Bundle size analysis
- Performance budget validation
- Automated PR comments with results
- Artifact uploads for detailed analysis
## Security Enhancements
The following security headers are now configured:
- `X-DNS-Prefetch-Control: on`
- `X-Frame-Options: SAMEORIGIN`
- `X-Content-Type-Options: nosniff`
- `Referrer-Policy: strict-origin-when-cross-origin`
## Image Optimization Strategy
- **Formats**: AVIF (primary), WebP (fallback), JPEG/PNG (legacy)
- **Device Sizes**: 640, 750, 828, 1080, 1200, 1920, 2048, 3840
- **Cache**: 1 year immutable for static images
- **Lazy Loading**: Supported via Intersection Observer utility
## Caching Strategy
- **Static Assets** (images, fonts): `max-age=31536000, immutable`
- **Manifest**: `max-age=86400, must-revalidate`
- **Dynamic Content**: No cache headers (handled by Next.js)
@@ -145,11 +166,13 @@ The following security headers are now configured:
## Monitoring & Analytics
### Development
- Web Vitals logged to console
- Bundle analyzer available
- Performance reports generated locally
### Production
- Web Vitals sent to `/api/analytics` endpoint
- Ready for integration with analytics service
- Real User Monitoring (RUM) foundation in place
@@ -157,6 +180,7 @@ The following security headers are now configured:
## Commands Reference
### Build & Analysis
```bash
npm run build # Production build
npm run build:analyze # Build with bundle analysis
@@ -164,6 +188,7 @@ npm run perf:report # Generate performance report
```
### Testing
```bash
npm run perf:audit # Run Lighthouse CI
npm run perf:collect # Collect Lighthouse data
@@ -171,6 +196,7 @@ npm run perf:assert # Assert Lighthouse budgets
```
### Development
```bash
npm run dev # Start dev server
npm run lint # Lint code
@@ -180,22 +206,26 @@ npm run format # Format code
## Optimization Roadmap
### Phase 1: Completed ✅
- Infrastructure setup
- Monitoring implementation
- Budget establishment
- CI/CD integration
### Phase 2: High Priority (Next PR)
- [ ] Code splitting for form components
- [ ] Dynamic imports for tab content
- [ ] Reduce JavaScript bundle to 1.5 MB
### Phase 3: Medium Priority
- [ ] Replace `<img>` tags with Next.js `<Image>`
- [ ] Add blur placeholders for images
- [ ] Implement route prefetching
### Phase 4: Low Priority
- [ ] Progressive Web App (PWA) enhancements
- [ ] Service worker for offline support
- [ ] Advanced caching strategies
@@ -203,37 +233,44 @@ npm run format # Format code
## Validation & Testing
### Security
✅ CodeQL analysis: No vulnerabilities detected
✅ Security headers configured
✅ No secrets in code
### Build
✅ Production build successful
✅ TypeScript compilation clean
✅ Linting passed (5 warnings about img tags - documented for future)
### Performance
✅ Performance budgets passing
✅ Report generation working
✅ CI workflow validated
## Next Steps
1. Monitor real-world Core Web Vitals after deployment
2. Implement code splitting in next PR
3. Set up analytics service integration
4. Create performance dashboard
## Resources
- [Next.js Performance Best Practices](https://nextjs.org/docs/app/building-your-application/optimizing)
- [Core Web Vitals](https://web.dev/vitals/)
- [Lighthouse CI Documentation](https://github.com/GoogleChrome/lighthouse-ci)
- [Performance Budget Calculator](https://www.performancebudget.io/)
## Contributors
- Implementation: GitHub Copilot
- Review: Automated code review + CodeQL
- Testing: CI/CD pipeline
---
**Last Updated**: October 30, 2025
**Status**: ✅ Ready for deployment

View File

@@ -12,12 +12,12 @@ This repo scaffolds a minimal on-chain content provenance flow:
> Note: This proves provenance, not truth. It helps distinguish opted-in human-created content from anonymous deepfakes.
**📚 Documentation:**
- **New here?** Start with the [Contributor Onboarding Guide](./docs/CONTRIBUTOR_ONBOARDING.md)
- **Architecture Overview:** See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for system design and component interactions
- **Plain-English Pitch:** [PITCH.md](./PITCH.md) explains the problem and solution
- **Accessibility:** See [web/ACCESSIBILITY.md](./web/ACCESSIBILITY.md) for WCAG 2.1 AA conformance and [web/ACCESSIBILITY_TESTING.md](./web/ACCESSIBILITY_TESTING.md) for testing guide
## Stack
- Solidity (ContentRegistry)
@@ -142,11 +142,13 @@ View the [CI workflow configuration](.github/workflows/ci.yml) and [E2E workflow
### Essential Configuration
1. **Install dependencies:**
```bash
npm install --legacy-peer-deps
```
2. **Configure environment:**
```bash
cp .env.example .env
# Edit .env and set:
@@ -158,6 +160,7 @@ View the [CI workflow configuration](.github/workflows/ci.yml) and [E2E workflow
```
3. **Set up database:**
```bash
npm run db:generate
npm run db:migrate
@@ -183,6 +186,7 @@ cp web/.env.example web/.env.local
```
**Note on Multi-Chain Deployments:**
- Each network requires a separate deployment of the ContentRegistry contract
- Deployed addresses are saved in `deployed/<network>.json` files
- The registry service automatically resolves the correct contract address based on the chain ID
@@ -194,6 +198,7 @@ Internet-ID supports deployment and verification across multiple EVM-compatible
### Supported Networks
**Mainnets (Production):**
- **Ethereum Mainnet** (chain ID: 1) High security, higher gas costs
- **Polygon** (chain ID: 137) Low cost, good UX, MATIC gas token
- **Base** (chain ID: 8453) Coinbase L2, low cost, good UX
@@ -201,6 +206,7 @@ Internet-ID supports deployment and verification across multiple EVM-compatible
- **Optimism** (chain ID: 10) Low cost L2
**Testnets (Development):**
- **Ethereum Sepolia** (chain ID: 11155111)
- **Polygon Amoy** (chain ID: 80002)
- **Base Sepolia** (chain ID: 84532)
@@ -210,12 +216,14 @@ Internet-ID supports deployment and verification across multiple EVM-compatible
### Chain Configuration
Chain configurations are defined in `config/chains.ts` with:
- RPC URLs (with environment variable overrides)
- Block explorer URLs
- Native currency details
- Gas settings
You can override default RPC URLs via environment variables:
```bash
ETHEREUM_RPC_URL=https://your-eth-rpc.com
POLYGON_RPC_URL=https://your-polygon-rpc.com
@@ -228,6 +236,7 @@ BASE_RPC_URL=https://your-base-rpc.com
- `build` compile contracts
**Deployment Scripts (Multi-Chain):**
- `deploy:ethereum` deploy to Ethereum Mainnet
- `deploy:sepolia` deploy to Ethereum Sepolia testnet
- `deploy:polygon` deploy to Polygon
@@ -241,6 +250,7 @@ BASE_RPC_URL=https://your-base-rpc.com
- `deploy:local` deploy to local Hardhat node
**Other Scripts:**
- `register` hash a file and register its hash + manifest URI on-chain
- `RPC_URL` for your preferred network. For local, you can use `LOCAL_RPC_URL=http://127.0.0.1:8545`.
- For IPFS uploads: `IPFS_API_URL` and optional `IPFS_PROJECT_ID`/`IPFS_PROJECT_SECRET`
@@ -297,6 +307,7 @@ npm run deploy:local
**Production Deployments:**
For mainnet deployments, ensure you have:
- Sufficient native tokens for gas (ETH, MATIC, etc.)
- `PRIVATE_KEY` set in `.env`
- Appropriate RPC URL configured
@@ -778,19 +789,42 @@ See the complete [E2E Testing Guide](./web/E2E_TESTING.md) for detailed document
- Support Merkle batch anchoring.
- Add selective disclosure/zk proof of “is a real person” VC.
## Public API for Third-Party Integrations
## CLI Tool and SDK for Programmatic Access
Internet ID provides a **public API** for developers to build integrations, tools, and services on top of the platform.
Internet ID provides multiple ways to interact with the platform programmatically:
### Features
### CLI Tool
- ✅ **RESTful API** with versioning (`/api/v1/`)
- ✅ **Multiple authentication methods**: API keys and JWT tokens
- ✅ **Rate limiting** per API key tier (free: 100 req/min, paid: 1000 req/min)
- ✅ **OpenAPI/Swagger documentation** at `/api/docs`
- ✅ **Official TypeScript/JavaScript SDK** (`@internet-id/sdk`)
Command-line tool for content registration and verification. Perfect for automation, scripting, and CI/CD workflows.
### Quick Start
```bash
# Install globally
npm install -g @internet-id/cli
# Configure credentials
internet-id init
# Upload and register content
internet-id upload ./my-video.mp4
# Verify content
internet-id verify ./my-video.mp4
```
**Features:**
- ✅ Interactive configuration with `init` command
- ✅ Privacy mode (only manifest uploaded by default)
- ✅ Optional content upload to IPFS
- ✅ Content verification by file or manifest URI
- ✅ Support for multiple IPFS providers (Web3.Storage, Pinata, Infura, local)
- ✅ Multi-chain support (Base, Ethereum, Polygon, Arbitrum, Optimism)
**Documentation:** [CLI README](./cli/README.md)
### TypeScript/JavaScript SDK
Official SDK for building integrations and tools.
```bash
# Install the SDK
@@ -798,26 +832,46 @@ npm install @internet-id/sdk
```
```typescript
import { InternetIdClient } from '@internet-id/sdk';
import { InternetIdClient } from "@internet-id/sdk";
const client = new InternetIdClient({
apiKey: 'iid_your_api_key_here'
apiKey: "iid_your_api_key_here",
});
// Verify content by platform URL
const result = await client.verifyByPlatform({
url: 'https://youtube.com/watch?v=abc123'
url: "https://youtube.com/watch?v=abc123",
});
console.log(result.verified); // true or false
console.log(result.creator); // Creator's Ethereum address
```
### Documentation
**Features:**
- ✅ Full TypeScript support with type definitions
- ✅ Content verification and metadata retrieval
- ✅ API key management
- ✅ JWT authentication
- ✅ Automatic rate limiting and error handling
**Documentation:** [SDK README](./sdk/typescript/README.md)
### Public API
RESTful API for third-party integrations.
**Features:**
- ✅ Versioned API (`/api/v1/`)
- ✅ Multiple authentication methods (API keys, JWT tokens)
- ✅ Rate limiting per tier (free: 100 req/min, paid: 1000 req/min)
- ✅ OpenAPI/Swagger documentation at `/api/docs`
**Documentation:**
- **[Public API Documentation](./docs/PUBLIC_API.md)** - Complete API reference
- **[Developer Onboarding Guide](./docs/DEVELOPER_ONBOARDING.md)** - Get started quickly
- **[SDK Documentation](./sdk/typescript/README.md)** - TypeScript/JavaScript SDK usage
- **Interactive API Explorer**: http://localhost:3001/api/docs (when running locally)
## API reference (summary)

View File

@@ -13,12 +13,14 @@ This document summarizes the security improvements implemented for secret manage
### 1. Comprehensive Documentation (125KB, 7 guides)
**Strategic Guides:**
- `SECRET_MANAGEMENT.md` - Architecture and principles
- `AWS_SECRETS_MANAGER.md` - AWS integration guide
- `HASHICORP_VAULT.md` - Vault integration guide
- `README_SECRET_MANAGEMENT.md` - Quick start guide
**Operational Procedures:**
- `SECRET_ROTATION_PROCEDURES.md` - Rotation schedule and procedures
- `SECRET_ACCESS_CONTROL.md` - RBAC and governance
- `SECRET_MONITORING_ALERTS.md` - Monitoring and incident response
@@ -26,6 +28,7 @@ This document summarizes the security improvements implemented for secret manage
### 2. Security Scanning Tools
**Automated Secret Scanner (`scripts/security/scan-secrets.sh`):**
- Scans for 15+ secret patterns
- Detects: API keys, passwords, tokens, private keys, database URLs, cloud credentials
- Checks git history for exposed secrets
@@ -33,12 +36,14 @@ This document summarizes the security improvements implemented for secret manage
- **Current scan: 151 findings (all documentation examples and test fixtures)**
**Git-Secrets Integration (`scripts/security/setup-git-secrets.sh`):**
- Pre-commit hooks to block secrets
- Custom patterns for Internet-ID project
- AWS secret detection
- Automatic installation script
**CI/CD Security Workflow (`.github/workflows/secret-security.yml`):**
- Weekly automated scans
- PR security checks
- TruffleHog integration (verified secrets only)
@@ -47,30 +52,31 @@ This document summarizes the security improvements implemented for secret manage
### 3. Secret Rotation Policies
| Secret Category | Rotation Frequency | Automation Level |
|----------------|-------------------|------------------|
| Database passwords | Every 90 days | Fully automated (AWS RDS + Secrets Manager) |
| IPFS API keys | Every 90 days | Semi-automated |
| NextAuth secrets | Every 90 days | Manual with procedures |
| OAuth credentials | Every 180 days | Manual with procedures |
| Private keys (blockchain) | Annually or on compromise | Manual with emergency procedures |
| Infrastructure keys | Every 90 days | Semi-automated |
| Secret Category | Rotation Frequency | Automation Level |
| ------------------------- | ------------------------- | ------------------------------------------- |
| Database passwords | Every 90 days | Fully automated (AWS RDS + Secrets Manager) |
| IPFS API keys | Every 90 days | Semi-automated |
| NextAuth secrets | Every 90 days | Manual with procedures |
| OAuth credentials | Every 180 days | Manual with procedures |
| Private keys (blockchain) | Annually or on compromise | Manual with emergency procedures |
| Infrastructure keys | Every 90 days | Semi-automated |
### 4. Access Control (RBAC)
**Implemented Roles:**
| Role | Development | Staging | Production |
|------|------------|---------|------------|
| Developer | Read/Write | None | None |
| QA Engineer | None | Read-only | None |
| DevOps Engineer | Read/Write | Read/Write | Read-only* |
| Security Engineer | Read/Write | Read/Write | Read/Write |
| Service Account | N/A | Read-only | Read-only |
| Role | Development | Staging | Production |
| ----------------- | ----------- | ---------- | ----------- |
| Developer | Read/Write | None | None |
| QA Engineer | None | Read-only | None |
| DevOps Engineer | Read/Write | Read/Write | Read-only\* |
| Security Engineer | Read/Write | Read/Write | Read/Write |
| Service Account | N/A | Read-only | Read-only |
*Production write access requires security team approval
\*Production write access requires security team approval
**Access Controls:**
- Least-privilege principle enforced
- Environment-specific isolation
- MFA required for production access
@@ -80,22 +86,26 @@ This document summarizes the security improvements implemented for secret manage
### 5. Monitoring and Alerting
**Critical Alerts (Immediate Response Required):**
- Multiple failed access attempts (>5 in 10 minutes)
- Unauthorized access from unknown IP/IAM role
- Secret deletion or modification in production
- Secret exposure in logs or code
**High Priority Alerts (1 Hour Response):**
- Excessive secret access (>100 requests/hour)
- Secret rotation failure
- Anomalous access patterns
**Medium Priority Alerts (24 Hour Response):**
- Secrets nearing rotation deadline (>80 days)
- Unusual geographic access patterns
- New service account accessing secrets
**Monitoring Infrastructure:**
- CloudWatch metrics and alarms
- Prometheus + Grafana dashboards
- CloudTrail audit logging
@@ -105,12 +115,14 @@ This document summarizes the security improvements implemented for secret manage
### 6. Deployment Integration
**GitHub Actions (OIDC):**
- No long-lived credentials in CI/CD
- Temporary credentials via OpenID Connect
- Secret masking in logs (`::add-mask::`)
- Environment-specific secret access
**Docker/Kubernetes:**
- Entry-point secret injection
- External Secrets Operator support
- Pod service account authentication
@@ -121,6 +133,7 @@ This document summarizes the security improvements implemented for secret manage
### Current State (October 26, 2025)
**Codebase Scan Results:**
```
Total Scans: 15+ secret patterns
Findings: 151 potential issues
@@ -129,11 +142,13 @@ False Positives: 151 (all from documentation examples and test fixtures)
```
**Breakdown:**
- 85 findings: Documentation examples (deliberately showing secret formats)
- 66 findings: Test fixtures (using placeholder values like "test-secret")
- 0 findings: Actual production secrets or credentials
**Validation:**
- ✅ No `.env` files committed to git
- ✅ No secrets in git history
- ✅ All production secrets referenced via environment variables
@@ -142,6 +157,7 @@ False Positives: 151 (all from documentation examples and test fixtures)
### Pre-Commit Protection
**Git-Secrets Configuration:**
- 15+ custom patterns registered
- AWS secret patterns included
- Blockchain private key detection
@@ -149,6 +165,7 @@ False Positives: 151 (all from documentation examples and test fixtures)
- API key and token detection
**Testing:**
```bash
# Test case: Try to commit a file with API key
echo "api_key=AKIA1234567890123456" > test.txt
@@ -164,6 +181,7 @@ git commit -m "test"
### SOC 2 Type II
**Control Implementation:**
- ✅ CC6.1 - Logical and physical access controls
- ✅ CC6.2 - Authorization for secrets access
- ✅ CC6.3 - Authentication mechanisms (MFA)
@@ -174,6 +192,7 @@ git commit -m "test"
### GDPR
**Article Compliance:**
- ✅ Article 25 - Data protection by design (least privilege)
- ✅ Article 30 - Records of processing (audit logs)
- ✅ Article 32 - Security of processing (encryption, access control)
@@ -181,6 +200,7 @@ git commit -m "test"
### PCI-DSS (If Applicable)
**Requirement Coverage:**
- ✅ Req 2.1 - Change default passwords
- ✅ Req 3.4 - Render PAN unreadable (encryption at rest)
- ✅ Req 7 - Restrict access by business need-to-know
@@ -190,6 +210,7 @@ git commit -m "test"
### HIPAA (If Applicable)
**Safeguard Implementation:**
- ✅ Access Control (§164.312(a)(1))
- ✅ Audit Controls (§164.312(b))
- ✅ Integrity (§164.312(c)(1))
@@ -200,27 +221,27 @@ git commit -m "test"
### Before Implementation
| Risk | Likelihood | Impact | Severity |
|------|-----------|--------|----------|
| Hardcoded secrets in code | High | Critical | 🔴 Critical |
| Secrets exposed in git history | Medium | Critical | 🔴 Critical |
| No secret rotation | High | High | 🟠 High |
| Over-privileged access | High | High | 🟠 High |
| No monitoring/alerting | High | Medium | 🟠 High |
| Manual secret management | High | Medium | 🟡 Medium |
| Risk | Likelihood | Impact | Severity |
| ------------------------------ | ---------- | -------- | ----------- |
| Hardcoded secrets in code | High | Critical | 🔴 Critical |
| Secrets exposed in git history | Medium | Critical | 🔴 Critical |
| No secret rotation | High | High | 🟠 High |
| Over-privileged access | High | High | 🟠 High |
| No monitoring/alerting | High | Medium | 🟠 High |
| Manual secret management | High | Medium | 🟡 Medium |
**Overall Risk Level:** 🔴 **Critical**
### After Implementation
| Risk | Likelihood | Impact | Severity |
|------|-----------|--------|----------|
| Hardcoded secrets in code | Low | Critical | 🟡 Low |
| Secrets exposed in git history | Very Low | Critical | 🟢 Very Low |
| No secret rotation | Very Low | High | 🟢 Very Low |
| Over-privileged access | Low | High | 🟡 Low |
| No monitoring/alerting | Very Low | Medium | 🟢 Very Low |
| Manual secret management | Low | Medium | 🟢 Low |
| Risk | Likelihood | Impact | Severity |
| ------------------------------ | ---------- | -------- | ----------- |
| Hardcoded secrets in code | Low | Critical | 🟡 Low |
| Secrets exposed in git history | Very Low | Critical | 🟢 Very Low |
| No secret rotation | Very Low | High | 🟢 Very Low |
| Over-privileged access | Low | High | 🟡 Low |
| No monitoring/alerting | Very Low | Medium | 🟢 Very Low |
| Manual secret management | Low | Medium | 🟢 Low |
**Overall Risk Level:** 🟢 **Low**
@@ -269,6 +290,7 @@ git commit -m "test"
### Automated Testing
**Secret Scanner:**
```bash
npm run security:scan
# Scans entire codebase
@@ -278,6 +300,7 @@ npm run security:scan
```
**Git-Secrets:**
```bash
npm run security:setup-git-secrets
# Installs pre-commit hooks
@@ -287,6 +310,7 @@ npm run security:setup-git-secrets
```
**CI/CD Workflow:**
- Runs on every PR
- Runs weekly on main branch
- Uses TruffleHog + GitLeaks
@@ -295,6 +319,7 @@ npm run security:setup-git-secrets
### Manual Testing
**Validated Scenarios:**
1. ✅ Scanner detects API keys in code
2. ✅ Scanner detects AWS credentials
3. ✅ Scanner detects private keys (blockchain)
@@ -340,24 +365,29 @@ npm run security:setup-git-secrets
### Ongoing Operations
**Daily:**
- Monitor secret access metrics
- Review failed access attempts
**Weekly:**
- Review audit logs
- Check rotation schedule
**Monthly:**
- Verify backup secret list
- Test emergency procedures
**Quarterly:**
- Rotate non-automated secrets
- Conduct access reviews
- Update documentation
- Security team drill
**Annually:**
- Comprehensive security audit
- Rotate high-value secrets (private keys)
- Review and update policies
@@ -367,15 +397,15 @@ npm run security:setup-git-secrets
### Key Performance Indicators (KPIs)
| Metric | Target | Current Status |
|--------|--------|---------------|
| Hardcoded secrets in code | 0 | ✅ 0 |
| Secrets rotation compliance | 100% | ✅ 100% (policies defined) |
| Time to rotate compromised secret | <1 hour | ✅ Procedures documented |
| Secret access audit coverage | 100% | ✅ 100% (when enabled) |
| Failed access attempts (false) | <5/day | 🔄 To be measured |
| MTTR for secret-related incidents | <4 hours | 🔄 To be measured |
| Access review completion | 100% quarterly | 🔄 To be scheduled |
| Metric | Target | Current Status |
| --------------------------------- | -------------- | -------------------------- |
| Hardcoded secrets in code | 0 | ✅ 0 |
| Secrets rotation compliance | 100% | ✅ 100% (policies defined) |
| Time to rotate compromised secret | <1 hour | ✅ Procedures documented |
| Secret access audit coverage | 100% | ✅ 100% (when enabled) |
| Failed access attempts (false) | <5/day | 🔄 To be measured |
| MTTR for secret-related incidents | <4 hours | 🔄 To be measured |
| Access review completion | 100% quarterly | 🔄 To be scheduled |
### Security Posture Improvements
@@ -424,7 +454,7 @@ This implementation provides a **production-grade secret management system** tha
**Enables automatic rotation** for critical secrets
**Provides comprehensive monitoring** and alerting
**Supports compliance** with SOC 2, GDPR, PCI-DSS, HIPAA
**Documents procedures** for operations and emergencies
**Documents procedures** for operations and emergencies
**Security Risk Reduction: ~85%**
**Compliance Readiness: 90%**
@@ -433,14 +463,17 @@ This implementation provides a **production-grade secret management system** tha
## Contact Information
**Security Questions:**
- Email: security@subculture.io
- Slack: #security-team
**Implementation Support:**
- Email: ops@subculture.io
- Slack: #devops
**Emergency:**
- PagerDuty: Security on-call
- Email: security@subculture.io

View File

@@ -17,6 +17,7 @@ This document details the comprehensive security headers implementation for the
**Package**: `helmet@8.1.0` (✅ No known vulnerabilities)
**Features**:
- Comprehensive security headers via helmet
- CSP with nonce-based script execution
- CSP violation reporting endpoint
@@ -28,6 +29,7 @@ This document details the comprehensive security headers implementation for the
**Configuration**: Updated `headers()` function in Next.js config
**Features**:
- Comprehensive security headers
- CSP tailored for React/Next.js
- Support for IPFS and blockchain RPC endpoints
@@ -97,6 +99,7 @@ upgradeInsecureRequests
**CSP Mode**: **Enforcing** (not report-only)
**Nonce Support**: Implemented for Express API server
- Cryptographically secure random nonces generated per request
- Available via `res.locals.cspNonce`
@@ -107,6 +110,7 @@ upgradeInsecureRequests
**Purpose**: Prevents MIME type sniffing attacks
**Protection Against**:
- Browsers executing files as unexpected types
- Script execution from non-script MIME types
- Style injection from non-CSS MIME types
@@ -115,18 +119,21 @@ upgradeInsecureRequests
### X-Frame-Options
**Value**:
**Value**:
- Express API: `DENY` (no embedding allowed)
- Next.js: `SAMEORIGIN` (embedding only on same origin)
**Purpose**: Prevents clickjacking attacks
**Protection Against**:
- UI redress attacks
- Clickjacking via iframe embedding
- Cross-origin frame attacks
**Rationale**:
**Rationale**:
- API should never be embedded in frames
- Next.js app may need same-origin embedding for certain features
@@ -147,12 +154,14 @@ upgradeInsecureRequests
**Purpose**: Controls referrer information sent with requests
**Behavior**:
- Same-origin requests: Full URL sent
- Cross-origin HTTPS→HTTPS: Origin only
- Cross-origin HTTPS→HTTP: No referrer
- Downgrades: No referrer
**Benefits**:
- Prevents information leakage in referrer
- Maintains analytics capability
- Protects user privacy
@@ -164,6 +173,7 @@ upgradeInsecureRequests
**Value**: Restricts browser features to prevent abuse
**Disabled Features**:
- `camera`
- `microphone`
- `geolocation`
@@ -184,11 +194,13 @@ upgradeInsecureRequests
**Purpose**: Forces HTTPS connections
**Configuration**:
- Max age: 1 year (31,536,000 seconds)
- Include subdomains: Yes
- Preload ready: Yes
**Benefits**:
- Prevents downgrade attacks
- Forces HTTPS even on first visit (with preload)
- Protects all subdomains
@@ -224,6 +236,7 @@ upgradeInsecureRequests
**Handler**: `cspReportHandler` in `security-headers.middleware.ts`
**Log Format**:
```javascript
{
timestamp: ISO 8601 timestamp,
@@ -234,6 +247,7 @@ upgradeInsecureRequests
```
**Usage**:
1. CSP violations are automatically sent to this endpoint
2. Violations are logged to console with warning level
3. Monitor logs for potential attacks or misconfigurations
@@ -255,10 +269,11 @@ upgradeInsecureRequests
- Expected grade: A or A+
3. **CSP Testing**:
```bash
# Test API endpoint
curl -I https://your-api.com/api/health
# Test Next.js app
curl -I https://your-app.com/
```
@@ -266,6 +281,7 @@ upgradeInsecureRequests
### Automated Testing
**Recommended Tools**:
- OWASP ZAP for header validation
- Burp Suite for security testing
- Lighthouse for CSP compliance
@@ -290,6 +306,7 @@ upgradeInsecureRequests
**File**: `/scripts/middleware/security-headers.middleware.ts`
**Key Functions**:
- `generateNonce()`: Generate cryptographic nonces
- `cspReportHandler()`: Handle CSP violations
- `securityHeaders`: Helmet middleware with configuration
@@ -297,14 +314,15 @@ upgradeInsecureRequests
- `applySecurityHeaders()`: Complete middleware stack
**Integration**: `/scripts/api.ts`
```typescript
import { applySecurityHeaders, cspReportHandler } from './middleware/security-headers.middleware';
import { applySecurityHeaders, cspReportHandler } from "./middleware/security-headers.middleware";
// Apply security headers
app.use(applySecurityHeaders);
// CSP reporting endpoint
app.post('/api/csp-report', express.json({ type: 'application/csp-report' }), cspReportHandler);
app.post("/api/csp-report", express.json({ type: "application/csp-report" }), cspReportHandler);
```
### Next.js
@@ -314,6 +332,7 @@ app.post('/api/csp-report', express.json({ type: 'application/csp-report' }), cs
**Configuration**: `headers()` async function
**Key Features**:
- Security headers on all routes (`/:path*`)
- Cache headers for static assets
- CSP with multi-line formatting for readability
@@ -324,11 +343,13 @@ app.post('/api/csp-report', express.json({ type: 'application/csp-report' }), cs
**Issue**: Next.js requires `'unsafe-eval'` and `'unsafe-inline'` in `script-src`
**Reason**:
**Reason**:
- Next.js uses eval for hot module replacement in development
- React hydration requires inline scripts
**Mitigation**:
- Consider using nonce-based approach in production
- Monitor CSP violations for abuse
- Keep Next.js updated for security fixes
@@ -342,6 +363,7 @@ app.post('/api/csp-report', express.json({ type: 'application/csp-report' }), cs
**Reason**: Content may be served from various gateways for resilience
**Mitigation**:
- Use content-addressed storage (hashes verify integrity)
- Monitor gateway availability and reputation
- Can remove unused gateways
@@ -355,6 +377,7 @@ app.post('/api/csp-report', express.json({ type: 'application/csp-report' }), cs
**Reason**: Web3 functionality requires blockchain connectivity
**Mitigation**:
- Use only reputable providers (Infura, Alchemy, etc.)
- Implement client-side signature verification
- Monitor for suspicious activity
@@ -366,6 +389,7 @@ app.post('/api/csp-report', express.json({ type: 'application/csp-report' }), cs
### OWASP Top 10 2021
✅ **A05:2021 - Security Misconfiguration**
- Comprehensive security headers implemented
- Secure defaults enforced
- Unnecessary features disabled
@@ -373,11 +397,13 @@ app.post('/api/csp-report', express.json({ type: 'application/csp-report' }), cs
### Security Standards
✅ **OWASP Secure Headers Project**
- All recommended headers implemented
- Strict CSP policy
- HSTS with preload
✅ **Mozilla Web Security Guidelines**
- Modern security header configuration
- Defense in depth approach
- Regular updates
@@ -395,16 +421,19 @@ app.post('/api/csp-report', express.json({ type: 'application/csp-report' }), cs
### Regular Tasks
**Monthly**:
- Review CSP violation logs
- Update helmet package if needed
- Check for new security best practices
**Quarterly**:
- Security header audit
- Review trusted sources
- Update documentation
**Annually**:
- Comprehensive security review
- Update HSTS max-age if needed
- Review Permissions-Policy features
@@ -412,18 +441,21 @@ app.post('/api/csp-report', express.json({ type: 'application/csp-report' }), cs
### Adding New Trusted Sources
**IPFS Gateway**:
1. Add to `imgSrc` in both Express and Next.js CSP
2. Add to `connectSrc` if API access needed
3. Test image loading from new gateway
4. Document in this file
**Blockchain RPC**:
1. Add to `connectSrc` in both configurations
2. Test Web3 connectivity
3. Document provider details
4. Monitor for issues
**CDN/External Resource**:
1. Identify required directive (script-src, style-src, etc.)
2. Add to both Express and Next.js CSP
3. Test functionality
@@ -432,18 +464,21 @@ app.post('/api/csp-report', express.json({ type: 'application/csp-report' }), cs
### Troubleshooting
**CSP Violations**:
1. Check browser console for violation details
2. Verify resource URL is in whitelist
3. Check CSP reporting endpoint logs
4. Add legitimate source if needed
**Images Not Loading**:
1. Verify IPFS gateway in CSP `imgSrc`
2. Check browser console for CSP violations
3. Test gateway accessibility
4. Verify image URL format
**Web3 Connection Issues**:
1. Verify RPC endpoint in CSP `connectSrc`
2. Check browser console for CSP violations
3. Test endpoint availability
@@ -466,6 +501,7 @@ Status: ✅ No known vulnerabilities (verified via GitHub Advisory Database)
### Header Scanner Results
Expected grade: **A** or **A+** on:
- securityheaders.com
- Mozilla Observatory
@@ -478,6 +514,7 @@ Expected grade: **A** or **A+** on:
**Description**: Implement nonce-based CSP for Next.js to remove `'unsafe-inline'`
**Benefits**:
- Stricter CSP policy
- Better XSS protection
- Remove CSP exceptions
@@ -491,6 +528,7 @@ Expected grade: **A** or **A+** on:
**Description**: Aggregate CSP violation reports for analysis
**Benefits**:
- Identify attack patterns
- Detect misconfigurations
- Monitor security posture
@@ -504,11 +542,13 @@ Expected grade: **A** or **A+** on:
**Description**: Submit domain to HSTS preload list
**Benefits**:
- HTTPS enforcement on first visit
- Increased security
- Browser-level protection
**Prerequisites**:
- HTTPS fully deployed
- All subdomains support HTTPS
- HSTS header configured (already done)
@@ -522,6 +562,7 @@ Expected grade: **A** or **A+** on:
**Description**: Restrict additional browser features as they become available
**Examples**:
- `identity-credentials-get`
- `otp-credentials`
- `publickey-credentials-get`
@@ -533,6 +574,7 @@ Expected grade: **A** or **A+** on:
**Implementation Status**: COMPLETE ✅
All acceptance criteria met:
- ✅ Content-Security-Policy with strict directives
- ✅ X-Content-Type-Options: nosniff
- ✅ X-Frame-Options: DENY/SAMEORIGIN
@@ -548,6 +590,7 @@ All acceptance criteria met:
**Risk Assessment**: LOW
The implementation successfully protects against:
- Cross-Site Scripting (XSS)
- Clickjacking attacks
- MIME type sniffing
@@ -568,6 +611,7 @@ With the addition of HSTS preload submission and CSP nonce for Next.js, the secu
**Test Results**: All 9 tests passing ✅
**Test Coverage**:
- `generateNonce()` - Nonce generation validation (3 tests)
- `cspReportHandler()` - CSP violation reporting (1 test)
- `permissionsPolicyMiddleware()` - Permissions-Policy header (2 tests)
@@ -575,6 +619,7 @@ With the addition of HSTS preload submission and CSP nonce for Next.js, the secu
- Security Headers Integration - End-to-end verification (1 test)
**Test Execution**:
```bash
npx mocha test/middleware/security-headers.test.ts --require ts-node/register/transpile-only
```
@@ -586,6 +631,7 @@ npx mocha test/middleware/security-headers.test.ts --require ts-node/register/tr
**Results**: 0 security alerts found
**Scan Coverage**:
- SQL injection vulnerabilities
- Cross-site scripting (XSS)
- Command injection
@@ -606,6 +652,7 @@ npx mocha test/middleware/security-headers.test.ts --require ts-node/register/tr
**ESLint**: ✅ PASSED (for new files)
**Files Linted**:
- `/scripts/middleware/security-headers.middleware.ts` - No errors
- `/test/middleware/security-headers.test.ts` - No errors

View File

@@ -7,6 +7,7 @@ This document provides an overview of all SEO best practices implemented in the
### Meta Tags & Metadata (Per-Page Optimization)
#### Root Layout (`web/app/layout.tsx`)
-**Title template**: Dynamic title with site branding
-**Description**: Comprehensive, keyword-rich description
-**Keywords**: Targeted SEO keywords array
@@ -29,6 +30,7 @@ This document provides an overview of all SEO best practices implemented in the
#### Page-Specific Layouts
**Verify Page** (`web/app/verify/layout.tsx`)
- ✅ Unique title and description
- ✅ Open Graph tags
- ✅ Twitter Card tags
@@ -37,6 +39,7 @@ This document provides an overview of all SEO best practices implemented in the
- ✅ VerifyAction structured data (JSON-LD)
**Badges Page** (`web/app/badges/layout.tsx`)
- ✅ Unique title and description
- ✅ Targeted keywords
- ✅ Open Graph tags
@@ -45,21 +48,25 @@ This document provides an overview of all SEO best practices implemented in the
- ✅ Breadcrumb structured data (JSON-LD)
**Dashboard Page** (`web/app/dashboard/layout.tsx`)
- ✅ Private page metadata (noindex, nofollow)
- ✅ Canonical URL
**Profile Page** (`web/app/profile/layout.tsx`)
- ✅ Private page metadata (noindex, nofollow)
- ✅ Canonical URL
**Auth Pages** (`web/app/(auth)/layout.tsx`)
- ✅ Private page metadata (noindex, nofollow)
### Technical SEO
#### Robots.txt (`web/app/robots.ts`)
-**File type**: Next.js App Router route handler
-**User agent**: Wildcard (*) for all crawlers
-**User agent**: Wildcard (\*) for all crawlers
-**Allow directives**: Public pages allowed
-**Disallow directives**: Private pages and API routes blocked
- `/api/` - API endpoints
@@ -68,6 +75,7 @@ This document provides an overview of all SEO best practices implemented in the
-**Sitemap reference**: Points to sitemap.xml
#### Sitemap.xml (`web/app/sitemap.ts`)
-**File type**: Next.js App Router route handler
-**Dynamic generation**: Automatically generates sitemap
-**Pages included**:
@@ -83,6 +91,7 @@ This document provides an overview of all SEO best practices implemented in the
#### Structured Data (JSON-LD)
**Organization Schema** (`web/app/layout.tsx`)
- ✅ Organization type
- ✅ Name and description
- ✅ URL and logo
@@ -90,16 +99,19 @@ This document provides an overview of all SEO best practices implemented in the
- ✅ Contact point
**Website Schema** (`web/app/layout.tsx`)
- ✅ WebSite type
- ✅ Name, URL, description
- ✅ SearchAction for site search
**Breadcrumb Schema** (`web/app/verify/layout.tsx`, `web/app/badges/layout.tsx`)
- ✅ BreadcrumbList type
- ✅ Position-based items
- ✅ Home → Page hierarchy
**WebPage Schema** (`web/app/verify/layout.tsx`)
- ✅ WebPage type with SoftwareApplication
- ✅ Security application category
- ✅ Free offering (price: 0)
@@ -107,25 +119,31 @@ This document provides an overview of all SEO best practices implemented in the
### Page Optimization
#### Title Optimization
All page titles include:
- ✅ Primary keywords
- ✅ Clear value proposition
- ✅ Brand name
- ✅ Optimal length (50-60 characters)
Examples:
- "Internet-ID - Verify Human-Created Content On-Chain"
- "Verify Content | Internet-ID"
- "Verification Badges | Internet-ID"
#### Description Optimization
All meta descriptions include:
- ✅ Target keywords
- ✅ Clear benefits
- ✅ Call to action
- ✅ Optimal length (150-160 characters)
#### Crawlability
- ✅ No render-blocking resources
- ✅ Proper semantic HTML structure
- ✅ Server-side rendering (SSR) support
@@ -135,17 +153,20 @@ All meta descriptions include:
### Analytics & Tracking (Ready to Enable)
#### Google Analytics 4
-**Component created**: `web/app/components/GoogleAnalytics.tsx`
-**Environment variable support**: `NEXT_PUBLIC_GA_MEASUREMENT_ID`
-**Custom event tracking**: Helper function provided
-**Documentation**: Full setup guide in `docs/SEO_ANALYTICS_SETUP.md`
**To Enable**:
1. Add measurement ID to `.env.local`
2. Import and add component to root layout
3. Deploy changes
#### Search Console Verification
-**Meta tag support**: Built into root layout
-**Environment variable**: `NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION`
-**Documentation**: Setup instructions provided
@@ -170,6 +191,7 @@ All meta descriptions include:
## 📋 Next Steps (Manual Actions Required)
### 1. Analytics Setup (Priority: High)
- [ ] Create Google Analytics 4 property
- [ ] Add measurement ID to environment variables
- [ ] Enable GoogleAnalytics component in root layout
@@ -180,6 +202,7 @@ All meta descriptions include:
**Documentation**: `docs/SEO_ANALYTICS_SETUP.md`
### 2. Search Console Setup (Priority: High)
- [ ] Add property to Google Search Console
- [ ] Verify ownership (HTML meta tag method)
- [ ] Submit sitemap.xml
@@ -191,6 +214,7 @@ All meta descriptions include:
**Documentation**: `docs/SEO_ANALYTICS_SETUP.md`
### 3. Content Creation (Priority: Medium)
- [ ] Create "Getting Started" guide
- [ ] Write first use case/case study
- [ ] Add FAQ page with structured data
@@ -201,6 +225,7 @@ All meta descriptions include:
**Documentation**: `docs/SEO_CONTENT_STRATEGY.md`
### 4. Visual Assets (Priority: Medium)
- [ ] Create Open Graph image (1200x630px)
- [ ] Create Twitter Card image (1200x675px)
- [ ] Design logo.png for structured data
@@ -208,12 +233,14 @@ All meta descriptions include:
- [ ] Add alt text to all images
**Estimated Time**: 1-2 days
**Files Needed**:
**Files Needed**:
- `web/public/og-image.png`
- `web/public/twitter-image.png`
- `web/public/logo.png`
### 5. Link Building (Priority: Low)
- [ ] Submit to Product Hunt
- [ ] Create guest post outreach list
- [ ] Engage in relevant communities (Reddit, Discord)
@@ -275,6 +302,7 @@ npm run build
```
Verify output includes:
-`○ /robots.txt` - Static route
-`○ /sitemap.xml` - Static route
- ✅ All pages render without errors
@@ -304,12 +332,14 @@ Verify output includes:
Run these tools to validate implementation:
1. **Lighthouse SEO Audit**:
```bash
npm run perf:audit
```
Target: 90+ SEO score
2. **Google Rich Results Test**:
2. **Google Rich Results Test**:
- URL: https://search.google.com/test/rich-results
- Check: All structured data validates
@@ -324,6 +354,7 @@ Run these tools to validate implementation:
## 📊 Success Metrics
### Short-term (1-3 months)
- Pages indexed in Google: 100%
- Sitemap submitted and accepted
- Google Analytics tracking active
@@ -331,6 +362,7 @@ Run these tools to validate implementation:
- Initial keyword rankings established
### Medium-term (3-6 months)
- 10+ keywords ranking in top 50
- 100+ organic visitors per month
- Domain authority 20+
@@ -338,6 +370,7 @@ Run these tools to validate implementation:
- Conversion rate 2%+
### Long-term (6-12 months)
- 50+ keywords ranking in top 50
- 5+ keywords in top 10
- 1,000+ organic visitors per month
@@ -348,18 +381,21 @@ Run these tools to validate implementation:
## 🛠️ Maintenance
### Weekly Tasks
- [ ] Check Search Console for errors
- [ ] Review top search queries
- [ ] Monitor ranking changes
- [ ] Check for broken links
### Monthly Tasks
- [ ] Analyze traffic trends
- [ ] Update low-performing content
- [ ] Create new content based on search data
- [ ] Review and respond to user feedback
### Quarterly Tasks
- [ ] Conduct comprehensive SEO audit
- [ ] Update keyword strategy
- [ ] Review and update structured data
@@ -369,6 +405,7 @@ Run these tools to validate implementation:
## 📚 Resources
### Documentation
- [Next.js Metadata API](https://nextjs.org/docs/app/building-your-application/optimizing/metadata)
- [Google Search Central](https://developers.google.com/search/docs)
- [Schema.org Structured Data](https://schema.org/)
@@ -376,6 +413,7 @@ Run these tools to validate implementation:
- [Twitter Cards](https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/abouts-cards)
### Tools
- [Google Search Console](https://search.google.com/search-console)
- [Google Analytics](https://analytics.google.com/)
- [Bing Webmaster Tools](https://www.bing.com/webmasters)
@@ -383,6 +421,7 @@ Run these tools to validate implementation:
- [PageSpeed Insights](https://pagespeed.web.dev/)
### Internal Documentation
- `docs/SEO_ANALYTICS_SETUP.md` - Analytics and tracking setup
- `docs/SEO_CONTENT_STRATEGY.md` - Content strategy and planning
- `web/app/components/GoogleAnalytics.tsx` - GA4 implementation

29
cli/.eslintrc.json Normal file
View File

@@ -0,0 +1,29 @@
{
"root": true,
"env": {
"node": true,
"es2022": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module",
"project": "./tsconfig.json"
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
]
},
"ignorePatterns": ["node_modules/", "dist/"]
}

4
cli/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.DS_Store

295
cli/README.md Normal file
View File

@@ -0,0 +1,295 @@
# Internet ID CLI
Command-line tool for Internet ID content registration and verification.
## Installation
### Global Installation (npm)
```bash
npm install -g @internet-id/cli
```
### Local Installation (from source)
```bash
cd cli
npm install
npm run build
npm link
```
## Quick Start
### 1. Initialize Configuration
Configure your credentials and settings:
```bash
internet-id init
```
You'll be prompted for:
- **API URL**: Internet ID API endpoint (default: http://localhost:3001)
- **API Key**: Optional API key for protected endpoints
- **Private Key**: Your Ethereum private key for signing content (required)
- **RPC URL**: Blockchain RPC endpoint (default: https://sepolia.base.org)
- **Registry Address**: ContentRegistry contract address
- **IPFS Provider**: Choose from web3storage, pinata, infura, or local
- **Provider Credentials**: Token/credentials for your chosen IPFS provider
Configuration is saved to `~/.internet-id.json`
### 2. Upload and Register Content
Upload a file, create a manifest, and register it on-chain:
```bash
# Privacy mode (default): Only manifest is uploaded to IPFS
internet-id upload ./my-video.mp4
# Upload content to IPFS as well
internet-id upload ./my-video.mp4 --upload-content
```
**Privacy Mode (Default)**:
- Computes content hash locally
- Creates and uploads manifest to IPFS
- Registers on-chain with manifest URI
- Original content stays private on your machine
**With Content Upload**:
- Uploads content to IPFS
- Creates manifest with content URI
- Uploads manifest to IPFS
- Registers everything on-chain
### 3. Verify Content
Verify content against manifest and on-chain registry:
```bash
# Verify by file path (looks up on-chain entry)
internet-id verify ./my-video.mp4
# Verify by manifest URI
internet-id verify ipfs://QmXxx...
```
Verification checks:
- ✓ Content hash matches manifest
- ✓ Signature is valid
- ✓ On-chain entry exists and matches
- ✓ Creator address is consistent
## Command Reference
### `internet-id init`
Configure credentials and settings interactively.
**Example:**
```bash
internet-id init
```
### `internet-id upload <file>`
Upload and register content on-chain.
**Arguments:**
- `<file>` - Path to file to upload
**Options:**
- `-u, --upload-content` - Upload content to IPFS (default: privacy mode)
- `-k, --private-key <key>` - Private key for signing (overrides config)
- `-r, --rpc-url <url>` - RPC URL (overrides config)
- `-g, --registry <address>` - Registry contract address (overrides config)
- `-p, --ipfs-provider <provider>` - IPFS provider (web3storage, pinata, infura, local)
**Examples:**
```bash
# Privacy mode (default)
internet-id upload ./video.mp4
# Upload content to IPFS
internet-id upload ./video.mp4 --upload-content
# Override registry address
internet-id upload ./video.mp4 --registry 0x123...
# Use different IPFS provider
internet-id upload ./video.mp4 --ipfs-provider pinata
```
### `internet-id verify <input>`
Verify content against manifest and on-chain registry.
**Arguments:**
- `<input>` - File path or manifest URI
**Options:**
- `-r, --rpc-url <url>` - RPC URL (overrides config)
- `-g, --registry <address>` - Registry contract address (overrides config)
**Examples:**
```bash
# Verify by file path
internet-id verify ./video.mp4
# Verify by manifest URI
internet-id verify ipfs://QmXxx...
# Override RPC URL
internet-id verify ./video.mp4 --rpc-url https://mainnet.base.org
```
## Configuration File
Configuration is stored in `~/.internet-id.json`:
```json
{
"apiUrl": "http://localhost:3001",
"apiKey": "optional-api-key",
"privateKey": "your-private-key",
"rpcUrl": "https://sepolia.base.org",
"registryAddress": "0x123...",
"ipfsProvider": "web3storage",
"web3StorageToken": "your-token"
}
```
You can manually edit this file or reconfigure with `internet-id init`.
## IPFS Providers
### Web3.Storage
```bash
# During init, select 'web3storage' and provide your token
# Get token from: https://web3.storage
```
### Pinata
```bash
# During init, select 'pinata' and provide your JWT
# Get JWT from: https://pinata.cloud
```
### Infura
```bash
# During init, select 'infura' and provide:
# - Project ID
# - Project Secret
# Get credentials from: https://infura.io
```
### Local IPFS Node
```bash
# Start local IPFS daemon first:
ipfs daemon
# During init, select 'local' and provide API URL (default: http://127.0.0.1:5001)
```
## Multi-Chain Support
The CLI supports multiple EVM-compatible chains:
**Testnets:**
- Base Sepolia: `https://sepolia.base.org`
- Ethereum Sepolia: `https://ethereum-sepolia-rpc.publicnode.com`
- Polygon Amoy: `https://rpc-amoy.polygon.technology`
**Mainnets:**
- Base: `https://mainnet.base.org`
- Ethereum: `https://eth.llamarpc.com`
- Polygon: `https://polygon-rpc.com`
Configure the RPC URL during `init` or override with `--rpc-url`.
## Troubleshooting
### "Private key not configured"
Run `internet-id init` to configure your credentials.
### "Registry address not configured"
You need to deploy the ContentRegistry contract and configure its address with `internet-id init`.
### "IPFS upload failed"
Check your IPFS provider credentials. You can reconfigure with `internet-id init`.
### "Transaction failed"
- Ensure you have sufficient gas tokens (ETH, MATIC, etc.)
- Verify the registry address is correct
- Check the RPC URL is accessible
## Security
- Never commit or share your `~/.internet-id.json` file
- Keep your private key secure
- Consider using a dedicated wallet for content registration
- Use testnets for testing before mainnet deployments
## Examples
### Complete Workflow
```bash
# 1. Configure (one time)
internet-id init
# 2. Upload and register content
internet-id upload ./my-creation.jpg --upload-content
# 3. Verify it worked
internet-id verify ./my-creation.jpg
# 4. Share the manifest URI with others
# They can verify without downloading your original file
internet-id verify ipfs://QmYourManifestCID...
```
### Automation Script
```bash
#!/bin/bash
# Batch register multiple files
for file in *.jpg; do
echo "Registering $file..."
internet-id upload "$file"
done
```
## Related
- [Internet ID SDK](../sdk/typescript/README.md) - TypeScript/JavaScript SDK
- [Internet ID API](../README.md#api-reference-summary) - REST API documentation
- [Web App](../web/README.md) - Web interface
## License
MIT

2684
cli/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
cli/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "@internet-id/cli",
"version": "1.0.0",
"description": "CLI tool for Internet ID content registration and verification",
"main": "dist/index.js",
"bin": {
"internet-id": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build",
"lint": "eslint . --ext .ts",
"lint:fix": "eslint . --ext .ts --fix"
},
"keywords": [
"internet-id",
"content-provenance",
"verification",
"blockchain",
"cli"
],
"author": "Internet ID Team",
"license": "MIT",
"dependencies": {
"axios": "^1.7.2",
"chalk": "^4.1.2",
"commander": "^11.1.0",
"dotenv": "^16.4.5",
"ethers": "^6.13.3",
"inquirer": "^9.2.12",
"ora": "^5.4.1"
},
"devDependencies": {
"@types/inquirer": "^9.0.7",
"@types/node": "^20.12.12",
"@typescript-eslint/eslint-plugin": "^8.46.2",
"@typescript-eslint/parser": "^8.46.2",
"eslint": "^8.57.1",
"typescript": "^5.6.3"
},
"files": [
"dist",
"README.md"
]
}

116
cli/src/commands/init.ts Normal file
View File

@@ -0,0 +1,116 @@
import inquirer from "inquirer";
import { ConfigManager } from "../config";
export async function initCommand(): Promise<void> {
console.log("🔧 Internet ID CLI Configuration\n");
const config = new ConfigManager();
const current = config.getAll();
const answers = await inquirer.prompt([
{
type: "input",
name: "apiUrl",
message: "API URL:",
default: current.apiUrl || "http://localhost:3001",
},
{
type: "input",
name: "apiKey",
message: "API Key (optional):",
default: current.apiKey || "",
},
{
type: "password",
name: "privateKey",
message: "Private Key (for signing):",
default: current.privateKey || "",
validate: (input: string) => {
if (!input) return "Private key is required";
if (!/^(0x)?[0-9a-fA-F]{64}$/.test(input)) {
return "Invalid private key format (must be 64 hex characters, optionally prefixed with 0x)";
}
return true;
},
},
{
type: "input",
name: "rpcUrl",
message: "RPC URL:",
default: current.rpcUrl || "https://sepolia.base.org",
},
{
type: "input",
name: "registryAddress",
message: "Registry Contract Address (optional):",
default: current.registryAddress || "",
},
{
type: "list",
name: "ipfsProvider",
message: "IPFS Provider:",
choices: ["web3storage", "pinata", "infura", "local"],
default: current.ipfsProvider || "web3storage",
},
]);
// Ask for provider-specific credentials
if (answers.ipfsProvider === "web3storage") {
const web3Answers = await inquirer.prompt([
{
type: "password",
name: "web3StorageToken",
message: "Web3.Storage Token:",
default: current.web3StorageToken || "",
},
]);
answers.web3StorageToken = web3Answers.web3StorageToken;
} else if (answers.ipfsProvider === "pinata") {
const pinataAnswers = await inquirer.prompt([
{
type: "password",
name: "pinataJwt",
message: "Pinata JWT:",
default: current.pinataJwt || "",
},
]);
answers.pinataJwt = pinataAnswers.pinataJwt;
} else if (answers.ipfsProvider === "infura") {
const infuraAnswers = await inquirer.prompt([
{
type: "input",
name: "infuraProjectId",
message: "Infura Project ID:",
default: current.infuraProjectId || "",
},
{
type: "password",
name: "infuraProjectSecret",
message: "Infura Project Secret:",
default: current.infuraProjectSecret || "",
},
{
type: "input",
name: "ipfsApiUrl",
message: "IPFS API URL:",
default: current.ipfsApiUrl || "https://ipfs.infura.io:5001",
},
]);
Object.assign(answers, infuraAnswers);
} else if (answers.ipfsProvider === "local") {
const localAnswers = await inquirer.prompt([
{
type: "input",
name: "ipfsApiUrl",
message: "Local IPFS API URL:",
default: current.ipfsApiUrl || "http://127.0.0.1:5001",
},
]);
answers.ipfsApiUrl = localAnswers.ipfsApiUrl;
}
config.setAll(answers);
config.save();
console.log(`\n✅ Configuration saved to ${config.getConfigPath()}`);
}

128
cli/src/commands/upload.ts Normal file
View File

@@ -0,0 +1,128 @@
import { existsSync, writeFileSync } from "fs";
import { ethers } from "ethers";
import { ConfigManager } from "../config";
import { sha256HexFromFile, signMessage, getAddress, uploadToIpfs, createManifest } from "../utils";
interface UploadOptions {
uploadContent?: boolean;
privateKey?: string;
rpcUrl?: string;
registry?: string;
ipfsProvider?: string;
}
export async function uploadCommand(filePath: string, options: UploadOptions): Promise<void> {
console.log("📤 Internet ID Upload & Register\n");
// Validate file exists
if (!existsSync(filePath)) {
console.error(`❌ Error: File not found: ${filePath}`);
process.exit(1);
}
// Load configuration
const config = new ConfigManager();
const privateKey = (options.privateKey || config.get("privateKey")) as string;
const rpcUrl = (options.rpcUrl || config.get("rpcUrl")) as string;
const registryAddress = (options.registry || config.get("registryAddress")) as string;
const ipfsProvider = (options.ipfsProvider || config.get("ipfsProvider")) as
| "web3storage"
| "pinata"
| "infura"
| "local";
if (!privateKey) {
console.error("❌ Error: Private key not configured. Run: internet-id init");
process.exit(1);
}
if (!rpcUrl) {
console.error("❌ Error: RPC URL not configured. Run: internet-id init");
process.exit(1);
}
if (!registryAddress) {
console.error("❌ Error: Registry address not configured. Run: internet-id init");
process.exit(1);
}
try {
// Step 1: Compute content hash
console.log("1⃣ Computing content hash...");
const contentHash = await sha256HexFromFile(filePath);
console.log(` Content hash: ${contentHash}`);
// Step 2: Upload file to IPFS (optional)
let contentCid: string | undefined;
if (options.uploadContent) {
console.log("\n2⃣ Uploading content to IPFS...");
const credentials = {
web3StorageToken: config.get("web3StorageToken"),
pinataJwt: config.get("pinataJwt"),
infuraProjectId: config.get("infuraProjectId"),
infuraProjectSecret: config.get("infuraProjectSecret"),
ipfsApiUrl: config.get("ipfsApiUrl"),
};
contentCid = await uploadToIpfs(filePath, ipfsProvider, credentials);
console.log(` Content CID: ${contentCid}`);
} else {
console.log("\n2⃣ Skipping content upload (privacy mode)");
}
// Step 3: Create and sign manifest
console.log("\n3⃣ Creating manifest...");
const creatorAddress = getAddress(privateKey);
const signature = await signMessage(contentHash, privateKey);
const contentUri = contentCid ? `ipfs://${contentCid}` : "";
const manifest = createManifest(contentHash, contentUri, creatorAddress, signature);
console.log(` Creator: ${creatorAddress}`);
// Step 4: Upload manifest to IPFS
console.log("\n4⃣ Uploading manifest to IPFS...");
const manifestJson = JSON.stringify(manifest, null, 2);
const manifestPath = `/tmp/manifest-${Date.now()}.json`;
writeFileSync(manifestPath, manifestJson);
const credentials = {
web3StorageToken: config.get("web3StorageToken"),
pinataJwt: config.get("pinataJwt"),
infuraProjectId: config.get("infuraProjectId"),
infuraProjectSecret: config.get("infuraProjectSecret"),
ipfsApiUrl: config.get("ipfsApiUrl"),
};
const manifestCid = await uploadToIpfs(manifestPath, ipfsProvider, credentials);
const manifestUri = `ipfs://${manifestCid}`;
console.log(` Manifest URI: ${manifestUri}`);
// Step 5: Register on-chain
console.log("\n5⃣ Registering on-chain...");
const provider = new ethers.JsonRpcProvider(rpcUrl);
const wallet = new ethers.Wallet(privateKey, provider);
const abi = [
"function register(bytes32 contentHash, string manifestURI) external",
"function entries(bytes32) view returns (address creator, bytes32 contentHash, string manifestURI, uint64 timestamp)",
];
const registry = new ethers.Contract(registryAddress, abi, wallet);
const tx = await registry.register(contentHash, manifestUri);
console.log(` Transaction sent: ${tx.hash}`);
console.log(" Waiting for confirmation...");
const receipt = await tx.wait();
console.log(` ✅ Confirmed in block ${receipt?.blockNumber}`);
// Summary
console.log("\n✅ Upload & Registration Complete!\n");
console.log("📝 Summary:");
console.log(` Content Hash: ${contentHash}`);
if (contentCid) {
console.log(` Content URI: ipfs://${contentCid}`);
}
console.log(` Manifest URI: ${manifestUri}`);
console.log(` Transaction: ${receipt?.hash}`);
console.log(` Registry: ${registryAddress}`);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`\n❌ Error: ${errorMessage}`);
process.exit(1);
}
}

122
cli/src/commands/verify.ts Normal file
View File

@@ -0,0 +1,122 @@
import { existsSync } from "fs";
import { ethers } from "ethers";
import { ConfigManager } from "../config";
import { sha256HexFromFile, fetchManifest } from "../utils";
interface VerifyOptions {
rpcUrl?: string;
registry?: string;
}
export async function verifyCommand(input: string, options: VerifyOptions): Promise<void> {
console.log("🔍 Internet ID Verify\n");
const config = new ConfigManager();
const rpcUrl = options.rpcUrl || config.get("rpcUrl");
const registryAddress = options.registry || config.get("registryAddress");
if (!rpcUrl) {
console.error("❌ Error: RPC URL not configured. Run: internet-id init");
process.exit(1);
}
if (!registryAddress) {
console.error("❌ Error: Registry address not configured. Run: internet-id init");
process.exit(1);
}
try {
let contentHash: string;
let manifestUri: string;
// Determine if input is a file path or manifest URI
if (existsSync(input)) {
// Input is a file path
console.log("1⃣ Computing content hash from file...");
contentHash = await sha256HexFromFile(input);
console.log(` Content hash: ${contentHash}`);
// Get manifest URI from on-chain registry
console.log("\n2⃣ Fetching on-chain entry...");
const provider = new ethers.JsonRpcProvider(rpcUrl);
const abi = [
"function entries(bytes32) view returns (address creator, bytes32 contentHash, string manifestURI, uint64 timestamp)",
];
const registry = new ethers.Contract(registryAddress, abi, provider);
const entry = await registry.entries(contentHash);
if (entry.creator === ethers.ZeroAddress) {
console.error(`\n❌ Verification Failed: Content not registered on-chain`);
process.exit(1);
}
manifestUri = entry.manifestURI;
console.log(` Creator: ${entry.creator}`);
console.log(` Manifest URI: ${manifestUri}`);
console.log(` Registered at: ${new Date(Number(entry.timestamp) * 1000).toISOString()}`);
} else {
// Input is a manifest URI
manifestUri = input;
console.log("1⃣ Using provided manifest URI...");
console.log(` Manifest URI: ${manifestUri}`);
}
// Fetch and verify manifest
console.log("\n3⃣ Fetching manifest...");
const manifest = await fetchManifest(manifestUri);
contentHash = manifest.content_hash as string;
console.log(` Content hash from manifest: ${contentHash}`);
console.log(` Creator DID: ${manifest.creator_did}`);
console.log(` Created at: ${manifest.created_at}`);
// Verify signature
console.log("\n4⃣ Verifying signature...");
const messageBytes = ethers.getBytes(contentHash);
const recoveredAddress = ethers.verifyMessage(messageBytes, manifest.signature as string);
console.log(` Recovered signer: ${recoveredAddress}`);
// Verify on-chain
console.log("\n5⃣ Verifying on-chain...");
const provider = new ethers.JsonRpcProvider(rpcUrl);
const abi = [
"function entries(bytes32) view returns (address creator, bytes32 contentHash, string manifestURI, uint64 timestamp)",
];
const registry = new ethers.Contract(registryAddress, abi, provider);
const entry = await registry.entries(contentHash);
if (entry.creator === ethers.ZeroAddress) {
console.error(`\n❌ Verification Failed: Content not registered on-chain`);
process.exit(1);
}
// Check if creator matches
const isValid =
entry.creator.toLowerCase() === recoveredAddress.toLowerCase() &&
entry.contentHash === contentHash &&
entry.manifestURI === manifestUri;
if (isValid) {
console.log(` On-chain creator: ${entry.creator}`);
console.log(` On-chain hash: ${entry.contentHash}`);
console.log(` On-chain manifest URI: ${entry.manifestURI}`);
console.log(` Registered at: ${new Date(Number(entry.timestamp) * 1000).toISOString()}`);
console.log("\n✅ Verification Successful!\n");
console.log("📝 Summary:");
console.log(` ✓ Content hash matches manifest`);
console.log(` ✓ Signature is valid`);
console.log(` ✓ On-chain entry matches`);
console.log(` ✓ Creator: ${entry.creator}`);
} else {
console.error("\n❌ Verification Failed: Data mismatch");
console.log(` Manifest signer: ${recoveredAddress}`);
console.log(` On-chain creator: ${entry.creator}`);
console.log(` Match: ${entry.creator.toLowerCase() === recoveredAddress.toLowerCase()}`);
process.exit(1);
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`\n❌ Error: ${errorMessage}`);
process.exit(1);
}
}

75
cli/src/config.ts Normal file
View File

@@ -0,0 +1,75 @@
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
export interface InternetIdConfig {
apiUrl?: string;
apiKey?: string;
privateKey?: string;
rpcUrl?: string;
registryAddress?: string;
ipfsProvider?: "web3storage" | "pinata" | "infura" | "local";
web3StorageToken?: string;
pinataJwt?: string;
infuraProjectId?: string;
infuraProjectSecret?: string;
ipfsApiUrl?: string;
}
export class ConfigManager {
private configPath: string;
private config: InternetIdConfig;
constructor() {
this.configPath = path.join(os.homedir(), ".internet-id.json");
this.config = this.load();
}
private load(): InternetIdConfig {
try {
if (fs.existsSync(this.configPath)) {
const data = fs.readFileSync(this.configPath, "utf-8");
return JSON.parse(data);
}
} catch (_error) {
console.warn("Warning: Could not load config file, using defaults");
}
return {};
}
public save(): void {
try {
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
} catch (error) {
throw new Error(`Failed to save config: ${error}`);
}
}
public get(key: keyof InternetIdConfig): string | undefined {
return this.config[key];
}
public set(key: keyof InternetIdConfig, value: string | undefined): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.config as any)[key] = value;
}
public getAll(): InternetIdConfig {
return { ...this.config };
}
public setAll(config: InternetIdConfig): void {
this.config = { ...config };
}
public clear(): void {
this.config = {};
if (fs.existsSync(this.configPath)) {
fs.unlinkSync(this.configPath);
}
}
public getConfigPath(): string {
return this.configPath;
}
}

82
cli/src/index.ts Normal file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env node
import { Command } from "commander";
import { initCommand } from "./commands/init";
import { uploadCommand } from "./commands/upload";
import { verifyCommand } from "./commands/verify";
interface UploadOptions {
uploadContent?: boolean;
privateKey?: string;
rpcUrl?: string;
registry?: string;
ipfsProvider?: string;
}
interface VerifyOptions {
rpcUrl?: string;
registry?: string;
}
const program = new Command();
program
.name("internet-id")
.description("CLI tool for Internet ID content registration and verification")
.version("1.0.0");
// init command
program
.command("init")
.description("Configure credentials and settings")
.action(async () => {
try {
await initCommand();
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error: ${errorMessage}`);
process.exit(1);
}
});
// upload command
program
.command("upload <file>")
.description("Upload and register content")
.option(
"-u, --upload-content",
"Upload content to IPFS (default: privacy mode, only manifest is uploaded)"
)
.option("-k, --private-key <key>", "Private key for signing (overrides config)")
.option("-r, --rpc-url <url>", "RPC URL (overrides config)")
.option("-g, --registry <address>", "Registry contract address (overrides config)")
.option("-p, --ipfs-provider <provider>", "IPFS provider: web3storage, pinata, infura, local")
.action(async (file: string, options: UploadOptions) => {
try {
await uploadCommand(file, options);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error: ${errorMessage}`);
process.exit(1);
}
});
// verify command
program
.command("verify <input>")
.description(
"Verify content against manifest and on-chain registry (input: file path or manifest URI)"
)
.option("-r, --rpc-url <url>", "RPC URL (overrides config)")
.option("-g, --registry <address>", "Registry contract address (overrides config)")
.action(async (input: string, options: VerifyOptions) => {
try {
await verifyCommand(input, options);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error: ${errorMessage}`);
process.exit(1);
}
});
program.parse(process.argv);

194
cli/src/utils.ts Normal file
View File

@@ -0,0 +1,194 @@
import { createHash } from "crypto";
import { createReadStream } from "fs";
import { pipeline } from "stream/promises";
import { ethers } from "ethers";
import axios from "axios";
import FormData from "form-data";
/**
* Compute SHA256 hash of a file
*/
export function sha256Hex(buffer: Buffer): string {
return "0x" + createHash("sha256").update(buffer).digest("hex");
}
/**
* Compute SHA256 hash of a file using streaming (memory efficient)
*/
export async function sha256HexFromFile(filePath: string): Promise<string> {
const hash = createHash("sha256");
await pipeline(createReadStream(filePath), hash);
return "0x" + hash.digest("hex");
}
/**
* Sign a message with a private key
*/
export async function signMessage(message: string, privateKey: string): Promise<string> {
const wallet = new ethers.Wallet(privateKey);
return wallet.signMessage(ethers.getBytes(message));
}
/**
* Get wallet address from private key
*/
export function getAddress(privateKey: string): string {
const wallet = new ethers.Wallet(privateKey);
return wallet.address;
}
/**
* Upload file to IPFS using configured provider
*/
export async function uploadToIpfs(
filePath: string,
provider: "web3storage" | "pinata" | "infura" | "local",
credentials: {
web3StorageToken?: string;
pinataJwt?: string;
infuraProjectId?: string;
infuraProjectSecret?: string;
ipfsApiUrl?: string;
}
): Promise<string> {
if (provider === "web3storage") {
if (!credentials.web3StorageToken) {
throw new Error("Web3.Storage token is required for web3storage provider");
}
return uploadViaWeb3Storage(filePath, credentials.web3StorageToken);
} else if (provider === "pinata") {
if (!credentials.pinataJwt) {
throw new Error("Pinata JWT is required for pinata provider");
}
return uploadViaPinata(filePath, credentials.pinataJwt);
} else if (provider === "infura") {
if (!credentials.infuraProjectId || !credentials.infuraProjectSecret) {
throw new Error("Infura Project ID and Secret are required for infura provider");
}
return uploadViaInfura(
filePath,
credentials.ipfsApiUrl || "https://ipfs.infura.io:5001",
credentials.infuraProjectId,
credentials.infuraProjectSecret
);
} else if (provider === "local") {
return uploadViaLocal(filePath, credentials.ipfsApiUrl || "http://127.0.0.1:5001");
}
throw new Error("Unsupported IPFS provider");
}
async function uploadViaWeb3Storage(filePath: string, token: string): Promise<string> {
// Use streaming for memory efficiency with large files
const form = new FormData();
form.append("file", createReadStream(filePath));
const response = await axios.post("https://api.web3.storage/upload", form, {
headers: {
Authorization: `Bearer ${token}`,
...form.getHeaders(),
},
});
return response.data.cid;
}
async function uploadViaPinata(filePath: string, jwt: string): Promise<string> {
const form = new FormData();
form.append("file", createReadStream(filePath));
const response = await axios.post("https://api.pinata.cloud/pinning/pinFileToIPFS", form, {
headers: {
Authorization: `Bearer ${jwt}`,
...form.getHeaders(),
},
});
return response.data.IpfsHash;
}
async function uploadViaInfura(
filePath: string,
apiUrl: string,
projectId: string,
projectSecret: string
): Promise<string> {
const addUrl = `${apiUrl.replace(/\/$/, "")}/api/v0/add?pin=true&wrap-with-directory=false`;
const auth = "Basic " + Buffer.from(`${projectId}:${projectSecret}`).toString("base64");
// Use streaming for memory efficiency
const form = new FormData();
form.append("file", createReadStream(filePath));
const response = await axios.post(addUrl, form, {
headers: {
Authorization: auth,
...form.getHeaders(),
},
});
const body = response.data;
if (typeof body === "string") {
const lines = body.trim().split(/\r?\n/).filter(Boolean);
const last = JSON.parse(lines[lines.length - 1]);
return last.Hash;
}
return body.Hash;
}
async function uploadViaLocal(filePath: string, apiUrl: string): Promise<string> {
const addUrl = `${apiUrl.replace(/\/$/, "")}/api/v0/add?pin=true&wrap-with-directory=false`;
// No auth header for local IPFS nodes
const form = new FormData();
form.append("file", createReadStream(filePath));
const response = await axios.post(addUrl, form, {
headers: {
...form.getHeaders(),
},
});
const body = response.data;
if (typeof body === "string") {
const lines = body.trim().split(/\r?\n/).filter(Boolean);
const last = JSON.parse(lines[lines.length - 1]);
return last.Hash;
}
return body.Hash;
}
/**
* Fetch manifest from IPFS or HTTP(S)
*/
export async function fetchManifest(uri: string): Promise<Record<string, unknown>> {
if (uri.startsWith("ipfs://")) {
const path = uri.replace("ipfs://", "");
const url = `https://ipfs.io/ipfs/${path}`;
const response = await axios.get(url);
return response.data;
}
if (uri.startsWith("http://") || uri.startsWith("https://")) {
const response = await axios.get(uri);
return response.data;
}
throw new Error("Unsupported manifest URI scheme: " + uri);
}
/**
* Create a manifest object
*/
export function createManifest(
contentHash: string,
contentUri: string,
creatorAddress: string,
signature: string
): Record<string, unknown> {
return {
version: "1.0",
algorithm: "sha256",
content_hash: contentHash,
content_uri: contentUri,
creator_did: `did:pkh:eip155:1:${creatorAddress}`,
created_at: new Date().toISOString(),
signature,
attestations: [],
};
}

19
cli/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -13,6 +13,7 @@ October 31, 2024
### 1. Database Schema
**New Model: ApiKey**
```prisma
model ApiKey {
id String @id @default(cuid())
@@ -35,6 +36,7 @@ model ApiKey {
### 2. Authentication Services
#### API Key Service (`scripts/services/api-key.service.ts`)
- `createApiKey()` - Generate new API key with format `iid_<64hex>`
- `verifyApiKey()` - Verify and validate API key
- `listApiKeys()` - List user's API keys
@@ -44,6 +46,7 @@ model ApiKey {
**Security**: API keys are hashed with SHA-256 before storage. SHA-256 is appropriate because API keys are cryptographically random 32-byte values, not user-chosen passwords.
#### JWT Service (`scripts/services/jwt.service.ts`)
- `generateJwtToken()` - Create JWT for user-scoped access
- `verifyJwtToken()` - Validate and decode JWT
- `extractTokenFromHeader()` - Parse Authorization header
@@ -51,6 +54,7 @@ model ApiKey {
**Security**: JWT_SECRET is required in production (validates on startup).
#### Authentication Middleware (`scripts/middleware/api-auth.middleware.ts`)
- `authenticateRequest()` - Requires valid authentication
- `optionalAuthentication()` - Optional authentication
- Supports both API keys (`x-api-key` header) and JWT tokens (`Authorization: Bearer` header)
@@ -58,29 +62,31 @@ model ApiKey {
### 3. API Routes (v1)
#### Verification Endpoints
- `GET /api/v1/verify/platform` - Verify by platform binding
- Query: `url` OR (`platform` + `platformId`)
- Returns: verification result with manifest data
- `GET /api/v1/verify/hash/:hash` - Verify by content hash
- Path: content hash (32-byte hex with 0x prefix)
- Returns: on-chain registration details
#### Content Endpoints
- `GET /api/v1/content` - List content with pagination
- Query: `limit` (max 100), `offset`, `creator` (filter)
- Returns: paginated content list
- `GET /api/v1/content/:id` - Get content by database ID
- `GET /api/v1/content/hash/:hash` - Get content by hash
#### API Key Management (Auth Required)
- `POST /api/v1/api-keys` - Create new API key
- `GET /api/v1/api-keys` - List user's keys
- `PATCH /api/v1/api-keys/:id/revoke` - Revoke key
- `DELETE /api/v1/api-keys/:id` - Delete key
#### Authentication
- `POST /api/v1/auth/token` - Generate JWT by wallet signature
- Body: `{ address, signature, message }`
- Returns: JWT token with 24h expiry
@@ -88,6 +94,7 @@ model ApiKey {
### 4. OpenAPI Documentation
**Swagger Service** (`scripts/services/swagger.service.ts`)
- Generates OpenAPI 3.0 spec from JSDoc comments
- Interactive Swagger UI at `/api/docs`
- JSON spec at `/api/docs.json`
@@ -98,29 +105,31 @@ model ApiKey {
**Package**: `@internet-id/sdk`
**Features**:
- Full TypeScript type definitions
- Support for all v1 API endpoints
- Both API key and JWT authentication
- Error handling and response types
**Key Methods**:
```typescript
const client = new InternetIdClient({ apiKey: '...' });
const client = new InternetIdClient({ apiKey: "..." });
// Verification
await client.verifyByPlatform({ url: 'https://...' });
await client.verifyByHash('0x...');
await client.verifyByPlatform({ url: "https://..." });
await client.verifyByHash("0x...");
// Content
await client.listContent({ limit: 20, offset: 0 });
await client.getContentById('id');
await client.getContentByHash('0x...');
await client.getContentById("id");
await client.getContentByHash("0x...");
// API Keys (requires JWT)
await client.createApiKey({ name: 'My Key' });
await client.createApiKey({ name: "My Key" });
await client.listApiKeys();
await client.revokeApiKey('id');
await client.deleteApiKey('id');
await client.revokeApiKey("id");
await client.deleteApiKey("id");
// Authentication
await client.generateToken({ address, signature, message });
@@ -129,6 +138,7 @@ await client.generateToken({ address, signature, message });
### 6. Documentation
#### Public API Documentation (`docs/PUBLIC_API.md`)
- Complete API reference
- Authentication guide
- Rate limits and versioning
@@ -136,12 +146,14 @@ await client.generateToken({ address, signature, message });
- Examples for all endpoints
#### Developer Onboarding Guide (`docs/DEVELOPER_ONBOARDING.md`)
- Quick start guide
- Common use cases with code examples
- Best practices for security, rate limiting, caching
- Testing recommendations
#### SDK Documentation (`sdk/typescript/README.md`)
- Installation and setup
- Complete API reference
- Code examples
@@ -150,18 +162,21 @@ await client.generateToken({ address, signature, message });
### 7. Tests
**API Key Service Tests** (14 tests)
- Create API key with default/custom settings
- Verify valid/invalid/revoked/expired keys
- List, revoke, and delete keys
- User isolation
**JWT Service Tests** (10 tests)
- Generate and verify tokens
- Handle invalid/tampered tokens
- Extract tokens from headers
- Issuer validation
**Public API Integration Tests** (19 tests)
- Verify endpoints (platform and hash)
- Content listing and retrieval
- API key management flows
@@ -173,6 +188,7 @@ await client.generateToken({ address, signature, message });
### 8. Code Quality
**Improvements Made**:
- ✅ Shared validation constants (`CONTENT_HASH_PATTERN`)
- ✅ JWT_SECRET validation in production
- ✅ Improved cache service timeout (3s with retry limit)
@@ -180,6 +196,7 @@ await client.generateToken({ address, signature, message });
- ✅ Security documentation for API key hashing
**Security Analysis (CodeQL)**:
- ✅ No new vulnerabilities introduced
- ✅ API key hashing documented and appropriate
- ✅ JWT secrets validated
@@ -188,10 +205,12 @@ await client.generateToken({ address, signature, message });
## Rate Limiting
### Tiers
- **Free**: 100 requests per minute
- **Paid**: 1000 requests per minute
### Implementation
- Per-tier rate limits enforced via express-rate-limit
- In-memory rate limiting (falls back if Redis unavailable)
- Redis support for distributed deployments
@@ -199,12 +218,14 @@ await client.generateToken({ address, signature, message });
## API Versioning
### Strategy
- URL path versioning: `/api/v1/`, `/api/v2/`, etc.
- Current version: v1
- Semantic versioning principles
- Deprecation policy (3 months notice, 6 months support)
### Response Headers
```
Deprecation: true (when applicable)
Sunset: <date> (when applicable)
@@ -214,6 +235,7 @@ Link: <migration-guide>; rel="sunset"
## Authentication
### API Keys
- Format: `iid_<64-character-hex>`
- Hashed with SHA-256 before storage
- Tied to user accounts
@@ -221,12 +243,14 @@ Link: <migration-guide>; rel="sunset"
- Track last usage
### JWT Tokens
- 24-hour expiry (configurable)
- HS256 signing algorithm
- Issuer: "internet-id-api"
- Payload: userId, address, email, tier
### Headers
```
# API Key
x-api-key: iid_abc123...
@@ -240,6 +264,7 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
### Environment Variables Required
**Production**:
```bash
JWT_SECRET=<strong-secret-32-bytes> # REQUIRED
DATABASE_URL=<postgres-connection-string>
@@ -248,6 +273,7 @@ NODE_ENV=production
```
**Optional**:
```bash
REDIS_URL=redis://localhost:6379 # For distributed rate limiting
JWT_EXPIRY=24h # Token expiry duration
@@ -255,6 +281,7 @@ PORT=3001 # API server port
```
### Security Checklist
- [ ] Set strong JWT_SECRET (32+ bytes)
- [ ] Enable HTTPS in production
- [ ] Configure Redis for rate limiting (optional)
@@ -265,35 +292,41 @@ PORT=3001 # API server port
## Performance
### Caching
- Content metadata: 10 minutes
- Manifests: 15 minutes
- Platform bindings: 3 minutes
- Verification status: 5 minutes
### Rate Limits
- Free tier: 100 req/min (prevents abuse)
- Paid tier: 1000 req/min (supports production loads)
## Future Enhancements (Out of Scope)
### GraphQL API
- Flexible query language
- Single endpoint
- Field-level permissions
- Real-time subscriptions
### WebSocket Support
- Real-time verification updates
- Push notifications for content changes
- Live feed of new registrations
### Additional SDKs
- Python SDK
- Go SDK
- Rust SDK
- Ruby SDK
### Advanced Features
- API usage analytics dashboard
- Billing and payment integration
- Sandbox environment for testing
@@ -302,6 +335,7 @@ PORT=3001 # API server port
- Batch operations
### Developer Tools
- CLI tool for API management
- Postman collection
- API testing utilities
@@ -310,9 +344,11 @@ PORT=3001 # API server port
## Migration Guide (Legacy to v1)
### Breaking Changes
None - v1 is additive. Legacy endpoints still available at `/api/`.
### Recommended Migration Path
1. Generate API key via `/api/v1/auth/token` and `/api/v1/api-keys`
2. Update client code to use v1 endpoints
3. Add proper error handling for rate limits
@@ -320,6 +356,7 @@ None - v1 is additive. Legacy endpoints still available at `/api/`.
5. Monitor API usage
### Compatibility
- Legacy endpoints: Available at `/api/`
- v1 endpoints: Available at `/api/v1/`
- Both can coexist indefinitely
@@ -327,6 +364,7 @@ None - v1 is additive. Legacy endpoints still available at `/api/`.
## Monitoring and Observability
### Metrics to Track
- API request rate (per endpoint)
- Authentication success/failure rate
- Rate limit hits
@@ -335,6 +373,7 @@ None - v1 is additive. Legacy endpoints still available at `/api/`.
- Cache hit/miss ratio
### Logging
- All authentication attempts
- Rate limit violations
- API key usage patterns
@@ -343,12 +382,14 @@ None - v1 is additive. Legacy endpoints still available at `/api/`.
## Support and Resources
### Documentation
- Public API Docs: `/docs/PUBLIC_API.md`
- Developer Onboarding: `/docs/DEVELOPER_ONBOARDING.md`
- SDK Documentation: `/sdk/typescript/README.md`
- Interactive API Explorer: `/api/docs` (Swagger UI)
### Community
- GitHub Issues: Report bugs and request features
- Example Apps: Coming soon

View File

@@ -5,6 +5,7 @@ This document provides comprehensive documentation for the Internet ID badge gen
## Overview
The Badge API enables you to:
- Generate SVG badges with verification status
- Customize badge appearance (theme, size, style)
- Get embed codes for HTML and Markdown
@@ -14,6 +15,7 @@ The Badge API enables you to:
## Base URL
All API endpoints are relative to your API base URL:
- Development: `http://localhost:3001`
- Production: Set via `NEXT_PUBLIC_API_BASE` environment variable
@@ -27,16 +29,17 @@ Generate an SVG badge for a content hash with customizable appearance.
**Parameters:**
| Parameter | Type | Options | Default | Description |
|-----------|------|---------|---------|-------------|
| `theme` | string | `dark`, `light`, `blue`, `green`, `purple` | `dark` | Color theme for the badge |
| `size` | string \| number | `small`, `medium`, `large`, or `120-640` | `medium` | Badge width in pixels |
| `style` | string | `flat`, `rounded`, `pill`, `minimal` | `rounded` | Badge shape style |
| `showTimestamp` | boolean | `true`, `false` | `false` | Display verification timestamp |
| `showPlatform` | boolean | `true`, `false` | `false` | Display platform name |
| `platform` | string | Any platform name | - | Override platform display name |
| Parameter | Type | Options | Default | Description |
| --------------- | ---------------- | ------------------------------------------ | --------- | ------------------------------ |
| `theme` | string | `dark`, `light`, `blue`, `green`, `purple` | `dark` | Color theme for the badge |
| `size` | string \| number | `small`, `medium`, `large`, or `120-640` | `medium` | Badge width in pixels |
| `style` | string | `flat`, `rounded`, `pill`, `minimal` | `rounded` | Badge shape style |
| `showTimestamp` | boolean | `true`, `false` | `false` | Display verification timestamp |
| `showPlatform` | boolean | `true`, `false` | `false` | Display platform name |
| `platform` | string | Any platform name | - | Override platform display name |
**Size Presets:**
- `small`: 180px
- `medium`: 240px
- `large`: 320px
@@ -80,6 +83,7 @@ Get pre-generated HTML and Markdown embed codes for a badge.
**Endpoint:** `GET /api/badge/:hash/embed`
**Query Parameters:**
- Same as SVG endpoint (theme, size, style)
**Example Request:**
@@ -176,26 +180,31 @@ GET /api/badge/options
## Theme Options
### Dark (Default)
- Background: `#0b0f1a`
- Foreground: `#9ef`
- Accent: `#0cf`
### Light
- Background: `#ffffff`
- Foreground: `#0b0f1a`
- Accent: `#0080ff`
### Blue
- Background: `#1a237e`
- Foreground: `#e3f2fd`
- Accent: `#64b5f6`
### Green
- Background: `#1b5e20`
- Foreground: `#e8f5e9`
- Accent: `#81c784`
### Purple
- Background: `#4a148c`
- Foreground: `#f3e5f5`
- Accent: `#ba68c8`
@@ -205,21 +214,25 @@ GET /api/badge/options
## Style Options
### Flat
- No border radius
- Sharp corners
- Clean, modern look
### Rounded (Default)
- 6px border radius (scaled with size)
- Soft corners
- Balanced appearance
### Pill
- Border radius equals half of height
- Fully rounded ends
- Distinctive, modern style
### Minimal
- Only shows verification checkmark
- No hash display
- Compact design
@@ -238,9 +251,9 @@ GET /api/badge/options
<!-- With custom styling -->
<a href="https://your-site.com/verify?hash=0x1234..." style="display: inline-block;">
<img
src="https://your-site.com/api/badge/0x1234.../svg?theme=blue&size=large"
alt="Verified on Internet ID"
<img
src="https://your-site.com/api/badge/0x1234.../svg?theme=blue&size=large"
alt="Verified on Internet ID"
style="vertical-align: middle;"
/>
</a>
@@ -250,44 +263,46 @@ GET /api/badge/options
```markdown
<!-- Basic embed -->
[![Verified on Internet ID](https://your-site.com/api/badge/0x1234.../svg)](https://your-site.com/verify?hash=0x1234...)
<!-- With custom theme -->
[![Verified on Internet ID](https://your-site.com/api/badge/0x1234.../svg?theme=light&style=pill)](https://your-site.com/verify?hash=0x1234...)
```
### React Component
```tsx
import React from 'react';
import React from "react";
interface BadgeProps {
hash: string;
theme?: 'dark' | 'light' | 'blue' | 'green' | 'purple';
size?: 'small' | 'medium' | 'large' | number;
style?: 'flat' | 'rounded' | 'pill' | 'minimal';
theme?: "dark" | "light" | "blue" | "green" | "purple";
size?: "small" | "medium" | "large" | number;
style?: "flat" | "rounded" | "pill" | "minimal";
showTimestamp?: boolean;
}
export function VerificationBadge({
hash,
theme = 'dark',
size = 'medium',
style = 'rounded',
showTimestamp = false
export function VerificationBadge({
hash,
theme = "dark",
size = "medium",
style = "rounded",
showTimestamp = false,
}: BadgeProps) {
const apiBase = process.env.NEXT_PUBLIC_API_BASE || 'http://localhost:3001';
const apiBase = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:3001";
const siteBase = process.env.NEXT_PUBLIC_SITE_BASE || window.location.origin;
const params = new URLSearchParams();
params.set('theme', theme);
params.set('size', String(size));
params.set('style', style);
if (showTimestamp) params.set('showTimestamp', 'true');
params.set("theme", theme);
params.set("size", String(size));
params.set("style", style);
if (showTimestamp) params.set("showTimestamp", "true");
const badgeUrl = `${apiBase}/api/badge/${hash}/svg?${params.toString()}`;
const verifyUrl = `${siteBase}/verify?hash=${hash}`;
return (
<a href={verifyUrl} target="_blank" rel="noopener noreferrer">
<img src={badgeUrl} alt="Verified on Internet ID" />
@@ -301,40 +316,35 @@ export function VerificationBadge({
```javascript
// Create and insert a badge dynamically
function createBadge(hash, options = {}) {
const {
theme = 'dark',
size = 'medium',
style = 'rounded',
container = document.body
} = options;
const apiBase = 'http://localhost:3001';
const { theme = "dark", size = "medium", style = "rounded", container = document.body } = options;
const apiBase = "http://localhost:3001";
const siteBase = window.location.origin;
const params = new URLSearchParams({ theme, size, style });
const badgeUrl = `${apiBase}/api/badge/${hash}/svg?${params.toString()}`;
const verifyUrl = `${siteBase}/verify?hash=${hash}`;
const link = document.createElement('a');
const link = document.createElement("a");
link.href = verifyUrl;
link.target = '_blank';
link.rel = 'noopener noreferrer';
const img = document.createElement('img');
link.target = "_blank";
link.rel = "noopener noreferrer";
const img = document.createElement("img");
img.src = badgeUrl;
img.alt = 'Verified on Internet ID';
img.alt = "Verified on Internet ID";
link.appendChild(img);
container.appendChild(link);
return link;
}
// Usage
createBadge('0x1234...', {
theme: 'blue',
size: 'large',
container: document.getElementById('badge-container')
createBadge("0x1234...", {
theme: "blue",
size: "large",
container: document.getElementById("badge-container"),
});
```
@@ -345,17 +355,20 @@ createBadge('0x1234...', {
The Badge API implements a multi-layer caching strategy for optimal performance:
### Server-Side Cache
- **Cache Backend:** Redis (when available)
- **Badge SVG Cache:** 10 minutes
- **Status Cache:** 5 minutes
- **Automatic Invalidation:** On content updates
### Client-Side Cache
- **Cache-Control Header:** `public, max-age=3600`
- **CDN Compatible:** Badges can be cached by CDNs
- **Versioning:** Change query params to bust cache
### Cache Keys
```
badge:svg:{hash}:{options} # SVG badge with specific options
badge:status:{hash} # Verification status
@@ -366,6 +379,7 @@ badge:status:{hash} # Verification status
## Rate Limiting
Badge endpoints use **moderate** rate limiting:
- Suitable for public badge display
- Designed for high-traffic scenarios
- No API key required for badge generation
@@ -375,27 +389,32 @@ Badge endpoints use **moderate** rate limiting:
## Best Practices
### 1. Use Appropriate Sizes
- **Small (180px):** Social media avatars, inline text
- **Medium (240px):** Blog posts, documentation (default)
- **Large (320px):** Hero sections, feature highlights
- **Custom:** Match your design requirements
### 2. Choose the Right Theme
- **Dark:** Best for dark backgrounds
- **Light:** Best for light backgrounds
- **Blue/Green/Purple:** Brand-specific themes
### 3. Cache Badges Locally
- Use CDN for high-traffic sites
- Cache badges on your server if needed
- Respect cache headers
### 4. Handle Unverified Content
- Check badge status before embedding
- Display appropriate messaging for unverified content
- Consider fallback badges
### 5. Accessibility
- Always include descriptive `alt` text
- Ensure sufficient color contrast
- Use semantic HTML (links to verification)
@@ -415,7 +434,10 @@ Badge endpoints use **moderate** rate limiting:
```html
<a href="https://internet-id.com/verify?hash=0x1234..." target="_blank">
<img src="https://api.internet-id.com/api/badge/0x1234.../svg?theme=blue" alt="View verification" />
<img
src="https://api.internet-id.com/api/badge/0x1234.../svg?theme=blue"
alt="View verification"
/>
</a>
```
@@ -423,9 +445,15 @@ Badge endpoints use **moderate** rate limiting:
```html
<picture>
<source media="(max-width: 768px)" srcset="https://api.internet-id.com/api/badge/0x1234.../svg?size=small">
<source media="(min-width: 769px)" srcset="https://api.internet-id.com/api/badge/0x1234.../svg?size=large">
<img src="https://api.internet-id.com/api/badge/0x1234.../svg" alt="Verified on Internet ID">
<source
media="(max-width: 768px)"
srcset="https://api.internet-id.com/api/badge/0x1234.../svg?size=small"
/>
<source
media="(min-width: 769px)"
srcset="https://api.internet-id.com/api/badge/0x1234.../svg?size=large"
/>
<img src="https://api.internet-id.com/api/badge/0x1234.../svg" alt="Verified on Internet ID" />
</picture>
```
@@ -434,17 +462,20 @@ Badge endpoints use **moderate** rate limiting:
## Troubleshooting
### Badge Not Displaying
1. Check that the content hash is valid
2. Verify the API base URL is correct
3. Ensure CORS is properly configured
4. Check browser console for errors
### Badge Shows "Unverified"
1. Verify content is actually registered on-chain
2. Check that the hash matches registered content
3. Allow time for cache refresh (5 minutes)
### Styling Issues
1. Use appropriate size for container
2. Check that SVG images are allowed by CSP
3. Verify theme matches your design
@@ -454,6 +485,7 @@ Badge endpoints use **moderate** rate limiting:
## Support
For additional support or questions:
- View the badge showcase: `/badges`
- Check the main documentation: `README.md`
- Open an issue on GitHub
@@ -473,6 +505,7 @@ For additional support or questions:
## Future Enhancements
Potential future additions:
- PNG badge generation (with server-side rendering)
- Animated badges for special content
- Custom badge templates

View File

@@ -7,41 +7,51 @@ This document shows visual examples of the verification badges generated by the
### Theme Examples
**Dark Theme (Default)**
```
✓ Verified · 0x12345678…abcdef
```
- Background: #0b0f1a (dark blue-black)
- Foreground: #9ef (light blue)
- Best for: Dark websites, GitHub dark mode
**Light Theme**
```
✓ Verified · 0x12345678…abcdef
```
- Background: #ffffff (white)
- Foreground: #0b0f1a (dark)
- Best for: Light websites, documentation
**Blue Theme**
```
✓ Verified · 0x12345678…abcdef
```
- Background: #1a237e (deep blue)
- Foreground: #e3f2fd (light blue)
- Best for: Professional/corporate sites
**Green Theme**
```
✓ Verified · 0x12345678…abcdef
```
- Background: #1b5e20 (dark green)
- Foreground: #e8f5e9 (light green)
- Best for: Environmental/eco themes
**Purple Theme**
```
✓ Verified · 0x12345678…abcdef
```
- Background: #4a148c (deep purple)
- Foreground: #f3e5f5 (light purple)
- Best for: Creative/artistic sites
@@ -49,18 +59,21 @@ This document shows visual examples of the verification badges generated by the
### Size Examples
**Small (180px)**
```
[━━━━━━━━━━━━━━━━━━━]
✓ Verified · 0x1234…
```
**Medium (240px) - Default**
```
[━━━━━━━━━━━━━━━━━━━━━━━━━━━━]
✓ Verified · 0x12345678…abcdef
```
**Large (320px)**
```
[━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━]
✓ Verified · 0x1234567890ab…abcdef
@@ -69,40 +82,49 @@ This document shows visual examples of the verification badges generated by the
### Style Examples
**Flat**
```
┌────────────────────────────┐
│ ✓ Verified · 0x1234…abcdef │
└────────────────────────────┘
```
No border radius - sharp corners
**Rounded (Default)**
```
╭────────────────────────────╮
│ ✓ Verified · 0x1234…abcdef │
╰────────────────────────────╯
```
6px border radius - soft corners
**Pill**
```
╭──────────────────────────────╮
│ ✓ Verified · 0x1234…abcdef │
╰──────────────────────────────╯
```
Full border radius - rounded ends
**Minimal**
```
┌──┐
│ ✓ │
└──┘
```
Only shows checkmark - compact
### Feature Examples
**With Timestamp**
```
╭────────────────────────────╮
│ ✓ Verified · 0x1234…abcdef │
@@ -111,6 +133,7 @@ Only shows checkmark - compact
```
**With Platform**
```
╭────────────────────────────────────╮
│ ✓ Verified · youtube · 0x1234…cdef │
@@ -118,6 +141,7 @@ Only shows checkmark - compact
```
**Unverified Content**
```
╭──────────────────────────────╮
│ ✗ Unverified · 0x5678…9abc │
@@ -151,6 +175,7 @@ GET /api/badge/0x1234.../embed?theme=blue&size=large
```
Returns:
```json
{
"html": "<a href=\"...\"><img src=\"...\" /></a>",
@@ -168,6 +193,7 @@ GET /api/badge/0x1234.../status
```
Returns:
```json
{
"contentHash": "0x1234...",
@@ -196,8 +222,10 @@ This project's content is verified on-chain.
```html
<div class="verification-badge">
<a href="https://internet-id.com/verify?hash=0x1234..." target="_blank">
<img src="https://api.internet-id.com/api/badge/0x1234.../svg?theme=light&size=medium"
alt="Verified on Internet ID" />
<img
src="https://api.internet-id.com/api/badge/0x1234.../svg?theme=light&size=medium"
alt="Verified on Internet ID"
/>
</a>
</div>
```
@@ -206,11 +234,13 @@ This project's content is verified on-chain.
```html
<p>
This content is verified
This content is verified
<a href="https://internet-id.com/verify?hash=0x1234...">
<img src="https://api.internet-id.com/api/badge/0x1234.../svg?style=minimal&size=small"
alt="Verified"
style="vertical-align: middle;" />
<img
src="https://api.internet-id.com/api/badge/0x1234.../svg?style=minimal&size=small"
alt="Verified"
style="vertical-align: middle;"
/>
</a>
</p>
```
@@ -224,6 +254,7 @@ All badge functionality is covered by comprehensive tests:
- **54 total tests passing**
Run tests:
```bash
npm test test/services/badge.test.ts
npm test test/integration/badge-endpoints.test.ts
@@ -238,6 +269,7 @@ npm test test/integration/badge-endpoints.test.ts
## Gallery
Visit `/badges` in the web UI for an interactive badge gallery with:
- Live preview of all badge variations
- Interactive customizer
- Copy-to-clipboard for embed codes

View File

@@ -239,12 +239,14 @@ npm run db:seed
```
This creates:
- **5 test creator accounts** with deterministic Ethereum addresses
- **5 sample content entries** (video, image, audio, document, tutorial)
- **10 platform bindings** (YouTube, TikTok, GitHub, Instagram, Discord, LinkedIn)
- **3 verification records** (mix of verified and failed)
**Benefits:**
- No need for manual API calls to create test data
- Deterministic data for consistent testing
- Ready-to-use platform bindings for verification testing

View File

@@ -160,6 +160,7 @@ Composite indexes optimize queries with multiple filters:
- Uses: `Account.[userId, provider]` composite index
6. **Lookup platform binding:**
```typescript
prisma.platformBinding.upsert({
where: { platform_platformId: { platform, platformId } },

View File

@@ -11,6 +11,7 @@ Internet ID API supports two authentication methods:
#### Option A: API Keys (Recommended for Server-Side Apps)
Best for:
- Backend services
- Server-to-server integrations
- Automated scripts and bots
@@ -18,6 +19,7 @@ Best for:
#### Option B: JWT Tokens (Recommended for User-Scoped Access)
Best for:
- User-facing applications
- Mobile apps
- Frontend applications needing user-specific access
@@ -31,11 +33,11 @@ You'll need a JWT token first to create API keys. Follow these steps:
1. **Sign a message with your wallet** (using MetaMask, WalletConnect, etc.):
```typescript
import { ethers } from 'ethers';
import { ethers } from "ethers";
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const message = 'Sign in to Internet ID API';
const message = "Sign in to Internet ID API";
const signature = await signer.signMessage(message);
const address = await signer.getAddress();
```
@@ -83,23 +85,23 @@ Or use direct HTTP requests with your preferred HTTP client.
#### Using the SDK:
```typescript
import { InternetIdClient } from '@internet-id/sdk';
import { InternetIdClient } from "@internet-id/sdk";
const client = new InternetIdClient({
apiKey: 'iid_your_api_key_here'
apiKey: "iid_your_api_key_here",
});
// Verify a YouTube video
const result = await client.verifyByPlatform({
url: 'https://youtube.com/watch?v=abc123'
url: "https://youtube.com/watch?v=abc123",
});
if (result.verified) {
console.log('✅ Content is verified!');
console.log('Creator:', result.creator);
console.log('Registered on:', new Date(result.timestamp * 1000));
console.log("✅ Content is verified!");
console.log("Creator:", result.creator);
console.log("Registered on:", new Date(result.timestamp * 1000));
} else {
console.log('❌ Content not verified');
console.log("❌ Content not verified");
}
```
@@ -115,33 +117,33 @@ curl -H "x-api-key: iid_your_api_key_here" \
### Verify Content on Your Platform
```typescript
import { InternetIdClient } from '@internet-id/sdk';
import { InternetIdClient } from "@internet-id/sdk";
const client = new InternetIdClient({ apiKey: process.env.INTERNET_ID_API_KEY });
async function checkContentAuthenticity(platformUrl: string) {
try {
const result = await client.verifyByPlatform({ url: platformUrl });
if (result.verified) {
return {
isVerified: true,
creator: result.creator,
registeredAt: new Date(result.timestamp * 1000),
blockchain: result.chainId,
manifest: result.manifest
manifest: result.manifest,
};
}
return { isVerified: false };
} catch (error) {
console.error('Verification failed:', error);
console.error("Verification failed:", error);
return { isVerified: false, error };
}
}
// Usage
const verification = await checkContentAuthenticity('https://youtube.com/watch?v=xyz');
const verification = await checkContentAuthenticity("https://youtube.com/watch?v=xyz");
console.log(verification);
```
@@ -150,17 +152,17 @@ console.log(verification);
```typescript
async function listUserContent(creatorAddress: string) {
const client = new InternetIdClient({ apiKey: process.env.INTERNET_ID_API_KEY });
const response = await client.listContent({
creator: creatorAddress,
limit: 50,
offset: 0
offset: 0,
});
return response.data.map(item => ({
return response.data.map((item) => ({
hash: item.contentHash,
platforms: item.bindings?.map(b => `${b.platform}/${b.platformId}`),
createdAt: new Date(item.createdAt)
platforms: item.bindings?.map((b) => `${b.platform}/${b.platformId}`),
createdAt: new Date(item.createdAt),
}));
}
```
@@ -170,35 +172,35 @@ async function listUserContent(creatorAddress: string) {
```typescript
async function getVerificationBadge(contentUrl: string) {
const client = new InternetIdClient({ apiKey: process.env.INTERNET_ID_API_KEY });
const result = await client.verifyByPlatform({ url: contentUrl });
if (result.verified) {
return {
type: 'verified',
text: '✓ Verified by Internet ID',
type: "verified",
text: "✓ Verified by Internet ID",
creator: result.creator,
badgeUrl: `https://api.internet-id.io/api/badge/${result.contentHash}?theme=light&w=200`
badgeUrl: `https://api.internet-id.io/api/badge/${result.contentHash}?theme=light&w=200`,
};
}
return { type: 'unverified', text: 'Not verified' };
return { type: "unverified", text: "Not verified" };
}
```
### Monitor Content Verification Status
```typescript
import { InternetIdClient } from '@internet-id/sdk';
import { InternetIdClient } from "@internet-id/sdk";
class VerificationMonitor {
private client: InternetIdClient;
private checkInterval: NodeJS.Timeout | null = null;
constructor(apiKey: string) {
this.client = new InternetIdClient({ apiKey });
}
startMonitoring(contentHashes: string[], onUpdate: (hash: string, verified: boolean) => void) {
this.checkInterval = setInterval(async () => {
for (const hash of contentHashes) {
@@ -211,7 +213,7 @@ class VerificationMonitor {
}
}, 60000); // Check every minute
}
stopMonitoring() {
if (this.checkInterval) {
clearInterval(this.checkInterval);
@@ -222,12 +224,9 @@ class VerificationMonitor {
// Usage
const monitor = new VerificationMonitor(process.env.INTERNET_ID_API_KEY!);
monitor.startMonitoring(
['0xabc...', '0xdef...'],
(hash, verified) => {
console.log(`${hash}: ${verified ? '✓' : '✗'}`);
}
);
monitor.startMonitoring(["0xabc...", "0xdef..."], (hash, verified) => {
console.log(`${hash}: ${verified ? "✓" : "✗"}`);
});
```
## Best Practices
@@ -256,7 +255,7 @@ async function verifyWithRetry(client: InternetIdClient, url: string, maxRetries
} catch (error: any) {
if (error.response?.status === 429 && attempt < maxRetries - 1) {
const delay = Math.pow(2, attempt) * 1000; // Exponential backoff
await new Promise(resolve => setTimeout(resolve, delay));
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
@@ -275,7 +274,7 @@ async function verifyWithRetry(client: InternetIdClient, url: string, maxRetries
// Example: Simple in-memory cache
class VerificationCache {
private cache = new Map<string, { result: any; expires: number }>();
get(key: string) {
const entry = this.cache.get(key);
if (!entry) return null;
@@ -285,11 +284,11 @@ class VerificationCache {
}
return entry.result;
}
set(key: string, result: any, ttlSeconds = 300) {
this.cache.set(key, {
result,
expires: Date.now() + ttlSeconds * 1000
expires: Date.now() + ttlSeconds * 1000,
});
}
}
@@ -308,13 +307,13 @@ async function safeVerify(client: InternetIdClient, url: string) {
return { success: true, data: result };
} catch (error: any) {
if (error.response?.status === 404) {
return { success: true, data: { verified: false, reason: 'not_registered' } };
return { success: true, data: { verified: false, reason: "not_registered" } };
}
if (error.response?.status === 429) {
return { success: false, reason: 'rate_limit_exceeded' };
return { success: false, reason: "rate_limit_exceeded" };
}
console.error('Verification error:', error);
return { success: false, reason: 'unknown_error' };
console.error("Verification error:", error);
return { success: false, reason: "unknown_error" };
}
}
```
@@ -327,14 +326,15 @@ For testing, point to the development server:
```typescript
const client = new InternetIdClient({
baseURL: 'http://localhost:3001/api/v1',
apiKey: 'iid_test_key'
baseURL: "http://localhost:3001/api/v1",
apiKey: "iid_test_key",
});
```
### Test Data
Use test content hashes and platform IDs for development:
- Test hash: `0x0000000000000000000000000000000000000000000000000000000000000001`
- Test platform: `youtube`
- Test platform ID: `test_video_123`
@@ -364,6 +364,7 @@ Explore the API interactively using Swagger UI:
## Feedback
We'd love to hear about your experience! Please:
- Open an issue for bugs or feature requests
- Share your integration on our community forum _(coming soon)_
- Contribute to the SDK or documentation

View File

@@ -9,6 +9,7 @@ This document details the gas optimizations implemented in the ContentRegistry s
### 1. Storage Layout Optimization (Struct Packing)
**Before:**
```solidity
struct Entry {
address creator; // 20 bytes - Slot 0
@@ -19,6 +20,7 @@ struct Entry {
```
**After:**
```solidity
struct Entry {
address creator; // 20 bytes - Slot 0 (bytes 0-19)
@@ -32,6 +34,7 @@ struct Entry {
### 2. Removed Redundant Storage
**Before:**
```solidity
struct Entry {
bytes32 contentHash; // Stored redundantly
@@ -41,6 +44,7 @@ mapping(bytes32 => Entry) public entries; // contentHash is already the key
```
**After:**
```solidity
struct Entry {
// contentHash removed - it's the mapping key
@@ -53,6 +57,7 @@ struct Entry {
### 3. Cache Timestamp Calculation
**Before:**
```solidity
entries[contentHash] = Entry({
timestamp: uint64(block.timestamp) // First cast
@@ -61,6 +66,7 @@ emit ContentRegistered(..., uint64(block.timestamp)); // Second cast
```
**After:**
```solidity
uint64 currentTime = uint64(block.timestamp);
entries[contentHash] = Entry({
@@ -74,11 +80,13 @@ emit ContentRegistered(..., currentTime);
### 4. Use Calldata for Internal Functions (Solidity 0.8.8+)
**Before:**
```solidity
function _platformKey(string memory platform, string memory platformId) internal pure
```
**After:**
```solidity
function _platformKey(string calldata platform, string calldata platformId) internal pure
```
@@ -91,9 +99,9 @@ function _platformKey(string calldata platform, string calldata platformId) inte
### Deployment Costs
| Metric | Cost (gas) |
|--------|------------|
| Contract Deployment | 825,317 |
| Metric | Cost (gas) |
| ------------------- | ---------- |
| Contract Deployment | 825,317 |
### Function Costs
@@ -101,35 +109,35 @@ All costs measured with optimizer enabled (200 runs).
#### register(bytes32 contentHash, string calldata manifestURI)
| URI Length | Gas Cost |
|------------|----------|
| Short URI (~10 chars) | ~50,368 |
| Medium URI (~30 chars) | ~71,650 |
| Long URI (~60 chars) | ~115,935 |
| URI Length | Gas Cost |
| ---------------------- | -------- |
| Short URI (~10 chars) | ~50,368 |
| Medium URI (~30 chars) | ~71,650 |
| Long URI (~60 chars) | ~115,935 |
**Note:** Gas cost increases linearly with URI length due to string storage.
#### bindPlatform(bytes32 contentHash, string calldata platform, string calldata platformId)
| Scenario | Gas Cost |
|----------|----------|
| First binding | ~78,228 |
| Subsequent bindings | ~95,640 |
| Scenario | Gas Cost |
| ------------------- | -------- |
| First binding | ~78,228 |
| Subsequent bindings | ~95,640 |
**Note:** First binding costs less because array initialization is cheaper.
#### updateManifest(bytes32 contentHash, string calldata newManifestURI)
| URI Length | Gas Cost |
|------------|----------|
| Similar length URI | ~33,227 |
| Different length URI | ~33,263 |
| URI Length | Gas Cost |
| -------------------- | -------- |
| Similar length URI | ~33,227 |
| Different length URI | ~33,263 |
#### revoke(bytes32 contentHash)
| Metric | Gas Cost |
|--------|----------|
| Revoke (clear manifest) | ~26,407 |
| Metric | Gas Cost |
| ----------------------- | -------- |
| Revoke (clear manifest) | ~26,407 |
#### resolveByPlatform(string calldata platform, string calldata platformId) (view function)
@@ -139,13 +147,13 @@ This is a view function and does not consume gas when called externally. Interna
Comparison of gas costs before and after optimization:
| Function | Before | After | Savings | % Improvement |
|----------|--------|-------|---------|---------------|
| Deployment | 855,191 | 825,317 | 29,874 | 3.5% |
| register (avg) | 115,317 | 71,650 | 43,667 | **37.9%** |
| bindPlatform (avg) | 95,219 | 92,690 | 2,529 | 2.7% |
| updateManifest | 35,234 | 33,245 | 1,989 | 5.6% |
| revoke | 28,396 | 26,407 | 1,989 | 7.0% |
| Function | Before | After | Savings | % Improvement |
| ------------------ | ------- | ------- | ------- | ------------- |
| Deployment | 855,191 | 825,317 | 29,874 | 3.5% |
| register (avg) | 115,317 | 71,650 | 43,667 | **37.9%** |
| bindPlatform (avg) | 95,219 | 92,690 | 2,529 | 2.7% |
| updateManifest | 35,234 | 33,245 | 1,989 | 5.6% |
| revoke | 28,396 | 26,407 | 1,989 | 7.0% |
**Total average savings: ~37.9% on register (most commonly used function)**
@@ -166,6 +174,7 @@ Assuming average gas price of 30 gwei and ETH price of $2000:
### Savings at Scale
For 10,000 registrations:
- Before: 10,000 × $6.92 = **$69,200**
- After: 10,000 × $4.30 = **$43,000**
- **Total savings: $26,200** (37.9%)
@@ -175,6 +184,7 @@ For 10,000 registrations:
Gas regression tests are implemented in `test/ContentRegistry.gas.ts` to ensure optimizations are maintained over time.
Run gas regression tests:
```bash
npm test -- test/ContentRegistry.gas.ts
```

View File

@@ -6,43 +6,45 @@ Internet-ID supports deployment across multiple EVM-compatible blockchain networ
### Production Networks (Mainnets)
| Network | Chain ID | Gas Token | Use Case | Cost |
|---------|----------|-----------|----------|------|
| Ethereum Mainnet | 1 | ETH | Maximum security | High |
| Polygon | 137 | MATIC | Low cost, high throughput | Low |
| Base | 8453 | ETH | Coinbase ecosystem, low cost | Low |
| Arbitrum One | 42161 | ETH | Low cost L2 | Low |
| Optimism | 10 | ETH | Low cost L2 | Low |
| Network | Chain ID | Gas Token | Use Case | Cost |
| ---------------- | -------- | --------- | ---------------------------- | ---- |
| Ethereum Mainnet | 1 | ETH | Maximum security | High |
| Polygon | 137 | MATIC | Low cost, high throughput | Low |
| Base | 8453 | ETH | Coinbase ecosystem, low cost | Low |
| Arbitrum One | 42161 | ETH | Low cost L2 | Low |
| Optimism | 10 | ETH | Low cost L2 | Low |
### Test Networks (Testnets)
| Network | Chain ID | Faucet | Explorer |
|---------|----------|--------|----------|
| Ethereum Sepolia | 11155111 | [Sepolia Faucet](https://sepoliafaucet.com/) | [Sepolia Etherscan](https://sepolia.etherscan.io) |
| Polygon Amoy | 80002 | [Amoy Faucet](https://faucet.polygon.technology/) | [Amoy PolygonScan](https://amoy.polygonscan.com) |
| Base Sepolia | 84532 | [Base Faucet](https://www.coinbase.com/faucets/base-ethereum-sepolia-faucet) | [Base Sepolia Scan](https://sepolia.basescan.org) |
| Arbitrum Sepolia | 421614 | [Arbitrum Faucet](https://faucet.quicknode.com/arbitrum/sepolia) | [Arbiscan Sepolia](https://sepolia.arbiscan.io) |
| Optimism Sepolia | 11155420 | [Optimism Faucet](https://app.optimism.io/faucet) | [Optimism Sepolia Scan](https://sepolia-optimism.etherscan.io) |
| Network | Chain ID | Faucet | Explorer |
| ---------------- | -------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------- |
| Ethereum Sepolia | 11155111 | [Sepolia Faucet](https://sepoliafaucet.com/) | [Sepolia Etherscan](https://sepolia.etherscan.io) |
| Polygon Amoy | 80002 | [Amoy Faucet](https://faucet.polygon.technology/) | [Amoy PolygonScan](https://amoy.polygonscan.com) |
| Base Sepolia | 84532 | [Base Faucet](https://www.coinbase.com/faucets/base-ethereum-sepolia-faucet) | [Base Sepolia Scan](https://sepolia.basescan.org) |
| Arbitrum Sepolia | 421614 | [Arbitrum Faucet](https://faucet.quicknode.com/arbitrum/sepolia) | [Arbiscan Sepolia](https://sepolia.arbiscan.io) |
| Optimism Sepolia | 11155420 | [Optimism Faucet](https://app.optimism.io/faucet) | [Optimism Sepolia Scan](https://sepolia-optimism.etherscan.io) |
## Deployment Steps
### Prerequisites
1. **Install Dependencies**
```bash
npm install --legacy-peer-deps
```
2. **Configure Environment Variables**
Create a `.env` file (copy from `.env.example`):
```bash
# Your deployer private key
PRIVATE_KEY=your_private_key_here
# Default RPC URL (used by scripts)
RPC_URL=https://sepolia.base.org
# Optional: Override RPC URLs for specific chains
ETHEREUM_RPC_URL=https://your-eth-rpc.com
POLYGON_RPC_URL=https://your-polygon-rpc.com
@@ -72,6 +74,7 @@ npm run deploy:optimism-sepolia # Optimism Sepolia
```
**Output:**
```
ContentRegistry deployed to: 0x1234567890123456789012345678901234567890
Saved address to: /path/to/deployed/baseSepolia.json
@@ -80,6 +83,7 @@ Saved address to: /path/to/deployed/baseSepolia.json
### Deploy to Mainnet
⚠️ **Warning**: Mainnet deployments cost real money. Ensure you have:
- Sufficient gas tokens (ETH, MATIC, etc.)
- Verified your deployment works on testnet
- Reviewed gas costs for your target chain
@@ -114,6 +118,7 @@ deployed/
```
Each file contains:
```json
{
"address": "0x1234567890123456789012345678901234567890"
@@ -127,11 +132,11 @@ Each file contains:
The registry service automatically resolves contract addresses based on chain ID:
```typescript
import {
import {
resolveDefaultRegistry,
getRegistryAddress,
getAllRegistryAddresses,
getProviderForChain
getProviderForChain,
} from "./scripts/services/registry.service";
// Get registry for current network
@@ -158,6 +163,7 @@ curl "http://localhost:3001/api/resolve/cross-chain?platform=youtube&platformId=
```
Response includes chain information:
```json
{
"platform": "youtube",
@@ -193,6 +199,7 @@ const txUrl = getExplorerTxUrl(137, "0x1234...");
### Choosing a Chain
**For Development:**
- Start with **Base Sepolia** or **Polygon Amoy** (free testnet tokens)
- Test cross-chain features on multiple testnets
@@ -234,6 +241,7 @@ const txUrl = getExplorerTxUrl(137, "0x1234...");
### Deployment Fails
**Error: Insufficient funds**
```
Solution: Ensure wallet has gas tokens for the target chain
- Check balance at block explorer
@@ -242,6 +250,7 @@ Solution: Ensure wallet has gas tokens for the target chain
```
**Error: Network not configured**
```
Solution: Check hardhat.config.ts includes the network
- Verify chain is in SUPPORTED_CHAINS (config/chains.ts)
@@ -252,6 +261,7 @@ Solution: Check hardhat.config.ts includes the network
### Resolution Issues
**Error: Registry address not configured**
```
Solution: Deploy contract to the target chain first
- Run appropriate deploy:* script
@@ -260,6 +270,7 @@ Solution: Deploy contract to the target chain first
```
**Cross-chain resolution returns 404**
```
Solution: Platform binding doesn't exist on any chain
- Verify content was registered on-chain
@@ -290,6 +301,7 @@ ARBITRUM_RPC_URL=https://rpc.ankr.com/arbitrum
To add support for a new EVM chain:
1. Add chain configuration to `config/chains.ts`:
```typescript
mychain: {
chainId: 99999, // Use the actual chain ID from chainlist.org
@@ -303,6 +315,7 @@ To add support for a new EVM chain:
```
2. Add network to `hardhat.config.ts`:
```typescript
mychain: {
url: SUPPORTED_CHAINS.mychain.rpcUrl,
@@ -312,6 +325,7 @@ To add support for a new EVM chain:
```
3. Add deployment script to `package.json`:
```json
"deploy:mychain": "hardhat run --network mychain scripts/deploy.ts"
```
@@ -324,6 +338,7 @@ To add support for a new EVM chain:
## Support
For issues or questions:
- Open an issue on [GitHub](https://github.com/subculture-collective/internet-id/issues)
- Check existing [documentation](../README.md)
- Review [security policy](../SECURITY_POLICY.md)

View File

@@ -20,11 +20,13 @@ The Internet-ID system supports verification for the following platforms:
**Verification Approach**: Video ID or channel verification via URL parsing
**URL Formats**:
- Standard watch URL: `https://www.youtube.com/watch?v=VIDEO_ID`
- Short URL: `https://youtu.be/VIDEO_ID`
- Shorts URL: `https://www.youtube.com/shorts/VIDEO_ID`
**Usage**:
```bash
# Bind YouTube video to content
npm run bind:youtube -- <masterFilePath> <youtubeVideoId> <registryAddress>
@@ -34,6 +36,7 @@ npm run verify:youtube -- <youtubeUrlOrId> <registryAddress>
```
**Limitations**:
- Requires valid YouTube video ID
- Video must be publicly accessible for verification
- Re-encoded versions on YouTube can be bound to original content
@@ -45,14 +48,17 @@ npm run verify:youtube -- <youtubeUrlOrId> <registryAddress>
**Verification Approach**: Video ID or user profile verification via URL parsing
**URL Formats**:
- Video with username: `https://www.tiktok.com/@username/video/1234567890`
- Video without username: `https://www.tiktok.com/video/1234567890`
- Mobile URL: `https://m.tiktok.com/@user/video/9876543210`
**Extracted ID Format**:
**Extracted ID Format**:
- `@username/video/1234567890` or `video/1234567890`
**Usage**:
```bash
# Bind TikTok video to content
npm run bind:tiktok -- <masterFilePath> <tiktokId> <registryAddress>
@@ -62,6 +68,7 @@ npm run verify:tiktok -- <tiktokUrlOrId> <registryAddress>
```
**Limitations**:
- Requires publicly accessible TikTok content
- Platform ID should include username path when available
- TikTok's content algorithm may show re-encoded versions
@@ -73,16 +80,19 @@ npm run verify:tiktok -- <tiktokUrlOrId> <registryAddress>
**Verification Approach**: Post shortcode or profile bio verification via URL parsing
**URL Formats**:
- Post URL: `https://www.instagram.com/p/SHORTCODE/`
- Reel URL: `https://www.instagram.com/reel/SHORTCODE/`
- Profile URL: `https://www.instagram.com/username/`
**Extracted ID Format**:
**Extracted ID Format**:
- `p/SHORTCODE` for posts
- `reel/SHORTCODE` for reels
- `username` for profiles
**Usage**:
```bash
# Bind Instagram post to content
npm run bind:instagram -- <masterFilePath> <instagramId> <registryAddress>
@@ -92,6 +102,7 @@ npm run verify:instagram -- <instagramUrlOrId> <registryAddress>
```
**Limitations**:
- Requires publicly accessible Instagram content
- Private profiles cannot be verified by third parties
- Instagram may compress or modify uploaded content
@@ -103,16 +114,19 @@ npm run verify:instagram -- <instagramUrlOrId> <registryAddress>
**Verification Approach**: Repository file or gist verification
**URL Formats**:
- Repository: `https://github.com/user/repo`
- File in repo: `https://github.com/user/repo/blob/main/file.txt`
- Gist: `https://gist.github.com/user/gistid`
**Extracted ID Format**:
**Extracted ID Format**:
- `user/repo` for repositories
- `user/repo/blob/main/file.txt` for files
- `gist/user/gistid` for gists
**Usage**:
```bash
# Bind GitHub repository to content
npm run bind:github -- <masterFilePath> <githubId> <registryAddress>
@@ -122,11 +136,13 @@ npm run verify:github -- <githubUrlOrId> <registryAddress>
```
**Verification Methods**:
1. **Repository README**: Add content hash to README.md
2. **Gist**: Create a gist containing the content hash
3. **File in repo**: Add verification file with content hash
**Limitations**:
- Repository/gist must be publicly accessible
- Verification requires on-chain binding before creating verification proof
- Private repositories cannot be verified by third parties
@@ -138,16 +154,19 @@ npm run verify:github -- <githubUrlOrId> <registryAddress>
**Verification Approach**: Server invite or custom status verification
**URL Formats**:
- Invite URL (discord.gg): `https://discord.gg/INVITE_CODE`
- Invite URL (discord.com): `https://discord.com/invite/INVITE_CODE`
- Channel URL: `https://discord.com/channels/SERVER_ID/CHANNEL_ID`
**Extracted ID Format**:
**Extracted ID Format**:
- `INVITE_CODE` for discord.gg invites
- `invite/INVITE_CODE` for discord.com invites
- `channels/SERVER_ID/CHANNEL_ID` for channel links
**Usage**:
```bash
# Bind Discord server to content
npm run bind:discord -- <masterFilePath> <discordId> <registryAddress>
@@ -157,11 +176,13 @@ npm run verify:discord -- <discordUrlOrId> <registryAddress>
```
**Verification Methods**:
1. **Server Description**: Add content hash to server description
2. **Custom Status**: Set custom status with content hash
3. **Channel Topic**: Add verification hash to channel topic
**Limitations**:
- Server must be publicly accessible or user must be a member
- Invite codes may expire
- Verification requires appropriate server permissions
@@ -173,16 +194,19 @@ npm run verify:discord -- <discordUrlOrId> <registryAddress>
**Verification Approach**: Profile summary or post verification
**URL Formats**:
- Profile: `https://www.linkedin.com/in/username/`
- Post: `https://www.linkedin.com/posts/activity-ID/`
- Company: `https://www.linkedin.com/company/companyname/`
**Extracted ID Format**:
**Extracted ID Format**:
- `in/username` for profiles
- `posts/activity-ID` for posts
- `company/companyname` for companies
**Usage**:
```bash
# Bind LinkedIn profile to content
npm run bind:linkedin -- <masterFilePath> <linkedinId> <registryAddress>
@@ -192,11 +216,13 @@ npm run verify:linkedin -- <linkedinUrlOrId> <registryAddress>
```
**Verification Methods**:
1. **Profile Summary**: Add content hash to profile summary
2. **Post Content**: Create a post with the content hash
3. **Company Description**: Add verification to company page
**Limitations**:
- Profile must be publicly visible
- LinkedIn's privacy settings may restrict visibility
- Posts may have limited lifetime or visibility
@@ -208,16 +234,19 @@ npm run verify:linkedin -- <linkedinUrlOrId> <registryAddress>
For all platforms, the verification process follows these steps:
1. **Register Content**: Register your original content hash on-chain
```bash
npm run register -- <filePath> <manifestURI> <registryAddress>
```
2. **Bind Platform ID**: Bind your platform-specific ID to the content hash
```bash
npm run bind:<platform> -- <masterFilePath> <platformId> <registryAddress>
```
3. **Verify Binding**: Verify the platform binding is correct
```bash
npm run verify:<platform> -- <platformUrlOrId> <registryAddress>
```
@@ -227,6 +256,7 @@ For all platforms, the verification process follows these steps:
## Web UI Integration
All platforms are integrated into the web UI with:
- Platform selection dropdown in One-shot, Bind forms
- Automatic URL parsing for all supported platforms
- Platform-specific verification badges and links
@@ -235,6 +265,7 @@ All platforms are integrated into the web UI with:
## API Integration
The API supports platform verification through:
- `/api/resolve?platform=<platform>&platformId=<id>` - Resolve platform binding
- `/api/public-verify?platform=<platform>&platformId=<id>` - Public verification
@@ -265,6 +296,7 @@ The API supports platform verification through:
### Support
For additional help:
- Check the [GitHub Issues](https://github.com/subculture-collective/internet-id/issues)
- Review test files for examples: `test/verify-*.test.ts`
- See the main [README.md](../README.md) for general setup
@@ -272,6 +304,7 @@ For additional help:
## Future Enhancements
Planned features for platform verification:
- Automated verification proof generation
- Platform-specific metadata extraction
- Multi-chain support for bindings

View File

@@ -36,11 +36,12 @@ curl -H "Authorization: Bearer your_jwt_token_here" \
Rate limits vary by tier:
| Tier | Requests per Minute |
|------|---------------------|
| ---- | ------------------- |
| Free | 100 |
| Paid | 1000 |
Rate limit headers are included in responses:
- `X-RateLimit-Limit`: Maximum requests per window
- `X-RateLimit-Remaining`: Remaining requests in current window
- `X-RateLimit-Reset`: Time when the rate limit resets (Unix timestamp)
@@ -50,6 +51,7 @@ Rate limit headers are included in responses:
The API uses URL path versioning. The current version is `v1`, accessible at `/api/v1/`.
We follow semantic versioning principles:
- **Minor updates** (new features, backward-compatible): No version change required
- **Major updates** (breaking changes): New version path (e.g., `/api/v2/`)
@@ -58,6 +60,7 @@ We follow semantic versioning principles:
### Interactive Documentation
Visit `/api/docs` for interactive Swagger UI documentation:
- **Development**: http://localhost:3001/api/docs
- **OpenAPI JSON**: http://localhost:3001/api/docs.json
@@ -72,6 +75,7 @@ GET /api/v1/verify/platform
```
**Query Parameters:**
- `url` (string, optional): Full platform URL (e.g., `https://youtube.com/watch?v=xyz`)
- `platform` (string, optional): Platform name (`youtube`, `tiktok`, `instagram`, etc.)
- `platformId` (string, optional): Platform-specific content ID
@@ -115,6 +119,7 @@ GET /api/v1/verify/hash/:hash
```
**Path Parameters:**
- `hash` (string, required): Content hash (32-byte hex string with 0x prefix)
**Example Request:**
@@ -134,6 +139,7 @@ GET /api/v1/content
```
**Query Parameters:**
- `limit` (number, optional): Items per page (max 100, default 20)
- `offset` (number, optional): Pagination offset (default 0)
- `creator` (string, optional): Filter by creator address
@@ -317,15 +323,15 @@ npm install @internet-id/sdk
```
```typescript
import { InternetIdClient } from '@internet-id/sdk';
import { InternetIdClient } from "@internet-id/sdk";
const client = new InternetIdClient({
apiKey: 'iid_your_api_key_here'
apiKey: "iid_your_api_key_here",
});
// Verify content
const result = await client.verifyByPlatform({
url: 'https://youtube.com/watch?v=abc123'
url: "https://youtube.com/watch?v=abc123",
});
console.log(result.verified); // true or false
@@ -375,6 +381,7 @@ When we need to introduce breaking changes, we will:
4. **Support** both old and new versions during the transition period
Deprecated endpoints will include warnings in response headers:
```
Deprecation: true
Sunset: Sat, 31 Oct 2025 23:59:59 GMT

View File

@@ -92,19 +92,19 @@ Add custom event tracking for key actions:
```typescript
// Track verification completion
gtag('event', 'verification_complete', {
gtag("event", "verification_complete", {
content_hash: contentHash,
platform: platform,
});
// Track content registration
gtag('event', 'content_registered', {
gtag("event", "content_registered", {
registry_address: registryAddress,
transaction_hash: txHash,
});
// Track badge downloads
gtag('event', 'badge_download', {
gtag("event", "badge_download", {
content_hash: contentHash,
badge_theme: theme,
});
@@ -125,18 +125,21 @@ gtag('event', 'badge_download', {
Choose one of these verification methods:
#### Method A: HTML File Upload
1. Download the verification HTML file
2. Upload it to `web/public/` directory
3. Deploy your site
4. Click "Verify" in Search Console
#### Method B: DNS Verification (Recommended)
1. Copy the TXT record provided
2. Add it to your DNS settings
3. Wait for DNS propagation (up to 24 hours)
4. Click "Verify" in Search Console
#### Method C: HTML Meta Tag
Add the verification meta tag to your `.env.local`:
```bash
@@ -178,15 +181,18 @@ verification: {
Choose one of these methods:
#### Method A: XML File Upload
1. Download the BingSiteAuth.xml file
2. Upload to `web/public/`
3. Deploy and verify
#### Method B: DNS Verification
1. Add the provided CNAME record to your DNS
2. Click "Verify"
#### Method C: Import from Google Search Console (Easiest)
1. Click "Import from Google Search Console"
2. Authorize the connection
3. Your site will be automatically verified

View File

@@ -30,16 +30,16 @@ This document outlines the content strategy for improving SEO and organic traffi
### Primary Keywords (High Priority)
| Keyword | Monthly Volume | Difficulty | Priority |
|---------|----------------|------------|----------|
| content verification | 2,400 | Medium | High |
| blockchain content authentication | 720 | Low | High |
| verify digital content | 1,300 | Medium | High |
| content authenticity verification | 480 | Low | High |
| proof of content ownership | 390 | Low | High |
| on-chain content verification | 260 | Low | Medium |
| IPFS content storage | 1,900 | Medium | Medium |
| deepfake detection | 12,100 | High | Low |
| Keyword | Monthly Volume | Difficulty | Priority |
| --------------------------------- | -------------- | ---------- | -------- |
| content verification | 2,400 | Medium | High |
| blockchain content authentication | 720 | Low | High |
| verify digital content | 1,300 | Medium | High |
| content authenticity verification | 480 | Low | High |
| proof of content ownership | 390 | Low | High |
| on-chain content verification | 260 | Low | Medium |
| IPFS content storage | 1,900 | Medium | Medium |
| deepfake detection | 12,100 | High | Low |
### Long-Tail Keywords
@@ -64,6 +64,7 @@ This document outlines the content strategy for improving SEO and organic traffi
### 1. Educational Content (How-To Guides)
**Topics:**
- How to Verify Your Content on the Blockchain
- Complete Guide to Content Authentication
- Protecting Your Original Work: A Creator's Guide
@@ -71,12 +72,14 @@ This document outlines the content strategy for improving SEO and organic traffi
- Blockchain for Content Creators: Getting Started
**Format:**
- Step-by-step tutorials with screenshots
- Video walkthroughs (embed on site)
- Downloadable PDF guides
- Interactive demos
**Target Pages:**
- `/guides/getting-started`
- `/guides/content-verification`
- `/guides/blockchain-basics`
@@ -84,18 +87,21 @@ This document outlines the content strategy for improving SEO and organic traffi
### 2. Use Cases & Success Stories
**Topics:**
- How Creators Use Internet-ID to Protect Their Work
- Case Study: Fighting Deepfakes with Blockchain
- Real-World Examples of Content Theft Prevention
- Success Stories from Verified Creators
**Format:**
- Case study articles (800-1200 words)
- Creator interviews
- Before/after comparisons
- Statistical results and ROI
**Target Pages:**
- `/case-studies`
- `/use-cases`
- `/success-stories`
@@ -103,6 +109,7 @@ This document outlines the content strategy for improving SEO and organic traffi
### 3. Technical Documentation
**Topics:**
- API Documentation for Developers
- Integrating Internet-ID into Your Platform
- Smart Contract Architecture
@@ -110,12 +117,14 @@ This document outlines the content strategy for improving SEO and organic traffi
- Understanding Cryptographic Hashing
**Format:**
- Technical guides with code examples
- API reference documentation
- Architecture diagrams
- Integration tutorials
**Target Pages:**
- `/docs/api`
- `/docs/integration`
- `/docs/architecture`
@@ -123,6 +132,7 @@ This document outlines the content strategy for improving SEO and organic traffi
### 4. Industry Insights & Thought Leadership
**Topics:**
- The Future of Content Authenticity
- Blockchain's Role in Fighting Misinformation
- The Economics of Digital Trust
@@ -130,12 +140,14 @@ This document outlines the content strategy for improving SEO and organic traffi
- Comparing Content Verification Solutions
**Format:**
- Blog posts (1000-1500 words)
- Infographics
- Expert interviews
- Market analysis reports
**Target Pages:**
- `/blog`
- `/insights`
- `/resources`
@@ -145,12 +157,14 @@ This document outlines the content strategy for improving SEO and organic traffi
### Month 1-2: Foundation
**Week 1-2:**
- [ ] Create homepage with optimized meta tags
- [ ] Write "Getting Started" guide
- [ ] Create FAQ page with structured data
- [ ] Set up blog infrastructure
**Week 3-4:**
- [ ] Publish "How to Verify Content" tutorial
- [ ] Create "Use Cases" landing page
- [ ] Write API documentation
@@ -159,21 +173,25 @@ This document outlines the content strategy for improving SEO and organic traffi
### Month 3-4: Content Expansion
**Week 1:**
- [ ] Publish case study #1: Content creator
- [ ] Create comparison guide (vs competitors)
- [ ] Write "Blockchain Basics for Creators"
**Week 2:**
- [ ] Publish technical guide: Smart contracts
- [ ] Create infographic: Content verification flow
- [ ] Write blog post: Future of content authenticity
**Week 3:**
- [ ] Publish case study #2: Media organization
- [ ] Create video tutorial series
- [ ] Write guide: IPFS for beginners
**Week 4:**
- [ ] Publish industry insights article
- [ ] Create creator success stories page
- [ ] Write comparison: Blockchain vs traditional methods
@@ -181,6 +199,7 @@ This document outlines the content strategy for improving SEO and organic traffi
### Month 5-6: Optimization & Scaling
**Ongoing:**
- [ ] Update existing content based on analytics
- [ ] Create new content based on search queries
- [ ] Guest posts on relevant industry blogs
@@ -241,12 +260,15 @@ This document outlines the content strategy for improving SEO and organic traffi
## Main Content
### Section 1: [H2 with keyword]
[Content with internal links]
### Section 2: [H2 with semantic keyword]
[Content with examples]
### Section 3: [H2 with long-tail keyword]
[Content with visuals]
## Key Takeaways
@@ -273,6 +295,7 @@ This document outlines the content strategy for improving SEO and organic traffi
For every page:
### Meta Tags
- [ ] Unique title (50-60 characters)
- [ ] Compelling meta description (150-160 characters)
- [ ] Open Graph tags (title, description, image)
@@ -280,6 +303,7 @@ For every page:
- [ ] Canonical URL
### Content Optimization
- [ ] H1 tag with primary keyword (only one per page)
- [ ] H2-H6 tags for structure
- [ ] Primary keyword in first 100 words
@@ -290,6 +314,7 @@ For every page:
- [ ] Image file names with keywords
### Technical SEO
- [ ] Mobile-responsive design
- [ ] Fast page load (<3 seconds)
- [ ] HTTPS enabled
@@ -299,6 +324,7 @@ For every page:
- [ ] Robots.txt allows crawling
### User Experience
- [ ] Clear call-to-action
- [ ] Easy navigation
- [ ] Readable font size (16px+)
@@ -352,6 +378,7 @@ For every page:
### Conversion Funnels
**Funnel 1: New Creator**
1. Land on homepage → Learn about service
2. Read getting started guide
3. Create account
@@ -360,6 +387,7 @@ For every page:
6. Share verification badge
**Funnel 2: Content Verifier**
1. Land on verify page
2. Input content URL
3. View verification result
@@ -432,6 +460,7 @@ For every page:
### Content Gap Analysis
Identify topics competitors rank for that we don't:
- [Topic 1]
- [Topic 2]
- [Topic 3]
@@ -439,6 +468,7 @@ Identify topics competitors rank for that we don't:
### Differentiation Strategy
How we stand out:
- On-chain verification (not just digital signatures)
- IPFS-based storage
- Platform-agnostic approach
@@ -448,6 +478,7 @@ How we stand out:
## Resources & Tools
### SEO Tools
- Google Search Console
- Google Analytics 4
- Ahrefs or SEMrush
@@ -455,6 +486,7 @@ How we stand out:
- Google PageSpeed Insights
### Content Tools
- Grammarly for editing
- Hemingway for readability
- Canva for graphics
@@ -462,6 +494,7 @@ How we stand out:
- BuzzSumo for content ideas
### Keyword Research
- Google Keyword Planner
- Ahrefs Keywords Explorer
- Answer The Public
@@ -471,24 +504,28 @@ How we stand out:
## Next Steps
### Immediate (Week 1)
1. Set up Google Analytics and Search Console
2. Create content calendar in project management tool
3. Draft first 3 pieces of content
4. Implement on-page SEO checklist
### Short-term (Month 1)
1. Publish 8-10 pieces of content
2. Build internal linking structure
3. Submit sitemap to search engines
4. Begin outreach for backlinks
### Medium-term (Months 2-3)
1. Expand content library to 30+ pages
2. Launch link building campaign
3. Optimize based on search data
4. Start guest posting
### Long-term (Months 4-6)
1. Scale content production
2. Build domain authority
3. Rank for target keywords

View File

@@ -22,79 +22,79 @@ Add this script to your HTML to dynamically insert verification badges:
```html
<!DOCTYPE html>
<html>
<head>
<title>My Page</title>
</head>
<body>
<div id="badge-container"></div>
<script>
// Internet ID Badge Widget
(function() {
const InternetIDBadge = {
create: function(options) {
const {
hash,
theme = 'dark',
size = 'medium',
style = 'rounded',
showTimestamp = false,
showPlatform = false,
platform = null,
container = document.body,
clickable = true,
apiBase = 'http://localhost:3001',
siteBase = window.location.origin
} = options;
// Build badge URL
const params = new URLSearchParams();
params.set('theme', theme);
params.set('size', size);
params.set('style', style);
if (showTimestamp) params.set('showTimestamp', 'true');
if (showPlatform) params.set('showPlatform', 'true');
if (platform) params.set('platform', platform);
const badgeUrl = `${apiBase}/api/badge/${hash}/svg?${params.toString()}`;
const verifyUrl = `${siteBase}/verify?hash=${hash}`;
// Create elements
const img = document.createElement('img');
img.src = badgeUrl;
img.alt = 'Verified on Internet ID';
img.style.display = 'inline-block';
if (clickable) {
const link = document.createElement('a');
link.href = verifyUrl;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.style.display = 'inline-block';
link.style.textDecoration = 'none';
link.appendChild(img);
container.appendChild(link);
return link;
} else {
container.appendChild(img);
return img;
}
}
};
// Make globally accessible
window.InternetIDBadge = InternetIDBadge;
})();
// Usage
InternetIDBadge.create({
hash: '0x1234567890abcdef...',
theme: 'dark',
size: 'medium',
container: document.getElementById('badge-container')
});
</script>
</body>
<head>
<title>My Page</title>
</head>
<body>
<div id="badge-container"></div>
<script>
// Internet ID Badge Widget
(function () {
const InternetIDBadge = {
create: function (options) {
const {
hash,
theme = "dark",
size = "medium",
style = "rounded",
showTimestamp = false,
showPlatform = false,
platform = null,
container = document.body,
clickable = true,
apiBase = "http://localhost:3001",
siteBase = window.location.origin,
} = options;
// Build badge URL
const params = new URLSearchParams();
params.set("theme", theme);
params.set("size", size);
params.set("style", style);
if (showTimestamp) params.set("showTimestamp", "true");
if (showPlatform) params.set("showPlatform", "true");
if (platform) params.set("platform", platform);
const badgeUrl = `${apiBase}/api/badge/${hash}/svg?${params.toString()}`;
const verifyUrl = `${siteBase}/verify?hash=${hash}`;
// Create elements
const img = document.createElement("img");
img.src = badgeUrl;
img.alt = "Verified on Internet ID";
img.style.display = "inline-block";
if (clickable) {
const link = document.createElement("a");
link.href = verifyUrl;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.style.display = "inline-block";
link.style.textDecoration = "none";
link.appendChild(img);
container.appendChild(link);
return link;
} else {
container.appendChild(img);
return img;
}
},
};
// Make globally accessible
window.InternetIDBadge = InternetIDBadge;
})();
// Usage
InternetIDBadge.create({
hash: "0x1234567890abcdef...",
theme: "dark",
size: "medium",
container: document.getElementById("badge-container"),
});
</script>
</body>
</html>
```
@@ -108,10 +108,10 @@ Add this script to your HTML to dynamically insert verification badges:
<div id="my-badge"></div>
<script>
InternetID.createBadge({
hash: '0x1234567890abcdef...',
theme: 'blue',
size: 'large',
container: '#my-badge'
hash: "0x1234567890abcdef...",
theme: "blue",
size: "large",
container: "#my-badge",
});
</script>
```
@@ -123,13 +123,13 @@ Add this script to your HTML to dynamically insert verification badges:
### Using the Built-in Component
```tsx
import { VerificationBadge } from '@/app/components/VerificationBadge';
import { VerificationBadge } from "@/app/components/VerificationBadge";
function MyComponent() {
return (
<div>
<h1>My Verified Content</h1>
<VerificationBadge
<VerificationBadge
hash="0x1234567890abcdef..."
theme="blue"
size="large"
@@ -145,19 +145,19 @@ export default MyComponent;
### Custom React Component
```tsx
import React from 'react';
import React from "react";
interface BadgeProps {
hash: string;
theme?: 'dark' | 'light' | 'blue' | 'green' | 'purple';
size?: 'small' | 'medium' | 'large';
theme?: "dark" | "light" | "blue" | "green" | "purple";
size?: "small" | "medium" | "large";
}
export function Badge({ hash, theme = 'dark', size = 'medium' }: BadgeProps) {
const apiBase = process.env.NEXT_PUBLIC_API_BASE || 'http://localhost:3001';
export function Badge({ hash, theme = "dark", size = "medium" }: BadgeProps) {
const apiBase = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:3001";
const badgeUrl = `${apiBase}/api/badge/${hash}/svg?theme=${theme}&size=${size}`;
const verifyUrl = `/verify?hash=${hash}`;
return (
<a href={verifyUrl} target="_blank" rel="noopener noreferrer">
<img src={badgeUrl} alt="Verified on Internet ID" />
@@ -169,30 +169,28 @@ export function Badge({ hash, theme = 'dark', size = 'medium' }: BadgeProps) {
### Using the Badge Hook
```tsx
import { useBadgeUrls } from '@/app/components/VerificationBadge';
import { useState } from 'react';
import { useBadgeUrls } from "@/app/components/VerificationBadge";
import { useState } from "react";
function BadgeEmbed() {
const hash = '0x1234567890abcdef...';
const hash = "0x1234567890abcdef...";
const { badgeUrl, verifyUrl, html, markdown } = useBadgeUrls(hash, {
theme: 'blue',
size: 'large'
theme: "blue",
size: "large",
});
const [copied, setCopied] = useState(false);
const copyMarkdown = () => {
navigator.clipboard.writeText(markdown);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div>
<img src={badgeUrl} alt="Verified" />
<button onClick={copyMarkdown}>
{copied ? 'Copied!' : 'Copy Markdown'}
</button>
<button onClick={copyMarkdown}>{copied ? "Copied!" : "Copy Markdown"}</button>
</div>
);
}
@@ -206,44 +204,35 @@ function BadgeEmbed() {
```vue
<template>
<a
:href="verifyUrl"
target="_blank"
rel="noopener noreferrer"
class="badge-link"
>
<img
:src="badgeUrl"
alt="Verified on Internet ID"
class="badge-image"
/>
<a :href="verifyUrl" target="_blank" rel="noopener noreferrer" class="badge-link">
<img :src="badgeUrl" alt="Verified on Internet ID" class="badge-image" />
</a>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed } from "vue";
interface Props {
hash: string;
theme?: 'dark' | 'light' | 'blue' | 'green' | 'purple';
size?: 'small' | 'medium' | 'large';
style?: 'flat' | 'rounded' | 'pill' | 'minimal';
theme?: "dark" | "light" | "blue" | "green" | "purple";
size?: "small" | "medium" | "large";
style?: "flat" | "rounded" | "pill" | "minimal";
}
const props = withDefaults(defineProps<Props>(), {
theme: 'dark',
size: 'medium',
style: 'rounded'
theme: "dark",
size: "medium",
style: "rounded",
});
const apiBase = import.meta.env.VITE_API_BASE || 'http://localhost:3001';
const apiBase = import.meta.env.VITE_API_BASE || "http://localhost:3001";
const siteBase = import.meta.env.VITE_SITE_BASE || window.location.origin;
const badgeUrl = computed(() => {
const params = new URLSearchParams({
theme: props.theme,
size: props.size,
style: props.style
style: props.style,
});
return `${apiBase}/api/badge/${props.hash}/svg?${params.toString()}`;
});
@@ -269,42 +258,38 @@ const verifyUrl = computed(() => {
```vue
<template>
<a
:href="verifyUrl"
target="_blank"
rel="noopener noreferrer"
>
<a :href="verifyUrl" target="_blank" rel="noopener noreferrer">
<img :src="badgeUrl" alt="Verified on Internet ID" />
</a>
</template>
<script>
export default {
name: 'VerificationBadge',
name: "VerificationBadge",
props: {
hash: {
type: String,
required: true
required: true,
},
theme: {
type: String,
default: 'dark'
default: "dark",
},
size: {
type: String,
default: 'medium'
}
default: "medium",
},
},
computed: {
badgeUrl() {
const apiBase = process.env.VUE_APP_API_BASE || 'http://localhost:3001';
const apiBase = process.env.VUE_APP_API_BASE || "http://localhost:3001";
return `${apiBase}/api/badge/${this.hash}/svg?theme=${this.theme}&size=${this.size}`;
},
verifyUrl() {
const siteBase = process.env.VUE_APP_SITE_BASE || window.location.origin;
return `${siteBase}/verify?hash=${this.hash}`;
}
}
},
},
};
</script>
```
@@ -327,23 +312,23 @@ function internetid_badge_shortcode($atts) {
'size' => 'medium',
'style' => 'rounded',
), $atts);
if (empty($atts['hash'])) {
return '<p>Error: Badge hash is required</p>';
}
$api_base = get_option('internetid_api_base', 'http://localhost:3001');
$site_base = get_option('internetid_site_base', home_url());
$params = http_build_query(array(
'theme' => $atts['theme'],
'size' => $atts['size'],
'style' => $atts['style']
));
$badge_url = esc_url($api_base . '/api/badge/' . $atts['hash'] . '/svg?' . $params);
$verify_url = esc_url($site_base . '/verify?hash=' . $atts['hash']);
return sprintf(
'<a href="%s" target="_blank" rel="noopener noreferrer"><img src="%s" alt="Verified on Internet ID" style="display: inline-block;" /></a>',
$verify_url,
@@ -398,14 +383,14 @@ function internetid_settings_page_html() {
function internetid_register_settings() {
register_setting('internetid_settings', 'internetid_api_base');
register_setting('internetid_settings', 'internetid_site_base');
add_settings_section(
'internetid_section',
'Badge Configuration',
null,
'internetid-settings'
);
add_settings_field(
'internetid_api_base',
'API Base URL',
@@ -413,7 +398,7 @@ function internetid_register_settings() {
'internetid-settings',
'internetid_section'
);
add_settings_field(
'internetid_site_base',
'Site Base URL',
@@ -467,10 +452,10 @@ This project's authenticity is verified on-chain.
```markdown
## Verified Content
| Content | Status |
|---------|--------|
| Documentation | [![Verified](https://api.internet-id.com/api/badge/0x1234.../svg?size=small&theme=green)](https://internet-id.com/verify?hash=0x1234...) |
| Source Code | [![Verified](https://api.internet-id.com/api/badge/0x5678.../svg?size=small&theme=green)](https://internet-id.com/verify?hash=0x5678...) |
| Content | Status |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Documentation | [![Verified](https://api.internet-id.com/api/badge/0x1234.../svg?size=small&theme=green)](https://internet-id.com/verify?hash=0x1234...) |
| Source Code | [![Verified](https://api.internet-id.com/api/badge/0x5678.../svg?size=small&theme=green)](https://internet-id.com/verify?hash=0x5678...) |
| Release Binary | [![Verified](https://api.internet-id.com/api/badge/0x9abc.../svg?size=small&theme=green)](https://internet-id.com/verify?hash=0x9abc...) |
```
@@ -483,21 +468,28 @@ This project's authenticity is verified on-chain.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>My Verified Content</title>
</head>
<body>
<article>
<h1>My Article</h1>
<p>This content is verified on Internet ID.</p>
<!-- Verification Badge -->
<a href="https://internet-id.com/verify?hash=0x1234..." target="_blank" rel="noopener noreferrer">
<img src="https://api.internet-id.com/api/badge/0x1234.../svg?theme=dark&size=medium" alt="Verified on Internet ID" />
</a>
</article>
</body>
<head>
<meta charset="UTF-8" />
<title>My Verified Content</title>
</head>
<body>
<article>
<h1>My Article</h1>
<p>This content is verified on Internet ID.</p>
<!-- Verification Badge -->
<a
href="https://internet-id.com/verify?hash=0x1234..."
target="_blank"
rel="noopener noreferrer"
>
<img
src="https://api.internet-id.com/api/badge/0x1234.../svg?theme=dark&size=medium"
alt="Verified on Internet ID"
/>
</a>
</article>
</body>
</html>
```
@@ -506,20 +498,20 @@ This project's authenticity is verified on-chain.
```html
<picture>
<!-- Small screens: small badge -->
<source
media="(max-width: 768px)"
<source
media="(max-width: 768px)"
srcset="https://api.internet-id.com/api/badge/0x1234.../svg?size=small"
>
/>
<!-- Medium screens: medium badge -->
<source
media="(max-width: 1024px)"
<source
media="(max-width: 1024px)"
srcset="https://api.internet-id.com/api/badge/0x1234.../svg?size=medium"
>
/>
<!-- Large screens: large badge -->
<img
src="https://api.internet-id.com/api/badge/0x1234.../svg?size=large"
<img
src="https://api.internet-id.com/api/badge/0x1234.../svg?size=large"
alt="Verified on Internet ID"
>
/>
</picture>
```
@@ -530,11 +522,11 @@ This project's authenticity is verified on-chain.
<div class="container">
<h1>Authentic Content Creator</h1>
<p>All my content is verified on-chain</p>
<div class="badge-container">
<a href="https://internet-id.com/verify?hash=0x1234..." target="_blank">
<img
src="https://api.internet-id.com/api/badge/0x1234.../svg?theme=blue&size=large&style=pill"
<img
src="https://api.internet-id.com/api/badge/0x1234.../svg?theme=blue&size=large&style=pill"
alt="Verified Creator"
style="margin: 20px auto; display: block;"
/>
@@ -550,15 +542,15 @@ This project's authenticity is verified on-chain.
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.badge-container {
margin-top: 30px;
}
.badge-container img {
transition: transform 0.2s ease;
}
.badge-container img:hover {
transform: scale(1.05);
}
@@ -604,11 +596,11 @@ const badgeUrl = `${apiBase}/api/badge/${hash}/svg?theme=dark&_t=${timestamp}`;
### 4. Handle Loading States
```javascript
const img = document.createElement('img');
const img = document.createElement("img");
img.src = badgeUrl;
img.alt = 'Verified on Internet ID';
img.alt = "Verified on Internet ID";
img.onerror = () => {
img.src = '/fallback-badge.svg'; // Provide fallback
img.src = "/fallback-badge.svg"; // Provide fallback
};
```
@@ -634,6 +626,7 @@ Use appropriate sizes for different screen sizes:
## Support
For additional support or custom integration assistance:
- Visit the badge showcase: `/badges`
- Read the API documentation: `docs/BADGE_API.md`
- Open an issue on GitHub

View File

@@ -19,20 +19,17 @@ import {
relaxedRateLimit,
} from "./middleware/rate-limit.middleware";
import { cacheService } from "./services/cache.service";
import {
applySecurityHeaders,
cspReportHandler,
} from "./middleware/security-headers.middleware";
import { applySecurityHeaders, cspReportHandler } from "./middleware/security-headers.middleware";
async function startServer() {
// Initialize cache service
await cacheService.connect();
const app = express();
// Apply security headers first (before other middleware)
app.use(applySecurityHeaders);
app.use(cors());
app.use(express.json({ limit: "50mb" }));

View File

@@ -8,9 +8,7 @@ dotenv.config();
async function main() {
const [filePath, discordId, registryAddress] = process.argv.slice(2);
if (!filePath || !discordId || !registryAddress) {
console.error(
"Usage: npm run bind:discord -- <masterFilePath> <discordId> <registryAddress>"
);
console.error("Usage: npm run bind:discord -- <masterFilePath> <discordId> <registryAddress>");
process.exit(1);
}

View File

@@ -8,9 +8,7 @@ dotenv.config();
async function main() {
const [filePath, githubId, registryAddress] = process.argv.slice(2);
if (!filePath || !githubId || !registryAddress) {
console.error(
"Usage: npm run bind:github -- <masterFilePath> <githubId> <registryAddress>"
);
console.error("Usage: npm run bind:github -- <masterFilePath> <githubId> <registryAddress>");
process.exit(1);
}

View File

@@ -8,9 +8,7 @@ dotenv.config();
async function main() {
const [filePath, tiktokId, registryAddress] = process.argv.slice(2);
if (!filePath || !tiktokId || !registryAddress) {
console.error(
"Usage: npm run bind:tiktok -- <masterFilePath> <tiktokId> <registryAddress>"
);
console.error("Usage: npm run bind:tiktok -- <masterFilePath> <tiktokId> <registryAddress>");
process.exit(1);
}

View File

@@ -4,13 +4,13 @@ import { randomBytes } from "crypto";
/**
* Security Headers Middleware
*
*
* Implements comprehensive security headers to protect against common web vulnerabilities:
* - XSS (Cross-Site Scripting)
* - Clickjacking
* - MIME type sniffing
* - Information leakage
*
*
* CSP Configuration:
* - Strict default-src policy
* - Allows IPFS gateways for images
@@ -149,11 +149,7 @@ export const securityHeaders = helmet({
* Permissions-Policy middleware
* Restricts browser features to minimize attack surface
*/
export function permissionsPolicyMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
export function permissionsPolicyMiddleware(req: Request, res: Response, next: NextFunction) {
res.setHeader(
"Permissions-Policy",
[
@@ -174,11 +170,7 @@ export function permissionsPolicyMiddleware(
* Middleware to generate and attach CSP nonce to response locals
* This nonce can be used in inline scripts/styles
*/
export function cspNonceMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
export function cspNonceMiddleware(req: Request, res: Response, next: NextFunction) {
res.locals.cspNonce = generateNonce();
next();
}
@@ -187,11 +179,7 @@ export function cspNonceMiddleware(
* Complete security headers middleware stack
* Usage: app.use(applySecurityHeaders)
*/
export function applySecurityHeaders(
req: Request,
res: Response,
next: NextFunction
) {
export function applySecurityHeaders(req: Request, res: Response, next: NextFunction) {
// Generate nonce first
cspNonceMiddleware(req, res, () => {
// Then apply helmet with nonce

View File

@@ -1,15 +1,15 @@
/**
* Badge Routes
*
*
* API endpoints for badge generation, embed code generation, and badge customization.
*/
import { Router, Request, Response } from 'express';
import { prisma } from '../db';
import { validateParams } from '../validation/middleware';
import { contentHashParamSchema } from '../validation/schemas';
import { cacheService, DEFAULT_TTL } from '../services/cache.service';
import { badgeService, BadgeData } from '../services/badge.service';
import { Router, Request, Response } from "express";
import { prisma } from "../db";
import { validateParams } from "../validation/middleware";
import { contentHashParamSchema } from "../validation/schemas";
import { cacheService, DEFAULT_TTL } from "../services/cache.service";
import { badgeService, BadgeData } from "../services/badge.service";
const router = Router();
@@ -18,12 +18,12 @@ const router = Router();
* Generate SVG badge for a content hash
*/
router.get(
'/badge/:hash/svg',
"/badge/:hash/svg",
validateParams(contentHashParamSchema),
async (req: Request, res: Response) => {
try {
const hash = req.params.hash;
// Parse query options
const options = badgeService.validateBadgeOptions({
theme: req.query.theme,
@@ -35,10 +35,10 @@ router.get(
// Create cache key based on hash and options
const cacheKey = `badge:svg:${hash}:${JSON.stringify(options)}`;
// Try to get from cache
let svg = await cacheService.get(cacheKey);
if (!svg) {
// Fetch content data from database (gracefully handle errors)
let content = null;
@@ -49,7 +49,7 @@ router.get(
});
} catch (dbError) {
// Database unavailable - continue with unverified badge
console.warn('Database query failed, generating unverified badge:', dbError);
console.warn("Database query failed, generating unverified badge:", dbError);
}
// Prepare badge data
@@ -62,22 +62,22 @@ router.get(
};
// Override platform if specified in query
if (req.query.platform && typeof req.query.platform === 'string') {
if (req.query.platform && typeof req.query.platform === "string") {
badgeData.platform = req.query.platform;
}
// Generate SVG
svg = badgeService.generateBadgeSVG(badgeData, options);
// Cache the result
await cacheService.set(cacheKey, svg, { ttl: DEFAULT_TTL.CONTENT_METADATA });
}
res.setHeader('Content-Type', 'image/svg+xml');
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader("Content-Type", "image/svg+xml");
res.setHeader("Cache-Control", "public, max-age=3600");
res.send(svg);
} catch (e: any) {
console.error('Badge generation error:', e);
console.error("Badge generation error:", e);
res.status(500).json({ error: e?.message || String(e) });
}
}
@@ -88,7 +88,7 @@ router.get(
* Generate PNG badge (redirects to SVG for now, could use conversion library)
*/
router.get(
'/badge/:hash/png',
"/badge/:hash/png",
validateParams(contentHashParamSchema),
async (req: Request, res: Response) => {
try {
@@ -96,8 +96,8 @@ router.get(
// TODO: Implement actual PNG conversion if needed
const hash = req.params.hash;
const queryString = new URLSearchParams(req.query as any).toString();
const svgUrl = `/api/badge/${hash}/svg${queryString ? '?' + queryString : ''}`;
const svgUrl = `/api/badge/${hash}/svg${queryString ? "?" + queryString : ""}`;
res.redirect(svgUrl);
} catch (e: any) {
res.status(500).json({ error: e?.message || String(e) });
@@ -110,12 +110,12 @@ router.get(
* Get embed codes (HTML and Markdown) for a badge
*/
router.get(
'/badge/:hash/embed',
"/badge/:hash/embed",
validateParams(contentHashParamSchema),
async (req: Request, res: Response) => {
try {
const hash = req.params.hash;
// Parse options for badge URL
const options = badgeService.validateBadgeOptions({
theme: req.query.theme,
@@ -125,23 +125,24 @@ router.get(
// Build badge URL with query params
const queryParams = new URLSearchParams();
if (options.theme) queryParams.set('theme', options.theme);
if (options.size) queryParams.set('size', String(options.size));
if (options.style) queryParams.set('style', options.style);
if (options.theme) queryParams.set("theme", options.theme);
if (options.size) queryParams.set("size", String(options.size));
if (options.style) queryParams.set("style", options.style);
const queryString = queryParams.toString();
// Get base URL from environment or construct from request
const siteBase = process.env.NEXT_PUBLIC_SITE_BASE ||
process.env.API_BASE_URL ||
`${req.protocol}://${req.get('host')}`;
const badgeUrl = `${siteBase}/api/badge/${hash}/svg${queryString ? '?' + queryString : ''}`;
const siteBase =
process.env.NEXT_PUBLIC_SITE_BASE ||
process.env.API_BASE_URL ||
`${req.protocol}://${req.get("host")}`;
const badgeUrl = `${siteBase}/api/badge/${hash}/svg${queryString ? "?" + queryString : ""}`;
const verifyUrl = `${siteBase}/verify?hash=${hash}`;
// Generate embed snippets
const snippets = badgeService.generateEmbedSnippets(badgeUrl, verifyUrl, hash);
res.json(snippets);
} catch (e: any) {
res.status(500).json({ error: e?.message || String(e) });
@@ -153,24 +154,24 @@ router.get(
* GET /api/badge/options
* Get available badge customization options
*/
router.get('/badge/options', async (_req: Request, res: Response) => {
router.get("/badge/options", async (_req: Request, res: Response) => {
try {
const options = {
themes: ['dark', 'light', 'blue', 'green', 'purple'],
sizes: ['small', 'medium', 'large', 'custom (120-640)'],
styles: ['flat', 'rounded', 'pill', 'minimal'],
themes: ["dark", "light", "blue", "green", "purple"],
sizes: ["small", "medium", "large", "custom (120-640)"],
styles: ["flat", "rounded", "pill", "minimal"],
customization: {
showTimestamp: 'boolean',
showPlatform: 'boolean',
platform: 'string (platform name)',
showTimestamp: "boolean",
showPlatform: "boolean",
platform: "string (platform name)",
},
examples: [
'/api/badge/{hash}/svg?theme=dark&size=medium&style=rounded',
'/api/badge/{hash}/svg?theme=light&size=large&style=pill',
'/api/badge/{hash}/svg?theme=blue&size=small&style=flat&showTimestamp=true',
"/api/badge/{hash}/svg?theme=dark&size=medium&style=rounded",
"/api/badge/{hash}/svg?theme=light&size=large&style=pill",
"/api/badge/{hash}/svg?theme=blue&size=small&style=flat&showTimestamp=true",
],
};
res.json(options);
} catch (e: any) {
res.status(500).json({ error: e?.message || String(e) });
@@ -182,16 +183,16 @@ router.get('/badge/options', async (_req: Request, res: Response) => {
* Get verification status for a content hash (without generating badge)
*/
router.get(
'/badge/:hash/status',
"/badge/:hash/status",
validateParams(contentHashParamSchema),
async (req: Request, res: Response) => {
try {
const hash = req.params.hash;
// Check cache first
const cacheKey = `badge:status:${hash}`;
let status = await cacheService.get(cacheKey);
if (!status) {
// Fetch content data (gracefully handle errors)
let content = null;
@@ -202,21 +203,21 @@ router.get(
});
} catch (dbError) {
// Database unavailable - return unverified status
console.warn('Database query failed for status check:', dbError);
console.warn("Database query failed for status check:", dbError);
}
status = {
contentHash: hash,
verified: !!content,
timestamp: content?.createdAt?.toISOString(),
platforms: content?.bindings?.map(b => b.platform) || [],
platforms: content?.bindings?.map((b) => b.platform) || [],
creator: content?.creatorAddress,
registryAddress: content?.registryAddress,
};
// Cache with shorter TTL for status checks
await cacheService.set(cacheKey, JSON.stringify(status), {
ttl: DEFAULT_TTL.VERIFICATION_STATUS
await cacheService.set(cacheKey, JSON.stringify(status), {
ttl: DEFAULT_TTL.VERIFICATION_STATUS,
});
} else {
status = JSON.parse(status);

View File

@@ -14,55 +14,47 @@ const router = Router();
* Create a new API key for the authenticated user
* Body: { name?: string, tier?: string, expiresAt?: string }
*/
router.post(
"/",
authenticateRequest,
async (req: AuthenticatedRequest, res: Response) => {
try {
const { name, tier, expiresAt } = req.body;
const userId = req.auth!.userId;
router.post("/", authenticateRequest, async (req: AuthenticatedRequest, res: Response) => {
try {
const { name, tier, expiresAt } = req.body;
const userId = req.auth!.userId;
const expiryDate = expiresAt ? new Date(expiresAt) : undefined;
const expiryDate = expiresAt ? new Date(expiresAt) : undefined;
const apiKey = await createApiKey(userId, name, tier, expiryDate);
const apiKey = await createApiKey(userId, name, tier, expiryDate);
return res.status(201).json({
message: "API key created successfully",
data: apiKey,
warning: "Save this key securely. It won't be shown again.",
});
} catch (e: any) {
return res.status(500).json({
error: "Failed to create API key",
message: e?.message || String(e),
});
}
return res.status(201).json({
message: "API key created successfully",
data: apiKey,
warning: "Save this key securely. It won't be shown again.",
});
} catch (e: any) {
return res.status(500).json({
error: "Failed to create API key",
message: e?.message || String(e),
});
}
);
});
/**
* GET /api/v1/api-keys
* List all API keys for the authenticated user
*/
router.get(
"/",
authenticateRequest,
async (req: AuthenticatedRequest, res: Response) => {
try {
const userId = req.auth!.userId;
const keys = await listApiKeys(userId);
router.get("/", authenticateRequest, async (req: AuthenticatedRequest, res: Response) => {
try {
const userId = req.auth!.userId;
const keys = await listApiKeys(userId);
return res.json({
data: keys,
});
} catch (e: any) {
return res.status(500).json({
error: "Failed to list API keys",
message: e?.message || String(e),
});
}
return res.json({
data: keys,
});
} catch (e: any) {
return res.status(500).json({
error: "Failed to list API keys",
message: e?.message || String(e),
});
}
);
});
/**
* PATCH /api/v1/api-keys/:id/revoke
@@ -94,26 +86,22 @@ router.patch(
* DELETE /api/v1/api-keys/:id
* Delete an API key
*/
router.delete(
"/:id",
authenticateRequest,
async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
const userId = req.auth!.userId;
router.delete("/:id", authenticateRequest, async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
const userId = req.auth!.userId;
await deleteApiKey(id, userId);
await deleteApiKey(id, userId);
return res.json({
message: "API key deleted successfully",
});
} catch (e: any) {
return res.status(500).json({
error: "Failed to delete API key",
message: e?.message || String(e),
});
}
return res.json({
message: "API key deleted successfully",
});
} catch (e: any) {
return res.status(500).json({
error: "Failed to delete API key",
message: e?.message || String(e),
});
}
);
});
export default router;

View File

@@ -9,7 +9,7 @@ const router = Router();
* POST /api/v1/auth/token
* Generate a JWT token for user-scoped API access
* Body: { address: string, signature: string, message: string }
*
*
* This endpoint allows users to authenticate by signing a message with their wallet
* and receive a JWT token for making authenticated API requests.
*/

View File

@@ -10,122 +10,114 @@ const router = Router();
* List registered content with pagination
* Query params: limit, offset, creator
*/
router.get(
"/",
optionalAuthentication,
async (req: AuthenticatedRequest, res: Response) => {
try {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const offset = parseInt(req.query.offset as string) || 0;
const creator = req.query.creator as string | undefined;
router.get("/", optionalAuthentication, async (req: AuthenticatedRequest, res: Response) => {
try {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const offset = parseInt(req.query.offset as string) || 0;
const creator = req.query.creator as string | undefined;
const where = creator ? { creatorAddress: creator } : {};
const where = creator ? { creatorAddress: creator } : {};
const cacheKey = `contents:${creator || "all"}:${limit}:${offset}`;
const result = await cacheService.getOrSet(
cacheKey,
async () => {
const [items, total] = await Promise.all([
prisma.content.findMany({
where,
take: limit,
skip: offset,
orderBy: { createdAt: "desc" },
select: {
id: true,
contentHash: true,
manifestUri: true,
creatorAddress: true,
registryAddress: true,
txHash: true,
createdAt: true,
bindings: {
select: {
platform: true,
platformId: true,
},
const cacheKey = `contents:${creator || "all"}:${limit}:${offset}`;
const result = await cacheService.getOrSet(
cacheKey,
async () => {
const [items, total] = await Promise.all([
prisma.content.findMany({
where,
take: limit,
skip: offset,
orderBy: { createdAt: "desc" },
select: {
id: true,
contentHash: true,
manifestUri: true,
creatorAddress: true,
registryAddress: true,
txHash: true,
createdAt: true,
bindings: {
select: {
platform: true,
platformId: true,
},
},
}),
prisma.content.count({ where }),
]);
},
}),
prisma.content.count({ where }),
]);
return { items, total };
},
{ ttl: DEFAULT_TTL.CONTENT_METADATA }
);
return { items, total };
},
{ ttl: DEFAULT_TTL.CONTENT_METADATA }
);
return res.json({
data: result.items,
pagination: {
limit,
offset,
total: result.total,
hasMore: offset + limit < result.total,
},
});
} catch (e: any) {
return res.status(500).json({
error: "Failed to fetch content",
message: e?.message || String(e),
});
}
return res.json({
data: result.items,
pagination: {
limit,
offset,
total: result.total,
hasMore: offset + limit < result.total,
},
});
} catch (e: any) {
return res.status(500).json({
error: "Failed to fetch content",
message: e?.message || String(e),
});
}
);
});
/**
* GET /api/v1/content/:id
* Get specific content by ID
*/
router.get(
"/:id",
optionalAuthentication,
async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
router.get("/:id", optionalAuthentication, async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
const cacheKey = `content:${id}`;
const content = await cacheService.getOrSet(
cacheKey,
async () => {
return await prisma.content.findUnique({
where: { id },
include: {
bindings: {
select: {
platform: true,
platformId: true,
createdAt: true,
},
},
verifications: {
take: 10,
orderBy: { createdAt: "desc" },
select: {
status: true,
recoveredAddress: true,
createdAt: true,
},
const cacheKey = `content:${id}`;
const content = await cacheService.getOrSet(
cacheKey,
async () => {
return await prisma.content.findUnique({
where: { id },
include: {
bindings: {
select: {
platform: true,
platformId: true,
createdAt: true,
},
},
});
},
{ ttl: DEFAULT_TTL.CONTENT_METADATA }
);
verifications: {
take: 10,
orderBy: { createdAt: "desc" },
select: {
status: true,
recoveredAddress: true,
createdAt: true,
},
},
},
});
},
{ ttl: DEFAULT_TTL.CONTENT_METADATA }
);
if (!content) {
return res.status(404).json({ error: "Content not found" });
}
return res.json({ data: content });
} catch (e: any) {
return res.status(500).json({
error: "Failed to fetch content",
message: e?.message || String(e),
});
if (!content) {
return res.status(404).json({ error: "Content not found" });
}
return res.json({ data: content });
} catch (e: any) {
return res.status(500).json({
error: "Failed to fetch content",
message: e?.message || String(e),
});
}
);
});
/**
* GET /api/v1/content/hash/:hash

View File

@@ -153,7 +153,7 @@ router.get(
async (req: AuthenticatedRequest, res: Response) => {
try {
const { hash } = req.params;
if (!hash || !CONTENT_HASH_PATTERN.test(hash)) {
return res.status(400).json({
error: "Invalid hash format",

View File

@@ -22,7 +22,7 @@ function generateApiKey(): string {
/**
* Hash an API key for storage
*
*
* Note: SHA-256 is appropriate here because API keys are cryptographically random
* 32-byte values, not user-chosen passwords. Unlike passwords, they don't need
* slow key derivation functions like bcrypt/scrypt since they have sufficient entropy.
@@ -72,15 +72,12 @@ export async function createApiKey(
*/
export async function verifyApiKey(plainKey: string) {
const hashedKey = hashApiKey(plainKey);
const apiKey = await prisma.apiKey.findFirst({
where: {
key: hashedKey,
isActive: true,
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } }
]
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
include: {
user: true,

View File

@@ -1,14 +1,14 @@
/**
* Badge Service
*
*
* Generates embeddable verification badges with different styles, themes, and configurations.
* Supports SVG generation with caching and customization options.
*/
export interface BadgeOptions {
theme?: 'dark' | 'light' | 'blue' | 'green' | 'purple';
size?: 'small' | 'medium' | 'large' | number;
style?: 'flat' | 'rounded' | 'pill' | 'minimal';
theme?: "dark" | "light" | "blue" | "green" | "purple";
size?: "small" | "medium" | "large" | number;
style?: "flat" | "rounded" | "pill" | "minimal";
showTimestamp?: boolean;
showPlatform?: boolean;
platform?: string;
@@ -23,11 +23,11 @@ export interface BadgeData {
}
const THEME_COLORS = {
dark: { bg: '#0b0f1a', fg: '#9ef', accent: '#0cf' },
light: { bg: '#ffffff', fg: '#0b0f1a', accent: '#0080ff' },
blue: { bg: '#1a237e', fg: '#e3f2fd', accent: '#64b5f6' },
green: { bg: '#1b5e20', fg: '#e8f5e9', accent: '#81c784' },
purple: { bg: '#4a148c', fg: '#f3e5f5', accent: '#ba68c8' },
dark: { bg: "#0b0f1a", fg: "#9ef", accent: "#0cf" },
light: { bg: "#ffffff", fg: "#0b0f1a", accent: "#0080ff" },
blue: { bg: "#1a237e", fg: "#e3f2fd", accent: "#64b5f6" },
green: { bg: "#1b5e20", fg: "#e8f5e9", accent: "#81c784" },
purple: { bg: "#4a148c", fg: "#f3e5f5", accent: "#ba68c8" },
};
const SIZE_PRESETS = {
@@ -41,51 +41,52 @@ const SIZE_PRESETS = {
*/
export function generateBadgeSVG(data: BadgeData, options: BadgeOptions = {}): string {
const {
theme = 'dark',
size = 'medium',
style = 'rounded',
theme = "dark",
size = "medium",
style = "rounded",
showTimestamp = false,
showPlatform = false,
} = options;
// Resolve size
const width = typeof size === 'number' ? size : SIZE_PRESETS[size];
const width = typeof size === "number" ? size : SIZE_PRESETS[size];
const colors = THEME_COLORS[theme];
// Calculate dimensions
const scale = width / 240;
const height = Math.round(32 * scale);
const rx = style === 'pill' ? height / 2 : style === 'rounded' ? Math.round(6 * scale) : 0;
const rx = style === "pill" ? height / 2 : style === "rounded" ? Math.round(6 * scale) : 0;
const xPad = Math.round(10 * scale);
const yText = Math.round(21 * scale);
const fontSize = 12 * scale;
// Truncate hash for display
const shortHash = data.contentHash && data.contentHash.length > 20
? `${data.contentHash.slice(0, 10)}${data.contentHash.slice(-6)}`
: data.contentHash;
const shortHash =
data.contentHash && data.contentHash.length > 20
? `${data.contentHash.slice(0, 10)}${data.contentHash.slice(-6)}`
: data.contentHash;
// Status indicator
const statusIcon = data.verified ? '✓' : '✗';
const statusText = data.verified ? 'Verified' : 'Unverified';
const statusIcon = data.verified ? "✓" : "✗";
const statusText = data.verified ? "Verified" : "Unverified";
// Build badge text
let badgeText = `${statusIcon} ${statusText}`;
if (showPlatform && data.platform) {
badgeText += ` · ${data.platform}`;
}
if (style === 'minimal') {
if (style === "minimal") {
badgeText = statusIcon;
} else if (data.contentHash && style !== 'pill') {
} else if (data.contentHash && style !== "pill") {
badgeText += ` · ${shortHash}`;
}
// Add timestamp if requested
let timestampText = '';
let timestampText = "";
if (showTimestamp && data.timestamp) {
const dateStr = data.timestamp.toISOString().split('T')[0];
const dateStr = data.timestamp.toISOString().split("T")[0];
timestampText = `<text x="${xPad}" y="${yText + fontSize + 4}" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="${fontSize * 0.8}" fill="${colors.fg}" opacity="0.7">${dateStr}</text>`;
}
@@ -111,9 +112,9 @@ export function generateEmbedHTML(
targetUrl: string,
options: { alt?: string; title?: string } = {}
): string {
const alt = options.alt || 'Verified on Internet ID';
const title = options.title || 'View verification details';
const alt = options.alt || "Verified on Internet ID";
const title = options.title || "View verification details";
return `<a href="${targetUrl}" target="_blank" rel="noopener noreferrer" title="${title}">
<img src="${badgeUrl}" alt="${alt}" />
</a>`;
@@ -127,21 +128,19 @@ export function generateEmbedMarkdown(
targetUrl: string,
options: { alt?: string } = {}
): string {
const alt = options.alt || 'Verified on Internet ID';
const alt = options.alt || "Verified on Internet ID";
return `[![${alt}](${badgeUrl})](${targetUrl})`;
}
/**
* Generate a complete embed snippet package
*/
export function generateEmbedSnippets(
badgeUrl: string,
verifyUrl: string,
contentHash: string
) {
export function generateEmbedSnippets(badgeUrl: string, verifyUrl: string, contentHash: string) {
return {
html: generateEmbedHTML(badgeUrl, verifyUrl, { alt: `Verified content ${contentHash}` }),
markdown: generateEmbedMarkdown(badgeUrl, verifyUrl, { alt: `Verified content ${contentHash}` }),
markdown: generateEmbedMarkdown(badgeUrl, verifyUrl, {
alt: `Verified content ${contentHash}`,
}),
direct: badgeUrl,
verify: verifyUrl,
contentHash,
@@ -154,12 +153,12 @@ export function generateEmbedSnippets(
export function validateBadgeOptions(options: any): BadgeOptions {
const validated: BadgeOptions = {};
if (options.theme && ['dark', 'light', 'blue', 'green', 'purple'].includes(options.theme)) {
if (options.theme && ["dark", "light", "blue", "green", "purple"].includes(options.theme)) {
validated.theme = options.theme;
}
if (options.size) {
if (['small', 'medium', 'large'].includes(options.size)) {
if (["small", "medium", "large"].includes(options.size)) {
validated.size = options.size;
} else {
const numSize = Number(options.size);
@@ -169,14 +168,14 @@ export function validateBadgeOptions(options: any): BadgeOptions {
}
}
if (options.style && ['flat', 'rounded', 'pill', 'minimal'].includes(options.style)) {
if (options.style && ["flat", "rounded", "pill", "minimal"].includes(options.style)) {
validated.style = options.style;
}
validated.showTimestamp = options.showTimestamp === 'true' || options.showTimestamp === true;
validated.showPlatform = options.showPlatform === 'true' || options.showPlatform === true;
validated.showTimestamp = options.showTimestamp === "true" || options.showTimestamp === true;
validated.showPlatform = options.showPlatform === "true" || options.showPlatform === true;
if (options.platform && typeof options.platform === 'string') {
if (options.platform && typeof options.platform === "string") {
validated.platform = options.platform;
}

View File

@@ -94,8 +94,8 @@ class CacheService {
await Promise.race([
this.client.connect(),
new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('Connection timeout')), 3000);
})
timeoutId = setTimeout(() => reject(new Error("Connection timeout")), 3000);
}),
]).finally(() => {
if (timeoutId) clearTimeout(timeoutId);
});

View File

@@ -5,7 +5,7 @@ const isProduction = process.env.NODE_ENV === "production";
if (!process.env.JWT_SECRET && isProduction) {
throw new Error(
"JWT_SECRET environment variable is required in production. " +
"Generate a strong secret with: openssl rand -base64 32"
"Generate a strong secret with: openssl rand -base64 32"
);
}
@@ -49,10 +49,10 @@ export function verifyJwtToken(token: string): JwtPayload | null {
*/
export function extractTokenFromHeader(authHeader?: string): string | null {
if (!authHeader) return null;
if (authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return authHeader;
}

View File

@@ -105,12 +105,16 @@ export function extractInstagramId(input: string): string {
// Extract GitHub ID from URL or raw input
export function extractGitHubId(input: string): string {
return extractPlatformIdFromUrl(input, ["gist.github.com", "github.com"], (pathname, hostname) => {
if (hostname.includes("gist.github.com")) {
return `gist/${pathname}`;
return extractPlatformIdFromUrl(
input,
["gist.github.com", "github.com"],
(pathname, hostname) => {
if (hostname.includes("gist.github.com")) {
return `gist/${pathname}`;
}
return pathname;
}
return pathname;
});
);
}
// Extract Discord ID from URL or raw input

View File

@@ -37,7 +37,7 @@ export async function resolveDefaultRegistry(): Promise<RegistryInfo> {
const chainId = Number(net.chainId);
const override = process.env.REGISTRY_ADDRESS;
if (override) return { registryAddress: override, chainId };
const deployedFileName = CHAIN_DEPLOYMENT_FILES[chainId];
if (deployedFileName) {
const deployedFile = path.join(process.cwd(), "deployed", deployedFileName);
@@ -55,7 +55,7 @@ export async function resolveDefaultRegistry(): Promise<RegistryInfo> {
export async function getRegistryAddress(chainId: number): Promise<string | undefined> {
const deployedFileName = CHAIN_DEPLOYMENT_FILES[chainId];
if (!deployedFileName) return undefined;
const deployedFile = path.join(process.cwd(), "deployed", deployedFileName);
try {
const data = JSON.parse((await readFile(deployedFile)).toString("utf8"));
@@ -69,7 +69,7 @@ export async function getRegistryAddress(chainId: number): Promise<string | unde
// Helper to get all deployed registry addresses
export async function getAllRegistryAddresses(): Promise<Record<number, string>> {
const addresses: Record<number, string> = {};
for (const [chainIdStr, fileName] of Object.entries(CHAIN_DEPLOYMENT_FILES)) {
const chainId = parseInt(chainIdStr);
const deployedFile = path.join(process.cwd(), "deployed", fileName);
@@ -85,12 +85,14 @@ export async function getAllRegistryAddresses(): Promise<Record<number, string>>
}
}
}
return addresses;
}
export function getProvider(rpcUrl?: string): ethers.JsonRpcProvider {
return new ethers.JsonRpcProvider(rpcUrl || process.env.RPC_URL || SUPPORTED_CHAINS.baseSepolia.rpcUrl);
return new ethers.JsonRpcProvider(
rpcUrl || process.env.RPC_URL || SUPPORTED_CHAINS.baseSepolia.rpcUrl
);
}
// Helper to get provider for a specific chain
@@ -193,7 +195,9 @@ export async function resolveByPlatformCrossChain(
* Get a content entry across all supported chains
* Returns the first match found
*/
export async function getEntryCrossChain(contentHash: string): Promise<CrossChainRegistryEntry | null> {
export async function getEntryCrossChain(
contentHash: string
): Promise<CrossChainRegistryEntry | null> {
const addresses = await getAllRegistryAddresses();
const chainIds = Object.keys(addresses).map((id) => parseInt(id));

View File

@@ -3,7 +3,7 @@ import { createApp } from "./app";
async function main() {
const app = await createApp();
const port = process.env.PORT || 3001;
app.listen(port, () => {
console.log(`API server running on http://localhost:${port}`);
console.log(`Swagger docs: http://localhost:${port}/api/docs`);

View File

@@ -13,15 +13,15 @@ npm install @internet-id/sdk
### Using API Key
```typescript
import { InternetIdClient } from '@internet-id/sdk';
import { InternetIdClient } from "@internet-id/sdk";
const client = new InternetIdClient({
apiKey: 'iid_your_api_key_here'
apiKey: "iid_your_api_key_here",
});
// Verify content by YouTube URL
const result = await client.verifyByPlatform({
url: 'https://youtube.com/watch?v=abc123'
url: "https://youtube.com/watch?v=abc123",
});
console.log(result.verified); // true or false
@@ -31,12 +31,12 @@ console.log(result.creator); // Creator's Ethereum address
### Using JWT Token
```typescript
import { InternetIdClient } from '@internet-id/sdk';
import { ethers } from 'ethers';
import { InternetIdClient } from "@internet-id/sdk";
import { ethers } from "ethers";
// 1. Sign a message with your wallet
const wallet = new ethers.Wallet('your_private_key');
const message = 'Sign in to Internet ID API';
const wallet = new ethers.Wallet("your_private_key");
const message = "Sign in to Internet ID API";
const signature = await wallet.signMessage(message);
// 2. Generate JWT token
@@ -44,7 +44,7 @@ const client = new InternetIdClient();
const authResponse = await client.generateToken({
address: wallet.address,
signature,
message
message,
});
// 3. Use the JWT token for authenticated requests
@@ -52,11 +52,11 @@ client.setJwtToken(authResponse.token);
// Now you can make authenticated requests
const apiKey = await client.createApiKey({
name: 'My Application Key',
tier: 'free'
name: "My Application Key",
tier: "free",
});
console.log('Save this key:', apiKey.data.key);
console.log("Save this key:", apiKey.data.key);
```
## API Reference
@@ -70,13 +70,13 @@ Verify content by platform URL or platform + platformId.
```typescript
// By URL
const result = await client.verifyByPlatform({
url: 'https://youtube.com/watch?v=abc123'
url: "https://youtube.com/watch?v=abc123",
});
// By platform and ID
const result = await client.verifyByPlatform({
platform: 'youtube',
platformId: 'abc123'
platform: "youtube",
platformId: "abc123",
});
```
@@ -85,7 +85,7 @@ const result = await client.verifyByPlatform({
Verify content by content hash.
```typescript
const result = await client.verifyByHash('0x123...');
const result = await client.verifyByHash("0x123...");
```
### Content Metadata
@@ -98,7 +98,7 @@ List registered content with pagination.
const response = await client.listContent({
limit: 20,
offset: 0,
creator: '0x123...' // optional filter by creator
creator: "0x123...", // optional filter by creator
});
console.log(response.data); // Array of content items
@@ -110,7 +110,7 @@ console.log(response.pagination); // Pagination info
Get content by database ID.
```typescript
const content = await client.getContentById('cuid123');
const content = await client.getContentById("cuid123");
```
#### `getContentByHash(hash)`
@@ -118,7 +118,7 @@ const content = await client.getContentById('cuid123');
Get content by content hash.
```typescript
const content = await client.getContentByHash('0x123...');
const content = await client.getContentByHash("0x123...");
```
### API Key Management
@@ -131,13 +131,13 @@ Create a new API key.
```typescript
const result = await client.createApiKey({
name: 'Production Key',
tier: 'free', // or 'paid'
expiresAt: '2024-12-31T23:59:59Z' // optional
name: "Production Key",
tier: "free", // or 'paid'
expiresAt: "2024-12-31T23:59:59Z", // optional
});
// IMPORTANT: Save the key immediately, it won't be shown again
console.log('API Key:', result.data.key);
console.log("API Key:", result.data.key);
```
#### `listApiKeys()`
@@ -154,7 +154,7 @@ console.log(keys.data); // Array of API keys (without the actual key values)
Revoke an API key (makes it inactive but doesn't delete it).
```typescript
await client.revokeApiKey('key-id');
await client.revokeApiKey("key-id");
```
#### `deleteApiKey(keyId)`
@@ -162,7 +162,7 @@ await client.revokeApiKey('key-id');
Permanently delete an API key.
```typescript
await client.deleteApiKey('key-id');
await client.deleteApiKey("key-id");
```
### Authentication
@@ -173,9 +173,9 @@ Generate a JWT token by signing a message with your wallet.
```typescript
const response = await client.generateToken({
address: '0x123...',
signature: '0xabc...',
message: 'Sign in to Internet ID API'
address: "0x123...",
signature: "0xabc...",
message: "Sign in to Internet ID API",
});
console.log(response.token); // JWT token
@@ -186,10 +186,10 @@ console.log(response.expiresIn); // "24h"
```typescript
const client = new InternetIdClient({
baseURL: 'https://api.internet-id.io/api/v1', // API base URL
apiKey: 'iid_xxx', // API key (optional)
jwtToken: 'eyJ...', // JWT token (optional)
timeout: 30000 // Request timeout in milliseconds (default: 30000)
baseURL: "https://api.internet-id.io/api/v1", // API base URL
apiKey: "iid_xxx", // API key (optional)
jwtToken: "eyJ...", // JWT token (optional)
timeout: 30000, // Request timeout in milliseconds (default: 30000)
});
```
@@ -200,16 +200,17 @@ All methods throw errors on failure. Use try-catch blocks:
```typescript
try {
const result = await client.verifyByPlatform({
url: 'https://youtube.com/watch?v=invalid'
url: "https://youtube.com/watch?v=invalid",
});
} catch (error) {
console.error('Verification failed:', error.message);
console.error("Verification failed:", error.message);
}
```
## Rate Limits
Rate limits depend on your API key tier:
- **Free tier**: 100 requests per minute
- **Paid tier**: 1000 requests per minute

View File

@@ -95,20 +95,20 @@ export interface AuthTokenResponse {
/**
* Internet ID SDK Client
*
*
* Example usage:
* ```typescript
* import { InternetIdClient } from '@internet-id/sdk';
*
*
* const client = new InternetIdClient({
* apiKey: 'iid_your_api_key_here'
* });
*
*
* // Verify content by platform URL
* const result = await client.verifyByPlatform({
* url: 'https://youtube.com/watch?v=abc123'
* });
*
*
* // Verify content by hash
* const result2 = await client.verifyByHash('0x123...');
* ```
@@ -120,7 +120,7 @@ export class InternetIdClient {
// Note: Default production URL is a placeholder
// Update this to your actual API endpoint in production
const baseURL = config.baseURL || "https://api.internet-id.io/api/v1";
this.client = axios.create({
baseURL,
timeout: config.timeout || 30000,

View File

@@ -3,10 +3,10 @@ import { ethers } from "hardhat";
/**
* Gas Regression Tests for ContentRegistry
*
*
* These tests ensure that gas optimizations are maintained over time.
* If these tests fail, it means the gas usage has increased and should be investigated.
*
*
* BASELINE GAS USAGE (after optimization):
* - Deployment: ~825,317 gas
* - register: 50,368 - 115,935 gas (avg: ~71,650)
@@ -26,7 +26,7 @@ describe("Gas Regression Tests", function () {
const Factory = await ethers.getContractFactory("ContentRegistry");
const registry = await Factory.deploy();
const deployReceipt = await registry.deploymentTransaction()?.wait();
const gasUsed = deployReceipt?.gasUsed || 0n;
expect(gasUsed).to.be.lessThan(MAX_DEPLOYMENT_GAS);
});
@@ -72,7 +72,7 @@ describe("Gas Regression Tests", function () {
const hash = ethers.keccak256(ethers.toUtf8Bytes("test-content"));
const uri = "ipfs://QmTest/manifest.json";
await registry.connect(creator).register(hash, uri);
const tx = await registry.connect(creator).bindPlatform(hash, "youtube", "dQw4w9WgXcQ");
@@ -91,7 +91,7 @@ describe("Gas Regression Tests", function () {
const hash = ethers.keccak256(ethers.toUtf8Bytes("test-content"));
const uri = "ipfs://QmTest/manifest.json";
const newUri = "ipfs://QmNewTest/manifest.json";
await registry.connect(creator).register(hash, uri);
const tx = await registry.connect(creator).updateManifest(hash, newUri);
@@ -109,7 +109,7 @@ describe("Gas Regression Tests", function () {
const hash = ethers.keccak256(ethers.toUtf8Bytes("test-content"));
const uri = "ipfs://QmTest/manifest.json";
await registry.connect(creator).register(hash, uri);
const tx = await registry.connect(creator).revoke(hash);

View File

@@ -88,7 +88,9 @@ describe("Chain Configuration", function () {
});
it("should work for all supported chain IDs", function () {
const expectedChainIds = [1, 11155111, 137, 80002, 8453, 84532, 42161, 421614, 10, 11155420, 31337];
const expectedChainIds = [
1, 11155111, 137, 80002, 8453, 84532, 42161, 421614, 10, 11155420, 31337,
];
for (const chainId of expectedChainIds) {
const chain = getChainById(chainId);
expect(chain).to.exist;
@@ -210,17 +212,23 @@ describe("Chain Configuration", function () {
describe("getExplorerAddressUrl", function () {
it("should return correct URL for Ethereum mainnet", function () {
const url = getExplorerAddressUrl(1, "0x1234567890123456789012345678901234567890");
expect(url).to.equal("https://etherscan.io/address/0x1234567890123456789012345678901234567890");
expect(url).to.equal(
"https://etherscan.io/address/0x1234567890123456789012345678901234567890"
);
});
it("should return correct URL for Base Sepolia", function () {
const url = getExplorerAddressUrl(84532, "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd");
expect(url).to.equal("https://sepolia.basescan.org/address/0xabcdefabcdefabcdefabcdefabcdefabcdefabcd");
expect(url).to.equal(
"https://sepolia.basescan.org/address/0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"
);
});
it("should return correct URL for Polygon", function () {
const url = getExplorerAddressUrl(137, "0x1111111111111111111111111111111111111111");
expect(url).to.equal("https://polygonscan.com/address/0x1111111111111111111111111111111111111111");
expect(url).to.equal(
"https://polygonscan.com/address/0x1111111111111111111111111111111111111111"
);
});
it("should return undefined for invalid chain ID", function () {

View File

@@ -2,13 +2,13 @@
* Integration tests for Badge API endpoints
*/
import { expect } from 'chai';
import request from 'supertest';
import { ethers } from 'ethers';
import { IntegrationTestEnvironment } from '../fixtures/helpers';
import { createTestFile } from '../fixtures/factories';
import { expect } from "chai";
import request from "supertest";
import { ethers } from "ethers";
import { IntegrationTestEnvironment } from "../fixtures/helpers";
import { createTestFile } from "../fixtures/factories";
describe('Integration: Badge API Endpoints', function () {
describe("Integration: Badge API Endpoints", function () {
this.timeout(30000);
let env: IntegrationTestEnvironment;
@@ -32,13 +32,13 @@ describe('Integration: Badge API Endpoints', function () {
// Create test content in database if available
if (env.db.isDbAvailable()) {
const prisma = env.db.getClient();
const testFile = createTestFile('Badge test content');
const testFile = createTestFile("Badge test content");
testContentHash = testFile.hash;
await prisma.content.create({
data: {
contentHash: testContentHash,
manifestUri: 'ipfs://QmBadgeTest123',
manifestUri: "ipfs://QmBadgeTest123",
creatorAddress: creator.address.toLowerCase(),
registryAddress: registryAddress,
},
@@ -54,69 +54,67 @@ describe('Integration: Badge API Endpoints', function () {
await env.cleanup();
});
describe('GET /api/badge/options', function () {
it('should return available badge customization options', async function () {
const response = await request(app).get('/api/badge/options').expect(200);
describe("GET /api/badge/options", function () {
it("should return available badge customization options", async function () {
const response = await request(app).get("/api/badge/options").expect(200);
expect(response.body).to.have.property('themes');
expect(response.body).to.have.property('sizes');
expect(response.body).to.have.property('styles');
expect(response.body).to.have.property('customization');
expect(response.body).to.have.property('examples');
expect(response.body).to.have.property("themes");
expect(response.body).to.have.property("sizes");
expect(response.body).to.have.property("styles");
expect(response.body).to.have.property("customization");
expect(response.body).to.have.property("examples");
expect(response.body.themes).to.include('dark');
expect(response.body.themes).to.include('light');
expect(response.body.styles).to.include('rounded');
expect(response.body.themes).to.include("dark");
expect(response.body.themes).to.include("light");
expect(response.body.styles).to.include("rounded");
});
});
describe('GET /api/badge/:hash/svg', function () {
it('should generate SVG badge for valid hash', async function () {
const hash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
describe("GET /api/badge/:hash/svg", function () {
it("should generate SVG badge for valid hash", async function () {
const hash = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
const response = await request(app).get(`/api/badge/${hash}/svg`).expect(200);
expect(response.headers['content-type']).to.include('image/svg+xml');
expect(response.headers["content-type"]).to.include("image/svg+xml");
const body = response.text || response.body.toString();
expect(body).to.include('<?xml');
expect(body).to.include('<svg');
expect(body).to.include('</svg>');
expect(body).to.include("<?xml");
expect(body).to.include("<svg");
expect(body).to.include("</svg>");
});
it('should include cache control headers', async function () {
const hash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
it("should include cache control headers", async function () {
const hash = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
const response = await request(app).get(`/api/badge/${hash}/svg`).expect(200);
expect(response.headers['cache-control']).to.include('public');
expect(response.headers['cache-control']).to.include('max-age');
expect(response.headers["cache-control"]).to.include("public");
expect(response.headers["cache-control"]).to.include("max-age");
});
it('should accept theme query parameter', async function () {
const hash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
const response = await request(app)
.get(`/api/badge/${hash}/svg?theme=light`)
.expect(200);
it("should accept theme query parameter", async function () {
const hash = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
const response = await request(app).get(`/api/badge/${hash}/svg?theme=light`).expect(200);
const body = response.text || response.body.toString();
expect(body).to.include('fill="#ffffff"'); // light theme bg
});
it('should accept size query parameter', async function () {
const hash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
it("should accept size query parameter", async function () {
const hash = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
const response = await request(app).get(`/api/badge/${hash}/svg?size=large`).expect(200);
const body = response.text || response.body.toString();
expect(body).to.include('width="320"'); // large size
});
it('should accept style query parameter', async function () {
const hash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
it("should accept style query parameter", async function () {
const hash = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
const response = await request(app).get(`/api/badge/${hash}/svg?style=flat`).expect(200);
const body = response.text || response.body.toString();
expect(body).to.include('rx="0"'); // flat style has no border radius
});
it('should show verified status for registered content', async function () {
it("should show verified status for registered content", async function () {
if (!env.db.isDbAvailable() || !testContentHash) {
this.skip();
}
@@ -124,70 +122,68 @@ describe('Integration: Badge API Endpoints', function () {
const response = await request(app).get(`/api/badge/${testContentHash}/svg`).expect(200);
const body = response.text || response.body.toString();
expect(body).to.include('Verified');
expect(body).to.include('✓');
expect(body).to.include("Verified");
expect(body).to.include("✓");
});
});
describe('GET /api/badge/:hash/embed', function () {
it('should return embed codes', async function () {
const hash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
describe("GET /api/badge/:hash/embed", function () {
it("should return embed codes", async function () {
const hash = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
const response = await request(app).get(`/api/badge/${hash}/embed`).expect(200);
expect(response.body).to.have.property('html');
expect(response.body).to.have.property('markdown');
expect(response.body).to.have.property('direct');
expect(response.body).to.have.property('verify');
expect(response.body).to.have.property('contentHash');
expect(response.body).to.have.property("html");
expect(response.body).to.have.property("markdown");
expect(response.body).to.have.property("direct");
expect(response.body).to.have.property("verify");
expect(response.body).to.have.property("contentHash");
});
it('should include badge URL in html embed code', async function () {
const hash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
it("should include badge URL in html embed code", async function () {
const hash = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
const response = await request(app).get(`/api/badge/${hash}/embed`).expect(200);
expect(response.body.html).to.include('<a href=');
expect(response.body.html).to.include('<img src=');
expect(response.body.html).to.include('/api/badge/');
expect(response.body.html).to.include('/verify');
expect(response.body.html).to.include("<a href=");
expect(response.body.html).to.include("<img src=");
expect(response.body.html).to.include("/api/badge/");
expect(response.body.html).to.include("/verify");
});
it('should include badge URL in markdown embed code', async function () {
const hash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
it("should include badge URL in markdown embed code", async function () {
const hash = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
const response = await request(app).get(`/api/badge/${hash}/embed`).expect(200);
expect(response.body.markdown).to.include('[![');
expect(response.body.markdown).to.include('](');
expect(response.body.markdown).to.include('/api/badge/');
expect(response.body.markdown).to.include("[![");
expect(response.body.markdown).to.include("](");
expect(response.body.markdown).to.include("/api/badge/");
});
it('should respect theme parameter in embed URLs', async function () {
const hash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
const response = await request(app)
.get(`/api/badge/${hash}/embed?theme=blue`)
.expect(200);
it("should respect theme parameter in embed URLs", async function () {
const hash = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
const response = await request(app).get(`/api/badge/${hash}/embed?theme=blue`).expect(200);
expect(response.body.direct).to.include('theme=blue');
expect(response.body.direct).to.include("theme=blue");
});
});
describe('GET /api/badge/:hash/status', function () {
it('should return verification status', async function () {
const hash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
describe("GET /api/badge/:hash/status", function () {
it("should return verification status", async function () {
const hash = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
const response = await request(app).get(`/api/badge/${hash}/status`).expect(200);
expect(response.body).to.have.property('contentHash');
expect(response.body).to.have.property('verified');
expect(response.body).to.have.property("contentHash");
expect(response.body).to.have.property("verified");
expect(response.body.contentHash).to.equal(hash);
});
it('should return verified=false for unregistered content', async function () {
const hash = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
it("should return verified=false for unregistered content", async function () {
const hash = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
const response = await request(app).get(`/api/badge/${hash}/status`).expect(200);
expect(response.body.verified).to.be.false;
});
it('should return verified=true for registered content', async function () {
it("should return verified=true for registered content", async function () {
if (!env.db.isDbAvailable() || !testContentHash) {
this.skip();
}
@@ -195,17 +191,17 @@ describe('Integration: Badge API Endpoints', function () {
const response = await request(app).get(`/api/badge/${testContentHash}/status`).expect(200);
expect(response.body.verified).to.be.true;
expect(response.body).to.have.property('timestamp');
expect(response.body).to.have.property('creator');
expect(response.body).to.have.property("timestamp");
expect(response.body).to.have.property("creator");
});
});
describe('GET /api/badge/:hash/png', function () {
it('should redirect to SVG endpoint', async function () {
const hash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
describe("GET /api/badge/:hash/png", function () {
it("should redirect to SVG endpoint", async function () {
const hash = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
const response = await request(app).get(`/api/badge/${hash}/png`).expect(302);
expect(response.headers.location).to.include('/svg');
expect(response.headers.location).to.include("/svg");
});
});
});

View File

@@ -157,13 +157,13 @@ describe("Security Headers Middleware", function () {
const next = () => {
// Check for critical security headers
// Permissions-Policy should be set
expect(headers["Permissions-Policy"]).to.exist;
// CSP nonce should be generated
expect(res.locals.cspNonce).to.be.a("string");
done();
};

View File

@@ -118,9 +118,7 @@ describe("API Key Service", () => {
});
expect(after!.lastUsedAt).to.not.be.null;
expect(after!.lastUsedAt!.getTime()).to.be.greaterThan(
before?.lastUsedAt?.getTime() || 0
);
expect(after!.lastUsedAt!.getTime()).to.be.greaterThan(before?.lastUsedAt?.getTime() || 0);
});
});

View File

@@ -1,170 +1,170 @@
import { expect } from 'chai';
import { badgeService, BadgeData, BadgeOptions } from '../../scripts/services/badge.service';
import { expect } from "chai";
import { badgeService, BadgeData, BadgeOptions } from "../../scripts/services/badge.service";
describe('Badge Service', function () {
describe("Badge Service", function () {
const mockBadgeData: BadgeData = {
contentHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
contentHash: "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
verified: true,
timestamp: new Date('2025-01-01T00:00:00Z'),
platform: 'youtube',
creator: '0xABCDEF1234567890',
timestamp: new Date("2025-01-01T00:00:00Z"),
platform: "youtube",
creator: "0xABCDEF1234567890",
};
describe('generateBadgeSVG', function () {
it('should generate a valid SVG with default options', function () {
describe("generateBadgeSVG", function () {
it("should generate a valid SVG with default options", function () {
const svg = badgeService.generateBadgeSVG(mockBadgeData);
expect(svg).to.be.a('string');
expect(svg).to.be.a("string");
expect(svg).to.include('<?xml version="1.0"');
expect(svg).to.include('<svg');
expect(svg).to.include('</svg>');
expect(svg).to.include('Verified');
expect(svg).to.include("<svg");
expect(svg).to.include("</svg>");
expect(svg).to.include("Verified");
});
it('should include content hash in SVG', function () {
it("should include content hash in SVG", function () {
const svg = badgeService.generateBadgeSVG(mockBadgeData);
// Hash is truncated as "0x12345678…abcdef"
expect(svg).to.include('0x12345678…abcdef');
expect(svg).to.include("0x12345678…abcdef");
});
it('should apply dark theme colors by default', function () {
it("should apply dark theme colors by default", function () {
const svg = badgeService.generateBadgeSVG(mockBadgeData);
expect(svg).to.include('fill="#0b0f1a"'); // dark bg
expect(svg).to.include('fill="#9ef"'); // dark fg
});
it('should apply light theme colors', function () {
const options: BadgeOptions = { theme: 'light' };
it("should apply light theme colors", function () {
const options: BadgeOptions = { theme: "light" };
const svg = badgeService.generateBadgeSVG(mockBadgeData, options);
expect(svg).to.include('fill="#ffffff"'); // light bg
expect(svg).to.include('fill="#0b0f1a"'); // light fg
});
it('should apply blue theme colors', function () {
const options: BadgeOptions = { theme: 'blue' };
it("should apply blue theme colors", function () {
const options: BadgeOptions = { theme: "blue" };
const svg = badgeService.generateBadgeSVG(mockBadgeData, options);
expect(svg).to.include('fill="#1a237e"');
});
it('should use medium size by default', function () {
it("should use medium size by default", function () {
const svg = badgeService.generateBadgeSVG(mockBadgeData);
expect(svg).to.include('width="240"');
expect(svg).to.include('height="32"');
});
it('should apply small size preset', function () {
const options: BadgeOptions = { size: 'small' };
it("should apply small size preset", function () {
const options: BadgeOptions = { size: "small" };
const svg = badgeService.generateBadgeSVG(mockBadgeData, options);
expect(svg).to.include('width="180"');
});
it('should apply large size preset', function () {
const options: BadgeOptions = { size: 'large' };
it("should apply large size preset", function () {
const options: BadgeOptions = { size: "large" };
const svg = badgeService.generateBadgeSVG(mockBadgeData, options);
expect(svg).to.include('width="320"');
});
it('should apply custom numeric size', function () {
it("should apply custom numeric size", function () {
const options: BadgeOptions = { size: 300 };
const svg = badgeService.generateBadgeSVG(mockBadgeData, options);
expect(svg).to.include('width="300"');
});
it('should apply rounded style by default', function () {
it("should apply rounded style by default", function () {
const svg = badgeService.generateBadgeSVG(mockBadgeData);
expect(svg).to.match(/rx="\d+"/);
});
it('should apply flat style (no border radius)', function () {
const options: BadgeOptions = { style: 'flat' };
it("should apply flat style (no border radius)", function () {
const options: BadgeOptions = { style: "flat" };
const svg = badgeService.generateBadgeSVG(mockBadgeData, options);
expect(svg).to.include('rx="0"');
});
it('should apply pill style (full border radius)', function () {
const options: BadgeOptions = { style: 'pill' };
it("should apply pill style (full border radius)", function () {
const options: BadgeOptions = { style: "pill" };
const svg = badgeService.generateBadgeSVG(mockBadgeData, options);
expect(svg).to.match(/rx="\d+"/);
});
it('should show minimal badge with only checkmark', function () {
const options: BadgeOptions = { style: 'minimal' };
it("should show minimal badge with only checkmark", function () {
const options: BadgeOptions = { style: "minimal" };
const svg = badgeService.generateBadgeSVG(mockBadgeData, options);
expect(svg).to.include('✓');
expect(svg).to.include("✓");
// The hash is still in the SVG title/aria-label, but not in the badge text
// Check that the badge text is just the checkmark (not "Verified" or hash)
expect(svg).to.match(/>✓<\/text>/);
});
it('should show timestamp when enabled', function () {
it("should show timestamp when enabled", function () {
const options: BadgeOptions = { showTimestamp: true };
const svg = badgeService.generateBadgeSVG(mockBadgeData, options);
expect(svg).to.include('2025-01-01');
expect(svg).to.include("2025-01-01");
});
it('should not show timestamp by default', function () {
it("should not show timestamp by default", function () {
const svg = badgeService.generateBadgeSVG(mockBadgeData);
expect(svg).not.to.include('2025-01-01');
expect(svg).not.to.include("2025-01-01");
});
it('should show platform when enabled', function () {
it("should show platform when enabled", function () {
const options: BadgeOptions = { showPlatform: true };
const svg = badgeService.generateBadgeSVG(mockBadgeData, options);
expect(svg).to.include('youtube');
expect(svg).to.include("youtube");
});
it('should handle unverified content', function () {
it("should handle unverified content", function () {
const unverifiedData: BadgeData = {
...mockBadgeData,
verified: false,
};
const svg = badgeService.generateBadgeSVG(unverifiedData);
expect(svg).to.include('✗');
expect(svg).to.include('Unverified');
expect(svg).to.include("✗");
expect(svg).to.include("Unverified");
});
});
describe('generateEmbedHTML', function () {
const badgeUrl = 'https://example.com/badge.svg';
const targetUrl = 'https://example.com/verify';
describe("generateEmbedHTML", function () {
const badgeUrl = "https://example.com/badge.svg";
const targetUrl = "https://example.com/verify";
it('should generate valid HTML embed code', function () {
it("should generate valid HTML embed code", function () {
const html = badgeService.generateEmbedHTML(badgeUrl, targetUrl);
expect(html).to.include('<a href=');
expect(html).to.include("<a href=");
expect(html).to.include(badgeUrl);
expect(html).to.include(targetUrl);
expect(html).to.include('<img');
expect(html).to.include('alt=');
expect(html).to.include("<img");
expect(html).to.include("alt=");
});
it('should include custom alt text', function () {
it("should include custom alt text", function () {
const html = badgeService.generateEmbedHTML(badgeUrl, targetUrl, {
alt: 'Custom Alt Text',
alt: "Custom Alt Text",
});
expect(html).to.include('alt="Custom Alt Text"');
});
it('should include custom title', function () {
it("should include custom title", function () {
const html = badgeService.generateEmbedHTML(badgeUrl, targetUrl, {
title: 'Custom Title',
title: "Custom Title",
});
expect(html).to.include('title="Custom Title"');
@@ -178,50 +178,50 @@ describe('Badge Service', function () {
});
});
describe('generateEmbedMarkdown', function () {
const badgeUrl = 'https://example.com/badge.svg';
const targetUrl = 'https://example.com/verify';
describe("generateEmbedMarkdown", function () {
const badgeUrl = "https://example.com/badge.svg";
const targetUrl = "https://example.com/verify";
it('should generate valid Markdown embed code', function () {
it("should generate valid Markdown embed code", function () {
const markdown = badgeService.generateEmbedMarkdown(badgeUrl, targetUrl);
expect(markdown).to.include('[![');
expect(markdown).to.include('](');
expect(markdown).to.include("[![");
expect(markdown).to.include("](");
expect(markdown).to.include(badgeUrl);
expect(markdown).to.include(targetUrl);
});
it('should include custom alt text', function () {
it("should include custom alt text", function () {
const markdown = badgeService.generateEmbedMarkdown(badgeUrl, targetUrl, {
alt: 'Custom Alt',
alt: "Custom Alt",
});
expect(markdown).to.include('Custom Alt');
expect(markdown).to.include("Custom Alt");
});
it('should follow Markdown badge syntax', function () {
it("should follow Markdown badge syntax", function () {
const markdown = badgeService.generateEmbedMarkdown(badgeUrl, targetUrl);
expect(markdown).to.match(/\[!\[.*\]\(.*\)\]\(.*\)/);
});
});
describe('generateEmbedSnippets', function () {
const badgeUrl = 'https://example.com/badge.svg';
const verifyUrl = 'https://example.com/verify';
const contentHash = '0x1234567890abcdef';
describe("generateEmbedSnippets", function () {
const badgeUrl = "https://example.com/badge.svg";
const verifyUrl = "https://example.com/verify";
const contentHash = "0x1234567890abcdef";
it('should generate all embed snippet types', function () {
it("should generate all embed snippet types", function () {
const snippets = badgeService.generateEmbedSnippets(badgeUrl, verifyUrl, contentHash);
expect(snippets).to.have.property('html');
expect(snippets).to.have.property('markdown');
expect(snippets).to.have.property('direct');
expect(snippets).to.have.property('verify');
expect(snippets).to.have.property('contentHash');
expect(snippets).to.have.property("html");
expect(snippets).to.have.property("markdown");
expect(snippets).to.have.property("direct");
expect(snippets).to.have.property("verify");
expect(snippets).to.have.property("contentHash");
});
it('should include correct URLs in snippets', function () {
it("should include correct URLs in snippets", function () {
const snippets = badgeService.generateEmbedSnippets(badgeUrl, verifyUrl, contentHash);
expect(snippets.direct).to.equal(badgeUrl);
@@ -229,83 +229,83 @@ describe('Badge Service', function () {
expect(snippets.contentHash).to.equal(contentHash);
});
it('should generate valid HTML snippet', function () {
it("should generate valid HTML snippet", function () {
const snippets = badgeService.generateEmbedSnippets(badgeUrl, verifyUrl, contentHash);
expect(snippets.html).to.include('<a href=');
expect(snippets.html).to.include('<img src=');
expect(snippets.html).to.include("<a href=");
expect(snippets.html).to.include("<img src=");
});
it('should generate valid Markdown snippet', function () {
it("should generate valid Markdown snippet", function () {
const snippets = badgeService.generateEmbedSnippets(badgeUrl, verifyUrl, contentHash);
expect(snippets.markdown).to.include('[![');
expect(snippets.markdown).to.include('](');
expect(snippets.markdown).to.include("[![");
expect(snippets.markdown).to.include("](");
});
});
describe('validateBadgeOptions', function () {
it('should validate valid theme options', function () {
const result = badgeService.validateBadgeOptions({ theme: 'dark' });
expect(result.theme).to.equal('dark');
describe("validateBadgeOptions", function () {
it("should validate valid theme options", function () {
const result = badgeService.validateBadgeOptions({ theme: "dark" });
expect(result.theme).to.equal("dark");
});
it('should reject invalid theme options', function () {
const result = badgeService.validateBadgeOptions({ theme: 'invalid' });
it("should reject invalid theme options", function () {
const result = badgeService.validateBadgeOptions({ theme: "invalid" });
expect(result.theme).to.be.undefined;
});
it('should validate size preset options', function () {
const result = badgeService.validateBadgeOptions({ size: 'medium' });
expect(result.size).to.equal('medium');
it("should validate size preset options", function () {
const result = badgeService.validateBadgeOptions({ size: "medium" });
expect(result.size).to.equal("medium");
});
it('should validate numeric size within range', function () {
const result = badgeService.validateBadgeOptions({ size: '300' });
it("should validate numeric size within range", function () {
const result = badgeService.validateBadgeOptions({ size: "300" });
expect(result.size).to.equal(300);
});
it('should reject numeric size below minimum', function () {
const result = badgeService.validateBadgeOptions({ size: '50' });
it("should reject numeric size below minimum", function () {
const result = badgeService.validateBadgeOptions({ size: "50" });
expect(result.size).to.be.undefined;
});
it('should reject numeric size above maximum', function () {
const result = badgeService.validateBadgeOptions({ size: '1000' });
it("should reject numeric size above maximum", function () {
const result = badgeService.validateBadgeOptions({ size: "1000" });
expect(result.size).to.be.undefined;
});
it('should validate valid style options', function () {
const result = badgeService.validateBadgeOptions({ style: 'pill' });
expect(result.style).to.equal('pill');
it("should validate valid style options", function () {
const result = badgeService.validateBadgeOptions({ style: "pill" });
expect(result.style).to.equal("pill");
});
it('should reject invalid style options', function () {
const result = badgeService.validateBadgeOptions({ style: 'invalid' });
it("should reject invalid style options", function () {
const result = badgeService.validateBadgeOptions({ style: "invalid" });
expect(result.style).to.be.undefined;
});
it('should parse boolean string for showTimestamp', function () {
const result = badgeService.validateBadgeOptions({ showTimestamp: 'true' });
it("should parse boolean string for showTimestamp", function () {
const result = badgeService.validateBadgeOptions({ showTimestamp: "true" });
expect(result.showTimestamp).to.be.true;
});
it('should parse actual boolean for showTimestamp', function () {
it("should parse actual boolean for showTimestamp", function () {
const result = badgeService.validateBadgeOptions({ showTimestamp: true });
expect(result.showTimestamp).to.be.true;
});
it('should parse boolean string for showPlatform', function () {
const result = badgeService.validateBadgeOptions({ showPlatform: 'true' });
it("should parse boolean string for showPlatform", function () {
const result = badgeService.validateBadgeOptions({ showPlatform: "true" });
expect(result.showPlatform).to.be.true;
});
it('should accept string platform value', function () {
const result = badgeService.validateBadgeOptions({ platform: 'youtube' });
expect(result.platform).to.equal('youtube');
it("should accept string platform value", function () {
const result = badgeService.validateBadgeOptions({ platform: "youtube" });
expect(result.platform).to.equal("youtube");
});
it('should return empty object for no options', function () {
it("should return empty object for no options", function () {
const result = badgeService.validateBadgeOptions({});
expect(Object.keys(result)).to.have.length.at.least(0);
});

View File

@@ -58,10 +58,10 @@ describe("JWT Service", () => {
it("should return null for tampered token", () => {
const payload = { userId: "test-user" };
const token = generateJwtToken(payload);
// Tamper with the token by replacing characters in the signature
const parts = token.split(".");
parts[2] = 'x'.repeat(parts[2].length);
parts[2] = "x".repeat(parts[2].length);
const tamperedToken = parts.join(".");
const decoded = verifyJwtToken(tamperedToken);

View File

@@ -149,5 +149,5 @@ For accessibility-related questions or concerns, please contact:
---
*Last updated: October 30, 2025*
*WCAG 2.1 Level AA Conformance*
_Last updated: October 30, 2025_
_WCAG 2.1 Level AA Conformance_

View File

@@ -23,6 +23,7 @@ npm run audit:a11y
```
This checks for:
- ARIA labels on buttons
- Alt text on images
- Form input labels
@@ -45,6 +46,7 @@ npm run test:e2e:headed -- 07-accessibility.spec.ts
```
The test suite covers:
- Document structure and landmarks
- Heading hierarchy
- Skip-to-content link
@@ -68,6 +70,7 @@ npm run perf:collect
```
Lighthouse checks:
- Accessibility score (must be 90+)
- ARIA attribute validity
- Color contrast ratios
@@ -169,11 +172,13 @@ Lighthouse checks:
Use this checklist when implementing new features:
### Semantic HTML
- [ ] Use proper heading hierarchy (h1, h2, h3, etc.)
- [ ] Use semantic elements (main, nav, section, article, etc.)
- [ ] Use landmark roles or ARIA landmarks
### ARIA Attributes
- [ ] Add aria-label to icons and icon-only buttons
- [ ] Use aria-live for dynamic content updates
- [ ] Use aria-pressed for toggle buttons
@@ -181,29 +186,34 @@ Use this checklist when implementing new features:
- [ ] Use role="status" for loading indicators
### Forms
- [ ] All inputs have associated labels
- [ ] Required fields are marked with aria-required
- [ ] Error messages are linked with aria-describedby
- [ ] Form validation provides clear feedback
### Keyboard Navigation
- [ ] All interactive elements are keyboard accessible
- [ ] Focus indicators are visible and clear
- [ ] Tab order is logical
- [ ] Escape key closes modals/dialogs
### Images and Media
- [ ] All images have descriptive alt text
- [ ] Decorative images use alt="" or aria-hidden="true"
- [ ] Complex images have detailed descriptions
### Color and Contrast
- [ ] Text meets 4.5:1 contrast ratio (normal text)
- [ ] Large text meets 3:1 contrast ratio (18pt+)
- [ ] Interactive elements meet 3:1 contrast ratio
- [ ] Color is not the only way to convey information
### Dynamic Content
- [ ] Loading states are announced to screen readers
- [ ] Success/error messages are announced
- [ ] Content updates don't cause unexpected focus changes
@@ -213,6 +223,7 @@ Use this checklist when implementing new features:
### Issue: Focus indicator not visible
**Solution:** Ensure CSS includes proper focus styles:
```css
*:focus-visible {
outline: 3px solid #1d4ed8;
@@ -223,6 +234,7 @@ Use this checklist when implementing new features:
### Issue: Screen reader not announcing updates
**Solution:** Add aria-live region:
```tsx
<div aria-live="polite" aria-atomic="true">
{message}
@@ -232,6 +244,7 @@ Use this checklist when implementing new features:
### Issue: Button without accessible name
**Solution:** Add aria-label:
```tsx
<button aria-label="Close dialog" onClick={onClose}>
×
@@ -241,6 +254,7 @@ Use this checklist when implementing new features:
### Issue: Form input without label
**Solution:** Associate label with input:
```tsx
<label htmlFor="email-input">Email</label>
<input id="email-input" type="email" />
@@ -249,15 +263,18 @@ Use this checklist when implementing new features:
## Resources
### Official Guidelines
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
- [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/)
### Testing Tools
- [axe DevTools](https://www.deque.com/axe/devtools/)
- [WAVE Browser Extension](https://wave.webaim.org/extension/)
- [Lighthouse](https://developers.google.com/web/tools/lighthouse)
### Learning Resources
- [WebAIM](https://webaim.org/)
- [The A11Y Project](https://www.a11yproject.com/)
- [MDN Accessibility](https://developer.mozilla.org/en-US/docs/Web/Accessibility)
@@ -286,4 +303,4 @@ Accessibility is an ongoing effort. We:
---
*For questions, contact: support@subculture.io*
_For questions, contact: support@subculture.io_

View File

@@ -81,6 +81,7 @@ npm run test:e2e
### Test Modes
#### Headless Mode (Default)
Tests run in background without visible browser:
```bash
@@ -88,6 +89,7 @@ npm run test:e2e
```
#### Headed Mode
Watch tests run in real browsers:
```bash
@@ -95,6 +97,7 @@ npm run test:e2e:headed
```
#### UI Mode (Interactive)
Best for development - interactive test runner with time-travel debugging:
```bash
@@ -102,6 +105,7 @@ npm run test:e2e:ui
```
#### Debug Mode
Run tests with Playwright Inspector for step-by-step debugging:
```bash
@@ -197,6 +201,7 @@ npm run test:e2e:debug
```
Features:
- Step through each test action
- Pick selectors from the page
- View console logs
@@ -211,6 +216,7 @@ npm run test:e2e:ui
```
Features:
- Watch tests run in real-time
- See test timeline
- Pick locators interactively
@@ -239,6 +245,7 @@ npx playwright test --video on
```
Find artifacts in:
- `test-results/` - Screenshots, videos, traces
- `playwright-report/` - HTML test report
@@ -247,9 +254,9 @@ Find artifacts in:
View browser console during tests:
```typescript
test('debug example', async ({ page }) => {
page.on('console', msg => console.log(msg.text()));
await page.goto('/');
test("debug example", async ({ page }) => {
page.on("console", (msg) => console.log(msg.text()));
await page.goto("/");
});
```
@@ -258,8 +265,8 @@ test('debug example', async ({ page }) => {
Add breakpoints in tests:
```typescript
test('debug example', async ({ page }) => {
await page.goto('/');
test("debug example", async ({ page }) => {
await page.goto("/");
await page.pause(); // Opens inspector
// ... rest of test
});
@@ -270,18 +277,18 @@ test('debug example', async ({ page }) => {
### Basic Test Structure
```typescript
import { test, expect } from '@playwright/test';
import { test, expect } from "@playwright/test";
test.describe('Feature Name', () => {
test('should do something', async ({ page }) => {
test.describe("Feature Name", () => {
test("should do something", async ({ page }) => {
// Navigate to page
await page.goto('/');
await page.goto("/");
// Perform actions
await page.click('button');
await page.click("button");
// Assert expectations
await expect(page.locator('h1')).toBeVisible();
await expect(page.locator("h1")).toBeVisible();
});
});
```
@@ -291,53 +298,57 @@ test.describe('Feature Name', () => {
Import utilities from `test-helpers.ts`:
```typescript
import { waitForApiResponse, expectVisible } from './utils/test-helpers';
import { waitForApiResponse, expectVisible } from "./utils/test-helpers";
test("example", async ({ page }) => {
await page.goto("/");
test('example', async ({ page }) => {
await page.goto('/');
// Wait for API response
await waitForApiResponse(page, '/api/contents');
await waitForApiResponse(page, "/api/contents");
// Check visibility
await expectVisible(page, '.content-list');
await expectVisible(page, ".content-list");
});
```
### Best Practices
1. **Use data-testid for stability**:
```typescript
await page.locator('[data-testid="submit-button"]').click();
```
2. **Wait for navigation**:
```typescript
await page.waitForURL('/dashboard');
await page.waitForURL("/dashboard");
```
3. **Handle async operations**:
```typescript
await page.waitForLoadState('networkidle');
await page.waitForLoadState("networkidle");
```
4. **Use descriptive selectors**:
```typescript
// Good
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole("button", { name: "Submit" }).click();
// Avoid
await page.locator('button').nth(2).click();
await page.locator("button").nth(2).click();
```
5. **Group related tests**:
```typescript
test.describe('Authentication', () => {
test.describe('Sign In', () => {
test.describe("Authentication", () => {
test.describe("Sign In", () => {
// Sign in tests
});
test.describe('Sign Up', () => {
test.describe("Sign Up", () => {
// Sign up tests
});
});
@@ -362,28 +373,28 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: "20"
- name: Install dependencies
run: |
npm ci --legacy-peer-deps
cd web && npm ci --legacy-peer-deps
- name: Install Playwright browsers
working-directory: web
run: npx playwright install --with-deps
- name: Start API server
run: npm run start:api &
- name: Run E2E tests
working-directory: web
run: npm run test:e2e
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
@@ -424,7 +435,7 @@ Each test should be independent:
```typescript
test.beforeEach(async ({ page }) => {
// Setup for each test
await page.goto('/');
await page.goto("/");
});
test.afterEach(async ({ page }) => {
@@ -440,11 +451,11 @@ For complex pages, use page object pattern:
```typescript
class DashboardPage {
constructor(private page: Page) {}
async navigateTo() {
await this.page.goto('/dashboard');
await this.page.goto("/dashboard");
}
async getContentCount() {
return await this.page.locator('[data-testid="content-item"]').count();
}
@@ -457,13 +468,13 @@ Make tests more stable:
```typescript
// Use waitFor instead of static timeouts
await page.waitForSelector('.content', { state: 'visible' });
await page.waitForSelector(".content", { state: "visible" });
// Retry on failure
test.describe.configure({ retries: 2 });
// Wait for network idle
await page.waitForLoadState('networkidle');
await page.waitForLoadState("networkidle");
```
### 4. Skip Tests Conditionally
@@ -471,9 +482,9 @@ await page.waitForLoadState('networkidle');
Skip tests based on environment:
```typescript
test.skip(process.env.CI === 'true', 'Requires OAuth credentials');
test.skip(process.env.CI === "true", "Requires OAuth credentials");
test.skip(!process.env.ENABLE_UPLOADS, 'Upload tests disabled');
test.skip(!process.env.ENABLE_UPLOADS, "Upload tests disabled");
```
### 5. Organize Test Data
@@ -481,9 +492,9 @@ test.skip(!process.env.ENABLE_UPLOADS, 'Upload tests disabled');
Use fixtures for test data:
```typescript
import { testData } from './fixtures/users';
import { testData } from "./fixtures/users";
test('example', async ({ page }) => {
test("example", async ({ page }) => {
await page.fill('[name="email"]', testData.user1.email);
});
```
@@ -497,6 +508,7 @@ test('example', async ({ page }) => {
**Problem**: Tests exceed timeout limit
**Solutions**:
```bash
# Increase timeout
npx playwright test --timeout=60000
@@ -512,6 +524,7 @@ use: {
**Problem**: `Executable doesn't exist` error
**Solution**:
```bash
npx playwright install
```
@@ -521,6 +534,7 @@ npx playwright install
**Problem**: Dev server can't start on port 3000
**Solution**:
```bash
# Kill process on port 3000
lsof -ti:3000 | xargs kill -9
@@ -534,6 +548,7 @@ BASE_URL=http://localhost:3001 npm run test:e2e
**Problem**: OAuth tests fail without credentials
**Solution**: OAuth tests are skipped by default. To enable:
```bash
export GITHUB_TEST_EMAIL=test@example.com
export GITHUB_TEST_PASSWORD=testpass
@@ -545,6 +560,7 @@ npm run test:e2e
**Problem**: Screenshot comparison fails
**Solution**:
```bash
# Update baseline screenshots
npx playwright test --update-snapshots
@@ -557,13 +573,9 @@ npx playwright test --update-snapshots
Log all requests:
```typescript
page.on('request', request =>
console.log('>>', request.method(), request.url())
);
page.on("request", (request) => console.log(">>", request.method(), request.url()));
page.on('response', response =>
console.log('<<', response.status(), response.url())
);
page.on("response", (response) => console.log("<<", response.status(), response.url()));
```
#### Console Errors
@@ -571,8 +583,8 @@ page.on('response', response =>
Catch JavaScript errors:
```typescript
page.on('pageerror', error => {
console.error('Page error:', error);
page.on("pageerror", (error) => {
console.error("Page error:", error);
});
```

View File

@@ -3,6 +3,7 @@
## Implemented Optimizations
### 1. Next.js Configuration Enhancements
- **File**: `web/next.config.mjs`
- **Changes**:
- Enabled `compress: true` for gzip compression
@@ -16,6 +17,7 @@
- Added bundle analyzer support (`ANALYZE=true npm run build`)
### 2. Web Vitals Monitoring
- **Files**: `web/app/web-vitals.tsx`, `web/app/api/analytics/route.ts`
- **Changes**:
- Implemented Web Vitals tracking using Next.js built-in hooks
@@ -25,6 +27,7 @@
- Logs metrics in development, sends to analytics in production
### 3. Font Optimization
- **File**: `web/app/globals.css`
- **Changes**:
- Added `font-display: swap` to prevent FOIT (Flash of Invisible Text)
@@ -33,6 +36,7 @@
- Uses system fonts (already optimal, no external font loading)
### 3a. Security and Performance Headers
- **File**: `web/next.config.mjs`
- **Headers Added**:
- `X-DNS-Prefetch-Control: on` - Enables DNS prefetching
@@ -41,12 +45,14 @@
- `Referrer-Policy: strict-origin-when-cross-origin` - Controls referrer information
### 4. Resource Hints
- **File**: `web/app/layout.tsx`
- **Changes**:
- Added `preconnect` to IPFS gateway for faster third-party connections
- Added `dns-prefetch` as fallback for older browsers
### 5. Performance Budgets
- **File**: `web/performance-budget.json`
- **Budget Limits**:
- JavaScript: 400 KB
@@ -62,6 +68,7 @@
- Cumulative Layout Shift: 0.1
### 6. Lighthouse CI Integration
- **File**: `web/lighthouserc.json`
- **Configuration**:
- Runs 3 audits per session for consistency
@@ -70,6 +77,7 @@
- Automated assertions for Core Web Vitals
### 7. CI/CD Performance Checks
- **File**: `.github/workflows/performance.yml`
- **Features**:
- Runs on every PR affecting web code
@@ -79,6 +87,7 @@
- Prevents performance regressions
### 8. Performance Utilities
- **File**: `web/lib/performance.ts`
- **Utilities**:
- `reportWebVitals()` - Send metrics to analytics
@@ -88,6 +97,7 @@
- `observeImages()` - Lazy load images with Intersection Observer
### 9. Performance Report Script
- **File**: `web/scripts/performance-report.js`
- **Features**:
- Analyzes build output size
@@ -99,7 +109,9 @@
## Recommended Next Steps
### Code Splitting (Future Enhancement)
The main page.tsx (1683 lines) contains multiple form components that could benefit from lazy loading:
- Upload Form
- One-shot Form
- Manifest Form
@@ -113,13 +125,16 @@ The main page.tsx (1683 lines) contains multiple form components that could bene
**Recommendation**: Extract each form into separate components under `web/app/forms/` and use `next/dynamic` with `ssr: false` for client-side only forms. This would reduce initial bundle size significantly.
### Image Optimization (Future Enhancement)
- Replace `<img>` tags with Next.js `<Image>` component
- Add proper width/height attributes to prevent CLS
- Generate placeholder images for better perceived performance
- Consider using `blur` placeholder option
### Third-Party Script Optimization
Currently, no third-party scripts are detected. If added in the future:
- Use `next/script` with appropriate loading strategy (`defer` or `async`)
- Load analytics scripts after user interaction
- Consider using Partytown for heavy third-party scripts
@@ -127,11 +142,13 @@ Currently, no third-party scripts are detected. If added in the future:
## Core Web Vitals Targets
### Current Status
- ✅ Configuration in place to achieve targets
- ✅ Monitoring enabled
- ✅ CI checks configured
### Targets
- **LCP (Largest Contentful Paint)**: < 2.5s
- Current optimizations: Image optimization, font optimization, compression
- **FID (First Input Delay)**: < 100ms
@@ -142,10 +159,12 @@ Currently, no third-party scripts are detected. If added in the future:
## Monitoring & Analytics
### Development
- Web Vitals metrics logged to console
- Bundle analyzer available via `npm run build:analyze`
### Production (To Implement)
- Configure analytics service integration in `/api/analytics/route.ts`
- Options: Google Analytics 4, Vercel Analytics, or custom solution
- Set up Real User Monitoring (RUM) dashboard
@@ -153,6 +172,7 @@ Currently, no third-party scripts are detected. If added in the future:
## Performance Testing
### Local Testing
```bash
# Build with bundle analysis
npm run build:analyze
@@ -162,6 +182,7 @@ npm run perf:audit
```
### CI Testing
- Automated on every PR
- Performance budgets enforced
- Bundle size regression detection
@@ -169,10 +190,12 @@ npm run perf:audit
## CDN & Caching Strategy
### Static Assets
- Cache-Control headers configured for 1 year (immutable)
- Manifest cached for 24 hours with revalidation
### Recommended CDN Configuration
- Serve static assets from edge locations
- Enable Brotli compression (better than gzip)
- Use HTTP/2 or HTTP/3

View File

@@ -1,27 +1,32 @@
# Responsive Design Implementation
## Overview
This document outlines the responsive design features implemented across the Internet-ID web application.
## Features Implemented
### 1. Viewport Configuration
- ✅ Meta viewport tag added via Next.js `viewport` export
- ✅ Proper device-width and initial-scale settings
- ✅ User scalable enabled for accessibility (max scale: 5x)
- ✅ Theme color meta tag for mobile browsers
### 2. Mobile-First CSS
- ✅ All base styles designed for mobile (320px+)
- ✅ Progressive enhancement for larger screens using media queries
- ✅ Breakpoints: 640px (tablet), 768px (desktop)
### 3. Touch Targets
- ✅ All interactive elements minimum 44x44px (WCAG AAA compliant)
- ✅ Buttons, inputs, and selects properly sized
- ✅ Touch-friendly spacing and padding
### 4. Responsive Forms
- ✅ Full-width inputs on mobile
- ✅ 16px font size prevents iOS zoom on focus
- ✅ Proper keyboard handling for mobile
@@ -29,23 +34,27 @@ This document outlines the responsive design features implemented across the Int
- ✅ Textarea with vertical resize only
### 5. No Horizontal Scrolling
-`overflow-x: hidden` on body
- ✅ All content constrained to viewport width
- ✅ Word wrapping for long text
- ✅ Pre/code blocks with horizontal scroll only when needed
### 6. Responsive Navigation
- ✅ Tab navigation wraps on mobile
- ✅ Horizontal scroll fallback with touch support
- ✅ Thin scrollbar for cleaner appearance
### 7. Images and Media
-`max-width: 100%` on all images
-`height: auto` to maintain aspect ratio
- ✅ Responsive QR codes and badges
- ✅ Iframe constraints
### 8. PWA Support
- ✅ Web app manifest (`manifest.webmanifest`)
- ✅ Proper manifest metadata
- ✅ App shortcuts defined
@@ -53,8 +62,9 @@ This document outlines the responsive design features implemented across the Int
- ⚠️ Icons need to be created (placeholders exist)
### 9. Tested Viewports
- ✅ 320px - iPhone SE, small phones
- ✅ 375px - iPhone 6/7/8, standard phones
- ✅ 375px - iPhone 6/7/8, standard phones
- ✅ 768px - iPad portrait, tablets
- ✅ 1024px - iPad landscape, small laptops
- ✅ 1920px - Desktop monitors
@@ -62,6 +72,7 @@ This document outlines the responsive design features implemented across the Int
## Testing Checklist
### Mobile (320px - 640px)
- [x] No horizontal scrolling
- [x] Touch targets are 44x44px minimum
- [x] Text is readable without zoom
@@ -71,24 +82,28 @@ This document outlines the responsive design features implemented across the Int
- [x] Images scale correctly
### Tablet (640px - 1024px)
- [x] Layout adapts to wider screen
- [x] Forms use available space efficiently
- [x] Navigation displays horizontally when space allows
- [x] Two-column layouts where appropriate
### Desktop (1024px+)
- [x] Content centered with max-width
- [x] Optimal reading width maintained
- [x] All features accessible
- [x] Hover states work correctly
## Browser Compatibility
- ✅ Modern browsers (Chrome, Firefox, Safari, Edge)
- ✅ Mobile Safari (iOS 12+)
- ✅ Chrome Android
- ⚠️ IE11 not supported (Next.js 15 requirement)
## Accessibility Features
- ✅ Semantic HTML
- ✅ Proper heading hierarchy
- ✅ ARIA labels where needed
@@ -97,12 +112,14 @@ This document outlines the responsive design features implemented across the Int
- ✅ Text scaling support up to 5x
## Performance Optimizations
- ✅ CSS media queries for conditional styling
- ✅ Mobile-first approach reduces initial CSS
- ✅ Lazy loading ready (Next.js Image component available)
- ✅ Minimal JavaScript for responsive behavior
## Future Enhancements
- [ ] Create actual PWA icons (192x192, 512x512)
- [ ] Add service worker for offline support
- [ ] Implement image lazy loading for uploaded content
@@ -114,18 +131,23 @@ This document outlines the responsive design features implemented across the Int
## Screenshots
### Mobile (375px)
![Mobile Verify Page](https://github.com/user-attachments/assets/3e307ffc-697b-4a63-9f92-1d16b4f92e55)
### Mobile (320px)
### Mobile (320px)
![Mobile Verify Page 320px](https://github.com/user-attachments/assets/d8b8d49a-f309-4032-81a1-97451f3a235d)
### Tablet (768px)
![Tablet Verify Page](https://github.com/user-attachments/assets/600e8a91-b0ba-4a47-abb3-eac4113a73f5)
### Mobile Sign In
![Mobile Sign In](https://github.com/user-attachments/assets/cf33e15a-3700-48e0-b7f1-558099cbc9eb)
## Notes
- Font size of 16px on inputs prevents iOS auto-zoom
- Flex-wrap ensures content doesn't overflow on narrow screens
- Max-width constraints prevent excessive line length on large screens

View File

@@ -1,28 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextRequest, NextResponse } from "next/server";
// Use the edge runtime for lower latency and global distribution of analytics endpoint.
// This enables faster response times for users worldwide and efficient handling of analytics events.
export const runtime = 'edge';
export const runtime = "edge";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Log Web Vitals metrics
// In production, send to your analytics service (e.g., Google Analytics, Vercel Analytics, etc.)
console.log('[Analytics] Web Vitals:', {
console.log("[Analytics] Web Vitals:", {
name: body.name,
value: body.value,
rating: body.rating,
id: body.id,
});
// TODO: Send to analytics service
// Example: await sendToAnalytics(body);
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error('[Analytics] Error:', error);
console.error("[Analytics] Error:", error);
return NextResponse.json({ success: false }, { status: 500 });
}
}

View File

@@ -1,9 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ hash: string }> }
) {
export async function GET(req: NextRequest, { params }: { params: Promise<{ hash: string }> }) {
const { hash } = await params;
const short = hash && hash.length > 20 ? `${hash.slice(0, 10)}${hash.slice(-6)}` : hash;
const sp = req.nextUrl.searchParams;

View File

@@ -5,15 +5,12 @@ import { ToastMessage, ToastType } from "../components/Toast";
export function useToast() {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const addToast = useCallback(
(message: string, type: ToastType = "info", duration?: number) => {
const id = crypto.randomUUID();
const newToast: ToastMessage = { id, message, type, duration };
setToasts((prev) => [...prev, newToast]);
return id;
},
[]
);
const addToast = useCallback((message: string, type: ToastType = "info", duration?: number) => {
const id = crypto.randomUUID();
const newToast: ToastMessage = { id, message, type, duration };
setToasts((prev) => [...prev, newToast]);
return id;
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));

View File

@@ -1,14 +1,14 @@
import { MetadataRoute } from 'next';
import { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const siteUrl = process.env.NEXT_PUBLIC_SITE_BASE || 'https://internet-id.io';
const siteUrl = process.env.NEXT_PUBLIC_SITE_BASE || "https://internet-id.io";
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/dashboard/', '/profile/'],
userAgent: "*",
allow: "/",
disallow: ["/api/", "/dashboard/", "/profile/"],
},
],
sitemap: `${siteUrl}/sitemap.xml`,

View File

@@ -1,20 +1,14 @@
import { MetadataRoute } from 'next';
import { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
const siteUrl = process.env.NEXT_PUBLIC_SITE_BASE || 'https://internet-id.io';
const siteUrl = process.env.NEXT_PUBLIC_SITE_BASE || "https://internet-id.io";
// Define all public pages
const routes = [
'',
'/verify',
'/badges',
'/signin',
'/register',
].map((route) => ({
const routes = ["", "/verify", "/badges", "/signin", "/register"].map((route) => ({
url: `${siteUrl}${route}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: route === '' ? 1.0 : 0.8,
changeFrequency: "weekly" as const,
priority: route === "" ? 1.0 : 0.8,
}));
return routes;

View File

@@ -1,22 +1,22 @@
import { test, expect } from '@playwright/test';
import { test, expect } from "@playwright/test";
test.describe("Navigation and Home Page", () => {
test("should load home page successfully", async ({ page }) => {
await page.goto("/");
test.describe('Navigation and Home Page', () => {
test('should load home page successfully', async ({ page }) => {
await page.goto('/');
// Check that the page loads
await expect(page).toHaveTitle(/Internet-ID|Internet ID/i);
// Check for key elements on home page
await expect(page.locator('body')).toBeVisible();
await expect(page.locator("body")).toBeVisible();
});
test('should navigate to dashboard page', async ({ page }) => {
await page.goto('/');
test("should navigate to dashboard page", async ({ page }) => {
await page.goto("/");
// Try to find dashboard link
const dashboardLink = page.getByRole('link', { name: /dashboard/i });
const dashboardLink = page.getByRole("link", { name: /dashboard/i });
// If dashboard link exists, click it and verify navigation
const dashboardLinkCount = await dashboardLink.count();
if (dashboardLinkCount > 0) {
@@ -25,29 +25,29 @@ test.describe('Navigation and Home Page', () => {
}
});
test('should navigate to verify page', async ({ page }) => {
await page.goto('/');
test("should navigate to verify page", async ({ page }) => {
await page.goto("/");
// Try to find verify link
const verifyLink = page.getByRole('link', { name: /verify/i });
const verifyLink = page.getByRole("link", { name: /verify/i });
const verifyLinkCount = await verifyLink.count();
if (verifyLinkCount > 0) {
await verifyLink.first().click();
await expect(page).toHaveURL(/\/verify/);
} else {
// Navigate directly if no link found
await page.goto('/verify');
await page.goto("/verify");
await expect(page).toHaveURL(/\/verify/);
}
});
test('should navigate to profile page', async ({ page }) => {
await page.goto('/');
test("should navigate to profile page", async ({ page }) => {
await page.goto("/");
// Try to find profile link
const profileLink = page.getByRole('link', { name: /profile/i });
const profileLink = page.getByRole("link", { name: /profile/i });
const profileLinkCount = await profileLink.count();
if (profileLinkCount > 0) {
await profileLink.first().click();
@@ -56,25 +56,25 @@ test.describe('Navigation and Home Page', () => {
}
});
test('should have responsive design', async ({ page }) => {
await page.goto('/');
test("should have responsive design", async ({ page }) => {
await page.goto("/");
// Test desktop viewport
await page.setViewportSize({ width: 1920, height: 1080 });
await expect(page.locator('body')).toBeVisible();
await expect(page.locator("body")).toBeVisible();
// Test tablet viewport
await page.setViewportSize({ width: 768, height: 1024 });
await expect(page.locator('body')).toBeVisible();
await expect(page.locator("body")).toBeVisible();
// Test mobile viewport
await page.setViewportSize({ width: 375, height: 667 });
await expect(page.locator('body')).toBeVisible();
await expect(page.locator("body")).toBeVisible();
});
test('should handle 404 page', async ({ page }) => {
const response = await page.goto('/non-existent-page-12345');
test("should handle 404 page", async ({ page }) => {
const response = await page.goto("/non-existent-page-12345");
// Next.js may return 404 status or redirect
if (response) {
const status = response.status();

View File

@@ -1,28 +1,28 @@
import { test, expect } from '@playwright/test';
import { isCI } from './utils/test-helpers';
import { test, expect } from "@playwright/test";
import { isCI } from "./utils/test-helpers";
test.describe("Authentication Flow", () => {
test("should display sign-in page", async ({ page }) => {
await page.goto("/signin");
test.describe('Authentication Flow', () => {
test('should display sign-in page', async ({ page }) => {
await page.goto('/signin');
// Check that sign-in page loads
await expect(page).toHaveURL(/\/signin/);
// Look for authentication options (GitHub, Google, etc.)
const signInButtons = page.locator('button, a').filter({
const signInButtons = page.locator("button, a").filter({
hasText: /sign in|login|github|google/i,
});
// At least one authentication option should be present if OAuth is configured
// If no OAuth providers are configured, the page may be empty or show a message
const count = await signInButtons.count();
// Test passes if either:
// 1. OAuth providers are configured (count > 0), OR
// 2. Page loads without errors (even if no providers)
if (count === 0) {
// No OAuth providers configured - check for warning or empty state
const warningText = page.locator('text=/missing|configure|setup|oauth|provider/i');
const warningText = page.locator("text=/missing|configure|setup|oauth|provider/i");
const warningCount = await warningText.count();
// Either we see a configuration warning or the page is simply empty (both valid)
expect(warningCount).toBeGreaterThanOrEqual(0);
@@ -31,57 +31,55 @@ test.describe('Authentication Flow', () => {
}
});
test('should display register page', async ({ page }) => {
test("should display register page", async ({ page }) => {
// Try to navigate to register page
await page.goto('/register');
await page.goto("/register");
// Register page may redirect to signin or have its own form
await page.waitForURL(/\/(register|signin)/);
});
test('should redirect to sign-in when accessing protected pages', async ({
page,
}) => {
test("should redirect to sign-in when accessing protected pages", async ({ page }) => {
// Try to access profile page without authentication
await page.goto('/profile');
await page.goto("/profile");
// Should redirect to sign-in page or show sign-in prompt
await page.waitForURL(/\/(signin|profile)/);
// If on profile page, check if sign-in prompt is shown
const currentUrl = page.url();
if (currentUrl.includes('/profile')) {
if (currentUrl.includes("/profile")) {
// May show sign-in button or authentication prompt
const signInElement = page.locator('text=/sign in|login|authenticate/i');
const signInElement = page.locator("text=/sign in|login|authenticate/i");
const elementCount = await signInElement.count();
// Either we're redirected or see a sign-in prompt
expect(elementCount).toBeGreaterThanOrEqual(0);
}
});
test.describe('OAuth Provider Display', () => {
test('should show GitHub OAuth option', async ({ page }) => {
await page.goto('/signin');
test.describe("OAuth Provider Display", () => {
test("should show GitHub OAuth option", async ({ page }) => {
await page.goto("/signin");
// Look for GitHub sign-in button
const githubButton = page.locator('button, a').filter({
const githubButton = page.locator("button, a").filter({
hasText: /github/i,
});
const count = await githubButton.count();
if (count > 0) {
await expect(githubButton.first()).toBeVisible();
}
});
test('should show Google OAuth option', async ({ page }) => {
await page.goto('/signin');
test("should show Google OAuth option", async ({ page }) => {
await page.goto("/signin");
// Look for Google sign-in button
const googleButton = page.locator('button, a').filter({
const googleButton = page.locator("button, a").filter({
hasText: /google/i,
});
const count = await googleButton.count();
if (count > 0) {
await expect(googleButton.first()).toBeVisible();
@@ -91,32 +89,26 @@ test.describe('Authentication Flow', () => {
// Note: Actual OAuth flow testing requires test credentials and mocking
// These tests are skipped by default to avoid requiring OAuth setup
test.describe('OAuth Flow (Requires Setup)', () => {
test.skip(
isCI(),
'OAuth flow requires test credentials - skip in CI'
);
test.describe("OAuth Flow (Requires Setup)", () => {
test.skip(isCI(), "OAuth flow requires test credentials - skip in CI");
test('should initiate GitHub OAuth flow', async ({ page, context }) => {
test("should initiate GitHub OAuth flow", async ({ page, context }) => {
// This test requires GITHUB_TEST_EMAIL and GITHUB_TEST_PASSWORD environment variables
if (
!process.env.GITHUB_TEST_EMAIL ||
!process.env.GITHUB_TEST_PASSWORD
) {
if (!process.env.GITHUB_TEST_EMAIL || !process.env.GITHUB_TEST_PASSWORD) {
test.skip();
}
await page.goto('/signin');
await page.goto("/signin");
// Click GitHub sign-in button
const githubButton = page.locator('button, a').filter({
const githubButton = page.locator("button, a").filter({
hasText: /github/i,
});
if ((await githubButton.count()) > 0) {
// Listen for popup or redirect
const [popup] = await Promise.all([
context.waitForEvent('page'),
context.waitForEvent("page"),
githubButton.first().click(),
]);
@@ -129,26 +121,23 @@ test.describe('Authentication Flow', () => {
}
});
test('should initiate Google OAuth flow', async ({ page, context }) => {
test("should initiate Google OAuth flow", async ({ page, context }) => {
// This test requires GOOGLE_TEST_EMAIL and GOOGLE_TEST_PASSWORD
if (
!process.env.GOOGLE_TEST_EMAIL ||
!process.env.GOOGLE_TEST_PASSWORD
) {
if (!process.env.GOOGLE_TEST_EMAIL || !process.env.GOOGLE_TEST_PASSWORD) {
test.skip();
}
await page.goto('/signin');
await page.goto("/signin");
// Click Google sign-in button
const googleButton = page.locator('button, a').filter({
const googleButton = page.locator("button, a").filter({
hasText: /google/i,
});
if ((await googleButton.count()) > 0) {
// Listen for popup or redirect
const [popup] = await Promise.all([
context.waitForEvent('page'),
context.waitForEvent("page"),
googleButton.first().click(),
]);
@@ -162,18 +151,18 @@ test.describe('Authentication Flow', () => {
});
});
test('should handle sign-out', async ({ page }) => {
test("should handle sign-out", async ({ page }) => {
// Navigate to profile or dashboard where sign-out might be available
await page.goto('/profile');
await page.goto("/profile");
// Wait for page to load
await page.waitForLoadState('networkidle');
await page.waitForLoadState("networkidle");
// Look for sign-out button
const signOutButton = page.locator('button, a').filter({
const signOutButton = page.locator("button, a").filter({
hasText: /sign out|logout/i,
});
const count = await signOutButton.count();
if (count > 0) {
// Sign out button exists

View File

@@ -1,164 +1,167 @@
import { test, expect } from '@playwright/test';
import { test, expect } from "@playwright/test";
test.describe("Dashboard and Content Viewing", () => {
test("should load dashboard page", async ({ page }) => {
await page.goto("/dashboard");
test.describe('Dashboard and Content Viewing', () => {
test('should load dashboard page', async ({ page }) => {
await page.goto('/dashboard');
// Dashboard should load
await expect(page).toHaveURL(/\/dashboard/);
// Wait for page to be fully loaded
await page.waitForLoadState('networkidle');
await page.waitForLoadState("networkidle");
});
test('should display content list', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test("should display content list", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Look for content sections or empty state
const contentSection = page.locator('[data-testid="content-list"], .content-list, main');
await expect(contentSection).toBeVisible();
});
test('should show empty state when no content exists', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test("should show empty state when no content exists", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Look for either content items or empty state message
const hasContent = await page.locator('[data-testid="content-item"]').count();
if (hasContent === 0) {
// Should show empty state or no content message
const emptyState = page.locator('text=/no content|empty|get started|upload/i');
const emptyState = page.locator("text=/no content|empty|get started|upload/i");
const emptyStateCount = await emptyState.count();
// Either empty state exists or page is just empty (both valid)
expect(emptyStateCount).toBeGreaterThanOrEqual(0);
}
});
test('should have filter options', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test("should have filter options", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Look for filter controls
const filters = page.locator('select, [role="combobox"], button').filter({
hasText: /filter|status|platform|sort/i,
});
// Filters may or may not be present depending on implementation
const count = await filters.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should display verification statistics', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test("should display verification statistics", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Look for stats or metrics sections
const statsSection = page.locator('[data-testid="stats"], [class*="stat"]');
// Stats may or may not be visible depending on content
const count = await statsSection.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should show content details when item is clicked', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test("should show content details when item is clicked", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Check if there are content items
const contentItems = page.locator('[data-testid="content-item"]').first();
const count = await page.locator('[data-testid="content-item"]').count();
if (count > 0) {
// Click on first content item
await contentItems.click();
// Should show more details (modal, expanded view, or navigation)
await page.waitForTimeout(500); // Wait for any animations
}
});
test('should display platform bindings', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test("should display platform bindings", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Look for platform-related elements (YouTube, Twitter, etc.)
const platformElements = page.locator('text=/youtube|twitter|tiktok|instagram/i');
const platformElements = page.locator("text=/youtube|twitter|tiktok|instagram/i");
const count = await platformElements.count();
// Platform elements may or may not exist
expect(count).toBeGreaterThanOrEqual(0);
});
test('should show verification status badges', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test("should show verification status badges", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Look for status indicators
const statusBadges = page.locator('[class*="badge"], [class*="status"]');
const count = await statusBadges.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should have pagination or load more functionality', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test("should have pagination or load more functionality", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Look for pagination controls
const paginationElements = page.locator(
'button, a',
{ hasText: /next|previous|load more|page/i }
).or(page.locator('[data-testid="pagination"]'));
const paginationElements = page
.locator("button, a", { hasText: /next|previous|load more|page/i })
.or(page.locator('[data-testid="pagination"]'));
const count = await paginationElements.count();
// Pagination may not be present if there's not enough content
expect(count).toBeGreaterThanOrEqual(0);
});
test('should display content hash information', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test("should display content hash information", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Look for hash-like strings (hex patterns)
const hashPatterns = page.locator('code, [class*="hash"], [class*="mono"]');
const count = await hashPatterns.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should show transaction links for on-chain content', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test("should show transaction links for on-chain content", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Look for blockchain explorer links
const explorerLinks = page.locator('a[href*="etherscan"], a[href*="basescan"], a[href*="polygonscan"]');
const explorerLinks = page.locator(
'a[href*="etherscan"], a[href*="basescan"], a[href*="polygonscan"]'
);
const count = await explorerLinks.count();
// Explorer links only present if content is registered on-chain
expect(count).toBeGreaterThanOrEqual(0);
});
test('should handle loading state', async ({ page }) => {
await page.goto('/dashboard');
test("should handle loading state", async ({ page }) => {
await page.goto("/dashboard");
// Look for loading indicators
const loadingIndicator = page.locator('[data-testid="loading"], [class*="loading"], [class*="spinner"]');
const loadingIndicator = page.locator(
'[data-testid="loading"], [class*="loading"], [class*="spinner"]'
);
// May briefly show loading state
const count = await loadingIndicator.count();
expect(count).toBeGreaterThanOrEqual(0);
// Wait for loading to complete
await page.waitForLoadState('networkidle');
await page.waitForLoadState("networkidle");
});
test('should be responsive on mobile viewport', async ({ page }) => {
test("should be responsive on mobile viewport", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Dashboard should still be visible and functional on mobile
const mainContent = page.locator('main, [role="main"]');
await expect(mainContent).toBeVisible();

View File

@@ -1,19 +1,19 @@
import { test, expect } from '@playwright/test';
import { isCI } from './utils/test-helpers';
import { test, expect } from "@playwright/test";
import { isCI } from "./utils/test-helpers";
test.describe('Content Upload and Registration', () => {
test.skip(isCI(), 'Upload tests require API server - skip in CI without setup');
test.describe("Content Upload and Registration", () => {
test.skip(isCI(), "Upload tests require API server - skip in CI without setup");
test('should display upload page or form', async ({ page }) => {
test("should display upload page or form", async ({ page }) => {
// Navigate to home page
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for upload-related buttons or links
const uploadElements = page.locator('button, a, input[type="file"]').filter({
hasText: /upload|register|one-shot|add content/i,
});
const count = await uploadElements.count();
if (count > 0) {
// Upload functionality is present
@@ -21,191 +21,198 @@ test.describe('Content Upload and Registration', () => {
}
});
test('should show file input for upload', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("should show file input for upload", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for file input elements
const fileInputs = page.locator('input[type="file"]');
const count = await fileInputs.count();
// File input may or may not be visible initially
expect(count).toBeGreaterThanOrEqual(0);
});
test('should validate file selection', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("should validate file selection", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for file input
const fileInput = page.locator('input[type="file"]').first();
const count = await page.locator('input[type="file"]').count();
if (count > 0) {
// Check if file input is present
await expect(fileInput).toBeDefined();
}
});
test.describe('Upload Form Validation', () => {
test('should show required fields', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test.describe("Upload Form Validation", () => {
test("should show required fields", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for form fields related to upload/registration
const formFields = page.locator('input, select, textarea');
const formFields = page.locator("input, select, textarea");
const count = await formFields.count();
// Some form fields should exist for upload
expect(count).toBeGreaterThanOrEqual(0);
});
test('should display registry address field', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("should display registry address field", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for registry address input
const registryInput = page.locator('input').filter({
hasText: /registry|address|contract/i,
}).or(page.locator('input[placeholder*="registry"], input[placeholder*="address"]'));
const registryInput = page
.locator("input")
.filter({
hasText: /registry|address|contract/i,
})
.or(page.locator('input[placeholder*="registry"], input[placeholder*="address"]'));
const count = await registryInput.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should show IPFS or manifest options', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("should show IPFS or manifest options", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for IPFS/manifest related text or inputs
const ipfsElements = page.locator('text=/ipfs|manifest|cid/i');
const ipfsElements = page.locator("text=/ipfs|manifest|cid/i");
const count = await ipfsElements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test.describe('One-Shot Upload Flow', () => {
test('should display one-shot upload option', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test.describe("One-Shot Upload Flow", () => {
test("should display one-shot upload option", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for one-shot upload button/section
const oneShotElement = page.locator('text=/one-shot|one shot|quick upload|all-in-one/i');
const oneShotElement = page.locator("text=/one-shot|one shot|quick upload|all-in-one/i");
const count = await oneShotElement.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should show privacy options for upload', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("should show privacy options for upload", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for privacy-related checkboxes
const privacyCheckbox = page.locator('input[type="checkbox"]').filter({
hasText: /upload|privacy|content|video/i,
});
const count = await privacyCheckbox.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test.describe('Manifest Generation', () => {
test('should display manifest preview', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test.describe("Manifest Generation", () => {
test("should display manifest preview", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for manifest-related sections
const manifestSection = page.locator('[data-testid="manifest"], pre, code').filter({
hasText: /manifest|content_hash|signature/i,
});
const count = await manifestSection.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should show content hash after file selection', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("should show content hash after file selection", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for hash display elements
const hashElements = page.locator('code, [class*="hash"], pre');
const count = await hashElements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test.describe('Blockchain Registration', () => {
test('should show registration status', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test.describe("Blockchain Registration", () => {
test("should show registration status", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Look for status indicators
const statusElements = page.locator('[class*="status"], [class*="badge"]');
const count = await statusElements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should display transaction hash after registration', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test("should display transaction hash after registration", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Look for transaction hash links
const txLinks = page.locator('a[href*="etherscan"], a[href*="basescan"], a[href*="polygonscan"]');
const txLinks = page.locator(
'a[href*="etherscan"], a[href*="basescan"], a[href*="polygonscan"]'
);
const count = await txLinks.count();
// Only present if content has been registered
expect(count).toBeGreaterThanOrEqual(0);
});
test('should show explorer link for registered content', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test("should show explorer link for registered content", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Look for "View on Explorer" type links
const explorerLinks = page.locator('a').filter({
const explorerLinks = page.locator("a").filter({
hasText: /explorer|etherscan|basescan|view on/i,
});
const count = await explorerLinks.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test.describe('Error Handling', () => {
test('should display error messages for invalid input', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test.describe("Error Handling", () => {
test("should display error messages for invalid input", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for error message elements
const errorElements = page.locator('[class*="error"], [role="alert"], .text-red, [class*="danger"]');
const errorElements = page.locator(
'[class*="error"], [role="alert"], .text-red, [class*="danger"]'
);
const count = await errorElements.count();
// Errors may not be visible initially
expect(count).toBeGreaterThanOrEqual(0);
});
test('should handle upload failures gracefully', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("should handle upload failures gracefully", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// The page should be stable even if uploads fail
const mainContent = page.locator('main, body');
const mainContent = page.locator("main, body");
await expect(mainContent).toBeVisible();
});
});
test('should be responsive during upload flow', async ({ page }) => {
test("should be responsive during upload flow", async ({ page }) => {
// Test mobile viewport
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.goto("/");
await page.waitForLoadState("networkidle");
// Upload interface should be visible on mobile
const mainContent = page.locator('main, body');
const mainContent = page.locator("main, body");
await expect(mainContent).toBeVisible();
});
});

View File

@@ -1,31 +1,24 @@
import { test, expect } from '@playwright/test';
import { test, expect } from "@playwright/test";
test.describe("Platform Binding and Verification", () => {
test("should display platform binding options", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
test.describe('Platform Binding and Verification', () => {
test('should display platform binding options', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Look for platform-related text (YouTube, Twitter, TikTok, etc.)
const platformElements = page.locator('text=/youtube|twitter|tiktok|instagram|platform/i');
const platformElements = page.locator("text=/youtube|twitter|tiktok|instagram|platform/i");
const count = await platformElements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should show supported platforms list', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("should show supported platforms list", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for platform names in the UI
const platforms = [
'youtube',
'twitter',
'x',
'tiktok',
'instagram',
'github',
];
const platforms = ["youtube", "twitter", "x", "tiktok", "instagram", "github"];
let foundCount = 0;
for (const platform of platforms) {
const platformElement = page.locator(`text=/${platform}/i`);
@@ -34,223 +27,231 @@ test.describe('Platform Binding and Verification', () => {
foundCount++;
}
}
// At least some platforms should be mentioned
expect(foundCount).toBeGreaterThanOrEqual(0);
});
test.describe('Binding Form', () => {
test('should have platform selector', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test.describe("Binding Form", () => {
test("should have platform selector", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for select/dropdown for platform selection
const platformSelector = page.locator('select').filter({
hasText: /platform|youtube|twitter/i,
}).or(page.locator('[role="combobox"]'));
const platformSelector = page
.locator("select")
.filter({
hasText: /platform|youtube|twitter/i,
})
.or(page.locator('[role="combobox"]'));
const count = await platformSelector.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should have platform ID input field', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("should have platform ID input field", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for input field for platform ID/URL
const platformIdInput = page.locator('input').filter({
hasText: /platform|url|video|post|id/i,
}).or(page.locator('input[placeholder*="platform"], input[placeholder*="URL"]'));
const platformIdInput = page
.locator("input")
.filter({
hasText: /platform|url|video|post|id/i,
})
.or(page.locator('input[placeholder*="platform"], input[placeholder*="URL"]'));
const count = await platformIdInput.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should show bind button', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("should show bind button", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for bind/link button
const bindButton = page.locator('button').filter({
const bindButton = page.locator("button").filter({
hasText: /bind|link|connect|add platform/i,
});
const count = await bindButton.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test.describe('YouTube Binding', () => {
test('should accept YouTube URLs', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test.describe("YouTube Binding", () => {
test("should accept YouTube URLs", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for YouTube-specific elements
const youtubeElements = page.locator('text=/youtube|video id/i');
const youtubeElements = page.locator("text=/youtube|video id/i");
const count = await youtubeElements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should show YouTube video ID format', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("should show YouTube video ID format", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for placeholder or help text showing YouTube format
const helpText = page.locator('text=/watch\\?v=|youtube\\.com/i');
const helpText = page.locator("text=/watch\\?v=|youtube\\.com/i");
const count = await helpText.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test.describe('Twitter/X Binding', () => {
test('should accept Twitter/X URLs', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test.describe("Twitter/X Binding", () => {
test("should accept Twitter/X URLs", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for Twitter/X-specific elements
const twitterElements = page.locator('text=/twitter|^x$|x\\.com|tweet/i');
const twitterElements = page.locator("text=/twitter|^x$|x\\.com|tweet/i");
const count = await twitterElements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test.describe('Batch Binding', () => {
test('should support multiple platform bindings', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test.describe("Batch Binding", () => {
test("should support multiple platform bindings", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for batch binding functionality
const batchElements = page.locator('text=/batch|multiple|add another/i');
const batchElements = page.locator("text=/batch|multiple|add another/i");
const count = await batchElements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test.describe('Verification Flow', () => {
test('should load verify page', async ({ page }) => {
await page.goto('/verify');
await page.waitForLoadState('networkidle');
test.describe("Verification Flow", () => {
test("should load verify page", async ({ page }) => {
await page.goto("/verify");
await page.waitForLoadState("networkidle");
// Verify page should load
await expect(page).toHaveURL(/\/verify/);
});
test('should have verification input form', async ({ page }) => {
await page.goto('/verify');
await page.waitForLoadState('networkidle');
test("should have verification input form", async ({ page }) => {
await page.goto("/verify");
await page.waitForLoadState("networkidle");
// Look for input fields for verification
const inputs = page.locator('input, select, textarea');
const inputs = page.locator("input, select, textarea");
const count = await inputs.count();
expect(count).toBeGreaterThan(0);
});
test('should accept platform URLs for verification', async ({ page }) => {
await page.goto('/verify');
await page.waitForLoadState('networkidle');
test("should accept platform URLs for verification", async ({ page }) => {
await page.goto("/verify");
await page.waitForLoadState("networkidle");
// Look for URL input field
const urlInput = page.locator('input[type="url"], input[placeholder*="URL"], input[placeholder*="url"]');
const urlInput = page.locator(
'input[type="url"], input[placeholder*="URL"], input[placeholder*="url"]'
);
const count = await urlInput.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should have verify button', async ({ page }) => {
await page.goto('/verify');
await page.waitForLoadState('networkidle');
test("should have verify button", async ({ page }) => {
await page.goto("/verify");
await page.waitForLoadState("networkidle");
// Look for verify button or form submit
const verifyButton = page.locator('button, input[type="submit"]').filter({
hasText: /verify|check|validate|submit/i,
});
const count = await verifyButton.count();
// Verify page should have some form of action button or be functional
expect(count).toBeGreaterThanOrEqual(0);
});
test('should display verification results', async ({ page }) => {
await page.goto('/verify');
await page.waitForLoadState('networkidle');
test("should display verification results", async ({ page }) => {
await page.goto("/verify");
await page.waitForLoadState("networkidle");
// Look for results section (may be hidden initially)
const resultsSection = page.locator('[data-testid="verification-result"], [class*="result"]');
const count = await resultsSection.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test.describe('Verification Status', () => {
test('should show verification badge', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test.describe("Verification Status", () => {
test("should show verification badge", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Look for verification badges
const badges = page.locator('[data-testid="verification-badge"], [class*="badge"]');
const count = await badges.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should display verification timestamp', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test("should display verification timestamp", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Look for timestamp elements
const timestamps = page.locator('time, [class*="timestamp"], [class*="date"]');
const count = await timestamps.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test.describe('Public Verification', () => {
test('should allow verification without authentication', async ({ page }) => {
await page.goto('/verify');
await page.waitForLoadState('networkidle');
test.describe("Public Verification", () => {
test("should allow verification without authentication", async ({ page }) => {
await page.goto("/verify");
await page.waitForLoadState("networkidle");
// Verify page should be accessible without auth
const mainContent = page.locator('main, body');
const mainContent = page.locator("main, body");
await expect(mainContent).toBeVisible();
});
test('should show shareable verification link', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test("should show shareable verification link", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Look for share or link buttons
const shareButtons = page.locator('button, a').filter({
const shareButtons = page.locator("button, a").filter({
hasText: /share|copy|link/i,
});
const count = await shareButtons.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test('should handle invalid platform URLs', async ({ page }) => {
await page.goto('/verify');
await page.waitForLoadState('networkidle');
test("should handle invalid platform URLs", async ({ page }) => {
await page.goto("/verify");
await page.waitForLoadState("networkidle");
// The page should handle errors gracefully
const mainContent = page.locator('main, body');
const mainContent = page.locator("main, body");
await expect(mainContent).toBeVisible();
});
test('should be mobile responsive', async ({ page }) => {
test("should be mobile responsive", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/verify');
await page.waitForLoadState('networkidle');
await page.goto("/verify");
await page.waitForLoadState("networkidle");
// Verify page should work on mobile
const mainContent = page.locator('main, body');
const mainContent = page.locator("main, body");
await expect(mainContent).toBeVisible();
});
});

View File

@@ -1,255 +1,263 @@
import { test, expect } from '@playwright/test';
import { test, expect } from "@playwright/test";
test.describe("Profile Page", () => {
test("should load profile page", async ({ page }) => {
await page.goto("/profile");
test.describe('Profile Page', () => {
test('should load profile page', async ({ page }) => {
await page.goto('/profile');
// Profile page should load (may redirect to signin if not authenticated)
await page.waitForURL(/\/(profile|signin)/);
});
test('should redirect to signin when not authenticated', async ({ page }) => {
await page.goto('/profile');
test("should redirect to signin when not authenticated", async ({ page }) => {
await page.goto("/profile");
// Should either show profile or redirect to signin
const url = page.url();
expect(url).toMatch(/\/(profile|signin)/);
});
test.describe('Authenticated User Profile', () => {
test.describe("Authenticated User Profile", () => {
// These tests assume user is authenticated (skip if no auth)
test('should display user information', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test("should display user information", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for user info elements
const userElements = page.locator('[data-testid="user-info"], [class*="profile"], [class*="user"]');
const userElements = page.locator(
'[data-testid="user-info"], [class*="profile"], [class*="user"]'
);
const count = await userElements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should show content history', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test("should show content history", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for content list or history section
const contentSection = page.locator('[data-testid="content-history"], [class*="history"]');
const count = await contentSection.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should display linked accounts', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test("should display linked accounts", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for linked accounts section
const accountsSection = page.locator('text=/linked accounts|connected accounts|oauth/i');
const accountsSection = page.locator("text=/linked accounts|connected accounts|oauth/i");
const count = await accountsSection.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should show platform bindings', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test("should show platform bindings", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for platform binding information
const platformElements = page.locator('text=/youtube|twitter|tiktok|instagram|platform/i');
const platformElements = page.locator("text=/youtube|twitter|tiktok|instagram|platform/i");
const count = await platformElements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test.describe('Account Management', () => {
test('should show account settings option', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test.describe("Account Management", () => {
test("should show account settings option", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for settings link/button
const settingsElement = page.locator('button, a').filter({
const settingsElement = page.locator("button, a").filter({
hasText: /settings|preferences|edit profile/i,
});
const count = await settingsElement.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should have sign out button', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test("should have sign out button", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for sign out button
const signOutButton = page.locator('button, a').filter({
const signOutButton = page.locator("button, a").filter({
hasText: /sign out|logout/i,
});
const count = await signOutButton.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test.describe('Content Statistics', () => {
test('should display registered content count', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test.describe("Content Statistics", () => {
test("should display registered content count", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for statistics or count elements
const statsElements = page.locator('[data-testid="stats"], [class*="stat"], [class*="count"]');
const statsElements = page.locator(
'[data-testid="stats"], [class*="stat"], [class*="count"]'
);
const count = await statsElements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should show verification statistics', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test("should show verification statistics", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for verification-related stats
const verificationStats = page.locator('text=/verified|verification|success rate/i');
const verificationStats = page.locator("text=/verified|verification|success rate/i");
const count = await verificationStats.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should display recent activity', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test("should display recent activity", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for activity feed or timeline
const activitySection = page.locator('[data-testid="activity"], [class*="activity"], [class*="timeline"]');
const activitySection = page.locator(
'[data-testid="activity"], [class*="activity"], [class*="timeline"]'
);
const count = await activitySection.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test.describe('OAuth Provider Linking', () => {
test('should show link account options', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test.describe("OAuth Provider Linking", () => {
test("should show link account options", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for "Link Account" or "Connect" buttons
const linkButtons = page.locator('button, a').filter({
const linkButtons = page.locator("button, a").filter({
hasText: /link|connect|add account|github|google/i,
});
const count = await linkButtons.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should display connected GitHub account', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test("should display connected GitHub account", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for GitHub-related elements
const githubElements = page.locator('text=/github/i');
const githubElements = page.locator("text=/github/i");
const count = await githubElements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should display connected Google account', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test("should display connected Google account", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for Google-related elements
const googleElements = page.locator('text=/google/i');
const googleElements = page.locator("text=/google/i");
const count = await googleElements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test.describe('Content Management', () => {
test('should allow navigation to content items', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test.describe("Content Management", () => {
test("should allow navigation to content items", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for clickable content items
const contentLinks = page.locator('a[href*="/dashboard"], [data-testid="content-link"]');
const count = await contentLinks.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should show empty state when no content', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test("should show empty state when no content", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for empty state message
const emptyState = page.locator('text=/no content|get started|upload your first/i');
const emptyState = page.locator("text=/no content|get started|upload your first/i");
const count = await emptyState.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test('should have action buttons for content', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test("should have action buttons for content", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for action buttons (upload, register, etc.)
const actionButtons = page.locator('button, a').filter({
const actionButtons = page.locator("button, a").filter({
hasText: /upload|register|create|add/i,
});
const count = await actionButtons.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test.describe('Wallet Information', () => {
test('should display wallet address if connected', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test.describe("Wallet Information", () => {
test("should display wallet address if connected", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for wallet address (hex pattern starting with 0x)
const walletElements = page.locator('code, [class*="address"], [class*="wallet"]');
const count = await walletElements.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});
test('should be responsive on mobile', async ({ page }) => {
test("should be responsive on mobile", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/profile');
await page.waitForLoadState('networkidle');
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Profile should be visible on mobile (or redirect to signin)
// Check that page loaded successfully
const body = page.locator('body');
const body = page.locator("body");
await expect(body).toBeVisible({ timeout: 10000 });
// Verify we're on profile or signin page
await page.waitForURL(/\/(profile|signin)/, { timeout: 10000 });
});
test('should handle loading state', async ({ page }) => {
await page.goto('/profile');
test("should handle loading state", async ({ page }) => {
await page.goto("/profile");
// Look for loading indicator
const loadingIndicator = page.locator('[data-testid="loading"], [class*="loading"], [class*="spinner"]');
const loadingIndicator = page.locator(
'[data-testid="loading"], [class*="loading"], [class*="spinner"]'
);
// May show loading state briefly
const count = await loadingIndicator.count();
expect(count).toBeGreaterThanOrEqual(0);
// Wait for page to fully load
await page.waitForLoadState('networkidle');
await page.waitForLoadState("networkidle");
});
test('should show badges/achievements section', async ({ page }) => {
await page.goto('/profile');
await page.waitForLoadState('networkidle');
test("should show badges/achievements section", async ({ page }) => {
await page.goto("/profile");
await page.waitForLoadState("networkidle");
// Look for badges section
const badgesSection = page.locator('text=/badges|achievement|milestone/i');
const badgesSection = page.locator("text=/badges|achievement|milestone/i");
const count = await badgesSection.count();
expect(count).toBeGreaterThanOrEqual(0);
});
});

View File

@@ -1,397 +1,397 @@
import { test, expect } from '@playwright/test';
import { test, expect } from "@playwright/test";
test.describe("Accessibility and Visual Regression", () => {
test.describe("Accessibility Standards", () => {
test("home page should have proper document structure", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
test.describe('Accessibility and Visual Regression', () => {
test.describe('Accessibility Standards', () => {
test('home page should have proper document structure', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Check for main landmark with id for skip link
const main = page.locator('main#main-content');
const main = page.locator("main#main-content");
await expect(main).toHaveCount(1);
// Check for navigation with aria-label
const nav = page.locator('nav[aria-label]');
const nav = page.locator("nav[aria-label]");
await expect(nav).toHaveCount(1);
// Check for skip-to-content link
const skipLink = page.locator('a.skip-to-content');
const skipLink = page.locator("a.skip-to-content");
await expect(skipLink).toHaveCount(1);
await expect(skipLink).toHaveAttribute('href', '#main-content');
await expect(skipLink).toHaveAttribute("href", "#main-content");
});
test('should have proper heading hierarchy', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("should have proper heading hierarchy", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Check for exactly one h1 heading
const h1 = page.locator('h1');
const h1 = page.locator("h1");
await expect(h1).toHaveCount(1);
// Verify h1 has content
const h1Text = await h1.textContent();
expect(h1Text).toBeTruthy();
});
test('skip to content link should work with keyboard', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("skip to content link should work with keyboard", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Press Tab to focus skip link
await page.keyboard.press('Tab');
await page.keyboard.press("Tab");
// Verify skip link is focused
const skipLink = page.locator('a.skip-to-content');
const skipLink = page.locator("a.skip-to-content");
await expect(skipLink).toBeFocused();
// Press Enter to activate skip link
await page.keyboard.press('Enter');
await page.keyboard.press("Enter");
// Verify main content is now in focus area
const main = page.locator('main#main-content');
const main = page.locator("main#main-content");
await expect(main).toBeVisible();
});
test('interactive elements should be keyboard accessible', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("interactive elements should be keyboard accessible", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Tab through interactive elements
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
// Check if focus is visible (at least one focusable element exists)
const focusableElements = page.locator('a, button, input, select, textarea');
const focusableElements = page.locator("a, button, input, select, textarea");
const count = await focusableElements.count();
expect(count).toBeGreaterThan(0);
// Verify at least one element can receive focus
const firstButton = page.locator('button').first();
if (await firstButton.count() > 0) {
const firstButton = page.locator("button").first();
if ((await firstButton.count()) > 0) {
await firstButton.focus();
await expect(firstButton).toBeFocused();
}
});
test('buttons should have accessible names', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("buttons should have accessible names", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Get all buttons
const buttons = page.locator('button');
const buttons = page.locator("button");
const buttonCount = await buttons.count();
// Each button should have text or aria-label
for (let i = 0; i < Math.min(buttonCount, 10); i++) {
const button = buttons.nth(i);
const text = await button.textContent();
const ariaLabel = await button.getAttribute('aria-label');
const ariaLabel = await button.getAttribute("aria-label");
// Button should have text or aria-label
expect(text || ariaLabel).toBeTruthy();
}
});
test('images should have alt text', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("images should have alt text", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Get all images
const images = page.locator('img');
const images = page.locator("img");
const imageCount = await images.count();
// Check alt attribute for each image
for (let i = 0; i < Math.min(imageCount, 5); i++) {
const img = images.nth(i);
const alt = await img.getAttribute('alt');
const alt = await img.getAttribute("alt");
// Alt attribute should exist (can be empty for decorative images)
expect(alt !== null).toBeTruthy();
}
});
test('form inputs should have labels', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("form inputs should have labels", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Get all input fields (excluding hidden)
const inputs = page.locator('input:visible, textarea:visible, select:visible');
const inputs = page.locator("input:visible, textarea:visible, select:visible");
const inputCount = await inputs.count();
// Each input should have label, aria-label, or aria-labelledby
for (let i = 0; i < Math.min(inputCount, 10); i++) {
const input = inputs.nth(i);
const id = await input.getAttribute('id');
const ariaLabel = await input.getAttribute('aria-label');
const ariaLabelledBy = await input.getAttribute('aria-labelledby');
const placeholder = await input.getAttribute('placeholder');
const id = await input.getAttribute("id");
const ariaLabel = await input.getAttribute("aria-label");
const ariaLabelledBy = await input.getAttribute("aria-labelledby");
const placeholder = await input.getAttribute("placeholder");
// Should have some form of label
const hasLabel = id || ariaLabel || ariaLabelledBy || placeholder;
expect(hasLabel).toBeTruthy();
}
});
test('links should have descriptive text', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("links should have descriptive text", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Get all links
const links = page.locator('a');
const links = page.locator("a");
const linkCount = await links.count();
// Check link text
for (let i = 0; i < Math.min(linkCount, 10); i++) {
const link = links.nth(i);
const text = await link.textContent();
const ariaLabel = await link.getAttribute('aria-label');
const ariaLabel = await link.getAttribute("aria-label");
// Link should have text or aria-label
expect(text || ariaLabel).toBeTruthy();
}
});
});
test.describe('Color Contrast and Theme', () => {
test('should have readable text contrast', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test.describe("Color Contrast and Theme", () => {
test("should have readable text contrast", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Get background and text colors
const body = page.locator('body');
const body = page.locator("body");
await expect(body).toBeVisible();
// Color contrast is hard to test automatically, but ensure elements are visible
const mainText = page.locator('p, h1, h2, h3, span');
const mainText = page.locator("p, h1, h2, h3, span");
const textCount = await mainText.count();
expect(textCount).toBeGreaterThan(0);
});
test('should support dark/light theme toggle', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("should support dark/light theme toggle", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for theme toggle button
const themeToggle = page.locator('button').filter({
const themeToggle = page.locator("button").filter({
hasText: /theme|dark|light/i,
});
const toggleCount = await themeToggle.count();
// Theme toggle is optional
expect(toggleCount).toBeGreaterThanOrEqual(0);
});
});
test.describe('Visual Regression Tests', () => {
test('home page visual snapshot', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test.describe("Visual Regression Tests", () => {
test("home page visual snapshot", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Take screenshot for visual comparison
await expect(page).toHaveScreenshot('home-page.png', {
await expect(page).toHaveScreenshot("home-page.png", {
fullPage: true,
maxDiffPixels: 100,
});
});
test('dashboard page visual snapshot', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
test("dashboard page visual snapshot", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForLoadState("networkidle");
// Take screenshot
await expect(page).toHaveScreenshot('dashboard-page.png', {
await expect(page).toHaveScreenshot("dashboard-page.png", {
fullPage: true,
maxDiffPixels: 100,
});
});
test('verify page visual snapshot', async ({ page }) => {
await page.goto('/verify');
await page.waitForLoadState('networkidle');
test("verify page visual snapshot", async ({ page }) => {
await page.goto("/verify");
await page.waitForLoadState("networkidle");
// Take screenshot
await expect(page).toHaveScreenshot('verify-page.png', {
await expect(page).toHaveScreenshot("verify-page.png", {
fullPage: true,
maxDiffPixels: 100,
});
});
test('signin page visual snapshot', async ({ page }) => {
await page.goto('/signin');
await page.waitForLoadState('networkidle');
test("signin page visual snapshot", async ({ page }) => {
await page.goto("/signin");
await page.waitForLoadState("networkidle");
// Take screenshot
await expect(page).toHaveScreenshot('signin-page.png', {
await expect(page).toHaveScreenshot("signin-page.png", {
fullPage: false, // Auth page might be short
maxDiffPixels: 100,
});
});
});
test.describe('Mobile Accessibility', () => {
test('should be touch-friendly on mobile', async ({ page }) => {
test.describe("Mobile Accessibility", () => {
test("should be touch-friendly on mobile", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.goto("/");
await page.waitForLoadState("networkidle");
// Check button sizes (should be at least 44x44 for touch)
const buttons = page.locator('button, a[role="button"]');
const buttonCount = await buttons.count();
if (buttonCount > 0) {
const firstButton = buttons.first();
const box = await firstButton.boundingBox();
// Buttons should exist and be clickable
expect(box).toBeTruthy();
}
});
test('should have mobile-friendly navigation', async ({ page }) => {
test("should have mobile-friendly navigation", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.goto("/");
await page.waitForLoadState("networkidle");
// Look for mobile menu or navigation
const nav = page.locator('nav, [role="navigation"], button').filter({
hasText: /menu|navigation/i,
});
const navCount = await nav.count();
expect(navCount).toBeGreaterThanOrEqual(0);
});
});
test.describe('Performance Accessibility', () => {
test('should have reasonable page load time', async ({ page }) => {
test.describe("Performance Accessibility", () => {
test("should have reasonable page load time", async ({ page }) => {
const startTime = Date.now();
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.goto("/");
await page.waitForLoadState("networkidle");
const loadTime = Date.now() - startTime;
// Page should load in reasonable time (10 seconds)
expect(loadTime).toBeLessThan(10000);
});
test('should not have excessive JavaScript errors', async ({ page }) => {
test("should not have excessive JavaScript errors", async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (error) => {
page.on("pageerror", (error) => {
errors.push(error.message);
});
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.goto("/");
await page.waitForLoadState("networkidle");
// Should have no or minimal errors
expect(errors.length).toBeLessThan(5);
});
});
test.describe('ARIA Roles and States', () => {
test('should use appropriate ARIA roles', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test.describe("ARIA Roles and States", () => {
test("should use appropriate ARIA roles", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Check for main role
const mainRole = page.locator('[role="main"], main');
await expect(mainRole).toHaveCount(1);
// Check for navigation with aria-label
const navWithLabel = page.locator('nav[aria-label]');
const navWithLabel = page.locator("nav[aria-label]");
await expect(navWithLabel).toHaveCount(1);
});
test('should indicate loading states with ARIA', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("should indicate loading states with ARIA", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Check for aria-live regions in toast container
const liveRegions = page.locator('[aria-live]');
const liveRegions = page.locator("[aria-live]");
const count = await liveRegions.count();
// Toast container and other live regions should be present
expect(count).toBeGreaterThanOrEqual(1);
});
test('tab buttons should have aria-pressed state', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("tab buttons should have aria-pressed state", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Find tab buttons with aria-pressed
const tabButtons = page.locator('button[aria-pressed]');
const tabButtons = page.locator("button[aria-pressed]");
const count = await tabButtons.count();
// Should have multiple tab buttons
expect(count).toBeGreaterThan(0);
// At least one should be pressed
const pressedButton = page.locator('button[aria-pressed="true"]');
await expect(pressedButton).toHaveCount(1);
});
test('error messages should have role="alert"', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.goto("/");
await page.waitForLoadState("networkidle");
// Error messages should use role="alert" when they appear
// This test checks that the component structure is correct
const alertStructure = await page.evaluate(() => {
// Check if ErrorMessage component structure exists in the page
return document.querySelectorAll('[role="alert"]').length >= 0;
});
expect(alertStructure).toBe(true);
});
test('loading spinners should have role="status"', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.goto("/");
await page.waitForLoadState("networkidle");
// LoadingSpinner component should use role="status"
const statusStructure = await page.evaluate(() => {
// Verify the component can display status roles
return true; // Structural check
});
expect(statusStructure).toBe(true);
});
});
test.describe('Focus Management', () => {
test('should have visible focus indicators', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test.describe("Focus Management", () => {
test("should have visible focus indicators", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Get first button and focus it
const button = page.locator('button').first();
const button = page.locator("button").first();
await button.focus();
// Check if button has focus
await expect(button).toBeFocused();
// Verify focus styling exists in computed styles
const hasOutline = await button.evaluate((el) => {
const styles = window.getComputedStyle(el);
return styles.outline !== 'none' || styles.outlineWidth !== '0px';
return styles.outline !== "none" || styles.outlineWidth !== "0px";
});
expect(hasOutline).toBe(true);
});
test('escape key should close toast notifications', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
test("escape key should close toast notifications", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Register error listener before the action
const errors: string[] = [];
page.on('pageerror', (error) => {
page.on("pageerror", (error) => {
errors.push(error.message);
});
// This test verifies the keyboard handler is set up
// Actual toast behavior would require triggering a toast first
await page.keyboard.press('Escape');
await page.keyboard.press("Escape");
// Verify no errors occur
expect(errors.length).toBe(0);
});

View File

@@ -1,4 +1,4 @@
import { Page, expect } from '@playwright/test';
import { Page, expect } from "@playwright/test";
/**
* Utility functions for E2E tests
@@ -14,11 +14,7 @@ export async function waitForNavigation(page: Page, url: string | RegExp) {
/**
* Fill form field by label text
*/
export async function fillFormField(
page: Page,
label: string,
value: string
) {
export async function fillFormField(page: Page, label: string, value: string) {
const input = page.getByLabel(label);
await input.fill(value);
}
@@ -27,7 +23,7 @@ export async function fillFormField(
* Click button by text
*/
export async function clickButton(page: Page, text: string | RegExp) {
const button = page.getByRole('button', { name: text });
const button = page.getByRole("button", { name: text });
await button.click();
}
@@ -42,7 +38,7 @@ export async function waitForApiResponse(
return page.waitForResponse(
(response) => {
const url = response.url();
if (typeof urlPattern === 'string') {
if (typeof urlPattern === "string") {
return url.includes(urlPattern);
}
return urlPattern.test(url);
@@ -84,9 +80,9 @@ export function generateTestData() {
*/
export async function createTestFile(
filename: string,
content: string = 'Test content for E2E testing'
content: string = "Test content for E2E testing"
): Promise<{ filename: string; content: string; buffer: Buffer }> {
const buffer = Buffer.from(content, 'utf-8');
const buffer = Buffer.from(content, "utf-8");
return {
filename,
content,
@@ -105,7 +101,7 @@ export function isCI(): boolean {
* Get API base URL
*/
export function getApiBaseUrl(): string {
return process.env.NEXT_PUBLIC_API_BASE || 'http://localhost:3001';
return process.env.NEXT_PUBLIC_API_BASE || "http://localhost:3001";
}
/**

View File

@@ -6,9 +6,9 @@
* Web Vitals metric structure
*/
export interface WebVitalsMetric {
name: 'CLS' | 'FCP' | 'FID' | 'INP' | 'LCP' | 'TTFB';
name: "CLS" | "FCP" | "FID" | "INP" | "LCP" | "TTFB";
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
rating: "good" | "needs-improvement" | "poor";
delta: number;
id: string;
navigationType?: string;
@@ -20,19 +20,19 @@ export interface WebVitalsMetric {
*/
export function reportWebVitals(metric: WebVitalsMetric) {
// Log in development
if (process.env.NODE_ENV === 'development') {
console.log('[Performance]', {
if (process.env.NODE_ENV === "development") {
console.log("[Performance]", {
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
});
}
// Send to analytics in production
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'production') {
if (typeof window !== "undefined" && process.env.NODE_ENV === "production") {
const body = JSON.stringify(metric);
const url = '/api/analytics';
const url = "/api/analytics";
// Use sendBeacon if available (more reliable)
if (navigator.sendBeacon) {
@@ -41,8 +41,8 @@ export function reportWebVitals(metric: WebVitalsMetric) {
// Fallback to fetch with keepalive
fetch(url, {
body,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method: "POST",
headers: { "Content-Type": "application/json" },
keepalive: true,
}).catch(console.error);
}
@@ -54,10 +54,7 @@ export function reportWebVitals(metric: WebVitalsMetric) {
* Usage with next/dynamic:
* const DynamicComponent = dynamic(() => import('./Component'), createDynamicOptions({ ssr: false }))
*/
export function createDynamicOptions(options?: {
loading?: () => any;
ssr?: boolean;
}) {
export function createDynamicOptions(options?: { loading?: () => any; ssr?: boolean }) {
return options || { ssr: true };
}
@@ -66,11 +63,11 @@ export function createDynamicOptions(options?: {
* Usage: deferScript(() => { your code here })
*/
export function deferScript(callback: () => void) {
if (typeof window !== 'undefined') {
if (document.readyState === 'complete') {
if (typeof window !== "undefined") {
if (document.readyState === "complete") {
callback();
} else {
window.addEventListener('load', callback);
window.addEventListener("load", callback);
}
}
}
@@ -80,9 +77,9 @@ export function deferScript(callback: () => void) {
* Usage: prefetchRoute('/dashboard')
*/
export function prefetchRoute(href: string) {
if (typeof window !== 'undefined') {
const link = document.createElement('link');
link.rel = 'prefetch';
if (typeof window !== "undefined") {
const link = document.createElement("link");
link.rel = "prefetch";
link.href = href;
document.head.appendChild(link);
}
@@ -95,7 +92,7 @@ export function prefetchRoute(href: string) {
let webpSupportPromise: Promise<boolean> | null = null;
export function supportsWebP(): Promise<boolean> {
if (typeof window === 'undefined') return Promise.resolve(false);
if (typeof window === "undefined") return Promise.resolve(false);
if (webpSupportPromise) return webpSupportPromise;
webpSupportPromise = new Promise((resolve) => {
@@ -103,7 +100,8 @@ export function supportsWebP(): Promise<boolean> {
webP.onload = webP.onerror = () => {
resolve(webP.height === 2);
};
webP.src = 'data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA';
webP.src =
"data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA";
});
return webpSupportPromise;
}
@@ -112,7 +110,7 @@ export function supportsWebP(): Promise<boolean> {
* Optimize images by lazy loading them
*/
export function observeImages() {
if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
if (typeof window === "undefined" || !("IntersectionObserver" in window)) {
return;
}
@@ -122,14 +120,14 @@ export function observeImages() {
const img = entry.target as HTMLImageElement;
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
img.removeAttribute("data-src");
imageObserver.unobserve(img);
}
}
});
});
document.querySelectorAll('img[data-src]').forEach((img) => {
document.querySelectorAll("img[data-src]").forEach((img) => {
imageObserver.observe(img);
});
}

View File

@@ -16,16 +16,16 @@
"assert": {
"preset": "lighthouse:recommended",
"assertions": {
"categories:performance": ["error", {"minScore": 0.8}],
"categories:accessibility": ["error", {"minScore": 0.9}],
"categories:best-practices": ["error", {"minScore": 0.9}],
"categories:seo": ["error", {"minScore": 0.9}],
"first-contentful-paint": ["error", {"maxNumericValue": 2000}],
"largest-contentful-paint": ["error", {"maxNumericValue": 2500}],
"cumulative-layout-shift": ["error", {"maxNumericValue": 0.1}],
"total-blocking-time": ["error", {"maxNumericValue": 300}],
"speed-index": ["error", {"maxNumericValue": 3000}],
"interactive": ["error", {"maxNumericValue": 5000}]
"categories:performance": ["error", { "minScore": 0.8 }],
"categories:accessibility": ["error", { "minScore": 0.9 }],
"categories:best-practices": ["error", { "minScore": 0.9 }],
"categories:seo": ["error", { "minScore": 0.9 }],
"first-contentful-paint": ["error", { "maxNumericValue": 2000 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 300 }],
"speed-index": ["error", { "maxNumericValue": 3000 }],
"interactive": ["error", { "maxNumericValue": 5000 }]
}
},
"upload": {

View File

@@ -1,4 +1,4 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
@@ -10,7 +10,7 @@ import { defineConfig, devices } from '@playwright/test';
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
testDir: "./e2e",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -20,58 +20,56 @@ export default defineConfig({
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: process.env.CI
? [['html'], ['github'], ['list']]
: [['html'], ['list']],
reporter: process.env.CI ? [["html"], ["github"], ["list"]] : [["html"], ["list"]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.BASE_URL || 'http://localhost:3000',
baseURL: process.env.BASE_URL || "http://localhost:3000",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
trace: "on-first-retry",
/* Take screenshot on failure */
screenshot: 'only-on-failure',
screenshot: "only-on-failure",
/* Video recording on first retry */
video: 'retain-on-failure',
video: "retain-on-failure",
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
name: "chromium",
use: {
...devices['Desktop Chrome'],
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 720 },
},
},
{
name: 'firefox',
name: "firefox",
use: {
...devices['Desktop Firefox'],
...devices["Desktop Firefox"],
viewport: { width: 1280, height: 720 },
},
},
{
name: 'webkit',
name: "webkit",
use: {
...devices['Desktop Safari'],
...devices["Desktop Safari"],
viewport: { width: 1280, height: 720 },
},
},
/* Test against mobile viewports. */
{
name: 'Mobile Chrome',
name: "Mobile Chrome",
use: {
...devices['Pixel 5'],
...devices["Pixel 5"],
},
},
{
name: 'Mobile Safari',
name: "Mobile Safari",
use: {
...devices['iPhone 12'],
...devices["iPhone 12"],
},
},
@@ -88,11 +86,11 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
stdout: 'ignore',
stderr: 'pipe',
stdout: "ignore",
stderr: "pipe",
timeout: 120 * 1000,
},
});

View File

@@ -2,34 +2,34 @@
/**
* Accessibility Audit Script
*
*
* This script performs automated accessibility checks on the application
* to ensure WCAG 2.1 Level AA compliance.
*/
const fs = require('fs');
const path = require('path');
const fs = require("fs");
const path = require("path");
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
bold: '\x1b[1m',
reset: "\x1b[0m",
green: "\x1b[32m",
red: "\x1b[31m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
bold: "\x1b[1m",
};
function log(message, color = 'reset') {
function log(message, color = "reset") {
console.log(`${colors[color]}${message}${colors.reset}`);
}
function checkFile(filePath, checks) {
const content = fs.readFileSync(filePath, 'utf-8');
const content = fs.readFileSync(filePath, "utf-8");
const issues = [];
const successes = [];
checks.forEach(check => {
checks.forEach((check) => {
const result = check.test(content, filePath);
if (result.passed) {
successes.push(result);
@@ -44,103 +44,108 @@ function checkFile(filePath, checks) {
// Define accessibility checks
const accessibilityChecks = [
{
name: 'ARIA Labels on Buttons',
name: "ARIA Labels on Buttons",
test: (content) => {
const buttonMatches = content.match(/<button[^>]*>/g) || [];
const buttonsWithLabels = buttonMatches.filter(btn =>
btn.includes('aria-label=') || btn.includes('aria-labelledby=')
const buttonsWithLabels = buttonMatches.filter(
(btn) => btn.includes("aria-label=") || btn.includes("aria-labelledby=")
).length;
return {
name: 'ARIA Labels on Buttons',
name: "ARIA Labels on Buttons",
passed: true,
message: `Found ${buttonMatches.length} buttons, ${buttonsWithLabels} with ARIA labels`,
};
},
},
{
name: 'Image Alt Text',
name: "Image Alt Text",
test: (content) => {
const imgMatches = content.match(/<img[^>]*>/g) || [];
const imgsWithAlt = imgMatches.filter(img => img.includes('alt=')).length;
const imgsWithAlt = imgMatches.filter((img) => img.includes("alt=")).length;
const passed = imgMatches.length === 0 || imgsWithAlt === imgMatches.length;
return {
name: 'Image Alt Text',
name: "Image Alt Text",
passed,
message: passed
? `All ${imgMatches.length} images have alt text`
message: passed
? `All ${imgMatches.length} images have alt text`
: `${imgMatches.length - imgsWithAlt} images missing alt text`,
};
},
},
{
name: 'Form Labels',
name: "Form Labels",
test: (content) => {
const inputMatches = content.match(/<input[^>]*>/g) || [];
const inputsWithLabels = inputMatches.filter(input =>
input.includes('aria-label=') ||
input.includes('aria-labelledby=') ||
input.includes('id=')
const inputsWithLabels = inputMatches.filter(
(input) =>
input.includes("aria-label=") ||
input.includes("aria-labelledby=") ||
input.includes("id=")
).length;
return {
name: 'Form Labels',
name: "Form Labels",
passed: true,
message: `Found ${inputMatches.length} inputs, ${inputsWithLabels} with labels or IDs`,
};
},
},
{
name: 'ARIA Live Regions',
name: "ARIA Live Regions",
test: (content, filePath) => {
const fileName = path.basename(filePath);
const hasAriaLive = content.includes('aria-live=');
const hasAriaLive = content.includes("aria-live=");
// Only check for aria-live in components that should have dynamic content
const requiresAriaLive = fileName === 'Toast.tsx' ||
fileName === 'ErrorMessage.tsx' ||
fileName === 'LoadingSpinner.tsx';
const requiresAriaLive =
fileName === "Toast.tsx" ||
fileName === "ErrorMessage.tsx" ||
fileName === "LoadingSpinner.tsx";
// page.tsx uses ToastContainer component which has aria-live
if (fileName === 'page.tsx') {
const usesToastContainer = content.includes('ToastContainer');
if (fileName === "page.tsx") {
const usesToastContainer = content.includes("ToastContainer");
return {
name: 'ARIA Live Regions',
name: "ARIA Live Regions",
passed: usesToastContainer,
message: usesToastContainer ? 'Uses ToastContainer with ARIA live regions' : 'Missing ARIA live regions',
message: usesToastContainer
? "Uses ToastContainer with ARIA live regions"
: "Missing ARIA live regions",
};
}
if (!requiresAriaLive) {
return { name: 'ARIA Live Regions', passed: true, message: 'N/A for this file' };
return { name: "ARIA Live Regions", passed: true, message: "N/A for this file" };
}
return {
name: 'ARIA Live Regions',
name: "ARIA Live Regions",
passed: hasAriaLive,
message: hasAriaLive ? 'ARIA live regions found' : 'Missing ARIA live regions',
message: hasAriaLive ? "ARIA live regions found" : "Missing ARIA live regions",
};
},
},
{
name: 'Role Attributes',
name: "Role Attributes",
test: (content, filePath) => {
const fileName = path.basename(filePath);
const hasRoles = content.includes('role=');
const hasRoles = content.includes("role=");
// Only check for roles in components that should have semantic roles
const requiresRoles = fileName === 'Toast.tsx' ||
fileName === 'ErrorMessage.tsx' ||
fileName === 'LoadingSpinner.tsx' ||
fileName === 'page.tsx';
const requiresRoles =
fileName === "Toast.tsx" ||
fileName === "ErrorMessage.tsx" ||
fileName === "LoadingSpinner.tsx" ||
fileName === "page.tsx";
if (!requiresRoles) {
return { name: 'Role Attributes', passed: true, message: 'N/A for this file' };
return { name: "Role Attributes", passed: true, message: "N/A for this file" };
}
return {
name: 'Role Attributes',
name: "Role Attributes",
passed: hasRoles,
message: hasRoles ? 'Role attributes found' : 'Missing role attributes',
message: hasRoles ? "Role attributes found" : "Missing role attributes",
};
},
},
@@ -148,63 +153,64 @@ const accessibilityChecks = [
// Main audit function
function runAudit() {
log('\n🔍 Running Accessibility Audit...', 'blue');
log('================================\n', 'blue');
log("\n🔍 Running Accessibility Audit...", "blue");
log("================================\n", "blue");
const componentsDir = path.join(__dirname, "..", "app", "components");
const pageFile = path.join(__dirname, "..", "app", "page.tsx");
const layoutFile = path.join(__dirname, "..", "app", "layout.tsx");
const componentsDir = path.join(__dirname, '..', 'app', 'components');
const pageFile = path.join(__dirname, '..', 'app', 'page.tsx');
const layoutFile = path.join(__dirname, '..', 'app', 'layout.tsx');
const filesToCheck = [pageFile, layoutFile];
// Add component files
if (fs.existsSync(componentsDir)) {
const componentFiles = fs.readdirSync(componentsDir)
.filter(file => file.endsWith('.tsx') || file.endsWith('.jsx'))
.map(file => path.join(componentsDir, file));
const componentFiles = fs
.readdirSync(componentsDir)
.filter((file) => file.endsWith(".tsx") || file.endsWith(".jsx"))
.map((file) => path.join(componentsDir, file));
filesToCheck.push(...componentFiles);
}
let totalIssues = 0;
let totalSuccesses = 0;
filesToCheck.forEach(filePath => {
filesToCheck.forEach((filePath) => {
if (!fs.existsSync(filePath)) return;
const fileName = path.basename(filePath);
const { issues, successes } = checkFile(filePath, accessibilityChecks);
if (issues.length > 0 || successes.length > 0) {
log(`\n📄 ${fileName}`, 'bold');
successes.forEach(success => {
log(`${success.message}`, 'green');
log(`\n📄 ${fileName}`, "bold");
successes.forEach((success) => {
log(`${success.message}`, "green");
totalSuccesses++;
});
issues.forEach(issue => {
log(`${issue.message}`, 'red');
issues.forEach((issue) => {
log(`${issue.message}`, "red");
totalIssues++;
});
}
});
// Summary
log('\n================================', 'blue');
log('Summary:', 'bold');
log(`${totalSuccesses} checks passed`, 'green');
log("\n================================", "blue");
log("Summary:", "bold");
log(`${totalSuccesses} checks passed`, "green");
if (totalIssues > 0) {
log(`${totalIssues} issues found`, 'red');
log('\n⚠ Please address the accessibility issues above.', 'yellow');
log(`${totalIssues} issues found`, "red");
log("\n⚠ Please address the accessibility issues above.", "yellow");
} else {
log('\n✅ All accessibility checks passed!', 'green');
log("\n✅ All accessibility checks passed!", "green");
}
log('\nFor full compliance, also run:', 'blue');
log(' - npm run test:e2e -- 07-accessibility.spec.ts', 'blue');
log(' - npm run perf:audit (Lighthouse)', 'blue');
log('\n');
log("\nFor full compliance, also run:", "blue");
log(" - npm run test:e2e -- 07-accessibility.spec.ts", "blue");
log(" - npm run perf:audit (Lighthouse)", "blue");
log("\n");
process.exit(totalIssues > 0 ? 1 : 0);
}

View File

@@ -5,19 +5,19 @@
* Usage: node scripts/performance-report.js
*/
const fs = require('fs');
const path = require('path');
const fs = require("fs");
const path = require("path");
const BUILD_DIR = path.join(__dirname, '..', '.next');
const REPORT_FILE = path.join(__dirname, '..', 'performance-report.json');
const BUILD_DIR = path.join(__dirname, "..", ".next");
const REPORT_FILE = path.join(__dirname, "..", "performance-report.json");
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
if (bytes < 0) return 'Invalid size';
if (bytes === 0) return "0 Bytes";
if (bytes < 0) return "Invalid size";
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
}
function getDirectorySize(dir) {
@@ -67,7 +67,7 @@ function getJavaScriptSize(dir) {
const stats = fs.statSync(filePath);
if (stats.isDirectory()) {
size += getJavaScriptSize(filePath);
} else if (file.endsWith('.js')) {
} else if (file.endsWith(".js")) {
size += stats.size;
}
});
@@ -79,16 +79,16 @@ function getJavaScriptSize(dir) {
function generateReport() {
if (!fs.existsSync(BUILD_DIR)) {
console.error('Build directory not found. Run `npm run build` first.');
console.error("Build directory not found. Run `npm run build` first.");
process.exit(1);
}
const totalSize = getDirectorySize(BUILD_DIR);
const jsCount = countFiles(BUILD_DIR, '.js');
const cssCount = countFiles(BUILD_DIR, '.css');
const jsCount = countFiles(BUILD_DIR, ".js");
const cssCount = countFiles(BUILD_DIR, ".css");
const jsSize = getJavaScriptSize(BUILD_DIR);
const staticDir = path.join(BUILD_DIR, 'static');
const staticDir = path.join(BUILD_DIR, "static");
const staticSize = fs.existsSync(staticDir) ? getDirectorySize(staticDir) : 0;
const report = {
@@ -110,12 +110,12 @@ function generateReport() {
javascript: {
limit: 3 * 1024 * 1024, // 3 MB (baseline 2.57 MB, target 1.5 MB)
current: jsSize,
status: jsSize < 3 * 1024 * 1024 ? 'PASS' : 'FAIL',
status: jsSize < 3 * 1024 * 1024 ? "PASS" : "FAIL",
},
total: {
limit: 12 * 1024 * 1024, // 12 MB (baseline 10.22 MB)
current: totalSize,
status: totalSize < 12 * 1024 * 1024 ? 'PASS' : 'FAIL',
status: totalSize < 12 * 1024 * 1024 ? "PASS" : "FAIL",
},
},
};
@@ -124,24 +124,28 @@ function generateReport() {
fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2));
// Print summary
console.log('\n📊 Performance Report\n');
console.log('Build Information:');
console.log("\n📊 Performance Report\n");
console.log("Build Information:");
console.log(` Total size: ${report.buildSize.totalFormatted}`);
console.log(` JavaScript size: ${report.buildSize.javascriptFormatted}`);
console.log(` Static size: ${report.buildSize.staticFormatted}`);
console.log(` JavaScript files: ${report.fileCount.javascript}`);
console.log(` CSS files: ${report.fileCount.css}`);
console.log('\nBudget Status:');
console.log(` JavaScript: ${report.budgets.javascript.status} (${formatBytes(report.budgets.javascript.current)} / ${formatBytes(report.budgets.javascript.limit)})`);
console.log(` Total: ${report.budgets.total.status} (${formatBytes(report.budgets.total.current)} / ${formatBytes(report.budgets.total.limit)})`);
console.log("\nBudget Status:");
console.log(
` JavaScript: ${report.budgets.javascript.status} (${formatBytes(report.budgets.javascript.current)} / ${formatBytes(report.budgets.javascript.limit)})`
);
console.log(
` Total: ${report.budgets.total.status} (${formatBytes(report.budgets.total.current)} / ${formatBytes(report.budgets.total.limit)})`
);
console.log(`\nReport saved to: ${REPORT_FILE}\n`);
// Exit with error if budgets failed
if (report.budgets.javascript.status === 'FAIL' || report.budgets.total.status === 'FAIL') {
console.error('❌ Performance budgets exceeded!');
if (report.budgets.javascript.status === "FAIL" || report.budgets.total.status === "FAIL") {
console.error("❌ Performance budgets exceeded!");
process.exit(1);
} else {
console.log('✅ All performance budgets passed!');
console.log("✅ All performance budgets passed!");
}
}