Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 38 additions & 38 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# ObjectOS Roadmap

> **Version**: 7.0.0
> **Version**: 8.0.0
> **Date**: February 12, 2026
> **Status**: Phase M — Technical Debt Resolution
> **Status**: Phase M — Technical Debt Resolution ✅ COMPLETE
> **Spec SDK**: `@objectstack/spec@2.0.7`
> **ObjectUI**: `@object-ui/*@2.0.0`

Expand Down Expand Up @@ -249,28 +249,28 @@ Integrate `@objectos/browser` with the Admin Console for offline-first capabilit

| # | Task | TD | Priority | Status |
|---|------|:--:|:--------:|:------:|
| M.1.1 | Rate limiting middleware — sliding-window counter on `/api/v1/*` with per-IP/per-user throttling | TD-3 | 🔴 | |
| M.1.2 | Input sanitization middleware — body size limit, XSS stripping, Zod validation factory | TD-4 | 🔴 | |
| M.1.3 | WebSocket auth enforcement — token extraction from cookie/protocol header, session verification | TD-5 | 🟡 | |
| M.1.4 | Mock data tree-shaking — `DevDataProvider`, dynamic imports, `VITE_USE_MOCK_DATA` env flag | TD-8 | 🟡 | |
| M.1.1 | Rate limiting middleware — sliding-window counter on `/api/v1/*` with per-IP/per-user throttling | TD-3 | 🔴 | |
| M.1.2 | Input sanitization middleware — body size limit, XSS stripping, Zod validation factory | TD-4 | 🔴 | |
| M.1.3 | WebSocket auth enforcement — token extraction from cookie/protocol header, session verification | TD-5 | 🟡 | |
| M.1.4 | Mock data tree-shaking — `DevDataProvider`, dynamic imports, `VITE_USE_MOCK_DATA` env flag | TD-8 | 🟡 | |

### M.2 — Infrastructure (v1.1.0 — Target: April 2026)

| # | Task | TD | Priority | Status |
|---|------|:--:|:--------:|:------:|
| M.2.1 | Event bus persistence — `PersistentJobStorage` backed by SQLite via `@objectos/storage` | TD-1 | 🟡 | |
| M.2.2 | Dead Letter Queue + Replay API — DLQ table, `replayEvent()`, admin endpoint | TD-1 | 🟡 | |
| M.2.3 | Schema migration engine — `SchemaDiffer`, `MigrationGenerator`, `MigrationRunner` | TD-2 | 🟡 | |
| M.2.4 | `objectstack migrate` CLI — up/down/status commands | TD-2 | 🟡 | |
| M.2.5 | Browser sync E2E tests — 5 Playwright tests covering full sync lifecycle | TD-6 | 🟡 | |
| M.2.1 | Event bus persistence — `PersistentJobStorage` backed by `@objectos/storage` | TD-1 | 🟡 | |
| M.2.2 | Dead Letter Queue + Replay API — DLQ, `replayDeadLetter()`, `purgeDeadLetters()` | TD-1 | 🟡 | |
| M.2.3 | Schema migration engine — `SchemaDiffer`, `MigrationGenerator`, `MigrationRunnerImpl` | TD-2 | 🟡 | |
| M.2.4 | `objectstack migrate` CLI — `MigrationCLI` with up/down/status commands | TD-2 | 🟡 | |
| M.2.5 | Browser sync E2E tests — 5 Playwright specs covering sync lifecycle | TD-6 | 🟡 | |

### M.3 — Platform Hardening (v2.0.0 — Target: September 2026)

| # | Task | TD | Priority | Status |
|---|------|:--:|:--------:|:------:|
| M.3.1 | Worker Thread plugin host — Level 1 isolation via `worker_threads` | TD-7 | 🟢 | |
| M.3.2 | Child Process plugin host — Level 2 isolation via `child_process.fork()` | TD-7 | 🟢 | |
| M.3.3 | Plugin watchdog — auto-restart with backoff, resource limit enforcement | TD-7 | 🟢 | |
| M.3.1 | Worker Thread plugin host — Level 1 isolation via `worker_threads` | TD-7 | 🟢 | |
| M.3.2 | Child Process plugin host — Level 2 isolation via `child_process.fork()` | TD-7 | 🟢 | |
| M.3.3 | Plugin watchdog — auto-restart with backoff, resource limit enforcement | TD-7 | 🟢 | |

---

Expand All @@ -293,33 +293,33 @@ Integrate `@objectos/browser` with the Admin Console for offline-first capabilit

### v1.0.1 — Security Hardening (Target: March 2026)

