Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/assets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion lib/helpers/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>} 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
Expand Down
100 changes: 100 additions & 0 deletions lib/helpers/guides.js
Original file line number Diff line number Diff line change
@@ -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,
};
9 changes: 9 additions & 0 deletions lib/services/express.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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).
Expand Down
80 changes: 80 additions & 0 deletions modules/core/doc/guides/authentication.md
Original file line number Diff line number Diff line change
@@ -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": "<resetToken>", "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.
46 changes: 46 additions & 0 deletions modules/core/doc/guides/getting-started.md
Original file line number Diff line number Diff line change
@@ -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
82 changes: 82 additions & 0 deletions modules/core/doc/guides/organizations.md
Original file line number Diff line number Diff line change
@@ -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/<orgId>/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/<token>/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/<orgId>/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.
Loading
Loading