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
7 changes: 5 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ name: CI
on:
push:
branches: ["main"]

permissions:
contents: read
pull_request:
branches: ["main"]

Expand All @@ -11,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22

Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ SecureTaskOps exposes a dashboard and Node.js API that model workflow items, sco
- `POST /api/tasks` workflow item creation with validation
- `GET /api/release-readiness` release summary and status signal
- Risk scoring based on severity, blocked work, due date and security-sensitive changes
- Tests for validation, filtering, risk scoring and release readiness
- Domain and HTTP tests for validation, date boundaries, filtering, risk scoring, release readiness, response contracts and browser headers
- Dockerfile, Docker Compose, `.env.example`, CI workflow, changelog and security notes

## Architecture
Expand All @@ -56,6 +56,7 @@ Main modules:
- `src/domain.js` contains validation, risk scoring, filtering and release summary logic.
- `src/sample-data.js` provides deterministic sample workflow items.
- `test/domain.test.js` covers core logic and edge cases.
- `test/server.test.js` exercises health, filtering, item creation, malformed JSON and static response headers over HTTP.

## Local Setup

Expand Down Expand Up @@ -112,14 +113,17 @@ npm test
Covered:

- Required-field validation
- Strict date-only validation and reference-clock behavior
- Risk scoring boundaries
- Blocked and security-sensitive risk reasons
- Query-style filtering
- Release-readiness summary logic
- HTTP response contracts and error handling
- Restrictive static browser headers

Not covered yet:

- Browser UI tests
- Browser interaction tests in this repository; cross-project browser/API checks live in QA Automation Lab
- Persistent database integration
- Authentication and role-based authorization

Expand All @@ -140,7 +144,9 @@ The repository includes GitHub Actions for syntax checks and tests on pushes and
- Request JSON is validated before workflow items are created.
- `.env.example` documents configuration without committing secrets.
- The deployed demo does not yet include authentication, authorization, rate limiting, persistent storage, or audit logging.
- Future production hardening should add auth, RBAC, request size limits, schema validation, database migrations, structured logs, and deployment secrets management.
- Request bodies are capped at 64 KB and malformed JSON returns a bounded validation error without stack details.
- Static responses use a restrictive Content Security Policy; API responses are non-cacheable and use `nosniff` and no-referrer headers.
- Future production hardening should add auth, RBAC, database migrations, structured logs, rate limiting and deployment secrets management.

## Tradeoffs

Expand Down
4 changes: 3 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ SecureTaskOps is a public product demo. Security-sensitive claims are kept inten
- No secrets committed to the repository
- `.env.example` documents expected configuration
- Security-sensitive workflow items are surfaced in the risk model
- Request bodies are limited to 64 KB and malformed JSON receives a bounded validation error
- Static browser responses use a restrictive Content Security Policy
- API responses are non-cacheable and include `nosniff` and no-referrer headers
- Known limitations are documented in the README

## Not Implemented Yet
Expand All @@ -16,7 +19,6 @@ SecureTaskOps is a public product demo. Security-sensitive claims are kept inten
- Authorization and role-based access
- Persistent audit logs
- Rate limiting
- Request size limits
- Production secrets management

## Reporting Issues
Expand Down
20 changes: 18 additions & 2 deletions api/_response.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export function sendJson(response, statusCode, body) {
response.statusCode = statusCode;
response.setHeader("cache-control", "no-store");
response.setHeader("content-type", "application/json; charset=utf-8");
response.setHeader("x-content-type-options", "nosniff");
response.setHeader("referrer-policy", "no-referrer");
response.end(JSON.stringify(body, null, 2));
}

Expand All @@ -24,7 +26,21 @@ export function methodNotAllowed(response, methods) {
}

export async function readBody(request) {
if (request.body && typeof request.body === "object") return request.body;
if (typeof request.body === "string" && request.body.trim()) return JSON.parse(request.body);
if (request.body && typeof request.body === "object") {
if (Buffer.byteLength(JSON.stringify(request.body), "utf8") > 65_536) {
throw new ValidationError("Request body must not exceed 64 KB.");
}
return request.body;
}
if (typeof request.body === "string" && request.body.trim()) {
if (Buffer.byteLength(request.body, "utf8") > 65_536) {
throw new ValidationError("Request body must not exceed 64 KB.");
}
try {
return JSON.parse(request.body);
} catch {
throw new ValidationError("Request body must be valid JSON.");
}
}
return {};
}
5 changes: 3 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ SecureTaskOps is intentionally small so reviewers can inspect the complete syste

## Request Flow

1. A client calls `src/server.js`.
1. A client calls `src/server.js` locally or a handler in `api/` on Vercel.
2. The server maps the HTTP method and path to a route handler.
3. Route handlers call domain functions from `src/domain.js`.
4. Domain logic validates input, calculates risk, filters workflow items, or summarizes release readiness.
Expand All @@ -15,7 +15,8 @@ SecureTaskOps is intentionally small so reviewers can inspect the complete syste
- HTTP concerns stay in `src/server.js`.
- Business rules stay in `src/domain.js`.
- Sample data stays in `src/sample-data.js`.
- Tests exercise the domain layer directly because that is where the important logic lives.
- Domain tests exercise the business rules directly.
- HTTP tests exercise route behavior, error envelopes, cache policy and static security headers.

## Future Database Boundary

