From 96f5cfdb6d3a02207c534ede5de92e6ed420394c Mon Sep 17 00:00:00 2001 From: Bit Cloud Labs Date: Sat, 27 Jun 2026 09:21:09 +0000 Subject: [PATCH] feat: convert Module 06 to interactive autograded starter workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the LMS-duplicated module into a work-along starter workspace mirroring the approved Module 04 template: - One root toolchain: package.json (typescript, vitest, @types/node), strict tsconfig, vitest config, scripts/grade.mjs, Autograde CI workflow. - 9 self-contained labs + capstone, each with README brief, src/ starter (// TODOs), and tests/ that ARE the spec. No answer keys shipped. - Exercises test pure handler/logic (REST routing, service+repository, boundary validation, scrypt/HMAC auth, role+ownership authz, error envelope/pagination/idempotency/rate-limit, retry queue/DLQ, ops readiness) — no real server or network required. - Capstone integrates the module into one strict module with behaviour and type-level (expectTypeOf) tests. - Remove Lesson_*/guide/syllabus and flat lab/assignment markdown. Verified: stubs grade 20/89 (22%, RED); reference solution 89/89 (100%, type-check clean, GREEN). --- .devcontainer/devcontainer.json | 4 +- .github/workflows/autograde.yml | 60 + .gitignore | 4 + LEARNER_GUIDE.md | 37 - Lesson_00.md | 102 -- Lesson_01.md | 102 -- Lesson_02.md | 107 -- Lesson_03.md | 99 -- Lesson_04.md | 117 -- Lesson_05.md | 108 -- Lesson_06.md | 116 -- Lesson_07.md | 103 -- Lesson_08.md | 113 -- Lesson_09.md | 90 - MODULE_SYLLABUS.md | 54 - README.md | 112 +- assignments/README.md | 19 - assignments/capstone-brief.md | 68 - assignments/capstone/README.md | 40 + assignments/capstone/src/forge.ts | 189 +++ assignments/capstone/tests/forge.test-d.ts | 10 + assignments/capstone/tests/forge.test.ts | 128 ++ labs/README.md | 41 - labs/lab-00-setup.md | 69 - labs/lab-00-setup/README.md | 23 + labs/lab-00-setup/src/health.ts | 27 + labs/lab-00-setup/tests/health.test.ts | 26 + labs/lab-01-rest-api.md | 62 - labs/lab-01-rest-api/README.md | 26 + labs/lab-01-rest-api/src/orders-api.ts | 100 ++ labs/lab-01-rest-api/tests/orders-api.test.ts | 61 + labs/lab-02-service-layer.md | 61 - labs/lab-02-service-layer/README.md | 27 + .../src/orders-service.ts | 94 ++ .../tests/orders-service.test.ts | 90 + labs/lab-03-validation.md | 52 - labs/lab-03-validation/README.md | 29 + labs/lab-03-validation/src/validation.ts | 41 + .../tests/validation.test.ts | 58 + labs/lab-04-auth.md | 66 - labs/lab-04-auth/README.md | 26 + labs/lab-04-auth/src/auth.ts | 74 + labs/lab-04-auth/tests/auth.test.ts | 80 + labs/lab-05-authz.md | 50 - labs/lab-05-authz/README.md | 27 + labs/lab-05-authz/src/authz.ts | 65 + labs/lab-05-authz/tests/authz.test.ts | 65 + labs/lab-06-api-platform.md | 65 - labs/lab-06-api-platform/README.md | 27 + labs/lab-06-api-platform/src/platform.ts | 64 + .../tests/platform.test.ts | 73 + labs/lab-07-jobs.md | 61 - labs/lab-07-jobs/README.md | 26 + labs/lab-07-jobs/src/jobs.ts | 76 + labs/lab-07-jobs/tests/jobs.test.ts | 61 + labs/lab-08-ops.md | 58 - labs/lab-08-ops/README.md | 26 + labs/lab-08-ops/src/ops.ts | 57 + labs/lab-08-ops/tests/ops.test.ts | 66 + package-lock.json | 1454 +++++++++++++++++ package.json | 19 + scripts/grade.mjs | 87 + tsconfig.json | 16 + vitest.config.ts | 12 + 64 files changed, 3486 insertions(+), 1884 deletions(-) create mode 100644 .github/workflows/autograde.yml delete mode 100644 LEARNER_GUIDE.md delete mode 100644 Lesson_00.md delete mode 100644 Lesson_01.md delete mode 100644 Lesson_02.md delete mode 100644 Lesson_03.md delete mode 100644 Lesson_04.md delete mode 100644 Lesson_05.md delete mode 100644 Lesson_06.md delete mode 100644 Lesson_07.md delete mode 100644 Lesson_08.md delete mode 100644 Lesson_09.md delete mode 100644 MODULE_SYLLABUS.md delete mode 100644 assignments/README.md delete mode 100644 assignments/capstone-brief.md create mode 100644 assignments/capstone/README.md create mode 100644 assignments/capstone/src/forge.ts create mode 100644 assignments/capstone/tests/forge.test-d.ts create mode 100644 assignments/capstone/tests/forge.test.ts delete mode 100644 labs/README.md delete mode 100644 labs/lab-00-setup.md create mode 100644 labs/lab-00-setup/README.md create mode 100644 labs/lab-00-setup/src/health.ts create mode 100644 labs/lab-00-setup/tests/health.test.ts delete mode 100644 labs/lab-01-rest-api.md create mode 100644 labs/lab-01-rest-api/README.md create mode 100644 labs/lab-01-rest-api/src/orders-api.ts create mode 100644 labs/lab-01-rest-api/tests/orders-api.test.ts delete mode 100644 labs/lab-02-service-layer.md create mode 100644 labs/lab-02-service-layer/README.md create mode 100644 labs/lab-02-service-layer/src/orders-service.ts create mode 100644 labs/lab-02-service-layer/tests/orders-service.test.ts delete mode 100644 labs/lab-03-validation.md create mode 100644 labs/lab-03-validation/README.md create mode 100644 labs/lab-03-validation/src/validation.ts create mode 100644 labs/lab-03-validation/tests/validation.test.ts delete mode 100644 labs/lab-04-auth.md create mode 100644 labs/lab-04-auth/README.md create mode 100644 labs/lab-04-auth/src/auth.ts create mode 100644 labs/lab-04-auth/tests/auth.test.ts delete mode 100644 labs/lab-05-authz.md create mode 100644 labs/lab-05-authz/README.md create mode 100644 labs/lab-05-authz/src/authz.ts create mode 100644 labs/lab-05-authz/tests/authz.test.ts delete mode 100644 labs/lab-06-api-platform.md create mode 100644 labs/lab-06-api-platform/README.md create mode 100644 labs/lab-06-api-platform/src/platform.ts create mode 100644 labs/lab-06-api-platform/tests/platform.test.ts delete mode 100644 labs/lab-07-jobs.md create mode 100644 labs/lab-07-jobs/README.md create mode 100644 labs/lab-07-jobs/src/jobs.ts create mode 100644 labs/lab-07-jobs/tests/jobs.test.ts delete mode 100644 labs/lab-08-ops.md create mode 100644 labs/lab-08-ops/README.md create mode 100644 labs/lab-08-ops/src/ops.ts create mode 100644 labs/lab-08-ops/tests/ops.test.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/grade.mjs create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4c473be..3646b6a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,7 @@ // Dev container for the Forge SWEXP starter repo. // Open in GitHub Codespaces (Code -> Codespaces -> Create) or VS Code Dev Containers // for a zero-setup environment with Git, the right runtime, and the gh CLI preinstalled. -// Then follow README.md / Lesson_00.md to pick up your first ticket. +// Then follow README.md to pick up your first exercise. { "name": "SWEXP 06 Backend Engineering API Design", "image": "mcr.microsoft.com/devcontainers/javascript-node:20", @@ -12,7 +12,7 @@ "forwardPorts": [ 3000 ], - "postCreateCommand": "echo '\\n=== Forge SWEXP environment ready ==='; git --version; node --version 2>/dev/null; echo 'Open README.md, then Lesson_00.md to begin.'", + "postCreateCommand": "npm install; echo '\\n=== Forge SWEXP environment ready ==='; git --version; node --version 2>/dev/null; echo 'Open README.md to begin.'", "customizations": { "vscode": { "extensions": [ diff --git a/.github/workflows/autograde.yml b/.github/workflows/autograde.yml new file mode 100644 index 0000000..6da497e --- /dev/null +++ b/.github/workflows/autograde.yml @@ -0,0 +1,60 @@ +name: Autograde + +on: + push: + branches: ["**"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + grade: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run autograder + id: grade + continue-on-error: true + run: npm run grade + + - name: Publish score to job summary + if: always() + run: cat grade-report.md >> "$GITHUB_STEP_SUMMARY" || true + + - name: Comment score on pull request + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const marker = ''; + let body = marker + '\n'; + try { body += fs.readFileSync('grade-report.md', 'utf8'); } + catch { body += 'Autograder did not produce a report.'; } + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } + + - name: Fail the check if incomplete + if: steps.grade.outcome != 'success' + run: | + echo "Exercises are not yet complete — see the autograde summary above." + exit 1 diff --git a/.gitignore b/.gitignore index 646ac51..f9bd586 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ .DS_Store node_modules/ +.grade/ +grade-report.md +dist/ +coverage/ diff --git a/LEARNER_GUIDE.md b/LEARNER_GUIDE.md deleted file mode 100644 index c01a199..0000000 --- a/LEARNER_GUIDE.md +++ /dev/null @@ -1,37 +0,0 @@ -# Learner Guide — Backend Engineering & API Design - -## You are a backend engineer shipping a platform -Every lesson is an **engineering ticket** on *Project Forge*. Approach each as real work: understand what's being asked, build it in layers with clear contracts, validate the untrusted boundary, make security fail safe, type-check the contracts, test the logic, exercise the endpoints, and document your reasoning. The goal isn't memorizing a framework — it's the architecture, security, and judgment to ship a backend a team can put on call. - -## The ideas that matter most -- **The boundary is untrusted.** Validate every request before any logic runs; never trust `req.body`. -- **Layers with clear contracts.** Controller (HTTP) / service (logic, no HTTP/DB) / repository (data behind an interface). -- **Make illegal states unrepresentable** (from Module 04): typed domain, literal unions, domain errors mapped to status codes in one place. -- **Fail safe, not open; least privilege.** Security defaults to deny; an unverified token is no identity. -- **Measure first, design for failure** (from Module 03): health/readiness, retries/backoff, dead-lettering, graceful shutdown. - -## How each lesson works -1. **Read the ticket and the deep dive.** -2. **Do the lab.** Build it, **predict** the type-check/test/status-code result, then verify. -3. **Investigate** — push from "it returns 200" to "I can show the bad input rejected before the service, the token failing safe, the 403-vs-401, the idempotent retry acting once, the readiness flipping when a dependency dies." -4. **Run the AI exercise** — draft → verify → log, deliberately. -5. **Submit the assignment** and **update your notebook.** -6. **Check the solution** to validate your reasoning — after you've done the work. - -Track progress in `dashboard.html`. - -## What every assignment must include -- **What you built** and *why this shape* — which layer holds the logic, how the boundary is validated, how security fails safe, what's idempotent. -- **Evidence:** the type-check result and confirmed contract errors; Node test output (logic + auth via real crypto); endpoint request/response transcripts (status codes, idempotency, 401/403, 429, readiness). -- **The fix at the cause** — no `any`, no silenced errors, no trusting input. -- **AI-usage log:** draft → verify → log. -- **Clean commits** (Module 02 habits). - -## Using AI responsibly -AI drafts fast and confidently, and is sometimes wrong in ways that are *dangerous* on a backend — trusting input, fast-hashing passwords, trusting a token before verifying, allow-by-default authorization. You have three mechanical checks: the type-checker, Node tests (auth with real crypto), and real requests. `resources/ai-workflow-guide.md` maps the failure modes. - -## The standard -A contract that doesn't type-check isn't done; a security claim without a test isn't proven; an endpoint behavior without a real request isn't verified. The compiler, the tests, and the requests are the arbiters — not confidence. **Fail safe, not open**, and prove it. - -## How you're graded -Against `ASSESSMENT_RUBRIC.md` — on architecture, security, API platform quality, data, background processing, and operations, with evidence. An endpoint that returns 200 but trusts its input, fast-hashes passwords, or authorizes by default scores poorly regardless of the happy path. diff --git a/Lesson_00.md b/Lesson_00.md deleted file mode 100644 index b6fc681..0000000 --- a/Lesson_00.md +++ /dev/null @@ -1,102 +0,0 @@ -# Lesson 00 — Welcome to the Backend Engineering Team - -> **Role:** Backend Software Engineer · **Competency:** Backend Orientation · **Track:** BE · **Est. time:** 2–3 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: BE-1000 -TITLE: Onboard to the Project Forge backend platform -PRIORITY: P1 — blocks all backend work -TYPE: Onboarding -ASSIGNEE: You (Backend Software Engineer, Platform Team) -DESCRIPTION: The Forge frontend (Module 05) currently runs against a mock API at - api.forge.dev. The team is now building the real backend that powers - it. Set up the toolchain, understand the request/response lifecycle - and where backend code sits, and stand up a minimal HTTP service you - can build on for the rest of the module. - -ACCEPTANCE CRITERIA: - - Node + TypeScript toolchain runs; a minimal HTTP service responds locally - - You can explain the request → handler → response lifecycle - - You can describe what lives on the backend vs the frontend, and why - - You understand how this module's pieces fit a production platform - - Your engineering notebook has a dated first entry -``` - -## 🏢 Business Context - -The frontend is only half the product. Behind every screen sits a backend that owns the data, enforces the rules, authenticates users, and stays up under load — the things you *cannot* trust a browser to do. In Module 05 the frontend talked to a mock; now you build the real service it depends on. A backend is where correctness, security, and reliability are decided, so the discipline you bring here determines whether Forge is a demo or a product. - -## 🎯 Learning Objectives - -- Set up a Node + TypeScript backend project and run an HTTP service -- Explain the request → handler → response lifecycle -- Articulate the backend/frontend split and why certain work must be server-side -- Map the module's arc (REST → architecture → validation → auth → platform → ops → release) - -## 📚 Technical Deep Dive - -**A backend is a function of requests.** At its core, an HTTP service receives a request (method, path, headers, body), does work, and returns a response (status code, headers, body). Everything else — frameworks, routers, middleware — is structure around that loop. - -```ts -import http from 'node:http'; -const server = http.createServer((req, res) => { - if (req.method === 'GET' && req.url === '/health') { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok' })); - return; - } - res.writeHead(404, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ error: 'not found' })); -}); -server.listen(3000); -``` - -**Why work belongs on the server.** The browser is untrusted: anything it enforces, a user can bypass. Authentication, authorization, validation, business rules, and data access live on the backend because that's the only place they can't be tampered with. (This is the *boundary is untrusted* discipline from Modules 04–05, now from the other side.) - -**TypeScript on the backend.** The same type safety from Module 04 applies — typed request/response shapes, typed domain models, illegal states unrepresentable. The compiler is still your first reviewer. - -**The module arc.** You'll replace the mock with real REST endpoints, organize them into layers, validate input, authenticate and authorize requests, harden the API into a platform (errors, pagination, rate limits), move slow work to background jobs, make it operationally ready (health, logging, graceful shutdown), and ship it. - -### Common gotchas -- Treating the backend like the frontend — trusting input, putting rules in the client. -- Forgetting that every response needs a deliberate status code and content type. -- Skipping types "because it's just an API" — typed contracts matter most at the boundary. - -## 🧪 Hands-on Labs - -Work through **`labs/lab-00-setup.md`**: set up the Node + TypeScript toolchain, stand up a minimal HTTP service with a `/health` endpoint, type-check it, and verify it responds correctly by making a real request to it. - -## 🔍 Engineering Investigation - -Start your service and hit `/health` and an unknown path. Record the exact status codes, headers, and bodies. In your notebook, trace one request through the lifecycle (received → matched → handled → responded) and note which decisions (status, content type) you made explicitly. - -## 🤖 AI Engineering Exercise - -Ask an AI to "set up a basic Node API." **Draft** it, then **verify**: does it type-check, return deliberate status codes, and keep server-only concerns on the server? **Log** what you kept and corrected. The loop all module: **draft → verify (type-check + run/test + measure) → log.** - -## 📝 Assignment - -1. Stand up the service; paste `node --version` and a real request/response to `/health`. -2. Complete the lab; include the type-check result and the responses for a known and unknown path. -3. Write a 5–8 sentence explainer: "what belongs on the backend vs the frontend, and why." -4. Commit your notebook. - -## 🚀 Stretch Goal - -Add a second route and a tiny router (a map of `method + path` → handler) instead of an `if` ladder. Note how this sets up the controller structure you'll build in Lesson 1. - -## ✅ Definition of Done - -- [ ] Node + TypeScript service runs locally -- [ ] `/health` returns a deliberate 200 JSON response; unknown paths return 404 -- [ ] Type-check is clean -- [ ] Backend-vs-frontend explainer written -- [ ] Notebook committed - -## 🪞 Reflection - -What must live on the backend that a browser can never be trusted to do? Where do you expect the request/response discipline to matter most as Forge grows? diff --git a/Lesson_01.md b/Lesson_01.md deleted file mode 100644 index 1a3ddbd..0000000 --- a/Lesson_01.md +++ /dev/null @@ -1,102 +0,0 @@ -# Lesson 01 — Replace the Mock API - -> **Role:** Backend Software Engineer · **Competency:** REST APIs & Controllers · **Track:** API · **Est. time:** 3–4 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: API-1010 -TITLE: The frontend runs on a mock; build the real Orders REST API -PRIORITY: P1 -TYPE: Feature -DESCRIPTION: Module 05's frontend reads orders from a mock at api.forge.dev. - Build the real REST API: resource-oriented routes, correct HTTP - methods and status codes, typed request/response shapes, and a - controller layer that maps requests to responses. The endpoints must - match what the frontend's typed data layer expects. - -ACCEPTANCE CRITERIA: - - Resource-oriented routes (/orders, /orders/:id) with correct HTTP methods - - Correct status codes (200/201/204/400/404/…) for each outcome - - Typed request and response bodies; JSON content types - - A controller layer maps requests to responses (no logic sprawl in the router) -``` - -## 🏢 Business Context - -REST is the contract between frontend and backend. When it's consistent — predictable URLs, the right verbs, honest status codes — the frontend team can build against it without surprises, and tools, caches, and proxies behave correctly. When it's ad-hoc, every integration is a negotiation. Replacing the mock with a real, well-shaped API is what turns Forge from a prototype into a system other teams can rely on. - -## 🎯 Learning Objectives - -- Design resource-oriented routes and choose correct HTTP methods -- Return honest status codes for each outcome -- Type request and response bodies; set JSON content types -- Separate routing from handling with a controller layer - -## 📚 Technical Deep Dive - -**Resources, not actions.** REST models *nouns* (resources) acted on by HTTP *verbs*: - -| Method | Path | Meaning | Success | -|--------|------|---------|---------| -| GET | `/orders` | list orders | 200 | -| GET | `/orders/:id` | fetch one | 200 (404 if absent) | -| POST | `/orders` | create | 201 (+ `Location`) | -| PUT/PATCH | `/orders/:id` | replace/update | 200 | -| DELETE | `/orders/:id` | delete | 204 | - -Avoid `GET /getOrders` or `POST /orders/delete` — the verb is the method, the URL is the resource. - -**Status codes are part of the contract.** They tell the client what happened without parsing the body: `200` ok, `201` created, `204` no content, `400` bad request, `401` unauthenticated, `403` forbidden, `404` not found, `409` conflict, `422` unprocessable, `500` server error. Returning `200` with `{ error: ... }` lies to every client and cache. - -**A controller maps request → response.** Keep the router thin (match route → call controller); the controller reads the request, calls into the (coming-in-Lesson-2) service layer, and shapes the response: - -```ts -async function getOrder(req: ApiRequest, params: { id: string }): Promise { - const order = await orders.find(params.id); // service (Lesson 2) - if (!order) return { status: 404, body: { error: 'order not found' } }; - return { status: 200, body: order }; -} -``` - -**Typed request/response shapes.** Model the API contract in types so the compiler enforces it — the same domain modeling from Module 04, now describing the wire format the frontend consumes. - -### Common gotchas -- Verbs in URLs (`/createOrder`) instead of methods on resources. -- Lying status codes (200 for errors; 200 for a created resource instead of 201). -- Business logic crammed into the router; no controller seam. -- Untyped bodies — the boundary is exactly where types matter most. - -## 🧪 Hands-on Labs - -Work through **`labs/lab-01-rest-api.md`**. You'll build the Orders REST API with typed controllers and a tiny router, then verify it end-to-end: a real in-process server is started and hit with `fetch`, asserting the routes, methods, and status codes (200 list, 201 create with `Location`, 404 missing, 400 bad body) behave correctly. - -## 🔍 Engineering Investigation - -List every endpoint the Module 05 frontend's data layer expects and map each to a method + path + status codes. After building, exercise each with real requests and record the status/body for the success and failure cases. Note any place you were tempted to use a verb in a URL and why the resource model is better. - -## 🤖 AI Engineering Exercise - -Ask an AI to "build CRUD endpoints for orders." **Verify** the routes are resource-oriented, status codes are honest (201 on create, 404 on missing), and bodies are typed. **Log** where it used a verb-in-URL or a lying status code and how you corrected it. - -## 📝 Assignment - -Submit the Orders REST API: typed controllers + router, the endpoint→method→status mapping, and the passing end-to-end request/response evidence for each success and failure case. - -## 🚀 Stretch Goal - -Add content negotiation or a `HEAD`/`OPTIONS` handler, or support conditional requests (`ETag`/`If-None-Match`) for `GET /orders/:id`, and explain what it buys clients and caches. - -## ✅ Definition of Done - -- [ ] Resource-oriented routes with correct methods -- [ ] Honest status codes for each outcome -- [ ] Typed request/response bodies; JSON content types -- [ ] Router thin; controllers map request → response -- [ ] End-to-end requests pass for success and failure cases - -## 🪞 Reflection - -Which status code were you most tempted to get lazy about, and what would a wrong one cost a client? How does the controller seam make the next lessons (services, validation, auth) easier to add? diff --git a/Lesson_02.md b/Lesson_02.md deleted file mode 100644 index 601495b..0000000 --- a/Lesson_02.md +++ /dev/null @@ -1,107 +0,0 @@ -# Lesson 02 — Organize the Service Layer - -> **Role:** Backend Software Engineer · **Competency:** Layered Architecture · **Track:** ARCH · **Est. time:** 3–4 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: ARCH-2001 -TITLE: The controllers are doing everything; introduce layers -PRIORITY: P1 -TYPE: Refactor / Architecture -DESCRIPTION: Order logic, data access, and HTTP handling are tangled inside the - controllers. Introduce a layered architecture — controller → service - → repository — with clear contracts between layers, so business logic - is testable in isolation and the data source can change without - touching HTTP code. - -ACCEPTANCE CRITERIA: - - Three layers with clear responsibilities: controller, service, repository - - Business logic lives in services and is testable without HTTP - - Data access is behind a repository interface (swappable implementation) - - Dependencies point inward; layers depend on contracts, not concretions -``` - -## 🏢 Business Context - -A backend that mixes HTTP parsing, business rules, and SQL in one function is impossible to test, reason about, or change. Layering separates *what the system does* (services) from *how it talks to the world* (controllers) and *how it stores data* (repositories). This is the backend version of componentizing the UI (Module 05): small pieces with clear contracts. It's what lets you unit-test business logic without a server or database, and swap Postgres for an in-memory store in tests. - -## 🎯 Learning Objectives - -- Separate controller, service, and repository responsibilities -- Put business logic in services that are testable without HTTP -- Define a repository interface and depend on it, not a concrete store -- Keep dependencies pointing inward (toward the domain) - -## 📚 Technical Deep Dive - -**Three layers, three jobs.** -- **Controller** — translate HTTP ↔ domain. Read the request, call a service, shape the response and status. No business rules. -- **Service** — the business logic. Pure-ish functions over the domain; no knowledge of HTTP or the database. -- **Repository** — data access behind an interface. The service calls `orders.find(id)`; whether that's SQL, an API, or a `Map` is the repository's secret. - -```ts -interface OrderRepository { - find(id: string): Promise; - list(): Promise; - save(order: Order): Promise; -} - -class OrderService { - constructor(private repo: OrderRepository) {} - async markPaid(id: string): Promise { - const order = await this.repo.find(id); - if (!order) throw new NotFoundError('order', id); - if (order.status === 'cancelled') throw new ConflictError('cannot pay a cancelled order'); - const updated = { ...order, status: 'paid' as const }; - await this.repo.save(updated); - return updated; - } -} -``` - -**Dependencies point inward.** The service depends on the `OrderRepository` *interface*, not a concrete database class. You inject the implementation (dependency injection), so tests pass an in-memory repo and production passes the real one. The domain doesn't know or care. - -**Why this is testable.** `OrderService.markPaid` can be unit-tested with a fake repository — no server, no DB — asserting the business rules (can't pay a cancelled order → conflict; missing → not found). The controller just maps those outcomes to status codes. - -**Errors as domain types.** Throwing typed domain errors (`NotFoundError`, `ConflictError`) lets the controller map them to status codes in one place, instead of returning status codes from deep in the logic. - -### Common gotchas -- A "service" that still imports the HTTP request or the DB driver — leaky layers. -- Repositories that return HTTP-shaped data instead of domain types. -- Business rules in the controller (can't reuse, hard to test). -- Circular dependencies / dependencies pointing outward (domain importing the web framework). - -## 🧪 Hands-on Labs - -Work through **`labs/lab-02-service-layer.md`**. You'll refactor the Lesson 1 controllers into controller → `OrderService` → `OrderRepository` layers with an in-memory repo, and unit-test the service's business rules in Node (mark-paid succeeds, paying a cancelled order is a conflict, a missing order is not-found) — entirely without HTTP. The layer contracts type-check. - -## 🔍 Engineering Investigation - -Take one tangled controller and identify each line's true layer (HTTP / business / data). After refactoring, confirm the service has zero HTTP/DB imports and the controller has zero business rules. Swap the in-memory repo for a different fake and confirm the service tests still pass unchanged — evidence the contract holds. - -## 🤖 AI Engineering Exercise - -Ask an AI to "refactor this controller into layers." **Verify** the service has no HTTP/DB dependencies, the repository is an interface, and business rules moved out of the controller. **Log** where the AI left a leaky layer and how you fixed it. - -## 📝 Assignment - -Submit the layered Orders module: the controller/service/repository split, the repository interface, passing Node unit tests of the service's business rules (no HTTP/DB), and a note proving the service is free of HTTP/DB imports. - -## 🚀 Stretch Goal - -Add a second repository implementation (e.g. a file-backed store) and run the *same* service tests against both, demonstrating the interface is a true seam. - -## ✅ Definition of Done - -- [ ] Controller / service / repository layers with clear responsibilities -- [ ] Business logic in services, testable without HTTP -- [ ] Data access behind a repository interface -- [ ] Dependencies point inward; no leaky layers -- [ ] Service unit tests pass; contracts type-check - -## 🪞 Reflection - -Which logic was hardest to pull out of the controller, and why? How does depending on a repository *interface* change what you can do in tests? diff --git a/Lesson_03.md b/Lesson_03.md deleted file mode 100644 index 9eb1241..0000000 --- a/Lesson_03.md +++ /dev/null @@ -1,99 +0,0 @@ -# Lesson 03 — Stop Invalid Requests at the Door - -> **Role:** Backend Software Engineer · **Competency:** Validation · **Track:** VAL · **Est. time:** 3–4 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: VAL-2010 -TITLE: The API trusts whatever it's sent; validate at the boundary -PRIORITY: P1 — security & correctness -TYPE: Feature / Bug -DESCRIPTION: Endpoints currently parse request bodies and use them directly, - trusting clients to send well-formed data. Validate every request at - the boundary against a schema, reject bad input with a clear 400, and - pass only typed, trusted data into the service layer. - -ACCEPTANCE CRITERIA: - - Every request body/params/query validated against a schema at the boundary - - Invalid input is rejected with 400 and a clear, structured error - - Only validated, typed data reaches the service layer - - Validation is centralized (not scattered ad-hoc checks) -``` - -## 🏢 Business Context - -The boundary is untrusted — the lesson you learned validating API responses in Module 05, now applied to *incoming* requests. A backend that trusts its input is one malformed payload away from a corrupted database, a crash, or a security hole. Validating at the door means every request is either rejected with a clear error or proven well-formed before any business logic runs. It's the single highest-leverage correctness-and-security practice in an API. - -## 🎯 Learning Objectives - -- Validate request bodies, params, and query against a schema at the boundary -- Reject invalid input with a 400 and a structured, useful error -- Pass only validated, typed data into services -- Centralize validation instead of scattering ad-hoc checks - -## 📚 Technical Deep Dive - -**Parse, don't trust.** Treat the request body as `unknown` until proven (Module 04's discipline). A schema turns `unknown` into a typed value or an error: - -```ts -const CreateOrder = z.object({ - customer: z.string().min(1), - total: z.number().positive(), - status: z.enum(['placed', 'paid', 'shipped', 'cancelled']), -}); -type CreateOrder = z.infer; // type derived from the schema - -const parsed = CreateOrder.safeParse(req.body); -if (!parsed.success) return { status: 400, body: { error: 'validation_failed', issues: parsed.error.issues } }; -const order: CreateOrder = parsed.data; // typed & trusted from here -``` -(A hand-written validator returning `{ ok: true, value } | { ok: false, errors }` works the same way — the point is a single function that *narrows* unknown input to a trusted type.) - -**Validate everything that crosses the boundary:** body, path params (`:id` format), query (pagination, filters), and headers you depend on. Reject early, before any service call. - -**A useful 400.** Tell the client *what* was wrong: which fields, which rules. A bare `400 Bad Request` forces guesswork; a structured `{ error: 'validation_failed', issues: [...] }` lets them fix it. - -**Validation vs business rules.** Validation answers "is this request well-formed?" (a 400 — *400 = your request is malformed*). Business rules answer "is this operation allowed right now?" (often a 409/422 from the service — e.g. can't pay a cancelled order). Keep them distinct: schema at the boundary, rules in the service. - -**Centralize it.** A validation middleware/wrapper that runs the schema and either rejects or passes typed data into the controller keeps every endpoint consistent and the controllers clean. - -### Common gotchas -- Using `req.body` directly (trusting the client) — the boundary bug. -- `as` casting the body to a type instead of validating (an unchecked claim, Module 04). -- Vague 400s with no field-level detail. -- Conflating validation (400) with business rules (409/422) — or doing business checks before validating. - -## 🧪 Hands-on Labs - -Work through **`labs/lab-03-validation.md`**. You'll add boundary validation to the create/update Order endpoints with a schema, return structured 400s for bad input, and pass only typed data to the service. The validators are unit-tested in Node (valid input narrows to a typed value; each bad field is rejected with a useful error) and the endpoint is exercised end-to-end (a malformed body → 400 with issues; a valid body → 201). - -## 🔍 Engineering Investigation - -Send several malformed requests (missing field, wrong type, out-of-range, extra fields) and record the exact 400 responses. Confirm none of them reached the service (add a log/throw in the service to prove it). Then send a valid request and confirm it passes. Note one bug that would have happened downstream if the bad input had been trusted. - -## 🤖 AI Engineering Exercise - -Ask an AI to "validate this endpoint's input." **Verify** it validates at the boundary (not deep in the service), returns a structured 400, narrows to a typed value (no `as`), and distinguishes validation from business rules. **Log** where it trusted `req.body` or cast it and your fix. - -## 📝 Assignment - -Submit: the schema-validated endpoints, the structured 400 responses, passing Node tests of the validators, end-to-end evidence that bad input is rejected (and never reaches the service) while valid input succeeds, and a note distinguishing one validation failure (400) from one business-rule failure (409/422). - -## 🚀 Stretch Goal - -Add request-size limits and reject unknown/extra fields (strict schemas), and explain how each closes a specific abuse or bug vector. - -## ✅ Definition of Done - -- [ ] Every boundary input validated against a schema -- [ ] Invalid input rejected with a structured 400 -- [ ] Only validated, typed data reaches services -- [ ] Validation centralized; distinct from business rules -- [ ] Validators unit-tested; endpoint verified end-to-end - -## 🪞 Reflection - -Which malformed input would have done the most damage if trusted? Where's the line between a 400 (validation) and a 409/422 (business rule), and why does keeping them separate matter? diff --git a/Lesson_04.md b/Lesson_04.md deleted file mode 100644 index b209c33..0000000 --- a/Lesson_04.md +++ /dev/null @@ -1,117 +0,0 @@ -# Lesson 04 — Authenticate Every Request - -> **Role:** Backend Software Engineer · **Competency:** Authentication · **Track:** AUTH · **Est. time:** 4 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: AUTH-3001 -TITLE: Anyone can call the API as anyone; add authentication -PRIORITY: P0 — security -TYPE: Feature -DESCRIPTION: The Orders API has no notion of who is calling. Add authentication: - securely store passwords, issue a signed token on login, and verify - that token on every protected request so the service knows the - authenticated identity. Never store plaintext passwords or trust an - unverified token. - -ACCEPTANCE CRITERIA: - - Passwords stored using a salted, slow hash (never plaintext/fast hash) - - Login verifies credentials and issues a signed token - - Protected endpoints verify the token and attach the identity to the request - - Tampered, expired, or missing tokens are rejected with 401 -``` - -## 🏢 Business Context - -Authentication answers "who is making this request?" — the foundation everything else (authorization, auditing, rate limits per user) builds on. Get it wrong and the consequences are catastrophic and irreversible: leaked password databases, forged identities, account takeover. This is security-critical code where the rule is **fail safe, not open**: an unverified token is no identity, not a trusted one. You'll use real cryptographic primitives, because rolling your own is how breaches happen. - -## 🎯 Learning Objectives - -- Store passwords with a salted, slow hash and verify them in constant time -- Issue a signed token on successful login -- Verify the token on protected requests and attach the identity -- Reject tampered, expired, or missing tokens with 401 - -## 📚 Technical Deep Dive - -**Never store plaintext.** Hash passwords with a *slow, salted* algorithm (scrypt, bcrypt, argon2). Slowness is the point — it makes brute-forcing a stolen database expensive. A unique random salt per password defeats rainbow tables. - -```ts -import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto'; -function hashPassword(pw: string): string { - const salt = randomBytes(16); - const dk = scryptSync(pw, salt, 32); - return `${salt.toString('hex')}:${dk.toString('hex')}`; -} -function verifyPassword(pw: string, stored: string): boolean { - const [saltHex, hashHex] = stored.split(':'); - const dk = scryptSync(pw, Buffer.from(saltHex, 'hex'), 32); - return timingSafeEqual(dk, Buffer.from(hashHex, 'hex')); // constant-time compare -} -``` -Use `timingSafeEqual`, never `===`, to compare secrets — a normal compare leaks length/contents via timing. - -**Signed tokens.** On login, issue a token whose integrity you can verify without a session store. A JWT is a base64url `header.payload.signature` where the signature is an HMAC over `header.payload` with a server secret: - -```ts -import { createHmac } from 'node:crypto'; -function sign(payload: object, secret: string): string { - const h = b64url({ alg: 'HS256', typ: 'JWT' }); - const p = b64url({ ...payload, exp: Math.floor(Date.now()/1000) + 3600 }); - const sig = createHmac('sha256', secret).update(`${h}.${p}`).digest('base64url'); - return `${h}.${p}.${sig}`; -} -function verify(token: string, secret: string): Payload | null { - const [h, p, sig] = token.split('.'); - const expected = createHmac('sha256', secret).update(`${h}.${p}`).digest('base64url'); - if (sig !== expected) return null; // tampered → no identity - const payload = JSON.parse(Buffer.from(p, 'base64url').toString()); - if (payload.exp < Math.floor(Date.now()/1000)) return null; // expired - return payload; -} -``` - -**Verify on every protected request.** An auth middleware reads `Authorization: Bearer `, verifies it, and attaches the identity (`req.user`) — or returns 401. The payload is *not* trusted until the signature is verified; a token is only an identity *after* verification. - -**Fail safe.** Missing, malformed, tampered, or expired token → 401, no identity, request stops. Never "assume a user" when verification fails. Don't put secrets in the token payload (it's readable); the signature guarantees integrity, not secrecy. - -### Common gotchas -- Plaintext or fast-hashed (MD5/SHA-1, unsalted) passwords. -- Comparing secrets with `===` (timing leak) instead of `timingSafeEqual`. -- Trusting the token payload before verifying the signature. -- No expiry; secrets hardcoded in source; secrets in the token body. - -## 🧪 Hands-on Labs - -Work through **`labs/lab-04-auth.md`**. You'll implement password hashing/verification with `node:crypto` scrypt and a signed-token sign/verify with HMAC, then add an auth middleware. Everything is unit-tested in Node with **real crypto**: a correct password verifies and a wrong one is rejected; a valid token round-trips; and a tampered signature, wrong secret, or expired token all yield no identity (401). - -## 🔍 Engineering Investigation - -Hash the same password twice and confirm the stored values differ (salting). Tamper with one character of a token's payload and confirm verification fails. Let a token expire (short exp) and confirm rejection. Record each as evidence that the design fails safe. - -## 🤖 AI Engineering Exercise - -Ask an AI to "add login and JWT auth." **Verify** passwords are salted+slow-hashed, secrets compared in constant time, the token signature is verified *before* trusting the payload, and tokens expire. **Log** any plaintext/fast hash, `===` comparison, or trust-before-verify the AI produced and your fix. - -## 📝 Assignment - -Submit: password hashing/verification and token sign/verify (using real crypto), the auth middleware attaching identity, passing Node tests covering correct/wrong passwords and valid/tampered/expired/wrong-secret tokens, and a note on each way the design fails safe. - -## 🚀 Stretch Goal - -Add refresh tokens (short-lived access + longer-lived refresh) or token revocation, and explain the trade-off between stateless tokens and the ability to revoke. - -## ✅ Definition of Done - -- [ ] Passwords salted + slow-hashed; verified in constant time -- [ ] Login issues a signed, expiring token -- [ ] Protected endpoints verify the token and attach identity -- [ ] Tampered/expired/missing tokens rejected with 401 (fail safe) -- [ ] Auth logic unit-tested with real crypto - -## 🪞 Reflection - -Where was the temptation to take a shortcut that would "work" but be insecure (fast hash, `===`, trusting the payload)? Why is "fail safe, not open" the only acceptable default for auth? diff --git a/Lesson_05.md b/Lesson_05.md deleted file mode 100644 index 368823e..0000000 --- a/Lesson_05.md +++ /dev/null @@ -1,108 +0,0 @@ -# Lesson 05 — Stop Unauthorized Access - -> **Role:** Backend Software Engineer · **Competency:** Authorization · **Track:** AUTHZ · **Est. time:** 3–4 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: AUTHZ-3010 -TITLE: Authenticated users can act on resources that aren't theirs -PRIORITY: P0 — security -TYPE: Feature / Bug -DESCRIPTION: Authentication tells us WHO is calling; it doesn't say what they may - DO. Today any logged-in user can read or modify any order. Add - authorization: role- and ownership-based checks enforced on the - server, returning 403 when a verified user lacks permission, with - least privilege as the default. - -ACCEPTANCE CRITERIA: - - Authorization checks run server-side on every protected action - - Role-based and ownership-based rules enforced (least privilege default) - - Authenticated-but-unauthorized requests return 403 (distinct from 401) - - Authorization decisions are centralized and testable in isolation -``` - -## 🏢 Business Context - -Authentication and authorization are different questions: *who are you* vs *what may you do*. Conflating them — or doing authorization in the frontend — is one of the most common and damaging API vulnerabilities (broken access control). A user editing the URL to access someone else's order must be stopped on the server, every time. Least privilege (deny by default, grant explicitly) keeps the blast radius small when something is misconfigured. - -## 🎯 Learning Objectives - -- Distinguish authentication (401) from authorization (403) -- Enforce role-based and ownership-based rules on the server -- Default to least privilege (deny unless explicitly allowed) -- Centralize authorization so it's consistent and testable - -## 📚 Technical Deep Dive - -**401 vs 403.** `401 Unauthorized` means *not authenticated* (we don't know who you are — log in). `403 Forbidden` means *authenticated but not permitted* (we know who you are; you may not do this). Returning the wrong one confuses clients and can leak information. - -**Authorization is a pure decision.** Given the authenticated user and the resource/action, return allow/deny. Keeping it pure makes it trivially testable: - -```ts -type Role = 'customer' | 'support' | 'admin'; -interface User { id: string; role: Role; } - -function canViewOrder(user: User, order: Order): boolean { - if (user.role === 'admin' || user.role === 'support') return true; // role-based - return order.customerId === user.id; // ownership-based -} -function canRefundOrder(user: User, order: Order): boolean { - return user.role === 'admin'; // least privilege -} -``` - -**Enforce on the server, on every action.** The controller (or an authorization middleware) checks permission *after* authentication and *before* the service acts: - -```ts -const order = await orders.find(id); -if (!order) return { status: 404, body: { error: 'not found' } }; -if (!canViewOrder(req.user, order)) return { status: 403, body: { error: 'forbidden' } }; -return { status: 200, body: order }; -``` -Note the order of checks can itself leak information (404 vs 403 reveals existence) — decide deliberately per resource. - -**Least privilege by default.** Start from "deny," grant specific permissions. A new role or endpoint should have *no* access until you add it, not full access until you remember to restrict it. - -**Centralize the policy.** Keep authorization rules in one place (a policy module of pure functions) rather than scattered `if (user.role === …)` checks, so the rules are auditable and consistently applied. - -### Common gotchas -- Doing authorization only in the frontend (trivially bypassed). -- Using 401 where 403 belongs (or vice versa). -- Checking role but not ownership (any customer can read any order). -- "Allow by default" — forgetting to restrict a new endpoint exposes everything. -- Scattered, duplicated checks that drift apart. - -## 🧪 Hands-on Labs - -Work through **`labs/lab-05-authz.md`**. You'll build a centralized policy of pure authorization functions (role + ownership) and enforce them in the controllers. The policy is unit-tested in Node (owner can view their order; a stranger cannot; support/admin can; only admin can refund; default-deny for unknown roles) and the endpoints are exercised end-to-end to confirm 403 for authenticated-but-forbidden and 401 for unauthenticated. - -## 🔍 Engineering Investigation - -As a non-owner customer, attempt to read and modify another user's order; confirm 403. As the owner, confirm success. As an admin, confirm elevated access. With no token, confirm 401 (not 403). Record each, and identify one place where returning 404 instead of 403 is the better information-hiding choice. - -## 🤖 AI Engineering Exercise - -Ask an AI to "add role-based access control." **Verify** checks run server-side, ownership (not just role) is enforced, 401/403 are used correctly, and the default is deny. **Log** where the AI allowed-by-default, skipped ownership, or relied on the client and your fix. - -## 📝 Assignment - -Submit: the centralized authorization policy (pure functions), server-side enforcement in the controllers, passing Node tests covering role + ownership + default-deny, end-to-end evidence of 403 (forbidden) vs 401 (unauthenticated), and a note on one 404-vs-403 information-hiding decision. - -## 🚀 Stretch Goal - -Add attribute-/policy-based rules (e.g. support can refund only within 30 days) or resource scopes, and explain when ABAC beats simple RBAC. - -## ✅ Definition of Done - -- [ ] Authorization enforced server-side on every protected action -- [ ] Role- and ownership-based rules; least-privilege default -- [ ] 403 for authenticated-but-forbidden; 401 for unauthenticated -- [ ] Policy centralized and unit-tested -- [ ] End-to-end evidence of correct allow/deny - -## 🪞 Reflection - -Where did checking *role* but not *ownership* leave a hole? Why is "deny by default" safer than "allow by default," and what does that cost in convenience? diff --git a/Lesson_06.md b/Lesson_06.md deleted file mode 100644 index b7a3b0f..0000000 --- a/Lesson_06.md +++ /dev/null @@ -1,116 +0,0 @@ -# Lesson 06 — Build a Reliable API Platform - -> **Role:** Backend Software Engineer · **Competency:** API Platform Standards · **Track:** API · **Est. time:** 4 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: API-4001 -TITLE: Each endpoint behaves differently; make the API a consistent platform -PRIORITY: P1 -TYPE: Architecture / Platform -DESCRIPTION: Errors are shaped differently per endpoint, large lists return - everything at once, retries can double-charge, and there's no - protection from abusive traffic. Establish platform standards: a - consistent error envelope, pagination, idempotency for unsafe - retries, and rate limiting — applied uniformly across the API. - -ACCEPTANCE CRITERIA: - - One consistent error envelope across all endpoints - - List endpoints paginated with a documented, stable scheme - - Unsafe operations are idempotent under retry (idempotency keys) - - Rate limiting protects the platform; clients get 429 with retry guidance -``` - -## 🏢 Business Context - -The difference between "some endpoints" and "a platform" is consistency. When every endpoint reports errors the same way, paginates the same way, survives a retry the same way, and is protected from abuse, clients can build one integration that works everywhere — and the system stays up under real-world traffic (flaky networks, retries, bursts). These cross-cutting standards are what make an API dependable rather than a collection of one-offs. - -## 🎯 Learning Objectives - -- Define one consistent error envelope used everywhere -- Paginate list endpoints with a stable, documented scheme -- Make unsafe operations idempotent under retry -- Protect the platform with rate limiting (429 + retry guidance) - -## 📚 Technical Deep Dive - -**A consistent error envelope.** Every error — validation, auth, not-found, server — has the same shape, so clients parse one thing: - -```ts -interface ApiError { error: { code: string; message: string; details?: unknown }; } -// 400 → { error: { code: 'validation_failed', message: '…', details: [...] } } -// 404 → { error: { code: 'not_found', message: 'order not found' } } -``` -Map domain errors to `(status, code)` in one place. - -**Pagination.** Don't return unbounded lists. Two common schemes: -- **Offset/limit** — `?limit=20&offset=40`. Simple; drifts if data changes between pages. -- **Cursor** — `?limit=20&cursor=`. Stable under inserts/deletes; preferred for large/active datasets. - -Return the page plus metadata (`nextCursor`/`total`), and cap `limit` so a client can't request a million rows: - -```ts -const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 100); // clamp 1..100 -``` - -**Idempotency for unsafe retries.** Networks fail after the server acted, so clients retry — and a naive `POST /payments` double-charges. An **idempotency key** (client-supplied, e.g. `Idempotency-Key` header) lets the server recognize a retry and return the original result instead of acting twice: - -```ts -if (seen.has(key)) return seen.get(key)!; // replay the stored response -const result = await doWork(); -seen.set(key, result); -return result; -``` -Safe methods (GET, PUT, DELETE) are idempotent by definition; the work is making *POST*-style operations safe to retry. - -**Rate limiting.** Protect the platform from bursts/abuse. A token-bucket or fixed-window limiter per client returns `429 Too Many Requests` with a `Retry-After` header when exceeded: - -```ts -function allow(bucket: Bucket, now: number, ratePerSec: number, capacity: number): boolean { - bucket.tokens = Math.min(capacity, bucket.tokens + (now - bucket.last) / 1000 * ratePerSec); - bucket.last = now; - if (bucket.tokens < 1) return false; - bucket.tokens -= 1; return true; -} -``` - -### Common gotchas -- Different error shapes per endpoint (clients can't handle them uniformly). -- Unbounded list responses; no max on `limit`. -- Non-idempotent POSTs that double-charge/double-create on retry. -- No rate limiting (one client can take down the platform); 429 without `Retry-After`. - -## 🧪 Hands-on Labs - -Work through **`labs/lab-06-api-platform.md`**. You'll add a consistent error envelope, paginate the orders list with a clamped limit, make a create/payment operation idempotent via a key, and implement a token-bucket rate limiter. The pure logic (envelope mapping, limit clamp, idempotency replay, token bucket) is unit-tested in Node, and the endpoints are exercised end-to-end (a retried request returns the same result; an over-limit client gets 429). - -## 🔍 Engineering Investigation - -Trigger each error type and confirm they share the envelope. Page through a list and confirm the limit is clamped and metadata is correct. Send the same idempotent request twice and confirm the side effect happens once. Exceed the rate limit and confirm 429 + `Retry-After`. Record each. - -## 🤖 AI Engineering Exercise - -Ask an AI to "add pagination and rate limiting." **Verify** the error shape is consistent, `limit` is clamped, idempotent operations replay rather than re-execute, and 429 carries retry guidance. **Log** where the AI returned unbounded lists or non-idempotent retries and your fix. - -## 📝 Assignment - -Submit: the consistent error envelope, paginated list with clamped limit, idempotent unsafe operation, and rate limiter — with passing Node tests of each pure piece and end-to-end evidence (same-result retry; 429 on over-limit). - -## 🚀 Stretch Goal - -Add API versioning (`/v1`) or a deprecation policy, or `ETag`-based conditional requests, and explain how it lets the platform evolve without breaking clients. - -## ✅ Definition of Done - -- [ ] One consistent error envelope across endpoints -- [ ] List endpoints paginated with a clamped, documented scheme -- [ ] Unsafe operations idempotent under retry -- [ ] Rate limiting with 429 + `Retry-After` -- [ ] Pure logic unit-tested; endpoints verified end-to-end - -## 🪞 Reflection - -Which standard would clients miss most if it were inconsistent? Why is idempotency a *correctness* concern and not just a nicety? diff --git a/Lesson_07.md b/Lesson_07.md deleted file mode 100644 index 3d8af92..0000000 --- a/Lesson_07.md +++ /dev/null @@ -1,103 +0,0 @@ -# Lesson 07 — Move Work Off the Request - -> **Role:** Backend Software Engineer · **Competency:** Background Processing · **Track:** JOB · **Est. time:** 4 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: JOB-4010 -TITLE: Slow work blocks the request; move it to background jobs -PRIORITY: P1 -TYPE: Architecture -DESCRIPTION: Sending the order-confirmation email and generating invoices happen - inline, so the request hangs and a failure fails the whole call. - Move slow, retryable, or non-critical work to a background queue: - enqueue a job, return quickly, and process it with retries, backoff, - and idempotency so transient failures recover and jobs don't double-run. - -ACCEPTANCE CRITERIA: - - Slow/non-critical work is enqueued, not run inline; the request returns fast - - Workers process jobs with bounded retries and backoff - - Jobs are idempotent (safe to run more than once) - - Permanently failing jobs go to a dead-letter queue, not an infinite loop -``` - -## 🏢 Business Context - -Not all work belongs in the request. Sending email, generating documents, calling slow third parties, processing uploads — doing these inline makes the API slow and fragile: the user waits, and a flaky email provider fails the whole order. Moving work to a background queue lets the request return immediately while the work happens reliably, with retries for transient failures. It's how a backend stays fast and resilient under real conditions, and it's a core pattern of every production platform. - -## 🎯 Learning Objectives - -- Decide what work to move off the request path -- Enqueue jobs and return quickly; process them in a worker -- Implement bounded retries with backoff -- Make jobs idempotent and dead-letter the permanently failed - -## 📚 Technical Deep Dive - -**Enqueue, return, process later.** The request handler does the critical work (create the order), enqueues the rest (send confirmation), and returns. A worker picks up jobs and runs them: - -```ts -await orders.save(order); // critical, inline -await queue.enqueue({ type: 'sendOrderEmail', orderId: order.id }); // deferred -return { status: 201, body: order }; // fast response -``` - -**Retries with backoff.** Background work fails transiently (a provider blips). Retry a bounded number of times with increasing delay (exponential backoff) so you recover without hammering a struggling dependency: - -```ts -function nextDelayMs(attempt: number, baseMs = 1000, capMs = 60000): number { - return Math.min(capMs, baseMs * 2 ** attempt); // 1s, 2s, 4s, 8s … capped -} -function shouldRetry(attempt: number, maxAttempts: number): boolean { - return attempt < maxAttempts; -} -``` -Add jitter in production to avoid thundering herds. - -**Idempotency (again, and for the same reason).** A job may run more than once (a retry after the work partially succeeded, an at-least-once queue). Design jobs to be safe to repeat: check "already sent?" before sending, use the order id as a natural key. Same discipline as idempotent endpoints (Lesson 6), now for workers. - -**Dead-letter queue.** A job that fails every retry must not loop forever. After `maxAttempts`, move it to a dead-letter queue for inspection/alerting — failures become visible and bounded, not silent or infinite. - -**At-least-once vs at-most-once.** Most queues deliver *at least once* (a job can repeat) — which is why idempotency is mandatory. *Exactly once* is largely a myth at the system level; you achieve its effect with at-least-once delivery + idempotent jobs. - -### Common gotchas -- Doing slow/flaky work inline (slow, fragile requests). -- Unbounded retries (a poisoned job loops forever, burning resources). -- Non-idempotent jobs that double-send/double-charge on retry. -- No dead-letter path, so permanent failures vanish silently. -- No backoff (retry storm hammers a failing dependency). - -## 🧪 Hands-on Labs - -Work through **`labs/lab-07-jobs.md`**. You'll build an in-memory job queue with enqueue/process, implement bounded retries with exponential backoff, make a `sendOrderEmail` job idempotent, and dead-letter permanent failures. The pure logic (backoff schedule, retry decision, idempotent-run guard, dead-letter after max attempts) is unit-tested in Node — a transient failure recovers on retry; a job that always fails lands in the DLQ after `maxAttempts`; a duplicate run is a no-op. - -## 🔍 Engineering Investigation - -Make a job fail twice then succeed; confirm it recovers and the request was fast. Make a job always fail; confirm it stops after `maxAttempts` and lands in the dead-letter queue (not an infinite loop). Run a job twice; confirm the side effect happens once. Record the backoff delays produced. - -## 🤖 AI Engineering Exercise - -Ask an AI to "send the email in the background." **Verify** the work is enqueued (not inline), retries are bounded with backoff, the job is idempotent, and permanent failures dead-letter. **Log** where the AI used unbounded retries, a non-idempotent job, or no DLQ and your fix. - -## 📝 Assignment - -Submit: the job queue + worker, bounded retry with backoff, an idempotent job, and a dead-letter path — with passing Node tests (transient recovers, permanent → DLQ after max attempts, duplicate run is a no-op) and a note on what you moved off the request and why. - -## 🚀 Stretch Goal - -Add a scheduled/delayed job (e.g. a reminder after 24h) or priority queues, and explain how delivery guarantees (at-least-once) shape the design. - -## ✅ Definition of Done - -- [ ] Slow/non-critical work enqueued; request returns fast -- [ ] Bounded retries with backoff -- [ ] Jobs idempotent (safe to repeat) -- [ ] Permanent failures dead-lettered (no infinite loop) -- [ ] Queue/worker logic unit-tested - -## 🪞 Reflection - -What did moving work off the request do for latency and resilience? Why does at-least-once delivery make idempotency non-optional rather than a nice-to-have? diff --git a/Lesson_08.md b/Lesson_08.md deleted file mode 100644 index fe4d65a..0000000 --- a/Lesson_08.md +++ /dev/null @@ -1,113 +0,0 @@ -# Lesson 08 — Pass the Production Readiness Review - -> **Role:** Backend Software Engineer · **Competency:** Operational Readiness · **Track:** OPS · **Est. time:** 4 hours - ---- - -## 🎫 Engineering Ticket - -``` -TICKET: OPS-5001 -TITLE: The service works on a laptop; make it operable in production -PRIORITY: P1 -TYPE: Quality / Operations -DESCRIPTION: Before Forge's backend can ship, it must pass a production readiness - review: health and readiness checks for orchestration, structured - logging with request correlation, sane configuration and secrets - handling, graceful shutdown, and basic metrics. Make the service - observable and operable, not just functional. - -ACCEPTANCE CRITERIA: - - Liveness and readiness endpoints reflect real health - - Logs are structured (JSON) and correlate by request id - - Configuration/secrets come from the environment, not code - - The service shuts down gracefully (drains in-flight work) - - Key signals (errors, latency) are observable -``` - -## 🏢 Business Context - -"It runs on my machine" is not production. A production service has to be *operable*: an orchestrator needs to know if it's alive and ready, on-call engineers need logs they can search and correlate, secrets can't live in source, deploys and restarts can't drop in-flight requests, and someone needs to see error rates and latency. Operational readiness is what separates a service that survives a 3am incident from one that causes it. This is *design for failure* and *measure first* (Module 03) applied to running systems. - -## 🎯 Learning Objectives - -- Implement liveness and readiness checks that reflect real state -- Emit structured, correlated logs -- Load configuration and secrets from the environment -- Shut down gracefully, draining in-flight work -- Expose key operational signals (errors, latency) - -## 📚 Technical Deep Dive - -**Liveness vs readiness.** They answer different questions an orchestrator asks: -- **Liveness** (`/healthz`) — *is the process alive?* If it fails, restart me. Keep it trivial (don't check dependencies, or a slow DB will cause restart loops). -- **Readiness** (`/readyz`) — *can I serve traffic right now?* Checks dependencies (DB reachable, migrations done). If it fails, stop sending me requests but don't restart me. - -```ts -async function readiness(): Promise<{ ready: boolean; checks: Record }> { - const db = await pingDb().then(() => true).catch(() => false); - return { ready: db, checks: { db } }; -} -``` - -**Structured, correlated logs.** Log JSON (machine-parsable), and stamp every log line in a request with a **request id** (generated or from `X-Request-Id`) so you can trace one request across all its log lines and services: - -```ts -log.info({ requestId, route: 'POST /orders', userId, durationMs, status: 201 }); -``` -Never log secrets, tokens, passwords, or full PII. - -**Configuration & secrets from the environment.** Read config from environment variables (validated at startup), not hardcoded. Secrets (DB URL, signing key) come from the environment/secret manager — never committed. Fail fast at boot if a required config is missing. - -**Graceful shutdown.** On `SIGTERM` (a deploy/scale-down), stop accepting new connections, finish in-flight requests, drain workers, close the DB, then exit — within a deadline: - -```ts -process.on('SIGTERM', async () => { - server.close(); // stop accepting new connections - await drainInFlight(deadlineMs); - await db.close(); - process.exit(0); -}); -``` -Without this, a deploy drops live requests and corrupts in-flight work. - -**Observability.** Emit metrics for the signals that matter — request rate, error rate, latency (the "RED" metrics) — so you can see health and set alerts. You can't operate what you can't measure. - -### Common gotchas -- Liveness that checks dependencies → restart loops when a dependency blips. -- Unstructured logs with no request id (untraceable incidents). -- Secrets in source/config files; missing config discovered at runtime, not boot. -- No graceful shutdown → dropped requests on every deploy. -- No metrics → flying blind. - -## 🧪 Hands-on Labs - -Work through **`labs/lab-08-ops.md`**. You'll add liveness/readiness endpoints, a structured logger that correlates by request id, environment-based config validated at startup, and graceful-shutdown draining. The pure logic (readiness aggregation, config validation, log redaction, shutdown drain decision) is unit-tested in Node, and the health endpoints are exercised end-to-end (ready when deps are up, not-ready when a dep is down). - -## 🔍 Engineering Investigation - -Make a dependency "down" and confirm readiness flips to not-ready while liveness stays alive. Issue a request and confirm every log line shares its request id. Remove a required env var and confirm the service fails fast at boot. Trigger shutdown with an in-flight request and confirm it drains before exit. Record each. - -## 🤖 AI Engineering Exercise - -Ask an AI to "make this service production-ready." **Verify** liveness stays trivial (no dependency checks), logs are structured + correlated and redact secrets, config/secrets come from the environment, and shutdown drains in-flight work. **Log** where the AI checked deps in liveness, logged a secret, or skipped graceful shutdown and your fix. - -## 📝 Assignment - -Submit: liveness/readiness endpoints, the structured correlated logger (with redaction), env-based validated config, and graceful shutdown — with passing Node tests of the pure logic and end-to-end evidence (readiness reflects a downed dependency; logs share a request id; missing config fails fast). - -## 🚀 Stretch Goal - -Add a metrics endpoint (request count / error rate / latency histogram) or distributed-tracing context propagation, and explain which signal you'd alert on first and why. - -## ✅ Definition of Done - -- [ ] Liveness (trivial) and readiness (dependency-aware) endpoints -- [ ] Structured, request-correlated logs that redact secrets -- [ ] Config/secrets from the environment, validated at boot -- [ ] Graceful shutdown drains in-flight work -- [ ] Key signals observable; pure logic unit-tested - -## 🪞 Reflection - -Which readiness check would have caught a real outage early? Why must liveness stay dumb while readiness gets smart, and what breaks if you mix them up? diff --git a/Lesson_09.md b/Lesson_09.md deleted file mode 100644 index cc17705..0000000 --- a/Lesson_09.md +++ /dev/null @@ -1,90 +0,0 @@ -# Lesson 09 — Project Forge Platform Release - -> **Role:** Backend Software Engineer · **Competency:** Platform Release · **Track:** CAP · **Est. time:** 16–20 hours - ---- - -## 🎫 Engineering Ticket - -``` -EPIC: FORGE-9400 -TITLE: Ship the production Project Forge backend platform -PRIORITY: P1 — module capstone -TYPE: Epic (integrative) -DESCRIPTION: You own shipping the Forge backend that powers the Module 05 - frontend. Integrate everything: a layered REST API, boundary - validation, authentication and authorization, platform standards - (consistent errors, pagination, idempotency, rate limiting), - background processing, and operational readiness — under strict - TypeScript. Ship a coherent platform and a release report that proves - each quality bar with evidence. - -ACCEPTANCE CRITERIA: (full mapping in assignments/capstone-brief.md) - - Layered architecture: controller → service → repository, business logic testable without HTTP - - Resource-oriented REST with honest status codes and a consistent error envelope - - Every request validated at the boundary; only typed data reaches services - - Authentication (salted/slow-hashed passwords, signed expiring tokens) and authorization (role + ownership, least privilege) - - Platform standards: pagination, idempotency for unsafe retries, rate limiting - - Background processing with bounded retries, backoff, idempotency, dead-lettering - - Operational readiness: liveness/readiness, structured correlated logs, env config, graceful shutdown - - Strict TypeScript throughout; a release report proves each bar with evidence -``` - -## 🏢 Business Context - -This is the job: take the API from "works on a laptop" to a platform that is correct, secure, reliable, and operable — one other teams (including the Module 05 frontend) can build on. Shipping a backend is an exercise in integration and judgment: the layers, the security model, the platform standards, and the operational concerns all interact, and the security and reliability bars can't be bolted on at the end. Any one pattern is straightforward; composing them into a release you'd put on call for is the skill. - -## 🎯 Learning Objectives - -Integrate every module competency into a shippable platform: a layered REST API; boundary validation; authentication and authorization; platform standards (errors, pagination, idempotency, rate limiting); background processing; and operational readiness — all under strict TypeScript and evidence-based documentation. - -## 📚 Technical Deep Dive - -No new concepts — the capstone tests **integration, architecture, security, and judgment.** The full specification, the platform scope, the recommended build order, and the acceptance-criteria → rubric mapping live in **`assignments/capstone-brief.md`**; read it first and trace each criterion to the evidence you'll produce. - -A sound build order (detailed in the brief): - -1. **REST + layers** — resource-oriented endpoints over controller → service → repository (Lessons 1, 2). -2. **Validation** — schema validation at the boundary; only typed data into services (Lesson 3). -3. **Security** — authentication then authorization (role + ownership, least privilege) (Lessons 4, 5). -4. **Platform standards** — consistent error envelope, pagination, idempotency, rate limiting (Lesson 6). -5. **Background processing** — move slow work to a queue with retries/backoff/idempotency/DLQ (Lesson 7). -6. **Operational readiness & release** — health/readiness, structured logs, env config, graceful shutdown; assemble the release report (Lesson 8). - -Keep it type-checking and the service tests green throughout; commit in small, verified increments. - -## 🧪 Hands-on Labs - -The capstone *is* the lab. The endpoints, the Orders domain, the auth, and the jobs reuse the earlier lab generators, so you ship a real platform rather than a toy, and the evidence (service tests, end-to-end requests, security checks) is reproducible. - -## 🔍 Engineering Investigation - -Investigation is the deliverable. The release report must show, with evidence: the layered architecture (service tests run without HTTP); the REST contract and consistent error envelope; boundary validation rejecting bad input before the service; authentication failing safe (tampered/expired tokens → 401) and authorization enforcing role + ownership (403 vs 401); platform standards (a retried unsafe op acts once; over-limit → 429); background processing (transient recovers, permanent → DLQ); and operational readiness (readiness reflects a downed dependency, logs correlate, missing config fails fast, shutdown drains). End with a "production-readiness" summary: what each bar guarantees and how you verified it. - -## 🤖 AI Engineering Exercise - -Use AI throughout as a professional would — to draft a controller, a service, a validator, an auth helper, a job — **but every use follows draft → verify (type-check + test/run + measure) → log.** Maintain an AI-usage log. The recurring failures to catch: trusting `req.body`, plaintext/fast-hashed passwords, `===` secret comparison, trust-before-verify tokens, allow-by-default authorization, non-idempotent retries, unbounded job retries, dependency checks in liveness, and secrets in logs. The compiler, the tests, and the security checks are the arbiters. - -## 📝 Assignment - -Ship the Forge backend per `assignments/capstone-brief.md`, using `assignments/capstone-submission-template.md`. Your submission is the working, strict-typed platform plus a **release report** proving each quality bar with evidence, and the engineering notebook (including the AI-usage log). - -## 🚀 Stretch Goal - -Go beyond the brief in one production-grade way a real team would value — e.g. an OpenAPI spec generated from the types, contract tests against the Module 05 frontend's expectations, a real database behind the repository interface, audit logging, or a CI gate (type-check + tests + lint) — and justify it with evidence. - -## ✅ Definition of Done - -- [ ] Layered architecture; business logic testable without HTTP -- [ ] Resource-oriented REST; honest status codes; consistent error envelope -- [ ] Every request validated at the boundary; only typed data into services -- [ ] Authentication (salted/slow hash, signed expiring tokens) failing safe -- [ ] Authorization (role + ownership, least privilege); 403 vs 401 correct -- [ ] Platform standards: pagination, idempotency, rate limiting -- [ ] Background processing: bounded retries, backoff, idempotency, DLQ -- [ ] Operational readiness: liveness/readiness, structured logs, env config, graceful shutdown -- [ ] Strict TypeScript; release report + notebook + AI log complete and reproducible - -## 🪞 Reflection - -Which integration decision had the widest blast radius across the platform? Where did a security or reliability bar force a change you'd have skipped under time pressure — and why was building it in cheaper than bolting it on? diff --git a/MODULE_SYLLABUS.md b/MODULE_SYLLABUS.md deleted file mode 100644 index f827141..0000000 --- a/MODULE_SYLLABUS.md +++ /dev/null @@ -1,54 +0,0 @@ -# Module Syllabus — Backend Engineering & API Design - -## Description -A ticket-driven module that builds the **production backend** for *Project Forge* on Node and TypeScript. Across 10 lessons and a capstone, you operate as a Backend Software Engineer closing tickets that move from replacing a mock with a real REST API, through layered architecture and boundary validation, authentication and authorization, API platform standards, background processing, and operational readiness — culminating in shipping the platform. The emphasis is on **architecture, security, reliability, and judgment**: the boundary is untrusted, layers have clear contracts, illegal states are unrepresentable, security fails safe, and you design for failure. - -## Prerequisites -- Solid TypeScript (SWEXP Module 04) — the backend is fully typed. -- Familiarity with the Module 05 frontend's data needs (it consumes this API). -- Comfort at a command line and basic Git (Modules 01–02). -- **Node.js** (18+ for global `fetch`; `node:crypto` is built in) and **npm**. -- A TypeScript-aware editor. - -## Pacing Options - -| Track | Cadence | Duration | -|-------|---------|----------| -| Intensive (bootcamp) | ~1 lesson/day; capstone over the last 4–5 days | ~2–3 weeks | -| Part-time (cohort) | 2 lessons/week | ~6 weeks | -| Self-paced | 1 lesson per sitting; capstone when ready | flexible | - -Most lessons are 3–4 hours including the lab; the capstone is 16–20 hours. - -## Module Arc - -| Phase | Lessons | Focus | -|-------|---------|-------| -| Foundations | 0 | toolchain; request/response lifecycle; backend vs frontend | -| API Foundations | 1–3 | REST + controllers; layered architecture; boundary validation | -| Security | 4–5 | authentication; authorization | -| Platform | 6–7 | API platform standards; background processing | -| Quality & Production | 8 | operational readiness | -| Capstone | 9 | ship the full Forge backend platform with evidence | - -## Lesson Structure -Every lesson follows the same shape: **Engineering Ticket → Business Context → Learning Objectives → Technical Deep Dive → Hands-on Labs → Engineering Investigation → AI Engineering Exercise → Assignment → Stretch Goal → Definition of Done → Reflection.** - -## Labs -Every lab carries a running Forge backend forward and is **verified three ways**: contracts are TypeScript type-checked with `tsc` (+`@types/node`); pure logic and auth are unit-tested with `node` (real `node:crypto` — scrypt + HMAC); and endpoints are exercised end-to-end by a real in-process HTTP server (Node's built-in `http`) hit with global `fetch` — no external services. Note `tsc file.ts` ignores `tsconfig.json` — use `tsc --noEmit` or `-p`. - -## Deliverables -- **Per lesson:** a completed lab, an assignment via `assignments/submission-template.md`, and an engineering-notebook entry (what you built → evidence → fixes → AI log). -- **Capstone:** the working strict-typed Forge backend, a release report proving each quality bar with evidence (layered architecture with service tests sans HTTP, the REST contract, boundary validation, auth failing safe, authz 403-vs-401, idempotency/429, jobs recover/DLQ, readiness reflecting a downed dependency), and the notebook — per `assignments/capstone-brief.md`. - -## Final Assessment -Graded against `ASSESSMENT_RUBRIC.md`: Architecture (15%), Security (15%), API Platform (15%), Data Platform (10%), Background Processing (10%), Operations (10%), Documentation (10%), Engineering Judgment (10%), AI Workflow (5%). - -## Support Materials -- `resources/` — setup; REST reference; layered architecture; validation; authentication; authorization; API platform standards; background processing; operational readiness; HTTP status codes; error handling; AI-workflow; notebook template. -- `dashboard.html` — an interactive progress tracker. -- `solutions/` — worked solutions (type-check / Node-test / request reproducible) to check against. -- `instructor-notes/` — per-lesson facilitation guidance. - -## Academic & Professional Integrity -AI assistance is **encouraged**, used as a professional would: every use follows **draft → verify (type-check + test/run + measure) → log.** The recurring failures to catch — trusting `req.body`, plaintext/fast-hashed passwords, `===` secret comparison, trust-before-verify tokens, allow-by-default authorization, non-idempotent retries, unbounded job retries, dependency checks in liveness, secrets in logs — are exactly what the verifiers exist to surface. Unverified AI output in deliverables counts against you, and security shortcuts especially. diff --git a/README.md b/README.md index ce655e4..580c3ab 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,64 @@ -# SWEXP Module 06 — Backend Engineering & API Design +# SWEXP Module 06 — Backend Engineering & API Design · Starter Workspace -**Theme:** Powering Project Forge — build the production backend that supports the frontend created in Module 05. +This repo is your **work-along workspace** for Module 06. The lessons live in the LMS; here you do the +labs and the capstone: open an exercise, read its `README.md`, implement the `// TODO`s in its `src/`, +run the tests, and submit. -You are a **Backend Software Engineer** on the Platform Team. The Forge frontend runs against a mock at `api.forge.dev`; now you build the real service it depends on. Across 10 ticket-driven lessons you replace the mock with a real REST API, organize it into layers, validate the untrusted boundary, authenticate and authorize every request, harden the API into a platform (consistent errors, pagination, idempotency, rate limiting), move slow work to background jobs, make it operationally ready (health, logging, graceful shutdown), and ship it. +> **The tests are the spec.** Each exercise's `tests/` describes exactly what your code must do — make +> them pass without weakening the types (no `any`, no `as` to lie about a shape, no `@ts-ignore`). No answer +> keys are shipped. -The ethos, in every lesson: **the boundary is untrusted**; **layers with clear contracts**; **make illegal states unrepresentable**; **fail safe, not open**; **least privilege**; and **measure first, design for failure**. AI is used as **draft → verify (type-check + test/run + measure) → log**. +Every exercise is **pure handler/logic** — request validation, status-code decisions, auth/permission +checks, pagination, error envelopes, retry/queue logic. You never need to boot a real server or hit the +network; you test the decisions a server makes by calling functions directly. -## How You Work Here +## Quick start -| Step | What it means | -|------|---------------| -| Pick up a ticket | Each lesson is an engineering ticket (`AUTH-3001`, `API-4001`, …) with acceptance criteria | -| Build in layers | Controller (HTTP) / service (logic) / repository (data), with clear contracts | -| Type-check | `tsc --noEmit` against `@types/node` — broken contracts are compile errors | -| Test the logic | Service rules, validators, policy, auth (real `node:crypto`), jobs, ops run in Node | -| Verify endpoints | A real in-process HTTP server hit with `fetch` — status codes, idempotency, 401/403, 429, readiness | -| Verify AI | Draft → verify (type-check + test/run + measure) → log | - -## Learning Outcomes +```bash +npm install # one time (already done in your LMS code-server workspace) +npm test # run every exercise's behaviour tests +npm run test:types # add the type-level checks (expectTypeOf) +npm run check # strict type-check — "the compiler is your first reviewer" +npm run grade # your score + per-exercise breakdown (what CI reports) +``` -By the end you will be able to: -- Design resource-oriented REST APIs with honest status codes and typed contracts. -- Structure a backend into controller / service / repository layers with logic testable without HTTP. -- Validate every request at the boundary and reject bad input with structured errors. -- Authenticate requests with salted/slow-hashed passwords and signed, expiring tokens, failing safe. -- Authorize with role- and ownership-based rules under least privilege (403 vs 401). -- Establish API platform standards: a consistent error envelope, pagination, idempotency, rate limiting. -- Move slow work to a background queue with bounded retries, backoff, idempotency, and dead-lettering. -- Make a service operationally ready: liveness/readiness, structured logs, env config, graceful shutdown. -- Ship a coherent, strict-typed backend platform with evidence for each quality bar. +Run a single exercise while you work on it: -## Lesson Index +```bash +npx vitest run labs/lab-04-auth # or any folder below +npx vitest watch labs/lab-04-auth # re-run on save +``` -| # | Lesson | Competency | Ticket | -|---|--------|-----------|--------| -| 0 | Welcome to the Backend Engineering Team | Backend Orientation | BE-1000 | -| 1 | Replace the Mock API | REST APIs & Controllers | API-1010 | -| 2 | Organize the Service Layer | Layered Architecture | ARCH-2001 | -| 3 | Stop Invalid Requests at the Door | Validation | VAL-2010 | -| 4 | Authenticate Every Request | Authentication | AUTH-3001 | -| 5 | Stop Unauthorized Access | Authorization | AUTHZ-3010 | -| 6 | Build a Reliable API Platform | API Platform Standards | API-4001 | -| 7 | Move Work Off the Request | Background Processing | JOB-4010 | -| 8 | Pass the Production Readiness Review | Operational Readiness | OPS-5001 | -| 9 | Project Forge Platform Release | Platform Release | FORGE-9400 | +## Exercises -Phases: **Foundations** (0) → **API Foundations** (1–3) → **Security** (4–5) → **Platform** (6–7) → **Quality & Production** (8) → **Capstone** (9). +| Exercise | Folder | You implement | +| --- | --- | --- | +| Lab 00 — Toolchain & minimal service | `labs/lab-00-setup` | `handleRequest` — `/health` 200, else 404 | +| Lab 01 — REST API | `labs/lab-01-rest-api` | typed controllers + a thin router (200/201/404/400/405) | +| Lab 02 — Service layer | `labs/lab-02-service-layer` | `OrderService` + repository; domain errors → status codes | +| Lab 03 — Boundary validation | `labs/lab-03-validation` | `validateCreateOrder` — narrow `unknown` → typed/issues | +| Lab 04 — Authentication | `labs/lab-04-auth` | scrypt passwords + HMAC tokens; fail safe | +| Lab 05 — Authorization | `labs/lab-05-authz` | role + ownership policy; 401 vs 403 | +| Lab 06 — API platform | `labs/lab-06-api-platform` | error envelope, pagination, idempotency, rate limit | +| Lab 07 — Background jobs | `labs/lab-07-jobs` | retry/backoff queue, DLQ, idempotent job | +| Lab 08 — Operational readiness | `labs/lab-08-ops` | readiness, config-at-boot, log redaction, drain | +| Capstone — Forge backend | `assignments/capstone` | integrate it all into one strict module | -## Repository Layout +Each folder is self-contained: a `README.md` (the brief), `src/` (starter code with `// TODO`s), and +`tests/` (the spec). Reference guides are in [`resources/`](resources/). -``` -. -├── README.md # this file -├── MODULE_SYLLABUS.md # pacing, structure, deliverables -├── LEARNER_GUIDE.md # how to operate as a backend engineer here -├── INSTRUCTOR_GUIDE.md # facilitation and assessment -├── COMPETENCY_MATRIX.md # lesson → competency → skills -├── ASSESSMENT_RUBRIC.md # grading weights and performance levels -├── dashboard.html # interactive progress dashboard (open in a browser) -├── Lesson_00.md … Lesson_09.md # the 10 lessons -├── labs/ # hands-on labs (type-checked; logic + auth Node-tested; endpoints verified) -├── solutions/ # worked solutions / answer keys -├── resources/ # setup, REST, architecture, validation, auth, authz, platform, jobs, ops + more -├── assignments/ # submission templates + capstone brief -└── instructor-notes/ # per-lesson facilitation notes -``` +## How grading & submission work -## Getting Started +- Every exercise contributes tests — behaviour (`*.test.ts`) and, for the capstone, type-level assertions + (`*.test-d.ts`). `npm run grade` reports a per-exercise score plus a strict type-check gate. +- **Submit** by committing your changes and pushing (or opening a pull request). The **Autograde** GitHub + Action runs the same grader, posts your score to the run summary, and comments it on any PR. +- You're done when the score is **100%** and the type-check is clean. -1. Read `resources/backend-setup-guide.md`; set up the Node + TypeScript toolchain and the verify loop (Lesson 0 / `labs/lab-00-setup.md`). -2. Start your engineering notebook from `resources/engineering-notebook-template.md`. -3. Open `dashboard.html` in your browser to track progress through the lessons and phases. -4. Open `Lesson_00.md` and pick up your first ticket. Keep the relevant `resources/` references open as you build. +## The rules of this module -**Verification.** Type-check contracts with `tsc --noEmit` (+`@types/node`); run pure logic and auth with `node file.mjs` (real `node:crypto`); exercise endpoints with a real in-process HTTP server hit by `fetch` — no external services needed. Note `tsc file.ts` ignores `tsconfig.json` — use `--noEmit` or `-p`. +- The boundary is **untrusted** — parse, don't trust; only typed data reaches the service. +- Layers have clear contracts — business logic is testable without HTTP or a DB. +- **Fail safe, not open** — a wrong password or bad token yields no identity; deny by default. +- Least privilege; honest status codes; one consistent error envelope. +- The compiler is your first reviewer — a claim that doesn't type-check isn't true, and `any` is never the fix. diff --git a/assignments/README.md b/assignments/README.md deleted file mode 100644 index 988648b..0000000 --- a/assignments/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Assignments — Backend Engineering & API Design - -Each lesson has an assignment described in its `Lesson_NN.md`. Submit every one using `submission-template.md`, and back every claim with evidence — `tsc --noEmit` for contracts, Node tests for logic/auth, real requests for endpoints. - -| File | Purpose | -|------|---------| -| `submission-template.md` | per-lesson submission format | -| `capstone-brief.md` | the full FORGE-9400 platform-release specification | -| `capstone-submission-template.md` | the capstone release-report format | - -## What every submission must include -- **What you built** and *why this shape* — which layer logic lives in, how the boundary is validated, how security fails safe, what's idempotent. -- **Evidence:** the type-check result and confirmed contract errors; Node test output (service rules / validators / policy / auth with real crypto / jobs / ops); endpoint request/response transcripts (status codes, idempotency, 401/403, 429, readiness). -- **The fix at the cause** — no `any`, no silenced errors, no trusting input. -- **AI-usage log:** draft → verify (type-check + test/run + measure) → log. -- **Clean commits** (your Module 02 Git skills apply). - -## Grading -Against `../ASSESSMENT_RUBRIC.md`. The recurring standard: **the boundary is untrusted; layers with clear contracts; make illegal states unrepresentable; fail safe, not open; least privilege; measure first, design for failure.** diff --git a/assignments/capstone-brief.md b/assignments/capstone-brief.md deleted file mode 100644 index 7b541d3..0000000 --- a/assignments/capstone-brief.md +++ /dev/null @@ -1,68 +0,0 @@ -# Capstone Brief — FORGE-9400: Ship the Project Forge Backend Platform - -> **Epic:** FORGE-9400 · **Role:** Backend Software Engineer (release owner) · **Est. time:** 16–20 hours (staged) · **Submission:** `capstone-submission-template.md` - -## The situation -*Project Forge* has a frontend (Module 05) running against a mock at `api.forge.dev`. You own shipping the **real backend platform** that powers it: a layered REST API that is correct, secure, reliable, and operable — one the frontend team (and others) can build on. You integrate everything from this module into one coherent, strict-typed service, and you prove each quality bar with evidence. - -The capstone introduces **no new concepts.** It tests **integration, architecture, security, and judgment**: the layers, the security model, the platform standards, and the operational concerns all interact, and the security and reliability bars can't be bolted on at the end. - -## Platform scope -A working Forge backend with at least: -- **Orders REST API** — resource-oriented routes, honest status codes, a consistent error envelope (Lessons 1, 6). -- **Layered architecture** — controller → service → repository; business logic testable without HTTP (Lesson 2). -- **Boundary validation** — every request validated; only typed data reaches services (Lesson 3). -- **Authentication** — salted/slow-hashed passwords; signed, expiring tokens; verify-before-trust (Lesson 4). -- **Authorization** — role + ownership, least privilege; 403 vs 401 (Lesson 5). -- **Platform standards** — pagination, idempotency for unsafe retries, rate limiting (Lesson 6). -- **Background processing** — slow work on a queue with bounded retries, backoff, idempotency, dead-lettering (Lesson 7). -- **Operational readiness** — liveness/readiness, structured correlated logs, env config, graceful shutdown (Lesson 8). - -## Build order (follow it) -1. **REST + layers** — endpoints over controller → service → repository. (Lessons 1, 2) -2. **Validation** — schema validation at the boundary; only typed data into services. (Lesson 3) -3. **Security** — authentication, then authorization (role + ownership, least privilege). (Lessons 4, 5) -4. **Platform standards** — consistent error envelope, pagination, idempotency, rate limiting. (Lesson 6) -5. **Background processing** — move slow work to a queue with retries/backoff/idempotency/DLQ. (Lesson 7) -6. **Operational readiness & release** — health/readiness, structured logs, env config, graceful shutdown; assemble the release report. (Lesson 8) - -Keep it type-checking and the service tests green throughout; commit in small, verified increments. - -## Phases (stage the work) -- **Phase A — REST + layers + validation.** -- **Phase B — Security (authn + authz).** -- **Phase C — Platform standards + background processing.** -- **Phase D — Operational readiness + release report.** - -## Acceptance criteria → rubric mapping -| Acceptance criterion | Rubric category | -|----------------------|-----------------| -| Layered architecture; business logic testable without HTTP | Architecture (15%) | -| Authentication fails safe; authorization is role + ownership, least privilege | Security (15%) | -| Resource-oriented REST; consistent error envelope; pagination; idempotency; rate limiting | API Platform (15%) | -| Boundary validation; typed domain; repository interface (swappable data) | Data Platform (10%) | -| Background queue with bounded retries, backoff, idempotency, dead-lettering | Background Processing (10%) | -| Liveness/readiness, structured correlated logs, env config, graceful shutdown | Operations (10%) | -| Release report documents each bar with reproducible evidence | Documentation (10%) | -| Sound, justified architecture/security decisions; no over-engineering; no `any` | Engineering Judgment (10%) | -| AI used as draft → verify → log | AI Workflow (5%) | - -## Deliverables -1. **The working platform** — strict TypeScript, `tsc --noEmit` clean; service tests and endpoint checks reproducible (the in-process server + `fetch` pattern). Reuse the lab generators for the Orders domain, auth, and jobs so the build is reproducible. -2. **A release report** proving each quality bar with evidence: the layered architecture (service tests without HTTP); the REST contract + error envelope; boundary validation rejecting bad input before the service; authentication failing safe (tampered/expired → 401); authorization (403 vs 401, role + ownership); platform standards (idempotent retry acts once; over-limit → 429); background processing (transient recovers, permanent → DLQ); operational readiness (readiness reflects a downed dependency, logs correlate, missing config fails fast, shutdown drains). -3. **The engineering notebook**, including the **AI-usage log**. -4. **A "production-readiness" summary** — what each quality bar guarantees and how you verified it. - -## Definition of done -- [ ] Layered architecture; business logic testable without HTTP -- [ ] Resource-oriented REST; honest status codes; consistent error envelope -- [ ] Every request validated at the boundary; only typed data into services -- [ ] Authentication (salted/slow hash, signed expiring tokens) failing safe -- [ ] Authorization (role + ownership, least privilege); 403 vs 401 correct -- [ ] Platform standards: pagination, idempotency, rate limiting -- [ ] Background processing: bounded retries, backoff, idempotency, DLQ -- [ ] Operational readiness: liveness/readiness, structured logs, env config, graceful shutdown -- [ ] Strict TypeScript; release report + notebook + AI log complete and reproducible - -## The standard -The boundary is untrusted; layers with clear contracts; illegal states unrepresentable; **fail safe, not open**; least privilege; measure first, design for failure. A green type-check, a passing security test, and a real request transcript are how "production-ready" becomes true rather than asserted. diff --git a/assignments/capstone/README.md b/assignments/capstone/README.md new file mode 100644 index 0000000..1da60d7 --- /dev/null +++ b/assignments/capstone/README.md @@ -0,0 +1,40 @@ +# Capstone — FORGE-9400: Ship the Project Forge Backend Platform + +**Epic:** FORGE-9400 · **Role:** Backend Software Engineer (release owner) + +This is the integrated exercise: no new concepts, but you assemble the module — layered architecture, +boundary validation, authorization (role + ownership, least privilege), a consistent error envelope, and +pagination — into one strict, type-safe module. The full **release report + engineering notebook** are +submitted via the LMS using [`../capstone-submission-template.md`](../capstone-submission-template.md); the +code below is the part the autograder scores. + +## What you do +Implement every `// TODO` in [`src/forge.ts`](src/forge.ts): + +| Concern | What to do | From | +| --- | --- | --- | +| Domain | `OrderStatus` union; `Order` / `NewOrder = Omit` | Lessons 1–2 | +| Envelope | `errorEnvelope(code, message)` | Lesson 6 | +| Data | `InMemoryOrderRepository` behind `OrderRepository` | Lesson 2 | +| Validation | `validateNewOrder` narrows `unknown` → typed value or issues | Lesson 3 | +| Authorization | `policy.canViewOrder` (role + ownership) | Lesson 5 | +| Service | `OrderService` (getOrThrow / place / page) — no HTTP | Lesson 2 | +| Pagination | `clampLimit` | Lesson 6 | +| Handlers | `getOrderHandler` (401→404→403→200), `createOrderHandler`, `listOrdersHandler` | Lessons 1, 3, 5, 6 | + +Run: +```bash +npx vitest run assignments/capstone # behaviour +npm run test:types # type-level derivations +npm run check # strict, clean +``` + +## Definition of done +- All capstone tests pass and the project type-checks clean — **zero** `any` / `as` on untrusted input. +- The handler decides `401` **before** `404`/`403`, so an anonymous caller never learns whether a resource + exists. Your LMS release report proves each quality bar with reproducible evidence. + +## The standard +The boundary is untrusted; layers have clear contracts; illegal states are unrepresentable; **fail safe, +not open**; least privilege. A green type-check and a passing test are how "production-ready" becomes true +rather than asserted. diff --git a/assignments/capstone/src/forge.ts b/assignments/capstone/src/forge.ts new file mode 100644 index 0000000..f94e726 --- /dev/null +++ b/assignments/capstone/src/forge.ts @@ -0,0 +1,189 @@ +/** + * Capstone — FORGE-9400: Ship the Project Forge Backend Platform. + * + * Integrate the whole module into ONE coherent, strict-typed handler: + * layered architecture (service/repository) · boundary validation · authorization + * (role + ownership, least privilege) · consistent error envelope · pagination. + * + * No new concepts — assembly + judgment. No `any`, no `as` on untrusted input + * (validate/narrow instead). The LMS report + notebook are submitted separately; + * the code below is what the autograder scores. + */ + +// --- domain ------------------------------------------------------------------ + +export type OrderStatus = 'placed' | 'paid' | 'shipped' | 'cancelled'; +export interface Order { + id: string; + customerId: string; + status: OrderStatus; + total: number; +} +/** An order before it has a server-assigned id. */ +export type NewOrder = Omit; + +export type Role = 'customer' | 'support' | 'admin'; +export interface User { + id: string; + role: Role; +} + +// --- consistent error envelope ----------------------------------------------- + +export interface ApiError { + error: { code: string; message: string }; +} +export function errorEnvelope(code: string, message: string): ApiError { + // TODO: return the one true error shape. + return { error: { code: '', message: '' } }; +} + +// --- repository (data access behind an interface) ---------------------------- + +export interface OrderRepository { + find(id: string): Order | null; + list(): Order[]; + create(order: NewOrder): Order; +} + +/** In-memory repository. Generates ids `o-` starting at 1001. */ +export class InMemoryOrderRepository implements OrderRepository { + private seq = 1001; + private orders = new Map(); + constructor(seed: Order[] = []) { + for (const o of seed) this.orders.set(o.id, o); + } + find(id: string): Order | null { + // TODO + return null; + } + list(): Order[] { + // TODO + return []; + } + create(order: NewOrder): Order { + // TODO: assign id `o-${this.seq++}`, store, return the full Order. + void order; + return { id: '', customerId: '', status: 'placed', total: 0 }; + } +} + +// --- boundary validation ----------------------------------------------------- + +export interface FieldIssue { + field: string; + message: string; +} +export type ValidationResult = + | { ok: true; value: T } + | { ok: false; issues: FieldIssue[] }; + +/** + * Validate a create-order body (unknown) → typed `{ customerId, total }`. + * Rules: non-object → single `_` issue; `customerId` required non-empty string; + * `total` a number > 0. Collect all issues. (Status is server-assigned `placed`.) + */ +export function validateNewOrder(input: unknown): ValidationResult<{ customerId: string; total: number }> { + // TODO: narrow `unknown`, collect FieldIssues, return ok/issues. + void input; + return { ok: false, issues: [{ field: '_', message: 'not implemented' }] }; +} + +// --- authorization (role + ownership, least privilege) ----------------------- + +export const policy = { + /** admin/support, or the owner. */ + canViewOrder(user: User, order: Order): boolean { + // TODO + return false; + }, +}; + +// --- service (business rules; no HTTP) --------------------------------------- + +export class NotFoundError extends Error { + constructor(public id: string) { + super(`order ${id} not found`); + this.name = 'NotFoundError'; + } +} + +export class OrderService { + constructor(private repo: OrderRepository) {} + /** Fetch or throw NotFoundError. */ + getOrThrow(id: string): Order { + // TODO + throw new NotFoundError(id); + } + /** Create a placed order from validated input. */ + place(input: { customerId: string; total: number }): Order { + // TODO: repo.create({ ...input, status: 'placed' }). + void input; + throw new NotFoundError(''); + } + /** First `limit` orders (caller is responsible for clamping limit). */ + page(limit: number): Order[] { + // TODO: repo.list().slice(0, limit). + void limit; + return []; + } +} + +// --- HTTP-shaped responses & a thin handler ---------------------------------- + +export interface ApiResponse { + status: number; + body: unknown; +} + +/** Clamp a raw limit into 1..max (default def). Non-finite → def. */ +export function clampLimit(raw: unknown, def = 20, max = 100): number { + // TODO + return def; +} + +/** + * GET /orders/:id — fetch one order with authorization. + * - user null → 401 envelope(code 'unauthorized') + * - order missing → 404 envelope('not_found') + * - not permitted → 403 envelope('forbidden') + * - allowed → 200 with the order + * Decide 401 BEFORE 404/403 (don't leak existence to anonymous callers). + */ +export function getOrderHandler(user: User | null, id: string, service: OrderService): ApiResponse { + // TODO: 401 if no user; getOrThrow (catch NotFoundError → 404); policy check → 403; else 200. + void user; + void id; + void service; + return { status: 0, body: null }; +} + +/** + * POST /orders — create an order (must be authenticated). + * - user null → 401 envelope('unauthorized') + * - invalid body → 400 envelope('validation_failed') with `issues` attached to the body + * as `{ error: {...}, issues }` + * - valid → 201 with the created order + * The created order's customerId is the request body's customerId. + */ +export function createOrderHandler(user: User | null, body: unknown, service: OrderService): ApiResponse { + // TODO: 401 if no user; validateNewOrder(body) → 400 {...envelope, issues} on failure; + // else service.place(value) → 201. + void user; + void body; + void service; + return { status: 0, body: null }; +} + +/** + * GET /orders?limit= — list a clamped page (must be authenticated). + * - user null → 401 envelope('unauthorized') + * - else → 200 with { items, limit } where limit is clamped. + */ +export function listOrdersHandler(user: User | null, rawLimit: unknown, service: OrderService): ApiResponse { + // TODO: 401 if no user; clampLimit(rawLimit); 200 { items: service.page(limit), limit }. + void user; + void rawLimit; + void service; + return { status: 0, body: null }; +} diff --git a/assignments/capstone/tests/forge.test-d.ts b/assignments/capstone/tests/forge.test-d.ts new file mode 100644 index 0000000..ca316b5 --- /dev/null +++ b/assignments/capstone/tests/forge.test-d.ts @@ -0,0 +1,10 @@ +import { test, expectTypeOf } from 'vitest'; +import type { Order, OrderStatus, NewOrder } from '../src/forge'; + +test('OrderStatus is the exact literal union', () => { + expectTypeOf().toEqualTypeOf<'placed' | 'paid' | 'shipped' | 'cancelled'>(); +}); + +test('NewOrder is an Order without its id', () => { + expectTypeOf().toEqualTypeOf>(); +}); diff --git a/assignments/capstone/tests/forge.test.ts b/assignments/capstone/tests/forge.test.ts new file mode 100644 index 0000000..781c1fd --- /dev/null +++ b/assignments/capstone/tests/forge.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest'; +import { + InMemoryOrderRepository, + OrderService, + errorEnvelope, + validateNewOrder, + policy, + clampLimit, + getOrderHandler, + createOrderHandler, + listOrdersHandler, + type Order, + type User, +} from '../src/forge'; + +const seed = (): Order[] => [ + { id: 'o-1', customerId: 'u-owner', status: 'paid', total: 120 }, + { id: 'o-2', customerId: 'u-other', status: 'placed', total: 30 }, +]; +const makeService = () => new OrderService(new InMemoryOrderRepository(seed())); + +const owner: User = { id: 'u-owner', role: 'customer' }; +const stranger: User = { id: 'u-stranger', role: 'customer' }; +const support: User = { id: 'u-s', role: 'support' }; + +describe('capstone — envelope & validation', () => { + it('errorEnvelope is the one true shape', () => { + expect(errorEnvelope('not_found', 'gone')).toEqual({ error: { code: 'not_found', message: 'gone' } }); + }); + it('validates a create body', () => { + expect(validateNewOrder({ customerId: 'u-1', total: 10 })).toEqual({ + ok: true, + value: { customerId: 'u-1', total: 10 }, + }); + const bad = validateNewOrder({ customerId: '', total: -1 }); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.issues.map((i) => i.field).sort()).toEqual(['customerId', 'total']); + expect(validateNewOrder(null).ok).toBe(false); + }); +}); + +describe('capstone — repository & service', () => { + it('creates and lists', () => { + const repo = new InMemoryOrderRepository(); + const created = repo.create({ customerId: 'u-1', status: 'placed', total: 5 }); + expect(created.id).toMatch(/^o-\d+$/); + expect(repo.find(created.id)).toEqual(created); + expect(repo.list()).toHaveLength(1); + }); + it('service.place produces a placed order; page slices', () => { + const svc = makeService(); + const placed = svc.place({ customerId: 'u-owner', total: 9 }); + expect(placed.status).toBe('placed'); + expect(svc.page(1)).toHaveLength(1); + expect(svc.page(99)).toHaveLength(3); // 2 seed + 1 placed + }); +}); + +describe('capstone — authorization policy', () => { + it('owner and support can view; stranger cannot', () => { + const order = seed()[0]!; + expect(policy.canViewOrder(owner, order)).toBe(true); + expect(policy.canViewOrder(support, order)).toBe(true); + expect(policy.canViewOrder(stranger, order)).toBe(false); + }); +}); + +describe('capstone — clampLimit', () => { + it('defaults and clamps', () => { + expect(clampLimit(undefined)).toBe(20); + expect(clampLimit(99999)).toBe(100); + expect(clampLimit(0)).toBe(1); + expect(clampLimit('15')).toBe(15); + }); +}); + +describe('capstone — GET /orders/:id handler (401 → 404 → 403 → 200)', () => { + it('401 for an anonymous caller (before leaking existence)', () => { + const res = getOrderHandler(null, 'o-1', makeService()); + expect(res.status).toBe(401); + expect(res.body).toEqual(errorEnvelope('unauthorized', expect.any(String) as unknown as string)); + }); + it('404 when the order is missing', () => { + expect(getOrderHandler(owner, 'missing', makeService()).status).toBe(404); + }); + it('403 when authenticated but not permitted', () => { + expect(getOrderHandler(stranger, 'o-1', makeService()).status).toBe(403); + }); + it('200 with the order for the owner', () => { + const res = getOrderHandler(owner, 'o-1', makeService()); + expect(res.status).toBe(200); + expect((res.body as Order).id).toBe('o-1'); + }); +}); + +describe('capstone — POST /orders handler', () => { + it('401 for an anonymous caller', () => { + expect(createOrderHandler(null, { customerId: 'u-owner', total: 10 }, makeService()).status).toBe(401); + }); + it('400 with issues for an invalid body', () => { + const res = createOrderHandler(owner, { customerId: '', total: -1 }, makeService()); + expect(res.status).toBe(400); + expect((res.body as { error: { code: string } }).error.code).toBe('validation_failed'); + expect(Array.isArray((res.body as { issues: unknown[] }).issues)).toBe(true); + }); + it('201 with the created order for a valid body', () => { + const svc = makeService(); + const res = createOrderHandler(owner, { customerId: 'u-owner', total: 77 }, svc); + expect(res.status).toBe(201); + const created = res.body as Order; + expect(created.status).toBe('placed'); + expect(created.customerId).toBe('u-owner'); + expect(svc.getOrThrow(created.id).total).toBe(77); + }); +}); + +describe('capstone — GET /orders list handler', () => { + it('401 for an anonymous caller', () => { + expect(listOrdersHandler(null, undefined, makeService()).status).toBe(401); + }); + it('200 with a clamped page', () => { + const res = listOrdersHandler(owner, 1, makeService()); + expect(res.status).toBe(200); + const body = res.body as { items: Order[]; limit: number }; + expect(body.limit).toBe(1); + expect(body.items).toHaveLength(1); + }); +}); diff --git a/labs/README.md b/labs/README.md deleted file mode 100644 index 47eefe5..0000000 --- a/labs/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Labs — Backend Engineering & API Design - -Hands-on labs for each lesson. The platform you build is the Forge backend, carried through from lesson to lesson. Three kinds of verification run through the labs: - -- **Type-check the contracts.** Controllers, services, repositories, validators, and domain types are TypeScript checked with `tsc` (with `@types/node`) — illegal states and broken contracts are compile errors (your Module 04 standard). -- **Unit-test the logic in Node.** Service business rules, validators, auth (real `node:crypto`), authorization policy, pagination/idempotency/rate-limit math, job retry/backoff, and ops logic are pure and asserted with `node` — no server or database needed. -- **Verify endpoints end-to-end.** A real in-process HTTP server (Node's built-in `http`) is started and hit with `fetch`, asserting routes, methods, and status codes behave correctly. No external services. - -## How to use a lab -1. Read the matching `Lesson_NN.md` first. -2. Run the **Setup** (a generator writes files under `/tmp/swexp-be` or your project). -3. Work the **Tasks**, type-checking with `tsc --noEmit` and running the Node/HTTP tests as you go. -4. Produce the **Deliverable** for your engineering notebook (include type-check output, test results, request/response evidence). -5. Check your reasoning against `solutions/lab-NN-solution.md`. - -## Ground rules -- **TypeScript everywhere.** Typed controllers, services, repositories, and domain (Module 04 standard). No `any` escape hatches. -- **The boundary is untrusted.** Validate every request; never trust `req.body`. -- **Fail safe, not open.** Security defaults to deny; an unverified token is no identity. -- **Evidence, not assertion.** Paste real `tsc` output, Node test results, or request/response transcripts. -- **`tsc --noEmit`** type-checks; `node file.mjs` runs logic and in-process HTTP tests. `tsc file.ts` ignores `tsconfig.json` — use `-p` or no file argument. - -## Prerequisites -- **Node.js** (`node --version`) and **npm**. -- **TypeScript** + **Node types** (lab-00: `npm i -D typescript @types/node`). -- Node 18+ for global `fetch` (used to exercise the in-process server). `node:crypto` is built in (used for auth). - -## Lab index -| # | Lab | Focus | -|---|-----|-------| -| 0 | `lab-00-setup.md` | Node + TS toolchain; a minimal HTTP service | -| 1 | `lab-01-rest-api.md` | resource-oriented REST + typed controllers | -| 2 | `lab-02-service-layer.md` | controller → service → repository layers | -| 3 | `lab-03-validation.md` | boundary validation; structured 400s | -| 4 | `lab-04-auth.md` | password hashing + signed tokens (real crypto) | -| 5 | `lab-05-authz.md` | role + ownership authorization (403 vs 401) | -| 6 | `lab-06-api-platform.md` | error envelope, pagination, idempotency, rate limiting | -| 7 | `lab-07-jobs.md` | background queue: retries, backoff, idempotency, DLQ | -| 8 | `lab-08-ops.md` | liveness/readiness, structured logs, config, graceful shutdown | - -The Lesson 09 capstone reuses these to ship the full Forge backend platform. diff --git a/labs/lab-00-setup.md b/labs/lab-00-setup.md deleted file mode 100644 index 6cb5177..0000000 --- a/labs/lab-00-setup.md +++ /dev/null @@ -1,69 +0,0 @@ -# Lab 00 — Toolchain & a Minimal HTTP Service - -**Lesson:** 00 · **Goal:** a running Node + TypeScript HTTP service with a `/health` endpoint, type-checked and verified with a real request. - -## Goal -Stand up the backend toolchain, serve a deliberate `/health` response, and confirm it behaves by making a real request to it. - -## Setup -```bash -mkdir -p /tmp/swexp-be && cd /tmp/swexp-be -npm init -y >/dev/null -npm i -D typescript @types/node -cat > tsconfig.json <<'JSON' -{ "compilerOptions": { - "target": "ES2022", "module": "NodeNext", "moduleResolution": "nodenext", - "strict": true, "noEmit": true, "skipLibCheck": true, - "lib": ["ES2022"], "types": ["node"] }, - "include": ["*.ts"] } -JSON -cat > server.ts <<'TS' -import http from 'node:http'; -export function createServer() { - return http.createServer((req, res) => { - if (req.method === 'GET' && req.url === '/health') { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok' })); - return; - } - res.writeHead(404, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ error: 'not_found' })); - }); -} -TS -echo "Type-check, then verify with a real request:" -tsc --noEmit -``` - -## Tasks -1. **Type-check** the service: `tsc --noEmit` exits 0. -2. **Serve `/health`** returning a deliberate `200` JSON `{ status: 'ok' }` with a JSON content type; unknown paths return `404 { error: 'not_found' }`. -3. **Verify with a real request.** Write a small `.mjs` test that starts the server on an ephemeral port, `fetch`es `/health` (assert 200 + body) and an unknown path (assert 404), then closes it. -4. **Trace the lifecycle.** In your notebook, follow one request: received → method/url matched → handled → responded, noting the status and content type you set explicitly. -5. **Backend vs frontend.** Write 5–8 sentences on what must live server-side and why. - -## Verify (example harness) -```bash -cat > verify.mjs <<'JS' -import http from 'node:http'; -import assert from 'node:assert'; -const server = http.createServer((req,res)=>{ if(req.method==='GET'&&req.url==='/health'){res.writeHead(200,{'content-type':'application/json'});res.end(JSON.stringify({status:'ok'}));return;} res.writeHead(404,{'content-type':'application/json'});res.end(JSON.stringify({error:'not_found'})); }); -await new Promise(r=>server.listen(0,r)); -const p=server.address().port; -const a=await fetch(`http://127.0.0.1:${p}/health`); assert.strictEqual(a.status,200); assert.deepStrictEqual(await a.json(),{status:'ok'}); -const b=await fetch(`http://127.0.0.1:${p}/missing`); assert.strictEqual(b.status,404); -server.close(); console.log('HEALTH ENDPOINT VERIFIED'); -JS -node verify.mjs -``` - -## Deliverable -`node --version`; the clean type-check; the verified request/response for `/health` (200) and an unknown path (404); and your backend-vs-frontend explainer. - -## Cleanup -```bash -rm -f /tmp/swexp-be/verify.mjs # keep the project; you'll build on it -``` - -## Check -`../solutions/lab-00-solution.md`. diff --git a/labs/lab-00-setup/README.md b/labs/lab-00-setup/README.md new file mode 100644 index 0000000..27eae6f --- /dev/null +++ b/labs/lab-00-setup/README.md @@ -0,0 +1,23 @@ +# Lab 00 — Toolchain & a Minimal HTTP Service + +**Lesson:** 00 · **Goal:** a deliberate `/health` endpoint, decided by pure handler logic and verified by tests. + +## What you do +In [`src/health.ts`](src/health.ts), implement `handleRequest(method, url)` — the same decision a +`http.createServer` callback makes, isolated so it is trivially testable (no real socket): + +- `GET /health` → `200`, content-type `application/json`, body `{ status: 'ok' }`. +- anything else → `404`, content-type `application/json`, body `{ error: 'not_found' }`. + +Run: +```bash +npx vitest run labs/lab-00-setup +``` + +## Definition of done +- All tests pass; `npm run check` clean. +- In your notebook, trace one request: received → method/url matched → handled → responded, noting the + status and content type you set **explicitly**. Write 5–8 sentences on what must live server-side and why. + +## Submit +Edit `src/`, run the tests, commit and push. diff --git a/labs/lab-00-setup/src/health.ts b/labs/lab-00-setup/src/health.ts new file mode 100644 index 0000000..74fce75 --- /dev/null +++ b/labs/lab-00-setup/src/health.ts @@ -0,0 +1,27 @@ +/** + * Lab 00 — Toolchain & a Minimal HTTP Service. See README.md. + * + * We test the *handler logic* directly (no real socket): a pure function that maps + * a request (method + url) to a response (status + body + content type). This is the + * same decision a real `http.createServer` callback makes — just isolated so it is + * trivially testable. + * + * Implement by deciding on method/url — no `any`, no `as`. + */ + +export interface HttpResponse { + status: number; + contentType: string; + body: unknown; +} + +/** + * Route a request to a response. + * - `GET /health` → 200, content-type `application/json`, body `{ status: 'ok' }`. + * - anything else → 404, content-type `application/json`, body `{ error: 'not_found' }`. + */ +export function handleRequest(method: string, url: string): HttpResponse { + // TODO: return the deliberate 200 health response for `GET /health`, + // otherwise the 404 not_found response. + return { status: 0, contentType: '', body: null }; +} diff --git a/labs/lab-00-setup/tests/health.test.ts b/labs/lab-00-setup/tests/health.test.ts new file mode 100644 index 0000000..72dab53 --- /dev/null +++ b/labs/lab-00-setup/tests/health.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { handleRequest } from '../src/health'; + +describe('lab 00 — minimal HTTP service (handler logic)', () => { + it('GET /health returns 200 with { status: "ok" }', () => { + const res = handleRequest('GET', '/health'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'ok' }); + }); + + it('GET /health responds as JSON', () => { + expect(handleRequest('GET', '/health').contentType).toBe('application/json'); + }); + + it('unknown paths return 404 not_found', () => { + const res = handleRequest('GET', '/missing'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'not_found' }); + expect(res.contentType).toBe('application/json'); + }); + + it('a non-GET method on /health is not the health response', () => { + // /health is a GET resource; other methods fall through to 404 here. + expect(handleRequest('POST', '/health').status).toBe(404); + }); +}); diff --git a/labs/lab-01-rest-api.md b/labs/lab-01-rest-api.md deleted file mode 100644 index 5e6ec7d..0000000 --- a/labs/lab-01-rest-api.md +++ /dev/null @@ -1,62 +0,0 @@ -# Lab 01 — Replace the Mock API - -**Lesson:** 01 · **Goal:** build the Orders REST API with typed controllers and a tiny router; verify routes, methods, and status codes end-to-end. - -## Goal -Replace the mock with real resource-oriented endpoints and prove each behaves with real requests. - -## Setup -```bash -cd /tmp/swexp-be -cat > orders-api.ts <<'TS' -import http from 'node:http'; - -export type OrderStatus = 'placed' | 'paid' | 'shipped' | 'cancelled'; -export interface Order { id: string; customer: string; status: OrderStatus; total: number; } - -// In-memory store for the lab (becomes a repository in Lesson 2). -const store = new Map([ - ['o-1001', { id: 'o-1001', customer: 'Ada', status: 'paid', total: 120 }], -]); - -export interface ApiResponse { status: number; body: unknown; } - -// Controllers: map request → response. No routing logic here. -export const controllers = { - listOrders(): ApiResponse { return { status: 200, body: [...store.values()] }; }, - getOrder(id: string): ApiResponse { - const o = store.get(id); - return o ? { status: 200, body: o } : { status: 404, body: { error: 'not_found' } }; - }, - createOrder(body: unknown): ApiResponse { - // (real validation arrives in Lesson 3 — here just a minimal shape check) - if (typeof body !== 'object' || body === null) return { status: 400, body: { error: 'bad_request' } }; - const b = body as Partial; - if (!b.customer || typeof b.total !== 'number') return { status: 400, body: { error: 'bad_request' } }; - const id = `o-${1002 + store.size}`; - const order: Order = { id, customer: b.customer, status: 'placed', total: b.total }; - store.set(id, order); - return { status: 201, body: order }; - }, -}; -TS -echo "Build the router + server around the controllers, then verify end-to-end." -``` - -## Tasks -1. **Resource-oriented routes:** `GET /orders`, `GET /orders/:id`, `POST /orders`. Methods on resources, not verbs in URLs. -2. **Typed controllers** map request → `ApiResponse`; keep the router thin (match route → call controller → write response). -3. **Honest status codes:** 200 list/fetch, 201 create (set a `Location: /orders/:id` header), 404 missing, 400 bad body, 405 for an unsupported method on a known path. -4. **JSON everywhere:** parse the request body as JSON; set `content-type: application/json` on responses. -5. **Verify end-to-end:** start the server on an ephemeral port and `fetch` each case — assert the status, body, and the `Location` header on create. - -## Deliverable -The typed controllers + router; the endpoint→method→status mapping; and the passing end-to-end transcript (200 list, 201 create with Location, 404 missing, 400 bad body). - -## Cleanup -```bash -rm -f /tmp/swexp-be/orders-api.ts -``` - -## Check -`../solutions/lab-01-solution.md`. diff --git a/labs/lab-01-rest-api/README.md b/labs/lab-01-rest-api/README.md new file mode 100644 index 0000000..4a4c1f3 --- /dev/null +++ b/labs/lab-01-rest-api/README.md @@ -0,0 +1,26 @@ +# Lab 01 — Replace the Mock API + +**Ticket:** REST-1001 · **Goal:** build the Orders REST API with typed controllers and a thin router. + +## What you do +In [`src/orders-api.ts`](src/orders-api.ts), implement the controllers and the `route()` dispatcher: + +- **Resource-oriented routes:** `GET /orders`, `GET /orders/:id`, `POST /orders` — methods act on + resources, no verbs in the URL. +- **Typed controllers** map a request → `ApiResponse`; the router just matches a route → calls a controller. +- **Honest status codes:** `200` list/fetch, `201` create (with a `Location: /orders/:id` header), + `404` missing, `400` bad body, `405` for a wrong method on a known path. +- A minimal shape check on the create body only — **narrow** `unknown`, never `as` it into a lie. (Real + validation arrives in Lab 03.) + +Run: +```bash +npx vitest run labs/lab-01-rest-api +``` + +## Definition of done +- All tests pass; `npm run check` clean. +- In your notebook, write the endpoint → method → status mapping. + +## Submit +Edit `src/`, run the tests, commit and push. diff --git a/labs/lab-01-rest-api/src/orders-api.ts b/labs/lab-01-rest-api/src/orders-api.ts new file mode 100644 index 0000000..ba08d3e --- /dev/null +++ b/labs/lab-01-rest-api/src/orders-api.ts @@ -0,0 +1,100 @@ +/** + * Lab 01 — Replace the Mock API. See README.md. + * + * Build resource-oriented Orders endpoints as pure controller + router logic. + * Controllers map a request → an `ApiResponse`; the router matches a route → calls a + * controller. No real server is needed to prove the contract. + * + * No `any`, no `as` on the typed paths. + */ + +export type OrderStatus = 'placed' | 'paid' | 'shipped' | 'cancelled'; +export interface Order { + id: string; + customer: string; + status: OrderStatus; + total: number; +} + +export interface ApiResponse { + status: number; + body: unknown; + /** Extra response headers, e.g. `{ Location: '/orders/o-1002' }` on create. */ + headers?: Record; +} + +/** A request the router dispatches. `body` is untrusted (`unknown`) until validated. */ +export interface ApiRequest { + method: string; + /** Path only, e.g. `/orders` or `/orders/o-1001`. */ + path: string; + body?: unknown; +} + +/** In-memory store for the lab (becomes a repository in Lab 02). */ +export class OrderStore { + private seq = 1002; + private orders = new Map(); + + constructor(seed: Order[] = []) { + for (const o of seed) this.orders.set(o.id, o); + } + + list(): Order[] { + return [...this.orders.values()]; + } + find(id: string): Order | undefined { + return this.orders.get(id); + } + /** Insert a new order with a generated id and `placed` status. */ + create(customer: string, total: number): Order { + const id = `o-${this.seq++}`; + const order: Order = { id, customer, status: 'placed', total }; + this.orders.set(id, order); + return order; + } +} + +export function makeControllers(store: OrderStore) { + return { + /** GET /orders → 200 with the array of orders. */ + listOrders(): ApiResponse { + // TODO: 200 with all orders from the store. + return { status: 0, body: null }; + }, + + /** GET /orders/:id → 200 with the order, or 404 not_found. */ + getOrder(id: string): ApiResponse { + // TODO: look the order up; 200 if found, else 404 { error: 'not_found' }. + return { status: 0, body: null }; + }, + + /** + * POST /orders → 201 with the created order and a `Location: /orders/:id` header. + * A minimal shape check only (real validation arrives in Lab 03): the body must be a + * non-null object with a non-empty `customer` string and a numeric `total`; otherwise + * 400 { error: 'bad_request' }. + */ + createOrder(body: unknown): ApiResponse { + // TODO: minimal shape check on `body` (no `as` to lie about its shape — narrow it), + // then store.create(...) and return 201 with a Location header. + return { status: 0, body: null }; + }, + }; +} + +/** + * Thin router: match (method, path) → controller. Resource-oriented — methods act on + * resources, no verbs in the URL. + * - `GET /orders` → listOrders + * - `GET /orders/:id` → getOrder(id) + * - `POST /orders` → createOrder(body) + * - known path, wrong method → 405 { error: 'method_not_allowed' } + * - unknown path → 404 { error: 'not_found' } + */ +export function route(req: ApiRequest, store: OrderStore): ApiResponse { + const controllers = makeControllers(store); + // TODO: dispatch on req.method + req.path. Use the 405 / 404 fallbacks above. + void controllers; + return { status: 0, body: null }; +} diff --git a/labs/lab-01-rest-api/tests/orders-api.test.ts b/labs/lab-01-rest-api/tests/orders-api.test.ts new file mode 100644 index 0000000..43c1145 --- /dev/null +++ b/labs/lab-01-rest-api/tests/orders-api.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { OrderStore, route, type Order } from '../src/orders-api'; + +const seed: Order[] = [{ id: 'o-1001', customer: 'Ada', status: 'paid', total: 120 }]; +const freshStore = () => new OrderStore(seed.map((o) => ({ ...o }))); + +describe('lab 01 — orders REST API', () => { + it('GET /orders lists orders with 200', () => { + const res = route({ method: 'GET', path: '/orders' }, freshStore()); + expect(res.status).toBe(200); + expect(res.body).toEqual([{ id: 'o-1001', customer: 'Ada', status: 'paid', total: 120 }]); + }); + + it('GET /orders/:id returns the order with 200', () => { + const res = route({ method: 'GET', path: '/orders/o-1001' }, freshStore()); + expect(res.status).toBe(200); + expect(res.body).toEqual({ id: 'o-1001', customer: 'Ada', status: 'paid', total: 120 }); + }); + + it('GET /orders/:id returns 404 for a missing order', () => { + const res = route({ method: 'GET', path: '/orders/nope' }, freshStore()); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'not_found' }); + }); + + it('POST /orders creates an order: 201, placed status, Location header', () => { + const store = freshStore(); + const res = route({ method: 'POST', path: '/orders', body: { customer: 'Lin', total: 50 } }, store); + expect(res.status).toBe(201); + const created = res.body as Order; + expect(created.customer).toBe('Lin'); + expect(created.total).toBe(50); + expect(created.status).toBe('placed'); + expect(created.id).toMatch(/^o-\d+$/); + expect(res.headers?.Location).toBe(`/orders/${created.id}`); + // it actually persisted + const fetched = route({ method: 'GET', path: `/orders/${created.id}` }, store); + expect(fetched.status).toBe(200); + }); + + it('POST /orders with a bad body returns 400 bad_request', () => { + const store = freshStore(); + expect(route({ method: 'POST', path: '/orders', body: null }, store).status).toBe(400); + expect(route({ method: 'POST', path: '/orders', body: { customer: '' } }, store).status).toBe(400); + expect( + route({ method: 'POST', path: '/orders', body: { customer: 'X', total: 'lots' } }, store).status, + ).toBe(400); + }); + + it('a wrong method on a known path returns 405', () => { + const res = route({ method: 'DELETE', path: '/orders' }, freshStore()); + expect(res.status).toBe(405); + expect(res.body).toEqual({ error: 'method_not_allowed' }); + }); + + it('an unknown path returns 404', () => { + const res = route({ method: 'GET', path: '/widgets' }, freshStore()); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'not_found' }); + }); +}); diff --git a/labs/lab-02-service-layer.md b/labs/lab-02-service-layer.md deleted file mode 100644 index ff6b29e..0000000 --- a/labs/lab-02-service-layer.md +++ /dev/null @@ -1,61 +0,0 @@ -# Lab 02 — Organize the Service Layer - -**Lesson:** 02 · **Goal:** refactor into controller → service → repository; unit-test the service's business rules without HTTP. - -## Goal -Pull business logic out of the controller into an `OrderService` that depends on an `OrderRepository` interface, and test the rules with a fake repo — no server, no DB. - -## Setup -```bash -cd /tmp/swexp-be -cat > orders-service.ts <<'TS' -export type OrderStatus = 'placed' | 'paid' | 'shipped' | 'cancelled'; -export interface Order { id: string; customer: string; status: OrderStatus; total: number; } - -export class NotFoundError extends Error { constructor(public resource: string, public id: string) { super(`${resource} ${id} not found`); } } -export class ConflictError extends Error {} - -// Data access behind an interface — the service doesn't know if it's a Map, SQL, or an API. -export interface OrderRepository { - find(id: string): Promise; - list(): Promise; - save(order: Order): Promise; -} - -// Business logic. No HTTP, no DB driver — just the domain. -export class OrderService { - constructor(private repo: OrderRepository) {} - async get(id: string): Promise { - const o = await this.repo.find(id); - if (!o) throw new NotFoundError('order', id); - return o; - } - async markPaid(id: string): Promise { - const o = await this.get(id); - if (o.status === 'cancelled') throw new ConflictError('cannot pay a cancelled order'); - const updated: Order = { ...o, status: 'paid' }; - await this.repo.save(updated); - return updated; - } -} -TS -echo "Add an in-memory repository + a thin controller; unit-test the service in Node." -``` - -## Tasks -1. **Three layers.** Controller (HTTP ↔ domain), `OrderService` (business rules), `OrderRepository` (data access interface). The Lesson 1 controller becomes a thin mapper that calls the service and turns domain errors into status codes (`NotFoundError`→404, `ConflictError`→409). -2. **In-memory repository** implementing `OrderRepository` with a `Map`. -3. **No leaky layers.** The service imports no HTTP and no DB driver; the controller has no business rules. -4. **Unit-test the service** in Node with a fake repo: `markPaid` succeeds and persists; paying a `cancelled` order throws `ConflictError`; a missing order throws `NotFoundError`. No server, no DB. -5. **Prove the seam:** swap the fake repo for a second fake and confirm the same tests pass. - -## Deliverable -The controller/service/repository split; the repository interface + in-memory impl; passing Node unit tests of the service rules (no HTTP/DB); and a note confirming the service has zero HTTP/DB imports. - -## Cleanup -```bash -rm -f /tmp/swexp-be/orders-service.ts -``` - -## Check -`../solutions/lab-02-solution.md`. diff --git a/labs/lab-02-service-layer/README.md b/labs/lab-02-service-layer/README.md new file mode 100644 index 0000000..c23c2cd --- /dev/null +++ b/labs/lab-02-service-layer/README.md @@ -0,0 +1,27 @@ +# Lab 02 — Organize the Service Layer + +**Ticket:** ARCH-2001 · **Goal:** controller → service → repository; business rules testable without HTTP or a DB. + +## What you do +In [`src/orders-service.ts`](src/orders-service.ts): + +- **`InMemoryOrderRepository`** implements the `OrderRepository` interface over a `Map`. +- **`OrderService`** holds the business rules — `get` (throws `NotFoundError` when absent) and `markPaid` + (throws `ConflictError` for a cancelled order; otherwise an **immutable** update to `paid`). It imports + no HTTP and no DB driver. +- **`toResponse`** is the thin controller seam: success → `200`, `NotFoundError` → `404`, + `ConflictError` → `409`. + +The tests unit-test the service with a fake repo, then **swap** in a second fake to prove the seam. + +Run: +```bash +npx vitest run labs/lab-02-service-layer +``` + +## Definition of done +- All tests pass; `npm run check` clean. +- Note that the service has **zero** HTTP/DB imports. + +## Submit +Edit `src/`, run the tests, commit and push. diff --git a/labs/lab-02-service-layer/src/orders-service.ts b/labs/lab-02-service-layer/src/orders-service.ts new file mode 100644 index 0000000..ac3c6ea --- /dev/null +++ b/labs/lab-02-service-layer/src/orders-service.ts @@ -0,0 +1,94 @@ +/** + * Lab 02 — Organize the Service Layer. See README.md. + * + * Pull business logic into an `OrderService` that depends on an `OrderRepository` + * interface. The service knows nothing about HTTP or the storage engine. A thin + * controller maps domain errors → status codes. + * + * No `any`, no `as`. + */ + +export type OrderStatus = 'placed' | 'paid' | 'shipped' | 'cancelled'; +export interface Order { + id: string; + customer: string; + status: OrderStatus; + total: number; +} + +export class NotFoundError extends Error { + constructor(public resource: string, public id: string) { + super(`${resource} ${id} not found`); + this.name = 'NotFoundError'; + } +} +export class ConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConflictError'; + } +} + +/** Data access behind an interface — the service doesn't know Map vs SQL vs API. */ +export interface OrderRepository { + find(id: string): Promise; + list(): Promise; + save(order: Order): Promise; +} + +/** In-memory repository backed by a Map. */ +export class InMemoryOrderRepository implements OrderRepository { + private orders = new Map(); + constructor(seed: Order[] = []) { + for (const o of seed) this.orders.set(o.id, o); + } + async find(id: string): Promise { + // TODO: return the order or null. + return null; + } + async list(): Promise { + // TODO: return all orders. + return []; + } + async save(order: Order): Promise { + // TODO: upsert the order. + } +} + +/** Business logic. No HTTP, no DB driver — just the domain. */ +export class OrderService { + constructor(private repo: OrderRepository) {} + + /** Fetch an order or throw NotFoundError. */ + async get(id: string): Promise { + // TODO: find via the repo; throw new NotFoundError('order', id) when absent. + throw new NotFoundError('order', id); + } + + /** + * Mark an order paid. A cancelled order cannot be paid (ConflictError). + * Return the updated order WITHOUT mutating the original (immutable update). + */ + async markPaid(id: string): Promise { + // TODO: get the order; if status === 'cancelled' throw new ConflictError(...); + // otherwise save a copy with status 'paid' and return it. + throw new NotFoundError('order', id); + } +} + +export interface ApiResponse { + status: number; + body: unknown; +} + +/** + * Thin controller seam: run the work, map domain errors → status codes. + * - NotFoundError → 404 { error: 'not_found' } + * - ConflictError → 409 { error: 'conflict', message } + * - success → 200 with the value + */ +export async function toResponse(work: () => Promise): Promise { + // TODO: try the work (200 with the value); catch NotFoundError → 404, ConflictError → 409. + void work; + return { status: 0, body: null }; +} diff --git a/labs/lab-02-service-layer/tests/orders-service.test.ts b/labs/lab-02-service-layer/tests/orders-service.test.ts new file mode 100644 index 0000000..3d0c071 --- /dev/null +++ b/labs/lab-02-service-layer/tests/orders-service.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { + InMemoryOrderRepository, + OrderService, + NotFoundError, + ConflictError, + toResponse, + type Order, + type OrderRepository, +} from '../src/orders-service'; + +const seed = (): Order[] => [ + { id: 'o-1', customer: 'Ada', status: 'placed', total: 100 }, + { id: 'o-2', customer: 'Lin', status: 'cancelled', total: 40 }, +]; + +describe('lab 02 — service layer business rules (no HTTP, no DB)', () => { + it('get returns an existing order', async () => { + const svc = new OrderService(new InMemoryOrderRepository(seed())); + expect(await svc.get('o-1')).toMatchObject({ id: 'o-1', customer: 'Ada' }); + }); + + it('get throws NotFoundError for a missing order', async () => { + const svc = new OrderService(new InMemoryOrderRepository(seed())); + await expect(svc.get('missing')).rejects.toBeInstanceOf(NotFoundError); + }); + + it('markPaid succeeds and persists the new status', async () => { + const repo = new InMemoryOrderRepository(seed()); + const svc = new OrderService(repo); + const updated = await svc.markPaid('o-1'); + expect(updated.status).toBe('paid'); + expect((await repo.find('o-1'))?.status).toBe('paid'); + }); + + it('markPaid does not mutate the original order object (immutable update)', async () => { + const repo = new InMemoryOrderRepository(seed()); + const original = await repo.find('o-1'); + const svc = new OrderService(repo); + await svc.markPaid('o-1'); + expect(original?.status).toBe('placed'); // the object we read before is unchanged + }); + + it('markPaid on a cancelled order throws ConflictError', async () => { + const svc = new OrderService(new InMemoryOrderRepository(seed())); + await expect(svc.markPaid('o-2')).rejects.toBeInstanceOf(ConflictError); + }); + + it('works against any OrderRepository (swap the seam)', async () => { + // A second, hand-rolled fake repo — same tests must pass. + const map = new Map(seed().map((o) => [o.id, o])); + const fake: OrderRepository = { + async find(id) { + return map.get(id) ?? null; + }, + async list() { + return [...map.values()]; + }, + async save(o) { + map.set(o.id, o); + }, + }; + const svc = new OrderService(fake); + expect((await svc.markPaid('o-1')).status).toBe('paid'); + await expect(svc.markPaid('o-2')).rejects.toBeInstanceOf(ConflictError); + }); +}); + +describe('lab 02 — controller maps domain errors → status codes', () => { + it('success → 200 with the value', async () => { + const res = await toResponse(async () => ({ id: 'o-1' })); + expect(res).toEqual({ status: 200, body: { id: 'o-1' } }); + }); + + it('NotFoundError → 404', async () => { + const res = await toResponse(async () => { + throw new NotFoundError('order', 'x'); + }); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'not_found' }); + }); + + it('ConflictError → 409', async () => { + const res = await toResponse(async () => { + throw new ConflictError('cannot pay a cancelled order'); + }); + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ error: 'conflict', message: 'cannot pay a cancelled order' }); + }); +}); diff --git a/labs/lab-03-validation.md b/labs/lab-03-validation.md deleted file mode 100644 index 7374d25..0000000 --- a/labs/lab-03-validation.md +++ /dev/null @@ -1,52 +0,0 @@ -# Lab 03 — Stop Invalid Requests at the Door - -**Lesson:** 03 · **Goal:** validate request bodies at the boundary, reject bad input with a structured 400, and pass only typed data to the service. - -## Goal -Turn the minimal shape-check from Lesson 1 into real boundary validation: a single validator narrows `unknown` → a typed value or a useful error, and nothing invalid reaches the service. - -## Setup -```bash -cd /tmp/swexp-be -cat > validation.ts <<'TS' -export type OrderStatus = 'placed' | 'paid' | 'shipped' | 'cancelled'; -export interface CreateOrderInput { customer: string; total: number; status: OrderStatus; } - -export interface FieldIssue { field: string; message: string; } -export type ValidationResult = - | { ok: true; value: T } - | { ok: false; issues: FieldIssue[] }; - -// Parse, don't trust: narrow unknown → typed value, collecting per-field issues. -export function validateCreateOrder(input: unknown): ValidationResult { - const issues: FieldIssue[] = []; - if (typeof input !== 'object' || input === null) return { ok: false, issues: [{ field: '_', message: 'body must be an object' }] }; - const b = input as Record; - if (typeof b.customer !== 'string' || b.customer.trim() === '') issues.push({ field: 'customer', message: 'required non-empty string' }); - if (typeof b.total !== 'number' || !(b.total > 0)) issues.push({ field: 'total', message: 'must be a positive number' }); - const statuses: OrderStatus[] = ['placed', 'paid', 'shipped', 'cancelled']; - if (!statuses.includes(b.status as OrderStatus)) issues.push({ field: 'status', message: `must be one of ${statuses.join(', ')}` }); - if (issues.length) return { ok: false, issues }; - return { ok: true, value: { customer: b.customer as string, total: b.total as number, status: b.status as OrderStatus } }; -} -TS -echo "Wire validation into the create endpoint; unit-test it; verify end-to-end." -``` - -## Tasks -1. **Validate at the boundary.** In the create controller, run `validateCreateOrder(body)` *before* any service call. On `ok: false`, return `400 { error: 'validation_failed', issues }`. On `ok: true`, pass `value` (typed) to the service. -2. **No trust, no `as` on raw input.** The only place that touches `unknown` is the validator; everything downstream is typed. -3. **Distinguish validation from business rules.** A malformed body → 400 (validation). "Can't pay a cancelled order" → 409 (business rule, from the service). Keep them separate. -4. **Unit-test the validator** in Node: a valid body narrows to a typed value; each bad field (missing customer, non-positive total, bad status, non-object body) yields a useful issue. -5. **Verify end-to-end** that a malformed body returns 400 with issues and **never reaches the service** (instrument the service to prove it), while a valid body returns 201. - -## Deliverable -The boundary validator; the validated create endpoint returning structured 400s; passing Node validator tests; end-to-end evidence (bad input → 400, never hits the service; valid → 201); and a note distinguishing one 400 from one 409. - -## Cleanup -```bash -rm -f /tmp/swexp-be/validation.ts -``` - -## Check -`../solutions/lab-03-solution.md`. diff --git a/labs/lab-03-validation/README.md b/labs/lab-03-validation/README.md new file mode 100644 index 0000000..194fa4b --- /dev/null +++ b/labs/lab-03-validation/README.md @@ -0,0 +1,29 @@ +# Lab 03 — Stop Invalid Requests at the Door + +**Ticket:** VAL-3001 · **Goal:** validate request bodies at the boundary — narrow `unknown` → typed value or structured issues. + +## What you do +In [`src/validation.ts`](src/validation.ts), implement `validateCreateOrder(input)`: + +- A non-object / null body → a single issue on field `_`. +- `customer` — required non-empty string (after trim). +- `total` — must be a number `> 0`. +- `status` — must be one of `placed | paid | shipped | cancelled`. +- Collect **all** applicable field issues (don't stop at the first), each with a useful message. +- On success, return `{ ok: true, value }` with a fully typed `CreateOrderInput`. + +This is the **only** place that touches `unknown` — everything downstream is typed. A malformed body is a +`400` (validation); a business-rule violation like "can't pay a cancelled order" is a `409` (Lab 02) — keep +them separate. + +Run: +```bash +npx vitest run labs/lab-03-validation +``` + +## Definition of done +- All tests pass; `npm run check` clean. +- In your notebook, distinguish one `400` (validation) from one `409` (business rule). + +## Submit +Edit `src/`, run the tests, commit and push. diff --git a/labs/lab-03-validation/src/validation.ts b/labs/lab-03-validation/src/validation.ts new file mode 100644 index 0000000..9f5fe21 --- /dev/null +++ b/labs/lab-03-validation/src/validation.ts @@ -0,0 +1,41 @@ +/** + * Lab 03 — Stop Invalid Requests at the Door. See README.md. + * + * A single validator narrows `unknown` → a typed value OR a list of per-field issues. + * This is the ONLY place that touches `unknown`; everything downstream is typed. + * + * No `any`. The only `as` allowed is the final narrowing once every field is checked. + */ + +export type OrderStatus = 'placed' | 'paid' | 'shipped' | 'cancelled'; +export interface CreateOrderInput { + customer: string; + total: number; + status: OrderStatus; +} + +export interface FieldIssue { + field: string; + message: string; +} +export type ValidationResult = + | { ok: true; value: T } + | { ok: false; issues: FieldIssue[] }; + +const STATUSES: readonly OrderStatus[] = ['placed', 'paid', 'shipped', 'cancelled']; + +/** + * Parse, don't trust. Rules: + * - body must be a non-null object (else a single issue on field `_`: "body must be an object"). + * - `customer` — required non-empty string (after trim). + * - `total` — must be a number > 0. + * - `status` — must be one of: placed, paid, shipped, cancelled. + * Collect ALL applicable field issues (don't stop at the first) when the body is an object. + */ +export function validateCreateOrder(input: unknown): ValidationResult { + // TODO: implement per the rules above. + // 1. reject non-object/null bodies with the `_` issue. + // 2. push a FieldIssue for each bad field (customer, total, status). + // 3. if any issues → { ok: false, issues }; else → { ok: true, value }. + return { ok: false, issues: [{ field: '_', message: 'not implemented' }] }; +} diff --git a/labs/lab-03-validation/tests/validation.test.ts b/labs/lab-03-validation/tests/validation.test.ts new file mode 100644 index 0000000..e4a51b9 --- /dev/null +++ b/labs/lab-03-validation/tests/validation.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { validateCreateOrder } from '../src/validation'; + +const fieldsWithIssues = (input: unknown): string[] => { + const r = validateCreateOrder(input); + return r.ok ? [] : r.issues.map((i) => i.field); +}; + +describe('lab 03 — boundary validation', () => { + it('a valid body narrows to a typed value', () => { + const r = validateCreateOrder({ customer: 'Ada', total: 100, status: 'placed' }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.value).toEqual({ customer: 'Ada', total: 100, status: 'placed' }); + } + }); + + it('a non-object body is rejected with a single `_` issue', () => { + for (const bad of [null, 42, 'nope', undefined]) { + const r = validateCreateOrder(bad); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.issues).toHaveLength(1); + expect(r.issues[0]?.field).toBe('_'); + } + } + }); + + it('flags a missing / empty customer', () => { + expect(fieldsWithIssues({ total: 10, status: 'placed' })).toContain('customer'); + expect(fieldsWithIssues({ customer: ' ', total: 10, status: 'placed' })).toContain('customer'); + }); + + it('flags a non-positive or non-numeric total', () => { + expect(fieldsWithIssues({ customer: 'A', total: 0, status: 'placed' })).toContain('total'); + expect(fieldsWithIssues({ customer: 'A', total: -5, status: 'placed' })).toContain('total'); + expect(fieldsWithIssues({ customer: 'A', total: 'lots', status: 'placed' })).toContain('total'); + }); + + it('flags an invalid status', () => { + expect(fieldsWithIssues({ customer: 'A', total: 10, status: 'archived' })).toContain('status'); + expect(fieldsWithIssues({ customer: 'A', total: 10 })).toContain('status'); + }); + + it('collects multiple issues at once', () => { + const fields = fieldsWithIssues({ customer: '', total: -1, status: 'bad' }); + expect(fields).toEqual(expect.arrayContaining(['customer', 'total', 'status'])); + expect(fields.length).toBe(3); + }); + + it('every issue carries a non-empty message', () => { + const r = validateCreateOrder({ customer: '', total: -1, status: 'bad' }); + expect(r.ok).toBe(false); + if (!r.ok) { + for (const issue of r.issues) expect(issue.message.length).toBeGreaterThan(0); + } + }); +}); diff --git a/labs/lab-04-auth.md b/labs/lab-04-auth.md deleted file mode 100644 index e24d0f6..0000000 --- a/labs/lab-04-auth.md +++ /dev/null @@ -1,66 +0,0 @@ -# Lab 04 — Authenticate Every Request - -**Lesson:** 04 · **Goal:** real password hashing (scrypt) and signed-token sign/verify (HMAC); reject tampered/expired/wrong-secret tokens. Fail safe. - -## Goal -Implement authentication with real cryptographic primitives and prove it fails safe: wrong passwords and bad tokens yield no identity. - -## Setup -```bash -cd /tmp/swexp-be -cat > auth.ts <<'TS' -import { scryptSync, randomBytes, timingSafeEqual, createHmac } from 'node:crypto'; - -// --- passwords: salted, slow hash; constant-time verify --- -export function hashPassword(pw: string): string { - const salt = randomBytes(16); - const dk = scryptSync(pw, salt, 32); - return `${salt.toString('hex')}:${dk.toString('hex')}`; -} -export function verifyPassword(pw: string, stored: string): boolean { - const [saltHex, hashHex] = stored.split(':'); - const dk = scryptSync(pw, Buffer.from(saltHex, 'hex'), 32); - const expected = Buffer.from(hashHex, 'hex'); - return dk.length === expected.length && timingSafeEqual(dk, expected); // constant-time -} - -// --- signed tokens: HMAC over header.payload; verify BEFORE trusting --- -export interface TokenPayload { sub: string; role: string; exp: number; } -function b64url(o: object): string { return Buffer.from(JSON.stringify(o)).toString('base64url'); } -export function signToken(payload: Omit, secret: string, ttlSec = 3600): string { - const h = b64url({ alg: 'HS256', typ: 'JWT' }); - const p = b64url({ ...payload, exp: Math.floor(Date.now() / 1000) + ttlSec }); - const sig = createHmac('sha256', secret).update(`${h}.${p}`).digest('base64url'); - return `${h}.${p}.${sig}`; -} -export function verifyToken(token: string, secret: string): TokenPayload | null { - const parts = token.split('.'); - if (parts.length !== 3) return null; - const [h, p, sig] = parts; - const expected = createHmac('sha256', secret).update(`${h}.${p}`).digest('base64url'); - if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null; - const payload = JSON.parse(Buffer.from(p, 'base64url').toString()) as TokenPayload; - if (payload.exp < Math.floor(Date.now() / 1000)) return null; // expired - return payload; // identity ONLY after verify -} -TS -echo "Add an auth middleware; unit-test everything with REAL crypto." -``` - -## Tasks -1. **Password storage.** Use `hashPassword`/`verifyPassword` (scrypt + per-password salt + `timingSafeEqual`). Confirm hashing the same password twice yields different stored values (salting). -2. **Login** verifies credentials and issues a signed, **expiring** token via `signToken`. -3. **Verify before trusting.** `verifyToken` recomputes the HMAC and compares in constant time *before* parsing the payload as an identity; checks `exp`. -4. **Auth middleware** reads `Authorization: Bearer `, verifies it, attaches `req.user`, or returns **401** (missing/malformed/tampered/expired). Fail safe — never assume a user. -5. **Unit-test with real crypto:** correct vs wrong password; valid token round-trips; tampered signature, wrong secret, and expired token each → `null` (no identity). - -## Deliverable -The password + token functions and the auth middleware; passing Node tests (correct/wrong password; valid/tampered/wrong-secret/expired token); evidence of salting (two different hashes for one password); and a note on each way the design fails safe. - -## Cleanup -```bash -rm -f /tmp/swexp-be/auth.ts -``` - -## Check -`../solutions/lab-04-solution.md`. diff --git a/labs/lab-04-auth/README.md b/labs/lab-04-auth/README.md new file mode 100644 index 0000000..a76623a --- /dev/null +++ b/labs/lab-04-auth/README.md @@ -0,0 +1,26 @@ +# Lab 04 — Authenticate Every Request + +**Ticket:** AUTHN-4001 · **Goal:** real password hashing (scrypt) and signed-token sign/verify (HMAC) that fail safe. + +## What you do +In [`src/auth.ts`](src/auth.ts), implement four primitives with **real** `node:crypto`: + +- `hashPassword` / `verifyPassword` — per-password 16-byte salt + `scryptSync` (32-byte key), constant-time + compare with `timingSafeEqual`. Stored format `saltHex:hashHex`. +- `signToken` — HMAC-SHA256 over `header.payload`, with an `exp` `ttlSec` seconds out. +- `verifyToken` — recompute the HMAC and compare in **constant time before** parsing the payload as an + identity; reject expired tokens. Return `null` on any failure. +- `authenticate` — read `Authorization: Bearer `; `200 + user` on success, `401 + null` for + missing / malformed / tampered / expired. **Never assume a user.** + +Run: +```bash +npx vitest run labs/lab-04-auth +``` + +## Definition of done +- All tests pass (correct/wrong password; valid/tampered/wrong-secret/expired token); `npm run check` clean. +- Evidence of salting: two different hashes for one password. Note each way the design fails safe. + +## Submit +Edit `src/`, run the tests, commit and push. diff --git a/labs/lab-04-auth/src/auth.ts b/labs/lab-04-auth/src/auth.ts new file mode 100644 index 0000000..461c71b --- /dev/null +++ b/labs/lab-04-auth/src/auth.ts @@ -0,0 +1,74 @@ +/** + * Lab 04 — Authenticate Every Request. See README.md. + * + * Real cryptographic primitives — no home-grown crypto, no plaintext passwords. + * - Passwords: per-password salt + scrypt (slow) + constant-time compare. + * - Tokens: HMAC-SHA256 over `header.payload`; VERIFY before trusting; check `exp`. + * + * The design must FAIL SAFE: a wrong password or a bad token yields NO identity. + * No `any`. + */ +import { scryptSync, randomBytes, timingSafeEqual, createHmac } from 'node:crypto'; + +// --- passwords --------------------------------------------------------------- + +/** Hash a password as `saltHex:hashHex`. A fresh random salt every call. */ +export function hashPassword(pw: string): string { + // TODO: 16-byte random salt; scryptSync(pw, salt, 32); return `${saltHex}:${hashHex}`. + return ''; +} + +/** Verify a password against a stored `saltHex:hashHex`, in constant time. */ +export function verifyPassword(pw: string, stored: string): boolean { + // TODO: split stored into salt + hash; re-derive with scryptSync; compare with + // timingSafeEqual (guard against length mismatch first). Return false on any malformed input. + return false; +} + +// --- signed tokens ----------------------------------------------------------- + +export interface TokenPayload { + sub: string; + role: string; + exp: number; // unix seconds +} + +function b64url(o: object): string { + return Buffer.from(JSON.stringify(o)).toString('base64url'); +} + +/** Sign `{ sub, role }` into `header.payload.signature`, expiring in `ttlSec` seconds. */ +export function signToken(payload: Omit, secret: string, ttlSec = 3600): string { + // TODO: header = { alg:'HS256', typ:'JWT' }; payload gets exp = now + ttlSec; + // sig = HMAC-SHA256(secret) over `${h}.${p}` as base64url; return `${h}.${p}.${sig}`. + return ''; +} + +/** + * Verify a token and return its payload, or `null` on ANY failure + * (malformed, tampered signature, wrong secret, expired). + * Recompute and compare the HMAC in constant time BEFORE parsing the payload as an identity. + */ +export function verifyToken(token: string, secret: string): TokenPayload | null { + // TODO: split into 3 parts; recompute expected sig; timingSafeEqual (length-guard); + // only then JSON.parse the payload; reject if exp < now. Return null on any failure. + return null; +} + +// --- middleware -------------------------------------------------------------- + +export interface AuthResult { + status: number; + user: TokenPayload | null; +} + +/** + * Authenticate from an `Authorization: Bearer ` header value (may be undefined). + * - valid token → { status: 200, user } + * - missing / malformed / tampered / expired → { status: 401, user: null } + * Never assume a user. Fail safe. + */ +export function authenticate(authorizationHeader: string | undefined, secret: string): AuthResult { + // TODO: require a "Bearer " header; verifyToken it; 200 + user, else 401 + null. + return { status: 401, user: null }; +} diff --git a/labs/lab-04-auth/tests/auth.test.ts b/labs/lab-04-auth/tests/auth.test.ts new file mode 100644 index 0000000..511a208 --- /dev/null +++ b/labs/lab-04-auth/tests/auth.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { + hashPassword, + verifyPassword, + signToken, + verifyToken, + authenticate, +} from '../src/auth'; + +const SECRET = 'unit-test-secret-value'; + +describe('lab 04 — passwords (real scrypt + salt)', () => { + it('verifies the correct password', () => { + const stored = hashPassword('hunter2'); + expect(verifyPassword('hunter2', stored)).toBe(true); + }); + + it('rejects the wrong password', () => { + const stored = hashPassword('hunter2'); + expect(verifyPassword('Hunter2', stored)).toBe(false); + expect(verifyPassword('', stored)).toBe(false); + }); + + it('salts: the same password hashes to two different stored values', () => { + expect(hashPassword('same')).not.toBe(hashPassword('same')); + }); + + it('stored format is saltHex:hashHex (no plaintext)', () => { + const stored = hashPassword('hunter2'); + expect(stored).toMatch(/^[0-9a-f]+:[0-9a-f]+$/); + expect(stored).not.toContain('hunter2'); + }); +}); + +describe('lab 04 — signed tokens (HMAC, verify before trust)', () => { + it('a valid token round-trips to its payload', () => { + const token = signToken({ sub: 'u-1', role: 'admin' }, SECRET); + const payload = verifyToken(token, SECRET); + expect(payload).not.toBeNull(); + expect(payload?.sub).toBe('u-1'); + expect(payload?.role).toBe('admin'); + expect(typeof payload?.exp).toBe('number'); + }); + + it('a tampered signature yields no identity', () => { + const token = signToken({ sub: 'u-1', role: 'admin' }, SECRET); + const tampered = token.slice(0, -2) + (token.endsWith('aa') ? 'bb' : 'aa'); + expect(verifyToken(tampered, SECRET)).toBeNull(); + }); + + it('a wrong secret yields no identity', () => { + const token = signToken({ sub: 'u-1', role: 'admin' }, SECRET); + expect(verifyToken(token, 'other-secret')).toBeNull(); + }); + + it('an expired token yields no identity', () => { + const expired = signToken({ sub: 'u-1', role: 'admin' }, SECRET, -10); + expect(verifyToken(expired, SECRET)).toBeNull(); + }); + + it('a malformed token yields no identity', () => { + expect(verifyToken('not.a.token.at.all', SECRET)).toBeNull(); + expect(verifyToken('nonsense', SECRET)).toBeNull(); + }); +}); + +describe('lab 04 — auth middleware fails safe', () => { + it('200 + user for a valid Bearer token', () => { + const token = signToken({ sub: 'u-1', role: 'customer' }, SECRET); + const res = authenticate(`Bearer ${token}`, SECRET); + expect(res.status).toBe(200); + expect(res.user?.sub).toBe('u-1'); + }); + + it('401 + null for missing, malformed, or tampered tokens', () => { + expect(authenticate(undefined, SECRET)).toEqual({ status: 401, user: null }); + expect(authenticate('Token abc', SECRET)).toEqual({ status: 401, user: null }); + expect(authenticate('Bearer not-a-token', SECRET)).toEqual({ status: 401, user: null }); + }); +}); diff --git a/labs/lab-05-authz.md b/labs/lab-05-authz.md deleted file mode 100644 index abe9fb6..0000000 --- a/labs/lab-05-authz.md +++ /dev/null @@ -1,50 +0,0 @@ -# Lab 05 — Stop Unauthorized Access - -**Lesson:** 05 · **Goal:** a centralized authorization policy (role + ownership, least privilege); enforce it server-side; 403 vs 401 end-to-end. - -## Goal -Add authorization on top of authentication so users can only act on what they're permitted to — proven with a unit-tested policy and end-to-end 403/401 checks. - -## Setup -```bash -cd /tmp/swexp-be -cat > authz.ts <<'TS' -export type Role = 'customer' | 'support' | 'admin'; -export interface User { id: string; role: Role; } -export interface Order { id: string; customerId: string; status: string; total: number; } - -// Centralized policy — pure functions, trivially testable. Least privilege by default. -export const policy = { - canViewOrder(user: User, order: Order): boolean { - if (user.role === 'admin' || user.role === 'support') return true; // role-based - return order.customerId === user.id; // ownership-based - }, - canModifyOrder(user: User, order: Order): boolean { - if (user.role === 'admin') return true; - return order.customerId === user.id; - }, - canRefundOrder(user: User, _order: Order): boolean { - return user.role === 'admin'; // least privilege - }, -}; -TS -echo "Enforce the policy in controllers AFTER auth; unit-test it; verify 403 vs 401 end-to-end." -``` - -## Tasks -1. **Centralized policy.** Keep authorization in pure `policy.*` functions (role + ownership). Default to deny — a new role/action has no access until granted. -2. **Enforce server-side, after auth, before the service acts.** Authenticated but not permitted → **403**. No/invalid token → **401** (from auth, Lesson 4). Decide deliberately when 404 (hide existence) beats 403. -3. **401 vs 403 are distinct.** Don't return 403 to an unauthenticated caller or 401 to a forbidden one. -4. **Unit-test the policy** in Node: owner can view/modify their order; a stranger cannot; support/admin can view; only admin can refund; an unknown/extra role is denied by default. -5. **Verify end-to-end:** as owner → 200; as non-owner → 403; with no token → 401; as admin → elevated access. - -## Deliverable -The centralized policy; server-side enforcement; passing Node tests (role + ownership + default-deny); end-to-end evidence of 200/403/401; and a note on one 404-vs-403 information-hiding choice. - -## Cleanup -```bash -rm -f /tmp/swexp-be/authz.ts -``` - -## Check -`../solutions/lab-05-solution.md`. diff --git a/labs/lab-05-authz/README.md b/labs/lab-05-authz/README.md new file mode 100644 index 0000000..6adec38 --- /dev/null +++ b/labs/lab-05-authz/README.md @@ -0,0 +1,27 @@ +# Lab 05 — Stop Unauthorized Access + +**Ticket:** AUTHZ-5001 · **Goal:** a centralized authorization policy (role + ownership, least privilege) and a 401-vs-403 decision. + +## What you do +In [`src/authz.ts`](src/authz.ts): + +- **`policy`** — pure functions, **default-deny**, least privilege: + - `canViewOrder` — admin/support, or the owner. + - `canModifyOrder` — admin, or the owner. + - `canRefundOrder` — admin only. + - An unknown/extra role must be denied by default. +- **`authorize(user, permitted)`** — keeps the two failures distinct: `null` user → `401`; authenticated + but denied → `403`; permitted → `200`. Never return `403` to an unauthenticated caller, nor `401` to a + forbidden one. + +Run: +```bash +npx vitest run labs/lab-05-authz +``` + +## Definition of done +- All tests pass; `npm run check` clean. +- In your notebook, note one case where returning `404` (hide existence) beats `403`. + +## Submit +Edit `src/`, run the tests, commit and push. diff --git a/labs/lab-05-authz/src/authz.ts b/labs/lab-05-authz/src/authz.ts new file mode 100644 index 0000000..c14dc71 --- /dev/null +++ b/labs/lab-05-authz/src/authz.ts @@ -0,0 +1,65 @@ +/** + * Lab 05 — Stop Unauthorized Access. See README.md. + * + * Centralized authorization: pure policy functions (role + ownership), least privilege, + * default-deny. Then a decision helper that keeps 401 (not authenticated) and + * 403 (authenticated but forbidden) distinct. + * + * No `any`. + */ + +export type Role = 'customer' | 'support' | 'admin'; +export interface User { + id: string; + role: Role; +} +export interface Order { + id: string; + customerId: string; + status: string; + total: number; +} + +/** + * Centralized policy — pure, trivially testable, least privilege by default. + * - canViewOrder: admin or support (role) OR the owner (ownership). + * - canModifyOrder: admin (role) OR the owner. + * - canRefundOrder: admin only. + * Any unknown/extra role must be denied by default (don't enumerate every deny case — + * grant explicitly, deny otherwise). + */ +export const policy = { + canViewOrder(user: User, order: Order): boolean { + // TODO: admin/support → true; else owner check. + return false; + }, + canModifyOrder(user: User, order: Order): boolean { + // TODO: admin → true; else owner check. + return false; + }, + canRefundOrder(user: User, _order: Order): boolean { + // TODO: admin only. + return false; + }, +}; + +export interface AuthzDecision { + status: number; // 200 allowed, 401 unauthenticated, 403 forbidden + allowed: boolean; +} + +/** + * Decide access for an action. + * - `user` is `null` when the request is unauthenticated → 401. + * - authenticated but the policy denies → 403. + * - authenticated and permitted → 200. + * Never return 403 to an unauthenticated caller, nor 401 to a forbidden one. + */ +export function authorize( + user: User | null, + permitted: (u: User) => boolean, +): AuthzDecision { + // TODO: null user → 401; else run `permitted(user)` → 200 or 403. + void permitted; + return { status: 401, allowed: false }; +} diff --git a/labs/lab-05-authz/tests/authz.test.ts b/labs/lab-05-authz/tests/authz.test.ts new file mode 100644 index 0000000..0c0ffa9 --- /dev/null +++ b/labs/lab-05-authz/tests/authz.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { policy, authorize, type User, type Order, type Role } from '../src/authz'; + +const order: Order = { id: 'o-1', customerId: 'u-owner', status: 'placed', total: 100 }; +const owner: User = { id: 'u-owner', role: 'customer' }; +const stranger: User = { id: 'u-other', role: 'customer' }; +const support: User = { id: 'u-s', role: 'support' }; +const admin: User = { id: 'u-a', role: 'admin' }; + +describe('lab 05 — authorization policy (role + ownership, least privilege)', () => { + it('owner can view and modify their own order', () => { + expect(policy.canViewOrder(owner, order)).toBe(true); + expect(policy.canModifyOrder(owner, order)).toBe(true); + }); + + it('a stranger cannot view or modify the order', () => { + expect(policy.canViewOrder(stranger, order)).toBe(false); + expect(policy.canModifyOrder(stranger, order)).toBe(false); + }); + + it('support can view but not modify or refund', () => { + expect(policy.canViewOrder(support, order)).toBe(true); + expect(policy.canModifyOrder(support, order)).toBe(false); + expect(policy.canRefundOrder(support, order)).toBe(false); + }); + + it('admin can view, modify, and refund', () => { + expect(policy.canViewOrder(admin, order)).toBe(true); + expect(policy.canModifyOrder(admin, order)).toBe(true); + expect(policy.canRefundOrder(admin, order)).toBe(true); + }); + + it('only admin can refund', () => { + expect(policy.canRefundOrder(owner, order)).toBe(false); + }); + + it('an unknown/extra role is denied by default', () => { + const rogue = { id: 'u-x', role: 'superuser' as unknown as Role }; + expect(policy.canViewOrder(rogue, order)).toBe(false); + expect(policy.canModifyOrder(rogue, order)).toBe(false); + expect(policy.canRefundOrder(rogue, order)).toBe(false); + }); +}); + +describe('lab 05 — authorize keeps 401 and 403 distinct', () => { + it('unauthenticated → 401 (not 403)', () => { + const d = authorize(null, (u) => policy.canViewOrder(u, order)); + expect(d).toEqual({ status: 401, allowed: false }); + }); + + it('authenticated owner → 200', () => { + const d = authorize(owner, (u) => policy.canViewOrder(u, order)); + expect(d).toEqual({ status: 200, allowed: true }); + }); + + it('authenticated stranger → 403 (not 401)', () => { + const d = authorize(stranger, (u) => policy.canViewOrder(u, order)); + expect(d).toEqual({ status: 403, allowed: false }); + }); + + it('admin elevated access → 200 on refund', () => { + const d = authorize(admin, (u) => policy.canRefundOrder(u, order)); + expect(d.status).toBe(200); + }); +}); diff --git a/labs/lab-06-api-platform.md b/labs/lab-06-api-platform.md deleted file mode 100644 index d3a8e2b..0000000 --- a/labs/lab-06-api-platform.md +++ /dev/null @@ -1,65 +0,0 @@ -# Lab 06 — Build a Reliable API Platform - -**Lesson:** 06 · **Goal:** a consistent error envelope, clamped pagination, idempotency for unsafe retries, and a token-bucket rate limiter — verified. - -## Goal -Turn the endpoints into a platform with uniform cross-cutting behavior, proven with unit tests and end-to-end checks (retry acts once; over-limit → 429). - -## Setup -```bash -cd /tmp/swexp-be -cat > platform.ts <<'TS' -// --- consistent error envelope --- -export interface ApiError { error: { code: string; message: string; details?: unknown }; } -export function errorEnvelope(code: string, message: string, details?: unknown): ApiError { - return { error: { code, message, ...(details !== undefined ? { details } : {}) } }; -} - -// --- pagination: clamp the limit so a client can't request everything --- -export function clampLimit(raw: unknown, def = 20, max = 100): number { - const n = Number(raw); - if (!Number.isFinite(n)) return def; - return Math.min(Math.max(Math.trunc(n), 1), max); -} - -// --- idempotency: replay the stored result for a repeated key --- -export class IdempotencyStore { - private seen = new Map(); - run(key: string, work: () => T): { result: T; replayed: boolean } { - if (this.seen.has(key)) return { result: this.seen.get(key)!, replayed: true }; - const result = work(); - this.seen.set(key, result); - return { result, replayed: false }; - } -} - -// --- rate limiting: token bucket --- -export interface Bucket { tokens: number; last: number; } -export function allow(bucket: Bucket, now: number, ratePerSec: number, capacity: number): boolean { - bucket.tokens = Math.min(capacity, bucket.tokens + ((now - bucket.last) / 1000) * ratePerSec); - bucket.last = now; - if (bucket.tokens < 1) return false; - bucket.tokens -= 1; - return true; -} -TS -echo "Wire these into the API; unit-test each; verify idempotency + 429 end-to-end." -``` - -## Tasks -1. **Consistent error envelope.** Map every error (validation/auth/not-found/server) through `errorEnvelope` so all errors share one shape. -2. **Pagination.** Clamp `limit` to `1..100` (default 20); return the page plus metadata. A client requesting `limit=99999` gets 100, not everything. -3. **Idempotency.** For an unsafe operation (e.g. create/payment), use an `Idempotency-Key` header + `IdempotencyStore` so a retried request replays the original result and the side effect happens **once**. -4. **Rate limiting.** Use the token-bucket `allow` per client; on exhaustion return **429** with a `Retry-After` header. -5. **Unit-test** each pure piece (envelope shape, limit clamp at boundaries, idempotent replay, bucket refill/deny) and **verify end-to-end**: the same idempotent request twice → one side effect; exceeding the limit → 429. - -## Deliverable -The envelope/pagination/idempotency/rate-limit code; passing Node unit tests of each; and end-to-end evidence (retry replays with one side effect; over-limit → 429 + `Retry-After`). - -## Cleanup -```bash -rm -f /tmp/swexp-be/platform.ts -``` - -## Check -`../solutions/lab-06-solution.md`. diff --git a/labs/lab-06-api-platform/README.md b/labs/lab-06-api-platform/README.md new file mode 100644 index 0000000..230864c --- /dev/null +++ b/labs/lab-06-api-platform/README.md @@ -0,0 +1,27 @@ +# Lab 06 — Build a Reliable API Platform + +**Ticket:** PLAT-6001 · **Goal:** consistent error envelope, clamped pagination, idempotent retries, token-bucket rate limiting. + +## What you do +In [`src/platform.ts`](src/platform.ts), implement four cross-cutting primitives: + +- **`errorEnvelope(code, message, details?)`** — every error shares one shape; omit `details` when absent. +- **`clampLimit(raw, def, max)`** — non-finite → default; otherwise clamp into `1..max` and truncate + (a client asking `limit=99999` gets `100`, not everything). +- **`IdempotencyStore.run(key, work)`** — run `work` once per key; a repeat replays the stored result and + the side effect happens **once**. +- **`allow(bucket, now, ratePerSec, capacity)`** — token-bucket: refill by elapsed time (capped at + capacity), then spend one token; deny when empty. + +Run: +```bash +npx vitest run labs/lab-06-api-platform +``` + +## Definition of done +- All tests pass; `npm run check` clean. +- In your notebook, note how an idempotent retry maps to one side effect, and what an over-limit caller + should receive (`429` + `Retry-After`). + +## Submit +Edit `src/`, run the tests, commit and push. diff --git a/labs/lab-06-api-platform/src/platform.ts b/labs/lab-06-api-platform/src/platform.ts new file mode 100644 index 0000000..72724c1 --- /dev/null +++ b/labs/lab-06-api-platform/src/platform.ts @@ -0,0 +1,64 @@ +/** + * Lab 06 — Build a Reliable API Platform. See README.md. + * + * Cross-cutting platform primitives: a consistent error envelope, clamped pagination, + * idempotent replay for unsafe retries, and a token-bucket rate limiter. + * + * No `any`. + */ + +// --- consistent error envelope ---------------------------------------------- + +export interface ApiError { + error: { code: string; message: string; details?: unknown }; +} + +/** Wrap an error in the one true shape. Omit `details` entirely when undefined. */ +export function errorEnvelope(code: string, message: string, details?: unknown): ApiError { + // TODO: return { error: { code, message, ...(details !== undefined ? { details } : {}) } }. + return { error: { code: '', message: '' } }; +} + +// --- pagination: clamp the limit so a client can't request everything -------- + +/** + * Clamp a raw `limit` (unknown query value) into `1..max`, defaulting to `def`. + * Non-finite input → `def`. Truncate fractions. e.g. clampLimit(99999) → 100. + */ +export function clampLimit(raw: unknown, def = 20, max = 100): number { + // TODO: Number(raw); if !isFinite → def; else min(max, max(1, trunc(n))). + return def; +} + +// --- idempotency: replay the stored result for a repeated key ----------------- + +export class IdempotencyStore { + private seen = new Map(); + /** + * Run `work` once per `key`. A repeat returns the stored result with `replayed: true` + * and does NOT call `work` again (the side effect happens once). + */ + run(key: string, work: () => T): { result: T; replayed: boolean } { + // TODO: if key already seen → replay; else run, store, return replayed:false. + void work; + return { result: undefined as unknown as T, replayed: false }; + } +} + +// --- rate limiting: token bucket --------------------------------------------- + +export interface Bucket { + tokens: number; + last: number; // ms timestamp of last refill +} + +/** + * Token-bucket decision (mutates the bucket): refill based on elapsed time, then try + * to spend one token. Returns whether the request is allowed. + * Refill: tokens = min(capacity, tokens + (now - last)/1000 * ratePerSec); last = now. + * If tokens < 1 → deny (no spend). Else spend 1 and allow. + */ +export function allow(bucket: Bucket, now: number, ratePerSec: number, capacity: number): boolean { + // TODO: implement the refill-then-spend logic above. + return false; +} diff --git a/labs/lab-06-api-platform/tests/platform.test.ts b/labs/lab-06-api-platform/tests/platform.test.ts new file mode 100644 index 0000000..a86873d --- /dev/null +++ b/labs/lab-06-api-platform/tests/platform.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { errorEnvelope, clampLimit, IdempotencyStore, allow, type Bucket } from '../src/platform'; + +describe('lab 06 — error envelope', () => { + it('produces the one true shape', () => { + expect(errorEnvelope('not_found', 'order missing')).toEqual({ + error: { code: 'not_found', message: 'order missing' }, + }); + }); + it('omits details when undefined, includes them when present', () => { + expect('details' in errorEnvelope('x', 'y').error).toBe(false); + expect(errorEnvelope('bad', 'nope', { field: 'total' }).error.details).toEqual({ field: 'total' }); + }); +}); + +describe('lab 06 — pagination clamp', () => { + it('defaults non-finite input', () => { + expect(clampLimit(undefined)).toBe(20); + expect(clampLimit('abc')).toBe(20); + }); + it('clamps to 1..100 and truncates', () => { + expect(clampLimit(99999)).toBe(100); + expect(clampLimit(0)).toBe(1); + expect(clampLimit(-5)).toBe(1); + expect(clampLimit(50)).toBe(50); + expect(clampLimit('30')).toBe(30); + expect(clampLimit(10.9)).toBe(10); + }); + it('respects custom default and max', () => { + expect(clampLimit(undefined, 5, 50)).toBe(5); + expect(clampLimit(9999, 5, 50)).toBe(50); + }); +}); + +describe('lab 06 — idempotent replay', () => { + it('runs work once and replays thereafter', () => { + const store = new IdempotencyStore(); + let calls = 0; + const work = () => { + calls += 1; + return 42; + }; + const first = store.run('k1', work); + const second = store.run('k1', work); + expect(first).toEqual({ result: 42, replayed: false }); + expect(second).toEqual({ result: 42, replayed: true }); + expect(calls).toBe(1); // side effect happened exactly once + }); + it('distinct keys run independently', () => { + const store = new IdempotencyStore(); + expect(store.run('a', () => 'A').replayed).toBe(false); + expect(store.run('b', () => 'B').replayed).toBe(false); + expect(store.run('a', () => 'A').replayed).toBe(true); + }); +}); + +describe('lab 06 — token bucket', () => { + it('allows up to capacity then denies', () => { + const bucket: Bucket = { tokens: 2, last: 1000 }; + expect(allow(bucket, 1000, 1, 2)).toBe(true); // 2 -> 1 + expect(allow(bucket, 1000, 1, 2)).toBe(true); // 1 -> 0 + expect(allow(bucket, 1000, 1, 2)).toBe(false); // empty + }); + it('refills over time, capped at capacity', () => { + const bucket: Bucket = { tokens: 0, last: 1000 }; + // 2 seconds later at 1 token/sec → +2 tokens + expect(allow(bucket, 3000, 1, 5)).toBe(true); + // never exceeds capacity + const full: Bucket = { tokens: 0, last: 0 }; + allow(full, 1_000_000, 1, 3); // huge gap + expect(full.tokens).toBeLessThanOrEqual(3); + }); +}); diff --git a/labs/lab-07-jobs.md b/labs/lab-07-jobs.md deleted file mode 100644 index dcf9db2..0000000 --- a/labs/lab-07-jobs.md +++ /dev/null @@ -1,61 +0,0 @@ -# Lab 07 — Move Work Off the Request - -**Lesson:** 07 · **Goal:** a job queue with bounded retries + exponential backoff, idempotent jobs, and a dead-letter queue — verified. - -## Goal -Move slow work to a background queue that recovers from transient failures, never loops forever, and is safe to run more than once. - -## Setup -```bash -cd /tmp/swexp-be -cat > jobs.ts <<'TS' -// --- retry/backoff policy (pure) --- -export function nextDelayMs(attempt: number, baseMs = 1000, capMs = 60000): number { - return Math.min(capMs, baseMs * 2 ** attempt); // 1s, 2s, 4s, … capped -} -export function shouldRetry(attempt: number, maxAttempts: number): boolean { - return attempt < maxAttempts; -} - -export interface Job { id: string; type: string; payload: unknown; attempts: number; } - -// --- a minimal queue with retry + dead-letter --- -export class JobQueue { - private queue: Job[] = []; - readonly deadLetter: Job[] = []; - constructor(private maxAttempts = 3) {} - enqueue(job: Omit): void { this.queue.push({ ...job, attempts: 0 }); } - size(): number { return this.queue.length; } - // process one job with a handler that may throw on transient failure - async processOne(handler: (job: Job) => Promise): Promise<'done' | 'retried' | 'dead-lettered' | 'empty'> { - const job = this.queue.shift(); - if (!job) return 'empty'; - try { await handler(job); return 'done'; } - catch { - const attempts = job.attempts + 1; - if (shouldRetry(attempts, this.maxAttempts)) { this.queue.push({ ...job, attempts }); return 'retried'; } - this.deadLetter.push({ ...job, attempts }); return 'dead-lettered'; - } - } -} -TS -echo "Make a job idempotent; unit-test recover/DLQ/duplicate; note what you moved off the request." -``` - -## Tasks -1. **Enqueue, return fast.** The create-order handler saves the order (critical), enqueues `sendOrderEmail` (deferred), and returns 201 — the email is not on the request path. -2. **Bounded retries + backoff.** Use `shouldRetry`/`nextDelayMs`. A transient failure retries (with increasing delay) up to `maxAttempts`. -3. **Idempotent jobs.** `sendOrderEmail` checks "already sent for this order?" before sending, so a re-run is a no-op (at-least-once delivery makes this mandatory). -4. **Dead-letter.** A job that fails every attempt lands in `deadLetter` after `maxAttempts` — no infinite loop. -5. **Unit-test in Node:** a job that fails twice then succeeds recovers; a job that always fails is dead-lettered after `maxAttempts`; a duplicate run is a no-op; the backoff delays are `1s,2s,4s…` capped. - -## Deliverable -The queue + retry/backoff policy + idempotent job + DLQ; passing Node tests (transient recovers, permanent → DLQ after max attempts, duplicate run no-ops, correct backoff); and a note on what you moved off the request and why. - -## Cleanup -```bash -rm -f /tmp/swexp-be/jobs.ts -``` - -## Check -`../solutions/lab-07-solution.md`. diff --git a/labs/lab-07-jobs/README.md b/labs/lab-07-jobs/README.md new file mode 100644 index 0000000..21007c2 --- /dev/null +++ b/labs/lab-07-jobs/README.md @@ -0,0 +1,26 @@ +# Lab 07 — Move Work Off the Request + +**Ticket:** JOBS-7001 · **Goal:** a job queue with bounded retries + exponential backoff, idempotent jobs, and a dead-letter queue. + +## What you do +In [`src/jobs.ts`](src/jobs.ts): + +- **`nextDelayMs` / `shouldRetry`** — exponential backoff (`base * 2^attempt`, capped) and a bounded + retry decision. +- **`JobQueue.processOne(handler)`** — run a job; on a transient throw, re-enqueue with `attempts+1` + while attempts remain (`retried`), otherwise move it to `deadLetter` (`dead-lettered`). An empty queue → + `empty`. **No infinite loops.** +- **`sendOrderEmail(orderId, sent)`** — an idempotent job: at-least-once delivery means a re-run must be a + no-op. Send once, record it, no-op thereafter. + +Run: +```bash +npx vitest run labs/lab-07-jobs +``` + +## Definition of done +- All tests pass; `npm run check` clean. +- In your notebook, note what you moved off the request path and why. + +## Submit +Edit `src/`, run the tests, commit and push. diff --git a/labs/lab-07-jobs/src/jobs.ts b/labs/lab-07-jobs/src/jobs.ts new file mode 100644 index 0000000..44e9939 --- /dev/null +++ b/labs/lab-07-jobs/src/jobs.ts @@ -0,0 +1,76 @@ +/** + * Lab 07 — Move Work Off the Request. See README.md. + * + * A job queue with bounded retries + exponential backoff and a dead-letter queue, plus + * an idempotent job (at-least-once delivery makes a re-run a no-op). + * + * No `any`. + */ + +// --- retry / backoff policy (pure) ------------------------------------------ + +/** Exponential backoff: baseMs * 2^attempt, capped at capMs. attempt 0 → baseMs. */ +export function nextDelayMs(attempt: number, baseMs = 1000, capMs = 60000): number { + // TODO: Math.min(capMs, baseMs * 2 ** attempt). + return 0; +} + +/** Retry while we have attempts left. */ +export function shouldRetry(attempt: number, maxAttempts: number): boolean { + // TODO: attempt < maxAttempts. + return false; +} + +export interface Job { + id: string; + type: string; + payload: unknown; + attempts: number; +} + +export type ProcessOutcome = 'done' | 'retried' | 'dead-lettered' | 'empty'; + +/** A minimal queue with retry + dead-letter. */ +export class JobQueue { + private queue: Job[] = []; + readonly deadLetter: Job[] = []; + constructor(private maxAttempts = 3) {} + + /** Enqueue a new job (attempts start at 0). */ + enqueue(job: Omit): void { + // TODO: push { ...job, attempts: 0 }. + void job; + } + + size(): number { + return this.queue.length; + } + + /** + * Process one job with a handler that may throw on transient failure. + * - empty queue → 'empty' + * - handler resolves → 'done' + * - handler throws and attempts remain → re-enqueue with attempts+1, return 'retried' + * - handler throws and no attempts remain → push to deadLetter (attempts+1), return 'dead-lettered' + */ + async processOne(handler: (job: Job) => Promise): Promise { + // TODO: shift a job; try handler; on throw use shouldRetry(attempts, maxAttempts) + // to decide retry vs dead-letter. + void handler; + return 'empty'; + } +} + +// --- an idempotent job ------------------------------------------------------- + +/** + * Send an order email at-most-once per order. `sent` records orders already emailed. + * Returns true if it actually sent (first time), false if it was a no-op (already sent). + * Record the send in `sent` so a re-run is a no-op. + */ +export function sendOrderEmail(orderId: string, sent: Set): boolean { + // TODO: if already in `sent` → return false; else add and return true. + void orderId; + void sent; + return false; +} diff --git a/labs/lab-07-jobs/tests/jobs.test.ts b/labs/lab-07-jobs/tests/jobs.test.ts new file mode 100644 index 0000000..d638b12 --- /dev/null +++ b/labs/lab-07-jobs/tests/jobs.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { nextDelayMs, shouldRetry, JobQueue, sendOrderEmail, type Job } from '../src/jobs'; + +describe('lab 07 — backoff policy', () => { + it('is exponential and capped', () => { + expect(nextDelayMs(0)).toBe(1000); + expect(nextDelayMs(1)).toBe(2000); + expect(nextDelayMs(2)).toBe(4000); + expect(nextDelayMs(10)).toBe(60000); // capped + }); + it('shouldRetry respects maxAttempts', () => { + expect(shouldRetry(1, 3)).toBe(true); + expect(shouldRetry(3, 3)).toBe(false); + expect(shouldRetry(4, 3)).toBe(false); + }); +}); + +describe('lab 07 — job queue retry + dead-letter', () => { + it('a transient failure (fails twice, then succeeds) recovers', async () => { + const q = new JobQueue(3); + q.enqueue({ id: 'j1', type: 'email', payload: {} }); + let calls = 0; + const handler = async (_job: Job) => { + calls += 1; + if (calls < 3) throw new Error('transient'); + }; + expect(await q.processOne(handler)).toBe('retried'); + expect(await q.processOne(handler)).toBe('retried'); + expect(await q.processOne(handler)).toBe('done'); + expect(q.deadLetter).toHaveLength(0); + expect(q.size()).toBe(0); + }); + + it('a permanent failure dead-letters after maxAttempts (no infinite loop)', async () => { + const q = new JobQueue(3); + q.enqueue({ id: 'j2', type: 'email', payload: {} }); + const always = async () => { + throw new Error('permanent'); + }; + expect(await q.processOne(always)).toBe('retried'); // attempts 1 + expect(await q.processOne(always)).toBe('retried'); // attempts 2 + expect(await q.processOne(always)).toBe('dead-lettered'); // attempts 3 → DLQ + expect(q.deadLetter).toHaveLength(1); + expect(q.deadLetter[0]?.attempts).toBe(3); + expect(q.size()).toBe(0); + }); + + it('processing an empty queue returns "empty"', async () => { + const q = new JobQueue(); + expect(await q.processOne(async () => {})).toBe('empty'); + }); +}); + +describe('lab 07 — idempotent job', () => { + it('sends once, then no-ops on a duplicate run', () => { + const sent = new Set(); + expect(sendOrderEmail('o-1', sent)).toBe(true); + expect(sendOrderEmail('o-1', sent)).toBe(false); + expect(sendOrderEmail('o-2', sent)).toBe(true); + }); +}); diff --git a/labs/lab-08-ops.md b/labs/lab-08-ops.md deleted file mode 100644 index 2b37d64..0000000 --- a/labs/lab-08-ops.md +++ /dev/null @@ -1,58 +0,0 @@ -# Lab 08 — Pass the Production Readiness Review - -**Lesson:** 08 · **Goal:** liveness/readiness, structured correlated logs with redaction, env config validated at boot, and graceful-shutdown draining — verified. - -## Goal -Make the service operable: an orchestrator can tell if it's alive vs ready, incidents are traceable, secrets stay out of logs and source, and deploys don't drop in-flight work. - -## Setup -```bash -cd /tmp/swexp-be -cat > ops.ts <<'TS' -// --- readiness aggregation (pure): ready only if every dependency check passes --- -export function aggregateReadiness(checks: Record): { ready: boolean; checks: Record } { - return { ready: Object.values(checks).every(Boolean), checks }; -} - -// --- config validation at boot (fail fast on missing required vars) --- -export interface Config { port: number; dbUrl: string; jwtSecret: string; } -export function loadConfig(env: Record): { ok: true; config: Config } | { ok: false; missing: string[] } { - const missing: string[] = []; - for (const key of ['PORT', 'DATABASE_URL', 'JWT_SECRET']) if (!env[key]) missing.push(key); - if (missing.length) return { ok: false, missing }; - return { ok: true, config: { port: Number(env.PORT), dbUrl: env.DATABASE_URL!, jwtSecret: env.JWT_SECRET! } }; -} - -// --- log redaction: never log secrets/tokens/passwords --- -const SECRET_KEYS = ['password', 'token', 'secret', 'authorization']; -export function redact(entry: Record): Record { - const out: Record = {}; - for (const [k, v] of Object.entries(entry)) out[k] = SECRET_KEYS.includes(k.toLowerCase()) ? '[REDACTED]' : v; - return out; -} - -// --- graceful shutdown decision: keep draining until idle or deadline --- -export function shouldKeepDraining(inFlight: number, elapsedMs: number, deadlineMs: number): boolean { - return inFlight > 0 && elapsedMs < deadlineMs; -} -TS -echo "Wire health endpoints + structured logger + config + shutdown; unit-test; verify readiness end-to-end." -``` - -## Tasks -1. **Liveness vs readiness.** `/healthz` (liveness) stays trivial — process alive, no dependency checks. `/readyz` (readiness) aggregates dependency checks (DB reachable, etc.) and flips to not-ready when one is down. -2. **Structured, correlated logs.** Log JSON; stamp every line in a request with a `requestId` (from `X-Request-Id` or generated). Run untrusted fields through `redact` so secrets/tokens never land in logs. -3. **Config from the environment, validated at boot.** `loadConfig(process.env)` fails fast (lists missing vars) if a required var is absent — discovered at startup, not at 3am. -4. **Graceful shutdown.** On `SIGTERM`: stop accepting connections, drain in-flight work using `shouldKeepDraining` until idle or deadline, close resources, exit. -5. **Unit-test** the pure logic (readiness aggregation, config validation, redaction, drain decision) and **verify readiness end-to-end** (ready when deps up; not-ready when a dep is down). - -## Deliverable -The health endpoints, structured+redacting logger, validated config, and graceful-shutdown drain; passing Node tests of the pure logic; and end-to-end evidence (readiness reflects a downed dependency; missing config fails fast; logs share a requestId and redact secrets). - -## Cleanup -```bash -rm -f /tmp/swexp-be/ops.ts -``` - -## Check -`../solutions/lab-08-solution.md`. diff --git a/labs/lab-08-ops/README.md b/labs/lab-08-ops/README.md new file mode 100644 index 0000000..955cce8 --- /dev/null +++ b/labs/lab-08-ops/README.md @@ -0,0 +1,26 @@ +# Lab 08 — Pass the Production Readiness Review + +**Ticket:** OPS-8001 · **Goal:** readiness aggregation, env config validated at boot, log redaction, graceful-shutdown drain. + +## What you do +In [`src/ops.ts`](src/ops.ts), implement the operable logic as pure functions: + +- **`aggregateReadiness(checks)`** — ready only if **every** dependency check passes. +- **`loadConfig(env)`** — require `PORT`, `DATABASE_URL`, `JWT_SECRET`; fail fast listing the missing vars + (discovered at boot, not at 3am); otherwise return a typed `Config`. +- **`redact(entry)`** — return a copy with secret-ish keys (`password`, `token`, `secret`, + `authorization`, case-insensitive) replaced by `[REDACTED]`. Don't mutate the input. +- **`shouldKeepDraining(inFlight, elapsedMs, deadlineMs)`** — keep draining while there is in-flight work + **and** we are under the deadline. + +Run: +```bash +npx vitest run labs/lab-08-ops +``` + +## Definition of done +- All tests pass; `npm run check` clean. +- In your notebook, note how `/healthz` (liveness) differs from `/readyz` (readiness). + +## Submit +Edit `src/`, run the tests, commit and push. diff --git a/labs/lab-08-ops/src/ops.ts b/labs/lab-08-ops/src/ops.ts new file mode 100644 index 0000000..6eaae31 --- /dev/null +++ b/labs/lab-08-ops/src/ops.ts @@ -0,0 +1,57 @@ +/** + * Lab 08 — Pass the Production Readiness Review. See README.md. + * + * The operable bits as pure logic: readiness aggregation, boot-time config validation, + * log redaction, and a graceful-shutdown drain decision. + * + * No `any`. + */ + +// --- readiness aggregation --------------------------------------------------- + +/** Ready only if EVERY dependency check passes. Echo the checks back. */ +export function aggregateReadiness( + checks: Record, +): { ready: boolean; checks: Record } { + // TODO: ready = every value is true; return { ready, checks }. + return { ready: false, checks }; +} + +// --- config validation at boot (fail fast) ----------------------------------- + +export interface Config { + port: number; + dbUrl: string; + jwtSecret: string; +} + +/** + * Validate required env vars (PORT, DATABASE_URL, JWT_SECRET). Missing any → + * { ok: false, missing: [...] } listing the absent keys (in that order). All present → + * { ok: true, config }. + */ +export function loadConfig( + env: Record, +): { ok: true; config: Config } | { ok: false; missing: string[] } { + // TODO: collect missing of ['PORT','DATABASE_URL','JWT_SECRET']; if any → fail; + // else build the typed Config (Number(PORT), etc.). + return { ok: false, missing: ['PORT', 'DATABASE_URL', 'JWT_SECRET'] }; +} + +// --- log redaction ----------------------------------------------------------- + +const SECRET_KEYS = ['password', 'token', 'secret', 'authorization']; + +/** Return a copy with secret-ish keys (case-insensitive) replaced by '[REDACTED]'. */ +export function redact(entry: Record): Record { + // TODO: for each [k,v], if SECRET_KEYS includes k.toLowerCase() → '[REDACTED]' else v. + return { ...entry }; +} + +// --- graceful shutdown decision ---------------------------------------------- + +/** Keep draining while there is in-flight work AND we are under the deadline. */ +export function shouldKeepDraining(inFlight: number, elapsedMs: number, deadlineMs: number): boolean { + // TODO: inFlight > 0 && elapsedMs < deadlineMs. + return false; +} diff --git a/labs/lab-08-ops/tests/ops.test.ts b/labs/lab-08-ops/tests/ops.test.ts new file mode 100644 index 0000000..64371a9 --- /dev/null +++ b/labs/lab-08-ops/tests/ops.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { aggregateReadiness, loadConfig, redact, shouldKeepDraining } from '../src/ops'; + +describe('lab 08 — readiness aggregation', () => { + it('ready only when every check passes', () => { + expect(aggregateReadiness({ db: true, cache: true })).toEqual({ + ready: true, + checks: { db: true, cache: true }, + }); + expect(aggregateReadiness({ db: true, cache: false }).ready).toBe(false); + }); + it('an empty set of checks is vacuously ready', () => { + expect(aggregateReadiness({}).ready).toBe(true); + }); +}); + +describe('lab 08 — config validation at boot', () => { + it('fails fast listing missing vars', () => { + const r = loadConfig({ PORT: '3000' }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.missing).toEqual(['DATABASE_URL', 'JWT_SECRET']); + }); + it('succeeds with all required vars and parses port to a number', () => { + const r = loadConfig({ PORT: '8080', DATABASE_URL: 'postgres://x', JWT_SECRET: 's' }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.config).toEqual({ port: 8080, dbUrl: 'postgres://x', jwtSecret: 's' }); + expect(typeof r.config.port).toBe('number'); + } + }); +}); + +describe('lab 08 — log redaction', () => { + it('redacts secret-ish keys, case-insensitively, keeps the rest', () => { + const out = redact({ + requestId: 'r-1', + Authorization: 'Bearer abc', + password: 'p', + TOKEN: 't', + user: 'ada', + }); + expect(out.requestId).toBe('r-1'); + expect(out.user).toBe('ada'); + expect(out.Authorization).toBe('[REDACTED]'); + expect(out.password).toBe('[REDACTED]'); + expect(out.TOKEN).toBe('[REDACTED]'); + }); + it('does not mutate the original entry', () => { + const entry = { token: 'secret' }; + redact(entry); + expect(entry.token).toBe('secret'); + }); +}); + +describe('lab 08 — graceful shutdown drain', () => { + it('drains while work remains and under the deadline', () => { + expect(shouldKeepDraining(3, 500, 5000)).toBe(true); + }); + it('stops when idle', () => { + expect(shouldKeepDraining(0, 100, 5000)).toBe(false); + }); + it('stops at the deadline even with work left', () => { + expect(shouldKeepDraining(2, 5000, 5000)).toBe(false); + expect(shouldKeepDraining(2, 6000, 5000)).toBe(false); + }); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2f0b03e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1454 @@ +{ + "name": "swexp-module-06-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "swexp-module-06-backend", + "version": "1.0.0", + "devDependencies": { + "@types/node": "^20.16.5", + "typescript": "^5.6.3", + "vitest": "^2.1.8" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..493dcb5 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "swexp-module-06-backend", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Forge SWEXP Module 06 — interactive Backend Engineering / API Design exercises (clone, implement, npm test).", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "test:types": "vitest run --typecheck", + "check": "tsc --noEmit -p tsconfig.json", + "grade": "node scripts/grade.mjs" + }, + "devDependencies": { + "@types/node": "^20.16.5", + "typescript": "^5.6.3", + "vitest": "^2.1.8" + } +} diff --git a/scripts/grade.mjs b/scripts/grade.mjs new file mode 100644 index 0000000..3289040 --- /dev/null +++ b/scripts/grade.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** + * Forge SWEXP autograder (Module 06). + * Runs every exercise's tests (behaviour + type-level) and the strict type gate, + * then prints a per-exercise score and writes a Markdown report for GitHub Actions. + * + * Grouping is by exercise folder under labs/ and assignments/. The tests are the + * spec — no answer keys are shipped. + */ +import { execSync } from 'node:child_process'; +import { readFileSync, writeFileSync, mkdirSync, appendFileSync, existsSync } from 'node:fs'; + +const REPORT = '.grade/vitest.json'; +mkdirSync('.grade', { recursive: true }); + +function run(cmd) { + try { + return { ok: true, out: execSync(cmd, { stdio: ['ignore', 'pipe', 'pipe'] }).toString() }; + } catch (e) { + return { ok: false, out: `${e.stdout ?? ''}${e.stderr ?? ''}` }; + } +} + +// Exercise folder name from a test file path, e.g. ".../labs/lab-02-service-layer/tests/x.test.ts" +function exerciseOf(p) { + const m = p.replace(/\\/g, '/').match(/\/(labs|assignments)\/([^/]+)\//); + return m ? `${m[1]}/${m[2]}` : null; +} + +// 1) Behaviour + type-level tests. +run(`npx vitest run --typecheck --reporter=json --outputFile=${REPORT}`); +if (!existsSync(REPORT)) { + console.error('Could not produce a test report. Run `npm install` first.'); + process.exit(2); +} +const report = JSON.parse(readFileSync(REPORT, 'utf8')); + +// 2) Strict type gate (the compiler is your first reviewer). +const typeGate = run('npx tsc --noEmit -p tsconfig.json'); + +// Aggregate per exercise. +const tally = {}; +for (const file of report.testResults ?? []) { + const key = exerciseOf(file.name); + if (!key) continue; + tally[key] ??= { passed: 0, total: 0 }; + for (const a of file.assertionResults ?? []) { + tally[key].total += 1; + if (a.status === 'passed') tally[key].passed += 1; + } +} + +const passed = report.numPassedTests ?? 0; +const total = report.numTotalTests ?? 0; +const pct = total ? Math.round((passed / total) * 100) : 0; +const complete = passed === total && total > 0 && typeGate.ok; + +const rows = Object.keys(tally) + .sort() + .map((k) => { + const t = tally[k]; + const mark = t.passed === t.total ? '✅' : '❌'; + return `| \`${k}\` | ${t.passed}/${t.total} | ${mark} |`; + }); + +const md = [ + `## Forge SWEXP — Module 06 autograde`, + ``, + `**Score: ${passed}/${total} tests (${pct}%)** · Strict type-check: ${typeGate.ok ? '✅ clean' : '❌ errors'}`, + ``, + `| Exercise | Tests | Status |`, + `| --- | --- | --- |`, + ...rows, + ``, + complete + ? `🎉 **All exercises complete and the project type-checks clean.**` + : `Keep going — open each exercise folder, implement the \`// TODO\`s in its \`src/\`, and run \`npm test\`. The tests in each \`tests/\` folder are the spec.`, +].join('\n'); + +writeFileSync('grade-report.md', md + '\n'); +console.log('\n' + md + '\n'); + +if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, md + '\n'); +} + +process.exit(complete ? 0 : 1); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ce4a2fd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node", "vitest/globals"] + }, + "include": ["labs/**/src", "labs/**/tests", "assignments/**/src", "assignments/**/tests"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ce10ecb --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['labs/**/tests/**/*.test.ts', 'assignments/**/tests/**/*.test.ts'], + typecheck: { + enabled: false, // turned on by `npm run test:types` / the grader + include: ['labs/**/tests/**/*.test-d.ts', 'assignments/**/tests/**/*.test-d.ts'], + tsconfig: 'tsconfig.json', + }, + }, +});