- Phase M.1: Critical Security
- Rate limiting middleware (TD-3) 🔴
- Input sanitization middleware (TD-4) 🔴
- WebSocket auth enforcement (TD-5) 🟡
- Mock data tree-shaking (TD-8) 🟡
- Phase M.1: Critical Security
- Rate limiting middleware (TD-3)
- Input sanitization middleware (TD-4)
- WebSocket auth enforcement (TD-5)
- Mock data tree-shaking (TD-8)

### v1.1.0 — Rich Business UI + Infrastructure (Target: April 2026)

- Phase I: Rich Data Experience (inline editing, bulk actions, filters)
- Phase J.1-J.2: Visual Flow Editor, Approval Inbox
- Phase M.2: Infrastructure
- Event bus persistence + DLQ (TD-1) 🟡
- Schema migration engine (TD-2) 🟡
- Browser sync E2E tests (TD-6) 🟡
- Phase I: Rich Data Experience (inline editing, bulk actions, filters)
- Phase J.1-J.2: Visual Flow Editor, Approval Inbox
- Phase M.2: Infrastructure
- Event bus persistence + DLQ (TD-1)
- Schema migration engine (TD-2)
- Browser sync E2E tests (TD-6)

### v1.2.0 — Enterprise Features (Target: June 2026)

- Phase J.3-J.6: Full Workflow & Automation UI
- Phase K: Offline & Sync
- Phase J.3-J.6: Full Workflow & Automation UI
- Phase K: Offline & Sync
- Multi-tenancy data isolation
- OpenTelemetry integration

### v2.0.0 — Platform (Target: September 2026)

- Phase L: Polish & Performance
- Phase M.3: Platform Hardening
- Plugin isolation (Worker Threads + Child Process) (TD-7) 🟢
- Phase L: Polish & Performance
- Phase M.3: Platform Hardening
- Plugin isolation (Worker Threads + Child Process) (TD-7)
- Plugin Marketplace
- Dynamic Plugin Loading (Module Federation)
- AI Agent Framework
Expand Down Expand Up @@ -440,14 +440,14 @@ User Action → React Component → @object-ui/react SchemaRenderer

| # | Area | Details | Priority | Phase | Status |
|---|------|---------|:--------:|:-----:|:------:|
| 1 | Event bus persistence | In-memory only; no DLQ or replay | 🟡 | M.2 | |
| 2 | Schema migrations | No version-controlled schema evolution | 🟡 | M.2 | |
| 3 | Rate limiting | Not implemented at HTTP layer | 🔴 | M.1 | |
| 4 | Input sanitization | Zod schema validation only; no HTTP-level protection | 🔴 | M.1 | |
| 5 | Realtime auth | WebSocket auth not enforced | 🟡 | M.1 | |
| 6 | Browser sync E2E | Sync protocol needs E2E testing | 🟡 | M.2 | |
| 7 | Plugin isolation | Plugins share process | 🟢 | M.3 | |
| 8 | Mock data dependency | UI relies on mock data when server is down | 🟡 | M.1 | |
| 1 | Event bus persistence | `PersistentJobStorage` with DLQ and replay | 🟡 | M.2 | |
| 2 | Schema migrations | `SchemaDiffer` + `MigrationRunnerImpl` + `MigrationCLI` | 🟡 | M.2 | |
| 3 | Rate limiting | Sliding-window counter on `/api/v1/*` | 🔴 | M.1 | |
| 4 | Input sanitization | Body limit + XSS strip + content-type guard + Zod validate | 🔴 | M.1 | |
| 5 | Realtime auth | WebSocket auth enforced via cookie/protocol/query token | 🟡 | M.1 | |
| 6 | Browser sync E2E | 5 Playwright E2E test specs for sync lifecycle | 🟡 | M.2 | |
| 7 | Plugin isolation | `WorkerThreadPluginHost`, `ChildProcessPluginHost`, `PluginWatchdog` | 🟢 | M.3 | |
| 8 | Mock data dependency | DevDataProvider + tree-shaking via `__mocks__/` | 🟡 | M.1 | |

---

Expand Down
30 changes: 30 additions & 0 deletions api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
import { handle } from '@hono/node-server/vercel';
import { cors } from 'hono/cors';
import { secureHeaders } from 'hono/secure-headers';
import { rateLimit } from './middleware/rate-limit.js';
import { bodyLimit } from './middleware/body-limit.js';
import { sanitize } from './middleware/sanitize.js';
import { contentTypeGuard } from './middleware/content-type-guard.js';
Comment on lines 12 to +16
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description mentions the Zod validation middleware being registered on /api/v1/*, but api/index.ts doesn’t import or apply validate(). If validation is intended as part of the global middleware stack, add it here (or update the PR description/docs to reflect that validation is route-specific).

Copilot uses AI. Check for mistakes.

/* ------------------------------------------------------------------ */
/* Bootstrap (runs once per cold-start) */
Expand Down Expand Up @@ -64,6 +68,32 @@ async function bootstrapKernel(): Promise<void> {
}),
);

