docs: add 19 reusable AI coding agent skills + sessions module scaffold

This commit is contained in:
saravanakumardb1 2026-02-28 02:41:22 -08:00
parent 662d417267
commit 069d1ffda9
14 changed files with 1783 additions and 0 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View 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(['*']),
});

View 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;
}

View 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;
}

View File

@ -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);
});
});

View 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>;

View File

@ -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 });