Expand Down
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 19 additions & 6 deletions src/domain.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const allowedStatuses = new Set(["todo", "in_progress", "blocked", "review", "do
const allowedSeverities = new Set(Object.keys(severityWeights));

export function createWorkflowItem(input, now = new Date()) {
const item = normalizeInput(input);
const item = normalizeInput(input, now);
const risk = calculateRisk(item, now);

return {
Expand Down Expand Up @@ -69,8 +69,11 @@ export function calculateRisk(item, now = new Date()) {
reasons.push("security-sensitive change requires extra review");
}

const dueSoon = daysUntil(item.dueDate, now) <= 3;
if (dueSoon && item.status !== "done") {
const dueInDays = daysUntil(item.dueDate, now);
if (dueInDays < 0 && item.status !== "done") {
score += 10;
reasons.push("open item is overdue");
} else if (dueInDays <= 3 && item.status !== "done") {
score += 10;
reasons.push("open item is due within three days");
}
Expand All @@ -83,7 +86,7 @@ export function calculateRisk(item, now = new Date()) {
};
}

function normalizeInput(input) {
function normalizeInput(input, now) {
if (!input || typeof input !== "object") {
throw new ValidationError("Request body must be a JSON object.");
}
Expand All @@ -92,13 +95,13 @@ function normalizeInput(input) {
const owner = cleanText(input.owner);
const status = input.status || "todo";
const severity = input.severity || "medium";
const dueDate = input.dueDate || new Date().toISOString().slice(0, 10);
const dueDate = input.dueDate || toDateOnly(now);

if (!title) throw new ValidationError("title is required.");
if (!owner) throw new ValidationError("owner is required.");
if (!allowedStatuses.has(status)) throw new ValidationError(`status must be one of: ${[...allowedStatuses].join(", ")}.`);
if (!allowedSeverities.has(severity)) throw new ValidationError(`severity must be one of: ${[...allowedSeverities].join(", ")}.`);
if (Number.isNaN(Date.parse(dueDate))) throw new ValidationError("dueDate must be a valid date.");
if (!isDateOnly(dueDate)) throw new ValidationError("dueDate must be a valid YYYY-MM-DD date.");

return {
id: typeof input.id === "string" ? input.id : undefined,
Expand Down Expand Up @@ -131,6 +134,16 @@ function daysUntil(dateString, now) {
return Math.ceil((target.getTime() - today.getTime()) / 86_400_000);
}

function isDateOnly(value) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const parsed = new Date(`${value}T00:00:00.000Z`);
return !Number.isNaN(parsed.getTime()) && toDateOnly(parsed) === value;
}

function toDateOnly(date) {
return date.toISOString().slice(0, 10);
}

function cleanText(value) {
return typeof value === "string" ? value.trim().slice(0, 160) : "";
}
Expand Down
22 changes: 17 additions & 5 deletions src/server.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { dirname, join, resolve } from "node:path";
import { URL } from "node:url";
import { fileURLToPath } from "node:url";
import { createWorkflowItem, filterItems, summarizeRelease, ValidationError } from "./domain.js";
Expand Down Expand Up @@ -49,7 +49,9 @@ export default async function handler(request, response) {

export const server = createServer(handler);

if (process.env.NODE_ENV !== "test") {
const isDirectExecution = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);

if (isDirectExecution) {
server.listen(port, () => {
console.log(`SecureTaskOps API running on http://localhost:${port}`);
});
Expand All @@ -70,7 +72,9 @@ function notFound() {
function send(response, statusCode, body) {
response.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store"
"cache-control": "no-store",
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer"
});
response.end(JSON.stringify(body, null, 2));
}
Expand All @@ -81,7 +85,10 @@ async function serveStatic(response, pathname) {
const contents = await readFile(filePath);
response.writeHead(200, {
"content-type": contentType(fileName),
"cache-control": fileName.endsWith(".html") ? "no-store" : "public, max-age=3600"
"cache-control": fileName.endsWith(".html") ? "no-store" : "public, max-age=3600",
"content-security-policy": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer"
});
response.end(contents);
}
Expand All @@ -98,7 +105,12 @@ function contentType(fileName) {

async function readJson(request) {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > 65_536) throw new ValidationError("Request body must not exceed 64 KB.");
chunks.push(chunk);
}
const raw = Buffer.concat(chunks).toString("utf8");
if (!raw.trim()) return {};
try {
Expand Down
36 changes: 36 additions & 0 deletions test/domain.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@ test("createWorkflowItem validates required fields", () => {
);
});

test("createWorkflowItem uses the supplied clock for its default due date", () => {
const item = createWorkflowItem({ title: "Clocked item", owner: "Uzair", severity: "low" }, now);

assert.equal(item.dueDate, "2026-07-05");
assert.match(item.riskReasons.join(" "), /due within three days/);
});

test("createWorkflowItem rejects impossible or non-date-only due dates", () => {
for (const dueDate of ["2026-02-30", "2026-07-05T10:00:00Z", "not-a-date"]) {
assert.throws(
() => createWorkflowItem({ title: "Invalid date", owner: "Uzair", dueDate }, now),
/valid YYYY-MM-DD date/
);
}
});

test("calculateRisk raises risk for blocked security-sensitive work due soon", () => {
const item = createWorkflowItem(
{
Expand Down Expand Up @@ -69,3 +85,23 @@ test("calculateRisk remains bounded", () => {

assert.equal(risk.score, 100);
});

test("calculateRisk distinguishes overdue work from the due-soon boundary", () => {
const overdue = calculateRisk(
{ status: "todo", severity: "low", dueDate: "2026-07-04", securitySensitive: false },
now
);
const dueInFourDays = calculateRisk(
{ status: "todo", severity: "low", dueDate: "2026-07-09", securitySensitive: false },
now
);
const completed = calculateRisk(
{ status: "done", severity: "low", dueDate: "2026-07-04", securitySensitive: false },
now
);

assert.match(overdue.reasons.join(" "), /overdue/);
assert.equal(overdue.score, 20);
assert.equal(dueInFourDays.score, 10);
assert.equal(completed.score, 10);
});
Loading