feat(platform): add P0 foundational modules — event bus, scheduled jobs, password reset, email verification, status page

This commit is contained in:
saravanakumardb1 2026-02-28 02:29:08 -08:00
parent 0e468f74ea
commit 772f428967
30 changed files with 8850 additions and 2161 deletions

View File

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

View File

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

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

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

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

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

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

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

View 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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View 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(),
};
}

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

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

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

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

View File

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