diff --git a/config/assets.js b/config/assets.js index a7d0d731b..8568896ea 100644 --- a/config/assets.js +++ b/config/assets.js @@ -2,6 +2,7 @@ export default { allJS: ['server.js', 'config/**/*.js', 'lib/**/*.js', 'modules/*/**/*.js'], allYaml: 'modules/*/doc/*.yml', + allGuides: 'modules/*/doc/guides/*.md', mongooseModels: 'modules/*/models/*.mongoose.js', preRoutes: 'modules/*/routes/*.preroute.js', routes: 'modules/*/routes/!(*.preroute).js', diff --git a/config/index.js b/config/index.js index 48758b777..48b9f4fce 100644 --- a/config/index.js +++ b/config/index.js @@ -158,7 +158,7 @@ const initGlobalConfig = async () => { // Initialize global globbed files config = deepMerge(config, { files: await configHelper.initGlobalConfigFiles(assets) }); // Filter files by module activation (deactivated modules are excluded) - const fileKeys = ['swagger', 'mongooseModels', 'preRoutes', 'routes', 'configs', 'policies']; + const fileKeys = ['swagger', 'guides', 'mongooseModels', 'preRoutes', 'routes', 'configs', 'policies']; for (const key of fileKeys) { if (config.files[key]) { config.files[key] = configHelper.filterByActivation(config.files[key], config); diff --git a/lib/helpers/config.js b/lib/helpers/config.js index 26a061255..4af8ebed1 100644 --- a/lib/helpers/config.js +++ b/lib/helpers/config.js @@ -139,11 +139,20 @@ const filterByActivation = (files, config) => files.filter((file) => { }); /** - * Initialize global configuration files + * Initialize global configuration files by resolving asset glob patterns. + * + * Globs each category of files (swagger YAML, markdown guides, mongoose + * models, routes, configs, policies) from the provided asset patterns and + * returns them as a flat `files` object that is later merged into the + * runtime config and filtered by module activation. + * + * @param {object} assets - Asset glob patterns (see `config/assets.js`). + * @returns {Promise} Object keyed by file category with resolved file paths. */ const initGlobalConfigFiles = async (assets) => { const files = {}; // Appending files files.swagger = await getGlobbedPaths(assets.allYaml); // Setting Globbed module yaml files + files.guides = await getGlobbedPaths(assets.allGuides); // Setting Globbed module markdown guide files files.mongooseModels = await getGlobbedPaths(assets.mongooseModels); // Setting Globbed mongoose model files files.preRoutes = await getGlobbedPaths(assets.preRoutes); // Setting Globbed pre-parser route files files.routes = await getGlobbedPaths(assets.routes); // Setting Globbed route files diff --git a/lib/helpers/guides.js b/lib/helpers/guides.js new file mode 100644 index 000000000..62a2d1d3a --- /dev/null +++ b/lib/helpers/guides.js @@ -0,0 +1,100 @@ +/** + * Markdown guide loader for the Scalar API reference. + * + * Per-module markdown guides live under `modules/{name}/doc/guides/*.md` + * and are discovered by the same globbing mechanism as OpenAPI YAML files + * (see `config/assets.js` → `allGuides`). + * + * Guides are merged into the OpenAPI spec via `info.description`, which + * Scalar renders as a top-level "Introduction" section in the sidebar and + * splits on markdown H1/H2 headings. + */ +import fs from 'fs'; +import path from 'path'; + +import logger from '../services/logger.js'; + +/** + * Derive a human-readable title from a guide file path. + * E.g. `modules/auth/doc/guides/getting-started.md` → `Getting Started` + * @param {string} filePath - Absolute or relative path to the guide file. + * @returns {string} Title-cased guide name. + */ +const titleFromPath = (filePath) => { + const base = path.basename(String(filePath), path.extname(String(filePath))); + return base + .split(/[-_\s]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +}; + +/** + * Strip the first H1 heading from a markdown body (if present). + * The loader injects its own H1 based on the file name so Scalar's sidebar + * stays consistent even when guides omit a title or use a different one. + * @param {string} markdown - Raw markdown content. + * @returns {string} Markdown without the leading H1. + */ +const stripLeadingH1 = (markdown) => String(markdown).replace(/^\s*#\s+[^\n]*\n+/, ''); + +/** + * Load markdown guides from disk and return normalized entries. + * Invalid/unreadable files are skipped with a warning so one broken guide + * cannot take down the whole API reference. + * @param {string[]} filePaths - Absolute paths to `.md` guide files. + * @returns {{ title: string, body: string, path: string }[]} Loaded guides. + */ +const loadGuides = (filePaths) => { + if (!Array.isArray(filePaths) || filePaths.length === 0) return []; + return filePaths + .map((filePath) => { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const body = stripLeadingH1(raw).trim(); + if (!body) { + logger.warn(`[guides] skipping ${filePath}: empty markdown content`); + return null; + } + return { title: titleFromPath(filePath), body, path: filePath }; + } catch (err) { + logger.warn(`[guides] failed to load ${filePath}: ${err.message}`); + return null; + } + }) + .filter(Boolean) + // Stable alphabetical order so the sidebar is deterministic across + // filesystems (glob order varies on macOS vs Linux containers). + .sort((a, b) => a.title.localeCompare(b.title)); +}; + +/** + * Merge loaded guides into an OpenAPI spec's `info.description`. + * Each guide becomes a top-level H1 section, which Scalar renders as a + * sidebar entry alongside the API reference. + * + * The original spec is mutated (and returned) to match the merge style used + * by `initSwagger` in `lib/services/express.js`. + * + * @param {object} spec - OpenAPI spec object (will be mutated). + * @param {{ title: string, body: string }[]} guides - Loaded guide entries. + * @returns {object} The same spec, with guides appended to `info.description`. + */ +const mergeGuidesIntoSpec = (spec, guides) => { + if (!spec || typeof spec !== 'object') return spec; + if (!Array.isArray(guides) || guides.length === 0) return spec; + + const sections = guides.map(({ title, body }) => `# ${title}\n\n${body}`); + const existing = typeof spec.info?.description === 'string' ? spec.info.description.trim() : ''; + const merged = [existing, ...sections].filter(Boolean).join('\n\n'); + + spec.info = { ...(spec.info || {}), description: merged }; + return spec; +}; + +export default { + titleFromPath, + stripLeadingH1, + loadGuides, + mergeGuidesIntoSpec, +}; diff --git a/lib/services/express.js b/lib/services/express.js index a22cd752f..d4d9eeded 100644 --- a/lib/services/express.js +++ b/lib/services/express.js @@ -18,6 +18,7 @@ import YAML from 'js-yaml'; import { apiReference } from '@scalar/express-api-reference'; import config from '../../config/index.js'; +import guidesHelper from '../helpers/guides.js'; import logger from './logger.js'; import requestId from '../middlewares/requestId.js'; import sentry from './sentry.js'; @@ -65,6 +66,14 @@ const initSwagger = (app) => { return; } + // Merge per-module markdown guides into info.description so Scalar + // renders them in its sidebar alongside the OpenAPI reference. + const guides = guidesHelper.loadGuides(config.files.guides || []); + guidesHelper.mergeGuidesIntoSpec(spec, guides); + if (guides.length > 0) { + logger.info(`[swagger] merged ${guides.length} markdown guide(s) into API reference`); + } + /** * Serve the merged OpenAPI spec as JSON. * @param {import('express').Request} _req - Incoming request (unused). diff --git a/modules/core/doc/guides/authentication.md b/modules/core/doc/guides/authentication.md new file mode 100644 index 000000000..ed817d0ea --- /dev/null +++ b/modules/core/doc/guides/authentication.md @@ -0,0 +1,80 @@ +# Authentication + +The API uses JWT authentication delivered via an `httpOnly` `TOKEN` +cookie. Clients do not receive the token in the response body — they +receive user metadata and a `tokenExpiresIn` timestamp, and subsequent +requests are authenticated automatically as long as the cookie is sent. + +## Sign up + +Create a new account by sending a POST request: + +```bash +curl -X POST http://localhost:3000/api/auth/signup \ + -H "Content-Type: application/json" \ + -c cookies.txt \ + -d '{ "email": "user@example.com", "password": "YourPassword1!" }' +``` + +If email verification is enabled, you will receive a confirmation link. +On success the response sets the `TOKEN` cookie and returns a body like: + +```json +{ "user": { "id": "...", "email": "user@example.com" }, "tokenExpiresIn": 1735689600000 } +``` + +## Log in + +Authenticate with your credentials: + +```bash +curl -X POST http://localhost:3000/api/auth/signin \ + -H "Content-Type: application/json" \ + -c cookies.txt \ + -d '{ "email": "user@example.com", "password": "YourPassword1!" }' +``` + +The response sets the `TOKEN` cookie and returns the user, their CASL +abilities, and `tokenExpiresIn` (epoch ms at which the JWT expires). + +## Using the token + +Send the cookie on every protected request — the JWT is extracted from +the `TOKEN` cookie by the passport strategy: + +```bash +curl http://localhost:3000/api/users/me \ + -b cookies.txt +``` + +Browser clients get this for free: the cookie is `httpOnly`, `Secure`, +and `SameSite`-configured, so it is attached automatically to same-site +requests. + +## Token lifetime + +The JWT lifetime is controlled server-side by `config.jwt.expiresIn`. +The signin/signup responses expose `tokenExpiresIn` so clients can +proactively re-authenticate before expiry. There is no refresh-token +endpoint — call `/api/auth/signin` again when the token expires. + +## Password reset + +Request a reset email, then confirm with the token received: + +```bash +# Request reset +curl -X POST http://localhost:3000/api/auth/forgot \ + -H "Content-Type: application/json" \ + -d '{ "email": "user@example.com" }' + +# Confirm reset +curl -X POST http://localhost:3000/api/auth/reset \ + -H "Content-Type: application/json" \ + -d '{ "token": "", "newPassword": "NewPassword1!" }' +``` + +## Next steps + +- See the **Organizations** guide to create teams and manage roles. +- Browse the endpoint reference for the full list of auth routes. diff --git a/modules/core/doc/guides/getting-started.md b/modules/core/doc/guides/getting-started.md new file mode 100644 index 000000000..59efe36f8 --- /dev/null +++ b/modules/core/doc/guides/getting-started.md @@ -0,0 +1,46 @@ +# Getting Started + +Welcome to the Devkit Node API. This guide walks you through running the +backend locally and making your first API call. + +## Prerequisites + +- **Node.js** 22+ and npm +- **MongoDB** running locally or accessible via a connection string +- **Git** for cloning the repository + +## Setup + +1. Clone the Node backend repository. +2. Copy `.env.example` to `.env` and fill in your values (mongo URI, JWT + secret, mail provider, etc.). +3. Install dependencies: + +```bash +npm install +``` + +4. Start the development server: + +```bash +npm run dev +``` + +The API listens on `http://localhost:3000` by default. + +## Your first API call + +Once the server is running, verify it responds: + +```bash +curl http://localhost:3000/api/core/status +``` + +You should receive a JSON response confirming the server is healthy. + +## Explore the API + +- **Authentication** — sign up, log in, and manage tokens +- **Organizations** — create teams and manage roles +- Browse the endpoint reference in the sidebar for full request/response + schemas and interactive examples diff --git a/modules/core/doc/guides/organizations.md b/modules/core/doc/guides/organizations.md new file mode 100644 index 000000000..686f95208 --- /dev/null +++ b/modules/core/doc/guides/organizations.md @@ -0,0 +1,82 @@ +# Organizations + +Organizations let you group users under a shared context with role-based +access control. + +All examples below assume you are already authenticated and send the +`TOKEN` cookie set at signin (see the **Authentication** guide). + +## Creating an organization + +```bash +curl -X POST http://localhost:3000/api/organizations \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ "name": "My Team" }' +``` + +The creator is automatically assigned the **owner** role. + +## Listing organizations + +Retrieve all organizations you belong to: + +```bash +curl http://localhost:3000/api/organizations \ + -b cookies.txt +``` + +## Inviting members + +Invite a user by email. They receive an invitation they can accept or +decline: + +```bash +curl -X POST http://localhost:3000/api/organizations//invites \ + -H "Content-Type: application/json" \ + -b cookies.txt \ + -d '{ "email": "teammate@example.com", "role": "member" }' +``` + +The invitee then accepts (or declines) via the invite token they receive +by email: + +```bash +curl -X POST http://localhost:3000/api/invites//accept \ + -b cookies.txt +``` + +## Scoping requests to an organization + +The API does not use an `X-Organization-Id` header. Org context is +resolved in one of two ways: + +1. **Route parameter** — org-scoped routes include `:organizationId` in + the path, e.g. `/api/organizations/:organizationId/invites`. Pass the + org id directly in the URL. +2. **Current organization** — the authenticated user has a + `currentOrganization` stored server-side. Switch it with: + + ```bash + curl -X POST http://localhost:3000/api/organizations//switch \ + -b cookies.txt + ``` + + This updates `user.currentOrganization`, issues a fresh JWT cookie, + and rebuilds abilities. Subsequent requests that rely on the current + org (rather than a route param) use that value. + +## Roles + +| Role | Permissions | +|------|-------------| +| **owner** | Full access, manage billing, delete organization | +| **admin** | Manage members, update settings | +| **member** | Access shared resources | + +Roles are enforced by CASL abilities on the backend — see each +organization endpoint for the required ability. + +## Next steps + +- Browse the endpoint reference for the full list of organization routes. diff --git a/modules/core/tests/core.integration.tests.js b/modules/core/tests/core.integration.tests.js index bd60baeea..53ba52747 100644 --- a/modules/core/tests/core.integration.tests.js +++ b/modules/core/tests/core.integration.tests.js @@ -3,6 +3,7 @@ */ import path from 'path'; +import request from 'supertest'; import { jest } from '@jest/globals'; import config from '../../../config/index.js'; import logger from '../../../lib/services/logger.js'; @@ -18,10 +19,12 @@ describe('Core integration tests:', () => { let AuthService; let UserService; let TaskService; + let app; beforeAll(async () => { try { - await bootstrap(); + const init = await bootstrap(); + app = init.app; AuthService = (await import(path.resolve('./modules/auth/services/auth.service.js'))).default; UserService = (await import(path.resolve('./modules/users/services/users.service.js'))).default; TaskService = (await import(path.resolve('./modules/tasks/services/tasks.service.js'))).default; @@ -290,6 +293,26 @@ describe('Core integration tests:', () => { }); }); + describe('Scalar API reference — markdown guides', () => { + it('should expose /api/spec.json with markdown guides merged into info.description', async () => { + const res = await request(app).get('/api/spec.json').expect(200); + expect(res.body).toBeDefined(); + expect(res.body.info).toBeDefined(); + expect(typeof res.body.info.description).toBe('string'); + // Guides are rendered as H1 sections, one per file in modules/*/doc/guides/*.md + expect(res.body.info.description).toContain('# Getting Started'); + expect(res.body.info.description).toContain('Your first API call'); + expect(res.body.info.description).toContain('# Authentication'); + expect(res.body.info.description).toContain('# Organizations'); + }); + + it('should serve the Scalar API reference page on /api/docs', async () => { + const res = await request(app).get('/api/docs').expect(200); + // Scalar returns HTML referencing the spec URL + expect(res.headers['content-type']).toMatch(/html/); + }); + }); + // Mongoose disconnect afterAll(async () => { try { diff --git a/modules/core/tests/core.unit.tests.js b/modules/core/tests/core.unit.tests.js index bc3363ca6..a82e2e502 100644 --- a/modules/core/tests/core.unit.tests.js +++ b/modules/core/tests/core.unit.tests.js @@ -8,6 +8,7 @@ import { jest } from '@jest/globals'; import assets from '../../../config/assets.js'; import config, { deepMerge, assertSafeEnv } from '../../../config/index.js'; import configHelper from '../../../lib/helpers/config.js'; +import guidesHelper from '../../../lib/helpers/guides.js'; import logger from '../../../lib/services/logger.js'; import mongooseService from '../../../lib/services/mongoose.js'; import expressService from '../../../lib/services/express.js'; @@ -56,7 +57,7 @@ describe('Core unit tests:', () => { }); it('assets should contain the correct keys', () => { - const expectedKeys = ['allJS', 'allYaml', 'mongooseModels', 'preRoutes', 'routes', 'config', 'policies']; + const expectedKeys = ['allJS', 'allYaml', 'allGuides', 'mongooseModels', 'preRoutes', 'routes', 'config', 'policies']; expectedKeys.forEach((key) => { expect(assets).toHaveProperty(key); @@ -609,6 +610,27 @@ describe('Core unit tests:', () => { } }); + it('should merge markdown guides into spec info.description when guides are configured', () => { + config.swagger = { enable: true }; + config.files = { + ...config.files, + swagger: [path.join(process.cwd(), 'modules/core/doc/index.yml')], + guides: [path.join(process.cwd(), 'modules/core/doc/guides/getting-started.md')], + }; + const mockGet = jest.fn(); + const mockUse = jest.fn(); + const mockApp = { get: mockGet, use: mockUse }; + expressService.initSwagger(mockApp); + const handler = mockGet.mock.calls.find((c) => c[0] === '/api/spec.json')[1]; + const mockRes = { json: jest.fn() }; + handler({}, mockRes); + const served = mockRes.json.mock.calls[0][0]; + expect(served.info).toBeDefined(); + expect(typeof served.info.description).toBe('string'); + expect(served.info.description).toContain('# Getting Started'); + expect(served.info.description).toContain('Your first API call'); + }); + it('should warn and skip registration when all YAML files produce an empty spec', async () => { // Write a temp YAML file that parses to a scalar — after filter(Boolean), contents is empty → spec is {} const { default: fsMod } = await import('fs'); @@ -721,6 +743,88 @@ describe('Core unit tests:', () => { }); }); + describe('Guides helper', () => { + it('should derive a title-cased title from a guide file path', () => { + expect(guidesHelper.titleFromPath('modules/core/doc/guides/getting-started.md')).toBe('Getting Started'); + expect(guidesHelper.titleFromPath('/abs/path/authentication.md')).toBe('Authentication'); + expect(guidesHelper.titleFromPath('multi_word_file.md')).toBe('Multi Word File'); + }); + + it('should strip the leading H1 from markdown content', () => { + const input = '# Title\n\nBody paragraph'; + expect(guidesHelper.stripLeadingH1(input)).toBe('Body paragraph'); + }); + + it('should preserve markdown when no leading H1 is present', () => { + const input = 'Body paragraph\n\n## Subsection'; + expect(guidesHelper.stripLeadingH1(input)).toBe('Body paragraph\n\n## Subsection'); + }); + + it('should load guides from real files and sort them alphabetically', () => { + const files = [ + path.join(process.cwd(), 'modules/core/doc/guides/organizations.md'), + path.join(process.cwd(), 'modules/core/doc/guides/authentication.md'), + path.join(process.cwd(), 'modules/core/doc/guides/getting-started.md'), + ]; + const guides = guidesHelper.loadGuides(files); + expect(guides.map((g) => g.title)).toEqual(['Authentication', 'Getting Started', 'Organizations']); + guides.forEach((g) => expect(typeof g.body).toBe('string')); + }); + + it('should return an empty array when filePaths is empty or invalid', () => { + expect(guidesHelper.loadGuides([])).toEqual([]); + expect(guidesHelper.loadGuides(null)).toEqual([]); + expect(guidesHelper.loadGuides(undefined)).toEqual([]); + }); + + it('should skip unreadable guide files without throwing', () => { + const guides = guidesHelper.loadGuides(['/nonexistent/path/missing.md']); + expect(guides).toEqual([]); + }); + + it('should skip guides whose body is empty after stripping the H1', async () => { + const { default: fsMod } = await import('fs'); + const tmpFile = path.join('/tmp', `test-empty-guide-${Date.now()}.md`); + fsMod.writeFileSync(tmpFile, '# Only a title\n'); + try { + expect(guidesHelper.loadGuides([tmpFile])).toEqual([]); + } finally { + fsMod.unlinkSync(tmpFile); + } + }); + + it('should merge guides into spec info.description with H1 section headers', () => { + const spec = { openapi: '3.0.0', info: { title: 'API' } }; + const guides = [ + { title: 'Getting Started', body: 'Intro body' }, + { title: 'Authentication', body: 'Auth body' }, + ]; + guidesHelper.mergeGuidesIntoSpec(spec, guides); + expect(spec.info.description).toContain('# Getting Started'); + expect(spec.info.description).toContain('Intro body'); + expect(spec.info.description).toContain('# Authentication'); + expect(spec.info.description).toContain('Auth body'); + }); + + it('should preserve an existing info.description when merging guides', () => { + const spec = { info: { description: 'Original intro.' } }; + guidesHelper.mergeGuidesIntoSpec(spec, [{ title: 'Guide', body: 'Body' }]); + expect(spec.info.description.startsWith('Original intro.')).toBe(true); + expect(spec.info.description).toContain('# Guide'); + }); + + it('should be a no-op when no guides are provided', () => { + const spec = { info: { title: 'API' } }; + const result = guidesHelper.mergeGuidesIntoSpec(spec, []); + expect(result).toBe(spec); + expect(spec.info.description).toBeUndefined(); + }); + + it('should return the spec unchanged when spec is not an object', () => { + expect(guidesHelper.mergeGuidesIntoSpec(null, [{ title: 't', body: 'b' }])).toBeNull(); + }); + }); + describe('Policy', () => { beforeAll(async () => { // Auto-discover and register all policy files using the new abilities pattern