// ── Body size limit (1 MB default) ────────────────────────
honoApp.use('/api/v1/*', bodyLimit({ maxSize: 1_048_576 }));

// ── Content-Type guard (mutation routes must send JSON) ──
honoApp.use(
'/api/v1/*',
contentTypeGuard({
excludePaths: ['/api/v1/storage/upload'],
}),
);

// ── XSS sanitization (strips HTML/script from JSON bodies) ──
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment says sanitization “strips HTML/script from JSON bodies”, but sanitize() actually HTML-entity encodes string values. Please update the comment to accurately describe the behavior (encoding vs stripping).

Suggested change
// ── XSS sanitization (strips HTML/script from JSON bodies) ──
// ── XSS sanitization (HTML-entity encodes HTML/script in JSON bodies) ──

Copilot uses AI. Check for mistakes.
honoApp.use('/api/v1/*', sanitize());

// ── Rate limiting — General API (100 req/min per IP) ─────
honoApp.use(
'/api/v1/*',
rateLimit({ windowMs: 60_000, maxRequests: 100 }),
);

// ── Rate limiting — Auth endpoints (10 req/min — brute-force protection) ──
honoApp.use(
'/api/v1/auth/*',
rateLimit({ windowMs: 60_000, maxRequests: 10 }),
);

