Merge branch 'dev' into feature/session-persistence-new
343
.agents/skills/api-authentication/SKILL.md
Normal file
@@ -0,0 +1,343 @@
|
||||
---
|
||||
name: api-authentication
|
||||
description: Implement secure API authentication with JWT, OAuth 2.0, API keys, and session management. Use when securing APIs, managing tokens, or implementing user authentication flows.
|
||||
---
|
||||
|
||||
# API Authentication
|
||||
|
||||
## Overview
|
||||
|
||||
Implement comprehensive authentication strategies for APIs including JWT tokens, OAuth 2.0, API keys, and session management with proper security practices.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Securing API endpoints
|
||||
- Implementing user login/logout flows
|
||||
- Managing access tokens and refresh tokens
|
||||
- Integrating OAuth 2.0 providers
|
||||
- Protecting sensitive data
|
||||
- Implementing API key authentication
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. **JWT Authentication**
|
||||
|
||||
```javascript
|
||||
// Node.js JWT Implementation
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const app = express();
|
||||
const SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key';
|
||||
const REFRESH_SECRET = process.env.REFRESH_SECRET || 'your-refresh-secret';
|
||||
|
||||
// User login endpoint
|
||||
app.post('/api/auth/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
// Find user in database
|
||||
const user = await User.findOne({ email });
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await bcrypt.compare(password, user.password);
|
||||
if (!isValid) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = jwt.sign(
|
||||
{ userId: user.id, email: user.email, role: user.role },
|
||||
SECRET_KEY,
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ userId: user.id },
|
||||
REFRESH_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
// Store refresh token in database
|
||||
await RefreshToken.create({ token: refreshToken, userId: user.id });
|
||||
|
||||
res.json({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: 900,
|
||||
user: { id: user.id, email: user.email, role: user.role }
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Authentication failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh token endpoint
|
||||
app.post('/api/auth/refresh', (req, res) => {
|
||||
const { refreshToken } = req.body;
|
||||
|
||||
if (!refreshToken) {
|
||||
return res.status(401).json({ error: 'Refresh token required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
|
||||
|
||||
// Verify token exists in database
|
||||
const storedToken = await RefreshToken.findOne({
|
||||
token: refreshToken,
|
||||
userId: decoded.userId
|
||||
});
|
||||
|
||||
if (!storedToken) {
|
||||
return res.status(401).json({ error: 'Invalid refresh token' });
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
const newAccessToken = jwt.sign(
|
||||
{ userId: decoded.userId },
|
||||
SECRET_KEY,
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
res.json({ accessToken: newAccessToken, expiresIn: 900 });
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: 'Invalid refresh token' });
|
||||
}
|
||||
});
|
||||
|
||||
// Middleware to verify JWT
|
||||
const verifyToken = (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer token
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Access token required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, SECRET_KEY);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
|
||||
}
|
||||
res.status(403).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
|
||||
// Protected endpoint
|
||||
app.get('/api/profile', verifyToken, (req, res) => {
|
||||
res.json({ user: req.user });
|
||||
});
|
||||
|
||||
// Logout endpoint
|
||||
app.post('/api/auth/logout', verifyToken, async (req, res) => {
|
||||
try {
|
||||
await RefreshToken.deleteOne({ userId: req.user.userId });
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2. **OAuth 2.0 Implementation**
|
||||
|
||||
```javascript
|
||||
const passport = require('passport');
|
||||
const GoogleStrategy = require('passport-google-oauth20').Strategy;
|
||||
|
||||
passport.use(new GoogleStrategy(
|
||||
{
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: '/api/auth/google/callback'
|
||||
},
|
||||
async (accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
let user = await User.findOne({ googleId: profile.id });
|
||||
|
||||
if (!user) {
|
||||
user = await User.create({
|
||||
googleId: profile.id,
|
||||
email: profile.emails[0].value,
|
||||
firstName: profile.name.givenName,
|
||||
lastName: profile.name.familyName
|
||||
});
|
||||
}
|
||||
|
||||
return done(null, user);
|
||||
} catch (error) {
|
||||
return done(error);
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
// OAuth routes
|
||||
app.get('/api/auth/google',
|
||||
passport.authenticate('google', { scope: ['profile', 'email'] })
|
||||
);
|
||||
|
||||
app.get('/api/auth/google/callback',
|
||||
passport.authenticate('google', { failureRedirect: '/login' }),
|
||||
(req, res) => {
|
||||
const token = jwt.sign(
|
||||
{ userId: req.user.id, email: req.user.email },
|
||||
SECRET_KEY,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
res.redirect(`/dashboard?token=${token}`);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### 3. **API Key Authentication**
|
||||
|
||||
```javascript
|
||||
// API Key middleware
|
||||
const verifyApiKey = (req, res, next) => {
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({ error: 'API key required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify API key format and existence
|
||||
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
||||
const apiKeyRecord = await ApiKey.findOne({ key_hash: keyHash, active: true });
|
||||
|
||||
if (!apiKeyRecord) {
|
||||
return res.status(401).json({ error: 'Invalid API key' });
|
||||
}
|
||||
|
||||
req.apiKey = apiKeyRecord;
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Authentication failed' });
|
||||
}
|
||||
};
|
||||
|
||||
// Generate API key endpoint
|
||||
app.post('/api/apikeys/generate', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const apiKey = crypto.randomBytes(32).toString('hex');
|
||||
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
||||
|
||||
const record = await ApiKey.create({
|
||||
userId: req.user.userId,
|
||||
key_hash: keyHash,
|
||||
name: req.body.name,
|
||||
active: true
|
||||
});
|
||||
|
||||
res.json({ apiKey, message: 'Save this key securely' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to generate API key' });
|
||||
}
|
||||
});
|
||||
|
||||
// Protected endpoint with API key
|
||||
app.get('/api/data', verifyApiKey, (req, res) => {
|
||||
res.json({ data: 'sensitive data for API key holder' });
|
||||
});
|
||||
```
|
||||
|
||||
### 4. **Python Authentication Implementation**
|
||||
|
||||
```python
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_jwt_extended import JWTManager, create_access_token, jwt_required
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from functools import wraps
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['JWT_SECRET_KEY'] = 'secret-key'
|
||||
jwt = JWTManager(app)
|
||||
|
||||
@app.route('/api/auth/login', methods=['POST'])
|
||||
def login():
|
||||
data = request.get_json()
|
||||
user = User.query.filter_by(email=data['email']).first()
|
||||
|
||||
if not user or not check_password_hash(user.password, data['password']):
|
||||
return jsonify({'error': 'Invalid credentials'}), 401
|
||||
|
||||
access_token = create_access_token(
|
||||
identity=user.id,
|
||||
additional_claims={'email': user.email, 'role': user.role}
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'accessToken': access_token,
|
||||
'user': {'id': user.id, 'email': user.email}
|
||||
}), 200
|
||||
|
||||
@app.route('/api/protected', methods=['GET'])
|
||||
@jwt_required()
|
||||
def protected():
|
||||
from flask_jwt_extended import get_jwt_identity
|
||||
user_id = get_jwt_identity()
|
||||
return jsonify({'userId': user_id}), 200
|
||||
|
||||
def require_role(role):
|
||||
def decorator(fn):
|
||||
@wraps(fn)
|
||||
@jwt_required()
|
||||
def wrapper(*args, **kwargs):
|
||||
from flask_jwt_extended import get_jwt
|
||||
claims = get_jwt()
|
||||
if claims.get('role') != role:
|
||||
return jsonify({'error': 'Forbidden'}), 403
|
||||
return fn(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@app.route('/api/admin', methods=['GET'])
|
||||
@require_role('admin')
|
||||
def admin_endpoint():
|
||||
return jsonify({'message': 'Admin data'}), 200
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ DO
|
||||
- Use HTTPS for all authentication
|
||||
- Store tokens securely (HttpOnly cookies)
|
||||
- Implement token refresh mechanism
|
||||
- Set appropriate token expiration times
|
||||
- Hash and salt passwords
|
||||
- Use strong secret keys
|
||||
- Validate tokens on every request
|
||||
- Implement rate limiting on auth endpoints
|
||||
- Log authentication attempts
|
||||
- Rotate secrets regularly
|
||||
|
||||
### ❌ DON'T
|
||||
- Store passwords in plain text
|
||||
- Send tokens in URL parameters
|
||||
- Use weak secret keys
|
||||
- Store sensitive data in JWT payload
|
||||
- Ignore token expiration
|
||||
- Disable HTTPS in production
|
||||
- Log sensitive tokens
|
||||
- Reuse API keys across services
|
||||
- Store credentials in code
|
||||
|
||||
## Security Headers
|
||||
|
||||
```javascript
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
next();
|
||||
});
|
||||
```
|
||||
624
.agents/skills/api-contract-testing/SKILL.md
Normal file
@@ -0,0 +1,624 @@
|
||||
---
|
||||
name: api-contract-testing
|
||||
description: Verify API contracts between services to ensure compatibility and prevent breaking changes. Use for contract testing, Pact, API contract validation, schema validation, and consumer-driven contracts.
|
||||
---
|
||||
|
||||
# API Contract Testing
|
||||
|
||||
## Overview
|
||||
|
||||
Contract testing verifies that APIs honor their contracts between consumers and providers. It ensures that service changes don't break dependent consumers without requiring full integration tests. Contract tests validate request/response formats, data types, and API behavior independently.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Testing microservices communication
|
||||
- Preventing breaking API changes
|
||||
- Validating API versioning
|
||||
- Testing consumer-provider contracts
|
||||
- Ensuring backward compatibility
|
||||
- Validating OpenAPI/Swagger specifications
|
||||
- Testing third-party API integrations
|
||||
- Catching contract violations in CI
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Consumer**: Service that calls an API
|
||||
- **Provider**: Service that exposes the API
|
||||
- **Contract**: Agreement on API request/response format
|
||||
- **Pact**: Consumer-defined expectations
|
||||
- **Schema**: Structure definition (OpenAPI, JSON Schema)
|
||||
- **Stub**: Generated mock from contract
|
||||
- **Broker**: Central repository for contracts
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. **Pact for Consumer-Driven Contracts**
|
||||
|
||||
#### Consumer Test (Jest/Pact)
|
||||
```typescript
|
||||
// tests/pact/user-service.pact.test.ts
|
||||
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
|
||||
import { UserService } from '../../src/services/UserService';
|
||||
|
||||
const { like, eachLike, iso8601DateTimeWithMillis } = MatchersV3;
|
||||
|
||||
const provider = new PactV3({
|
||||
consumer: 'OrderService',
|
||||
provider: 'UserService',
|
||||
port: 1234,
|
||||
dir: './pacts',
|
||||
});
|
||||
|
||||
describe('User Service Contract', () => {
|
||||
const userService = new UserService('http://localhost:1234');
|
||||
|
||||
describe('GET /users/:id', () => {
|
||||
test('returns user when found', async () => {
|
||||
await provider
|
||||
.given('user with ID 123 exists')
|
||||
.uponReceiving('a request for user 123')
|
||||
.withRequest({
|
||||
method: 'GET',
|
||||
path: '/users/123',
|
||||
headers: {
|
||||
Authorization: like('Bearer token'),
|
||||
},
|
||||
})
|
||||
.willRespondWith({
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
id: like('123'),
|
||||
email: like('user@example.com'),
|
||||
name: like('John Doe'),
|
||||
age: like(30),
|
||||
createdAt: iso8601DateTimeWithMillis('2024-01-01T00:00:00.000Z'),
|
||||
role: like('user'),
|
||||
},
|
||||
})
|
||||
.executeTest(async (mockServer) => {
|
||||
const user = await userService.getUser('123');
|
||||
|
||||
expect(user.id).toBe('123');
|
||||
expect(user.email).toBeDefined();
|
||||
expect(user.name).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('returns 404 when user not found', async () => {
|
||||
await provider
|
||||
.given('user with ID 999 does not exist')
|
||||
.uponReceiving('a request for non-existent user')
|
||||
.withRequest({
|
||||
method: 'GET',
|
||||
path: '/users/999',
|
||||
})
|
||||
.willRespondWith({
|
||||
status: 404,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
error: like('User not found'),
|
||||
code: like('USER_NOT_FOUND'),
|
||||
},
|
||||
})
|
||||
.executeTest(async (mockServer) => {
|
||||
await expect(userService.getUser('999')).rejects.toThrow(
|
||||
'User not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /users', () => {
|
||||
test('creates new user', async () => {
|
||||
await provider
|
||||
.given('user does not exist')
|
||||
.uponReceiving('a request to create user')
|
||||
.withRequest({
|
||||
method: 'POST',
|
||||
path: '/users',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
email: like('newuser@example.com'),
|
||||
name: like('New User'),
|
||||
age: like(25),
|
||||
},
|
||||
})
|
||||
.willRespondWith({
|
||||
status: 201,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
id: like('new-123'),
|
||||
email: like('newuser@example.com'),
|
||||
name: like('New User'),
|
||||
age: like(25),
|
||||
createdAt: iso8601DateTimeWithMillis(),
|
||||
role: 'user',
|
||||
},
|
||||
})
|
||||
.executeTest(async (mockServer) => {
|
||||
const user = await userService.createUser({
|
||||
email: 'newuser@example.com',
|
||||
name: 'New User',
|
||||
age: 25,
|
||||
});
|
||||
|
||||
expect(user.id).toBeDefined();
|
||||
expect(user.email).toBe('newuser@example.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users/:id/orders', () => {
|
||||
test('returns user orders', async () => {
|
||||
await provider
|
||||
.given('user 123 has orders')
|
||||
.uponReceiving('a request for user orders')
|
||||
.withRequest({
|
||||
method: 'GET',
|
||||
path: '/users/123/orders',
|
||||
query: {
|
||||
limit: '10',
|
||||
offset: '0',
|
||||
},
|
||||
})
|
||||
.willRespondWith({
|
||||
status: 200,
|
||||
body: {
|
||||
orders: eachLike({
|
||||
id: like('order-1'),
|
||||
total: like(99.99),
|
||||
status: like('completed'),
|
||||
createdAt: iso8601DateTimeWithMillis(),
|
||||
}),
|
||||
total: like(5),
|
||||
hasMore: like(false),
|
||||
},
|
||||
})
|
||||
.executeTest(async (mockServer) => {
|
||||
const response = await userService.getUserOrders('123', {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(response.orders).toBeDefined();
|
||||
expect(Array.isArray(response.orders)).toBe(true);
|
||||
expect(response.total).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Provider Test (Verify Contract)
|
||||
```typescript
|
||||
// tests/pact/user-service.provider.test.ts
|
||||
import { Verifier } from '@pact-foundation/pact';
|
||||
import path from 'path';
|
||||
import { app } from '../../src/app';
|
||||
import { setupTestDB, teardownTestDB } from '../helpers/db';
|
||||
|
||||
describe('Pact Provider Verification', () => {
|
||||
let server;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDB();
|
||||
server = app.listen(3001);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDB();
|
||||
server.close();
|
||||
});
|
||||
|
||||
test('validates the expectations of OrderService', () => {
|
||||
return new Verifier({
|
||||
provider: 'UserService',
|
||||
providerBaseUrl: 'http://localhost:3001',
|
||||
pactUrls: [
|
||||
path.resolve(__dirname, '../../pacts/orderservice-userservice.json'),
|
||||
],
|
||||
// Provider state setup
|
||||
stateHandlers: {
|
||||
'user with ID 123 exists': async () => {
|
||||
await createTestUser({ id: '123', name: 'John Doe' });
|
||||
},
|
||||
'user with ID 999 does not exist': async () => {
|
||||
await deleteUser('999');
|
||||
},
|
||||
'user 123 has orders': async () => {
|
||||
await createTestUser({ id: '123' });
|
||||
await createTestOrder({ userId: '123' });
|
||||
},
|
||||
},
|
||||
})
|
||||
.verifyProvider()
|
||||
.then((output) => {
|
||||
console.log('Pact Verification Complete!');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2. **OpenAPI Schema Validation**
|
||||
|
||||
```typescript
|
||||
// tests/contract/openapi.test.ts
|
||||
import request from 'supertest';
|
||||
import { app } from '../../src/app';
|
||||
import OpenAPIValidator from 'express-openapi-validator';
|
||||
import fs from 'fs';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
describe('OpenAPI Contract Validation', () => {
|
||||
let validator;
|
||||
|
||||
beforeAll(() => {
|
||||
const spec = yaml.load(
|
||||
fs.readFileSync('./openapi.yaml', 'utf8')
|
||||
);
|
||||
|
||||
validator = OpenAPIValidator.middleware({
|
||||
apiSpec: spec,
|
||||
validateRequests: true,
|
||||
validateResponses: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('GET /users/:id matches schema', async () => {
|
||||
const response = await request(app)
|
||||
.get('/users/123')
|
||||
.expect(200);
|
||||
|
||||
// Validate against OpenAPI schema
|
||||
expect(response.body).toMatchObject({
|
||||
id: expect.any(String),
|
||||
email: expect.stringMatching(/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/),
|
||||
name: expect.any(String),
|
||||
age: expect.any(Number),
|
||||
createdAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
|
||||
});
|
||||
});
|
||||
|
||||
test('POST /users validates request body', async () => {
|
||||
const invalidUser = {
|
||||
email: 'invalid-email', // Should fail validation
|
||||
name: 'Test',
|
||||
};
|
||||
|
||||
await request(app)
|
||||
.post('/users')
|
||||
.send(invalidUser)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3. **JSON Schema Validation**
|
||||
|
||||
```python
|
||||
# tests/contract/test_schema_validation.py
|
||||
import pytest
|
||||
import jsonschema
|
||||
from jsonschema import validate
|
||||
import json
|
||||
|
||||
# Define schemas
|
||||
USER_SCHEMA = {
|
||||
"type": "object",
|
||||
"required": ["id", "email", "name"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"email": {"type": "string", "format": "email"},
|
||||
"name": {"type": "string"},
|
||||
"age": {"type": "integer", "minimum": 0, "maximum": 150},
|
||||
"role": {"type": "string", "enum": ["user", "admin"]},
|
||||
"createdAt": {"type": "string", "format": "date-time"},
|
||||
},
|
||||
"additionalProperties": False
|
||||
}
|
||||
|
||||
ORDER_SCHEMA = {
|
||||
"type": "object",
|
||||
"required": ["id", "userId", "total", "status"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"userId": {"type": "string"},
|
||||
"total": {"type": "number", "minimum": 0},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["pending", "paid", "shipped", "delivered", "cancelled"]
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["productId", "quantity", "price"],
|
||||
"properties": {
|
||||
"productId": {"type": "string"},
|
||||
"quantity": {"type": "integer", "minimum": 1},
|
||||
"price": {"type": "number", "minimum": 0},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TestAPIContracts:
|
||||
def test_get_user_response_schema(self, api_client):
|
||||
"""Validate user endpoint response against schema."""
|
||||
response = api_client.get('/api/users/123')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Validate against schema
|
||||
validate(instance=data, schema=USER_SCHEMA)
|
||||
|
||||
def test_create_user_request_schema(self, api_client):
|
||||
"""Validate create user request body."""
|
||||
valid_user = {
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"age": 30,
|
||||
}
|
||||
|
||||
response = api_client.post('/api/users', json=valid_user)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Response should also match schema
|
||||
validate(instance=response.json(), schema=USER_SCHEMA)
|
||||
|
||||
def test_invalid_request_rejected(self, api_client):
|
||||
"""Invalid requests should be rejected."""
|
||||
invalid_user = {
|
||||
"email": "not-an-email",
|
||||
"age": -5, # Invalid age
|
||||
}
|
||||
|
||||
response = api_client.post('/api/users', json=invalid_user)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_order_response_schema(self, api_client):
|
||||
"""Validate order endpoint response."""
|
||||
response = api_client.get('/api/orders/order-123')
|
||||
|
||||
assert response.status_code == 200
|
||||
validate(instance=response.json(), schema=ORDER_SCHEMA)
|
||||
|
||||
def test_order_items_array_validation(self, api_client):
|
||||
"""Validate nested array schema."""
|
||||
order_data = {
|
||||
"userId": "user-123",
|
||||
"items": [
|
||||
{"productId": "prod-1", "quantity": 2, "price": 29.99},
|
||||
{"productId": "prod-2", "quantity": 1, "price": 49.99},
|
||||
]
|
||||
}
|
||||
|
||||
response = api_client.post('/api/orders', json=order_data)
|
||||
assert response.status_code == 201
|
||||
|
||||
result = response.json()
|
||||
validate(instance=result, schema=ORDER_SCHEMA)
|
||||
```
|
||||
|
||||
### 4. **REST Assured for Java**
|
||||
|
||||
```java
|
||||
// ContractTest.java
|
||||
import io.restassured.RestAssured;
|
||||
import io.restassured.module.jsv.JsonSchemaValidator;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static io.restassured.RestAssured.*;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
public class UserAPIContractTest {
|
||||
|
||||
@Test
|
||||
public void getUserShouldMatchSchema() {
|
||||
given()
|
||||
.pathParam("id", "123")
|
||||
.when()
|
||||
.get("/api/users/{id}")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body(JsonSchemaValidator.matchesJsonSchemaInClasspath("schemas/user-schema.json"))
|
||||
.body("id", notNullValue())
|
||||
.body("email", matchesPattern("^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"))
|
||||
.body("age", greaterThanOrEqualTo(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createUserShouldValidateRequest() {
|
||||
String userJson = """
|
||||
{
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"age": 30
|
||||
}
|
||||
""";
|
||||
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.body(userJson)
|
||||
.when()
|
||||
.post("/api/users")
|
||||
.then()
|
||||
.statusCode(201)
|
||||
.body("id", notNullValue())
|
||||
.body("email", equalTo("test@example.com"))
|
||||
.body("createdAt", matchesPattern("\\d{4}-\\d{2}-\\d{2}T.*"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getUserOrdersShouldReturnArray() {
|
||||
given()
|
||||
.pathParam("id", "123")
|
||||
.queryParam("limit", 10)
|
||||
.when()
|
||||
.get("/api/users/{id}/orders")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("orders", isA(java.util.List.class))
|
||||
.body("orders[0].id", notNullValue())
|
||||
.body("orders[0].status", isIn(Arrays.asList(
|
||||
"pending", "paid", "shipped", "delivered", "cancelled"
|
||||
)))
|
||||
.body("total", greaterThanOrEqualTo(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidRequestShouldReturn400() {
|
||||
String invalidUser = """
|
||||
{
|
||||
"email": "not-an-email",
|
||||
"age": -5
|
||||
}
|
||||
""";
|
||||
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.body(invalidUser)
|
||||
.when()
|
||||
.post("/api/users")
|
||||
.then()
|
||||
.statusCode(400)
|
||||
.body("error", notNullValue());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. **Contract Testing with Postman**
|
||||
|
||||
```json
|
||||
// postman-collection.json
|
||||
{
|
||||
"info": {
|
||||
"name": "User API Contract Tests"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Get User",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/users/{{userId}}"
|
||||
},
|
||||
"test": "
|
||||
pm.test('Response status is 200', () => {
|
||||
pm.response.to.have.status(200);
|
||||
});
|
||||
|
||||
pm.test('Response matches schema', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
required: ['id', 'email', 'name'],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
name: { type: 'string' },
|
||||
age: { type: 'integer' }
|
||||
}
|
||||
};
|
||||
|
||||
pm.response.to.have.jsonSchema(schema);
|
||||
});
|
||||
|
||||
pm.test('Email format is valid', () => {
|
||||
const data = pm.response.json();
|
||||
pm.expect(data.email).to.match(/^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$/);
|
||||
});
|
||||
"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 6. **Pact Broker Integration**
|
||||
|
||||
```yaml
|
||||
# .github/workflows/contract-tests.yml
|
||||
name: Contract Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
consumer-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run test:pact
|
||||
|
||||
- name: Publish Pacts
|
||||
run: |
|
||||
npx pact-broker publish ./pacts \
|
||||
--consumer-app-version=${{ github.sha }} \
|
||||
--broker-base-url=${{ secrets.PACT_BROKER_URL }} \
|
||||
--broker-token=${{ secrets.PACT_BROKER_TOKEN }}
|
||||
|
||||
provider-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: consumer-tests
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run test:pact:provider
|
||||
|
||||
- name: Can I Deploy?
|
||||
run: |
|
||||
npx pact-broker can-i-deploy \
|
||||
--pacticipant=UserService \
|
||||
--version=${{ github.sha }} \
|
||||
--to-environment=production \
|
||||
--broker-base-url=${{ secrets.PACT_BROKER_URL }} \
|
||||
--broker-token=${{ secrets.PACT_BROKER_TOKEN }}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ DO
|
||||
- Test contracts from consumer perspective
|
||||
- Use matchers for flexible matching
|
||||
- Validate schema structure, not specific values
|
||||
- Version your contracts
|
||||
- Test error responses
|
||||
- Use Pact broker for contract sharing
|
||||
- Run contract tests in CI
|
||||
- Test backward compatibility
|
||||
|
||||
### ❌ DON'T
|
||||
- Test business logic in contract tests
|
||||
- Hard-code specific values in contracts
|
||||
- Skip error scenarios
|
||||
- Test UI in contract tests
|
||||
- Ignore contract versioning
|
||||
- Deploy without contract verification
|
||||
- Test implementation details
|
||||
- Mock contract tests
|
||||
|
||||
## Tools
|
||||
|
||||
- **Pact**: Consumer-driven contracts (multiple languages)
|
||||
- **Spring Cloud Contract**: JVM contract testing
|
||||
- **OpenAPI/Swagger**: API specification and validation
|
||||
- **Postman**: API contract testing
|
||||
- **REST Assured**: Java API testing
|
||||
- **Dredd**: OpenAPI/API Blueprint testing
|
||||
- **Spectral**: OpenAPI linting
|
||||
|
||||
## Examples
|
||||
|
||||
See also: integration-testing, api-versioning-strategy, continuous-testing for comprehensive API testing strategies.
|
||||
659
.agents/skills/api-security-hardening/SKILL.md
Normal file
@@ -0,0 +1,659 @@
|
||||
---
|
||||
name: api-security-hardening
|
||||
description: Secure REST APIs with authentication, rate limiting, CORS, input validation, and security middleware. Use when building or hardening API endpoints against common attacks.
|
||||
---
|
||||
|
||||
# API Security Hardening
|
||||
|
||||
## Overview
|
||||
|
||||
Implement comprehensive API security measures including authentication, authorization, rate limiting, input validation, and attack prevention to protect against common vulnerabilities.
|
||||
|
||||
## When to Use
|
||||
|
||||
- New API development
|
||||
- Security audit remediation
|
||||
- Production API hardening
|
||||
- Compliance requirements
|
||||
- High-traffic API protection
|
||||
- Public API exposure
|
||||
|
||||
## Implementation Examples
|
||||
|
||||
### 1. **Node.js/Express API Security**
|
||||
|
||||
```javascript
|
||||
// secure-api.js - Comprehensive API security
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const mongoSanitize = require('express-mongo-sanitize');
|
||||
const xss = require('xss-clean');
|
||||
const hpp = require('hpp');
|
||||
const cors = require('cors');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const validator = require('validator');
|
||||
|
||||
class SecureAPIServer {
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.setupSecurityMiddleware();
|
||||
this.setupRoutes();
|
||||
}
|
||||
|
||||
setupSecurityMiddleware() {
|
||||
// 1. Helmet - Set security headers
|
||||
this.app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
scriptSrc: ["'self'"],
|
||||
imgSrc: ["'self'", "data:", "https:"]
|
||||
}
|
||||
},
|
||||
hsts: {
|
||||
maxAge: 31536000,
|
||||
includeSubDomains: true,
|
||||
preload: true
|
||||
}
|
||||
}));
|
||||
|
||||
// 2. CORS configuration
|
||||
const corsOptions = {
|
||||
origin: (origin, callback) => {
|
||||
const whitelist = [
|
||||
'https://example.com',
|
||||
'https://app.example.com'
|
||||
];
|
||||
|
||||
if (!origin || whitelist.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
optionsSuccessStatus: 200,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
};
|
||||
|
||||
this.app.use(cors(corsOptions));
|
||||
|
||||
// 3. Rate limiting
|
||||
const generalLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // limit each IP to 100 requests per windowMs
|
||||
message: 'Too many requests from this IP',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: (req, res) => {
|
||||
res.status(429).json({
|
||||
error: 'rate_limit_exceeded',
|
||||
message: 'Too many requests, please try again later',
|
||||
retryAfter: req.rateLimit.resetTime
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 5, // Stricter limit for auth endpoints
|
||||
skipSuccessfulRequests: true
|
||||
});
|
||||
|
||||
this.app.use('/api/', generalLimiter);
|
||||
this.app.use('/api/auth/', authLimiter);
|
||||
|
||||
// 4. Body parsing with size limits
|
||||
this.app.use(express.json({ limit: '10kb' }));
|
||||
this.app.use(express.urlencoded({ extended: true, limit: '10kb' }));
|
||||
|
||||
// 5. NoSQL injection prevention
|
||||
this.app.use(mongoSanitize());
|
||||
|
||||
// 6. XSS protection
|
||||
this.app.use(xss());
|
||||
|
||||
// 7. HTTP Parameter Pollution prevention
|
||||
this.app.use(hpp());
|
||||
|
||||
// 8. Request ID for tracking
|
||||
this.app.use((req, res, next) => {
|
||||
req.id = require('crypto').randomUUID();
|
||||
res.setHeader('X-Request-ID', req.id);
|
||||
next();
|
||||
});
|
||||
|
||||
// 9. Security logging
|
||||
this.app.use(this.securityLogger());
|
||||
}
|
||||
|
||||
securityLogger() {
|
||||
return (req, res, next) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
requestId: req.id,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
statusCode: res.statusCode,
|
||||
duration,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent')
|
||||
};
|
||||
|
||||
// Log suspicious activity
|
||||
if (res.statusCode === 401 || res.statusCode === 403) {
|
||||
console.warn('Security event:', logEntry);
|
||||
}
|
||||
|
||||
if (res.statusCode >= 500) {
|
||||
console.error('Server error:', logEntry);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// JWT authentication middleware
|
||||
authenticateJWT() {
|
||||
return (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
error: 'unauthorized',
|
||||
message: 'Missing or invalid authorization header'
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'api.example.com',
|
||||
audience: 'api.example.com'
|
||||
});
|
||||
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
error: 'token_expired',
|
||||
message: 'Token has expired'
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
message: 'Invalid token'
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Input validation middleware
|
||||
validateInput(schema) {
|
||||
return (req, res, next) => {
|
||||
const errors = [];
|
||||
|
||||
// Validate request body
|
||||
if (schema.body) {
|
||||
for (const [field, rules] of Object.entries(schema.body)) {
|
||||
const value = req.body[field];
|
||||
|
||||
if (rules.required && !value) {
|
||||
errors.push(`${field} is required`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
// Type validation
|
||||
if (rules.type === 'email' && !validator.isEmail(value)) {
|
||||
errors.push(`${field} must be a valid email`);
|
||||
}
|
||||
|
||||
if (rules.type === 'uuid' && !validator.isUUID(value)) {
|
||||
errors.push(`${field} must be a valid UUID`);
|
||||
}
|
||||
|
||||
if (rules.type === 'url' && !validator.isURL(value)) {
|
||||
errors.push(`${field} must be a valid URL`);
|
||||
}
|
||||
|
||||
// Length validation
|
||||
if (rules.minLength && value.length < rules.minLength) {
|
||||
errors.push(`${field} must be at least ${rules.minLength} characters`);
|
||||
}
|
||||
|
||||
if (rules.maxLength && value.length > rules.maxLength) {
|
||||
errors.push(`${field} must be at most ${rules.maxLength} characters`);
|
||||
}
|
||||
|
||||
// Pattern validation
|
||||
if (rules.pattern && !rules.pattern.test(value)) {
|
||||
errors.push(`${field} format is invalid`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Input validation failed',
|
||||
details: errors
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Authorization middleware
|
||||
authorize(...roles) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: 'unauthorized',
|
||||
message: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
if (roles.length > 0 && !roles.includes(req.user.role)) {
|
||||
return res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message: 'Insufficient permissions'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
setupRoutes() {
|
||||
// Public endpoint
|
||||
this.app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'healthy' });
|
||||
});
|
||||
|
||||
// Protected endpoint with validation
|
||||
this.app.post('/api/users',
|
||||
this.authenticateJWT(),
|
||||
this.authorize('admin'),
|
||||
this.validateInput({
|
||||
body: {
|
||||
email: { required: true, type: 'email' },
|
||||
name: { required: true, minLength: 2, maxLength: 100 },
|
||||
password: { required: true, minLength: 8 }
|
||||
}
|
||||
}),
|
||||
async (req, res) => {
|
||||
try {
|
||||
// Sanitized and validated input
|
||||
const { email, name, password } = req.body;
|
||||
|
||||
// Process request
|
||||
res.status(201).json({
|
||||
message: 'User created successfully',
|
||||
userId: '123'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
error: 'internal_error',
|
||||
message: 'An error occurred'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Error handling middleware
|
||||
this.app.use((err, req, res, next) => {
|
||||
console.error('Unhandled error:', err);
|
||||
|
||||
res.status(500).json({
|
||||
error: 'internal_error',
|
||||
message: 'An unexpected error occurred',
|
||||
requestId: req.id
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
start(port = 3000) {
|
||||
this.app.listen(port, () => {
|
||||
console.log(`Secure API server running on port ${port}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const server = new SecureAPIServer();
|
||||
server.start(3000);
|
||||
```
|
||||
|
||||
### 2. **Python FastAPI Security**
|
||||
|
||||
```python
|
||||
# secure_api.py
|
||||
from fastapi import FastAPI, HTTPException, Depends, Security, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.util import get_remote_address
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from pydantic import BaseModel, EmailStr, validator, Field
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
import re
|
||||
from typing import Optional, List
|
||||
import secrets
|
||||
|
||||
app = FastAPI()
|
||||
security = HTTPBearer()
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
# Rate limiting
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# CORS configuration
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"https://example.com",
|
||||
"https://app.example.com"
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
max_age=3600
|
||||
)
|
||||
|
||||
# Trusted hosts
|
||||
app.add_middleware(
|
||||
TrustedHostMiddleware,
|
||||
allowed_hosts=["example.com", "*.example.com"]
|
||||
)
|
||||
|
||||
# Security headers middleware
|
||||
@app.middleware("http")
|
||||
async def add_security_headers(request, call_next):
|
||||
response = await call_next(request)
|
||||
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
response.headers["Content-Security-Policy"] = "default-src 'self'"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
||||
|
||||
return response
|
||||
|
||||
# Input validation models
|
||||
class CreateUserRequest(BaseModel):
|
||||
email: EmailStr
|
||||
name: str = Field(..., min_length=2, max_length=100)
|
||||
password: str = Field(..., min_length=8)
|
||||
|
||||
@validator('password')
|
||||
def validate_password(cls, v):
|
||||
if not re.search(r'[A-Z]', v):
|
||||
raise ValueError('Password must contain uppercase letter')
|
||||
if not re.search(r'[a-z]', v):
|
||||
raise ValueError('Password must contain lowercase letter')
|
||||
if not re.search(r'\d', v):
|
||||
raise ValueError('Password must contain digit')
|
||||
if not re.search(r'[!@#$%^&*]', v):
|
||||
raise ValueError('Password must contain special character')
|
||||
return v
|
||||
|
||||
@validator('name')
|
||||
def validate_name(cls, v):
|
||||
# Prevent XSS in name field
|
||||
if re.search(r'[<>]', v):
|
||||
raise ValueError('Name contains invalid characters')
|
||||
return v
|
||||
|
||||
class APIKeyRequest(BaseModel):
|
||||
name: str = Field(..., max_length=100)
|
||||
expires_in_days: int = Field(30, ge=1, le=365)
|
||||
|
||||
# JWT token verification
|
||||
def verify_token(credentials: HTTPAuthorizationCredentials = Security(security)):
|
||||
try:
|
||||
token = credentials.credentials
|
||||
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
"your-secret-key",
|
||||
algorithms=["HS256"],
|
||||
audience="api.example.com",
|
||||
issuer="api.example.com"
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has expired"
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
# Role-based authorization
|
||||
def require_role(required_roles: List[str]):
|
||||
def role_checker(token_payload: dict = Depends(verify_token)):
|
||||
user_role = token_payload.get('role')
|
||||
|
||||
if user_role not in required_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
|
||||
return token_payload
|
||||
|
||||
return role_checker
|
||||
|
||||
# API key authentication
|
||||
def verify_api_key(api_key: str):
|
||||
# Constant-time comparison to prevent timing attacks
|
||||
if not secrets.compare_digest(api_key, "expected-api-key"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key"
|
||||
)
|
||||
return True
|
||||
|
||||
# Endpoints
|
||||
@app.get("/api/health")
|
||||
@limiter.limit("100/minute")
|
||||
async def health_check():
|
||||
return {"status": "healthy"}
|
||||
|
||||
@app.post("/api/users")
|
||||
@limiter.limit("10/minute")
|
||||
async def create_user(
|
||||
user: CreateUserRequest,
|
||||
token_payload: dict = Depends(require_role(["admin"]))
|
||||
):
|
||||
"""Create new user (admin only)"""
|
||||
|
||||
# Hash password before storing
|
||||
# hashed_password = bcrypt.hashpw(user.password.encode(), bcrypt.gensalt())
|
||||
|
||||
return {
|
||||
"message": "User created successfully",
|
||||
"user_id": "123"
|
||||
}
|
||||
|
||||
@app.post("/api/keys")
|
||||
@limiter.limit("5/hour")
|
||||
async def create_api_key(
|
||||
request: APIKeyRequest,
|
||||
token_payload: dict = Depends(verify_token)
|
||||
):
|
||||
"""Generate API key"""
|
||||
|
||||
# Generate secure random API key
|
||||
api_key = secrets.token_urlsafe(32)
|
||||
|
||||
expires_at = datetime.now() + timedelta(days=request.expires_in_days)
|
||||
|
||||
return {
|
||||
"api_key": api_key,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"name": request.name
|
||||
}
|
||||
|
||||
@app.get("/api/protected")
|
||||
async def protected_endpoint(token_payload: dict = Depends(verify_token)):
|
||||
return {
|
||||
"message": "Access granted",
|
||||
"user_id": token_payload.get("sub")
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000, ssl_certfile="cert.pem", ssl_keyfile="key.pem")
|
||||
```
|
||||
|
||||
### 3. **API Gateway Security Configuration**
|
||||
|
||||
```yaml
|
||||
# nginx-api-gateway.conf
|
||||
# Nginx API Gateway with security hardening
|
||||
|
||||
http {
|
||||
# Security headers
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Content-Security-Policy "default-src 'self'" always;
|
||||
|
||||
# Rate limiting zones
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=1r/s;
|
||||
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
|
||||
|
||||
# Request body size limit
|
||||
client_max_body_size 10M;
|
||||
client_body_buffer_size 128k;
|
||||
|
||||
# Timeout settings
|
||||
client_body_timeout 12;
|
||||
client_header_timeout 12;
|
||||
send_timeout 10;
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name api.example.com;
|
||||
|
||||
# SSL configuration
|
||||
ssl_certificate /etc/ssl/certs/api.example.com.crt;
|
||||
ssl_certificate_key /etc/ssl/private/api.example.com.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# API endpoints
|
||||
location /api/ {
|
||||
# Rate limiting
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
limit_conn conn_limit 10;
|
||||
|
||||
# CORS headers
|
||||
add_header Access-Control-Allow-Origin "https://app.example.com" always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
|
||||
|
||||
# Block common exploits
|
||||
if ($request_method !~ ^(GET|POST|PUT|DELETE|HEAD)$ ) {
|
||||
return 444;
|
||||
}
|
||||
|
||||
# Proxy to backend
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Auth endpoints with stricter limits
|
||||
location /api/auth/ {
|
||||
limit_req zone=auth_limit burst=5 nodelay;
|
||||
|
||||
proxy_pass http://backend:3000;
|
||||
}
|
||||
|
||||
# Block access to sensitive files
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ DO
|
||||
- Use HTTPS everywhere
|
||||
- Implement rate limiting
|
||||
- Validate all inputs
|
||||
- Use security headers
|
||||
- Log security events
|
||||
- Implement CORS properly
|
||||
- Use strong authentication
|
||||
- Version your APIs
|
||||
|
||||
### ❌ DON'T
|
||||
- Expose stack traces
|
||||
- Return detailed errors
|
||||
- Trust user input
|
||||
- Use HTTP for APIs
|
||||
- Skip input validation
|
||||
- Ignore rate limiting
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] HTTPS enforced
|
||||
- [ ] Authentication required
|
||||
- [ ] Authorization implemented
|
||||
- [ ] Rate limiting active
|
||||
- [ ] Input validation
|
||||
- [ ] CORS configured
|
||||
- [ ] Security headers set
|
||||
- [ ] Error handling secure
|
||||
- [ ] Logging enabled
|
||||
- [ ] API versioning
|
||||
|
||||
## Resources
|
||||
|
||||
- [OWASP API Security Top 10](https://owasp.org/www-project-api-security/)
|
||||
- [API Security Best Practices](https://github.com/shieldfy/API-Security-Checklist)
|
||||
- [JWT Best Practices](https://tools.ietf.org/html/rfc8725)
|
||||
384
.agents/skills/database-migration-management/SKILL.md
Normal file
@@ -0,0 +1,384 @@
|
||||
---
|
||||
name: database-migration-management
|
||||
description: Manage database migrations and schema versioning. Use when planning migrations, version control, rollback strategies, or data transformations in PostgreSQL and MySQL.
|
||||
---
|
||||
|
||||
# Database Migration Management
|
||||
|
||||
## Overview
|
||||
|
||||
Implement robust database migration systems with version control, rollback capabilities, and data transformation strategies. Includes migration frameworks and production deployment patterns.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Schema versioning and evolution
|
||||
- Data transformations and cleanup
|
||||
- Adding/removing tables and columns
|
||||
- Index creation and optimization
|
||||
- Migration testing and validation
|
||||
- Rollback planning and execution
|
||||
- Multi-environment deployments
|
||||
|
||||
## Migration Framework Setup
|
||||
|
||||
### PostgreSQL - Schema Versioning
|
||||
|
||||
```sql
|
||||
-- Create migrations tracking table
|
||||
CREATE TABLE schema_migrations (
|
||||
version BIGINT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
duration_ms INTEGER,
|
||||
checksum VARCHAR(64)
|
||||
);
|
||||
|
||||
-- Create migration log table
|
||||
CREATE TABLE migration_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
version BIGINT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
error_message TEXT,
|
||||
rolled_back_at TIMESTAMP,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Function to record migration
|
||||
CREATE OR REPLACE FUNCTION record_migration(
|
||||
p_version BIGINT,
|
||||
p_name VARCHAR,
|
||||
p_duration_ms INTEGER
|
||||
) RETURNS void AS $$
|
||||
BEGIN
|
||||
INSERT INTO schema_migrations (version, name, duration_ms)
|
||||
VALUES (p_version, p_name, p_duration_ms)
|
||||
ON CONFLICT (version) DO UPDATE
|
||||
SET executed_at = CURRENT_TIMESTAMP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
```
|
||||
|
||||
### MySQL - Migration Tracking
|
||||
|
||||
```sql
|
||||
-- Create migrations table for MySQL
|
||||
CREATE TABLE schema_migrations (
|
||||
version BIGINT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
duration_ms INT,
|
||||
checksum VARCHAR(64)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migration status table
|
||||
CREATE TABLE migration_status (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
version BIGINT NOT NULL,
|
||||
status ENUM('pending', 'completed', 'failed', 'rolled_back'),
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
## Common Migration Patterns
|
||||
|
||||
### Adding Columns
|
||||
|
||||
**PostgreSQL - Safe Column Addition:**
|
||||
|
||||
```sql
|
||||
-- Migration: 20240115_001_add_phone_to_users.sql
|
||||
|
||||
-- Add column with default (non-blocking)
|
||||
ALTER TABLE users
|
||||
ADD COLUMN phone VARCHAR(20) DEFAULT '';
|
||||
|
||||
-- Add constraint after population
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT phone_format
|
||||
CHECK (phone = '' OR phone ~ '^\+?[0-9\-\(\)]{10,}$');
|
||||
|
||||
-- Create index
|
||||
CREATE INDEX CONCURRENTLY idx_users_phone ON users(phone);
|
||||
|
||||
-- Rollback:
|
||||
-- DROP INDEX CONCURRENTLY idx_users_phone;
|
||||
-- ALTER TABLE users DROP COLUMN phone;
|
||||
```
|
||||
|
||||
**MySQL - Column Addition:**
|
||||
|
||||
```sql
|
||||
-- Migration: 20240115_001_add_phone_to_users.sql
|
||||
|
||||
-- Add column with ALTER
|
||||
ALTER TABLE users
|
||||
ADD COLUMN phone VARCHAR(20) DEFAULT '',
|
||||
ADD INDEX idx_phone (phone);
|
||||
|
||||
-- Rollback:
|
||||
-- ALTER TABLE users DROP COLUMN phone;
|
||||
```
|
||||
|
||||
### Renaming Columns
|
||||
|
||||
**PostgreSQL - Column Rename:**
|
||||
|
||||
```sql
|
||||
-- Migration: 20240115_002_rename_user_name_columns.sql
|
||||
|
||||
-- Rename columns
|
||||
ALTER TABLE users RENAME COLUMN user_name TO full_name;
|
||||
ALTER TABLE users RENAME COLUMN user_email TO email_address;
|
||||
|
||||
-- Update indexes
|
||||
REINDEX TABLE users;
|
||||
|
||||
-- Rollback:
|
||||
-- ALTER TABLE users RENAME COLUMN email_address TO user_email;
|
||||
-- ALTER TABLE users RENAME COLUMN full_name TO user_name;
|
||||
```
|
||||
|
||||
### Creating Indexes Non-blocking
|
||||
|
||||
**PostgreSQL - Concurrent Index Creation:**
|
||||
|
||||
```sql
|
||||
-- Migration: 20240115_003_add_performance_indexes.sql
|
||||
|
||||
-- Create indexes without blocking writes
|
||||
CREATE INDEX CONCURRENTLY idx_orders_user_created
|
||||
ON orders(user_id, created_at DESC);
|
||||
|
||||
CREATE INDEX CONCURRENTLY idx_products_category_active
|
||||
ON products(category_id)
|
||||
WHERE active = true;
|
||||
|
||||
-- Verify index creation
|
||||
SELECT schemaname, tablename, indexname, idx_scan
|
||||
FROM pg_stat_user_indexes
|
||||
WHERE indexname LIKE 'idx_%';
|
||||
|
||||
-- Rollback:
|
||||
-- DROP INDEX CONCURRENTLY idx_orders_user_created;
|
||||
-- DROP INDEX CONCURRENTLY idx_products_category_active;
|
||||
```
|
||||
|
||||
**MySQL - Online Index Creation:**
|
||||
|
||||
```sql
|
||||
-- Migration: 20240115_003_add_performance_indexes.sql
|
||||
|
||||
-- Create indexes with ALGORITHM=INPLACE and LOCK=NONE
|
||||
ALTER TABLE orders
|
||||
ADD INDEX idx_user_created (user_id, created_at),
|
||||
ALGORITHM=INPLACE, LOCK=NONE;
|
||||
|
||||
-- Monitor progress
|
||||
SELECT * FROM INFORMATION_SCHEMA.PROCESSLIST
|
||||
WHERE INFO LIKE 'ALTER TABLE%';
|
||||
```
|
||||
|
||||
### Data Transformations
|
||||
|
||||
**PostgreSQL - Data Cleanup Migration:**
|
||||
|
||||
```sql
|
||||
-- Migration: 20240115_004_normalize_email_addresses.sql
|
||||
|
||||
-- Normalize existing email addresses
|
||||
UPDATE users
|
||||
SET email = LOWER(TRIM(email))
|
||||
WHERE email != LOWER(TRIM(email));
|
||||
|
||||
-- Remove duplicates by keeping latest
|
||||
DELETE FROM users
|
||||
WHERE id NOT IN (
|
||||
SELECT DISTINCT ON (LOWER(email)) id
|
||||
FROM users
|
||||
ORDER BY LOWER(email), created_at DESC
|
||||
);
|
||||
|
||||
-- Rollback: Restore from backup (no safe rollback for data changes)
|
||||
```
|
||||
|
||||
**MySQL - Bulk Data Update:**
|
||||
|
||||
```sql
|
||||
-- Migration: 20240115_004_update_product_categories.sql
|
||||
|
||||
-- Update multiple rows with JOIN
|
||||
UPDATE products p
|
||||
JOIN category_mapping cm ON p.old_category = cm.old_name
|
||||
SET p.category_id = cm.new_category_id
|
||||
WHERE p.old_category IS NOT NULL;
|
||||
|
||||
-- Verify update
|
||||
SELECT COUNT(*) as updated_count
|
||||
FROM products
|
||||
WHERE category_id IS NOT NULL;
|
||||
```
|
||||
|
||||
### Table Structure Changes
|
||||
|
||||
**PostgreSQL - Alter Table Migration:**
|
||||
|
||||
```sql
|
||||
-- Migration: 20240115_005_modify_order_columns.sql
|
||||
|
||||
-- Add new column
|
||||
ALTER TABLE orders
|
||||
ADD COLUMN status_updated_at TIMESTAMP;
|
||||
|
||||
-- Add constraint
|
||||
ALTER TABLE orders
|
||||
ADD CONSTRAINT valid_status
|
||||
CHECK (status IN ('pending', 'processing', 'completed', 'cancelled'));
|
||||
|
||||
-- Set default for existing records
|
||||
UPDATE orders
|
||||
SET status_updated_at = updated_at
|
||||
WHERE status_updated_at IS NULL;
|
||||
|
||||
-- Make column NOT NULL
|
||||
ALTER TABLE orders
|
||||
ALTER COLUMN status_updated_at SET NOT NULL;
|
||||
|
||||
-- Rollback:
|
||||
-- ALTER TABLE orders DROP COLUMN status_updated_at;
|
||||
-- ALTER TABLE orders DROP CONSTRAINT valid_status;
|
||||
```
|
||||
|
||||
## Testing Migrations
|
||||
|
||||
**PostgreSQL - Test in Transaction:**
|
||||
|
||||
```sql
|
||||
-- Test migration in transaction (will be rolled back)
|
||||
BEGIN;
|
||||
|
||||
-- Run migration statements
|
||||
ALTER TABLE users ADD COLUMN test_column VARCHAR(255);
|
||||
|
||||
-- Validate data
|
||||
SELECT COUNT(*) FROM users;
|
||||
SELECT COUNT(DISTINCT email) FROM users;
|
||||
|
||||
-- Rollback if issues found
|
||||
ROLLBACK;
|
||||
|
||||
-- Or commit if all good
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
**Validate Migration:**
|
||||
|
||||
```sql
|
||||
-- Check migration was applied
|
||||
SELECT version, name, executed_at FROM schema_migrations
|
||||
WHERE version = 20240115005;
|
||||
|
||||
-- Verify table structure
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'users'
|
||||
ORDER BY ordinal_position;
|
||||
```
|
||||
|
||||
## Rollback Strategies
|
||||
|
||||
**PostgreSQL - Bidirectional Migrations:**
|
||||
|
||||
```sql
|
||||
-- Migration file: 20240115_006_add_user_status.sql
|
||||
|
||||
-- ===== UP =====
|
||||
CREATE TYPE user_status AS ENUM ('active', 'suspended', 'deleted');
|
||||
ALTER TABLE users ADD COLUMN status user_status DEFAULT 'active';
|
||||
|
||||
-- ===== DOWN =====
|
||||
-- ALTER TABLE users DROP COLUMN status;
|
||||
-- DROP TYPE user_status;
|
||||
```
|
||||
|
||||
**Rollback Execution:**
|
||||
|
||||
```sql
|
||||
-- Function to rollback to specific version
|
||||
CREATE OR REPLACE FUNCTION rollback_to_version(p_target_version BIGINT)
|
||||
RETURNS TABLE (version BIGINT, name VARCHAR, status VARCHAR) AS $$
|
||||
BEGIN
|
||||
-- Execute down migrations in reverse order
|
||||
RETURN QUERY
|
||||
SELECT m.version, m.name, 'rolled_back'::VARCHAR
|
||||
FROM schema_migrations m
|
||||
WHERE m.version > p_target_version
|
||||
ORDER BY m.version DESC;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
**Safe Migration Checklist:**
|
||||
|
||||
- Test migration on production-like database
|
||||
- Verify backup exists before migration
|
||||
- Schedule during low-traffic window
|
||||
- Monitor table locks and long-running queries
|
||||
- Have rollback plan ready
|
||||
- Test rollback procedure
|
||||
- Document all changes
|
||||
- Run in transaction when possible
|
||||
- Verify data integrity after migration
|
||||
- Update application code coordinated with migration
|
||||
|
||||
**PostgreSQL - Long Transaction Safety:**
|
||||
|
||||
```sql
|
||||
-- Use statement timeout to prevent hanging migrations
|
||||
SET statement_timeout = '30min';
|
||||
|
||||
-- Use lock timeout to prevent deadlocks
|
||||
SET lock_timeout = '5min';
|
||||
|
||||
-- Run migration with timeouts
|
||||
ALTER TABLE large_table
|
||||
ADD COLUMN new_column VARCHAR(255),
|
||||
ALGORITHM='INPLACE';
|
||||
```
|
||||
|
||||
## Migration Examples
|
||||
|
||||
**Combined Migration - Multiple Changes:**
|
||||
|
||||
```sql
|
||||
-- Migration: 20240115_007_refactor_user_tables.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Create new column with data from old column
|
||||
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
|
||||
UPDATE users SET full_name = first_name || ' ' || last_name;
|
||||
|
||||
-- 2. Add indexes
|
||||
CREATE INDEX idx_users_full_name ON users(full_name);
|
||||
|
||||
-- 3. Add new constraint
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT email_unique UNIQUE(email);
|
||||
|
||||
-- 4. Drop old columns (after verification)
|
||||
-- ALTER TABLE users DROP COLUMN first_name;
|
||||
-- ALTER TABLE users DROP COLUMN last_name;
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Flyway - Java Migration Tool](https://flywaydb.org/)
|
||||
- [Liquibase - Database Changelog](https://www.liquibase.org/)
|
||||
- [Alembic - Python Migration](https://alembic.sqlalchemy.org/)
|
||||
- [PostgreSQL ALTER TABLE](https://www.postgresql.org/docs/current/sql-altertable.html)
|
||||
- [MySQL ALTER TABLE](https://dev.mysql.com/doc/refman/8.0/en/alter-table.html)
|
||||
133
.agents/skills/find-skills/SKILL.md
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
name: find-skills
|
||||
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
|
||||
---
|
||||
|
||||
# Find Skills
|
||||
|
||||
This skill helps you discover and install skills from the open agent skills ecosystem.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when the user:
|
||||
|
||||
- Asks "how do I do X" where X might be a common task with an existing skill
|
||||
- Says "find a skill for X" or "is there a skill for X"
|
||||
- Asks "can you do X" where X is a specialized capability
|
||||
- Expresses interest in extending agent capabilities
|
||||
- Wants to search for tools, templates, or workflows
|
||||
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
|
||||
|
||||
## What is the Skills CLI?
|
||||
|
||||
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
|
||||
|
||||
**Key commands:**
|
||||
|
||||
- `npx skills find [query]` - Search for skills interactively or by keyword
|
||||
- `npx skills add <package>` - Install a skill from GitHub or other sources
|
||||
- `npx skills check` - Check for skill updates
|
||||
- `npx skills update` - Update all installed skills
|
||||
|
||||
**Browse skills at:** https://skills.sh/
|
||||
|
||||
## How to Help Users Find Skills
|
||||
|
||||
### Step 1: Understand What They Need
|
||||
|
||||
When a user asks for help with something, identify:
|
||||
|
||||
1. The domain (e.g., React, testing, design, deployment)
|
||||
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
|
||||
3. Whether this is a common enough task that a skill likely exists
|
||||
|
||||
### Step 2: Search for Skills
|
||||
|
||||
Run the find command with a relevant query:
|
||||
|
||||
```bash
|
||||
npx skills find [query]
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
- User asks "how do I make my React app faster?" → `npx skills find react performance`
|
||||
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
|
||||
- User asks "I need to create a changelog" → `npx skills find changelog`
|
||||
|
||||
The command will return results like:
|
||||
|
||||
```
|
||||
Install with npx skills add <owner/repo@skill>
|
||||
|
||||
vercel-labs/agent-skills@vercel-react-best-practices
|
||||
└ https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices
|
||||
```
|
||||
|
||||
### Step 3: Present Options to the User
|
||||
|
||||
When you find relevant skills, present them to the user with:
|
||||
|
||||
1. The skill name and what it does
|
||||
2. The install command they can run
|
||||
3. A link to learn more at skills.sh
|
||||
|
||||
Example response:
|
||||
|
||||
```
|
||||
I found a skill that might help! The "vercel-react-best-practices" skill provides
|
||||
React and Next.js performance optimization guidelines from Vercel Engineering.
|
||||
|
||||
To install it:
|
||||
npx skills add vercel-labs/agent-skills@vercel-react-best-practices
|
||||
|
||||
Learn more: https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices
|
||||
```
|
||||
|
||||
### Step 4: Offer to Install
|
||||
|
||||
If the user wants to proceed, you can install the skill for them:
|
||||
|
||||
```bash
|
||||
npx skills add <owner/repo@skill> -g -y
|
||||
```
|
||||
|
||||
The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
|
||||
|
||||
## Common Skill Categories
|
||||
|
||||
When searching, consider these common categories:
|
||||
|
||||
| Category | Example Queries |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| Web Development | react, nextjs, typescript, css, tailwind |
|
||||
| Testing | testing, jest, playwright, e2e |
|
||||
| DevOps | deploy, docker, kubernetes, ci-cd |
|
||||
| Documentation | docs, readme, changelog, api-docs |
|
||||
| Code Quality | review, lint, refactor, best-practices |
|
||||
| Design | ui, ux, design-system, accessibility |
|
||||
| Productivity | workflow, automation, git |
|
||||
|
||||
## Tips for Effective Searches
|
||||
|
||||
1. **Use specific keywords**: "react testing" is better than just "testing"
|
||||
2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
|
||||
3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
|
||||
|
||||
## When No Skills Are Found
|
||||
|
||||
If no relevant skills exist:
|
||||
|
||||
1. Acknowledge that no existing skill was found
|
||||
2. Offer to help with the task directly using your general capabilities
|
||||
3. Suggest the user could create their own skill with `npx skills init`
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
I searched for skills related to "xyz" but didn't find any matches.
|
||||
I can still help you with this task directly! Would you like me to proceed?
|
||||
|
||||
If this is something you do often, you could create your own skill:
|
||||
npx skills init my-xyz-skill
|
||||
```
|
||||
292
.agents/skills/gcp-cloud-run/SKILL.md
Normal file
@@ -0,0 +1,292 @@
|
||||
---
|
||||
name: gcp-cloud-run
|
||||
description: "Specialized skill for building production-ready serverless applications on GCP. Covers Cloud Run services (containerized), Cloud Run Functions (event-driven), cold start optimization, and event-dri..."
|
||||
source: vibeship-spawner-skills (Apache 2.0)
|
||||
risk: unknown
|
||||
---
|
||||
|
||||
# GCP Cloud Run
|
||||
|
||||
## Patterns
|
||||
|
||||
### Cloud Run Service Pattern
|
||||
|
||||
Containerized web service on Cloud Run
|
||||
|
||||
**When to use**: ['Web applications and APIs', 'Need any runtime or library', 'Complex services with multiple endpoints', 'Stateless containerized workloads']
|
||||
|
||||
```javascript
|
||||
```dockerfile
|
||||
# Dockerfile - Multi-stage build for smaller image
|
||||
FROM node:20-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
FROM node:20-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only production dependencies
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY src ./src
|
||||
COPY package.json ./
|
||||
|
||||
# Cloud Run uses PORT env variable
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
# Run as non-root user
|
||||
USER node
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
```
|
||||
|
||||
```javascript
|
||||
// src/index.js
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.get('/api/items/:id', async (req, res) => {
|
||||
try {
|
||||
const item = await getItem(req.params.id);
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('SIGTERM received, shutting down gracefully');
|
||||
server.close(() => {
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 8080;
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`Server listening on port ${PORT}`);
|
||||
});
|
||||
```
|
||||
|
||||
```yaml
|
||||
# cloudbuild.yaml
|
||||
steps:
|
||||
# Build the container image
|
||||
- name: 'gcr.io/cloud-builders/docker'
|
||||
args: ['build', '-t', 'gcr.io/$PROJECT_ID/my-service:$COMMIT_SHA', '.']
|
||||
|
||||
# Push the container image
|
||||
- name: 'gcr.io/cloud-builders/docker'
|
||||
args: ['push', 'gcr.io/$PROJECT_ID/my-service:$COMMIT_SHA']
|
||||
|
||||
# Deploy to Cloud Run
|
||||
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
|
||||
entrypoint: gcloud
|
||||
args:
|
||||
- 'run'
|
||||
- 'deploy'
|
||||
- 'my-service'
|
||||
- '--image=gcr.io/$PROJECT_ID/my-service:$COMMIT_SHA'
|
||||
- '--region=us-central1'
|
||||
- '--platform=managed'
|
||||
- '--allow-unauthenticated'
|
||||
- '--memory=512Mi'
|
||||
- '--cpu=1'
|
||||
- '--min-instances=1'
|
||||
- '--max-instances=100'
|
||||
|
||||
```
|
||||
|
||||
### Cloud Run Functions Pattern
|
||||
|
||||
Event-driven functions (formerly Cloud Functions)
|
||||
|
||||
**When to use**: ['Simple event handlers', 'Pub/Sub message processing', 'Cloud Storage triggers', 'HTTP webhooks']
|
||||
|
||||
```javascript
|
||||
```javascript
|
||||
// HTTP Function
|
||||
// index.js
|
||||
const functions = require('@google-cloud/functions-framework');
|
||||
|
||||
functions.http('helloHttp', (req, res) => {
|
||||
const name = req.query.name || req.body.name || 'World';
|
||||
res.send(`Hello, ${name}!`);
|
||||
});
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Pub/Sub Function
|
||||
const functions = require('@google-cloud/functions-framework');
|
||||
|
||||
functions.cloudEvent('processPubSub', (cloudEvent) => {
|
||||
// Decode Pub/Sub message
|
||||
const message = cloudEvent.data.message;
|
||||
const data = message.data
|
||||
? JSON.parse(Buffer.from(message.data, 'base64').toString())
|
||||
: {};
|
||||
|
||||
console.log('Received message:', data);
|
||||
|
||||
// Process message
|
||||
processMessage(data);
|
||||
});
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Cloud Storage Function
|
||||
const functions = require('@google-cloud/functions-framework');
|
||||
|
||||
functions.cloudEvent('processStorageEvent', async (cloudEvent) => {
|
||||
const file = cloudEvent.data;
|
||||
|
||||
console.log(`Event: ${cloudEvent.type}`);
|
||||
console.log(`Bucket: ${file.bucket}`);
|
||||
console.log(`File: ${file.name}`);
|
||||
|
||||
if (cloudEvent.type === 'google.cloud.storage.object.v1.finalized') {
|
||||
await processUploadedFile(file.bucket, file.name);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
# Deploy HTTP function
|
||||
gcloud functions deploy hello-http \
|
||||
--gen2 \
|
||||
--runtime nodejs20 \
|
||||
--trigger-http \
|
||||
--allow-unauthenticated \
|
||||
--region us-central1
|
||||
|
||||
# Deploy Pub/Sub function
|
||||
gcloud functions deploy process-messages \
|
||||
--gen2 \
|
||||
--runtime nodejs20 \
|
||||
--trigger-topic my-topic \
|
||||
--region us-central1
|
||||
|
||||
# Deploy Cloud Storage function
|
||||
gcloud functions deploy process-uploads \
|
||||
--gen2 \
|
||||
--runtime nodejs20 \
|
||||
--trigger-event-filters="type=google.cloud.storage.object.v1.finalized" \
|
||||
--trigger-event-filters="bucket=my-bucket" \
|
||||
--region us-central1
|
||||
```
|
||||
```
|
||||
|
||||
### Cold Start Optimization Pattern
|
||||
|
||||
Minimize cold start latency for Cloud Run
|
||||
|
||||
**When to use**: ['Latency-sensitive applications', 'User-facing APIs', 'High-traffic services']
|
||||
|
||||
```javascript
|
||||
## 1. Enable Startup CPU Boost
|
||||
|
||||
```bash
|
||||
gcloud run deploy my-service \
|
||||
--cpu-boost \
|
||||
--region us-central1
|
||||
```
|
||||
|
||||
## 2. Set Minimum Instances
|
||||
|
||||
```bash
|
||||
gcloud run deploy my-service \
|
||||
--min-instances 1 \
|
||||
--region us-central1
|
||||
```
|
||||
|
||||
## 3. Optimize Container Image
|
||||
|
||||
```dockerfile
|
||||
# Use distroless for minimal image
|
||||
FROM node:20-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
FROM gcr.io/distroless/nodejs20-debian12
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY src ./src
|
||||
CMD ["src/index.js"]
|
||||
```
|
||||
|
||||
## 4. Lazy Initialize Heavy Dependencies
|
||||
|
||||
```javascript
|
||||
// Lazy load heavy libraries
|
||||
let bigQueryClient = null;
|
||||
|
||||
function getBigQueryClient() {
|
||||
if (!bigQueryClient) {
|
||||
const { BigQuery } = require('@google-cloud/bigquery');
|
||||
bigQueryClient = new BigQuery();
|
||||
}
|
||||
return bigQueryClient;
|
||||
}
|
||||
|
||||
// Only initialize when needed
|
||||
app.get('/api/analytics', async (req, res) => {
|
||||
const client = getBigQueryClient();
|
||||
const results = await client.query({...});
|
||||
res.json(results);
|
||||
});
|
||||
```
|
||||
|
||||
## 5. Increase Memory (More CPU)
|
||||
|
||||
```bash
|
||||
# Higher memory = more CPU during startup
|
||||
gcloud run deploy my-service \
|
||||
--memory 1Gi \
|
||||
--cpu 2 \
|
||||
--region us-central1
|
||||
```
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### ❌ CPU-Intensive Work Without Concurrency=1
|
||||
|
||||
**Why bad**: CPU is shared across concurrent requests. CPU-bound work
|
||||
will starve other requests, causing timeouts.
|
||||
|
||||
### ❌ Writing Large Files to /tmp
|
||||
|
||||
**Why bad**: /tmp is an in-memory filesystem. Large files consume
|
||||
your memory allocation and can cause OOM errors.
|
||||
|
||||
### ❌ Long-Running Background Tasks
|
||||
|
||||
**Why bad**: Cloud Run throttles CPU to near-zero when not handling
|
||||
requests. Background tasks will be extremely slow or stall.
|
||||
|
||||
## ⚠️ Sharp Edges
|
||||
|
||||
| Issue | Severity | Solution |
|
||||
|-------|----------|----------|
|
||||
| Issue | high | ## Calculate memory including /tmp usage |
|
||||
| Issue | high | ## Set appropriate concurrency |
|
||||
| Issue | high | ## Enable CPU always allocated |
|
||||
| Issue | medium | ## Configure connection pool with keep-alive |
|
||||
| Issue | high | ## Enable startup CPU boost |
|
||||
| Issue | medium | ## Explicitly set execution environment |
|
||||
| Issue | medium | ## Set consistent timeouts |
|
||||
|
||||
## When to Use
|
||||
This skill is applicable to execute the workflow or actions described in the overview.
|
||||
@@ -37,6 +37,7 @@ Before any design work, ensure you have loaded:
|
||||
- `krow-mobile-design-system` — Colors, typography, icons, spacing, component patterns
|
||||
- `frontend-design`
|
||||
- `ui-ux-pro-max`
|
||||
- `mobile-design`
|
||||
|
||||
Load additional skills as needed for specific design challenges.
|
||||
|
||||
|
||||
@@ -21,19 +21,42 @@ This skill defines the design token system, component patterns, screen structure
|
||||
|
||||
### Color Palette
|
||||
|
||||
**Primary:**
|
||||
| Token | Hex | Usage |
|
||||
|-------|-----|-------|
|
||||
| Primary | `#0A39DF` | CTAs, active states, links, selected chips, nav active icons, pay rates |
|
||||
| Foreground | `#121826` | Headings, primary text, dark UI elements |
|
||||
| Text Secondary | `#6A7382` | Labels, captions, inactive nav, section headers, placeholder text, back chevrons |
|
||||
| Secondary BG | `#F1F3F5` | Subtle backgrounds, dividers, map placeholders |
|
||||
| Border | `#D1D5DB` | Card borders, unselected chip borders, outline button borders |
|
||||
| Input Border | `#E2E8F0` | Text input borders (lighter than general border) |
|
||||
| Destructive | `#F04444` | Error states, destructive actions (e.g., Request Swap) |
|
||||
| Background | `#FAFBFC` | Page/artboard background |
|
||||
| Card BG | `#FFFFFF` | Card surfaces, input backgrounds |
|
||||
| Success | `#059669` | Active status dot, checkmark icons, requirement met |
|
||||
| Warning Amber | `#D97706` | Urgent/Pending badge text |
|
||||
| Foreground | `#121826` | Headings, primary text, dark UI elements |
|
||||
| Primary | `#0A39DF` | CTAs, active states, links, selected chips, nav active icons, pay rates |
|
||||
| Primary Fg | `#F7FAFC` | Light foreground on primary surfaces |
|
||||
|
||||
**Semantic:**
|
||||
| Token | Hex | Usage |
|
||||
|-------|-----|-------|
|
||||
| Secondary | `#F1F3F5` | Subtle backgrounds, dividers, secondary button bg |
|
||||
| Accent | `#F9E547` | Highlight, warning chip accents |
|
||||
| Text Secondary | `#6A7382` | Labels, captions, inactive nav, section headers, back chevrons |
|
||||
| Destructive | `#F04444` | Error states, destructive actions |
|
||||
|
||||
**Border & Input:**
|
||||
| Token | Hex | Usage |
|
||||
|-------|-----|-------|
|
||||
| Border | `#D1D5DB` | Card borders, unselected chip borders, outline button borders |
|
||||
| Input | `#F5F6F8` | Text input background (read-only/disabled states) |
|
||||
|
||||
**Status:**
|
||||
| Token | Hex | Usage |
|
||||
|-------|-----|-------|
|
||||
| Success | `#10B981` | Accept buttons, active status, checkmarks |
|
||||
| Info | `#0A39DF` | Informational badges (same as Primary) |
|
||||
| Warning | `#D97706` | Urgent/Pending badge text |
|
||||
| Neutral | `#94A3B8` | Disabled text, placeholder text |
|
||||
| Danger | `#F04444` | Error badges, destructive (same as Destructive) |
|
||||
|
||||
**Gradients:**
|
||||
| Token | Definition | Usage |
|
||||
|-------|-----------|-------|
|
||||
| mobileHero | Foreground → Primary → Primary Fg | Hero sections, splash screens |
|
||||
| adminHero | Primary → Success | Admin/dashboard hero cards |
|
||||
|
||||
### Semantic Badge Colors
|
||||
|
||||
@@ -44,74 +67,121 @@ This skill defines the design token system, component patterns, screen structure
|
||||
| Pending | `#FEF9EE` | `#D97706` |
|
||||
| Urgent | `#FEF9EE` | `#D97706` |
|
||||
| One-Time | `#ECFDF5` | `#059669` |
|
||||
| Recurring | `#EBF0FF` | `#0A39DF` (use `#EFF6FF` bg on detail pages) |
|
||||
| Recurring | `#EFF6FF` | `#0A39DF` |
|
||||
|
||||
### Typography
|
||||
|
||||
| Style | Font | Size | Weight | Line Height | Usage |
|
||||
|-------|------|------|--------|-------------|-------|
|
||||
| Display | Inter Tight | 28px | 700 | 34px | Page titles (Find Shifts, My Shifts) |
|
||||
| H1 | Inter Tight | 24px | 700 | 30px | Detail page titles (venue names) |
|
||||
| H2 | Inter Tight | 20px | 700 | 26px | Section headings |
|
||||
| H3 | Inter Tight | 18px | 700 | 22px | Card titles, schedule values |
|
||||
| Body Large | Manrope | 16px | 600 | 20px | Button text, CTA labels |
|
||||
| Body Default | Manrope | 14px | 400-500 | 18px | Body text, descriptions |
|
||||
| Body Small | Manrope | 13px | 400-500 | 16px | Card metadata, time/pay info |
|
||||
| Caption | Manrope | 12px | 500-600 | 16px | Small chip text, tab labels |
|
||||
| Section Label | Manrope | 11px | 700 | 14px | Uppercase section headers (letter-spacing: 0.06em) |
|
||||
| Badge Text | Manrope | 11px | 600-700 | 14px | Status badge labels (letter-spacing: 0.04em) |
|
||||
| Nav Label | Manrope | 10px | 600 | 12px | Bottom nav labels |
|
||||
**Inter Tight — Headings:**
|
||||
| Style | Size | Weight | Letter Spacing | Line Height | Usage |
|
||||
|-------|------|--------|---------------|-------------|-------|
|
||||
| Display | 28px | 700 | -0.02em | 34px | Page titles (Find Shifts, My Shifts) |
|
||||
| Heading 1 | 24px | 700 | -0.02em | 30px | Detail page titles (venue names) |
|
||||
| Heading 2 | 20px | 700 | -0.01em | 26px | Section headings |
|
||||
| Heading 3 | 18px | 700 | -0.01em | 22px | Card titles, schedule values |
|
||||
| Heading 4 | 16px | 700 | — | 20px | Card titles (standard cards), sub-headings |
|
||||
|
||||
### Spacing
|
||||
**Manrope — Body:**
|
||||
| Style | Size | Weight | Line Height | Usage |
|
||||
|-------|------|--------|-------------|-------|
|
||||
| Body Large Regular | 16px | 400 | 20px | Long body text |
|
||||
| Body Large Medium | 16px | 500 | 20px | Emphasized body text |
|
||||
| Body Large Semibold | 16px | 600 | 20px | Strong labels, Full Width CTA text (15px) |
|
||||
| Body Default | 14px | 400 | 18px | Body text, descriptions |
|
||||
| Body Default Semibold | 14px | 600 | 18px | Button text, chip text, bold body |
|
||||
| Caption | 12px | 400 | 16px | Small text, helper text, input labels |
|
||||
| Overline Label | 11px | 600 | 14px | Uppercase section headers (letter-spacing: 0.06em) |
|
||||
| Badge Text | 11px | 600-700 | 14px | Status badge labels (letter-spacing: 0.04em) |
|
||||
| Nav Label | 10px | 600 | 12px | Bottom nav labels |
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| Page padding | 24px | Horizontal padding from screen edge |
|
||||
| Section gap | 16-24px | Between major content sections |
|
||||
| Group gap | 8-12px | Within a section (e.g., label to input) |
|
||||
| Element gap | 4px | Tight spacing (e.g., subtitle under title) |
|
||||
| Bottom safe area | 40px | Padding below last element / CTA |
|
||||
| xs | 4px | Tight spacing (subtitle under title) |
|
||||
| sm | 8px | Element gap within groups |
|
||||
| md | 12px | Group gap (label to input) |
|
||||
| lg | 16px | Card padding, medium section gap |
|
||||
| xl | 24px | Page margins, section gap |
|
||||
| 2xl | 32px | Large section separation |
|
||||
|
||||
**Page Layout:**
|
||||
| Token | Value |
|
||||
|-------|-------|
|
||||
| Page margins | 24px |
|
||||
| Section gap | 24px |
|
||||
| Card padding | 16px |
|
||||
| Element gap | 8-12px |
|
||||
| Background | `#FAFBFC` |
|
||||
| Bottom safe area | 40px |
|
||||
|
||||
### Border Radii
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| sm | 8px | Small chips, badges, status pills, map placeholder |
|
||||
| md | 12px | Cards, inputs, location cards, contact cards, search fields |
|
||||
| lg | 14px | Buttons, CTA containers, shift cards (Find Shifts) |
|
||||
| xl | 24px | Not commonly used |
|
||||
| sm | 8px | Small chips, badges, status pills |
|
||||
| md | 12px | Cards, inputs, list row containers, data rows |
|
||||
| lg | 18px | Hero cards, gradient cards |
|
||||
| xl | 24px | Large containers |
|
||||
| pill | 999px | Progress bar segments only |
|
||||
|
||||
### Icon Sizes
|
||||
|
||||
Standard sizes: 16, 20, 24, 32dp
|
||||
|
||||
## 2. Component Patterns
|
||||
|
||||
### Buttons
|
||||
|
||||
**Primary CTA:**
|
||||
- Background: `#0A39DF`, radius: 14px, height: 52px
|
||||
- Text: Manrope 16px/600, color: `#FFFFFF`
|
||||
- Padding: 16px vertical, 16px horizontal
|
||||
All buttons: radius 14px, padding 12px/24px, text Manrope 14px/600
|
||||
|
||||
**Secondary/Outline Button:**
|
||||
- Background: `#FFFFFF`, border: 1.5px `#D1D5DB`, radius: 14px, height: 52px
|
||||
- Text: Manrope 16px/600, color: `#121826`
|
||||
**Primary:**
|
||||
- Background: `#0A39DF`, text: `#FFFFFF`
|
||||
|
||||
**Destructive Outline Button:**
|
||||
- Background: `#FFFFFF`, border: 1.5px `#F04444`, radius: 14px
|
||||
- Text: Manrope 14px/600, color: `#F04444`
|
||||
**Secondary:**
|
||||
- Background: `#F1F3F5`, border: 1.5px `#D1D5DB`, text: `#121826`
|
||||
|
||||
**Destructive:**
|
||||
- Background: `#F04444`, text: `#FFFFFF`
|
||||
|
||||
**Disabled:**
|
||||
- Background: `#F1F3F5`, no border, text: `#94A3B8`
|
||||
|
||||
**Accept:**
|
||||
- Background: `#10B981`, text: `#FFFFFF`
|
||||
|
||||
**Dark:**
|
||||
- Background: `#121826`, text: `#FFFFFF`
|
||||
|
||||
**Full Width CTA:**
|
||||
- Same as Primary but `width: 100%`, padding 14px/24px, text Manrope 15px/600
|
||||
|
||||
**Back Icon Button (Bottom CTA):**
|
||||
- 52x52px square, border: 1.5px `#D1D5DB`, radius: 14px, background: `#FFFFFF`
|
||||
- 52x52px square, border: 0.5px `#D1D5DB`, radius: 14px, background: `#FFFFFF`
|
||||
- Contains chevron-left SVG (20x20, viewBox 0 0 24 24, stroke `#121826`, strokeWidth 2)
|
||||
- Path: `M15 18L9 12L15 6`
|
||||
|
||||
### Chips
|
||||
|
||||
All chips: border 1.5px, text Manrope 14px/600, gap 8px for icon+text
|
||||
|
||||
**Default (Large) - for role/skill selection:**
|
||||
- Selected: bg `#EFF6FF`, border 1.5px `#0A39DF`, radius 10px, padding 12px/16px
|
||||
- Checkmark icon (14x14, stroke `#0A39DF`), text Manrope 14px/600 `#0A39DF`
|
||||
- Unselected: bg `#FFFFFF`, border 1.5px `#6A7382`, radius 10px, padding 12px/16px
|
||||
- Selected: bg `#EFF6FF`, border `#0A39DF`, radius 10px, padding 12px/16px
|
||||
- Checkmark icon (14x14, stroke `#0A39DF`), text `#0A39DF`
|
||||
- Unselected: bg `#FFFFFF`, border `#6A7382`, radius 10px, padding 12px/16px
|
||||
- Text Manrope 14px/500 `#6A7382`
|
||||
|
||||
**Warning Chips:**
|
||||
- Selected: bg `#F9E5471A`, border `#E6A817`, radius 10px, padding 12px/16px
|
||||
- Checkmark icon (stroke `#E6A817`), text `#E6A817`
|
||||
- Unselected: bg `#FFFFFF`, border `#F0D78C`, radius 10px, padding 12px/16px
|
||||
- Text `#E6A817`
|
||||
|
||||
**Error Chips:**
|
||||
- Selected: bg `#FEF2F2`, border `#F04444`, radius 10px, padding 12px/16px
|
||||
- Checkmark icon (stroke `#F04444`), text `#F04444`
|
||||
- Unselected: bg `#FFFFFF`, border `#FECACA`, radius 10px, padding 12px/16px
|
||||
- Text `#F04444`
|
||||
|
||||
**Small - for tabs, filters:**
|
||||
- Selected: bg `#EFF6FF`, border 1.5px `#0A39DF`, radius 8px, padding 6px/12px
|
||||
- Checkmark icon (12x12), text Manrope 12px/600 `#0A39DF`
|
||||
@@ -119,8 +189,6 @@ This skill defines the design token system, component patterns, screen structure
|
||||
- Text Manrope 12px/500 `#6A7382`
|
||||
- Active (filled): bg `#0A39DF`, radius 8px, padding 6px/12px
|
||||
- Text Manrope 12px/600 `#FFFFFF`
|
||||
- Dark (filters button): bg `#121826`, radius 8px, padding 6px/12px
|
||||
- Text Manrope 12px/600 `#FFFFFF`, with leading icon
|
||||
|
||||
**Status Badges:**
|
||||
- Radius: 8px, padding: 4px/8px
|
||||
@@ -131,39 +199,65 @@ This skill defines the design token system, component patterns, screen structure
|
||||
|
||||
- Border: 1.5px `#E2E8F0`, radius: 12px, padding: 12px/14px
|
||||
- Background: `#FFFFFF`
|
||||
- Placeholder: Manrope 14px/400, color `#6A7382`
|
||||
- Filled: Manrope 14px/500, color `#121826`
|
||||
- Label above: Manrope 14px/500, color `#121826`
|
||||
- Placeholder: Manrope 14px/400, color `#94A3B8`
|
||||
- Filled: Manrope 14px/400, color `#111827`
|
||||
- Label above: Manrope 12px/400, spacing 0%:
|
||||
- Default/filled: color `#94A3B8`
|
||||
- Filled with value: color `#6A7382`
|
||||
- Focused: color `#0A39DF`
|
||||
- Error: color `#F04444`
|
||||
- Disabled: color `#94A3B8`
|
||||
- Focused: border color `#0A39DF`, border-width 2px
|
||||
- Error: border color `#F04444`, helper text `#F04444`
|
||||
- Error: border color `#F04444`, border-width 2px, background `#FEF2F2`
|
||||
- Error helper text: Manrope 12px/400, color `#F04444`
|
||||
|
||||
### Cards (Shift List Items)
|
||||
### Border Width
|
||||
|
||||
- Background: `#FFFFFF`, border: 1px `#D1D5DB`, radius: 12-14px
|
||||
- Padding: 16px
|
||||
- Content: venue name (Manrope 15px/600 `#121826`), subtitle (Manrope 13px/400 `#6A7382`)
|
||||
- Metadata row: icon (14px, `#6A7382`) + text (Manrope 13px/500 `#6A7382`)
|
||||
- Pay rate: Inter Tight 18px/700 `#0A39DF`
|
||||
- **Standard border width: `0.5px`** — All card borders, dividers, and outline buttons use `0.5px` unless explicitly stated otherwise.
|
||||
- **Text inputs: `1.5px`** — To ensure visibility and distinction from card borders.
|
||||
- **Chips: `1.5px`** — All chip variants (default, warning, error, small).
|
||||
- **Secondary buttons: `1.5px`** — Outline/secondary button borders.
|
||||
|
||||
### Schedule/Pay Info Cards
|
||||
### Cards
|
||||
|
||||
- Two-column layout with 12px gap
|
||||
- Background: `#FFFFFF`, border: 1px `#D1D5DB`, radius: 12px, padding: 16px
|
||||
- Label: Manrope 11px/500-700 uppercase `#6A7382` (letter-spacing 0.05em)
|
||||
- Value: Inter Tight 18px/700 `#121826` (schedule) or `#121826` (pay)
|
||||
- Sub-text: Manrope 13px/400 `#6A7382`
|
||||
**Standard Card:**
|
||||
- Background: `#FFFFFF`, border: 0.5px `#D1D5DB`, radius: 12px, padding: 16px
|
||||
- Title: Inter Tight 16px/700 `#121826`
|
||||
- Body: Manrope 14px/400 `#6A7382`
|
||||
- Gap: 8px between title and body
|
||||
|
||||
**Hero / Gradient Card:**
|
||||
- Radius: 18px, padding: 20px, gap: 6px
|
||||
- Background: gradient (mobileHero or adminHero)
|
||||
- Label: Manrope 12px/400 `#FFFFFFB3` (white 70%)
|
||||
- Value: Inter Tight 28px/700 `#FFFFFF`
|
||||
- Sub-text: Manrope 12px/400 `#FFFFFF99` (white 60%)
|
||||
|
||||
**List Rows (grouped):**
|
||||
- Container: radius 12px, border 0.5px `#D1D5DB`, background `#FFFFFF`, overflow clip
|
||||
- Row: padding ~16px, gap between text elements 2px
|
||||
- Row title: Manrope 14px/600 `#121826`
|
||||
- Row subtitle: Manrope 13px/400 `#6A7382`
|
||||
- Row divider: 1px `#D1D5DB` (between rows, not on last)
|
||||
- Chevron: `›` or SVG, `#6A7382`
|
||||
|
||||
**Data Row:**
|
||||
- Background: `#F1F3F5`, radius: 12px, padding: 12px
|
||||
- Label: Manrope 11px/400 `#6A7382`
|
||||
- Value: Inter Tight 20px/700 `#121826`
|
||||
- Layout: flex row, equal width columns, gap 8px
|
||||
|
||||
### Contact/Info Rows
|
||||
|
||||
- Container: radius 12px, border 1px `#D1D5DB`, background `#FFFFFF`, overflow clip
|
||||
- Row: padding 13px/16px, gap 10px, border-bottom 1px `#F1F3F5` (except last)
|
||||
- Container: radius 12px, border 0.5px `#D1D5DB`, background `#FFFFFF`, overflow clip
|
||||
- Row: padding 13px/16px, gap 10px, border-bottom 0.5px `#F1F3F5` (except last)
|
||||
- Icon: 16px, stroke `#6A7382`
|
||||
- Label: Manrope 13px/500 `#6A7382`, width 72px fixed
|
||||
- Value: Manrope 13px/500 `#121826` (or `#0A39DF` for phone/links)
|
||||
|
||||
### Section Headers
|
||||
|
||||
- Text: Manrope 11px/700, uppercase, letter-spacing 0.06em, color `#6A7382`
|
||||
- Text: Manrope 11px/600, uppercase, letter-spacing 0.06em, color `#6A7382`
|
||||
- Gap to content below: 10px
|
||||
|
||||
## 3. Screen Structure
|
||||
|
||||
21
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -2,16 +2,10 @@
|
||||
|
||||
<!-- Provide a clear and concise description of your changes -->
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues
|
||||
|
||||
<!-- Link any related issues using #issue_number -->
|
||||
|
||||
Closes #
|
||||
Related to #
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change
|
||||
|
||||
@@ -26,8 +20,6 @@ Related to #
|
||||
- [ ] 🎨 **Style** (formatting, linting, or minor code style changes)
|
||||
- [ ] 🏗️ **Architecture** (significant structural changes)
|
||||
|
||||
---
|
||||
|
||||
## 📦 Affected Areas
|
||||
|
||||
<!-- Mark the relevant areas that were modified -->
|
||||
@@ -39,8 +31,6 @@ Related to #
|
||||
- [ ] 🚀 **CI/CD** (GitHub Actions, deployment configs)
|
||||
- [ ] 📚 **Documentation** (Docs, onboarding guides)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Testing
|
||||
|
||||
<!-- Describe how you tested these changes -->
|
||||
@@ -48,9 +38,6 @@ Related to #
|
||||
**Test Details:**
|
||||
<!-- Provide specific test cases or scenarios -->
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Breaking Changes
|
||||
|
||||
<!-- Are there any breaking changes? If yes, describe them -->
|
||||
@@ -61,9 +48,6 @@ Related to #
|
||||
**Details:**
|
||||
<!-- Describe migration path or deprecation period if applicable -->
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Checklist
|
||||
|
||||
<!-- Complete this before requesting review -->
|
||||
@@ -79,15 +63,10 @@ Related to #
|
||||
- [ ] Sensitive data is not committed
|
||||
- [ ] Environment variables documented (if added)
|
||||
|
||||
---
|
||||
|
||||
## 📝 Additional Notes
|
||||
|
||||
<!-- Any additional context, decisions, or considerations -->
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Review Checklist for Maintainers
|
||||
|
||||
- [ ] Code quality and readability
|
||||
|
||||
9
.github/workflows/backend-foundation.yml
vendored
@@ -26,6 +26,14 @@ jobs:
|
||||
make -n backend-smoke-core ENV=dev
|
||||
make -n backend-smoke-commands ENV=dev
|
||||
make -n backend-logs-core ENV=dev
|
||||
make -n backend-bootstrap-v2-dev ENV=dev
|
||||
make -n backend-deploy-core-v2 ENV=dev
|
||||
make -n backend-deploy-commands-v2 ENV=dev
|
||||
make -n backend-deploy-query-v2 ENV=dev
|
||||
make -n backend-smoke-core-v2 ENV=dev
|
||||
make -n backend-smoke-commands-v2 ENV=dev
|
||||
make -n backend-smoke-query-v2 ENV=dev
|
||||
make -n backend-logs-core-v2 ENV=dev
|
||||
|
||||
backend-services-tests:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -34,6 +42,7 @@ jobs:
|
||||
service:
|
||||
- backend/core-api
|
||||
- backend/command-api
|
||||
- backend/query-api
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ matrix.service }}
|
||||
|
||||
4
.gitignore
vendored
@@ -182,6 +182,8 @@ internal/launchpad/prototypes-src/
|
||||
# Temporary migration artifacts
|
||||
_legacy/
|
||||
krow-workforce-export-latest/
|
||||
skills/
|
||||
skills-lock.json
|
||||
|
||||
# Data Connect Generated SDKs (Explicit)
|
||||
apps/mobile/packages/data_connect/lib/src/dataconnect_generated/
|
||||
@@ -194,4 +196,6 @@ apps/mobile/legacy/*
|
||||
AGENTS.md
|
||||
TASKS.md
|
||||
CLAUDE.md
|
||||
.claude/agents/paper-designer.md
|
||||
.claude/agent-memory/paper-designer
|
||||
\n# Android Signing (Secure)\n**.jks\n**key.properties
|
||||
|
||||
@@ -28,3 +28,4 @@
|
||||
| 2026-02-25 | 0.1.23 | Updated schema blueprint and reconciliation docs to add `business_memberships` and `vendor_memberships` as first-class data actors. |
|
||||
| 2026-02-25 | 0.1.24 | Removed stale `m4-discrepencies.md` document from M4 planning docs cleanup. |
|
||||
| 2026-02-25 | 0.1.25 | Added target schema model catalog with keys and domain relationship diagrams for slide/workshop use. |
|
||||
| 2026-02-26 | 0.1.26 | Added isolated v2 backend foundation targets, scaffolded `backend/query-api`, and expanded backend CI dry-runs/tests for v2/query. |
|
||||
|
||||
149
CLAUDE.md
@@ -1,149 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
KROW Workforce is a workforce management platform monorepo containing Flutter mobile apps, a React web dashboard, and Firebase backend services.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
apps/mobile/ # Flutter monorepo (Melos workspace)
|
||||
apps/staff/ # Staff mobile app
|
||||
apps/client/ # Client (business) mobile app
|
||||
packages/
|
||||
design_system/ # Shared UI tokens & components
|
||||
core/ # Cross-cutting concerns (mixins, extensions)
|
||||
core_localization/# i18n via Slang
|
||||
domain/ # Pure Dart entities & failures
|
||||
data_connect/ # Firebase Data Connect adapter (connectors)
|
||||
features/staff/ # Staff feature packages
|
||||
features/client/ # Client feature packages
|
||||
apps/web/ # React/Vite web dashboard (TypeScript, Tailwind, Redux Toolkit)
|
||||
backend/
|
||||
dataconnect/ # Firebase Data Connect GraphQL schemas
|
||||
core-api/ # Core business logic service
|
||||
cloud-functions/ # Serverless functions
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
|
||||
All commands use the root `Makefile` (composed from `makefiles/*.mk`). Run `make help` for the full list.
|
||||
|
||||
### Mobile (Flutter)
|
||||
```bash
|
||||
make mobile-install # Bootstrap Melos workspace + generate SDK
|
||||
make mobile-staff-dev-android # Run staff app (add DEVICE=android)
|
||||
make mobile-client-dev-android # Run client app
|
||||
make mobile-analyze # Lint (flutter analyze)
|
||||
make mobile-test # Run tests
|
||||
make test-e2e # Maestro E2E tests (both apps)
|
||||
```
|
||||
|
||||
Single-package operations via Melos:
|
||||
```bash
|
||||
cd apps/mobile
|
||||
melos run gen:l10n # Generate localization (Slang)
|
||||
melos run gen:build # Run build_runner
|
||||
melos run analyze:all # Analyze all packages
|
||||
melos run test:all # Test all packages
|
||||
```
|
||||
|
||||
### Web (React/Vite)
|
||||
```bash
|
||||
make web-install # npm install
|
||||
make web-dev # Start dev server
|
||||
make web-build # Production build
|
||||
make web-lint # ESLint
|
||||
make web-test # Vitest
|
||||
```
|
||||
|
||||
### Backend (Data Connect)
|
||||
```bash
|
||||
make dataconnect-generate-sdk [ENV=dev] # Generate SDK
|
||||
make dataconnect-deploy [ENV=dev] # Deploy schemas
|
||||
make dataconnect-sync-full [ENV=dev] # Deploy + migrate + generate
|
||||
```
|
||||
|
||||
## Mobile Architecture
|
||||
|
||||
**Clean Architecture** with strict inward dependency flow:
|
||||
|
||||
```
|
||||
Presentation (Pages, BLoCs, Widgets)
|
||||
→ Application (Use Cases)
|
||||
→ Domain (Entities, Repository Interfaces, Failures)
|
||||
← Data (Repository Implementations, Connectors)
|
||||
```
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **State management:** Flutter BLoC/Cubit. Register BLoCs with `i.add()` (transient), never `i.addSingleton()`. Use `BlocProvider.value()` for shared BLoCs.
|
||||
- **DI & Routing:** Flutter Modular. Safe navigation via `safeNavigate()`, `safePush()`, `popSafe()`. Never use `Navigator.push()` directly.
|
||||
- **Error handling in BLoCs:** Use `BlocErrorHandler` mixin with `_safeEmit()` to prevent StateError on disposed BLoCs.
|
||||
- **Backend access:** All Data Connect calls go through the `data_connect` package's Connectors. Use `_service.run(() => connector.<query>().execute())` for automatic auth/token management.
|
||||
- **Session management:** `SessionHandlerMixin` + `SessionListener` widget. Initialized in `main.dart` with role-based config.
|
||||
- **Localization:** All user-facing strings via `context.strings.<key>` from `core_localization`. Error messages via `ErrorTranslator`.
|
||||
- **Design system:** Use tokens from `UiColors`, `UiTypography`, `UiConstants`. Never hardcode colors, fonts, or spacing.
|
||||
|
||||
### Feature Package Structure
|
||||
|
||||
New features go in `apps/mobile/packages/features/<app>/<feature>/`:
|
||||
```
|
||||
lib/src/
|
||||
domain/repositories/ # Abstract interface classes
|
||||
data/repositories_impl/ # Implementations using data_connect
|
||||
application/ # Use cases (business logic)
|
||||
presentation/
|
||||
blocs/ # BLoCs/Cubits
|
||||
pages/ # Pages (prefer StatelessWidget)
|
||||
widgets/ # Reusable widgets
|
||||
```
|
||||
|
||||
### Critical Rules
|
||||
|
||||
- Features must not import other features directly
|
||||
- Business logic belongs in Use Cases, never in BLoCs or widgets
|
||||
- Firebase packages (`firebase_auth`, `firebase_data_connect`) belong only in `data_connect`
|
||||
- Don't add 3rd-party packages without checking `packages/core` first
|
||||
- Generated code directories are excluded from analysis: `**/dataconnect_generated/**`, `**/*.g.dart`, `**/*.freezed.dart`
|
||||
|
||||
## Code Generation
|
||||
|
||||
- **Slang** (i18n): Input `lib/src/l10n/*.i18n.json` → Output `strings.g.dart`
|
||||
- **build_runner**: Various generated files (`.g.dart`, `.freezed.dart`)
|
||||
- **Firebase Data Connect**: Auto-generated SDK in `packages/data_connect/lib/src/dataconnect_generated/`
|
||||
|
||||
## Naming Conventions (Dart)
|
||||
|
||||
| Type | Convention | Example |
|
||||
|------|-----------|---------|
|
||||
| Files | `snake_case` | `user_profile_page.dart` |
|
||||
| Classes | `PascalCase` | `UserProfilePage` |
|
||||
| Interfaces | suffix `Interface` | `AuthRepositoryInterface` |
|
||||
| Implementations | suffix `Impl` | `AuthRepositoryImpl` |
|
||||
|
||||
## Key Documentation
|
||||
|
||||
- `docs/MOBILE/00-agent-development-rules.md` — Non-negotiable architecture rules
|
||||
- `docs/MOBILE/01-architecture-principles.md` — Clean architecture details
|
||||
- `docs/MOBILE/02-design-system-usage.md` — Design system token usage
|
||||
- `docs/MOBILE/03-data-connect-connectors-pattern.md` — Backend integration pattern
|
||||
- `docs/MOBILE/05-release-process.md` — Release quick reference
|
||||
- `docs/RELEASE/mobile-releases.md` — Complete release guide
|
||||
|
||||
## Skills & Sub-Agents
|
||||
|
||||
#### Skills
|
||||
- The project has 4 specialized skills in `.claude/skills/` that provide deep domain knowledge. Invoke them and other global skills that you have when working in their domains.
|
||||
|
||||
#### Sub-Agents
|
||||
- The project has 4 sub-agents in `.claude/sub-agents/` that can be invoked for specific tasks. Invoke them and other global sub-agents that you have when working in their domains.
|
||||
|
||||
|
||||
## CI/CD
|
||||
|
||||
- `.github/workflows/mobile-ci.yml` — Mobile build & test on PR
|
||||
- `.github/workflows/product-release.yml` — Automated versioning, tags, APK builds
|
||||
- `.github/workflows/web-quality.yml` — Web linting & tests
|
||||
12
Makefile
@@ -89,6 +89,18 @@ help:
|
||||
@echo " make backend-smoke-commands [ENV=dev] Run health smoke test for command service (/health)"
|
||||
@echo " make backend-logs-core [ENV=dev] Tail/read logs for core service"
|
||||
@echo ""
|
||||
@echo " ☁️ BACKEND FOUNDATION V2 (Isolated Parallel Stack)"
|
||||
@echo " ────────────────────────────────────────────────────────────────────"
|
||||
@echo " make backend-bootstrap-v2-dev [ENV=dev] Bootstrap isolated v2 resources + SQL instance"
|
||||
@echo " make backend-deploy-core-v2 [ENV=dev] Build and deploy core API v2 service"
|
||||
@echo " make backend-deploy-commands-v2 [ENV=dev] Build and deploy command API v2 service"
|
||||
@echo " make backend-deploy-query-v2 [ENV=dev] Build and deploy query API v2 scaffold"
|
||||
@echo " make backend-v2-migrate-idempotency Create/upgrade command idempotency table for v2 DB"
|
||||
@echo " make backend-smoke-core-v2 [ENV=dev] Run health smoke test for core API v2 (/health)"
|
||||
@echo " make backend-smoke-commands-v2 [ENV=dev] Run health smoke test for command API v2 (/health)"
|
||||
@echo " make backend-smoke-query-v2 [ENV=dev] Run health smoke test for query API v2 (/health)"
|
||||
@echo " make backend-logs-core-v2 [ENV=dev] Tail/read logs for core API v2"
|
||||
@echo ""
|
||||
@echo " 🛠️ DEVELOPMENT TOOLS"
|
||||
@echo " ────────────────────────────────────────────────────────────────────"
|
||||
@echo " make install-melos Install Melos globally (for mobile dev)"
|
||||
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@android:color/white"/>
|
||||
<foreground android:drawable="@mipmap/launcher_icon"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@android:color/white"/>
|
||||
<foreground android:drawable="@mipmap/launcher_icon"/>
|
||||
</adaptive-icon>
|
||||
BIN
apps/mobile/apps/client/assets/logo-dev.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
apps/mobile/apps/client/assets/logo-stage.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
2
apps/mobile/apps/client/ios/Flutter/Dev.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
||||
// Build configuration for dev flavor - use AppIcon-dev
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-dev
|
||||
2
apps/mobile/apps/client/ios/Flutter/Stage.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
||||
// Build configuration for stage flavor - use AppIcon-stage
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-stage
|
||||
@@ -256,7 +256,7 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
|
||||
};
|
||||
BC26E38F2F5F614000517BDF /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
@@ -383,7 +383,7 @@
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
APP_NAME = "KROW With Us Business [STAGE]";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stage";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -563,7 +563,7 @@
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
APP_NAME = "KROW With Us Business [DEV] ";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -665,7 +665,7 @@
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
APP_NAME = "KROW With Us Business [DEV] ";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -762,7 +762,7 @@
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
APP_NAME = "KROW With Us Business [STAGE] ";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stage";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -951,7 +951,7 @@
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
APP_NAME = "KROW With Us Business [DEV] ";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -1040,7 +1040,7 @@
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
APP_NAME = "KROW With Us Business [STAGE]";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stage";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -1220,7 +1220,7 @@
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
APP_NAME = "KROW With Us Business [DEV] ";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -1311,7 +1311,7 @@
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
APP_NAME = "KROW With Us Business [STAGE] ";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stage";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
@@ -0,0 +1 @@
|
||||
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@android:color/white"/>
|
||||
<foreground android:drawable="@mipmap/launcher_icon"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@android:color/white"/>
|
||||
<foreground android:drawable="@mipmap/launcher_icon"/>
|
||||
</adaptive-icon>
|
||||
BIN
apps/mobile/apps/staff/assets/logo-dev.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
apps/mobile/apps/staff/assets/logo-stage.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
2
apps/mobile/apps/staff/ios/Flutter/Dev.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
||||
// Build configuration for dev flavor - use AppIcon-dev
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-dev
|
||||
2
apps/mobile/apps/staff/ios/Flutter/Stage.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
||||
// Build configuration for stage flavor - use AppIcon-stage
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-stage
|
||||
@@ -384,7 +384,7 @@
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
APP_NAME = "KROW With Us [STAGE] ";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stage";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -564,7 +564,7 @@
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
APP_NAME = "KROW With Us [DEV] ";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -666,7 +666,7 @@
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
APP_NAME = "KROW With Us [DEV] ";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -763,7 +763,7 @@
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
APP_NAME = "KROW With Us [STAGE] ";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stage";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -954,7 +954,7 @@
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
APP_NAME = "KROW With Us [DEV] ";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -1045,7 +1045,7 @@
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
APP_NAME = "KROW With Us [STAGE] ";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stage";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -1225,7 +1225,7 @@
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
APP_NAME = "KROW With Us [DEV] ";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -1314,7 +1314,7 @@
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
APP_NAME = "KROW With Us [STAGE] ";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stage";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.8 KiB |