From 069d1ffda9273a681472f537df2405e2e7903c16 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 28 Feb 2026 02:41:22 -0800 Subject: [PATCH] docs: add 19 reusable AI coding agent skills + sessions module scaffold --- .../WINDSURF/_SKILLS/09-secret-scanning.md | 207 ++++++++++++++++ .../WINDSURF/_SKILLS/14-shared-packages.md | 226 +++++++++++++++++ .../_SKILLS/15-service-consolidation.md | 171 +++++++++++++ .../WINDSURF/_SKILLS/16-design-tokens.md | 232 ++++++++++++++++++ .../_SKILLS/17-multi-repo-coordination.md | 172 +++++++++++++ .../_SKILLS/18-environment-management.md | 193 +++++++++++++++ .../WINDSURF/_SKILLS/19-session-playbooks.md | 208 ++++++++++++++++ .../platform-service/src/lib/cosmos-init.ts | 2 + .../src/modules/maintenance/types.ts | 58 +++++ .../src/modules/sessions/repository.ts | 104 ++++++++ .../src/modules/sessions/routes.ts | 83 +++++++ .../src/modules/sessions/sessions.test.ts | 90 +++++++ .../src/modules/sessions/types.ts | 34 +++ services/platform-service/src/server.ts | 3 + 14 files changed, 1783 insertions(+) create mode 100644 __LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/09-secret-scanning.md create mode 100644 __LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/14-shared-packages.md create mode 100644 __LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/15-service-consolidation.md create mode 100644 __LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/16-design-tokens.md create mode 100644 __LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/17-multi-repo-coordination.md create mode 100644 __LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/18-environment-management.md create mode 100644 __LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/19-session-playbooks.md create mode 100644 services/platform-service/src/modules/maintenance/types.ts create mode 100644 services/platform-service/src/modules/sessions/repository.ts create mode 100644 services/platform-service/src/modules/sessions/routes.ts create mode 100644 services/platform-service/src/modules/sessions/sessions.test.ts create mode 100644 services/platform-service/src/modules/sessions/types.ts diff --git a/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/09-secret-scanning.md b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/09-secret-scanning.md new file mode 100644 index 00000000..568372cb --- /dev/null +++ b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/09-secret-scanning.md @@ -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= +JWT_SECRET= +PAYMENT_KEY= +``` + +## 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) diff --git a/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/14-shared-packages.md b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/14-shared-packages.md new file mode 100644 index 00000000..2045526c --- /dev/null +++ b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/14-shared-packages.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) diff --git a/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/15-service-consolidation.md b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/15-service-consolidation.md new file mode 100644 index 00000000..003682da --- /dev/null +++ b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/15-service-consolidation.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) diff --git a/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/16-design-tokens.md b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/16-design-tokens.md new file mode 100644 index 00000000..a6a75015 --- /dev/null +++ b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/16-design-tokens.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) diff --git a/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/17-multi-repo-coordination.md b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/17-multi-repo-coordination.md new file mode 100644 index 00000000..4b80fc16 --- /dev/null +++ b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/17-multi-repo-coordination.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 "" +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) diff --git a/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/18-environment-management.md b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/18-environment-management.md new file mode 100644 index 00000000..d19a353a --- /dev/null +++ b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/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= +DB_NAME=myapp + +# Auth +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= +DB_NAME=myapp + +# Auth +JWT_SECRET= + +# 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 = { + DB_KEY: 'myapp-db-key', + JWT_SECRET: 'myapp-jwt-secret', + PAYMENT_KEY: 'myapp-payment-key', +}; + +export async function resolveKeyVaultSecrets(): Promise { + 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) diff --git a/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/19-session-playbooks.md b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/19-session-playbooks.md new file mode 100644 index 00000000..f07deaa8 --- /dev/null +++ b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/19-session-playbooks.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):** +> **Scope:** + +## What Was Built + +### 1. [Component/Feature Name] + +- File: `path/to/file.ts` +- What: +- Tests: passing + +### 2. [Component/Feature Name] + +- ... + +## Architecture Decisions + +- **Decision:** +- **Reason:** +- **Alternative rejected:** + +## What's Pending + +- [ ] +- [ ] + +## 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 + +- [ ] +- [ ] + +## 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 + + +```` + +## Common Issues + +| Problem | Fix | +| --------- | ---------- | +| | | +| | | + +```` + +## 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) diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index 6fa392b0..64c23cd9 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -47,6 +47,8 @@ const CONTAINER_DEFS: Record = { // 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 diff --git a/services/platform-service/src/modules/maintenance/types.ts b/services/platform-service/src/modules/maintenance/types.ts new file mode 100644 index 00000000..75903fa5 --- /dev/null +++ b/services/platform-service/src/modules/maintenance/types.ts @@ -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; + +// ── 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(['*']), +}); diff --git a/services/platform-service/src/modules/sessions/repository.ts b/services/platform-service/src/modules/sessions/repository.ts new file mode 100644 index 00000000..3bf69031 --- /dev/null +++ b/services/platform-service/src/modules/sessions/repository.ts @@ -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 { + const { resource } = await container().items.create(doc); + return resource as SessionDoc; +} + +export async function getSession(id: string, userId: string): Promise { + try { + const { resource } = await container().item(id, userId).read(); + return resource ?? null; + } catch { + return null; + } +} + +export async function listUserSessions(userId: string): Promise { + const { resources } = await container() + .items.query( + { + 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 { + const { resources } = await container() + .items.query( + { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/services/platform-service/src/modules/sessions/routes.ts b/services/platform-service/src/modules/sessions/routes.ts new file mode 100644 index 00000000..fed1be4b --- /dev/null +++ b/services/platform-service/src/modules/sessions/routes.ts @@ -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; +} diff --git a/services/platform-service/src/modules/sessions/sessions.test.ts b/services/platform-service/src/modules/sessions/sessions.test.ts new file mode 100644 index 00000000..1032739d --- /dev/null +++ b/services/platform-service/src/modules/sessions/sessions.test.ts @@ -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); + }); +}); diff --git a/services/platform-service/src/modules/sessions/types.ts b/services/platform-service/src/modules/sessions/types.ts new file mode 100644 index 00000000..516099ae --- /dev/null +++ b/services/platform-service/src/modules/sessions/types.ts @@ -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; diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 2c392026..3562a6a6 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -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 });