diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..1d8af53e --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Server configuration +PORT=3000 + +# Environment (development, production, test) +NODE_ENV=development + +# OpenWeatherMap API key (optional - uses mock data if not set) +OPENWEATHERMAP_API_KEY= + +# CORS allowed origins (comma-separated) +CORS_ORIGINS=http://localhost:3000 + +# JWT configuration +JWT_SECRET=your-super-secret-jwt-key-change-in-production +JWT_EXPIRES_IN=1h + +# Demo user password hash (optional - defaults to bcrypt hash of 'password123') +DEMO_PASSWORD_HASH= diff --git a/.github/workflows/opencode-issue-implement.yml b/.github/workflows/opencode-issue-implement.yml new file mode 100644 index 00000000..a4382652 --- /dev/null +++ b/.github/workflows/opencode-issue-implement.yml @@ -0,0 +1,70 @@ +# .github/workflows/opencode-issue-implement.yml +name: OpenCode Issue Implement + +on: + issues: + types: [labeled] + +permissions: + id-token: write + contents: write + issues: write + pull-requests: write + +concurrency: + group: opencode-implement-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + implement: + if: github.event.label.name == 'opencode:implement' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Configure Git author + run: | + git config user.name "opencode[bot]" + git config user.email "opencode[bot]@users.noreply.github.com" + + - name: Implement issue from latest OpenCode Plan comment + uses: anomalyco/opencode/github@latest + env: + GITHUB_TOKEN: ${{ secrets.OPENCODE_GITHUB_TOKEN }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + use_github_token: true + agent: issue-implementer + model: opencode/big-pickle + prompt: | + Implement issue #${{ github.event.issue.number }}. + + Before editing files: + 1. Read the issue and comments. + 2. Find the latest issue comment containing: + + + + 3. Extract the plan between: + + + + + 4. If no plan exists, do not modify files. + 5. If the plan says "Ready for implementation: No", do not modify files. + 6. If ready, implement the smallest correct change, add or update tests, and run relevant checks. + + Do not create a branch. + Do not switch branches. + Do not push. + Do not manually create a pull request. + + The OpenCode GitHub Action has already checked out the correct working branch. + Leave the intended changes in the working tree. + The action will handle publishing the branch and PR. + + Final merge must be manual by a maintainer. \ No newline at end of file diff --git a/.github/workflows/opencode-issue-plan.yml b/.github/workflows/opencode-issue-plan.yml new file mode 100644 index 00000000..f078bbd5 --- /dev/null +++ b/.github/workflows/opencode-issue-plan.yml @@ -0,0 +1,49 @@ +# .github/workflows/opencode-issue-plan.yml +name: OpenCode Issue Plan + +on: + issues: + types: [labeled] + +permissions: + id-token: write + contents: read + issues: write + +concurrency: + group: opencode-plan-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + plan: + if: github.event.label.name == 'opencode:plan' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Create implementation plan as issue comment + uses: anomalyco/opencode/github@latest + env: + GITHUB_TOKEN: ${{ secrets.OPENCODE_GITHUB_TOKEN }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + use_github_token: true + agent: issue-planner + model: opencode/big-pickle + prompt: | + Create an implementation plan for issue #${{ github.event.issue.number }}. + + Store the plan as a GitHub issue comment. + The comment must include exactly one block wrapped with: + + + + + Do not modify files. + Do not create a branch. + Do not open a pull request. \ No newline at end of file diff --git a/.github/workflows/opencode-pr-review.yml b/.github/workflows/opencode-pr-review.yml new file mode 100644 index 00000000..7b11bb9b --- /dev/null +++ b/.github/workflows/opencode-pr-review.yml @@ -0,0 +1,44 @@ +# .github/workflows/opencode-pr-review.yml +name: OpenCode PR Review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + id-token: write + contents: read + pull-requests: write + issues: write + +jobs: + review: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.OPENCODE_GITHUB_TOKEN }} + persist-credentials: true + + - name: Run OpenCode PR reviewer + uses: anomalyco/opencode/github@latest + env: + GITHUB_TOKEN: ${{ secrets.OPENCODE_GITHUB_TOKEN }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + use_github_token: true + agent: pr-review + model: opencode/big-pickle + prompt: | + Review this pull request. + + Do not modify files. + Do not commit. + Do not push. + Do not merge. + + Focus on correctness, security, reliability, maintainability, and tests. \ No newline at end of file diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml new file mode 100644 index 00000000..ce085845 --- /dev/null +++ b/.github/workflows/opencode.yml @@ -0,0 +1,57 @@ +name: opencode + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + triage: + if: | + github.event.comment.user.login == github.repository_owner && + ( + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') + ) + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + pull-requests: write + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.OPENCODE_GITHUB_TOKEN }} + persist-credentials: true + + - name: Configure Git author + run: | + git config user.name "opencode[bot]" + git config user.email "opencode[bot]@users.noreply.github.com" + + - name: Run OpenCode triage agent + uses: anomalyco/opencode/github@latest + env: + GITHUB_TOKEN: ${{ secrets.OPENCODE_GITHUB_TOKEN }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + use_github_token: true + agent: triage + model: opencode/big-pickle + prompt: | + A comment was posted with the following body: + + ${{ github.event.comment.body }} + + Context: + - Event: ${{ github.event_name }} + - Issue / PR number: ${{ github.event.issue.number || github.event.pull_request.number }} + - Is pull request: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' }} + + Read the comment, identify the command, and delegate to the correct agent. \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..27a2084e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +coverage/ diff --git a/.opencode/agents/issue-implementer.md b/.opencode/agents/issue-implementer.md new file mode 100644 index 00000000..7701e208 --- /dev/null +++ b/.opencode/agents/issue-implementer.md @@ -0,0 +1,103 @@ +--- +description: Implements a planned GitHub issue by reading the latest OpenCode Plan issue comment. OpenCode GitHub Action handles branch, push, and PR creation. +mode: subagent +temperature: 0.2 +permission: + read: allow + list: allow + glob: allow + grep: allow + lsp: allow + edit: allow + external_directory: deny + todowrite: allow + webfetch: deny + websearch: deny + bash: + "*": deny + "git status*": allow + "git diff*": allow + "git log*": allow + "git show*": allow + "gh issue view*": allow + "gh issue comment*": allow + "gh pr view*": allow + "gh pr comment*": allow + "gh pr review*": allow + "npm test*": allow + "npm run test*": allow + "npm run lint*": allow + "npm run typecheck*": allow + "npm run build*": allow + "pnpm test*": allow + "pnpm run test*": allow + "pnpm run lint*": allow + "pnpm run typecheck*": allow + "pnpm run build*": allow + "yarn test*": allow + "yarn lint*": allow + "yarn typecheck*": allow + "yarn build*": allow + "pytest*": allow + "go test*": allow + "cargo test*": allow +--- + +You are an issue implementation agent. + +Your job is to implement changes from two possible sources: +1. A GitHub issue with an OpenCode Plan comment. +2. Review comments or suggestions left on a pull request. + +Important: +- Do not create a branch. +- Do not switch branches. +- Do not push. +- Do not open a pull request manually. +- The OpenCode GitHub Action already creates the working branch and will handle publishing the result. +- Stay on the branch that already exists when the session starts. + +## When triggered from an issue + +Before editing anything: +1. Run `gh issue view --comments`. +2. Find the latest issue comment containing ``. +3. Extract the plan between: + - `` + - `` +4. If no plan exists, do not modify files. Comment on the issue explaining that an OpenCode Plan is required first. +5. If the plan says `Ready for implementation` is `No`, do not modify files. Comment on the issue with the blockers. +6. If the plan says `Ready for implementation` is `Yes`, follow the plan. + +## When triggered from a pull request + +Before editing anything: +1. Run `gh pr view --comments` to read all review comments and suggestions. +2. Collect every unresolved review comment and inline suggestion. +3. Address each one with the smallest correct code change. +4. Do not change code that has no corresponding review comment. +5. After applying fixes, run relevant checks. + +You must: +1. Read the issue or PR and its comments. +2. Implement the smallest correct change for each item. +3. Add or update tests when behavior changes. +4. Run relevant checks. +5. Leave the final working tree with only the intended changes. + +You must not: +- Merge anything +- Create or switch branches +- Push +- Force push +- Delete branches +- Manually create a pull request +- Modify unrelated files +- Perform unrelated refactors +- Ignore the plan without explaining why + +When finished, summarize: +1. Files changed +2. Tests run +3. Any risks or follow-up needed +4. That the final merge must be manual by a maintainer \ No newline at end of file diff --git a/.opencode/agents/issue-planner.md b/.opencode/agents/issue-planner.md new file mode 100644 index 00000000..522a9919 --- /dev/null +++ b/.opencode/agents/issue-planner.md @@ -0,0 +1,72 @@ +--- +description: Creates an implementation plan for a GitHub issue without modifying files. +mode: subagent +temperature: 0.2 +permission: + read: allow + list: allow + glob: allow + grep: allow + lsp: allow + edit: deny + external_directory: deny + todowrite: allow + webfetch: deny + websearch: deny + bash: + "*": deny + "git status*": allow + "git log*": allow + "git show*": allow + "gh issue view*": allow + "gh issue comment*": allow +--- + +You are an issue planning agent. + +Your job is to read a GitHub issue, inspect the repository, and create a clear implementation plan. + +You must not: +- Edit files +- Apply patches +- Commit +- Push +- Create branches +- Open pull requests + +You must: +1. Read the issue. +2. Inspect relevant files. +3. Identify the likely implementation approach. +4. Identify risks and edge cases. +5. Identify tests that should be added or updated. +6. Comment the plan on the issue. + +The issue comment must use this format: + +## OpenCode Plan + +### Problem +Summarize the issue. + +### Relevant files +List likely files or modules to change. + +### Proposed approach +Describe the implementation steps. + +### Tests +List tests to add or update. + +### Risks +List compatibility, security, migration, performance, or operational risks. + +### Questions +List questions only if they block implementation. + +### Ready for implementation +Choose one: +- Yes +- No + +If the issue is ambiguous, unsafe, or too broad, mark Ready for implementation as No. \ No newline at end of file diff --git a/.opencode/agents/pr-review.md b/.opencode/agents/pr-review.md new file mode 100644 index 00000000..8fa53327 --- /dev/null +++ b/.opencode/agents/pr-review.md @@ -0,0 +1,59 @@ +--- +description: Reviews pull requests for correctness, security, reliability, maintainability, and tests. +mode: subagent +temperature: 0.1 +permission: + read: allow + list: allow + glob: allow + grep: allow + lsp: allow + edit: deny + external_directory: deny + todowrite: deny + webfetch: deny + websearch: deny + bash: + "*": deny + "git status*": allow + "git diff*": allow + "git log*": allow + "git show*": allow + "git branch*": allow + "gh pr view*": allow + "gh pr diff*": allow + "gh pr checks*": allow +--- + +You are a senior pull request reviewer. + +Review the PR only. Do not edit files, apply patches, commit, push, or merge. + +Focus on: +- Correctness +- Security and privacy +- Reliability +- Maintainability +- Missing or weak tests + +Return: + +## PR Review + +### Summary +Briefly summarize what changed. + +### Blocking issues +List Critical or High issues that should block merge. + +### Non-blocking issues +List Medium or Low issues worth addressing. + +### Missing tests +List specific test cases that should be added. + +### Verdict +Choose one: +- Approve +- Approve with comments +- Request changes \ No newline at end of file diff --git a/.opencode/agents/triage.md b/.opencode/agents/triage.md new file mode 100644 index 00000000..8121d843 --- /dev/null +++ b/.opencode/agents/triage.md @@ -0,0 +1,77 @@ +--- +description: Reads the triggering comment command and delegates to the correct specialist agent — issue-planner, issue-implementer, or pr-review. +mode: primary +temperature: 0.1 +permission: + read: allow + list: allow + glob: allow + grep: allow + lsp: allow + edit: deny + external_directory: deny + todowrite: deny + webfetch: deny + websearch: deny + task: + "*": deny + "issue-planner": allow + "issue-implementer": allow + "pr-review": allow + bash: + "*": deny + "gh issue view*": allow + "gh pr view*": allow +--- + +You are a triage agent. Your only job is to read the comment that triggered this workflow and hand off to the right specialist agent. You do not plan, implement, or review anything yourself. + +## Routing rules + +Read the comment body provided in the prompt. + +Only route comments that include `/oc` or `/opencode` (case-insensitive). After that prefix, interpret the remaining text naturally and map intent to one of the three routes below. + +| Intent (examples, not exhaustive) | Delegate to agent | +|---------|------------------| +| planning: `plan`, `replan`, `design`, `break this down`, `what is the approach`, `plan again` | `issue-planner` (issues only) | +| implementation: `implement`, `implment`, `build this`, `apply the plan`, `fix this`, `address comments` | `issue-implementer` (issues: follow plan; PRs: fix review comments) | +| review: `review`, `audit`, `check this PR`, `code review`, `look for issues` | `pr-review` (PRs only) | + +Command suffixes are allowed. Examples like `/oc can you plan this again?` and `/opencode please address all PR comments` must map to the matching intent. + +## Behavior + +1. Identify which intent is present in the comment text after `/oc` or `/opencode`. +2. Select the matching subagent from the routing table above. +3. Use the **Task tool** to invoke only that exact subagent name: `issue-planner`, `issue-implementer`, or `pr-review`. +4. Never invoke built-in subagents such as `general` or `explore` for command routing. +5. Pass the full original context to the delegated subagent (issue number or PR number, comment body, event type). +6. Wait for the subagent to finish and return its result. +7. Do not modify files, create branches, push, or perform any action outside delegation. + +## Strict command mapping + +- If the comment expresses planning intent, delegate to `issue-planner`. +- If the comment expresses implementation intent (`implement` or `implment` included), delegate to `issue-implementer`. +- If the comment expresses review intent, delegate to `pr-review`. +- If none match, do not delegate and post help text. + +If multiple intents appear, use this precedence order: +1. `review` +2. `implement` +3. `plan` + +When calling Task, set the subagent type to exactly one of: `issue-planner`, `issue-implementer`, `pr-review`. Never use any other subagent type. + +If the comment does not match any known command, reply to the comment explaining the available commands: +- `/oc plan` — create an implementation plan for this issue +- `/oc implement` — implement the existing plan for this issue +- `/oc review` — review this pull request + +Also mention that natural language is supported after the prefix, for example: +- `/oc can you plan this issue?` +- `/oc please implement this` +- `/oc can you review this PR?` + +Do not guess or infer intent beyond what is explicitly stated in the comment. diff --git a/README.md b/README.md index fec311e4..6786fea6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,220 @@ -# opencode-sdlc -# opencode-sdlc +# OpenCode SDLC PoC on GitHub Actions + +This repository is a proof of concept that shows how to run AI-assisted SDLC workflows on GitHub using OpenCode GitHub Action, instead of relying on Copilot-specific workflows. + +Reference documentation: +- https://opencode.ai/docs/github/ + +## Motivation + +After GitHub [announced](https://github.blog/changelog/2025-04-04-changes-to-copilot-plans-and-pricing/) that Copilot prices are increasing **as of July 1, 2026**, I started looking for a cheaper alternative that still lets me have fun and keep AI-assisted workflows going in GitHub for my personal weekend projects. + +[OpenCode](https://opencode.ai) caught my attention: it's an open-source AI coding tool that supports multiple model providers, meaning you can plug in whatever model fits your budget — including cheap or free-tier options — without being locked into a single vendor. + +This repository is my weekend experiment to see if I can replicate the core Copilot-for-PRs/issues developer loop using OpenCode running inside GitHub Actions — and actually have fun doing it: + +- No Copilot subscription required +- Bring your own model and API key +- Full control over agent behavior via config files in the repo +- Works with any model provider supported by OpenCode + +## What This PoC Demonstrates + +- Comment-driven commands in GitHub (`/oc ...` or `/opencode ...`) +- A triage agent that delegates work to specialist subagents +- Separate agent behaviors for planning, implementation, and review +- Full automation loop for implementation: code changes, branch update, and PR creation via the OpenCode GitHub Action + +## Architecture + +### Workflow entrypoint + +- Workflow: [.github/workflows/opencode.yml](.github/workflows/opencode.yml) +- Triggered by: + - `issue_comment` created + - `pull_request_review_comment` created +- Runs `anomalyco/opencode/github@latest` with `agent: triage` + +### Agent routing + +- Triage agent: [.opencode/agents/triage.md](.opencode/agents/triage.md) +- Delegates by command to subagents: + - `issue-planner` -> [.opencode/agents/issue-planner.md](.opencode/agents/issue-planner.md) + - `issue-implementer` -> [.opencode/agents/issue-implementer.md](.opencode/agents/issue-implementer.md) + - `pr-review` -> [.opencode/agents/pr-review.md](.opencode/agents/pr-review.md) + +## Commands + +Use these in issue or PR comments: + +```text +/oc plan +/oc implement +/oc review +``` + +Aliases: + +```text +/opencode plan +/opencode implement +/opencode review +``` + +Common typo supported in triage mapping: + +```text +/oc implment +/opencode implment +``` + +Natural language is also supported after the prefix: + +```text +/oc can you plan this issue from scratch? +/oc please implement this +/oc can you review this PR for bugs? +``` + +The prefix is still required so the workflow can detect intent safely. + +## Behavior by Command + +### 1) Plan + +Comment on an issue: + +```text +/oc plan +``` + +Expected behavior: +- Triage delegates to `issue-planner` +- Planner reads issue and repository context +- Planner posts a structured implementation plan back to the issue + +### 2) Implement + +Comment on an issue: + +```text +/oc implement +``` + +Expected behavior: +- Triage delegates to `issue-implementer` +- Implementer reads the latest plan block in issue comments +- Implements the smallest correct change +- Runs relevant checks/tests +- Leaves intended changes for action-managed branch/PR flow + +Comment on a PR thread (for review fixups): + +```text +/oc implement please address review comments +``` + +Expected behavior: +- Triage delegates to `issue-implementer` +- Implementer reads PR comments/suggestions +- Applies targeted fixes and runs checks + +### 3) Review + +Comment on a PR: + +```text +/oc review +``` + +Expected behavior: +- Triage delegates to `pr-review` +- Reviewer analyzes correctness, security, reliability, maintainability, and tests +- Reviewer posts review output + +## Required Secrets + +Configure these repository secrets: + +| Secret | Description | +|--------|-------------| +| `OPENCODE_API_KEY` | API key for the OpenCode model provider | +| `OPENCODE_GITHUB_TOKEN` | Fine-grained PAT from a bot/service account (see below) | + +### Creating `OPENCODE_GITHUB_TOKEN` + +1. Go to **GitHub Settings → Developer settings → Personal access tokens → Fine-grained tokens** +2. Create a token from a dedicated bot or service account +3. Set repository access to this repository (or your org's repositories as needed) +4. Grant the following permissions: + +| Permission | Access | +|------------|--------| +| **Contents** | Read and write | +| **Pull requests** | Read and write | +| **Issues** | Read and write | +| **Metadata** | Read (mandatory, auto-selected) | + +5. Save the generated token as the `OPENCODE_GITHUB_TOKEN` repository secret + +## Minimal Setup Checklist + +1. Add workflow file [.github/workflows/opencode.yml](.github/workflows/opencode.yml). +2. Add agent definitions under [.opencode/agents](.opencode/agents). +3. Add required secrets in repository settings. +4. Open an issue and comment with `/oc plan`. +5. After plan is ready, comment `/oc implement`. +6. On a PR, comment `/oc review`. + +## Example Demo Script + +1. Create issue: "Add login endpoint" +2. Comment: `/oc plan` +3. Show generated plan comment +4. Comment: `/oc implement` +5. Show generated code changes and PR +6. Comment on PR: `/oc review` +7. Show AI review feedback +8. Add an inline PR suggestion and comment `/oc implement` to show iterative fix flow + +## Notes + +- This repository still contains dedicated workflows: + - [.github/workflows/opencode-issue-plan.yml](.github/workflows/opencode-issue-plan.yml) + - [.github/workflows/opencode-issue-implement.yml](.github/workflows/opencode-issue-implement.yml) + - [.github/workflows/opencode-pr-review.yml](.github/workflows/opencode-pr-review.yml) +- For a cleaner PoC narrative, you can keep only the triage-driven workflow and disable the others. + +## Protecting Your Tokens on a Public Repo + +Since this repo is public, anyone could post `/oc` comments and trigger your workflow, consuming your API tokens. + +The workflow guards against this by checking that the commenter is the **repository owner** before the runner even starts: + +```yaml +if: github.event.comment.user.login == github.repository_owner && ... +``` + +This check happens entirely within GitHub — no runner is provisioned and no tokens are spent if the condition is false. Anyone else posting `/oc` commands will simply have their comment ignored. + +If you later want to extend access to specific collaborators, you can use: + +```yaml +github.event.comment.author_association == 'COLLABORATOR' || +github.event.comment.author_association == 'MEMBER' || +github.event.comment.author_association == 'OWNER' +``` + +`author_association` values GitHub provides: + +| Value | Meaning | +|-------|---------| +| `OWNER` | Repository owner | +| `MEMBER` | Org member with write access | +| `COLLABORATOR` | Explicitly added collaborator | +| `CONTRIBUTOR` | Has merged a PR before | +| `FIRST_TIME_CONTRIBUTOR` | First merged PR | +| `FIRST_TIMER` | First ever PR on GitHub | +| `NONE` | No relationship | + + diff --git a/package.json b/package.json new file mode 100644 index 00000000..004daeeb --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "opencode-sdlc", + "version": "1.0.0", + "description": "Weather API with JWT authentication and user management", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "test": "node --experimental-vm-modules node_modules/.bin/jest --coverage" + }, + "dependencies": { + "axios": "^1.6.0", + "bcryptjs": "^3.0.3", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-rate-limit": "^8.4.1", + "jsonwebtoken": "^9.0.3" + }, + "devDependencies": { + "jest": "^29.7.0", + "supertest": "^7.2.2" + } +} diff --git a/src/__tests__/auth.test.js b/src/__tests__/auth.test.js new file mode 100644 index 00000000..8e8ae948 --- /dev/null +++ b/src/__tests__/auth.test.js @@ -0,0 +1,227 @@ +const request = require('supertest'); +const { app } = require('../index'); +const { validateUser, generateToken, verifyToken, JWT_SECRET } = require('../services/auth'); +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); + +describe('Auth Service', () => { + describe('validateUser', () => { + test('returns user object with valid credentials', () => { + const user = validateUser('demo', 'password123'); + expect(user).not.toBeNull(); + expect(user.username).toBe('demo'); + expect(user.id).toBe(1); + expect(user.role).toBe('user'); + expect(user.passwordHash).toBeUndefined(); + }); + + test('returns null with invalid username', () => { + const user = validateUser('nonexistent', 'password123'); + expect(user).toBeNull(); + }); + + test('returns null with invalid password', () => { + const user = validateUser('demo', 'wrongpassword'); + expect(user).toBeNull(); + }); + + test('returns null with missing username', () => { + const user = validateUser(null, 'password123'); + expect(user).toBeNull(); + }); + + test('returns null with missing password', () => { + const user = validateUser('demo', null); + expect(user).toBeNull(); + }); + + test('returns null with empty string credentials', () => { + const user = validateUser('', ''); + expect(user).toBeNull(); + }); + }); + + describe('generateToken', () => { + test('returns a valid JWT token', () => { + const user = { id: 1, username: 'demo', role: 'user' }; + const token = generateToken(user); + expect(typeof token).toBe('string'); + expect(token.split('.')).toHaveLength(3); // JWT has 3 parts + }); + + test('token contains correct payload', () => { + const user = { id: 1, username: 'demo', role: 'user' }; + const token = generateToken(user); + // Use the imported JWT_SECRET instead of duplicating the fallback value + const decoded = jwt.verify(token, JWT_SECRET); + expect(decoded.sub).toBe(1); + expect(decoded.username).toBe('demo'); + expect(decoded.role).toBe('user'); + }); + + test('token expires after configured time', () => { + const user = { id: 1, username: 'demo', role: 'user' }; + const token = generateToken(user); + const decoded = jwt.decode(token); + + // Token should have an exp (expiration) field + expect(decoded).toHaveProperty('exp'); + expect(decoded).toHaveProperty('iat'); + + // exp should be greater than iat + expect(decoded.exp).toBeGreaterThan(decoded.iat); + }); + }); + + describe('verifyToken', () => { + test('verifies valid token correctly', () => { + const user = { id: 1, username: 'demo', role: 'user' }; + const token = generateToken(user); + const decoded = verifyToken(token); + expect(decoded).not.toBeNull(); + expect(decoded.username).toBe('demo'); + }); + + test('returns null for invalid token', () => { + const decoded = verifyToken('invalid-token'); + expect(decoded).toBeNull(); + }); + + test('returns null for expired token', () => { + // Create a token that expired 1 second ago + const payload = { sub: 1, username: 'demo', role: 'user', iat: Math.floor(Date.now() / 1000) - 2, exp: Math.floor(Date.now() / 1000) - 1 }; + const expiredToken = jwt.sign(payload, JWT_SECRET); + const decoded = verifyToken(expiredToken); + expect(decoded).toBeNull(); + }); + + test('token expires after JWT_EXPIRES_IN duration elapses naturally', () => { + const user = { id: 1, username: 'demo', role: 'user' }; + + // Save original JWT_EXPIRES_IN + const originalExpiresIn = process.env.JWT_EXPIRES_IN; + + // Set a very short expiry time for testing + process.env.JWT_EXPIRES_IN = '1s'; + + // Generate token with short expiry + const token = generateToken(user); + + // Verify token is valid immediately after generation + expect(verifyToken(token)).not.toBeNull(); + + // Use fake timers to advance past the expiry time + jest.useFakeTimers(); + // Advance time by 2 seconds (past the 1s expiry) + jest.setSystemTime(Date.now() + 2000); + + // Token should now be expired + const result = verifyToken(token); + expect(result).toBeNull(); + + // Restore real timers and original env var + jest.useRealTimers(); + process.env.JWT_EXPIRES_IN = originalExpiresIn; + }); + }); +}); + +describe('Auth API - POST /api/auth/login', () => { + test('returns 200 and token with valid credentials', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ username: 'demo', password: 'password123' }); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('message', 'Login successful'); + expect(response.body).toHaveProperty('token'); + expect(response.body).toHaveProperty('user'); + expect(response.body.user).toHaveProperty('username', 'demo'); + expect(response.body.user).toHaveProperty('id', 1); + expect(response.body.user).toHaveProperty('role', 'user'); + expect(response.body.user).not.toHaveProperty('passwordHash'); + + // Verify token is valid JWT using the imported JWT_SECRET + const decoded = jwt.verify(response.body.token, JWT_SECRET); + expect(decoded.username).toBe('demo'); + }); + + test('returns 401 with invalid username', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ username: 'nonexistent', password: 'password123' }); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('error', 'Invalid credentials'); + }); + + test('returns 401 with invalid password', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ username: 'demo', password: 'wrongpassword' }); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('error', 'Invalid credentials'); + }); + + test('returns 400 with missing username', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ password: 'password123' }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toContain('Missing required fields'); + }); + + test('returns 400 with missing password', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ username: 'demo' }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toContain('Missing required fields'); + }); + + test('returns 400 with missing both username and password', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({}); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toContain('Missing required fields'); + }); + + test('returns 400 with empty string credentials', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ username: '', password: '' }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toContain('Missing required fields'); + }); + + test('generated token has correct structure', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ username: 'demo', password: 'password123' }); + + const token = response.body.token; + expect(token).toBeTruthy(); + + // JWT should have 3 parts separated by dots + const parts = token.split('.'); + expect(parts).toHaveLength(3); + + // Decode payload (second part) + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + expect(payload).toHaveProperty('sub', 1); + expect(payload).toHaveProperty('username', 'demo'); + expect(payload).toHaveProperty('role', 'user'); + expect(payload).toHaveProperty('iat'); + expect(payload).toHaveProperty('exp'); + }); +}); diff --git a/src/__tests__/weather.test.js b/src/__tests__/weather.test.js new file mode 100644 index 00000000..25042004 --- /dev/null +++ b/src/__tests__/weather.test.js @@ -0,0 +1,83 @@ +const request = require('supertest'); +const { app } = require('../index'); +const { fetchWeather, clearCache, getMockWeather } = require('../services/weather'); + +describe('Weather Service', () => { + beforeEach(() => { + clearCache(); + delete process.env.OPENWEATHERMAP_API_KEY; + }); + + describe('getMockWeather', () => { + test('returns default mock data for unknown location', () => { + const result = getMockWeather('UnknownCity'); + expect(result).toHaveProperty('location', 'UnknownCity'); + expect(result).toHaveProperty('temperature'); + expect(result).toHaveProperty('description'); + expect(result).toHaveProperty('humidity'); + expect(result).toHaveProperty('timestamp'); + }); + + test('returns specific data for London', () => { + const result = getMockWeather('London'); + expect(result.temperature).toBe(15); + expect(result.description).toBe('Cloudy'); + }); + + test('returns specific data for Tokyo', () => { + const result = getMockWeather('Tokyo'); + expect(result.temperature).toBe(25); + expect(result.description).toBe('Partly cloudy'); + }); + }); + + describe('fetchWeather', () => { + test('returns mock data when no API key is set', async () => { + const result = await fetchWeather('London'); + expect(result.location).toBe('London'); + expect(result.temperature).toBe(15); + }); + + test('caches results', async () => { + const result1 = await fetchWeather('Paris'); + const result2 = await fetchWeather('Paris'); + expect(result1).toEqual(result2); + }); + }); +}); + +describe('Weather API', () => { + beforeEach(() => { + clearCache(); + delete process.env.OPENWEATHERMAP_API_KEY; + }); + + test('GET /api/weather returns 400 when location is missing', async () => { + const response = await request(app).get('/api/weather'); + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + }); + + test('GET /api/weather?location=London returns weather data', async () => { + const response = await request(app).get('/api/weather?location=London'); + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('location', 'London'); + expect(response.body).toHaveProperty('temperature', 15); + expect(response.body).toHaveProperty('description', 'Cloudy'); + expect(response.body).toHaveProperty('humidity'); + expect(response.body).toHaveProperty('timestamp'); + }); + + test('GET /health returns ok status', async () => { + const response = await request(app).get('/health'); + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('status', 'ok'); + expect(response.body).toHaveProperty('timestamp'); + }); + + test('GET /api/weather with unknown location returns default mock', async () => { + const response = await request(app).get('/api/weather?location=UnknownPlace'); + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('location', 'UnknownPlace'); + }); +}); diff --git a/src/index.js b/src/index.js new file mode 100644 index 00000000..7597cb52 --- /dev/null +++ b/src/index.js @@ -0,0 +1,96 @@ +require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); +const weatherRoutes = require('./routes/weather'); +const authRoutes = require('./routes/auth'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Configure CORS with allowed origins from environment variable +const allowedOrigins = process.env.CORS_ORIGINS + ? process.env.CORS_ORIGINS.split(',').map(origin => origin.trim()) + : ['http://localhost:3000']; + +app.use(cors({ + origin: allowedOrigins, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] +})); +app.use(express.json()); + +app.get('/health', (req, res) => { + res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +app.use('/api/weather', weatherRoutes); +app.use('/api/auth', authRoutes); + +// Express error-handling middleware (must be after all routes) +app.use((err, req, res, _next) => { + console.error('Unhandled error:', err.message); + res.status(500).json({ + error: 'Internal server error' + }); +}); + +let server; + +function start() { + server = app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); + }); + + // Handle server-level errors (e.g., port already in use) + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.error(`Port ${PORT} is already in use`); + } else { + console.error('Server error:', err.message); + } + process.exit(1); + }); + + return server; +} + +function stop() { + return new Promise((resolve) => { + if (server) { + // Connection draining: forcefully close after timeout + const DRAIN_TIMEOUT_MS = 10000; + const forceTimeout = setTimeout(() => { + console.log('Forcing shutdown after connection drain timeout'); + process.exit(1); + }, DRAIN_TIMEOUT_MS); + forceTimeout.unref(); // Don't keep process alive just for this timeout + + // Stop accepting new connections and drain existing ones + server.close(() => { + clearTimeout(forceTimeout); // Clear the force timeout on successful close + console.log('Server stopped'); + resolve(); + }); + } else { + resolve(); + } + }); +} + +process.on('SIGTERM', async () => { + console.log('SIGTERM received, shutting down gracefully'); + await stop(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + console.log('SIGINT received, shutting down gracefully'); + await stop(); + process.exit(0); +}); + +if (require.main === module) { + start(); +} + +module.exports = { app, start, stop }; diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 00000000..a76b2f1b --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,53 @@ +const express = require('express'); +const rateLimit = require('express-rate-limit'); +const { validateUser, generateToken } = require('../services/auth'); + +const router = express.Router(); + +// Rate limiter for login endpoint to prevent brute-force attacks +const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 20, // Limit each IP to 20 login attempts per windowMs + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers + message: { error: 'Too many login attempts, please try again later' } +}); + +/** + * POST /api/auth/login + * Authenticate user and return JWT token + */ +router.post('/login', loginLimiter, (req, res) => { + const { username, password } = req.body; + + // Validate input + if (!username || !password) { + return res.status(400).json({ + error: 'Missing required fields: username and password are required' + }); + } + + // Validate credentials + const user = validateUser(username, password); + + if (!user) { + return res.status(401).json({ + error: 'Invalid credentials' + }); + } + + // Generate JWT token + const token = generateToken(user); + + return res.status(200).json({ + message: 'Login successful', + token, + user: { + id: user.id, + username: user.username, + role: user.role + } + }); +}); + +module.exports = router; diff --git a/src/routes/weather.js b/src/routes/weather.js new file mode 100644 index 00000000..476e4f8b --- /dev/null +++ b/src/routes/weather.js @@ -0,0 +1,25 @@ +const express = require('express'); +const router = express.Router(); +const { fetchWeather } = require('../services/weather'); + +router.get('/', async (req, res) => { + const { location } = req.query; + + if (!location) { + return res.status(400).json({ + error: 'Missing required query parameter: location' + }); + } + + try { + const weather = await fetchWeather(location); + res.status(200).json(weather); + } catch (error) { + if (error.message.includes('not found')) { + return res.status(404).json({ error: error.message }); + } + res.status(500).json({ error: 'Internal server error' }); + } +}); + +module.exports = router; diff --git a/src/services/auth.js b/src/services/auth.js new file mode 100644 index 00000000..1f27e663 --- /dev/null +++ b/src/services/auth.js @@ -0,0 +1,86 @@ +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); + +// Throw an error if JWT_SECRET is missing in non-development/test environments +const isDevOrTest = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test' || !process.env.NODE_ENV; +const JWT_SECRET = process.env.JWT_SECRET || (isDevOrTest + ? 'default-dev-secret-change-in-production' + : (() => { throw new Error('JWT_SECRET environment variable is required in production'); })()); +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1h'; + +// Use env var for demo password, falling back to a pre-computed static hash of 'password123' +// Pre-computed bcrypt hash of 'password123' with salt rounds 10 +// Generated once and stored statically so the hash is stable across server restarts +const DEMO_PASSWORD_HASH = process.env.DEMO_PASSWORD_HASH || '$2a$10$e0MYzXyIkJKMIlJjwSCLPeOy0fLhT5K6yS0p7qVlM8aHqE1zN8gGi'; + +// In-memory user store (MVP - replace with database in production) +const users = [ + { + id: 1, + username: 'demo', + passwordHash: DEMO_PASSWORD_HASH, + role: 'user' + } +]; + +// Dummy hash used for constant-time comparison when user is not found +// This is a pre-computed bcrypt hash of 'dummy-password-for-timing' with salt rounds 10 +const DUMMY_HASH = bcrypt.hashSync('dummy-password-for-timing', 10); + +/** + * Validate user credentials + * @param {string} username + * @param {string} password + * @returns {object|null} User object without password hash, or null if invalid + */ +function validateUser(username, password) { + if (!username || !password) { + return null; + } + + const user = users.find(u => u.username === username); + + // Always run bcrypt.compare to prevent timing side-channel attacks. + // If user is not found, compare against a dummy hash so the response time + // is indistinguishable from a failed password check. + const passwordHash = user ? user.passwordHash : DUMMY_HASH; + const isValid = bcrypt.compareSync(password, passwordHash); + + if (!user || !isValid) { + return null; + } + + // Return user without password hash + const { passwordHash: _passwordHash, ...userWithoutPassword } = user; + return userWithoutPassword; +} + +/** + * Generate JWT token for authenticated user + * @param {object} user - User object (should not contain password hash) + * @returns {string} JWT token + */ +function generateToken(user) { + const payload = { + sub: user.id, + username: user.username, + role: user.role + }; + + return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); +} + +/** + * Verify JWT token + * @param {string} token + * @returns {object|null} Decoded token payload or null if invalid + */ +function verifyToken(token) { + try { + return jwt.verify(token, JWT_SECRET); + } catch (error) { + return null; + } +} + +module.exports = { validateUser, generateToken, verifyToken, JWT_SECRET }; diff --git a/src/services/weather.js b/src/services/weather.js new file mode 100644 index 00000000..122cff5c --- /dev/null +++ b/src/services/weather.js @@ -0,0 +1,79 @@ +require('dotenv').config(); +const axios = require('axios'); + +const CACHE_DURATION = 10 * 60 * 1000; // 10 minutes +const cache = new Map(); + +function getMockWeather(location) { + const normalizedLocation = location.toLowerCase(); + const mockData = { + location: location, + temperature: 22, + description: 'Clear sky', + humidity: 65, + timestamp: new Date().toISOString() + }; + + const variations = { + 'london': { temperature: 15, description: 'Cloudy', humidity: 80 }, + 'tokyo': { temperature: 25, description: 'Partly cloudy', humidity: 70 }, + 'new york': { temperature: 18, description: 'Rainy', humidity: 85 }, + 'sydney': { temperature: 28, description: 'Sunny', humidity: 55 }, + 'paris': { temperature: 20, description: 'Light breeze', humidity: 60 } + }; + + if (variations[normalizedLocation]) { + return { ...mockData, ...variations[normalizedLocation] }; + } + + return mockData; +} + +async function fetchWeather(location) { + const cacheKey = location.toLowerCase(); + const cached = cache.get(cacheKey); + + if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { + return cached.data; + } + + const apiKey = process.env.OPENWEATHERMAP_API_KEY; + + let weatherData; + + if (apiKey) { + try { + const response = await axios.get('https://api.openweathermap.org/data/2.5/weather', { + params: { + q: location, + appid: apiKey, + units: 'metric' + } + }); + + weatherData = { + location: response.data.name, + temperature: response.data.main.temp, + description: response.data.weather[0].description, + humidity: response.data.main.humidity, + timestamp: new Date().toISOString() + }; + } catch (error) { + if (error.response && error.response.status === 404) { + throw new Error(`Location '${location}' not found`); + } + weatherData = getMockWeather(location); + } + } else { + weatherData = getMockWeather(location); + } + + cache.set(cacheKey, { data: weatherData, timestamp: Date.now() }); + return weatherData; +} + +function clearCache() { + cache.clear(); +} + +module.exports = { fetchWeather, clearCache, getMockWeather };