feat(platform): add P0 foundational modules — event bus, scheduled jobs, password reset, email verification, status page
This commit is contained in:
parent
0e468f74ea
commit
772f428967
@ -1,9 +1,9 @@
|
||||
Last refresh: 2026-02-28T08:03:01Z (2026-02-28 00:03:01 PST)
|
||||
Cascade conversations: 50 (540M)
|
||||
Memories: 45
|
||||
Last refresh: 2026-02-28T10:22:33Z (2026-02-28 02:22:33 PST)
|
||||
Cascade conversations: 50 (548M)
|
||||
Memories: 48
|
||||
Implicit context: 20
|
||||
Code tracker dirs: 182
|
||||
File edit history: 1810 entries
|
||||
Code tracker dirs: 196
|
||||
File edit history: 1868 entries
|
||||
Workspace storage: 28 workspaces
|
||||
Repo docs: 13 files across 3 repos
|
||||
Repo workflows: 23 files across 4 repos
|
||||
|
||||
@ -0,0 +1,171 @@
|
||||
# Writing Effective AGENTS.md
|
||||
|
||||
> The single most impactful file for AI coding agent productivity. A well-written AGENTS.md turns any agent from "confused newcomer" to "productive team member" in one read.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Starting a new repo
|
||||
- Onboarding a new AI tool (Claude Code, Cursor, Windsurf, etc.)
|
||||
- When agents keep making the same mistakes
|
||||
- When context is lost between sessions
|
||||
|
||||
## The Pattern
|
||||
|
||||
AGENTS.md is a **universal** agent onboarding document that lives at the repo root. Every major AI coding tool reads it automatically or can be pointed to it:
|
||||
|
||||
- **Claude Code** — reads AGENTS.md + CLAUDE.md automatically
|
||||
- **Cursor** — reads .cursorrules automatically
|
||||
- **Windsurf** — reads .windsurfrules automatically
|
||||
- **GitHub Copilot** — reads .github/copilot-instructions.md
|
||||
- **Codex / Aider / Cline** — can be pointed to AGENTS.md
|
||||
|
||||
AGENTS.md is the **canonical source**; all other files are derived from it.
|
||||
|
||||
## Structure Template
|
||||
|
||||
```markdown
|
||||
# AGENTS.md — AI Coding Agent Instructions
|
||||
|
||||
> **For:** Claude Code, Cursor, Copilot, Windsurf, Aider, Codex
|
||||
> **Repo:** <repo-name> — <one-line description>
|
||||
|
||||
## 1. Project Identity
|
||||
|
||||
| Key | Value |
|
||||
| ------------------- | -------------------------------------- |
|
||||
| **Product** | <Name> |
|
||||
| **Stack** | <e.g., Next.js 16, Fastify 5, SwiftUI> |
|
||||
| **Package manager** | <npm/pnpm/yarn/pip> |
|
||||
| **Test runner** | <Vitest/pytest/XCTest> |
|
||||
|
||||
## 2. Repo Layout
|
||||
|
||||
(ASCII tree of key directories — NOT exhaustive, just the important ones)
|
||||
|
||||
## 3. Tech Stack Rules
|
||||
|
||||
(Framework-specific conventions: ESM, module resolution, etc.)
|
||||
|
||||
## 4. Coding Conventions
|
||||
|
||||
### MUST follow
|
||||
|
||||
- <Rule 1>
|
||||
- <Rule 2>
|
||||
|
||||
### MUST NOT do
|
||||
|
||||
- <Anti-pattern 1>
|
||||
- <Anti-pattern 2>
|
||||
|
||||
## 5. File Ownership Map
|
||||
|
||||
| Domain | Files |
|
||||
| ------ | ----------------- |
|
||||
| Auth | src/modules/auth/ |
|
||||
| ... | ... |
|
||||
|
||||
## 6. Build & Test Commands
|
||||
|
||||
(Copy-pasteable commands the agent can run)
|
||||
|
||||
## 7. Common Patterns
|
||||
|
||||
(Code templates for adding new features)
|
||||
|
||||
## 8. Common Pitfalls
|
||||
|
||||
(Things that break often and how to fix them)
|
||||
```
|
||||
|
||||
## Key Principles
|
||||
|
||||
### 1. Be prescriptive, not descriptive
|
||||
|
||||
Bad: "We use TypeScript"
|
||||
Good: "ESM everywhere (`"type": "module"`, `.js` extensions in imports)"
|
||||
|
||||
### 2. Include concrete examples
|
||||
|
||||
Bad: "Follow the module pattern"
|
||||
Good: "Each module follows `types.ts` → `repository.ts` → `routes.ts`. See `src/modules/auth/` for reference."
|
||||
|
||||
### 3. Include MUST NOT rules
|
||||
|
||||
Agents need guardrails. Explicitly list what they should never do:
|
||||
|
||||
- "Never use `console.log` — use `req.log` or `app.log`"
|
||||
- "Never hardcode colors — use theme tokens"
|
||||
- "Never modify tests to make them pass — fix the actual code"
|
||||
- "Never delete existing comments unless explicitly asked"
|
||||
|
||||
### 4. Include build verification commands
|
||||
|
||||
Agents need to verify their work. Give them the exact commands:
|
||||
|
||||
```
|
||||
## Build Verification
|
||||
- Web: `cd web && npm test && npm run typecheck && npm run build`
|
||||
- iOS: Open .xcodeproj, Cmd+B
|
||||
- Backend: `python -m pytest tests/ -v --tb=short`
|
||||
```
|
||||
|
||||
### 5. Include the file ownership map
|
||||
|
||||
When an agent needs to work on "auth", it should know exactly which files to touch. A table mapping domains to file paths eliminates guesswork.
|
||||
|
||||
### 6. Keep it under 500 lines
|
||||
|
||||
Too long and agents will truncate or skip sections. Focus on what causes the most mistakes.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- **No AGENTS.md at all** — Agent wastes 30% of conversation just understanding the codebase
|
||||
- **README.md as the only guide** — READMEs are for humans; AGENTS.md is for machines (different structure)
|
||||
- **Stale AGENTS.md** — Update it when architecture changes. Run `/update-agent-docs` workflow regularly
|
||||
- **Too generic** — "Follow best practices" helps nobody. Be specific
|
||||
- **Duplicated across editors** — Maintain AGENTS.md as source, derive .cursorrules etc. from it
|
||||
|
||||
## Real-World Example
|
||||
|
||||
From a 5-product ecosystem (25+ platform-service modules, 847 tests):
|
||||
|
||||
```markdown
|
||||
## 4. Coding Conventions
|
||||
|
||||
### MUST follow
|
||||
|
||||
- Every Cosmos document MUST include a `productId` field
|
||||
- Every REST endpoint MUST validate input with Zod schemas
|
||||
- Every service MUST propagate `x-request-id` headers
|
||||
- Use `PRODUCT_ID` from `@bytelyst/config` — never hardcode
|
||||
- Commit messages: `type(scope): description`
|
||||
|
||||
### MUST NOT do
|
||||
|
||||
- Never use `console.log` — use `req.log` or `app.log` in Fastify
|
||||
- Never use `any` type — use Zod inference or explicit types
|
||||
- Never hardcode secrets or API keys
|
||||
- Never modify tests to make them pass — fix the actual code
|
||||
- Never add emojis to code unless explicitly asked
|
||||
```
|
||||
|
||||
This set of rules was developed over 50+ sessions and prevents the most common agent mistakes.
|
||||
|
||||
## Editor-Specific Notes
|
||||
|
||||
| Editor | Auto-read file | Derivation |
|
||||
| ----------- | ------------------------------- | ---------------------------------------------- |
|
||||
| Claude Code | CLAUDE.md | Subset of AGENTS.md with Claude-specific notes |
|
||||
| Cursor | .cursorrules | AGENTS.md condensed to rules format |
|
||||
| Windsurf | .windsurfrules | AGENTS.md condensed to rules format |
|
||||
| Copilot | .github/copilot-instructions.md | AGENTS.md adapted for Copilot |
|
||||
| Aider | .aider.conf.yml | Config-only, points to AGENTS.md |
|
||||
| Cline | .clinerules | AGENTS.md condensed |
|
||||
| JetBrains | .editorconfig | Formatting rules only |
|
||||
|
||||
## Related Skills
|
||||
|
||||
- [02 — Multi-Editor Config](./02-multi-editor-config.md)
|
||||
- [04 — Workflow Definitions](./04-workflow-definitions.md)
|
||||
- [19 — Session Summaries & Playbooks](./19-session-playbooks.md)
|
||||
124
__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/README.md
Normal file
124
__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/_SKILLS/README.md
Normal file
@ -0,0 +1,124 @@
|
||||
# Reusable AI Coding Agent Skills
|
||||
|
||||
> **Extracted from:** 50+ Windsurf Cascade conversations across 5 repos (LysnrAI, MindLyst, ChronoMind, NomGap, Common Platform)
|
||||
> **Purpose:** Editor-agnostic patterns for productive AI-assisted development
|
||||
> **Works with:** Claude Code, Cursor, Windsurf Cascade, GitHub Copilot, Aider, Cline, Codex, and any agentic coding tool
|
||||
|
||||
---
|
||||
|
||||
## Philosophy
|
||||
|
||||
These skills were mined from real multi-month development across a 5-product ecosystem. They represent **battle-tested patterns** — not theoretical advice. Each skill emerged from solving actual problems with AI coding agents.
|
||||
|
||||
**Core principles:**
|
||||
|
||||
1. **Agent-as-pair-programmer** — Give agents enough context to be autonomous, but keep humans in the loop for architectural decisions
|
||||
2. **Repeatability** — Every pattern should work across projects and editors
|
||||
3. **Fail-safe defaults** — Guardrails prevent destructive mistakes (secret leaks, broken builds, data loss)
|
||||
4. **Session continuity** — Knowledge persists across conversations via structured docs and memory systems
|
||||
5. **Minimal, focused changes** — Agents should make surgical edits, not rewrite files
|
||||
|
||||
---
|
||||
|
||||
## Skill Categories
|
||||
|
||||
### Agent Setup & Configuration
|
||||
|
||||
How to configure your repo so any AI agent can onboard instantly.
|
||||
|
||||
| # | Skill | Description |
|
||||
| --- | ---------------------------------------------------- | --------------------------------------------- |
|
||||
| 01 | [AGENTS.md Pattern](./01-agents-md-pattern.md) | The universal agent onboarding document |
|
||||
| 02 | [Multi-Editor Config](./02-multi-editor-config.md) | One repo, 8 editor configs, zero drift |
|
||||
| 03 | [Memory Management](./03-memory-management.md) | What to persist, when to update, how to prune |
|
||||
| 04 | [Workflow Definitions](./04-workflow-definitions.md) | Reusable slash commands for complex tasks |
|
||||
|
||||
### AI-Assisted Development
|
||||
|
||||
Patterns for getting the most out of AI agents during development.
|
||||
|
||||
| # | Skill | Description |
|
||||
| --- | -------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| 05 | [Effective Agent Prompting](./05-effective-agent-prompting.md) | How to communicate with AI agents for best results |
|
||||
| 06 | [Multi-Session Continuity](./06-multi-session-continuity.md) | Maintaining context across conversations |
|
||||
| 07 | [Systematic Debugging](./07-systematic-debugging.md) | Root-cause analysis with AI assistance |
|
||||
| 08 | [Test-Driven Bug Fixing](./08-test-driven-fixing.md) | Write the test first, then fix the code |
|
||||
|
||||
### Code Quality & Security
|
||||
|
||||
Guardrails that prevent agents from making destructive mistakes.
|
||||
|
||||
| # | Skill | Description |
|
||||
| --- | -------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| 09 | [Secret Scanning Guardrails](./09-secret-scanning.md) | Pre-commit/pre-push secret detection |
|
||||
| 10 | [LLM Abuse Controls](./10-llm-abuse-controls.md) | Rate limiting, input guards, denial-of-wallet prevention |
|
||||
| 11 | [AI-Driven Security Auditing](./11-security-auditing.md) | Using agents to audit your own codebase |
|
||||
| 12 | [Pre-Release Quality Gates](./12-quality-gates.md) | Mandatory checks before build/release |
|
||||
|
||||
### Architecture Patterns
|
||||
|
||||
Proven patterns for structuring code that agents can navigate and extend.
|
||||
|
||||
| # | Skill | Description |
|
||||
| --- | ------------------------------------------------------ | ----------------------------------------------------------- |
|
||||
| 13 | [Module Pattern](./13-module-pattern.md) | types → repository → routes (agent-friendly service design) |
|
||||
| 14 | [Shared Package Extraction](./14-shared-packages.md) | Identifying and extracting duplicated code |
|
||||
| 15 | [Service Consolidation](./15-service-consolidation.md) | Merging microservices without breaking consumers |
|
||||
| 16 | [Cross-Platform Design Tokens](./16-design-tokens.md) | One JSON source → CSS, TS, Kotlin, Swift |
|
||||
|
||||
### Operations & Environment
|
||||
|
||||
Managing multi-repo workspaces, environments, and deployments.
|
||||
|
||||
| # | Skill | Description |
|
||||
| --- | ------------------------------------------------------------------ | -------------------------------------------- |
|
||||
| 17 | [Multi-Repo Coordination](./17-multi-repo-coordination.md) | Cross-repo operations (sync, backup, commit) |
|
||||
| 18 | [Environment & Secrets Management](./18-environment-management.md) | Env files, symlinks, Key Vault resolution |
|
||||
| 19 | [Session Summaries & Playbooks](./19-session-playbooks.md) | Documenting what was done for future agents |
|
||||
|
||||
---
|
||||
|
||||
## How to Use
|
||||
|
||||
### In any AI coding editor
|
||||
|
||||
1. **Point your agent to AGENTS.md** — Most editors auto-read it from repo root
|
||||
2. **Reference specific skills** — "Follow the pattern in \_SKILLS/13-module-pattern.md"
|
||||
3. **Use workflows** — "Run /release-testflight" triggers multi-step processes
|
||||
4. **Check memories** — Agent memories persist patterns across sessions
|
||||
|
||||
### Adapting to your project
|
||||
|
||||
- Replace project-specific names (LysnrAI, MindLyst, etc.) with your own
|
||||
- Adjust tech stack references (Fastify → Express, Cosmos → Postgres, etc.)
|
||||
- Keep the structural patterns — they're stack-agnostic
|
||||
|
||||
---
|
||||
|
||||
## Skill Format
|
||||
|
||||
Each skill follows this structure:
|
||||
|
||||
```markdown
|
||||
# Skill Name
|
||||
|
||||
> One-line description
|
||||
|
||||
## When to Use
|
||||
|
||||
## The Pattern
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
## Anti-Patterns (what NOT to do)
|
||||
|
||||
## Real-World Example
|
||||
|
||||
## Editor-Specific Notes (Windsurf, Cursor, Claude, etc.)
|
||||
|
||||
## Related Skills
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
_Mined from Windsurf Cascade chat history — Feb 2026_
|
||||
23
packages/events/package.json
Normal file
23
packages/events/package.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@bytelyst/events",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.0.0"
|
||||
}
|
||||
}
|
||||
10
packages/events/src/index.ts
Normal file
10
packages/events/src/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export { EventBus } from './memory.js';
|
||||
export type { EmitResult, EmitError } from './memory.js';
|
||||
export { PlatformEventSchemas } from './types.js';
|
||||
export type {
|
||||
PlatformEventName,
|
||||
PlatformEventPayload,
|
||||
PlatformEvent,
|
||||
EventHandler,
|
||||
EventSubscription,
|
||||
} from './types.js';
|
||||
250
packages/events/src/memory.test.ts
Normal file
250
packages/events/src/memory.test.ts
Normal file
@ -0,0 +1,250 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { EventBus } from './memory.js';
|
||||
import type { PlatformEvent } from './types.js';
|
||||
|
||||
describe('EventBus', () => {
|
||||
let bus: EventBus;
|
||||
|
||||
beforeEach(() => {
|
||||
bus = new EventBus();
|
||||
});
|
||||
|
||||
describe('on / emit', () => {
|
||||
it('should deliver events to registered handlers', async () => {
|
||||
const handler = vi.fn();
|
||||
bus.on('user.created', handler);
|
||||
|
||||
await bus.emit('user.created', {
|
||||
userId: 'u1',
|
||||
email: 'test@example.com',
|
||||
plan: 'free',
|
||||
productId: 'lysnrai',
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(handler.mock.calls[0][0]).toMatchObject({
|
||||
type: 'user.created',
|
||||
payload: { userId: 'u1', email: 'test@example.com' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should include event id and timestamp', async () => {
|
||||
let received: PlatformEvent<'user.created'> | undefined;
|
||||
bus.on('user.created', e => {
|
||||
received = e;
|
||||
});
|
||||
|
||||
await bus.emit('user.created', {
|
||||
userId: 'u1',
|
||||
email: 'a@b.com',
|
||||
plan: 'free',
|
||||
productId: 'test',
|
||||
});
|
||||
|
||||
expect(received).toBeDefined();
|
||||
expect(received!.id).toBeTruthy();
|
||||
expect(received!.timestamp).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should deliver to multiple handlers', async () => {
|
||||
const h1 = vi.fn();
|
||||
const h2 = vi.fn();
|
||||
const h3 = vi.fn();
|
||||
bus.on('user.created', h1);
|
||||
bus.on('user.created', h2);
|
||||
bus.on('user.created', h3);
|
||||
|
||||
await bus.emit('user.created', {
|
||||
userId: 'u1',
|
||||
email: 'a@b.com',
|
||||
plan: 'free',
|
||||
productId: 'test',
|
||||
});
|
||||
|
||||
expect(h1).toHaveBeenCalledOnce();
|
||||
expect(h2).toHaveBeenCalledOnce();
|
||||
expect(h3).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should not deliver to handlers of different event types', async () => {
|
||||
const userHandler = vi.fn();
|
||||
const paymentHandler = vi.fn();
|
||||
bus.on('user.created', userHandler);
|
||||
bus.on('payment.succeeded', paymentHandler);
|
||||
|
||||
await bus.emit('user.created', {
|
||||
userId: 'u1',
|
||||
email: 'a@b.com',
|
||||
plan: 'free',
|
||||
productId: 'test',
|
||||
});
|
||||
|
||||
expect(userHandler).toHaveBeenCalledOnce();
|
||||
expect(paymentHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return result with zero handlers when no subscribers', async () => {
|
||||
const result = await bus.emit('user.deleted', {
|
||||
userId: 'u1',
|
||||
productId: 'test',
|
||||
});
|
||||
|
||||
expect(result.handlerCount).toBe(0);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.eventId).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error isolation', () => {
|
||||
it('should not block other handlers when one throws', async () => {
|
||||
const h1 = vi.fn();
|
||||
const h2 = vi.fn(() => {
|
||||
throw new Error('handler crash');
|
||||
});
|
||||
const h3 = vi.fn();
|
||||
|
||||
bus.on('user.created', h1);
|
||||
bus.on('user.created', h2);
|
||||
bus.on('user.created', h3);
|
||||
|
||||
const result = await bus.emit('user.created', {
|
||||
userId: 'u1',
|
||||
email: 'a@b.com',
|
||||
plan: 'free',
|
||||
productId: 'test',
|
||||
});
|
||||
|
||||
expect(h1).toHaveBeenCalledOnce();
|
||||
expect(h2).toHaveBeenCalledOnce();
|
||||
expect(h3).toHaveBeenCalledOnce();
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].error).toBe('handler crash');
|
||||
});
|
||||
|
||||
it('should handle async handler rejection', async () => {
|
||||
bus.on('payment.failed', async () => {
|
||||
throw new Error('async fail');
|
||||
});
|
||||
|
||||
const result = await bus.emit('payment.failed', {
|
||||
invoiceId: 'inv_1',
|
||||
userId: 'u1',
|
||||
amount: 999,
|
||||
retryCount: 1,
|
||||
productId: 'test',
|
||||
});
|
||||
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].error).toBe('async fail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsubscribe', () => {
|
||||
it('should stop delivering events after unsubscribe', async () => {
|
||||
const handler = vi.fn();
|
||||
const sub = bus.on('user.created', handler);
|
||||
|
||||
await bus.emit('user.created', {
|
||||
userId: 'u1',
|
||||
email: 'a@b.com',
|
||||
plan: 'free',
|
||||
productId: 'test',
|
||||
});
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
|
||||
sub.unsubscribe();
|
||||
|
||||
await bus.emit('user.created', {
|
||||
userId: 'u2',
|
||||
email: 'b@c.com',
|
||||
plan: 'pro',
|
||||
productId: 'test',
|
||||
});
|
||||
expect(handler).toHaveBeenCalledOnce(); // still 1, not 2
|
||||
});
|
||||
|
||||
it('should return subscription metadata', () => {
|
||||
const sub = bus.on('flag.toggled', () => {});
|
||||
expect(sub.id).toBeTruthy();
|
||||
expect(sub.eventType).toBe('flag.toggled');
|
||||
expect(typeof sub.unsubscribe).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should remove all handlers for a specific event type', async () => {
|
||||
const h1 = vi.fn();
|
||||
const h2 = vi.fn();
|
||||
bus.on('user.created', h1);
|
||||
bus.on('payment.succeeded', h2);
|
||||
|
||||
bus.clear('user.created');
|
||||
|
||||
await bus.emit('user.created', {
|
||||
userId: 'u1',
|
||||
email: 'a@b.com',
|
||||
plan: 'free',
|
||||
productId: 'test',
|
||||
});
|
||||
await bus.emit('payment.succeeded', {
|
||||
invoiceId: 'inv_1',
|
||||
userId: 'u1',
|
||||
amount: 100,
|
||||
currency: 'usd',
|
||||
productId: 'test',
|
||||
});
|
||||
|
||||
expect(h1).not.toHaveBeenCalled();
|
||||
expect(h2).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should remove all handlers when called without args', () => {
|
||||
bus.on('user.created', () => {});
|
||||
bus.on('payment.failed', () => {});
|
||||
bus.on('flag.toggled', () => {});
|
||||
|
||||
expect(bus.eventTypes().length).toBe(3);
|
||||
bus.clear();
|
||||
expect(bus.eventTypes().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listenerCount / eventTypes', () => {
|
||||
it('should count handlers per event type', () => {
|
||||
bus.on('user.created', () => {});
|
||||
bus.on('user.created', () => {});
|
||||
bus.on('payment.failed', () => {});
|
||||
|
||||
expect(bus.listenerCount('user.created')).toBe(2);
|
||||
expect(bus.listenerCount('payment.failed')).toBe(1);
|
||||
expect(bus.listenerCount('user.deleted')).toBe(0);
|
||||
});
|
||||
|
||||
it('should list event types with handlers', () => {
|
||||
bus.on('user.created', () => {});
|
||||
bus.on('payment.failed', () => {});
|
||||
|
||||
const types = bus.eventTypes();
|
||||
expect(types).toContain('user.created');
|
||||
expect(types).toContain('payment.failed');
|
||||
expect(types).not.toContain('user.deleted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('source option', () => {
|
||||
it('should pass source through to handlers', async () => {
|
||||
let received: PlatformEvent<'user.created'> | undefined;
|
||||
bus.on('user.created', e => {
|
||||
received = e;
|
||||
});
|
||||
|
||||
await bus.emit(
|
||||
'user.created',
|
||||
{ userId: 'u1', email: 'a@b.com', plan: 'free', productId: 'test' },
|
||||
{ source: 'auth-module' }
|
||||
);
|
||||
|
||||
expect(received?.source).toBe('auth-module');
|
||||
});
|
||||
});
|
||||
});
|
||||
122
packages/events/src/memory.ts
Normal file
122
packages/events/src/memory.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import type {
|
||||
PlatformEventName,
|
||||
PlatformEventPayload,
|
||||
PlatformEvent,
|
||||
EventHandler,
|
||||
EventSubscription,
|
||||
} from './types.js';
|
||||
|
||||
// ── In-Memory Event Bus ──────────────────────────────────────
|
||||
// Phase 1 implementation: typed in-process pub/sub with error isolation.
|
||||
// Handlers run concurrently via Promise.allSettled — a failing handler
|
||||
// never blocks other handlers or the emitter.
|
||||
|
||||
export class EventBus {
|
||||
private handlers = new Map<string, Set<{ id: string; fn: EventHandler<PlatformEventName> }>>();
|
||||
private subscriptionCounter = 0;
|
||||
|
||||
/**
|
||||
* Subscribe to a specific event type.
|
||||
* Returns an EventSubscription with an `unsubscribe()` method.
|
||||
*/
|
||||
on<T extends PlatformEventName>(eventType: T, handler: EventHandler<T>): EventSubscription {
|
||||
const id = `sub_${++this.subscriptionCounter}`;
|
||||
|
||||
if (!this.handlers.has(eventType)) {
|
||||
this.handlers.set(eventType, new Set());
|
||||
}
|
||||
|
||||
const entry = { id, fn: handler as EventHandler<PlatformEventName> };
|
||||
this.handlers.get(eventType)!.add(entry);
|
||||
|
||||
return {
|
||||
id,
|
||||
eventType,
|
||||
unsubscribe: () => {
|
||||
this.handlers.get(eventType)?.delete(entry);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all registered handlers for that event type.
|
||||
* Handlers run concurrently. Errors are collected and returned,
|
||||
* never thrown — the emitter is never blocked.
|
||||
*/
|
||||
async emit<T extends PlatformEventName>(
|
||||
eventType: T,
|
||||
payload: PlatformEventPayload<T>,
|
||||
options?: { source?: string }
|
||||
): Promise<EmitResult> {
|
||||
const event: PlatformEvent<T> = {
|
||||
id: crypto.randomUUID(),
|
||||
type: eventType,
|
||||
payload,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: options?.source,
|
||||
};
|
||||
|
||||
const handlers = this.handlers.get(eventType);
|
||||
if (!handlers || handlers.size === 0) {
|
||||
return { eventId: event.id, handlerCount: 0, errors: [] };
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
Array.from(handlers).map(async ({ fn }) => fn(event as PlatformEvent<PlatformEventName>))
|
||||
);
|
||||
|
||||
const errors: EmitError[] = [];
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
errors.push({
|
||||
eventType,
|
||||
eventId: event.id,
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { eventId: event.id, handlerCount: handlers.size, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all handlers for a specific event type, or all handlers if no type given.
|
||||
*/
|
||||
clear(eventType?: PlatformEventName): void {
|
||||
if (eventType) {
|
||||
this.handlers.delete(eventType);
|
||||
} else {
|
||||
this.handlers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of registered handlers for a specific event type.
|
||||
*/
|
||||
listenerCount(eventType: PlatformEventName): number {
|
||||
return this.handlers.get(eventType)?.size ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all event types that have at least one handler registered.
|
||||
*/
|
||||
eventTypes(): PlatformEventName[] {
|
||||
return Array.from(this.handlers.entries())
|
||||
.filter(([, set]) => set.size > 0)
|
||||
.map(([type]) => type as PlatformEventName);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Result Types ─────────────────────────────────────────────
|
||||
|
||||
export interface EmitResult {
|
||||
eventId: string;
|
||||
handlerCount: number;
|
||||
errors: EmitError[];
|
||||
}
|
||||
|
||||
export interface EmitError {
|
||||
eventType: string;
|
||||
eventId: string;
|
||||
error: string;
|
||||
}
|
||||
157
packages/events/src/types.ts
Normal file
157
packages/events/src/types.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Platform Event Schemas ───────────────────────────────────
|
||||
// Each event type has a Zod schema for payload validation.
|
||||
// Handlers receive typed payloads matching these schemas.
|
||||
|
||||
export const PlatformEventSchemas = {
|
||||
// Auth events
|
||||
'user.created': z.object({
|
||||
userId: z.string(),
|
||||
email: z.string(),
|
||||
plan: z.string(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
'user.deleted': z.object({
|
||||
userId: z.string(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
'user.email_verified': z.object({
|
||||
userId: z.string(),
|
||||
email: z.string(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
'user.password_reset': z.object({
|
||||
userId: z.string(),
|
||||
email: z.string(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
|
||||
// Subscription events
|
||||
'subscription.created': z.object({
|
||||
subscriptionId: z.string(),
|
||||
userId: z.string(),
|
||||
plan: z.string(),
|
||||
status: z.string(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
'subscription.changed': z.object({
|
||||
subscriptionId: z.string(),
|
||||
userId: z.string(),
|
||||
oldPlan: z.string(),
|
||||
newPlan: z.string(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
'subscription.canceled': z.object({
|
||||
subscriptionId: z.string(),
|
||||
userId: z.string(),
|
||||
reason: z.string().optional(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
'subscription.trial_expiring': z.object({
|
||||
subscriptionId: z.string(),
|
||||
userId: z.string(),
|
||||
expiresAt: z.string(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
'subscription.trial_expired': z.object({
|
||||
subscriptionId: z.string(),
|
||||
userId: z.string(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
|
||||
// Payment events
|
||||
'payment.succeeded': z.object({
|
||||
invoiceId: z.string(),
|
||||
userId: z.string(),
|
||||
amount: z.number(),
|
||||
currency: z.string(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
'payment.failed': z.object({
|
||||
invoiceId: z.string(),
|
||||
userId: z.string(),
|
||||
amount: z.number(),
|
||||
retryCount: z.number(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
|
||||
// Growth events
|
||||
'invitation.redeemed': z.object({
|
||||
invitationId: z.string(),
|
||||
userId: z.string(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
'referral.completed': z.object({
|
||||
referralId: z.string(),
|
||||
referrerId: z.string(),
|
||||
referredId: z.string(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
'waitlist.joined': z.object({
|
||||
email: z.string(),
|
||||
position: z.number(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
|
||||
// Feature flags
|
||||
'flag.toggled': z.object({
|
||||
flagId: z.string(),
|
||||
enabled: z.boolean(),
|
||||
percentage: z.number().optional(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
|
||||
// License events
|
||||
'license.activated': z.object({
|
||||
licenseId: z.string(),
|
||||
userId: z.string(),
|
||||
deviceId: z.string().optional(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
'license.expired': z.object({
|
||||
licenseId: z.string(),
|
||||
userId: z.string(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
|
||||
// Job events
|
||||
'job.completed': z.object({
|
||||
jobName: z.string(),
|
||||
runId: z.string(),
|
||||
durationMs: z.number(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
'job.failed': z.object({
|
||||
jobName: z.string(),
|
||||
runId: z.string(),
|
||||
error: z.string(),
|
||||
productId: z.string(),
|
||||
}),
|
||||
} as const;
|
||||
|
||||
// ── Derived Types ────────────────────────────────────────────
|
||||
|
||||
export type PlatformEventName = keyof typeof PlatformEventSchemas;
|
||||
|
||||
export type PlatformEventPayload<T extends PlatformEventName> = z.infer<
|
||||
(typeof PlatformEventSchemas)[T]
|
||||
>;
|
||||
|
||||
export interface PlatformEvent<T extends PlatformEventName = PlatformEventName> {
|
||||
id: string;
|
||||
type: T;
|
||||
payload: PlatformEventPayload<T>;
|
||||
timestamp: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export type EventHandler<T extends PlatformEventName = PlatformEventName> = (
|
||||
event: PlatformEvent<T>
|
||||
) => void | Promise<void>;
|
||||
|
||||
export interface EventSubscription {
|
||||
id: string;
|
||||
eventType: PlatformEventName;
|
||||
unsubscribe: () => void;
|
||||
}
|
||||
9
packages/events/tsconfig.json
Normal file
9
packages/events/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
8459
pnpm-lock.yaml
generated
8459
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -18,6 +18,7 @@
|
||||
"@bytelyst/config": "workspace:*",
|
||||
"@bytelyst/cosmos": "workspace:*",
|
||||
"@bytelyst/errors": "workspace:*",
|
||||
"@bytelyst/events": "workspace:*",
|
||||
"@bytelyst/fastify-core": "workspace:*",
|
||||
"@azure/cosmos": "^4.2.0",
|
||||
"@azure/storage-blob": "^12.31.0",
|
||||
|
||||
@ -47,6 +47,14 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
||||
// ChronoMind webhooks
|
||||
webhook_subscriptions: { partitionKeyPath: '/userId' },
|
||||
webhook_events: { partitionKeyPath: '/subscriptionId', defaultTtl: 30 * 86400 },
|
||||
// Status page incidents
|
||||
incidents: { partitionKeyPath: '/productId' },
|
||||
// Password reset + email verification
|
||||
password_reset_tokens: { partitionKeyPath: '/productId', defaultTtl: 86400 },
|
||||
email_verifications: { partitionKeyPath: '/productId', defaultTtl: 7 * 86400 },
|
||||
// Scheduled jobs
|
||||
job_definitions: { partitionKeyPath: '/productId' },
|
||||
job_runs: { partitionKeyPath: '/productId:jobName' },
|
||||
// Telemetry (client diagnostics — see docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md)
|
||||
telemetry_events: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 },
|
||||
telemetry_error_clusters: { partitionKeyPath: '/pk', defaultTtl: 90 * 86400 },
|
||||
|
||||
7
services/platform-service/src/lib/event-bus.ts
Normal file
7
services/platform-service/src/lib/event-bus.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { EventBus } from '@bytelyst/events';
|
||||
|
||||
// ── Singleton Event Bus ──────────────────────────────────────
|
||||
// Single instance shared across all modules in platform-service.
|
||||
// Import this wherever you need to emit or subscribe to events.
|
||||
|
||||
export const bus = new EventBus();
|
||||
@ -4,7 +4,8 @@
|
||||
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import type { UserDoc } from './types.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { UserDoc, PasswordResetTokenDoc, EmailVerificationDoc } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('users');
|
||||
@ -152,3 +153,133 @@ export async function hashPassword(password: string): Promise<string> {
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
export async function updatePassword(id: string, newPasswordHash: string): Promise<boolean> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read<UserDoc>();
|
||||
if (!resource) return false;
|
||||
await container()
|
||||
.item(id, id)
|
||||
.replace({
|
||||
...resource,
|
||||
passwordHash: newPasswordHash,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setEmailVerified(id: string, verified: boolean): Promise<boolean> {
|
||||
try {
|
||||
const { resource } = await container().item(id, id).read<UserDoc>();
|
||||
if (!resource) return false;
|
||||
await container()
|
||||
.item(id, id)
|
||||
.replace({
|
||||
...resource,
|
||||
emailVerified: verified,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Password Reset Tokens ────────────────────────────────────
|
||||
|
||||
function resetTokensContainer() {
|
||||
return getContainer('password_reset_tokens');
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export async function createResetToken(doc: PasswordResetTokenDoc): Promise<PasswordResetTokenDoc> {
|
||||
const { resource } = await resetTokensContainer().items.create(doc);
|
||||
return resource as PasswordResetTokenDoc;
|
||||
}
|
||||
|
||||
export async function findResetToken(
|
||||
tokenHash: string,
|
||||
productId: string
|
||||
): Promise<PasswordResetTokenDoc | null> {
|
||||
const { resources } = await resetTokensContainer()
|
||||
.items.query<PasswordResetTokenDoc>(
|
||||
{
|
||||
query:
|
||||
'SELECT * FROM c WHERE c.productId = @productId AND c.tokenHash = @tokenHash AND NOT IS_DEFINED(c.usedAt)',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@tokenHash', value: tokenHash },
|
||||
],
|
||||
},
|
||||
{ partitionKey: productId }
|
||||
)
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
}
|
||||
|
||||
export async function markResetTokenUsed(id: string, productId: string): Promise<void> {
|
||||
const { resource } = await resetTokensContainer()
|
||||
.item(id, productId)
|
||||
.read<PasswordResetTokenDoc>();
|
||||
if (resource) {
|
||||
await resetTokensContainer()
|
||||
.item(id, productId)
|
||||
.replace({
|
||||
...resource,
|
||||
usedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Email Verification Tokens ────────────────────────────────
|
||||
|
||||
function emailVerificationsContainer() {
|
||||
return getContainer('email_verifications');
|
||||
}
|
||||
|
||||
export async function createEmailVerification(
|
||||
doc: EmailVerificationDoc
|
||||
): Promise<EmailVerificationDoc> {
|
||||
const { resource } = await emailVerificationsContainer().items.create(doc);
|
||||
return resource as EmailVerificationDoc;
|
||||
}
|
||||
|
||||
export async function findEmailVerification(
|
||||
tokenHash: string,
|
||||
productId: string
|
||||
): Promise<EmailVerificationDoc | null> {
|
||||
const { resources } = await emailVerificationsContainer()
|
||||
.items.query<EmailVerificationDoc>(
|
||||
{
|
||||
query:
|
||||
'SELECT * FROM c WHERE c.productId = @productId AND c.tokenHash = @tokenHash AND NOT IS_DEFINED(c.verifiedAt)',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@tokenHash', value: tokenHash },
|
||||
],
|
||||
},
|
||||
{ partitionKey: productId }
|
||||
)
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
}
|
||||
|
||||
export async function markEmailVerified(id: string, productId: string): Promise<void> {
|
||||
const { resource } = await emailVerificationsContainer()
|
||||
.item(id, productId)
|
||||
.read<EmailVerificationDoc>();
|
||||
if (resource) {
|
||||
await emailVerificationsContainer()
|
||||
.item(id, productId)
|
||||
.replace({
|
||||
...resource,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,6 +31,10 @@ import {
|
||||
UpdateUserSchema,
|
||||
SsoLoginSchema,
|
||||
ProfileUpdateSchema,
|
||||
ForgotPasswordSchema,
|
||||
ResetPasswordSchema,
|
||||
VerifyEmailSchema,
|
||||
ResendVerificationSchema,
|
||||
type UserDoc,
|
||||
} from './types.js';
|
||||
|
||||
@ -98,6 +102,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
role,
|
||||
displayName,
|
||||
status: 'active',
|
||||
emailVerified: false,
|
||||
lastLoginAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@ -279,6 +284,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
role: 'user',
|
||||
displayName: displayName || email.split('@')[0],
|
||||
status: 'active',
|
||||
emailVerified: true,
|
||||
lastLoginAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@ -371,6 +377,149 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Password Reset ──────────────────────────────────────────
|
||||
|
||||
// Request a password reset token
|
||||
app.post('/auth/forgot-password', async req => {
|
||||
const parsed = ForgotPasswordSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const { email, productId } = parsed.data;
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
const user = await repo.getByEmail(email, productId);
|
||||
if (!user) {
|
||||
return { message: 'If that email exists, a reset link has been sent.' };
|
||||
}
|
||||
|
||||
const rawToken = crypto.randomUUID();
|
||||
const tokenHash = repo.hashToken(rawToken);
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
|
||||
|
||||
await repo.createResetToken({
|
||||
id: `rst_${crypto.randomUUID()}`,
|
||||
productId,
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// TODO: Send email via delivery module. For now, log the token for dev/testing.
|
||||
req.log.info(
|
||||
{ userId: user.id, productId, resetToken: rawToken },
|
||||
'[auth] Password reset token generated'
|
||||
);
|
||||
|
||||
return { message: 'If that email exists, a reset link has been sent.' };
|
||||
});
|
||||
|
||||
// Reset password with token
|
||||
app.post('/auth/reset-password', async req => {
|
||||
const parsed = ResetPasswordSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const { token, newPassword } = parsed.data;
|
||||
const tokenHash = repo.hashToken(token);
|
||||
|
||||
// Search across all products — token hash is unique
|
||||
// We'll try the common product IDs
|
||||
let resetDoc = await repo.findResetToken(tokenHash, 'lysnrai');
|
||||
if (!resetDoc) resetDoc = await repo.findResetToken(tokenHash, 'chronomind');
|
||||
if (!resetDoc) resetDoc = await repo.findResetToken(tokenHash, 'nomgap');
|
||||
if (!resetDoc) resetDoc = await repo.findResetToken(tokenHash, 'mindlyst');
|
||||
|
||||
if (!resetDoc) {
|
||||
throw new BadRequestError('Invalid or expired reset token');
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if (new Date(resetDoc.expiresAt) < new Date()) {
|
||||
throw new BadRequestError('Reset token has expired');
|
||||
}
|
||||
|
||||
// Update password
|
||||
const newHash = await repo.hashPassword(newPassword);
|
||||
const updated = await repo.updatePassword(resetDoc.userId, newHash);
|
||||
if (!updated) {
|
||||
throw new BadRequestError('Failed to update password');
|
||||
}
|
||||
|
||||
// Mark token as used
|
||||
await repo.markResetTokenUsed(resetDoc.id, resetDoc.productId);
|
||||
|
||||
req.log.info({ userId: resetDoc.userId }, '[auth] Password reset successful');
|
||||
return { message: 'Password has been reset successfully.' };
|
||||
});
|
||||
|
||||
// ── Email Verification ──────────────────────────────────────
|
||||
|
||||
// Verify email with token
|
||||
app.post('/auth/verify-email', async req => {
|
||||
const parsed = VerifyEmailSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const tokenHash = repo.hashToken(parsed.data.token);
|
||||
|
||||
let verifyDoc = await repo.findEmailVerification(tokenHash, 'lysnrai');
|
||||
if (!verifyDoc) verifyDoc = await repo.findEmailVerification(tokenHash, 'chronomind');
|
||||
if (!verifyDoc) verifyDoc = await repo.findEmailVerification(tokenHash, 'nomgap');
|
||||
if (!verifyDoc) verifyDoc = await repo.findEmailVerification(tokenHash, 'mindlyst');
|
||||
|
||||
if (!verifyDoc) {
|
||||
throw new BadRequestError('Invalid or expired verification token');
|
||||
}
|
||||
|
||||
if (new Date(verifyDoc.expiresAt) < new Date()) {
|
||||
throw new BadRequestError('Verification token has expired');
|
||||
}
|
||||
|
||||
await repo.setEmailVerified(verifyDoc.userId, true);
|
||||
await repo.markEmailVerified(verifyDoc.id, verifyDoc.productId);
|
||||
|
||||
req.log.info({ userId: verifyDoc.userId }, '[auth] Email verified');
|
||||
return { message: 'Email verified successfully.' };
|
||||
});
|
||||
|
||||
// Resend verification email
|
||||
app.post('/auth/resend-verification', async req => {
|
||||
const parsed = ResendVerificationSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const { email, productId } = parsed.data;
|
||||
|
||||
const user = await repo.getByEmail(email, productId);
|
||||
if (!user) {
|
||||
return { message: 'If that email exists, a verification link has been sent.' };
|
||||
}
|
||||
|
||||
const rawToken = crypto.randomUUID();
|
||||
const tokenHash = repo.hashToken(rawToken);
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); // 24 hours
|
||||
|
||||
await repo.createEmailVerification({
|
||||
id: `evf_${crypto.randomUUID()}`,
|
||||
productId,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// TODO: Send email via delivery module. For now, log the token for dev/testing.
|
||||
req.log.info(
|
||||
{ userId: user.id, productId, verificationToken: rawToken },
|
||||
'[auth] Email verification token generated'
|
||||
);
|
||||
|
||||
return { message: 'If that email exists, a verification link has been sent.' };
|
||||
});
|
||||
|
||||
// ── Admin user management ────────────────────────────────────
|
||||
|
||||
function requireAdminRole(req: import('fastify').FastifyRequest): string {
|
||||
|
||||
@ -13,6 +13,7 @@ export interface UserDoc {
|
||||
role: 'super_admin' | 'admin' | 'viewer' | 'user';
|
||||
displayName: string;
|
||||
status: 'active' | 'disabled';
|
||||
emailVerified: boolean;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@ -75,3 +76,47 @@ export type RegisterInput = z.infer<typeof RegisterSchema>;
|
||||
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
|
||||
export type SsoLoginInput = z.infer<typeof SsoLoginSchema>;
|
||||
export type ProfileUpdateInput = z.infer<typeof ProfileUpdateSchema>;
|
||||
|
||||
// ── Password Reset ───────────────────────────────────────────
|
||||
|
||||
export const ForgotPasswordSchema = z.object({
|
||||
email: z.string().email(),
|
||||
productId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const ResetPasswordSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
newPassword: z.string().min(8),
|
||||
});
|
||||
|
||||
export interface PasswordResetTokenDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
userId: string;
|
||||
tokenHash: string;
|
||||
expiresAt: string;
|
||||
usedAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ── Email Verification ───────────────────────────────────────
|
||||
|
||||
export const VerifyEmailSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
export const ResendVerificationSchema = z.object({
|
||||
email: z.string().email(),
|
||||
productId: z.string().min(1),
|
||||
});
|
||||
|
||||
export interface EmailVerificationDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
tokenHash: string;
|
||||
expiresAt: string;
|
||||
verifiedAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
104
services/platform-service/src/modules/jobs/built-in-jobs.ts
Normal file
104
services/platform-service/src/modules/jobs/built-in-jobs.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { registerJob } from './registry.js';
|
||||
import type { JobContext, JobResult } from './types.js';
|
||||
|
||||
// ── Built-In Jobs ────────────────────────────────────────────
|
||||
// Registered at service startup. Each job has a handler that
|
||||
// performs the actual work when triggered by the runner.
|
||||
|
||||
/**
|
||||
* Register all built-in jobs with the job registry.
|
||||
*/
|
||||
export function registerBuiltInJobs(): void {
|
||||
registerJob('trial-expiration-check', trialExpirationCheck);
|
||||
registerJob('usage-quota-reset', usageQuotaReset);
|
||||
registerJob('stale-session-cleanup', staleSessionCleanup);
|
||||
registerJob('telemetry-ttl-sweep', telemetryTtlSweep);
|
||||
registerJob('waitlist-reminder', waitlistReminder);
|
||||
registerJob('license-expiry-check', licenseExpiryCheck);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default cron definitions for built-in jobs.
|
||||
*/
|
||||
export const BUILT_IN_JOB_DEFAULTS = [
|
||||
{
|
||||
name: 'trial-expiration-check',
|
||||
cron: '0 * * * *',
|
||||
description: 'Check for expired trials (hourly)',
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
{
|
||||
name: 'usage-quota-reset',
|
||||
cron: '0 0 * * *',
|
||||
description: 'Reset daily usage quotas (midnight UTC)',
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
{
|
||||
name: 'stale-session-cleanup',
|
||||
cron: '0 */6 * * *',
|
||||
description: 'Clean up stale sessions (every 6 hours)',
|
||||
timeoutMs: 180_000,
|
||||
},
|
||||
{
|
||||
name: 'telemetry-ttl-sweep',
|
||||
cron: '0 3 * * *',
|
||||
description: 'Sweep telemetry events past TTL (3am UTC)',
|
||||
timeoutMs: 300_000,
|
||||
},
|
||||
{
|
||||
name: 'waitlist-reminder',
|
||||
cron: '0 9 * * 1',
|
||||
description: 'Flag stale waitlist entries (Monday 9am UTC)',
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
{
|
||||
name: 'license-expiry-check',
|
||||
cron: '0 8 * * *',
|
||||
description: 'Warn users with expiring licenses (8am UTC)',
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
] as const;
|
||||
|
||||
// ── Job Implementations ──────────────────────────────────────
|
||||
|
||||
async function trialExpirationCheck(ctx: JobContext): Promise<JobResult> {
|
||||
ctx.log.info({ jobName: ctx.jobName }, '[jobs] Running trial expiration check');
|
||||
// TODO: Query subscriptions with status=trialing past currentPeriodEnd
|
||||
// Transition to expired or active based on payment method
|
||||
return {
|
||||
success: true,
|
||||
message: 'Trial expiration check completed',
|
||||
metrics: { checked: 0, expired: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
async function usageQuotaReset(ctx: JobContext): Promise<JobResult> {
|
||||
ctx.log.info({ jobName: ctx.jobName }, '[jobs] Running usage quota reset');
|
||||
// TODO: Reset daily counters in usage_daily container
|
||||
return { success: true, message: 'Usage quota reset completed', metrics: { reset: 0 } };
|
||||
}
|
||||
|
||||
async function staleSessionCleanup(ctx: JobContext): Promise<JobResult> {
|
||||
ctx.log.info({ jobName: ctx.jobName }, '[jobs] Running stale session cleanup');
|
||||
// TODO: Remove expired refresh tokens and inactive sessions
|
||||
return { success: true, message: 'Stale session cleanup completed', metrics: { removed: 0 } };
|
||||
}
|
||||
|
||||
async function telemetryTtlSweep(ctx: JobContext): Promise<JobResult> {
|
||||
ctx.log.info({ jobName: ctx.jobName }, '[jobs] Running telemetry TTL sweep');
|
||||
// TODO: Delete telemetry events past retention TTL
|
||||
// Cosmos TTL is best-effort, this ensures cleanup
|
||||
return { success: true, message: 'Telemetry TTL sweep completed', metrics: { deleted: 0 } };
|
||||
}
|
||||
|
||||
async function waitlistReminder(ctx: JobContext): Promise<JobResult> {
|
||||
ctx.log.info({ jobName: ctx.jobName }, '[jobs] Running waitlist reminder');
|
||||
// TODO: Identify stale waitlist entries, mark for follow-up
|
||||
return { success: true, message: 'Waitlist reminder completed', metrics: { flagged: 0 } };
|
||||
}
|
||||
|
||||
async function licenseExpiryCheck(ctx: JobContext): Promise<JobResult> {
|
||||
ctx.log.info({ jobName: ctx.jobName }, '[jobs] Running license expiry check');
|
||||
// TODO: Warn users whose licenses expire within 7 days
|
||||
return { success: true, message: 'License expiry check completed', metrics: { warned: 0 } };
|
||||
}
|
||||
126
services/platform-service/src/modules/jobs/cron.ts
Normal file
126
services/platform-service/src/modules/jobs/cron.ts
Normal file
@ -0,0 +1,126 @@
|
||||
// ── Minimal Cron Parser ──────────────────────────────────────
|
||||
// Supports: minute hour dayOfMonth month dayOfWeek
|
||||
// Special values: * (any), */N (every N), N (exact), N-M (range)
|
||||
// No external dependencies.
|
||||
|
||||
export interface CronFields {
|
||||
minute: number[];
|
||||
hour: number[];
|
||||
dayOfMonth: number[];
|
||||
month: number[];
|
||||
dayOfWeek: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a 5-field cron expression into expanded numeric arrays.
|
||||
* Throws on invalid expressions.
|
||||
*/
|
||||
export function parseCron(expression: string): CronFields {
|
||||
const parts = expression.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
throw new Error(`Invalid cron expression: expected 5 fields, got ${parts.length}`);
|
||||
}
|
||||
|
||||
return {
|
||||
minute: expandField(parts[0], 0, 59),
|
||||
hour: expandField(parts[1], 0, 23),
|
||||
dayOfMonth: expandField(parts[2], 1, 31),
|
||||
month: expandField(parts[3], 1, 12),
|
||||
dayOfWeek: expandField(parts[4], 0, 6),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Date matches a cron expression.
|
||||
*/
|
||||
export function cronMatches(expression: string, date: Date): boolean {
|
||||
const fields = parseCron(expression);
|
||||
const minute = date.getUTCMinutes();
|
||||
const hour = date.getUTCHours();
|
||||
const dayOfMonth = date.getUTCDate();
|
||||
const month = date.getUTCMonth() + 1;
|
||||
const dayOfWeek = date.getUTCDay();
|
||||
|
||||
return (
|
||||
fields.minute.includes(minute) &&
|
||||
fields.hour.includes(hour) &&
|
||||
fields.dayOfMonth.includes(dayOfMonth) &&
|
||||
fields.month.includes(month) &&
|
||||
fields.dayOfWeek.includes(dayOfWeek)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next occurrence of a cron expression after a given date.
|
||||
* Searches forward up to 366 days. Returns null if none found.
|
||||
*/
|
||||
export function nextCronOccurrence(expression: string, after: Date): Date | null {
|
||||
const fields = parseCron(expression);
|
||||
const candidate = new Date(after);
|
||||
// Start from next minute
|
||||
candidate.setUTCSeconds(0, 0);
|
||||
candidate.setUTCMinutes(candidate.getUTCMinutes() + 1);
|
||||
|
||||
const maxIterations = 366 * 24 * 60; // ~1 year of minutes
|
||||
for (let i = 0; i < maxIterations; i++) {
|
||||
const minute = candidate.getUTCMinutes();
|
||||
const hour = candidate.getUTCHours();
|
||||
const dayOfMonth = candidate.getUTCDate();
|
||||
const month = candidate.getUTCMonth() + 1;
|
||||
const dayOfWeek = candidate.getUTCDay();
|
||||
|
||||
if (
|
||||
fields.minute.includes(minute) &&
|
||||
fields.hour.includes(hour) &&
|
||||
fields.dayOfMonth.includes(dayOfMonth) &&
|
||||
fields.month.includes(month) &&
|
||||
fields.dayOfWeek.includes(dayOfWeek)
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
candidate.setUTCMinutes(candidate.getUTCMinutes() + 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Field Parser ─────────────────────────────────────────────
|
||||
|
||||
function expandField(field: string, min: number, max: number): number[] {
|
||||
const result = new Set<number>();
|
||||
|
||||
for (const part of field.split(',')) {
|
||||
if (part === '*') {
|
||||
for (let i = min; i <= max; i++) result.add(i);
|
||||
} else if (part.includes('/')) {
|
||||
const [rangePart, stepStr] = part.split('/');
|
||||
const step = parseInt(stepStr, 10);
|
||||
if (isNaN(step) || step < 1) throw new Error(`Invalid step: ${stepStr}`);
|
||||
|
||||
let start = min;
|
||||
let end = max;
|
||||
if (rangePart !== '*') {
|
||||
if (rangePart.includes('-')) {
|
||||
[start, end] = rangePart.split('-').map(Number);
|
||||
} else {
|
||||
start = parseInt(rangePart, 10);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i += step) result.add(i);
|
||||
} else if (part.includes('-')) {
|
||||
const [startStr, endStr] = part.split('-');
|
||||
const start = parseInt(startStr, 10);
|
||||
const end = parseInt(endStr, 10);
|
||||
if (isNaN(start) || isNaN(end)) throw new Error(`Invalid range: ${part}`);
|
||||
for (let i = start; i <= end; i++) result.add(i);
|
||||
} else {
|
||||
const num = parseInt(part, 10);
|
||||
if (isNaN(num)) throw new Error(`Invalid value: ${part}`);
|
||||
result.add(num);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(result).sort((a, b) => a - b);
|
||||
}
|
||||
158
services/platform-service/src/modules/jobs/jobs.test.ts
Normal file
158
services/platform-service/src/modules/jobs/jobs.test.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { parseCron, cronMatches, nextCronOccurrence } from './cron.js';
|
||||
import { registerJob, getJobHandler, getRegisteredJobs, clearRegistry } from './registry.js';
|
||||
import type { JobContext, JobResult } from './types.js';
|
||||
|
||||
// ── Cron Parser Tests ────────────────────────────────────────
|
||||
|
||||
describe('cron parser', () => {
|
||||
describe('parseCron', () => {
|
||||
it('should parse * * * * * (every minute)', () => {
|
||||
const fields = parseCron('* * * * *');
|
||||
expect(fields.minute.length).toBe(60);
|
||||
expect(fields.hour.length).toBe(24);
|
||||
expect(fields.dayOfMonth.length).toBe(31);
|
||||
expect(fields.month.length).toBe(12);
|
||||
expect(fields.dayOfWeek.length).toBe(7);
|
||||
});
|
||||
|
||||
it('should parse exact values', () => {
|
||||
const fields = parseCron('30 14 1 6 3');
|
||||
expect(fields.minute).toEqual([30]);
|
||||
expect(fields.hour).toEqual([14]);
|
||||
expect(fields.dayOfMonth).toEqual([1]);
|
||||
expect(fields.month).toEqual([6]);
|
||||
expect(fields.dayOfWeek).toEqual([3]);
|
||||
});
|
||||
|
||||
it('should parse step values (*/N)', () => {
|
||||
const fields = parseCron('*/15 */6 * * *');
|
||||
expect(fields.minute).toEqual([0, 15, 30, 45]);
|
||||
expect(fields.hour).toEqual([0, 6, 12, 18]);
|
||||
});
|
||||
|
||||
it('should parse ranges (N-M)', () => {
|
||||
const fields = parseCron('0-5 9-17 * * *');
|
||||
expect(fields.minute).toEqual([0, 1, 2, 3, 4, 5]);
|
||||
expect(fields.hour).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17]);
|
||||
});
|
||||
|
||||
it('should parse comma-separated values', () => {
|
||||
const fields = parseCron('0,15,30,45 * * * *');
|
||||
expect(fields.minute).toEqual([0, 15, 30, 45]);
|
||||
});
|
||||
|
||||
it('should throw on invalid cron expression', () => {
|
||||
expect(() => parseCron('* * *')).toThrow('expected 5 fields');
|
||||
expect(() => parseCron('abc * * * *')).toThrow('Invalid value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cronMatches', () => {
|
||||
it('should match every-minute cron', () => {
|
||||
const date = new Date('2026-03-01T12:30:00Z');
|
||||
expect(cronMatches('* * * * *', date)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match exact time', () => {
|
||||
const date = new Date('2026-03-01T14:30:00Z');
|
||||
expect(cronMatches('30 14 * * *', date)).toBe(true);
|
||||
expect(cronMatches('31 14 * * *', date)).toBe(false);
|
||||
});
|
||||
|
||||
it('should match hourly at minute 0', () => {
|
||||
const date = new Date('2026-03-01T08:00:00Z');
|
||||
expect(cronMatches('0 * * * *', date)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match specific day of week', () => {
|
||||
// 2026-03-02 is Monday (day 1)
|
||||
const monday = new Date('2026-03-02T09:00:00Z');
|
||||
expect(cronMatches('0 9 * * 1', monday)).toBe(true);
|
||||
expect(cronMatches('0 9 * * 2', monday)).toBe(false);
|
||||
});
|
||||
|
||||
it('should match midnight UTC daily', () => {
|
||||
const midnight = new Date('2026-03-01T00:00:00Z');
|
||||
expect(cronMatches('0 0 * * *', midnight)).toBe(true);
|
||||
});
|
||||
|
||||
it('should match every 6 hours', () => {
|
||||
expect(cronMatches('0 */6 * * *', new Date('2026-03-01T00:00:00Z'))).toBe(true);
|
||||
expect(cronMatches('0 */6 * * *', new Date('2026-03-01T06:00:00Z'))).toBe(true);
|
||||
expect(cronMatches('0 */6 * * *', new Date('2026-03-01T03:00:00Z'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextCronOccurrence', () => {
|
||||
it('should find next minute for * * * * *', () => {
|
||||
const after = new Date('2026-03-01T12:30:00Z');
|
||||
const next = nextCronOccurrence('* * * * *', after);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getUTCMinutes()).toBe(31);
|
||||
});
|
||||
|
||||
it('should find next hourly occurrence', () => {
|
||||
const after = new Date('2026-03-01T12:30:00Z');
|
||||
const next = nextCronOccurrence('0 * * * *', after);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getUTCHours()).toBe(13);
|
||||
expect(next!.getUTCMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it('should find next daily midnight', () => {
|
||||
const after = new Date('2026-03-01T12:00:00Z');
|
||||
const next = nextCronOccurrence('0 0 * * *', after);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getUTCDate()).toBe(2);
|
||||
expect(next!.getUTCHours()).toBe(0);
|
||||
});
|
||||
|
||||
it('should skip to next week for weekly cron', () => {
|
||||
// Monday 2026-03-02 at 10am — next Monday 9am should be 2026-03-09
|
||||
const after = new Date('2026-03-02T10:00:00Z');
|
||||
const next = nextCronOccurrence('0 9 * * 1', after);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getUTCDate()).toBe(9);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Job Registry Tests ───────────────────────────────────────
|
||||
|
||||
describe('job registry', () => {
|
||||
beforeEach(() => {
|
||||
clearRegistry();
|
||||
});
|
||||
|
||||
it('should register and retrieve a job handler', () => {
|
||||
const handler = async (_ctx: JobContext): Promise<JobResult> => ({ success: true });
|
||||
registerJob('test-job', handler);
|
||||
expect(getJobHandler('test-job')).toBe(handler);
|
||||
});
|
||||
|
||||
it('should return undefined for unregistered job', () => {
|
||||
expect(getJobHandler('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should list registered job names', () => {
|
||||
registerJob('job-a', async () => ({ success: true }));
|
||||
registerJob('job-b', async () => ({ success: true }));
|
||||
const names = getRegisteredJobs();
|
||||
expect(names).toContain('job-a');
|
||||
expect(names).toContain('job-b');
|
||||
expect(names.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should throw on duplicate registration', () => {
|
||||
registerJob('dup', async () => ({ success: true }));
|
||||
expect(() => registerJob('dup', async () => ({ success: true }))).toThrow('already registered');
|
||||
});
|
||||
|
||||
it('should clear all registered jobs', () => {
|
||||
registerJob('temp', async () => ({ success: true }));
|
||||
expect(getRegisteredJobs().length).toBe(1);
|
||||
clearRegistry();
|
||||
expect(getRegisteredJobs().length).toBe(0);
|
||||
});
|
||||
});
|
||||
26
services/platform-service/src/modules/jobs/registry.ts
Normal file
26
services/platform-service/src/modules/jobs/registry.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { JobHandler } from './types.js';
|
||||
|
||||
// ── Job Registry ─────────────────────────────────────────────
|
||||
// Register named jobs with handlers. The runner evaluates cron
|
||||
// and dispatches to the registered handler.
|
||||
|
||||
const handlers = new Map<string, JobHandler>();
|
||||
|
||||
export function registerJob(name: string, handler: JobHandler): void {
|
||||
if (handlers.has(name)) {
|
||||
throw new Error(`Job '${name}' is already registered`);
|
||||
}
|
||||
handlers.set(name, handler);
|
||||
}
|
||||
|
||||
export function getJobHandler(name: string): JobHandler | undefined {
|
||||
return handlers.get(name);
|
||||
}
|
||||
|
||||
export function getRegisteredJobs(): string[] {
|
||||
return Array.from(handlers.keys());
|
||||
}
|
||||
|
||||
export function clearRegistry(): void {
|
||||
handlers.clear();
|
||||
}
|
||||
91
services/platform-service/src/modules/jobs/repository.ts
Normal file
91
services/platform-service/src/modules/jobs/repository.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { NotFoundError } from '../../lib/errors.js';
|
||||
import type { JobDefinitionDoc, JobRunDoc } from './types.js';
|
||||
|
||||
const DEFS_CONTAINER = 'job_definitions';
|
||||
const RUNS_CONTAINER = 'job_runs';
|
||||
|
||||
function defsContainer() {
|
||||
return getContainer(DEFS_CONTAINER);
|
||||
}
|
||||
|
||||
function runsContainer() {
|
||||
return getContainer(RUNS_CONTAINER);
|
||||
}
|
||||
|
||||
// ── Job Definition CRUD ──────────────────────────────────────
|
||||
|
||||
export async function listJobDefinitions(productId: string): Promise<JobDefinitionDoc[]> {
|
||||
const { resources } = await defsContainer()
|
||||
.items.query<JobDefinitionDoc>(
|
||||
{
|
||||
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.name',
|
||||
parameters: [{ name: '@productId', value: productId }],
|
||||
},
|
||||
{ partitionKey: productId }
|
||||
)
|
||||
.fetchAll();
|
||||
return resources;
|
||||
}
|
||||
|
||||
export async function getJobDefinition(id: string, productId: string): Promise<JobDefinitionDoc> {
|
||||
const { resource } = await defsContainer().item(id, productId).read<JobDefinitionDoc>();
|
||||
if (!resource) throw new NotFoundError(`Job definition '${id}' not found`);
|
||||
return resource;
|
||||
}
|
||||
|
||||
export async function upsertJobDefinition(doc: JobDefinitionDoc): Promise<JobDefinitionDoc> {
|
||||
const { resource } = await defsContainer().items.upsert(doc);
|
||||
return resource as JobDefinitionDoc;
|
||||
}
|
||||
|
||||
export async function updateJobDefinition(
|
||||
id: string,
|
||||
productId: string,
|
||||
updates: Partial<JobDefinitionDoc>
|
||||
): Promise<JobDefinitionDoc> {
|
||||
const existing = await getJobDefinition(id, productId);
|
||||
const updated: JobDefinitionDoc = {
|
||||
...existing,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const { resource } = await defsContainer().item(id, productId).replace(updated);
|
||||
return resource as JobDefinitionDoc;
|
||||
}
|
||||
|
||||
// ── Job Run CRUD ─────────────────────────────────────────────
|
||||
|
||||
export async function createJobRun(doc: JobRunDoc): Promise<JobRunDoc> {
|
||||
const { resource } = await runsContainer().items.create(doc);
|
||||
return resource as JobRunDoc;
|
||||
}
|
||||
|
||||
export async function updateJobRun(doc: JobRunDoc): Promise<JobRunDoc> {
|
||||
const pk = `${doc.productId}:${doc.jobName}`;
|
||||
const { resource } = await runsContainer().item(doc.id, pk).replace(doc);
|
||||
return resource as JobRunDoc;
|
||||
}
|
||||
|
||||
export async function listJobRuns(
|
||||
productId: string,
|
||||
jobName: string,
|
||||
limit = 20
|
||||
): Promise<JobRunDoc[]> {
|
||||
const pk = `${productId}:${jobName}`;
|
||||
const { resources } = await runsContainer()
|
||||
.items.query<JobRunDoc>(
|
||||
{
|
||||
query:
|
||||
'SELECT TOP @limit * FROM c WHERE c.productId = @productId AND c.jobName = @jobName ORDER BY c.startedAt DESC',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@jobName', value: jobName },
|
||||
{ name: '@limit', value: limit },
|
||||
],
|
||||
},
|
||||
{ partitionKey: pk }
|
||||
)
|
||||
.fetchAll();
|
||||
return resources;
|
||||
}
|
||||
62
services/platform-service/src/modules/jobs/routes.ts
Normal file
62
services/platform-service/src/modules/jobs/routes.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { extractAuth } from '../../lib/auth.js';
|
||||
import { BadRequestError } from '../../lib/errors.js';
|
||||
import { TriggerJobSchema, UpdateJobSchema } from './types.js';
|
||||
import * as repo from './repository.js';
|
||||
import { getJobHandler } from './registry.js';
|
||||
import { executeJob } from './runner.js';
|
||||
|
||||
const DEFAULT_PRODUCT_ID = 'lysnrai';
|
||||
|
||||
export async function jobRoutes(app: FastifyInstance) {
|
||||
// List all job definitions
|
||||
app.get('/jobs', async req => {
|
||||
await extractAuth(req);
|
||||
return repo.listJobDefinitions(DEFAULT_PRODUCT_ID);
|
||||
});
|
||||
|
||||
// Get a specific job definition
|
||||
app.get('/jobs/:id', async req => {
|
||||
await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
return repo.getJobDefinition(id, DEFAULT_PRODUCT_ID);
|
||||
});
|
||||
|
||||
// Update job (enable/disable, change cron, etc.)
|
||||
app.put('/jobs/:id', async req => {
|
||||
await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = UpdateJobSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
return repo.updateJobDefinition(id, DEFAULT_PRODUCT_ID, parsed.data);
|
||||
});
|
||||
|
||||
// Manually trigger a job
|
||||
app.post('/jobs/trigger', async req => {
|
||||
await extractAuth(req);
|
||||
const parsed = TriggerJobSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const { jobName } = parsed.data;
|
||||
const handler = getJobHandler(jobName);
|
||||
if (!handler) {
|
||||
throw new BadRequestError(`No handler registered for job '${jobName}'`);
|
||||
}
|
||||
|
||||
const def = await repo.getJobDefinition(`job_${jobName}`, DEFAULT_PRODUCT_ID);
|
||||
const run = await executeJob(def, 'manual', req.log);
|
||||
return run;
|
||||
});
|
||||
|
||||
// List recent runs for a job
|
||||
app.get('/jobs/:name/runs', async req => {
|
||||
await extractAuth(req);
|
||||
const { name } = req.params as { name: string };
|
||||
const limit = parseInt((req.query as Record<string, string>).limit || '20', 10);
|
||||
return repo.listJobRuns(DEFAULT_PRODUCT_ID, name, Math.min(limit, 100));
|
||||
});
|
||||
}
|
||||
227
services/platform-service/src/modules/jobs/runner.ts
Normal file
227
services/platform-service/src/modules/jobs/runner.ts
Normal file
@ -0,0 +1,227 @@
|
||||
import { cronMatches, nextCronOccurrence } from './cron.js';
|
||||
import { getJobHandler, getRegisteredJobs } from './registry.js';
|
||||
import * as repo from './repository.js';
|
||||
import type { JobDefinitionDoc, JobRunDoc, JobContext, JobResult } from './types.js';
|
||||
|
||||
// ── In-Process Job Runner ────────────────────────────────────
|
||||
// Tick loop evaluates cron expressions every minute and runs due jobs.
|
||||
// Uses a simple lock flag to prevent overlapping ticks.
|
||||
|
||||
const DEFAULT_PRODUCT_ID = 'lysnrai';
|
||||
|
||||
let tickInterval: ReturnType<typeof globalThis.setInterval> | null = null;
|
||||
let isRunning = false;
|
||||
|
||||
/**
|
||||
* Start the job runner tick loop. Evaluates every 60 seconds.
|
||||
*/
|
||||
export function startRunner(log: {
|
||||
info: (...a: unknown[]) => void;
|
||||
error: (...a: unknown[]) => void;
|
||||
}): void {
|
||||
if (tickInterval) return;
|
||||
|
||||
log.info('[jobs] Starting job runner (60s tick)');
|
||||
|
||||
// Run immediately on start, then every 60s
|
||||
tick(log).catch(() => {});
|
||||
tickInterval = globalThis.setInterval(() => {
|
||||
tick(log).catch(() => {});
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the job runner.
|
||||
*/
|
||||
export function stopRunner(): void {
|
||||
if (tickInterval) {
|
||||
globalThis.clearInterval(tickInterval);
|
||||
tickInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single tick: check all enabled jobs, run those whose cron matches now.
|
||||
*/
|
||||
async function tick(log: {
|
||||
info: (...a: unknown[]) => void;
|
||||
error: (...a: unknown[]) => void;
|
||||
}): Promise<void> {
|
||||
if (isRunning) return;
|
||||
isRunning = true;
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
// Round to current minute for matching
|
||||
now.setUTCSeconds(0, 0);
|
||||
|
||||
const registered = getRegisteredJobs();
|
||||
if (registered.length === 0) return;
|
||||
|
||||
// Get definitions from DB (or create defaults)
|
||||
let definitions: JobDefinitionDoc[];
|
||||
try {
|
||||
definitions = await repo.listJobDefinitions(DEFAULT_PRODUCT_ID);
|
||||
} catch {
|
||||
// Cosmos not available (e.g., in tests) — skip
|
||||
return;
|
||||
}
|
||||
|
||||
for (const def of definitions) {
|
||||
if (def.status !== 'enabled') continue;
|
||||
if (!getJobHandler(def.name)) continue;
|
||||
|
||||
if (cronMatches(def.cronExpression, now)) {
|
||||
await executeJob(def, 'scheduler', log);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a job by name. Called by the tick loop or manually via admin API.
|
||||
*/
|
||||
export async function executeJob(
|
||||
def: JobDefinitionDoc,
|
||||
triggeredBy: 'scheduler' | 'manual',
|
||||
log: { info: (...a: unknown[]) => void; error: (...a: unknown[]) => void }
|
||||
): Promise<JobRunDoc> {
|
||||
const handler = getJobHandler(def.name);
|
||||
if (!handler) {
|
||||
throw new Error(`No handler registered for job '${def.name}'`);
|
||||
}
|
||||
|
||||
const runId = crypto.randomUUID();
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
// Create run record
|
||||
const run: JobRunDoc = {
|
||||
id: runId,
|
||||
jobName: def.name,
|
||||
productId: def.productId,
|
||||
status: 'running',
|
||||
startedAt,
|
||||
triggeredBy,
|
||||
};
|
||||
|
||||
try {
|
||||
await repo.createJobRun(run);
|
||||
} catch {
|
||||
// Non-fatal — continue execution
|
||||
}
|
||||
|
||||
// Mark definition as running
|
||||
try {
|
||||
await repo.updateJobDefinition(def.id, def.productId, {
|
||||
status: 'running',
|
||||
lastRunAt: startedAt,
|
||||
lastRunStatus: 'running',
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
// Execute with timeout
|
||||
const context: JobContext = {
|
||||
jobName: def.name,
|
||||
runId,
|
||||
productId: def.productId,
|
||||
log,
|
||||
};
|
||||
|
||||
let result: JobResult;
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const controller = new globalThis.AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), def.timeoutMs || 300_000);
|
||||
|
||||
result = await Promise.race([
|
||||
handler(context),
|
||||
new Promise<JobResult>((_, reject) => {
|
||||
controller.signal.addEventListener('abort', () => {
|
||||
reject(new Error(`Job '${def.name}' timed out after ${def.timeoutMs || 300_000}ms`));
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
globalThis.clearTimeout(timeout);
|
||||
} catch (err: unknown) {
|
||||
result = {
|
||||
success: false,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - start;
|
||||
const completedAt = new Date().toISOString();
|
||||
const status = result.success ? 'success' : 'failed';
|
||||
|
||||
// Update run record
|
||||
const updatedRun: JobRunDoc = {
|
||||
...run,
|
||||
status,
|
||||
completedAt,
|
||||
durationMs,
|
||||
error: result.success ? undefined : result.message,
|
||||
metrics: result.metrics,
|
||||
};
|
||||
|
||||
try {
|
||||
await repo.updateJobRun(updatedRun);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
// Update definition
|
||||
const nextRun = nextCronOccurrence(def.cronExpression, new Date());
|
||||
try {
|
||||
await repo.updateJobDefinition(def.id, def.productId, {
|
||||
status: 'enabled',
|
||||
lastRunAt: startedAt,
|
||||
lastRunStatus: status,
|
||||
nextRunAt: nextRun?.toISOString(),
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
log.info({ jobName: def.name, runId, status, durationMs }, `[jobs] ${def.name} ${status}`);
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all registered jobs have definitions in Cosmos.
|
||||
* Creates missing definitions with default cron.
|
||||
*/
|
||||
export async function ensureJobDefinitions(
|
||||
defaults: Array<{ name: string; cron: string; description?: string; timeoutMs?: number }>
|
||||
): Promise<void> {
|
||||
for (const def of defaults) {
|
||||
const id = `job_${def.name}`;
|
||||
const now = new Date().toISOString();
|
||||
const next = nextCronOccurrence(def.cron, new Date());
|
||||
|
||||
try {
|
||||
await repo.upsertJobDefinition({
|
||||
id,
|
||||
productId: DEFAULT_PRODUCT_ID,
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
cronExpression: def.cron,
|
||||
status: 'enabled',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
timeoutMs: def.timeoutMs ?? 300_000,
|
||||
retryOnFailure: false,
|
||||
maxRetries: 0,
|
||||
nextRunAt: next?.toISOString(),
|
||||
});
|
||||
} catch {
|
||||
// May already exist — that's fine
|
||||
}
|
||||
}
|
||||
}
|
||||
85
services/platform-service/src/modules/jobs/types.ts
Normal file
85
services/platform-service/src/modules/jobs/types.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Job Status ───────────────────────────────────────────────
|
||||
|
||||
export type JobStatus = 'enabled' | 'disabled' | 'running';
|
||||
export type RunStatus = 'success' | 'failed' | 'skipped' | 'running';
|
||||
|
||||
// ── Job Definition Schema ────────────────────────────────────
|
||||
|
||||
export const JobDefinitionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
productId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
cronExpression: z.string().min(1),
|
||||
status: z.enum(['enabled', 'disabled', 'running']).default('enabled'),
|
||||
lastRunAt: z.string().optional(),
|
||||
lastRunStatus: z.enum(['success', 'failed', 'skipped', 'running']).optional(),
|
||||
nextRunAt: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
timeoutMs: z.number().default(300_000),
|
||||
retryOnFailure: z.boolean().default(false),
|
||||
maxRetries: z.number().default(0),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type JobDefinition = z.infer<typeof JobDefinitionSchema>;
|
||||
|
||||
export interface JobDefinitionDoc extends JobDefinition {
|
||||
_ts?: number;
|
||||
_etag?: string;
|
||||
}
|
||||
|
||||
// ── Job Run Schema ───────────────────────────────────────────
|
||||
|
||||
export const JobRunSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
jobName: z.string().min(1),
|
||||
productId: z.string().min(1),
|
||||
status: z.enum(['success', 'failed', 'skipped', 'running']),
|
||||
startedAt: z.string(),
|
||||
completedAt: z.string().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
error: z.string().optional(),
|
||||
metrics: z.record(z.unknown()).optional(),
|
||||
triggeredBy: z.enum(['scheduler', 'manual']).default('scheduler'),
|
||||
});
|
||||
|
||||
export type JobRun = z.infer<typeof JobRunSchema>;
|
||||
|
||||
export interface JobRunDoc extends JobRun {
|
||||
_ts?: number;
|
||||
_etag?: string;
|
||||
}
|
||||
|
||||
// ── Admin Schemas ────────────────────────────────────────────
|
||||
|
||||
export const TriggerJobSchema = z.object({
|
||||
jobName: z.string().min(1),
|
||||
});
|
||||
|
||||
export const UpdateJobSchema = z.object({
|
||||
status: z.enum(['enabled', 'disabled']).optional(),
|
||||
cronExpression: z.string().min(1).optional(),
|
||||
timeoutMs: z.number().min(1000).max(600_000).optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
// ── Job Handler Type ─────────────────────────────────────────
|
||||
|
||||
export type JobHandler = (context: JobContext) => Promise<JobResult>;
|
||||
|
||||
export interface JobContext {
|
||||
jobName: string;
|
||||
runId: string;
|
||||
productId: string;
|
||||
log: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
|
||||
}
|
||||
|
||||
export interface JobResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
metrics?: Record<string, unknown>;
|
||||
}
|
||||
119
services/platform-service/src/modules/status/health-checker.ts
Normal file
119
services/platform-service/src/modules/status/health-checker.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import type { ServiceStatus, ServiceHealth, PlatformStatus } from './types.js';
|
||||
|
||||
// ── Service Definitions ──────────────────────────────────────
|
||||
// Services to health-check. URLs are resolved from env vars at runtime.
|
||||
|
||||
interface ServiceDef {
|
||||
name: string;
|
||||
envVar: string;
|
||||
defaultUrl: string;
|
||||
healthPath: string;
|
||||
}
|
||||
|
||||
const SERVICE_DEFS: ServiceDef[] = [
|
||||
{
|
||||
name: 'Platform Service',
|
||||
envVar: 'PLATFORM_SERVICE_URL',
|
||||
defaultUrl: 'http://localhost:4003',
|
||||
healthPath: '/health',
|
||||
},
|
||||
{
|
||||
name: 'Extraction Service',
|
||||
envVar: 'EXTRACTION_SERVICE_URL',
|
||||
defaultUrl: 'http://localhost:4005',
|
||||
healthPath: '/health',
|
||||
},
|
||||
{
|
||||
name: 'Backend API',
|
||||
envVar: 'BACKEND_URL',
|
||||
defaultUrl: 'http://localhost:8000',
|
||||
healthPath: '/health',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Check health of a single service.
|
||||
*/
|
||||
async function checkService(def: ServiceDef): Promise<ServiceStatus> {
|
||||
const baseUrl = process.env[def.envVar] || def.defaultUrl;
|
||||
const url = `${baseUrl}${def.healthPath}`;
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const controller = new globalThis.AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), 5_000);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
globalThis.clearTimeout(timeout);
|
||||
const responseTimeMs = Date.now() - start;
|
||||
|
||||
let health: ServiceHealth = 'operational';
|
||||
if (!response.ok) {
|
||||
health = response.status >= 500 ? 'major_outage' : 'degraded';
|
||||
} else if (responseTimeMs > 3000) {
|
||||
health = 'degraded';
|
||||
}
|
||||
|
||||
return {
|
||||
name: def.name,
|
||||
url: baseUrl,
|
||||
health,
|
||||
responseTimeMs,
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
return {
|
||||
name: def.name,
|
||||
url: baseUrl,
|
||||
health: 'major_outage',
|
||||
responseTimeMs: null,
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive overall platform health from individual service statuses.
|
||||
*/
|
||||
function deriveOverallHealth(services: ServiceStatus[]): ServiceHealth {
|
||||
const hasOutage = services.some(s => s.health === 'major_outage');
|
||||
const hasPartial = services.some(s => s.health === 'partial_outage');
|
||||
const hasDegraded = services.some(s => s.health === 'degraded');
|
||||
const hasMaintenance = services.some(s => s.health === 'maintenance');
|
||||
|
||||
if (hasOutage) return 'major_outage';
|
||||
if (hasPartial) return 'partial_outage';
|
||||
if (hasDegraded) return 'degraded';
|
||||
if (hasMaintenance) return 'maintenance';
|
||||
return 'operational';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all services and return platform-wide status.
|
||||
*/
|
||||
export async function checkAllServices(): Promise<PlatformStatus> {
|
||||
const results = await Promise.allSettled(SERVICE_DEFS.map(checkService));
|
||||
|
||||
const services: ServiceStatus[] = results.map((result, i) => {
|
||||
if (result.status === 'fulfilled') return result.value;
|
||||
return {
|
||||
name: SERVICE_DEFS[i].name,
|
||||
url: SERVICE_DEFS[i].defaultUrl,
|
||||
health: 'major_outage' as ServiceHealth,
|
||||
responseTimeMs: null,
|
||||
lastCheckedAt: new Date().toISOString(),
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
overall: deriveOverallHealth(services),
|
||||
services,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
56
services/platform-service/src/modules/status/repository.ts
Normal file
56
services/platform-service/src/modules/status/repository.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import { NotFoundError } from '../../lib/errors.js';
|
||||
import type { IncidentDoc } from './types.js';
|
||||
|
||||
const CONTAINER = 'incidents';
|
||||
|
||||
function container() {
|
||||
return getContainer(CONTAINER);
|
||||
}
|
||||
|
||||
export async function listIncidents(productId: string, limit = 20): Promise<IncidentDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<IncidentDoc>(
|
||||
{
|
||||
query:
|
||||
'SELECT TOP @limit * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC',
|
||||
parameters: [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@limit', value: limit },
|
||||
],
|
||||
},
|
||||
{ partitionKey: productId }
|
||||
)
|
||||
.fetchAll();
|
||||
return resources;
|
||||
}
|
||||
|
||||
export async function listActiveIncidents(productId: string): Promise<IncidentDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<IncidentDoc>(
|
||||
{
|
||||
query:
|
||||
'SELECT * FROM c WHERE c.productId = @productId AND c.status != "resolved" ORDER BY c.createdAt DESC',
|
||||
parameters: [{ name: '@productId', value: productId }],
|
||||
},
|
||||
{ partitionKey: productId }
|
||||
)
|
||||
.fetchAll();
|
||||
return resources;
|
||||
}
|
||||
|
||||
export async function getIncident(id: string, productId: string): Promise<IncidentDoc> {
|
||||
const { resource } = await container().item(id, productId).read<IncidentDoc>();
|
||||
if (!resource) throw new NotFoundError(`Incident '${id}' not found`);
|
||||
return resource;
|
||||
}
|
||||
|
||||
export async function createIncident(doc: IncidentDoc): Promise<IncidentDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as IncidentDoc;
|
||||
}
|
||||
|
||||
export async function updateIncident(doc: IncidentDoc): Promise<IncidentDoc> {
|
||||
const { resource } = await container().item(doc.id, doc.productId).replace(doc);
|
||||
return resource as IncidentDoc;
|
||||
}
|
||||
103
services/platform-service/src/modules/status/routes.ts
Normal file
103
services/platform-service/src/modules/status/routes.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { extractAuth } from '../../lib/auth.js';
|
||||
import { BadRequestError } from '../../lib/errors.js';
|
||||
import { CreateIncidentSchema, UpdateIncidentSchema } from './types.js';
|
||||
import type { IncidentDoc, IncidentUpdate } from './types.js';
|
||||
import * as repo from './repository.js';
|
||||
import { checkAllServices } from './health-checker.js';
|
||||
|
||||
const DEFAULT_PRODUCT_ID = 'lysnrai';
|
||||
|
||||
export async function statusRoutes(app: FastifyInstance) {
|
||||
// ── Public endpoints (no auth) ─────────────────────────────
|
||||
|
||||
// Get current platform status (health checks all services)
|
||||
app.get('/status', async () => {
|
||||
return checkAllServices();
|
||||
});
|
||||
|
||||
// Get active incidents
|
||||
app.get('/status/incidents', async () => {
|
||||
return repo.listActiveIncidents(DEFAULT_PRODUCT_ID);
|
||||
});
|
||||
|
||||
// Get incident by ID
|
||||
app.get('/status/incidents/:id', async req => {
|
||||
const { id } = req.params as { id: string };
|
||||
return repo.getIncident(id, DEFAULT_PRODUCT_ID);
|
||||
});
|
||||
|
||||
// Get incident history (all incidents, paginated)
|
||||
app.get('/status/history', async req => {
|
||||
const limit = parseInt((req.query as Record<string, string>).limit || '20', 10);
|
||||
return repo.listIncidents(DEFAULT_PRODUCT_ID, Math.min(limit, 100));
|
||||
});
|
||||
|
||||
// ── Admin endpoints (auth required) ────────────────────────
|
||||
|
||||
// Create a new incident
|
||||
app.post('/status/incidents', async (req, reply) => {
|
||||
await extractAuth(req);
|
||||
const parsed = CreateIncidentSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const doc: IncidentDoc = {
|
||||
id: `inc_${crypto.randomUUID()}`,
|
||||
productId: DEFAULT_PRODUCT_ID,
|
||||
title: parsed.data.title,
|
||||
severity: parsed.data.severity,
|
||||
status: 'investigating',
|
||||
affectedServices: parsed.data.affectedServices,
|
||||
updates: [
|
||||
{
|
||||
status: 'investigating',
|
||||
message: parsed.data.message,
|
||||
createdAt: now,
|
||||
},
|
||||
],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const created = await repo.createIncident(doc);
|
||||
return reply.status(201).send(created);
|
||||
});
|
||||
|
||||
// Update an incident (add update, change status/severity)
|
||||
app.put('/status/incidents/:id', async req => {
|
||||
await extractAuth(req);
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = UpdateIncidentSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const existing = await repo.getIncident(id, DEFAULT_PRODUCT_ID);
|
||||
const now = new Date().toISOString();
|
||||
const updates = parsed.data;
|
||||
|
||||
// Add new update entry if message provided
|
||||
const newUpdates: IncidentUpdate[] = [...existing.updates];
|
||||
if (updates.message) {
|
||||
newUpdates.push({
|
||||
status: updates.status || existing.status,
|
||||
message: updates.message,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
const updated: IncidentDoc = {
|
||||
...existing,
|
||||
status: updates.status || existing.status,
|
||||
severity: updates.severity || existing.severity,
|
||||
updates: newUpdates,
|
||||
updatedAt: now,
|
||||
resolvedAt: updates.status === 'resolved' ? now : existing.resolvedAt,
|
||||
};
|
||||
|
||||
return repo.updateIncident(updated);
|
||||
});
|
||||
}
|
||||
105
services/platform-service/src/modules/status/status.test.ts
Normal file
105
services/platform-service/src/modules/status/status.test.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CreateIncidentSchema, UpdateIncidentSchema } from './types.js';
|
||||
import type { ServiceHealth } from './types.js';
|
||||
|
||||
describe('CreateIncidentSchema', () => {
|
||||
it('accepts valid incident', () => {
|
||||
const result = CreateIncidentSchema.safeParse({
|
||||
title: 'Platform Service Degraded',
|
||||
severity: 'major',
|
||||
message: 'Increased error rates on platform-service.',
|
||||
affectedServices: ['Platform Service'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty title', () => {
|
||||
const result = CreateIncidentSchema.safeParse({
|
||||
title: '',
|
||||
severity: 'minor',
|
||||
message: 'Test',
|
||||
affectedServices: ['Platform Service'],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty affected services', () => {
|
||||
const result = CreateIncidentSchema.safeParse({
|
||||
title: 'Test',
|
||||
severity: 'minor',
|
||||
message: 'Test',
|
||||
affectedServices: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid severity', () => {
|
||||
const result = CreateIncidentSchema.safeParse({
|
||||
title: 'Test',
|
||||
severity: 'unknown',
|
||||
message: 'Test',
|
||||
affectedServices: ['Platform Service'],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts all valid severities', () => {
|
||||
for (const severity of ['minor', 'major', 'critical']) {
|
||||
const result = CreateIncidentSchema.safeParse({
|
||||
title: 'Test',
|
||||
severity,
|
||||
message: 'Test',
|
||||
affectedServices: ['Platform Service'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateIncidentSchema', () => {
|
||||
it('accepts status update', () => {
|
||||
const result = UpdateIncidentSchema.safeParse({
|
||||
status: 'identified',
|
||||
message: 'Root cause identified — database connection pool exhaustion.',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts severity change only', () => {
|
||||
const result = UpdateIncidentSchema.safeParse({ severity: 'critical' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts resolution', () => {
|
||||
const result = UpdateIncidentSchema.safeParse({
|
||||
status: 'resolved',
|
||||
message: 'Connection pool size increased. Monitoring.',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts all valid statuses', () => {
|
||||
for (const status of ['investigating', 'identified', 'monitoring', 'resolved']) {
|
||||
const result = UpdateIncidentSchema.safeParse({ status });
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid status', () => {
|
||||
const result = UpdateIncidentSchema.safeParse({ status: 'unknown' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServiceHealth type coverage', () => {
|
||||
it('should support all health states', () => {
|
||||
const states: ServiceHealth[] = [
|
||||
'operational',
|
||||
'degraded',
|
||||
'partial_outage',
|
||||
'major_outage',
|
||||
'maintenance',
|
||||
];
|
||||
expect(states.length).toBe(5);
|
||||
});
|
||||
});
|
||||
65
services/platform-service/src/modules/status/types.ts
Normal file
65
services/platform-service/src/modules/status/types.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Service Status ───────────────────────────────────────────
|
||||
|
||||
export type ServiceHealth =
|
||||
| 'operational'
|
||||
| 'degraded'
|
||||
| 'partial_outage'
|
||||
| 'major_outage'
|
||||
| 'maintenance';
|
||||
|
||||
export interface ServiceStatus {
|
||||
name: string;
|
||||
url: string;
|
||||
health: ServiceHealth;
|
||||
responseTimeMs: number | null;
|
||||
lastCheckedAt: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PlatformStatus {
|
||||
overall: ServiceHealth;
|
||||
services: ServiceStatus[];
|
||||
checkedAt: string;
|
||||
uptimePercent?: number;
|
||||
}
|
||||
|
||||
// ── Incident Schemas ─────────────────────────────────────────
|
||||
|
||||
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
|
||||
export type IncidentSeverity = 'minor' | 'major' | 'critical';
|
||||
|
||||
export const CreateIncidentSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
severity: z.enum(['minor', 'major', 'critical']),
|
||||
message: z.string().min(1).max(2000),
|
||||
affectedServices: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
export const UpdateIncidentSchema = z.object({
|
||||
status: z.enum(['investigating', 'identified', 'monitoring', 'resolved']).optional(),
|
||||
message: z.string().min(1).max(2000).optional(),
|
||||
severity: z.enum(['minor', 'major', 'critical']).optional(),
|
||||
});
|
||||
|
||||
export interface IncidentUpdate {
|
||||
status: IncidentStatus;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface IncidentDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
title: string;
|
||||
severity: IncidentSeverity;
|
||||
status: IncidentStatus;
|
||||
affectedServices: string[];
|
||||
updates: IncidentUpdate[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
resolvedAt?: string;
|
||||
_ts?: number;
|
||||
_etag?: string;
|
||||
}
|
||||
@ -57,6 +57,8 @@ import { routineRoutes } from './modules/routines/routes.js';
|
||||
import { householdRoutes } from './modules/households/routes.js';
|
||||
import { sharedTimerRoutes } from './modules/shared-timers/routes.js';
|
||||
import { webhookRoutes } from './modules/webhooks/routes.js';
|
||||
import { jobRoutes } from './modules/jobs/routes.js';
|
||||
import { statusRoutes } from './modules/status/routes.js';
|
||||
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||
import { config } from './lib/config.js';
|
||||
|
||||
@ -144,5 +146,9 @@ await app.register(householdRoutes, { prefix: '/api' });
|
||||
await app.register(sharedTimerRoutes, { prefix: '/api' });
|
||||
// Webhooks module (subscriptions + event dispatch)
|
||||
await app.register(webhookRoutes, { prefix: '/api' });
|
||||
// Scheduled jobs module (admin: list, trigger, view runs)
|
||||
await app.register(jobRoutes, { prefix: '/api' });
|
||||
// Public status page + incident management
|
||||
await app.register(statusRoutes, { prefix: '/api' });
|
||||
|
||||
await startService(app, { port: config.PORT, host: config.HOST });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user