From 6990ae612ee17fda6dedd4aafd8b9aa9fca63399 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 13 Apr 2026 12:37:01 +0200 Subject: [PATCH] docs(api): remove stack onboarding guides from /api/docs Delete modules/core/doc/guides/{getting-started,authentication,organizations}.md. These are stack-private backend developer onboarding docs, not public API documentation. They were surfaced in every downstream project's /api/docs (e.g. https://api.trawl.me/api/docs) via the Scalar reference, exposing irrelevant "npm install / localhost:3000" content to project consumers. Convention going forward: the stack ships zero public API guides. Each downstream project owns the content rendered in its own /api/docs via its own modules' doc/guides/*.md files. Tests that exercised the guide loader against these specific files now use temporary fixtures under /tmp instead, so the loader behavior is still covered without depending on stack-owned content. The integration test on /api/spec.json no longer asserts on specific guide titles. The glob `modules/*/doc/guides/*.md` and `guidesHelper.loadGuides` already handle an empty guide list gracefully (see the no-op merge branch). Closes #3457 --- modules/core/doc/guides/authentication.md | 80 ------------------- modules/core/doc/guides/getting-started.md | 46 ----------- modules/core/doc/guides/organizations.md | 82 -------------------- modules/core/tests/core.integration.tests.js | 19 +++-- modules/core/tests/core.unit.tests.js | 81 ++++++++++++------- 5 files changed, 63 insertions(+), 245 deletions(-) delete mode 100644 modules/core/doc/guides/authentication.md delete mode 100644 modules/core/doc/guides/getting-started.md delete mode 100644 modules/core/doc/guides/organizations.md diff --git a/modules/core/doc/guides/authentication.md b/modules/core/doc/guides/authentication.md deleted file mode 100644 index ed817d0ea..000000000 --- a/modules/core/doc/guides/authentication.md +++ /dev/null @@ -1,80 +0,0 @@ -# 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 deleted file mode 100644 index 59efe36f8..000000000 --- a/modules/core/doc/guides/getting-started.md +++ /dev/null @@ -1,46 +0,0 @@ -# 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 deleted file mode 100644 index 686f95208..000000000 --- a/modules/core/doc/guides/organizations.md +++ /dev/null @@ -1,82 +0,0 @@ -# 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 53ba52747..fead04043 100644 --- a/modules/core/tests/core.integration.tests.js +++ b/modules/core/tests/core.integration.tests.js @@ -293,17 +293,20 @@ describe('Core integration tests:', () => { }); }); - describe('Scalar API reference — markdown guides', () => { - it('should expose /api/spec.json with markdown guides merged into info.description', async () => { + describe('Scalar API reference', () => { + it('should expose /api/spec.json with a valid OpenAPI info block', async () => { const res = await request(app).get('/api/spec.json').expect(200); expect(res.body).toBeDefined(); + expect(res.body.openapi).toBe('3.0.0'); 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'); + expect(typeof res.body.info.version).toBe('string'); + // The stack ships zero public API guides — downstream projects own the + // content rendered in their /api/docs via their own modules' doc/guides/*.md. + // `info.description` is therefore optional (string when any module provides + // a guide, undefined/empty otherwise). + if (res.body.info.description !== undefined) { + expect(typeof res.body.info.description).toBe('string'); + } }); it('should serve the Scalar API reference page on /api/docs', async () => { diff --git a/modules/core/tests/core.unit.tests.js b/modules/core/tests/core.unit.tests.js index a82e2e502..aa40dcbfd 100644 --- a/modules/core/tests/core.unit.tests.js +++ b/modules/core/tests/core.unit.tests.js @@ -610,25 +610,36 @@ 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 merge markdown guides into spec info.description when guides are configured', async () => { + const { default: fsMod } = await import('fs'); + const tmpDir = path.join('/tmp', `guides-${Date.now()}`); + fsMod.mkdirSync(tmpDir, { recursive: true }); + const tmpGuide = path.join(tmpDir, 'sample.md'); + fsMod.writeFileSync(tmpGuide, '# Ignored Heading\n\nSample guide body for unit test.\n'); + try { + config.swagger = { enable: true }; + config.files = { + ...config.files, + swagger: [path.join(process.cwd(), 'modules/core/doc/index.yml')], + guides: [tmpGuide], + }; + 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'); + // Guide loader derives the H1 from the file basename, not the markdown H1. + expect(served.info.description).toContain('# Sample'); + expect(served.info.description).toContain('Sample guide body for unit test.'); + } finally { + fsMod.unlinkSync(tmpGuide); + fsMod.rmdirSync(tmpDir); + } }); it('should warn and skip registration when all YAML files produce an empty spec', async () => { @@ -745,8 +756,8 @@ 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('modules/example/doc/guides/getting-started.md')).toBe('Getting Started'); + expect(guidesHelper.titleFromPath('/abs/path/api-reference.md')).toBe('Api Reference'); expect(guidesHelper.titleFromPath('multi_word_file.md')).toBe('Multi Word File'); }); @@ -760,15 +771,27 @@ describe('Core unit tests:', () => { expect(guidesHelper.stripLeadingH1(input)).toBe('Body paragraph\n\n## Subsection'); }); - it('should load guides from real files and sort them alphabetically', () => { + it('should load guides from real files and sort them alphabetically', async () => { + const { default: fsMod } = await import('fs'); + const tmpDir = path.join('/tmp', `guides-fixture-${Date.now()}`); + fsMod.mkdirSync(tmpDir, { recursive: true }); 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')); + { name: 'organizations.md', body: '# Organizations\n\nOrg body.\n' }, + { name: 'authentication.md', body: '# Authentication\n\nAuth body.\n' }, + { name: 'getting-started.md', body: '# Getting Started\n\nIntro body.\n' }, + ].map(({ name, body }) => { + const full = path.join(tmpDir, name); + fsMod.writeFileSync(full, body); + return full; + }); + try { + 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')); + } finally { + files.forEach((f) => fsMod.unlinkSync(f)); + fsMod.rmdirSync(tmpDir); + } }); it('should return an empty array when filePaths is empty or invalid', () => {