// Health-check (always available)
honoApp.get('/api/v1/health', (c) =>
c.json({
Expand Down
34 changes: 34 additions & 0 deletions api/middleware/body-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Body Size Limit Middleware for Hono
*
* Rejects requests whose Content-Length exceeds the configured maximum.
*
* @module api/middleware/body-limit
* @see docs/guide/technical-debt-resolution.md — TD-4
*/
import type { MiddlewareHandler } from 'hono';

export interface BodyLimitConfig {
/** Maximum body size in bytes (default: 1 MB) */
maxSize?: number;
}

/**
* Creates a middleware that rejects requests with bodies larger than `maxSize`.
*
* Returns 413 Payload Too Large when the Content-Length header exceeds the limit.
*/
export function bodyLimit(config: BodyLimitConfig = {}): MiddlewareHandler {
const maxSize = config.maxSize ?? 1_048_576; // 1 MB

return async (c, next) => {
const contentLength = c.req.header('content-length');
if (contentLength && parseInt(contentLength, 10) > maxSize) {
return c.json(
{ error: 'Payload too large', maxSize },
413,
);
}
Comment on lines +25 to +31
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bodyLimit only enforces limits when Content-Length is present. Requests using chunked transfer encoding (or incorrect/missing Content-Length) can bypass this check. Consider enforcing the max size based on bytes actually read (or using a body parser limit that doesn’t rely on headers).

Suggested change
const contentLength = c.req.header('content-length');
if (contentLength && parseInt(contentLength, 10) > maxSize) {
return c.json(
{ error: 'Payload too large', maxSize },
413,
);
}
const rawReq = c.req.raw;
// Fast-path: honor Content-Length when present
const contentLength = rawReq.headers.get('content-length');
if (contentLength && parseInt(contentLength, 10) > maxSize) {
return c.json(
{ error: 'Payload too large', maxSize },
413,
);
}
// If there's no body, nothing to limit.
if (!rawReq.body) {
await next();
return;
}
// Enforce limit based on actual bytes read, regardless of transfer encoding.
const [limitStream, forwardStream] = rawReq.body.tee();
const reader = limitStream.getReader();
let totalBytes = 0;
// Read until stream ends or we exceed maxSize.
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (value) {
totalBytes += value.byteLength;
if (totalBytes > maxSize) {
// Exceeded limit: stop reading and reject.
reader.releaseLock();
return c.json(
{ error: 'Payload too large', maxSize },
413,
);
}
}
}
// Within limit: create a new Request with the untouched tee branch
// so downstream middleware/handlers can still consume the body.
const limitedReq = new Request(rawReq, { body: forwardStream });
// Hono's Context.req wraps the underlying Request; update its raw field.
// @ts-expect-error: accessing framework-internal property
(c.req as any).raw = limitedReq;

Copilot uses AI. Check for mistakes.
await next();
};
}
54 changes: 54 additions & 0 deletions api/middleware/content-type-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Content-Type Guard Middleware for Hono
*
* Rejects mutation requests (POST/PUT/PATCH) that do not carry an accepted
* Content-Type header. File-upload endpoints can be excluded via the
* `excludePaths` option.
*
* @module api/middleware/content-type-guard
* @see docs/guide/technical-debt-resolution.md — TD-4
*/
import type { MiddlewareHandler } from 'hono';

export interface ContentTypeGuardConfig {
/** Accepted content types (default: `['application/json']`) */
allowedTypes?: string[];
/** Path prefixes to exclude (e.g., file upload endpoints) */
excludePaths?: string[];
}

/**
* Creates a middleware that rejects mutation requests without an allowed
* Content-Type header.
*/
export function contentTypeGuard(
config: ContentTypeGuardConfig = {},
): MiddlewareHandler {
const allowedTypes = config.allowedTypes ?? ['application/json'];
const excludePaths = config.excludePaths ?? [];

return async (c, next) => {
if (['POST', 'PUT', 'PATCH'].includes(c.req.method)) {
const path = c.req.path;

// Skip excluded paths (e.g., file uploads)
if (excludePaths.some((prefix) => path.startsWith(prefix))) {
return next();
}

const contentType = c.req.header('content-type') ?? '';
const isAllowed = allowedTypes.some((t) => contentType.includes(t));

if (!isAllowed) {
return c.json(
{
error: 'Unsupported Media Type',
message: `Content-Type must be one of: ${allowedTypes.join(', ')}`,
},
415,
);
}
}
await next();
};
}
94 changes: 94 additions & 0 deletions api/middleware/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Rate Limiting Middleware for Hono
*
* Implements a sliding-window counter per key (IP or user).
* Adds standard X-RateLimit-* headers and returns 429 when exceeded.
*
* @module api/middleware/rate-limit
* @see docs/guide/technical-debt-resolution.md — TD-3
*/
import type { MiddlewareHandler, Context } from 'hono';

export interface RateLimitConfig {
/** Time window in milliseconds (default: 60_000 = 1 minute) */
windowMs?: number;
/** Maximum requests per window (default: 100) */
maxRequests?: number;
/** Custom key generator — defaults to IP address */
keyGenerator?: (c: Context) => string;
/** Skip counting requests that returned a successful (2xx) status */
skipSuccessfulRequests?: boolean;
/** Skip counting requests that returned a failed (non-2xx) status */
skipFailedRequests?: boolean;
Comment on lines +19 to +22
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RateLimitConfig exposes skipSuccessfulRequests / skipFailedRequests, but the middleware never reads these options. Either implement the behavior (by evaluating response status after next()) or remove the options to avoid a misleading API.

Copilot uses AI. Check for mistakes.
/** Custom handler for 429 responses */
handler?: MiddlewareHandler;
}

interface WindowEntry {
count: number;
resetAt: number;
}

/**
* Creates a Hono rate-limiting middleware using a sliding-window counter.
*
* Expired entries are garbage-collected periodically to prevent memory leaks.
*/
export function rateLimit(config: RateLimitConfig = {}): MiddlewareHandler {
const windowMs = config.windowMs ?? 60_000;
const maxRequests = config.maxRequests ?? 100;
const keyGenerator =
config.keyGenerator ??
((c: Context) =>
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ??
c.req.header('x-real-ip') ??
'unknown');

const store = new Map<string, WindowEntry>();

// Periodic cleanup of expired entries (every 60 s)
const cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [key, entry] of store) {
if (now > entry.resetAt) {
store.delete(key);
}
}
}, 60_000);

// Allow the timer to be garbage-collected when the process exits
if (cleanupInterval && typeof cleanupInterval === 'object' && 'unref' in cleanupInterval) {
(cleanupInterval as NodeJS.Timeout).unref();
}

return async (c, next) => {
const key = keyGenerator(c);
const now = Date.now();
let entry = store.get(key);

if (!entry || now > entry.resetAt) {
entry = { count: 1, resetAt: now + windowMs };
store.set(key, entry);
} else if (entry.count >= maxRequests) {
// Rate limit exceeded
c.header('X-RateLimit-Limit', String(maxRequests));
c.header('X-RateLimit-Remaining', '0');
c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)));
c.header('Retry-After', String(Math.ceil((entry.resetAt - now) / 1000)));

if (config.handler) {
return config.handler(c, next);
}
return c.json({ error: 'Too many requests' }, 429);
} else {
entry.count++;
}

// Set rate-limit headers on successful pass-through
c.header('X-RateLimit-Limit', String(maxRequests));
c.header('X-RateLimit-Remaining', String(Math.max(0, maxRequests - entry.count)));
c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)));

await next();
};
}
Loading
Loading