docs: add 19 reusable AI coding agent skills + sessions module scaffold
This commit is contained in:
parent
662d417267
commit
069d1ffda9
@ -0,0 +1,207 @@
|
||||
# Secret Scanning Guardrails
|
||||
|
||||
> Pre-commit and pre-push hooks that prevent secrets from ever reaching git history. Once a secret is committed, it's compromised — prevention is the only real defense.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Setting up any new repo
|
||||
- After discovering a leaked secret
|
||||
- When onboarding AI agents (they can accidentally include secrets in code)
|
||||
- As part of security hardening
|
||||
|
||||
## The Pattern
|
||||
|
||||
Two-layer defense:
|
||||
|
||||
```
|
||||
Layer 1: Pre-commit hook
|
||||
→ Scans staged diff for secret patterns
|
||||
→ Blocks commit if secrets detected
|
||||
|
||||
Layer 2: Pre-push hook
|
||||
→ Scans all tracked files
|
||||
→ Blocks push if secrets detected
|
||||
|
||||
Layer 3: CI (optional)
|
||||
→ Scans on PR
|
||||
→ Fails pipeline if secrets detected
|
||||
```
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### 1. Create the staged-diff scanner
|
||||
|
||||
`scripts/secret-scan-staged.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Scan staged changes for secrets before commit
|
||||
set -euo pipefail
|
||||
|
||||
PATTERNS=(
|
||||
'[A-Za-z0-9/+]{40}' # Azure/AWS key-like (40+ base64 chars)
|
||||
'AccountKey=[A-Za-z0-9/+=]+' # Azure Storage account key
|
||||
'sk-[A-Za-z0-9]{20,}' # OpenAI API key
|
||||
'sk_live_[A-Za-z0-9]+' # Stripe live key
|
||||
'sk_test_[A-Za-z0-9]+' # Stripe test key
|
||||
'whsec_[A-Za-z0-9]+' # Stripe webhook secret
|
||||
'AKIA[0-9A-Z]{16}' # AWS access key ID
|
||||
'AIza[0-9A-Za-z_-]{35}' # Google API key
|
||||
'ghp_[A-Za-z0-9]{36}' # GitHub PAT
|
||||
'gho_[A-Za-z0-9]{36}' # GitHub OAuth token
|
||||
'password\s*[:=]\s*["\x27][^"\x27]+' # Hardcoded password
|
||||
'secret\s*[:=]\s*["\x27][^"\x27]+' # Hardcoded secret
|
||||
)
|
||||
|
||||
STAGED=$(git diff --cached --unified=0 -- ':(exclude)*.lock' ':(exclude)package-lock.json' ':(exclude)pnpm-lock.yaml')
|
||||
[ -z "$STAGED" ] && exit 0
|
||||
|
||||
FOUND=0
|
||||
for pattern in "${PATTERNS[@]}"; do
|
||||
MATCHES=$(echo "$STAGED" | grep -Pn "^\+.*$pattern" 2>/dev/null || true)
|
||||
if [ -n "$MATCHES" ]; then
|
||||
echo "SECRET DETECTED in staged changes:"
|
||||
echo "$MATCHES" | head -5
|
||||
echo ""
|
||||
FOUND=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FOUND" -eq 1 ]; then
|
||||
echo "COMMIT BLOCKED: Remove secrets before committing."
|
||||
echo "If this is a false positive, use: git commit --no-verify"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### 2. Create the tracked-file scanner
|
||||
|
||||
`scripts/secret-scan-repo.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Scan all tracked files for secrets (broader than staged-diff scan)
|
||||
set -euo pipefail
|
||||
|
||||
EXCLUDE='--exclude-dir=node_modules --exclude-dir=.git --exclude-dir=dist'
|
||||
EXCLUDE="$EXCLUDE --exclude=*.lock --exclude=package-lock.json --exclude=pnpm-lock.yaml"
|
||||
EXCLUDE="$EXCLUDE --exclude=secret-scan-staged.sh --exclude=secret-scan-repo.sh"
|
||||
|
||||
echo "Scanning tracked files for secrets..."
|
||||
FOUND=0
|
||||
|
||||
# Check for common secret patterns
|
||||
for pattern in \
|
||||
'AccountKey=[A-Za-z0-9/+=]+' \
|
||||
'sk-[A-Za-z0-9]{20,}' \
|
||||
'sk_live_[A-Za-z0-9]+' \
|
||||
'AKIA[0-9A-Z]{16}' \
|
||||
'AIza[0-9A-Za-z_-]{35}'; do
|
||||
|
||||
MATCHES=$(git ls-files -z | xargs -0 grep -Prl "$pattern" $EXCLUDE 2>/dev/null || true)
|
||||
if [ -n "$MATCHES" ]; then
|
||||
echo "POSSIBLE SECRET ($pattern) in:"
|
||||
echo "$MATCHES" | sed 's/^/ /'
|
||||
FOUND=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FOUND" -eq 1 ]; then
|
||||
echo ""
|
||||
echo "PUSH BLOCKED: Review above files for secrets."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "No secrets detected."
|
||||
```
|
||||
|
||||
### 3. Wire up git hooks
|
||||
|
||||
**Using Husky (Node.js projects):**
|
||||
|
||||
```bash
|
||||
npx husky init
|
||||
```
|
||||
|
||||
`.husky/pre-commit`:
|
||||
|
||||
```bash
|
||||
bash scripts/secret-scan-staged.sh
|
||||
npx lint-staged
|
||||
```
|
||||
|
||||
`.husky/pre-push`:
|
||||
|
||||
```bash
|
||||
bash scripts/secret-scan-repo.sh
|
||||
```
|
||||
|
||||
**Using git core.hooksPath (any project):**
|
||||
|
||||
```bash
|
||||
mkdir -p .githooks
|
||||
# Create .githooks/pre-commit and .githooks/pre-push
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
|
||||
### 4. Update .gitignore
|
||||
|
||||
```gitignore
|
||||
# Secrets & credentials
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.pem
|
||||
*.p12
|
||||
*.pfx
|
||||
*.key
|
||||
```
|
||||
|
||||
### 5. Create .env.example templates
|
||||
|
||||
For every `.env` file, create a `.env.example` with placeholders:
|
||||
|
||||
```bash
|
||||
# .env.example — Copy to .env and fill in real values
|
||||
DB_ENDPOINT=https://your-account.example.com:443/
|
||||
DB_KEY=<your-db-key-here>
|
||||
JWT_SECRET=<your-jwt-secret-here>
|
||||
PAYMENT_KEY=<your-payment-key-here>
|
||||
```
|
||||
|
||||
## If a Secret Was Already Committed
|
||||
|
||||
1. **Treat it as compromised immediately** — Don't wait for "later cleanup"
|
||||
2. **Rotate the secret** — Generate a new key/password in the provider
|
||||
3. **Update Key Vault / env** — Deploy the new secret
|
||||
4. **Consider git history cleanup** — `git filter-branch` or BFG Repo-Cleaner
|
||||
5. **Add the pattern to your scanner** — Prevent recurrence
|
||||
|
||||
## Integration with AI Agents
|
||||
|
||||
AI agents can accidentally include secrets when:
|
||||
|
||||
- Copying from environment files
|
||||
- Generating example configs with real values
|
||||
- Reading .env and including contents in code
|
||||
|
||||
**Prevention:**
|
||||
|
||||
- AGENTS.md rule: "Never hardcode secrets or API keys"
|
||||
- .gitignore covers .env files
|
||||
- Pre-commit hook catches accidental inclusions
|
||||
- Use `PLACEHOLDER_HERE` in examples
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- **No scanning at all** — One commit can expose all your secrets
|
||||
- **Only scanning in CI** — Too late; the secret is already in git history
|
||||
- **`--no-verify` as habit** — Bypassing hooks defeats the purpose
|
||||
- **Scanning only new files** — Secrets can be added to existing files
|
||||
- **Complex scanning tools** — A simple grep-based script catches 95% of leaks
|
||||
|
||||
## Related Skills
|
||||
|
||||
- [11 — AI-Driven Security Auditing](./11-security-auditing.md)
|
||||
- [18 — Environment & Secrets Management](./18-environment-management.md)
|
||||
- [12 — Pre-Release Quality Gates](./12-quality-gates.md)
|
||||
@ -0,0 +1,226 @@
|
||||
# Shared Package Extraction
|
||||
|
||||
> Identifying duplicated code across repos and extracting it into reusable shared packages. Turn 1,856 lines of duplication into 275 lines of shared library.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Same utility code appears in 3+ places
|
||||
- Multiple services/dashboards share identical patterns (DB client, auth, errors)
|
||||
- You're copy-pasting code between repos
|
||||
- A bug fix needs to be applied in multiple places
|
||||
|
||||
## The Pattern
|
||||
|
||||
```
|
||||
Before: Each consumer has its own copy
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Service A│ │ Service B│ │Dashboard │
|
||||
│ cosmos.ts│ │ cosmos.ts│ │ cosmos.ts│ ← 3 copies, drift over time
|
||||
│ auth.ts │ │ auth.ts │ │ auth.ts │
|
||||
│ errors.ts│ │ errors.ts│ │ errors.ts│
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
|
||||
After: Shared packages consumed by all
|
||||
┌──────────────────────────────────────┐
|
||||
│ packages/ │
|
||||
│ ├── cosmos/ → @scope/cosmos │
|
||||
│ ├── auth/ → @scope/auth │
|
||||
│ └── errors/ → @scope/errors │
|
||||
└──────────────────┬───────────────────┘
|
||||
┌───────────┼───────────┐
|
||||
┌──────▼──┐ ┌─────▼───┐ ┌────▼─────┐
|
||||
│ Service A│ │ Service B│ │Dashboard │
|
||||
└─────────┘ └─────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### 1. Identify duplication
|
||||
|
||||
Ask the agent to scan for patterns:
|
||||
|
||||
```
|
||||
Scan these directories for duplicated code patterns:
|
||||
- service-a/src/lib/
|
||||
- service-b/src/lib/
|
||||
- dashboard/src/lib/
|
||||
|
||||
List files that appear in 2+ places with similar content.
|
||||
Report: filename, line count, similarity %.
|
||||
```
|
||||
|
||||
Common duplications found in real projects:
|
||||
|
||||
| Package | Consumers | Duplicated LOC |
|
||||
| -------------------------------- | --------- | -------------- |
|
||||
| errors (typed HTTP errors) | 6 | ~157 |
|
||||
| cosmos (DB client singleton) | 6 | ~200 |
|
||||
| config (env loader + product ID) | 4 | ~180 |
|
||||
| auth (JWT + password hashing) | 5 | ~250 |
|
||||
| api-client (fetch wrapper) | 5 | ~150 |
|
||||
|
||||
### 2. Extract the package
|
||||
|
||||
Create a new package in the shared workspace:
|
||||
|
||||
```
|
||||
packages/errors/
|
||||
├── src/
|
||||
│ └── index.ts ← All exports
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
**package.json:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@scope/errors",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**tsconfig.json:**
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Migrate consumers
|
||||
|
||||
Replace local copies with the shared package:
|
||||
|
||||
```diff
|
||||
// service-a/package.json
|
||||
{
|
||||
"dependencies": {
|
||||
- // (inline cosmos.ts, errors.ts)
|
||||
+ "@scope/errors": "workspace:*",
|
||||
+ "@scope/cosmos": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```diff
|
||||
// service-a/src/lib/errors.ts
|
||||
- export class BadRequestError extends Error { ... }
|
||||
- export class NotFoundError extends Error { ... }
|
||||
+ export { BadRequestError, NotFoundError } from '@scope/errors';
|
||||
```
|
||||
|
||||
### 4. Verify all consumers
|
||||
|
||||
```bash
|
||||
# Build shared packages first
|
||||
pnpm build --filter '@scope/*'
|
||||
|
||||
# Then verify each consumer
|
||||
pnpm build --filter 'service-a'
|
||||
pnpm build --filter 'service-b'
|
||||
pnpm build --filter 'dashboard'
|
||||
```
|
||||
|
||||
## Package Design Guidelines
|
||||
|
||||
### Heavy deps go in peerDependencies
|
||||
|
||||
```json
|
||||
{
|
||||
"peerDependencies": {
|
||||
"@azure/cosmos": "^4.0.0",
|
||||
"jose": "^5.0.0",
|
||||
"zod": "^3.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This prevents version conflicts and reduces bundle size.
|
||||
|
||||
### Workspace references
|
||||
|
||||
In a pnpm workspace:
|
||||
|
||||
```json
|
||||
{ "@scope/errors": "workspace:*" }
|
||||
```
|
||||
|
||||
For consumers in separate repos (via `file:` refs):
|
||||
|
||||
```json
|
||||
{ "@scope/errors": "file:../common-platform/packages/errors" }
|
||||
```
|
||||
|
||||
**Important:** Run `pnpm build` in the shared repo before `npm install` in consuming repos.
|
||||
|
||||
### Self-contained config per service
|
||||
|
||||
Don't put Zod config schemas in shared packages (Zod version mismatch risk). Each service should have its own `src/lib/config.ts`:
|
||||
|
||||
```typescript
|
||||
// service-a/src/lib/config.ts — Self-contained
|
||||
import { z } from 'zod';
|
||||
|
||||
const configSchema = z.object({
|
||||
PORT: z.coerce.number().default(4003),
|
||||
COSMOS_ENDPOINT: z.string(),
|
||||
COSMOS_KEY: z.string(),
|
||||
JWT_SECRET: z.string(),
|
||||
});
|
||||
|
||||
export const config = configSchema.parse(process.env);
|
||||
```
|
||||
|
||||
### Re-export pattern
|
||||
|
||||
Services re-export from shared packages in their `src/lib/` files:
|
||||
|
||||
```typescript
|
||||
// service-a/src/lib/errors.ts
|
||||
export { BadRequestError, NotFoundError, ConflictError } from '@scope/errors';
|
||||
|
||||
// service-a/src/lib/cosmos.ts
|
||||
export { getCosmosClient, getContainer } from '@scope/cosmos';
|
||||
```
|
||||
|
||||
This provides a clean internal import path and makes swapping implementations easy.
|
||||
|
||||
## Priority Framework
|
||||
|
||||
| Priority | Criteria | Example |
|
||||
| -------- | -------------------------------------------- | ---------------------------------------- |
|
||||
| P0 | 5+ consumers, bugs require multi-repo fixes | errors, cosmos client |
|
||||
| P1 | 3-4 consumers, moderate complexity | config loader, auth/JWT |
|
||||
| P2 | 2-3 consumers, some variation between copies | API client, React auth |
|
||||
| P3 | Cross-platform generation needed | design tokens (JSON→CSS/TS/Kotlin/Swift) |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- **Extracting too early** — Wait until you have 3+ consumers before extracting
|
||||
- **Shared config schemas** — Zod version mismatch across packages causes runtime errors
|
||||
- **Breaking changes without versioning** — Use semantic versioning or workspace refs
|
||||
- **Putting everything in one mega-package** — Split by domain (errors, cosmos, auth)
|
||||
- **Not building before consuming** — `file:` refs need built dist/ to work
|
||||
|
||||
## Related Skills
|
||||
|
||||
- [13 — Module Pattern](./13-module-pattern.md)
|
||||
- [15 — Service Consolidation](./15-service-consolidation.md)
|
||||
- [17 — Multi-Repo Coordination](./17-multi-repo-coordination.md)
|
||||
@ -0,0 +1,171 @@
|
||||
# Service Consolidation
|
||||
|
||||
> Merging multiple microservices into one without breaking consumers. Reduce operational overhead while keeping modular code.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Too many services for the team size (each service = deployment + monitoring + env config overhead)
|
||||
- Services share the same database and auth
|
||||
- Services have low request volume individually
|
||||
- You're spending more time on infrastructure than features
|
||||
|
||||
## The Pattern
|
||||
|
||||
```
|
||||
Before: 4 services, 4 ports, 4 deployments
|
||||
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||
│ Billing │ │ Growth │ │ Platform │ │ Tracker │
|
||||
│ :4002 │ │ :4001 │ │ :4003 │ │ :4004 │
|
||||
└────────────┘ └────────────┘ └────────────┘ └────────────┘
|
||||
|
||||
After: 1 service, 1 port, modules preserved
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Platform Service :4003 │
|
||||
│ ├── modules/auth/ (from platform) │
|
||||
│ ├── modules/subscriptions/ (from billing) │
|
||||
│ ├── modules/stripe/ (from billing) │
|
||||
│ ├── modules/referrals/ (from growth) │
|
||||
│ ├── modules/items/ (from tracker) │
|
||||
│ └── modules/public/ (from tracker) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### 1. Inventory what exists
|
||||
|
||||
List all services with their modules, endpoints, and test counts:
|
||||
|
||||
| Service | Port | Modules | Endpoints | Tests |
|
||||
| ---------------- | ---- | ----------------------------------- | --------- | ----- |
|
||||
| platform-service | 4003 | auth, audit, flags | 15 | 80 |
|
||||
| billing-service | 4002 | subscriptions, stripe, usage, plans | 12 | 40 |
|
||||
| growth-service | 4001 | invitations, referrals, promos | 9 | 25 |
|
||||
| tracker-service | 4004 | items, comments, votes, public | 11 | 35 |
|
||||
|
||||
### 2. Move modules (not rewrite)
|
||||
|
||||
The key insight: **move the module directories intact**. Don't rewrite code.
|
||||
|
||||
```bash
|
||||
# Move billing modules into platform-service
|
||||
cp -r billing-service/src/modules/subscriptions/ platform-service/src/modules/
|
||||
cp -r billing-service/src/modules/stripe/ platform-service/src/modules/
|
||||
cp -r billing-service/src/modules/usage/ platform-service/src/modules/
|
||||
cp -r billing-service/src/modules/plans/ platform-service/src/modules/
|
||||
```
|
||||
|
||||
### 3. Register routes in the consolidated service
|
||||
|
||||
```typescript
|
||||
// platform-service/src/server.ts
|
||||
// Existing modules
|
||||
await server.register(import('./modules/auth/routes.js'), { prefix: '/api' });
|
||||
await server.register(import('./modules/audit/routes.js'), { prefix: '/api' });
|
||||
|
||||
// Modules from billing-service
|
||||
await server.register(import('./modules/subscriptions/routes.js'), { prefix: '/api' });
|
||||
await server.register(import('./modules/stripe/routes.js'), { prefix: '/api' });
|
||||
await server.register(import('./modules/usage/routes.js'), { prefix: '/api' });
|
||||
|
||||
// Modules from growth-service
|
||||
await server.register(import('./modules/referrals/routes.js'), { prefix: '/api' });
|
||||
await server.register(import('./modules/invitations/routes.js'), { prefix: '/api' });
|
||||
|
||||
// Modules from tracker-service
|
||||
await server.register(import('./modules/items/routes.js'), { prefix: '/api' });
|
||||
await server.register(import('./modules/public/routes.js'), { prefix: '/api' });
|
||||
```
|
||||
|
||||
### 4. Register Cosmos containers
|
||||
|
||||
```typescript
|
||||
// cosmos-init.ts
|
||||
const containers = [
|
||||
// Existing
|
||||
{ id: 'users', partitionKey: { paths: ['/productId'] } },
|
||||
{ id: 'audit_log', partitionKey: { paths: ['/productId'] } },
|
||||
|
||||
// From billing
|
||||
{ id: 'subscriptions', partitionKey: { paths: ['/userId'] } },
|
||||
{ id: 'usage_records', partitionKey: { paths: ['/userId'] } },
|
||||
|
||||
// From growth
|
||||
{ id: 'invitations', partitionKey: { paths: ['/productId'] } },
|
||||
{ id: 'referrals', partitionKey: { paths: ['/userId'] } },
|
||||
|
||||
// From tracker
|
||||
{ id: 'tracker_items', partitionKey: { paths: ['/productId'] } },
|
||||
{ id: 'comments', partitionKey: { paths: ['/itemId'] } },
|
||||
];
|
||||
```
|
||||
|
||||
### 5. Update all consumers
|
||||
|
||||
Every consumer that pointed to the old service URL must now point to platform-service:
|
||||
|
||||
```diff
|
||||
# .env files in dashboards
|
||||
- BILLING_SERVICE_URL=http://localhost:4002
|
||||
- GROWTH_SERVICE_URL=http://localhost:4001
|
||||
- TRACKER_SERVICE_URL=http://localhost:4004
|
||||
+ PLATFORM_SERVICE_URL=http://localhost:4003
|
||||
```
|
||||
|
||||
### 6. Run all tests
|
||||
|
||||
```bash
|
||||
# All tests must pass in the consolidated service
|
||||
pnpm test # Should show combined test count (80 + 40 + 25 + 35 = 180)
|
||||
```
|
||||
|
||||
### 7. Delete old services
|
||||
|
||||
Only after all tests pass and consumers are updated:
|
||||
|
||||
```bash
|
||||
rm -rf services/billing-service/
|
||||
rm -rf services/growth-service/
|
||||
rm -rf services/tracker-service/
|
||||
```
|
||||
|
||||
### 8. Update documentation
|
||||
|
||||
- Docker Compose (remove old service entries)
|
||||
- AGENTS.md (update service inventory)
|
||||
- Environment variable docs
|
||||
- Health check scripts
|
||||
|
||||
## When NOT to Consolidate
|
||||
|
||||
- Services have different scaling requirements
|
||||
- Services use different databases
|
||||
- Services are maintained by different teams
|
||||
- Services have conflicting dependency versions
|
||||
|
||||
## Real-World Result
|
||||
|
||||
| Metric | Before | After |
|
||||
| ----------- | ---------------------- | --------------------------- |
|
||||
| Services | 4 | 1 |
|
||||
| Ports | 4002, 4001, 4003, 4004 | 4003 |
|
||||
| Deployments | 4 Docker containers | 1 Docker container |
|
||||
| Env files | 4 sets of config | 1 set |
|
||||
| Test suites | 4 separate runs | 1 unified run (847 tests) |
|
||||
| Modules | Same count | Same count (code preserved) |
|
||||
|
||||
The module pattern (types → repository → routes) made this consolidation smooth — each module is self-contained, so moving directories was trivial.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- **Rewriting during consolidation** — Move code, don't rewrite it
|
||||
- **Consolidating before modularizing** — If code isn't modular, fix that first
|
||||
- **Removing tests** — All tests from all services must survive the merge
|
||||
- **Not updating consumers** — Stale URLs cause 502/ECONNREFUSED errors
|
||||
- **Keeping old service directories** — Delete them to prevent confusion
|
||||
|
||||
## Related Skills
|
||||
|
||||
- [13 — Module Pattern](./13-module-pattern.md)
|
||||
- [14 — Shared Package Extraction](./14-shared-packages.md)
|
||||
- [17 — Multi-Repo Coordination](./17-multi-repo-coordination.md)
|
||||
@ -0,0 +1,232 @@
|
||||
# Cross-Platform Design Tokens
|
||||
|
||||
> One canonical JSON source generates CSS, TypeScript, Kotlin, and Swift tokens. Change a color once, update everywhere.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Building apps across web + iOS + Android
|
||||
- Maintaining a design system with consistent colors, spacing, typography
|
||||
- When designers change the palette and you need to update 4 platforms
|
||||
- When AI agents create UI code and need to use the right tokens
|
||||
|
||||
## The Pattern
|
||||
|
||||
```
|
||||
bytelyst.tokens.json ← CANONICAL SOURCE (one file)
|
||||
│
|
||||
├── generate.ts ← Token generator script
|
||||
│
|
||||
├── tokens.css → Web (CSS custom properties)
|
||||
├── tokens.ts → Web (TypeScript constants)
|
||||
├── Tokens.kt → Android (Kotlin Color objects)
|
||||
└── Theme.swift → iOS (SwiftUI Color extension)
|
||||
```
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### 1. Define canonical tokens
|
||||
|
||||
`tokens/design-tokens.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"color": {
|
||||
"bg": {
|
||||
"canvas": { "value": "#06070A", "description": "Page background" },
|
||||
"elevated": { "value": "#0E1118", "description": "Elevated surfaces" }
|
||||
},
|
||||
"surface": {
|
||||
"card": { "value": "#121725", "description": "Cards, panels" },
|
||||
"muted": { "value": "#1A2335", "description": "Muted backgrounds" }
|
||||
},
|
||||
"text": {
|
||||
"primary": { "value": "#EFF4FF", "description": "Main text" },
|
||||
"secondary": { "value": "#A5B1C7", "description": "Descriptions" },
|
||||
"tertiary": { "value": "#6C7C98", "description": "Hints" }
|
||||
},
|
||||
"accent": {
|
||||
"primary": { "value": "#5A8CFF", "description": "Primary actions" },
|
||||
"secondary": { "value": "#2EE6D6", "description": "Secondary accent" }
|
||||
},
|
||||
"status": {
|
||||
"success": { "value": "#34D399" },
|
||||
"warning": { "value": "#F59E0B" },
|
||||
"danger": { "value": "#FF6E6E" }
|
||||
}
|
||||
},
|
||||
"spacing": {
|
||||
"xs": { "value": "4px" },
|
||||
"sm": { "value": "8px" },
|
||||
"md": { "value": "16px" },
|
||||
"lg": { "value": "24px" },
|
||||
"xl": { "value": "32px" }
|
||||
},
|
||||
"radius": {
|
||||
"sm": { "value": "6px" },
|
||||
"md": { "value": "12px" },
|
||||
"lg": { "value": "16px" },
|
||||
"full": { "value": "9999px" }
|
||||
},
|
||||
"font": {
|
||||
"family": {
|
||||
"display": { "value": "Space Grotesk" },
|
||||
"body": { "value": "DM Sans" },
|
||||
"mono": { "value": "IBM Plex Mono" }
|
||||
},
|
||||
"size": {
|
||||
"xs": { "value": "12px" },
|
||||
"sm": { "value": "14px" },
|
||||
"base": { "value": "16px" },
|
||||
"lg": { "value": "20px" },
|
||||
"xl": { "value": "24px" },
|
||||
"2xl": { "value": "32px" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Write the generator
|
||||
|
||||
`scripts/generate-tokens.ts`:
|
||||
|
||||
```typescript
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
|
||||
const tokens = JSON.parse(readFileSync('tokens/design-tokens.json', 'utf-8'));
|
||||
|
||||
function flattenTokens(
|
||||
obj: any,
|
||||
prefix = ''
|
||||
): Array<{ path: string; value: string; desc?: string }> {
|
||||
const result: Array<{ path: string; value: string; desc?: string }> = [];
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}-${key}` : key;
|
||||
if ((val as any).value !== undefined) {
|
||||
result.push({ path, value: (val as any).value, desc: (val as any).description });
|
||||
} else {
|
||||
result.push(...flattenTokens(val, path));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const flat = flattenTokens(tokens);
|
||||
|
||||
// ── CSS ──────────────────────────────────────────
|
||||
const css = `:root {\n${flat
|
||||
.map(t => ` --${t.path}: ${t.value};${t.desc ? ` /* ${t.desc} */` : ''}`)
|
||||
.join('\n')}\n}\n`;
|
||||
writeFileSync('generated/tokens.css', css);
|
||||
|
||||
// ── TypeScript ───────────────────────────────────
|
||||
const ts =
|
||||
flat
|
||||
.map(t => {
|
||||
const name = t.path.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
return `export const ${name} = '${t.value}';${t.desc ? ` // ${t.desc}` : ''}`;
|
||||
})
|
||||
.join('\n') + '\n';
|
||||
writeFileSync('generated/tokens.ts', ts);
|
||||
|
||||
// ── Kotlin ───────────────────────────────────────
|
||||
const ktColors = flat
|
||||
.filter(t => t.value.startsWith('#'))
|
||||
.map(t => {
|
||||
const name = t.path.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
const hex = t.value.replace('#', '');
|
||||
return ` val ${name} = Color(0xFF${hex})`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const kt = `package com.example.theme\n\nimport androidx.compose.ui.graphics.Color\n\nobject AppTokens {\n${ktColors}\n}\n`;
|
||||
writeFileSync('generated/AppTokens.kt', kt);
|
||||
|
||||
// ── Swift ────────────────────────────────────────
|
||||
const swiftColors = flat
|
||||
.filter(t => t.value.startsWith('#'))
|
||||
.map(t => {
|
||||
const name = t.path.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
const hex = t.value.replace('#', '');
|
||||
const r = parseInt(hex.substring(0, 2), 16) / 255;
|
||||
const g = parseInt(hex.substring(2, 4), 16) / 255;
|
||||
const b = parseInt(hex.substring(4, 6), 16) / 255;
|
||||
return ` static let ${name} = Color(red: ${r.toFixed(3)}, green: ${g.toFixed(3)}, blue: ${b.toFixed(3)})`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const swift = `import SwiftUI\n\nextension Color {\n${swiftColors}\n}\n`;
|
||||
writeFileSync('generated/AppTheme.swift', swift);
|
||||
|
||||
console.log(`Generated ${flat.length} tokens across 4 platforms`);
|
||||
```
|
||||
|
||||
### 3. Run the generator
|
||||
|
||||
```bash
|
||||
npx tsx scripts/generate-tokens.ts
|
||||
```
|
||||
|
||||
### 4. Consume in each platform
|
||||
|
||||
**Web (CSS):**
|
||||
|
||||
```css
|
||||
@import './tokens.css';
|
||||
|
||||
.card {
|
||||
background: var(--surface-card);
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
```
|
||||
|
||||
**Web (TypeScript):**
|
||||
|
||||
```typescript
|
||||
import { accentPrimary, textPrimary } from './tokens';
|
||||
```
|
||||
|
||||
**Android (Kotlin):**
|
||||
|
||||
```kotlin
|
||||
import com.example.theme.AppTokens
|
||||
|
||||
Text(
|
||||
text = "Hello",
|
||||
color = AppTokens.textPrimary,
|
||||
)
|
||||
```
|
||||
|
||||
**iOS (Swift):**
|
||||
|
||||
```swift
|
||||
Text("Hello")
|
||||
.foregroundColor(.textPrimary)
|
||||
```
|
||||
|
||||
## AGENTS.md Rule
|
||||
|
||||
Add this to your AGENTS.md so agents never hardcode colors:
|
||||
|
||||
```markdown
|
||||
## Conventions
|
||||
|
||||
- NEVER hardcode colors — use theme tokens
|
||||
- Web: `var(--accent-primary)` or import from tokens.ts
|
||||
- iOS: `Color.accentPrimary` (from AppTheme.swift)
|
||||
- Android: `AppTokens.accentPrimary` (from AppTokens.kt)
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- **Hardcoded hex values in components** — Always use token references
|
||||
- **Different colors on different platforms** — Generate from one source
|
||||
- **Manual token updates** — Run the generator, don't hand-edit generated files
|
||||
- **No description field** — Descriptions help agents pick the right token
|
||||
- **Tokens for everything** — Tokens are for design decisions, not one-off values
|
||||
|
||||
## Related Skills
|
||||
|
||||
- [01 — AGENTS.md Pattern](./01-agents-md-pattern.md)
|
||||
- [14 — Shared Package Extraction](./14-shared-packages.md)
|
||||
@ -0,0 +1,172 @@
|
||||
# Multi-Repo Coordination
|
||||
|
||||
> Managing cross-repo operations (sync, backup, commit, push) in a multi-repo workspace.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Working across 3+ repos that share dependencies
|
||||
- Starting a work session (sync all repos to latest)
|
||||
- Ending a work session (commit + backup all repos)
|
||||
- After a shared package change that affects consumers
|
||||
|
||||
## The Pattern
|
||||
|
||||
Organize cross-repo operations as **workflows with the `repo_` prefix**:
|
||||
|
||||
| Workflow | What it does | When to run |
|
||||
| ------------------------- | ---------------------------- | -------------------------- |
|
||||
| `repo_sync-repos` | Pull latest from origin main | Start of session |
|
||||
| `repo_commit-workspace` | Commit all dirty repos | End of session |
|
||||
| `repo_backup-main-branch` | Create backup/\* branches | Before risky changes |
|
||||
| `repo_push-repos` | Push main to origin | After commits are verified |
|
||||
|
||||
## Core Workflows
|
||||
|
||||
### Sync All Repos
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# sync-repos.sh
|
||||
REPOS_ROOT="$HOME/code/mygh"
|
||||
REPOS=(
|
||||
"learning_ai_common_plat"
|
||||
"learning_voice_ai_agent"
|
||||
"learning_multimodal_memory_agents"
|
||||
"learning_ai_clock"
|
||||
"learning_ai_fastgap"
|
||||
)
|
||||
|
||||
for repo in "${REPOS[@]}"; do
|
||||
echo "=== Syncing $repo ==="
|
||||
cd "$REPOS_ROOT/$repo" || continue
|
||||
|
||||
# Stash if dirty
|
||||
DIRTY=$(git status --porcelain)
|
||||
if [ -n "$DIRTY" ]; then
|
||||
echo " Stashing changes..."
|
||||
git stash push -m "auto-stash before sync"
|
||||
fi
|
||||
|
||||
git pull origin main --rebase
|
||||
|
||||
if [ -n "$DIRTY" ]; then
|
||||
echo " Restoring stash..."
|
||||
git stash pop
|
||||
fi
|
||||
|
||||
echo " Done."
|
||||
done
|
||||
```
|
||||
|
||||
### Backup Main Branches
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# backup-main.sh — Creates backup/main-YYYY-MM-DD branches
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
for repo in "${REPOS[@]}"; do
|
||||
cd "$REPOS_ROOT/$repo" || continue
|
||||
|
||||
BRANCH="backup/main-$DATE"
|
||||
if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
|
||||
echo " $repo: $BRANCH already exists (skipping)"
|
||||
else
|
||||
git branch "$BRANCH" main
|
||||
echo " $repo: Created $BRANCH"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
**Important:** Backup does NOT push to remote or modify main. It only creates local backup branches.
|
||||
|
||||
### Commit All Repos
|
||||
|
||||
For AI agent-driven commits:
|
||||
|
||||
```
|
||||
For each repo with uncommitted changes:
|
||||
1. Run `git diff --stat` to understand changes
|
||||
2. Generate a commit message from the diff:
|
||||
- feat(scope): description — for new features
|
||||
- fix(scope): description — for bug fixes
|
||||
- docs: description — for documentation
|
||||
- refactor(scope): description — for refactoring
|
||||
3. git add -A && git commit -m "<message>"
|
||||
4. Report what was committed
|
||||
```
|
||||
|
||||
### Push All Repos
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# push-repos.sh
|
||||
for repo in "${REPOS[@]}"; do
|
||||
cd "$REPOS_ROOT/$repo" || continue
|
||||
|
||||
# Check if there are commits to push
|
||||
AHEAD=$(git rev-list --count origin/main..main 2>/dev/null)
|
||||
if [ "$AHEAD" -gt 0 ]; then
|
||||
echo " $repo: Pushing $AHEAD commits..."
|
||||
git push origin main
|
||||
else
|
||||
echo " $repo: Already up to date"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
## Dependency Order
|
||||
|
||||
When repos depend on each other, operations must follow dependency order:
|
||||
|
||||
```
|
||||
1. learning_ai_common_plat ← Shared packages (build FIRST)
|
||||
2. learning_voice_ai_agent ← Consumes @bytelyst/* packages
|
||||
3. learning_multimodal_memory_agents ← Consumes @bytelyst/* packages
|
||||
4. learning_ai_clock ← Uses platform-service API
|
||||
5. learning_ai_fastgap ← Uses platform-service API
|
||||
```
|
||||
|
||||
**After changing a shared package:**
|
||||
|
||||
```bash
|
||||
# 1. Build shared packages
|
||||
cd learning_ai_common_plat && pnpm build
|
||||
|
||||
# 2. Update consumers
|
||||
cd ../learning_voice_ai_agent/admin-dashboard-web && npm install
|
||||
cd ../learning_voice_ai_agent/user-dashboard-web && npm install
|
||||
```
|
||||
|
||||
## Health Check Script
|
||||
|
||||
A quick script to check status across all repos:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# repo-status.sh
|
||||
echo "=== Repo Status ==="
|
||||
printf "%-40s %-10s %-10s %-10s\n" "REPO" "BRANCH" "DIRTY" "AHEAD"
|
||||
|
||||
for repo in "${REPOS[@]}"; do
|
||||
cd "$REPOS_ROOT/$repo" || continue
|
||||
BRANCH=$(git branch --show-current)
|
||||
DIRTY=$(git status --porcelain | wc -l | tr -d ' ')
|
||||
AHEAD=$(git rev-list --count origin/main..main 2>/dev/null || echo "?")
|
||||
printf "%-40s %-10s %-10s %-10s\n" "$repo" "$BRANCH" "$DIRTY" "$AHEAD"
|
||||
done
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- **Manual sync one repo at a time** — Automate it
|
||||
- **Forgetting to build shared packages before consumer install** — Dependencies break
|
||||
- **Pushing without verifying** — Always run tests first
|
||||
- **No backups before risky operations** — One bad rebase can lose work
|
||||
- **Cross-repo changes in wrong order** — Shared packages first, consumers second
|
||||
|
||||
## Related Skills
|
||||
|
||||
- [04 — Workflow Definitions](./04-workflow-definitions.md)
|
||||
- [14 — Shared Package Extraction](./14-shared-packages.md)
|
||||
- [18 — Environment & Secrets Management](./18-environment-management.md)
|
||||
@ -0,0 +1,193 @@
|
||||
# Environment & Secrets Management
|
||||
|
||||
> Single-source env files, symlinks for sharing, Key Vault for secrets, and .env.example templates for onboarding.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Setting up a new project with multiple components sharing the same secrets
|
||||
- Managing env vars across services, dashboards, desktop app, and mobile
|
||||
- Onboarding new developers (or AI agents) who need working environment
|
||||
- Migrating from hardcoded secrets to a vault
|
||||
|
||||
## The Pattern
|
||||
|
||||
```
|
||||
~/.AppName/.env ← SINGLE SOURCE (all secrets + config)
|
||||
│
|
||||
├── backend/.env → symlink
|
||||
├── .env → symlink (root)
|
||||
├── admin-web/.env.local → symlink
|
||||
├── user-web/.env.local → symlink
|
||||
└── tracker-web/.env.local → symlink
|
||||
|
||||
Azure Key Vault ← SECRET SOURCE OF TRUTH
|
||||
│
|
||||
└── Resolved at startup via resolveKeyVaultSecrets()
|
||||
```
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### 1. Create the canonical env file
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.AppName
|
||||
cat > ~/.AppName/.env << 'EOF'
|
||||
# Core
|
||||
NODE_ENV=development
|
||||
PRODUCT_ID=myapp
|
||||
|
||||
# Database
|
||||
DB_ENDPOINT=https://your-account.example.com:443/
|
||||
DB_KEY=<your-db-key-here>
|
||||
DB_NAME=myapp
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=<your-jwt-secret>
|
||||
|
||||
# External Services
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
AZURE_BLOB_CONNECTION_STRING=...
|
||||
|
||||
# Key Vault (for production — resolves above secrets from AKV)
|
||||
AZURE_KEYVAULT_URL=https://kv-myapp.vault.azure.net/
|
||||
EOF
|
||||
```
|
||||
|
||||
### 2. Create symlinks
|
||||
|
||||
```bash
|
||||
# All components read from the same file
|
||||
ln -sfn ~/.AppName/.env ./backend/.env
|
||||
ln -sfn ~/.AppName/.env ./.env
|
||||
ln -sfn ~/.AppName/.env ./admin-web/.env.local
|
||||
ln -sfn ~/.AppName/.env ./user-web/.env.local
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Change a secret once, all components pick it up
|
||||
- No risk of out-of-sync env files
|
||||
- `~/.AppName/.env` is outside the repo (can't be committed)
|
||||
|
||||
### 3. Create .env.example templates
|
||||
|
||||
For every component, create a committed template with placeholders:
|
||||
|
||||
```bash
|
||||
# .env.example — Copy to .env and fill in real values
|
||||
# Or set up symlinks: ln -sfn ~/.AppName/.env ./.env
|
||||
|
||||
NODE_ENV=development
|
||||
PRODUCT_ID=myapp
|
||||
|
||||
# Database
|
||||
DB_ENDPOINT=https://your-account.example.com:443/
|
||||
DB_KEY=<your-db-key-here>
|
||||
DB_NAME=myapp
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=<your-jwt-secret-here>
|
||||
|
||||
# Optional: Azure Key Vault (resolves secrets automatically)
|
||||
# AZURE_KEYVAULT_URL=https://kv-myapp.vault.azure.net/
|
||||
```
|
||||
|
||||
### 4. Key Vault resolution (production)
|
||||
|
||||
For production, secrets come from Azure Key Vault with env var fallback:
|
||||
|
||||
```typescript
|
||||
// lib/keyvault.ts
|
||||
import { SecretClient } from '@azure/keyvault-secrets';
|
||||
import { DefaultAzureCredential } from '@azure/identity';
|
||||
|
||||
const SECRET_MAP: Record<string, string> = {
|
||||
DB_KEY: 'myapp-db-key',
|
||||
JWT_SECRET: 'myapp-jwt-secret',
|
||||
PAYMENT_KEY: 'myapp-payment-key',
|
||||
};
|
||||
|
||||
export async function resolveKeyVaultSecrets(): Promise<void> {
|
||||
const vaultUrl = process.env.AZURE_KEYVAULT_URL;
|
||||
if (!vaultUrl) return; // Dev mode — use env vars directly
|
||||
|
||||
const client = new SecretClient(vaultUrl, new DefaultAzureCredential());
|
||||
|
||||
for (const [envVar, secretName] of Object.entries(SECRET_MAP)) {
|
||||
if (!process.env[envVar]) {
|
||||
try {
|
||||
const secret = await client.getSecret(secretName);
|
||||
process.env[envVar] = secret.value;
|
||||
} catch (e) {
|
||||
console.warn(`Failed to resolve ${secretName} from Key Vault`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Call at app startup (e.g., in Next.js `instrumentation.ts` or Fastify server bootstrap).
|
||||
|
||||
### 5. Gitignore everything sensitive
|
||||
|
||||
```gitignore
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.env
|
||||
|
||||
# Credentials
|
||||
*.pem
|
||||
*.p12
|
||||
*.pfx
|
||||
*.key
|
||||
|
||||
# Config directories
|
||||
.AppName/
|
||||
```
|
||||
|
||||
## Secret Naming Convention
|
||||
|
||||
Use a product prefix for Key Vault secrets:
|
||||
|
||||
```
|
||||
myapp-cosmos-key
|
||||
myapp-jwt-secret
|
||||
myapp-stripe-secret-key
|
||||
myapp-blob-connection-string
|
||||
```
|
||||
|
||||
This prevents collisions when multiple products share a Key Vault.
|
||||
|
||||
## Env Var Categories
|
||||
|
||||
| Category | Example | Where to store |
|
||||
| ------------ | ------------------------------- | ------------------------------------------ |
|
||||
| **Secrets** | DB_KEY, JWT_SECRET, PAYMENT_KEY | Key Vault (env var fallback for dev) |
|
||||
| **Config** | PORT, NODE_ENV, PRODUCT_ID | .env file directly |
|
||||
| **Public** | STRIPE_PUBLISHABLE_KEY | .env file (safe to commit in .env.example) |
|
||||
| **Computed** | Database name from PRODUCT_ID | Code (don't store) |
|
||||
|
||||
## Checklist for Adding a New Secret
|
||||
|
||||
- [ ] Add to Key Vault with product prefix
|
||||
- [ ] Map in SECRET_MAP (keyvault.ts or equivalent)
|
||||
- [ ] Add placeholder to `~/.AppName/.env`
|
||||
- [ ] Add placeholder to ALL `.env.example` files
|
||||
- [ ] Document in AGENTS.md or env docs
|
||||
- [ ] Verify all components can resolve it
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- **Different values in different .env files** — Use symlinks to one source
|
||||
- **Secrets in .env.example** — Only placeholders in committed files
|
||||
- **Hardcoded secrets in code** — Always use process.env
|
||||
- **No Key Vault for production** — Env vars leak in logs, crash reports, docker inspect
|
||||
- **Forgetting to update .env.example** — New devs can't set up without it
|
||||
|
||||
## Related Skills
|
||||
|
||||
- [09 — Secret Scanning Guardrails](./09-secret-scanning.md)
|
||||
- [17 — Multi-Repo Coordination](./17-multi-repo-coordination.md)
|
||||
- [01 — AGENTS.md Pattern](./01-agents-md-pattern.md)
|
||||
@ -0,0 +1,208 @@
|
||||
# Session Summaries & Playbooks
|
||||
|
||||
> Document what was done so future agents (or future you) can pick up without rework. Turn one-off work into repeatable playbooks.
|
||||
|
||||
## When to Use
|
||||
|
||||
- After completing a significant body of work (new module, migration, security hardening)
|
||||
- When the same process should be applied to other repos
|
||||
- When handing off work to another person or AI tool
|
||||
- As part of a post-mortem or retrospective
|
||||
|
||||
## The Pattern
|
||||
|
||||
Two document types:
|
||||
|
||||
| Type | Purpose | Audience |
|
||||
| --------------------- | --------------------------------------------------------- | --------------------------------- |
|
||||
| **Session Summary** | What was done, why, and what's left | Future agents continuing the work |
|
||||
| **Reusable Playbook** | Step-by-step checklist for applying the pattern elsewhere | Any agent working on any repo |
|
||||
|
||||
## Session Summary Template
|
||||
|
||||
````markdown
|
||||
# Session Summary: [Title]
|
||||
|
||||
> **Date:** YYYY-MM-DD
|
||||
> **Repo(s):** <repo names>
|
||||
> **Scope:** <what was accomplished>
|
||||
|
||||
## What Was Built
|
||||
|
||||
### 1. [Component/Feature Name]
|
||||
|
||||
- File: `path/to/file.ts`
|
||||
- What: <brief description>
|
||||
- Tests: <count> passing
|
||||
|
||||
### 2. [Component/Feature Name]
|
||||
|
||||
- ...
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
- **Decision:** <what was decided>
|
||||
- **Reason:** <why this approach>
|
||||
- **Alternative rejected:** <what was considered and why it was rejected>
|
||||
|
||||
## What's Pending
|
||||
|
||||
- [ ] <Remaining task 1>
|
||||
- [ ] <Remaining task 2>
|
||||
|
||||
## Key Commits
|
||||
|
||||
| Hash | Message |
|
||||
| ------- | ---------------------------------------- |
|
||||
| abc1234 | feat(auth): add JWT refresh token flow |
|
||||
| def5678 | fix(auth): handle case-insensitive email |
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Commands to verify everything works
|
||||
npm test # X tests passing
|
||||
npm run typecheck # Clean
|
||||
npm run build # Succeeds
|
||||
```
|
||||
````
|
||||
|
||||
````
|
||||
|
||||
## Reusable Playbook Template
|
||||
|
||||
```markdown
|
||||
# Playbook: [Process Name]
|
||||
|
||||
> **Apply to:** Any repo that needs [X]
|
||||
> **Estimated time:** [X] hours
|
||||
> **Source:** Developed during [session/project]
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [ ] <Tool or access needed>
|
||||
- [ ] <Dependency installed>
|
||||
|
||||
## Checklist
|
||||
|
||||
### A. [Phase 1 Name]
|
||||
|
||||
- [ ] Step 1 with exact command or action
|
||||
- [ ] Step 2
|
||||
- [ ] Step 3
|
||||
|
||||
### B. [Phase 2 Name]
|
||||
|
||||
- [ ] Step 4
|
||||
- [ ] Step 5
|
||||
|
||||
### C. Verification
|
||||
|
||||
- [ ] All tests pass
|
||||
- [ ] Build succeeds
|
||||
- [ ] Manual smoke test
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Most-used commands for this process
|
||||
<command 1>
|
||||
<command 2>
|
||||
````
|
||||
|
||||
## Common Issues
|
||||
|
||||
| Problem | Fix |
|
||||
| --------- | ---------- |
|
||||
| <Issue 1> | <Solution> |
|
||||
| <Issue 2> | <Solution> |
|
||||
|
||||
````
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Session Summary: Telemetry Implementation
|
||||
|
||||
```markdown
|
||||
# Session Summary: Cross-Platform Telemetry
|
||||
|
||||
## What Was Built
|
||||
1. Platform-service telemetry module (34 tests)
|
||||
- Cosmos containers: telemetry_events, telemetry_error_clusters, telemetry_collection_policies
|
||||
- Endpoints: POST /events (batch), GET /query, GET /config, policy CRUD
|
||||
|
||||
2. iOS keyboard client (LysnrTelemetry.swift)
|
||||
- App Group offline queue, X-Install-Token auth
|
||||
- Instrumented at 10+ points
|
||||
|
||||
3. Desktop Python client (platform_telemetry.py)
|
||||
- Threading flush timer, persistent install_id
|
||||
|
||||
4. Browser client (telemetry.ts)
|
||||
- sendBeacon + fetch fallback, 30s flush
|
||||
|
||||
## Pending
|
||||
- Phase 3: Error clustering alerts, geo enrichment, policy builder UI
|
||||
````
|
||||
|
||||
### Reusable Playbook: Secrets Hygiene
|
||||
|
||||
```markdown
|
||||
# Playbook: Secrets Hygiene (Apply to Any Repo)
|
||||
|
||||
### A. Inventory Secrets
|
||||
|
||||
- [ ] List all secrets the repo uses
|
||||
- [ ] Identify which are in Key Vault vs hardcoded
|
||||
|
||||
### B. Add Guardrails
|
||||
|
||||
- [ ] scripts/secret-scan-staged.sh (commit blocker)
|
||||
- [ ] scripts/secret-scan-repo.sh (push blocker)
|
||||
- [ ] .husky/pre-commit → runs staged scan
|
||||
- [ ] .husky/pre-push → runs repo scan
|
||||
- [ ] .gitignore: .env, .env.local, _.pem, _.p12, \*.key
|
||||
|
||||
### C. Create Templates
|
||||
|
||||
- [ ] .env.example with placeholders (no real values)
|
||||
- [ ] Document all env vars in AGENTS.md
|
||||
|
||||
### D. Rotate Compromised Secrets
|
||||
|
||||
- [ ] If any secret was ever in git history → rotate it NOW
|
||||
```
|
||||
|
||||
## Where to Store
|
||||
|
||||
| Document Type | Location | Committed? |
|
||||
| ------------------ | --------------------------------------- | ---------------------------------- |
|
||||
| Session summaries | `docs/WINDSURF/` or `docs/sessions/` | Yes |
|
||||
| Reusable playbooks | `docs/playbooks/` or in session summary | Yes |
|
||||
| Progress files | `progress.md` (root) | Optional (gitignored if temporary) |
|
||||
| Agent memories | Editor memory system | No (editor-specific) |
|
||||
|
||||
## The Playbook-to-Workflow Pipeline
|
||||
|
||||
When a playbook is used 3+ times, promote it to a workflow:
|
||||
|
||||
```
|
||||
Playbook (docs/playbooks/secrets-hygiene.md)
|
||||
→ Used successfully across 3 repos
|
||||
→ Promote to workflow (.windsurf/workflows/security-audit.md)
|
||||
→ Now any agent can run `/security-audit`
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- **No documentation at all** — Next session starts from scratch
|
||||
- **Documenting everything** — Focus on decisions and non-obvious steps
|
||||
- **Session summaries without "what's pending"** — The most useful section for continuity
|
||||
- **Playbooks without verification steps** — How do you know it worked?
|
||||
- **Playbooks in agent memory only** — Memories are editor-specific; committed docs are universal
|
||||
|
||||
## Related Skills
|
||||
|
||||
- [03 — Memory Management](./03-memory-management.md)
|
||||
- [06 — Multi-Session Continuity](./06-multi-session-continuity.md)
|
||||
- [04 — Workflow Definitions](./04-workflow-definitions.md)
|
||||
@ -47,6 +47,8 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
||||
// ChronoMind webhooks
|
||||
webhook_subscriptions: { partitionKeyPath: '/userId' },
|
||||
webhook_events: { partitionKeyPath: '/subscriptionId', defaultTtl: 30 * 86400 },
|
||||
// Sessions (refresh token rotation + device tracking)
|
||||
sessions: { partitionKeyPath: '/userId', defaultTtl: 30 * 86400 },
|
||||
// Email/push delivery log
|
||||
delivery_log: { partitionKeyPath: '/pk', defaultTtl: 90 * 86400 },
|
||||
// Status page incidents
|
||||
|
||||
58
services/platform-service/src/modules/maintenance/types.ts
Normal file
58
services/platform-service/src/modules/maintenance/types.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Maintenance Modes ────────────────────────────────────────
|
||||
|
||||
export type MaintenanceMode = 'off' | 'read_only' | 'maintenance' | 'emergency';
|
||||
|
||||
export interface MaintenanceConfig {
|
||||
mode: MaintenanceMode;
|
||||
message: string;
|
||||
adminMessage?: string;
|
||||
bypassRoles: string[];
|
||||
bypassIPs: string[];
|
||||
scheduledStart?: string;
|
||||
scheduledEnd?: string;
|
||||
affectedServices: string[];
|
||||
updatedAt: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
// ── Schemas ──────────────────────────────────────────────────
|
||||
|
||||
export const UpdateMaintenanceSchema = z.object({
|
||||
mode: z.enum(['off', 'read_only', 'maintenance', 'emergency']),
|
||||
message: z.string().min(1).max(500),
|
||||
adminMessage: z.string().max(500).optional(),
|
||||
bypassRoles: z.array(z.string()).default([]),
|
||||
bypassIPs: z.array(z.string()).default([]),
|
||||
scheduledStart: z.string().datetime().optional(),
|
||||
scheduledEnd: z.string().datetime().optional(),
|
||||
affectedServices: z.array(z.string()).default(['*']),
|
||||
});
|
||||
|
||||
export type UpdateMaintenanceInput = z.infer<typeof UpdateMaintenanceSchema>;
|
||||
|
||||
// ── Maintenance Window (scheduled) ───────────────────────────
|
||||
|
||||
export interface MaintenanceWindow {
|
||||
id: string;
|
||||
productId: string;
|
||||
title: string;
|
||||
message: string;
|
||||
mode: MaintenanceMode;
|
||||
scheduledStart: string;
|
||||
scheduledEnd: string;
|
||||
affectedServices: string[];
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
_ts?: number;
|
||||
}
|
||||
|
||||
export const CreateMaintenanceWindowSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
message: z.string().min(1).max(500),
|
||||
mode: z.enum(['read_only', 'maintenance']).default('maintenance'),
|
||||
scheduledStart: z.string().datetime(),
|
||||
scheduledEnd: z.string().datetime(),
|
||||
affectedServices: z.array(z.string()).default(['*']),
|
||||
});
|
||||
104
services/platform-service/src/modules/sessions/repository.ts
Normal file
104
services/platform-service/src/modules/sessions/repository.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import type { SessionDoc } from './types.js';
|
||||
|
||||
const CONTAINER = 'sessions';
|
||||
|
||||
function container() {
|
||||
return getContainer(CONTAINER);
|
||||
}
|
||||
|
||||
export async function createSession(doc: SessionDoc): Promise<SessionDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as SessionDoc;
|
||||
}
|
||||
|
||||
export async function getSession(id: string, userId: string): Promise<SessionDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(id, userId).read<SessionDoc>();
|
||||
return resource ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listUserSessions(userId: string): Promise<SessionDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<SessionDoc>(
|
||||
{
|
||||
query:
|
||||
'SELECT * FROM c WHERE c.userId = @userId AND NOT IS_DEFINED(c.revokedAt) ORDER BY c.lastActiveAt DESC',
|
||||
parameters: [{ name: '@userId', value: userId }],
|
||||
},
|
||||
{ partitionKey: userId }
|
||||
)
|
||||
.fetchAll();
|
||||
return resources;
|
||||
}
|
||||
|
||||
export async function listAllUserSessions(userId: string): Promise<SessionDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<SessionDoc>(
|
||||
{
|
||||
query: 'SELECT * FROM c WHERE c.userId = @userId ORDER BY c.createdAt DESC',
|
||||
parameters: [{ name: '@userId', value: userId }],
|
||||
},
|
||||
{ partitionKey: userId }
|
||||
)
|
||||
.fetchAll();
|
||||
return resources;
|
||||
}
|
||||
|
||||
export async function revokeSession(id: string, userId: string): Promise<boolean> {
|
||||
const session = await getSession(id, userId);
|
||||
if (!session || session.revokedAt) return false;
|
||||
|
||||
await container()
|
||||
.item(id, userId)
|
||||
.replace({
|
||||
...session,
|
||||
revokedAt: new Date().toISOString(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function revokeAllUserSessions(userId: string): Promise<number> {
|
||||
const sessions = await listUserSessions(userId);
|
||||
const now = new Date().toISOString();
|
||||
let revoked = 0;
|
||||
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
await container()
|
||||
.item(session.id, userId)
|
||||
.replace({
|
||||
...session,
|
||||
revokedAt: now,
|
||||
});
|
||||
revoked++;
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
return revoked;
|
||||
}
|
||||
|
||||
export async function touchSession(id: string, userId: string): Promise<void> {
|
||||
const session = await getSession(id, userId);
|
||||
if (!session || session.revokedAt) return;
|
||||
|
||||
await container()
|
||||
.item(id, userId)
|
||||
.replace({
|
||||
...session,
|
||||
lastActiveAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function isSessionRevoked(id: string, userId: string): Promise<boolean> {
|
||||
const session = await getSession(id, userId);
|
||||
if (!session) return true;
|
||||
if (session.revokedAt) return true;
|
||||
if (new Date(session.expiresAt) < new Date()) return true;
|
||||
return false;
|
||||
}
|
||||
83
services/platform-service/src/modules/sessions/routes.ts
Normal file
83
services/platform-service/src/modules/sessions/routes.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { BadRequestError, ForbiddenError, UnauthorizedError } from '../../lib/errors.js';
|
||||
import * as repo from './repository.js';
|
||||
|
||||
export async function sessionRoutes(app: FastifyInstance) {
|
||||
// ── User endpoints (authenticated) ─────────────────────────
|
||||
|
||||
// List my active sessions
|
||||
app.get('/sessions', async req => {
|
||||
const payload = req.jwtPayload;
|
||||
if (!payload?.sub) throw new UnauthorizedError('Authentication required');
|
||||
|
||||
const sessions = await repo.listUserSessions(payload.sub);
|
||||
return {
|
||||
sessions: sessions.map(stripSensitive),
|
||||
count: sessions.length,
|
||||
};
|
||||
});
|
||||
|
||||
// Revoke a specific session
|
||||
app.delete('/sessions/:id', async req => {
|
||||
const payload = req.jwtPayload;
|
||||
if (!payload?.sub) throw new UnauthorizedError('Authentication required');
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const revoked = await repo.revokeSession(id, payload.sub);
|
||||
if (!revoked) throw new BadRequestError('Session not found or already revoked');
|
||||
|
||||
req.log.info({ sessionId: id, userId: payload.sub }, '[sessions] Session revoked');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Revoke all my sessions (sign out everywhere)
|
||||
app.delete('/sessions', async req => {
|
||||
const payload = req.jwtPayload;
|
||||
if (!payload?.sub) throw new UnauthorizedError('Authentication required');
|
||||
|
||||
const count = await repo.revokeAllUserSessions(payload.sub);
|
||||
req.log.info({ userId: payload.sub, count }, '[sessions] All sessions revoked');
|
||||
return { success: true, revokedCount: count };
|
||||
});
|
||||
|
||||
// ── Admin endpoints ────────────────────────────────────────
|
||||
|
||||
function requireAdmin(req: import('fastify').FastifyRequest): void {
|
||||
const role = req.jwtPayload?.role;
|
||||
if (!role || !['super_admin', 'admin'].includes(role)) {
|
||||
throw new ForbiddenError('Admin access required');
|
||||
}
|
||||
}
|
||||
|
||||
// Admin: list a user's sessions (active + revoked)
|
||||
app.get('/sessions/user/:userId', async req => {
|
||||
requireAdmin(req);
|
||||
const { userId } = req.params as { userId: string };
|
||||
const sessions = await repo.listAllUserSessions(userId);
|
||||
return {
|
||||
sessions: sessions.map(stripSensitive),
|
||||
count: sessions.length,
|
||||
};
|
||||
});
|
||||
|
||||
// Admin: force-revoke all sessions for a user
|
||||
app.delete('/sessions/user/:userId', async req => {
|
||||
requireAdmin(req);
|
||||
const { userId } = req.params as { userId: string };
|
||||
const count = await repo.revokeAllUserSessions(userId);
|
||||
|
||||
req.log.info(
|
||||
{ userId, adminId: req.jwtPayload?.sub, count },
|
||||
'[sessions] Admin force-revoked all sessions'
|
||||
);
|
||||
return { success: true, revokedCount: count };
|
||||
});
|
||||
}
|
||||
|
||||
// Strip internal fields from response
|
||||
function stripSensitive(session: import('./types.js').SessionDoc) {
|
||||
const { _ts, _etag, ...rest } = session;
|
||||
void _ts;
|
||||
void _etag;
|
||||
return rest;
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CreateSessionSchema, RevokeSessionSchema } from './types.js';
|
||||
import type { SessionDoc, SessionPlatform } from './types.js';
|
||||
|
||||
describe('CreateSessionSchema', () => {
|
||||
it('accepts valid session input with platform', () => {
|
||||
const result = CreateSessionSchema.safeParse({ platform: 'ios', deviceId: 'dev_123' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.platform).toBe('ios');
|
||||
expect(result.data.deviceId).toBe('dev_123');
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults platform to unknown', () => {
|
||||
const result = CreateSessionSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.platform).toBe('unknown');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts all valid platforms', () => {
|
||||
const platforms: SessionPlatform[] = ['ios', 'android', 'desktop', 'web', 'watch', 'unknown'];
|
||||
for (const platform of platforms) {
|
||||
const result = CreateSessionSchema.safeParse({ platform });
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid platform', () => {
|
||||
const result = CreateSessionSchema.safeParse({ platform: 'invalid' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RevokeSessionSchema', () => {
|
||||
it('accepts empty body', () => {
|
||||
const result = RevokeSessionSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts reason string', () => {
|
||||
const result = RevokeSessionSchema.safeParse({ reason: 'Suspicious activity' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects reason over 200 chars', () => {
|
||||
const result = RevokeSessionSchema.safeParse({ reason: 'x'.repeat(201) });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SessionDoc type coverage', () => {
|
||||
it('should allow building a valid session document', () => {
|
||||
const now = new Date().toISOString();
|
||||
const session: SessionDoc = {
|
||||
id: 'ses_123',
|
||||
productId: 'lysnrai',
|
||||
userId: 'usr_abc',
|
||||
platform: 'web',
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
lastActiveAt: now,
|
||||
createdAt: now,
|
||||
expiresAt: new Date(Date.now() + 7 * 86400000).toISOString(),
|
||||
};
|
||||
expect(session.id).toBe('ses_123');
|
||||
expect(session.revokedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow deviceId and revokedAt optional fields', () => {
|
||||
const now = new Date().toISOString();
|
||||
const session: SessionDoc = {
|
||||
id: 'ses_456',
|
||||
productId: 'chronomind',
|
||||
userId: 'usr_def',
|
||||
deviceId: 'dev_789',
|
||||
platform: 'ios',
|
||||
ipAddress: '10.0.0.1',
|
||||
userAgent: 'ChronoMind/1.0',
|
||||
lastActiveAt: now,
|
||||
createdAt: now,
|
||||
expiresAt: now,
|
||||
revokedAt: now,
|
||||
};
|
||||
expect(session.deviceId).toBe('dev_789');
|
||||
expect(session.revokedAt).toBe(now);
|
||||
});
|
||||
});
|
||||
34
services/platform-service/src/modules/sessions/types.ts
Normal file
34
services/platform-service/src/modules/sessions/types.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Session Document ─────────────────────────────────────────
|
||||
|
||||
export type SessionPlatform = 'ios' | 'android' | 'desktop' | 'web' | 'watch' | 'unknown';
|
||||
|
||||
export interface SessionDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
userId: string;
|
||||
deviceId?: string;
|
||||
platform: SessionPlatform;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
lastActiveAt: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
revokedAt?: string;
|
||||
_ts?: number;
|
||||
_etag?: string;
|
||||
}
|
||||
|
||||
// ── Schemas ──────────────────────────────────────────────────
|
||||
|
||||
export const CreateSessionSchema = z.object({
|
||||
platform: z.enum(['ios', 'android', 'desktop', 'web', 'watch', 'unknown']).default('unknown'),
|
||||
deviceId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const RevokeSessionSchema = z.object({
|
||||
reason: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export type CreateSessionInput = z.infer<typeof CreateSessionSchema>;
|
||||
@ -60,6 +60,7 @@ import { webhookRoutes } from './modules/webhooks/routes.js';
|
||||
import { jobRoutes } from './modules/jobs/routes.js';
|
||||
import { statusRoutes } from './modules/status/routes.js';
|
||||
import { deliveryRoutes } from './modules/delivery/routes.js';
|
||||
import { sessionRoutes } from './modules/sessions/routes.js';
|
||||
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||
import { config } from './lib/config.js';
|
||||
|
||||
@ -153,5 +154,7 @@ await app.register(jobRoutes, { prefix: '/api' });
|
||||
await app.register(statusRoutes, { prefix: '/api' });
|
||||
// Transactional email delivery
|
||||
await app.register(deliveryRoutes, { prefix: '/api' });
|
||||
// Session management
|
||||
await app.register(sessionRoutes, { prefix: '/api' });
|
||||
|
||||
await startService(app, { port: config.PORT, host: config.HOST });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user