From ab0e6a9a16e2c01c1d76804c65a3016b0ff6080b Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 10 Apr 2026 14:22:07 +0200 Subject: [PATCH 1/3] feat(docs): embed markdown guides in Scalar API reference Add support for per-module markdown guides discovered via `modules/*/doc/guides/*.md` globbing. Guides are merged into the OpenAPI spec's `info.description` so Scalar renders them as sidebar sections alongside the API reference. Migrates the 3 guides previously shipped by the Vue docs module (getting-started, authentication, organizations) under `modules/core/doc/guides/` so they are always active regardless of downstream project configuration. Closes #3451 --- config/assets.js | 1 + lib/helpers/config.js | 1 + lib/helpers/guides.js | 100 +++++++++++++++++++ lib/services/express.js | 9 ++ modules/core/doc/guides/authentication.md | 68 +++++++++++++ modules/core/doc/guides/getting-started.md | 46 +++++++++ modules/core/doc/guides/organizations.md | 65 +++++++++++++ modules/core/tests/core.unit.tests.js | 106 ++++++++++++++++++++- 8 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 lib/helpers/guides.js create mode 100644 modules/core/doc/guides/authentication.md create mode 100644 modules/core/doc/guides/getting-started.md create mode 100644 modules/core/doc/guides/organizations.md 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/lib/helpers/config.js b/lib/helpers/config.js index 26a061255..d56f13d85 100644 --- a/lib/helpers/config.js +++ b/lib/helpers/config.js @@ -144,6 +144,7 @@ const filterByActivation = (files, config) => files.filter((file) => { 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..aabb7c6e7 --- /dev/null +++ b/modules/core/doc/guides/authentication.md @@ -0,0 +1,68 @@ +# Authentication + +The API uses JWT-based authentication with access and refresh tokens. + +## 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" \ + -d '{ "email": "user@example.com", "password": "YourPassword1!" }' +``` + +If email verification is enabled, you will receive a confirmation link. + +## Log in + +Authenticate with your credentials: + +```bash +curl -X POST http://localhost:3000/api/auth/signin \ + -H "Content-Type: application/json" \ + -d '{ "email": "user@example.com", "password": "YourPassword1!" }' +``` + +The response contains an `accessToken` and a `refreshToken`. + +## Using tokens + +Include the access token in the `Authorization` header for protected +endpoints: + +```bash +curl http://localhost:3000/api/users/me \ + -H "Authorization: Bearer " +``` + +## Refreshing tokens + +When the access token expires, use the refresh token to obtain a new pair: + +```bash +curl -X POST http://localhost:3000/api/auth/token \ + -H "Content-Type: application/json" \ + -d '{ "refreshToken": "" }' +``` + +## 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": "", "password": "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..1a982c1ab --- /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** 20+ 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..d214c8236 --- /dev/null +++ b/modules/core/doc/guides/organizations.md @@ -0,0 +1,65 @@ +# Organizations + +Organizations let you group users under a shared context with role-based +access control. + +## Creating an organization + +```bash +curl -X POST http://localhost:3000/api/organizations \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -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 \ + -H "Authorization: Bearer " +``` + +## 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//invitations \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ "email": "teammate@example.com", "role": "member" }' +``` + +## Scoping requests to an organization + +The active organization is set via the `X-Organization-Id` header on API +requests: + +```bash +curl http://localhost:3000/api/tasks \ + -H "Authorization: Bearer " \ + -H "X-Organization-Id: " +``` + +Most org-scoped resources require this header — omitting it returns data +from the caller's default organization (if any) or an empty set. + +## 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.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 From 76bb500099c04e8fcd5b45b61e552e618ed7f79c Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 10 Apr 2026 14:57:36 +0200 Subject: [PATCH 2/3] fix(doc): correct scalar guides to match actual auth and org APIs - Auth guide uses cookie-based JWT (TOKEN cookie), no refresh token - Fix password reset payload (newPassword instead of password) - Organizations: /invites route and currentOrganization + /switch (not the X-Organization-Id header) - getting-started prerequisite bumped to Node 22+ - Include guides in module-activation filtering (fileKeys) - Expand initGlobalConfigFiles JSDoc --- config/index.js | 2 +- lib/helpers/config.js | 10 ++++- modules/core/doc/guides/authentication.md | 40 +++++++++++++------- modules/core/doc/guides/getting-started.md | 2 +- modules/core/doc/guides/organizations.md | 43 +++++++++++++++------- 5 files changed, 67 insertions(+), 30 deletions(-) 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 d56f13d85..4af8ebed1 100644 --- a/lib/helpers/config.js +++ b/lib/helpers/config.js @@ -139,7 +139,15 @@ 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 diff --git a/modules/core/doc/guides/authentication.md b/modules/core/doc/guides/authentication.md index aabb7c6e7..ed817d0ea 100644 --- a/modules/core/doc/guides/authentication.md +++ b/modules/core/doc/guides/authentication.md @@ -1,6 +1,9 @@ # Authentication -The API uses JWT-based authentication with access and refresh tokens. +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 @@ -9,10 +12,16 @@ 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 @@ -21,30 +30,33 @@ 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 contains an `accessToken` and a `refreshToken`. +The response sets the `TOKEN` cookie and returns the user, their CASL +abilities, and `tokenExpiresIn` (epoch ms at which the JWT expires). -## Using tokens +## Using the token -Include the access token in the `Authorization` header for protected -endpoints: +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 \ - -H "Authorization: Bearer " + -b cookies.txt ``` -## Refreshing tokens +Browser clients get this for free: the cookie is `httpOnly`, `Secure`, +and `SameSite`-configured, so it is attached automatically to same-site +requests. -When the access token expires, use the refresh token to obtain a new pair: +## Token lifetime -```bash -curl -X POST http://localhost:3000/api/auth/token \ - -H "Content-Type: application/json" \ - -d '{ "refreshToken": "" }' -``` +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 @@ -59,7 +71,7 @@ curl -X POST http://localhost:3000/api/auth/forgot \ # Confirm reset curl -X POST http://localhost:3000/api/auth/reset \ -H "Content-Type: application/json" \ - -d '{ "token": "", "password": "NewPassword1!" }' + -d '{ "token": "", "newPassword": "NewPassword1!" }' ``` ## Next steps diff --git a/modules/core/doc/guides/getting-started.md b/modules/core/doc/guides/getting-started.md index 1a982c1ab..59efe36f8 100644 --- a/modules/core/doc/guides/getting-started.md +++ b/modules/core/doc/guides/getting-started.md @@ -5,7 +5,7 @@ backend locally and making your first API call. ## Prerequisites -- **Node.js** 20+ and npm +- **Node.js** 22+ and npm - **MongoDB** running locally or accessible via a connection string - **Git** for cloning the repository diff --git a/modules/core/doc/guides/organizations.md b/modules/core/doc/guides/organizations.md index d214c8236..686f95208 100644 --- a/modules/core/doc/guides/organizations.md +++ b/modules/core/doc/guides/organizations.md @@ -3,12 +3,15 @@ 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 "Authorization: Bearer " \ -H "Content-Type: application/json" \ + -b cookies.txt \ -d '{ "name": "My Team" }' ``` @@ -20,7 +23,7 @@ Retrieve all organizations you belong to: ```bash curl http://localhost:3000/api/organizations \ - -H "Authorization: Bearer " + -b cookies.txt ``` ## Inviting members @@ -29,25 +32,39 @@ Invite a user by email. They receive an invitation they can accept or decline: ```bash -curl -X POST http://localhost:3000/api/organizations//invitations \ - -H "Authorization: Bearer " \ +curl -X POST http://localhost:3000/api/organizations//invites \ -H "Content-Type: application/json" \ + -b cookies.txt \ -d '{ "email": "teammate@example.com", "role": "member" }' ``` -## Scoping requests to an organization - -The active organization is set via the `X-Organization-Id` header on API -requests: +The invitee then accepts (or declines) via the invite token they receive +by email: ```bash -curl http://localhost:3000/api/tasks \ - -H "Authorization: Bearer " \ - -H "X-Organization-Id: " +curl -X POST http://localhost:3000/api/invites//accept \ + -b cookies.txt ``` -Most org-scoped resources require this header — omitting it returns data -from the caller's default organization (if any) or an empty set. +## 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 From f2fc611f23db6fb8e169c1c5ac8c82e05dbfe5de Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Fri, 10 Apr 2026 15:06:13 +0200 Subject: [PATCH 3/3] test(core): add integration coverage for scalar guides - Verify /api/spec.json exposes merged markdown guides in info.description (Getting Started, Authentication, Organizations) - Verify /api/docs serves the Scalar HTML reference page --- modules/core/tests/core.integration.tests.js | 25 +++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) 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 {