This is the full developer documentation for Mantle JS
# Mantle JS
> Build real-time APIs in TypeScript with clean layers — services as contracts, swappable data adapters, and one hook pipeline for REST, sockets, and AI agents.
```bash
npm create mantlejs@latest my-app
```
Services are contracts
`Service` defines the API; `Repository` adapters do the data access. Swap Postgres for MongoDB or DynamoDB without touching a service.
One hook pipeline
Authentication, validation, logging, and auditing are plain function hooks that run identically for REST, Socket.IO, and MCP callers.
Agent-native
Expose services to AI agents over MCP — per-method tools or a sandboxed code mode — with scoped, revocable agent tokens and an audit trail.
Batteries, unbundled
Eight SQL, document, graph, and vector adapters; local and seven OAuth sign-in strategies; uploads to disk, S3, or GCS; OpenAPI; realtime sync — one package each.
## Explore
[Section titled “Explore”](#explore)
[Getting started](/getting-started/)Scaffold a project or write the smallest Mantle app by hand.
[Adapters & queries](/guides/adapters/)Every Repository adapter and the query operators each one supports.
[Agents (MCP)](/guides/agents/)Tool mode, code mode, agent tokens, and the audit trail.
[UI blocks](/blocks/)React Aria blocks for shadcn/ui, wired to Mantle — install with shadcn add.
[Packages](/packages/mantle/)The README of every @mantlejs package.
[API reference](/api/)Generated from each package's TypeScript exports.
Building with an AI assistant? Point it at [`/llms.txt`](/llms.txt) — or [`/llms-full.txt`](/llms-full.txt) for every guide and package README in one file.
# Architecture
> Mantle's layers, the inward-only dependency rule, and why services are contracts rather than data-access classes.
Mantle is a layered framework in the spirit of Clean and Onion Architecture. The name is the metaphor: the application layer is the geological *mantle* — between the database at the core and the UI at the crust.
```text
CRUST (UI) React, Vue, Angular, iOS, Android
Transport layer Express / Koa / HTTP / Socket.IO / MCP — route calls to services
Application layer Service contracts, the hook pipeline ← the mantle
Domain layer (core) Entities, Repository interfaces
Infrastructure KnexRepository, MongoRepository, … — implement the Domain interfaces
```
## The dependency rule
[Section titled “The dependency rule”](#the-dependency-rule)
Dependencies always point **inward**:
* Infrastructure implements interfaces the Domain defines — never the reverse. A service depends on `Repository`, not on `KnexRepository`.
* Nothing in the Domain or Application layers knows about HTTP or databases. A hook that needs to tell callers apart reads `context.provider` (`"rest"` for Express, `"koa"`, `"http"`, `"socket.io"`, `"mcp"`, or `undefined` for an internal call), never a request object.
* Transports are thin: they translate a request into a service call and run it through the same hook pipeline. REST, Socket.IO, and MCP calls to one service get identical authentication, validation, and auditing — that equivalence is covered by specs, not left to convention.
## Services are contracts
[Section titled “Services are contracts”](#services-are-contracts)
The closest relative of Mantle is FeathersJS, and the central difference is this: in Feathers a service *is* its data access — one class is use case, repository, and controller at once. In Mantle, `Service` is only a contract:
```typescript
interface Service> {
find(params?: ServiceParams): Promise>;
get(id: Id, params?: ServiceParams): Promise;
create(data: D, params?: ServiceParams): Promise;
update(id: Id, data: D, params?: ServiceParams): Promise;
patch(id: Id, data: D, params?: ServiceParams): Promise;
remove(id: Id, params?: ServiceParams): Promise;
}
```
Data access lives behind `Repository` (`findAll`, `findById`, `save`, `updateById`, `patchById`, `deleteById`, `count`, …), implemented by an adapter in the Infrastructure layer. `RepositoryService` bridges the two for plain CRUD; anything richer — a service that writes to two stores, or one with custom methods — is a hand-written class that implements `Service` and composes repositories it receives through its constructor.
| Concern | FeathersJS | Mantle JS |
| ------------------ | ------------------------------- | ------------------------------------------- |
| Service definition | Class implementing data + logic | Interface (contract) only |
| Data access | Inside the service via adapter | Repository in the Infrastructure layer |
| Business logic | Mixed into service methods | Isolated in the Application / Domain layers |
| Hooks | Transport-aware | Transport-agnostic |
| Swapping databases | Requires service changes | Swap the adapter only |
| Testability | Requires mocking the transport | Domain testable with no mocks |
## Hooks
[Section titled “Hooks”](#hooks)
Hooks are plain functions — `(context: HookContext) => HookContext | Promise`, never classes — registered per service and per method in three phases:
```typescript
app.service("users").hooks({
before: { all: [authenticate("jwt")], create: [hashPassword()] },
after: { all: [sanitizeUser()] },
error: { all: [logError()] },
});
```
A `before` hook that sets `context.result` skips the service call; an `error` hook sees `context.error`. Every cross-cutting package — `@mantlejs/auth`, `@mantlejs/schema`, `@mantlejs/logger`, `@mantlejs/audit`, `@mantlejs/storage`, `@mantlejs/embeddings` — plugs in as hooks rather than middleware, which is what keeps them transport-agnostic.
## Typed errors
[Section titled “Typed errors”](#typed-errors)
Code never throws a plain `Error`: every failure is a `MantleError` subclass — `BadRequest` (400), `NotAuthenticated` (401), `Forbidden` (403), `NotFound` (404), `Conflict` (409), `Unprocessable` (422), `GeneralError` (500), and a few more. Each serializes to the same JSON over every transport and can carry a `hint`: actionable guidance on how to make the request succeed, written for whoever reads it — a developer, a client SDK, or an AI agent.
## Packages and their boundaries
[Section titled “Packages and their boundaries”](#packages-and-their-boundaries)
The monorepo holds one package per concern. Which package may import which is part of the architecture, not a convention: every project is tagged in Nx, the root ESLint config turns each row below into an `@nx/enforce-module-boundaries` constraint, and a CI check fails if this table and the configuration drift apart.
| Package | May depend on |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| @mantlejs/mantle | nothing |
| @mantlejs/express | @mantlejs/mantle |
| @mantlejs/koa | @mantlejs/mantle |
| @mantlejs/http | @mantlejs/mantle |
| @mantlejs/knex | @mantlejs/mantle |
| @mantlejs/dynamodb | @mantlejs/mantle |
| @mantlejs/supabase | @mantlejs/mantle |
| @mantlejs/pinecone | @mantlejs/mantle |
| @mantlejs/qdrant | @mantlejs/mantle |
| @mantlejs/neo4j | @mantlejs/mantle |
| @mantlejs/mongodb | @mantlejs/mantle |
| @mantlejs/auth | @mantlejs/mantle |
| @mantlejs/auth-local | @mantlejs/mantle, @mantlejs/auth |
| @mantlejs/auth-oauth | @mantlejs/mantle, @mantlejs/auth |
| @mantlejs/auth-google | @mantlejs/mantle, @mantlejs/auth-oauth |
| @mantlejs/auth-github | @mantlejs/mantle, @mantlejs/auth-oauth |
| @mantlejs/auth-facebook | @mantlejs/mantle, @mantlejs/auth-oauth |
| @mantlejs/auth-apple | @mantlejs/mantle, @mantlejs/auth-oauth |
| @mantlejs/auth-microsoft | @mantlejs/mantle, @mantlejs/auth-oauth |
| @mantlejs/auth-linkedin | @mantlejs/mantle, @mantlejs/auth-oauth |
| @mantlejs/auth-twitter | @mantlejs/mantle, @mantlejs/auth-oauth |
| @mantlejs/auth-redis | @mantlejs/mantle, @mantlejs/auth, @mantlejs/auth-oauth |
| @mantlejs/storage | @mantlejs/mantle |
| @mantlejs/storage-s3 | @mantlejs/mantle, @mantlejs/storage |
| @mantlejs/storage-gcs | @mantlejs/mantle, @mantlejs/storage |
| @mantlejs/logger | @mantlejs/mantle |
| @mantlejs/audit | @mantlejs/mantle (test-only: @mantlejs/memory, @mantlejs/knex) |
| @mantlejs/embeddings | @mantlejs/mantle (test-only: @mantlejs/memory) |
| @mantlejs/schema | @mantlejs/mantle |
| @mantlejs/memory | @mantlejs/mantle |
| @mantlejs/config | @mantlejs/mantle |
| @mantlejs/socketio | @mantlejs/mantle |
| @mantlejs/openapi | @mantlejs/mantle |
| @mantlejs/mcp | @mantlejs/mantle (+ @modelcontextprotocol/sdk; test-only: @mantlejs/http, @mantlejs/memory, @mantlejs/auth) |
| @mantlejs/mcp-code | @mantlejs/mantle, @mantlejs/mcp (+ quickjs-emscripten-core, @jitl/quickjs-wasmfile-release-sync; test-only: @mantlejs/http, @mantlejs/memory, @mantlejs/auth, @mantlejs/audit) |
| @mantlejs/sync | @mantlejs/mantle |
| @mantlejs/client | nothing (optional peer: socket.io-client; test-only: @mantlejs/mantle for conformance specs) |
| @mantlejs/react | @mantlejs/client (peers: react, @tanstack/react-query) |
| @mantlejs/cli | nothing (standalone code generator) |
| create-mantlejs | @mantlejs/cli |
| ui-registry | @mantlejs/client, @mantlejs/react (`registry/`, unpublished; peers: react, react-aria-components, @tanstack/react-query) |
| examples/\* | anything (apps, not libraries — exempt from the boundary rules above, never depended on) |
| website | anything (the mantlejs.com docs site — an app like examples/\*, never depended on) |
Rendered at build time from [`CLAUDE.md`](https://github.com/MantleJs/mantle/blob/main/CLAUDE.md), so it can't drift from the repository.
Each package has a page under [Packages](/packages/mantle/) (its README) and a generated [API reference](/api/).
# Mantle UI registry
> Copy-in React blocks wired to Mantle — auth forms, OAuth buttons, an upload dropzone, a realtime list, a server-sorted data table, pagination, and a type-ahead search — distributed as a shadcn registry and built on React Aria through shadcn's React Aria base. You shadcn add a block and own its source; nothing here is published to npm.
Copy-in React blocks wired to Mantle — auth forms, OAuth buttons, an upload dropzone, a realtime list, a server-sorted data table, pagination, and a type-ahead search — distributed as a [shadcn registry](https://ui.shadcn.com/docs/registry) and built on [React Aria](https://react-spectrum.adobe.com/react-aria/) through shadcn’s React Aria base. You `shadcn add` a block and own its source; nothing here is published to npm.
## Requirements
[Section titled “Requirements”](#requirements)
* A shadcn project on a **React Aria style** (`aria-nova`, `aria-vega`, …): `npx shadcn init --base aria --preset nova`. Blocks compose React Aria primitives (`TextField` + the aria `Input`, `LinkButton`, `Table` render props) and don’t work on the Radix or Base UI styles — see [Why aria-only](#why-aria-only).
* `@mantlejs/client` + `@mantlejs/react` with a `` around your app (`@mantlejs/react`’s README).
* Tailwind v4 with the CSS-variable theme `shadcn init` writes. Blocks use only semantic tokens (`bg-primary`, `text-muted-foreground`, `border-border`, …), so they follow your theme and preset.
## Install
[Section titled “Install”](#install)
Register the namespace once in `components.json`. Keep `{style}` in the URL — it’s how a non-aria project gets refused at install time:
```json
{
"registries": {
"@mantle": "https://mantlejs.com/r/{style}/{name}.json"
}
}
```
Then add blocks (each pulls in the shadcn primitives and Mantle helpers it needs):
```bash
npx shadcn add @mantle/login-form @mantle/signup-form @mantle/oauth-buttons
npx shadcn add @mantle/upload-dropzone @mantle/realtime-list @mantle/data-table @mantle/search-combobox
```
Files land under `components/mantle/` (and `lib/mantle-errors.ts`), so they never collide with shadcn’s own blocks.
The registry is published with the Mantle website at [mantlejs.com/r/](https://mantlejs.com/r/aria-nova/registry.json), and every block has a live demo under [mantlejs.com/blocks/](https://mantlejs.com/blocks/). To try unreleased changes, serve a local build instead: `npx nx run ui-registry:build-registry`, then `npx serve registry/public` and use `http://localhost:3000/r/{style}/{name}.json`.
## Blocks
[Section titled “Blocks”](#blocks)
| Item | What it does | Built on |
| ------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| `auth-provider` | Session state + `useAuth()`: restores a persisted session, consumes OAuth `#accessToken` redirects | `@mantlejs/react` client |
| `login-form` | Email/password against `@mantlejs/auth-local`; field + form-level server errors | RAC `Form`, `TextField`, `FieldError`; aria `Input` |
| `signup-form` | Creates the user through your users service, then logs in | RAC `Form`, `TextField`; aria `Input`, `Button` |
| `oauth-buttons` | “Continue with …” links to the seven `@mantlejs/auth-*` strategies (text-only, no logos bundled) | aria `LinkButton` |
| `upload-dropzone` | Drop or pick files → multipart `POST` to a `handleUpload()` service, with progress | RAC `DropZone`, `FileTrigger`; aria `Progress` |
| `realtime-list` | A `find()` result that applies `created`/`patched`/`removed` events in place — no refetch | RAC `GridList` |
| `data-table` | Server-side sort (`$sort`) and paging (`$limit`/`$skip`), `$select` of shown fields | aria `Table` (RAC `Table`/`Column`) |
| `mantle-pagination` | Page links for a `Paginated` envelope, driving `$skip` | aria `pagination` |
| `search-combobox` | Debounced type-ahead over `find()` with `$ilike`, falling back to `$like` | aria `Combobox` (RAC `ComboBox`) |
| `mantle-errors` | `toFormErrors()` — `MantleClientError` → React Aria `validationErrors` + a form-level message | — |
## Why aria-only
[Section titled “Why aria-only”](#why-aria-only)
The `{style}` URL placeholder is filled from the consumer’s `components.json`, and the build ([`scripts/fan-out-styles.mjs`](https://github.com/MantleJs/mantle/blob/main/registry/scripts/fan-out-styles.mjs)) only publishes items under `r/aria-/`. Observed on a `radix-nova` project with a plain `{name}` URL: `shadcn add` succeeds, then `tsc` fails with TS2322 in `data-table`, `login-form`, and `mantle-pagination` — and a block that happens to compile (an `Input` inside a React Aria `TextField`) is silently unwired at runtime. With `{style}`, the same project gets `The item at …/r/radix-nova/login-form.json was not found` before any file is written. The flat `r/.json` files are still published for tooling that can’t template `{style}`.
## Developing
[Section titled “Developing”](#developing)
This project is itself an `aria-nova` shadcn project (`components.json`, `@/` alias), so blocks are written with the exact imports a consumer gets and type-check/test in place.
```bash
npx nx test ui-registry # Vitest + Testing Library + axe (jsdom)
npx nx run ui-registry:build-registry # shadcn registry validate → shadcn build → per-style fan-out
npx nx run ui-registry:e2e-install # fresh aria-nova + aria-vega apps: shadcn add every block, tsc + vite build;
# a radix-nova app must be refused
```
* Block sources: `src/components/mantle/`, `src/lib/mantle-errors.ts`. Register new items in `registry.json` and add them to `e2e/fixture-app.tsx`.
* `src/components/ui/` is **vendored verbatim** from shadcn’s aria-nova style (`npx shadcn add …` in a scratch aria-nova app, then copied) and excluded from lint/prettier so re-vendoring stays a clean diff. One local deviation: `label.tsx` drops upstream’s unused `import * as React` (TS6133 under this repo’s `noUnusedLocals`; the smoke test applies the same workaround to consumer apps and logs when upstream no longer needs it).
* Every block spec covers the happy path, typed `MantleClientError` rendering, keyboard-only operation, and zero axe violations (`region` and `color-contrast` off — blocks render without page landmarks, and jsdom can’t lay out colors).
* May import only `@mantlejs/client` and `@mantlejs/react` among workspace packages (`ui-registry` row of `CLAUDE.md`’s dependency matrix, enforced by lint).
# Auth provider
> Session state for the auth blocks — restores a persisted session, consumes OAuth redirects, and exposes useAuth().
A live demo against a mocked server in your browser — the block is the registry's own source.
## Install
[Section titled “Install”](#install)
```bash
npx shadcn add @mantle/auth-provider
```
Needs a React Aria shadcn project and the `@mantle` registry in `components.json` — see [Install](/blocks/#install). The block lands in `components/mantle/auth-provider.tsx`, yours to edit.
## Usage
[Section titled “Usage”](#usage)
Wrap your app once, inside ``. Every auth block reads the session from it.
```tsx
import { mantle } from "@mantlejs/client";
import { MantleProvider } from "@mantlejs/react";
import { AuthProvider, useAuth } from "@/components/mantle/auth-provider";
const client = mantle({ url: "https://api.example.com" });
export function App() {
return (
);
}
function Header() {
const { status, user, logout } = useAuth();
if (status === "loading") return null;
return status === "authenticated" ? (
) : (
Log in
);
}
```
* `status` is `"loading"` until the persisted-session check on mount resolves; `user` is set by `login()` and is `undefined` after a reload (the client persists tokens, not the user record).
* With `handleOAuthRedirect` (default on), tokens arriving in the URL fragment from an `@mantlejs/auth-oauth` redirect are handed to the client and stripped from the address bar.
* `signup()` creates the user through the `users` service (configurable with `usersService`), then logs in.
[View the source](https://github.com/MantleJs/mantle/blob/main/registry/src/components/mantle/auth-provider.tsx).
# Data table
> Server-side sorting and paging over find(), selecting only the fields it shows.
A live demo against a mocked server in your browser — the block is the registry's own source.
## Install
[Section titled “Install”](#install)
```bash
npx shadcn add @mantle/data-table
```
Needs a React Aria shadcn project and the `@mantle` registry in `components.json` — see [Install](/blocks/#install). The block lands in `components/mantle/data-table.tsx`, yours to edit.
## Usage
[Section titled “Usage”](#usage)
Works with any service that returns `Paginated` (every `RepositoryService` does).
```tsx
import { DataTable } from "@/components/mantle/data-table";
service="articles"
aria-label="Articles"
pageSize={20}
columns={[
{ id: "title", header: "Title", sortable: true },
{ id: "views", header: "Views", sortable: true },
]}
/>;
```
* Column sort descriptors become `$sort`, pages become `$skip`/`$limit`, and visible columns become `$select`.
* Built on shadcn’s aria `Table` (React Aria `Table`/`Column`), with sortable headers announced to screen readers.
* Pagination controls appear automatically when the result is a `Paginated` envelope.
[View the source](https://github.com/MantleJs/mantle/blob/main/registry/src/components/mantle/data-table.tsx).
# Login form
> Email and password sign-in against @mantlejs/auth-local, with server errors mapped onto the form.
A live demo against a mocked server in your browser — the block is the registry's own source.
## Install
[Section titled “Install”](#install)
```bash
npx shadcn add @mantle/login-form
```
Needs a React Aria shadcn project and the `@mantle` registry in `components.json` — see [Install](/blocks/#install). The block lands in `components/mantle/login-form.tsx`, yours to edit.
## Usage
[Section titled “Usage”](#usage)
Render inside ``.
```tsx
import { LoginForm } from "@/components/mantle/login-form";
navigate("/")} />;
```
* Built on React Aria `Form` + `TextField` with shadcn’s aria `Input`, so labels, required-field validation, and error messages are announced to assistive technology.
* A `401 NotAuthenticated` becomes a form-level alert; a `422 Unprocessable` carrying field errors lands on the matching field.
* An error from an OAuth redirect (`#error=…`) is shown here too.
[View the source](https://github.com/MantleJs/mantle/blob/main/registry/src/components/mantle/login-form.tsx).
# Pagination
> Page links driven by a Paginated envelope.
A live demo against a mocked server in your browser — the block is the registry's own source.
## Install
[Section titled “Install”](#install)
```bash
npx shadcn add @mantle/mantle-pagination
```
Needs a React Aria shadcn project and the `@mantle` registry in `components.json` — see [Install](/blocks/#install). The block lands in `components/mantle/mantle-pagination.tsx`, yours to edit.
## Usage
[Section titled “Usage”](#usage)
Feed it any `{ total, limit, skip }` and handle `onSkipChange`; `data-table` uses it internally.
```tsx
import { MantlePagination } from "@/components/mantle/mantle-pagination";
setQuery({ ...query, $skip: skip })} />;
```
* Named `mantle-pagination` so it never collides with shadcn’s own `pagination` primitive, which it wraps.
* Pass `getHref` for real, crawlable page URLs; omit it for in-place paging.
* Renders nothing when everything fits on one page.
[View the source](https://github.com/MantleJs/mantle/blob/main/registry/src/components/mantle/mantle-pagination.tsx).
# OAuth buttons
> "Continue with …" buttons for the seven @mantlejs OAuth strategies.
A live demo against a mocked server in your browser — the block is the registry's own source.
## Install
[Section titled “Install”](#install)
```bash
npx shadcn add @mantle/oauth-buttons
```
Needs a React Aria shadcn project and the `@mantle` registry in `components.json` — see [Install](/blocks/#install). The block lands in `components/mantle/oauth-buttons.tsx`, yours to edit.
## Usage
[Section titled “Usage”](#usage)
List only the strategies configured on your server. Set each strategy’s `redirectUrl` to your frontend so the user comes back signed in.
```tsx
import { OAuthButtons } from "@/components/mantle/oauth-buttons";
;
```
* Each button is a link (a full-page navigation, as OAuth requires) to `${apiUrl}/auth/`; override routes with `paths`.
* Text-only by design: provider logos are each licensed under their own brand guidelines, so add official assets yourself if you want them.
* Pair with `auth-provider`, which consumes the tokens the provider redirect brings back.
[View the source](https://github.com/MantleJs/mantle/blob/main/registry/src/components/mantle/oauth-buttons.tsx).
# Realtime list
> A find() result that applies created, patched, and removed events in place — no refetch.
A live demo against a mocked server in your browser — the block is the registry's own source.
## Install
[Section titled “Install”](#install)
```bash
npx shadcn add @mantle/realtime-list
```
Needs a React Aria shadcn project and the `@mantle` registry in `components.json` — see [Install](/blocks/#install). The block lands in `components/mantle/realtime-list.tsx`, yours to edit.
## Usage
[Section titled “Usage”](#usage)
The client needs a socket (`mantle({ url, socket: {} })`) and the server a Socket.IO publisher for the service.
```tsx
import { RealtimeList } from "@/components/mantle/realtime-list";
service="messages"
aria-label="Messages"
query={{ $sort: { createdAt: "desc" }, $limit: 50 }}
insert="start"
renderItem={(message) => message.text}
/>;
```
* Built on React Aria `GridList` — arrow-key navigation, typeahead, and an `onAction` for opening an item.
* Uses `@mantlejs/react`’s `useFind` with `realtime: { mode: "patch" }`, which writes each event into the cached page instead of invalidating and refetching.
* `matches` mirrors your query’s filter client-side, so a patched record that no longer matches leaves the list.
[View the source](https://github.com/MantleJs/mantle/blob/main/registry/src/components/mantle/realtime-list.tsx).
# Search combobox
> Debounced type-ahead over find(), using $ilike where the adapter supports it.
A live demo against a mocked server in your browser — the block is the registry's own source.
## Install
[Section titled “Install”](#install)
```bash
npx shadcn add @mantle/search-combobox
```
Needs a React Aria shadcn project and the `@mantle` registry in `components.json` — see [Install](/blocks/#install). The block lands in `components/mantle/search-combobox.tsx`, yours to edit.
## Usage
[Section titled “Usage”](#usage)
Point it at a service and the field to match.
```tsx
import { SearchCombobox } from "@/components/mantle/search-combobox";
service="articles"
field="title"
label="Search articles"
onSelect={(article) => open(article.id)}
/>;
```
* Built on shadcn’s aria `Combobox` (React Aria `ComboBox`): keyboard navigation and live-region announcements included.
* `operator="auto"` (default) tries case-insensitive `$ilike` and falls back to `$like` for the rest of the session if the adapter rejects it — see the [capability matrix](/guides/adapters/#what-each-adapter-supports).
* `minChars`, `debounceMs`, and `limit` tune how eagerly it queries.
[View the source](https://github.com/MantleJs/mantle/blob/main/registry/src/components/mantle/search-combobox.tsx).
# Signup form
> Account creation through your users service, followed by an automatic login.
A live demo against a mocked server in your browser — the block is the registry's own source.
## Install
[Section titled “Install”](#install)
```bash
npx shadcn add @mantle/signup-form
```
Needs a React Aria shadcn project and the `@mantle` registry in `components.json` — see [Install](/blocks/#install). The block lands in `components/mantle/signup-form.tsx`, yours to edit.
## Usage
[Section titled “Usage”](#usage)
Render inside ``; the users service must accept `create` from unauthenticated callers.
```tsx
import { SignupForm } from "@/components/mantle/signup-form";
navigate("/welcome")} />;
```
* `withName` (default `true`) adds a name field sent as `name`.
* `minPasswordLength` is a client-side check — keep it in line with your server-side schema.
* Validation errors from `@mantlejs/schema`’s `validate()` hook (`data.errors[].field`) are mapped onto the matching fields.
[View the source](https://github.com/MantleJs/mantle/blob/main/registry/src/components/mantle/signup-form.tsx).
# Upload dropzone
> Drag-and-drop or picked files, uploaded to a @mantlejs/storage service with progress and typed errors.
A live demo against a mocked server in your browser — the block is the registry's own source.
## Install
[Section titled “Install”](#install)
```bash
npx shadcn add @mantle/upload-dropzone
```
Needs a React Aria shadcn project and the `@mantle` registry in `components.json` — see [Install](/blocks/#install). The block lands in `components/mantle/upload-dropzone.tsx`, yours to edit.
## Usage
[Section titled “Usage”](#usage)
Point `service` at a service whose `create` runs `@mantlejs/storage`’s `handleUpload()` hook — or pass `id` to upload into an existing record through its `patch`.
```tsx
import { UploadDropzone } from "@/components/mantle/upload-dropzone";
console.log("stored", attachment)}
/>;
```
* React Aria `DropZone` + `FileTrigger`: the zone is keyboard-focusable, pasting works, and the picker is a real button.
* Type and size are checked before upload, with the reason shown per file; the server’s own limits still apply.
* Each file goes through `client.service(service).upload()`, so it gets the client’s bearer token, its 401 refresh-and-retry, and typed `MantleClientError`s, with progress events.
[View the source](https://github.com/MantleJs/mantle/blob/main/registry/src/components/mantle/upload-dropzone.tsx).
# Getting started
> Scaffold a Mantle app with npm create, or write the smallest possible one by hand — one service, one file, no external infrastructure.
Mantle needs **Node.js 22 or newer**. Every package is published under the `@mantlejs` scope.
## Scaffold a project
[Section titled “Scaffold a project”](#scaffold-a-project)
`create-mantlejs` generates a complete app — transport, database adapter, auth strategy, and a first service — and installs it:
```bash
npm create mantlejs@latest my-app
```
It prompts for a database (`pg`, `sqlite`, `mongodb`, or none), an auth strategy (`local` or one of the OAuth providers), and a package manager. Pass flags after `--` to skip the prompts:
```bash
npm create mantlejs@latest my-app -- --database pg --auth local --package-manager npm
cd my-app
npm run dev
```
See [`create-mantlejs`](/packages/create-mantlejs/) and [`@mantlejs/cli`](/packages/cli/) for every option and for generating services, repositories, and hooks inside an existing project.
## Or write one by hand
[Section titled “Or write one by hand”](#or-write-one-by-hand)
The smallest runnable Mantle app is one service over an in-memory repository, served by the zero-dependency HTTP transport. This is the whole of `examples/todo-minimal`:
```bash
npm install @mantlejs/mantle @mantlejs/http @mantlejs/memory
```
src/index.ts
```typescript
import { mantle, RepositoryService } from "@mantlejs/mantle";
import { http } from "@mantlejs/http";
import { MemoryRepository } from "@mantlejs/memory";
interface Todo extends Record {
id: string;
title: string;
done: boolean;
}
const app = mantle().configure(http({ cors: true }));
app.use("todos", new RepositoryService(new MemoryRepository()), {
methods: ["find", "get", "create", "update", "patch", "remove"],
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
```
Three pieces, one per layer:
* **`MemoryRepository`** is the data access — a `Repository` implementation. Swap it for `KnexRepository`, `MongoRepository`, `DynamoDbRepository`, … without touching anything else.
* **`RepositoryService`** is the service — the `Service` contract (`find`, `get`, `create`, `update`, `patch`, `remove`) over one repository, with query-string parsing, pagination, and typed errors built in.
* **`http()`** is the transport — it maps `GET /todos`, `POST /todos`, `PATCH /todos/:id`, … onto the service.
Try it:
```bash
curl -X POST http://localhost:3000/todos -H 'content-type: application/json' \
-d '{"title": "write the README", "done": false}'
curl 'http://localhost:3000/todos?$sort[title]=asc&$limit=10'
```
`find` always answers with a `Paginated` envelope — `{ "total": 1, "limit": 10, "skip": 0, "data": [ … ] }` — and the query string maps onto the repository’s `where`/`sort`/`limit`/`skip`/`select` ([query conventions](/packages/mantle/#query-conventions)). Query values arrive as strings; attach a schema to the service (`new RepositoryService(repo, { schema })`) to have them coerced to numbers and booleans.
## Add behavior with hooks
[Section titled “Add behavior with hooks”](#add-behavior-with-hooks)
Cross-cutting behavior — authentication, validation, logging, auditing — goes in **hooks**: plain functions that run before, after, or on error of a service method, for every transport alike.
```typescript
import { BadRequest, type HookContext } from "@mantlejs/mantle";
const requireTitle = (context: HookContext) => {
const title = (context.data as { title?: unknown } | undefined)?.title;
if (typeof title !== "string" || title.trim() === "") {
throw new BadRequest("A todo needs a title", undefined, undefined, "Send a non-empty string in `title`.");
}
return context;
};
app.service("todos").hooks({
before: { create: [requireTitle] },
});
```
Errors are always typed (`BadRequest`, `NotFound`, `Forbidden`, …) and serialize to JSON with an optional `hint` telling the caller — a person or an agent — how to fix the request.
## Call it from a frontend
[Section titled “Call it from a frontend”](#call-it-from-a-frontend)
```typescript
import { mantle } from "@mantlejs/client";
const api = mantle({ url: "http://localhost:3000" });
const todos = api.service("todos");
const todo = await todos.create({ title: "ship it", done: false });
await todos.patch(todo.id, { done: true });
const page = await todos.find({ query: { $sort: { title: "asc" }, $limit: 10 } });
```
In React, [`@mantlejs/react`](/packages/react/) wraps the client in TanStack Query hooks, and the [UI blocks](/blocks/) give you ready-made auth forms, tables, lists, and uploads on top of them.
## Next steps
[Section titled “Next steps”](#next-steps)
* [Architecture](/architecture/) — the layers, the dependency rule, and how Mantle differs from FeathersJS.
* [Adapters](/guides/adapters/) — databases, vector stores, and which query operators each supports.
* [Authentication](/guides/auth/) — JWTs, local and OAuth sign-in, refresh-token rotation.
* [Agents](/guides/agents/) — exposing services to AI agents over MCP, scoped agent tokens, and an audit trail.
* [`examples/knowledge-base`](https://github.com/MantleJs/mantle/tree/main/examples/knowledge-base) — the canonical example, with nearly every package wired into one app.
# Adapters & queries
> The Repository adapters for SQL, document, key-value, graph, and vector stores — and exactly which query operators each one supports.
An adapter is a `Repository` implementation in the Infrastructure layer. Services never import one directly — they receive a `Repository` — so moving a service from SQLite to Postgres to MongoDB is a change to the repository you construct, not to the service.
| Package | Repository classes | Backends |
| ------------------------------------------- | ------------------------------------------ | --------------------------------------------------------- |
| [`@mantlejs/knex`](/packages/knex/) | `KnexRepository`, `KnexVectorRepository` | PostgreSQL (incl. pgvector), MySQL/MariaDB, SQLite, MSSQL |
| [`@mantlejs/mongodb`](/packages/mongodb/) | `MongoRepository`, `MongoVectorRepository` | MongoDB, Atlas Vector Search |
| [`@mantlejs/supabase`](/packages/supabase/) | `SupabaseRepository` | Supabase (Postgres via PostgREST) |
| [`@mantlejs/dynamodb`](/packages/dynamodb/) | `DynamoDbRepository` | Amazon DynamoDB |
| [`@mantlejs/neo4j`](/packages/neo4j/) | `Neo4jRepository` | Neo4j (with graph traversal) |
| [`@mantlejs/pinecone`](/packages/pinecone/) | `PineconeRepository` | Pinecone (vector search) |
| [`@mantlejs/qdrant`](/packages/qdrant/) | `QdrantRepository` | Qdrant (vector search) |
| [`@mantlejs/memory`](/packages/memory/) | `MemoryRepository` | In-process `Map` — tests and prototypes |
## Defining a repository
[Section titled “Defining a repository”](#defining-a-repository)
Adapters are configured once as a plugin, then subclassed per table or collection:
```typescript
import { mantle, RepositoryService } from "@mantlejs/mantle";
import { knex, KnexRepository } from "@mantlejs/knex";
const app = mantle().configure(knex({ client: "pg", connection: process.env.DATABASE_URL }));
class UserRepository extends KnexRepository {
readonly tableName = "users";
}
app.use("users", new RepositoryService(new UserRepository(app)));
```
Every adapter keeps an escape hatch to its native client (the Knex query builder, the MongoDB collection, Neo4j’s Cypher `raw()`) for queries the portable API can’t express — put them in a named repository method, not in the service.
## Portable queries
[Section titled “Portable queries”](#portable-queries)
`find()` takes a `QueryParams` object — `where`, `sort`, `limit`, `skip`, `select` — that every adapter translates to its own query language. Over HTTP the same object travels as a bracket-notation query string (`?age[$gt]=21&$sort[name]=asc&$limit=10`).
The `where` operators:
* Equality `{ field: value }`, null `{ field: null }`, not-equal `$ne`
* Comparison `$lt`, `$lte`, `$gt`, `$gte`; inclusion `$in`, `$nin`
* Logical `$or`, `$and` (arrays of where clauses)
* Pattern `$like`, `$notlike`, `$ilike`
* Containment `$contains` — the field is a superset of the value (an element, every element, or a recursive key/value match)
* Nested paths — dot-path keys such as `"metadata.owner.name"`
## What each adapter supports
[Section titled “What each adapter supports”](#what-each-adapter-supports)
Support is uneven by design: some backends can’t represent nested data at all. Rather than silently misbehaving, every adapter **rejects an operator it doesn’t support with a `BadRequest` naming it**, and reports exactly what it accepts through `describe().capabilities` — the same metadata `@mantlejs/openapi` and `@mantlejs/mcp` use, so an AI agent is never offered an operator the adapter would reject.
| Adapter | `$contains` | Nested paths (`"a.b.c"`) |
| ------------------- | ----------------------------------------------------------------------- | ------------------------------------------------ |
| `memory` | ✅ full (reference implementation) | ✅ full |
| `supabase` | ✅ jsonb `@>` (Postgres) | ✅ PostgREST `->`/`->>` |
| `mongodb` | ✅ native | ✅ native |
| `knex` — PostgreSQL | ✅ jsonb `@>` | ✅ `jsonb_path_query_first` (via `whereJsonPath`) |
| `knex` — MySQL | ✅ `JSON_CONTAINS` | ✅ `JSON_EXTRACT` (via `whereJsonPath`) |
| `knex` — SQLite | ❌ no native JSON-superset function | ✅ `json_extract` (via `whereJsonPath`) |
| `knex` — MSSQL | ❌ no native JSON-superset function | ✅ `JSON_VALUE` (via `whereJsonPath`) |
| `dynamodb` | ✅ `contains()`, ANDed per element/leaf for array/object operands | ✅ multi-segment `#name0.#name1` attribute paths |
| `qdrant` | ✅ `match.value`, ANDed per element/leaf (arrays auto-match-any-element) | ✅ native (dot-path payload keys) |
| `neo4j` | ❌ node properties can't hold nested objects at all | ❌ same — architectural, not a gap |
| `pinecone` | ❌ metadata values are flat scalars/scalar-arrays only | ❌ same — architectural, not a gap |
Rendered at build time from [`CLAUDE.md`](https://github.com/MantleJs/mantle/blob/main/CLAUDE.md), so it can't drift from the repository.
On `knex`, capabilities depend on the **connected client** — check `describe().capabilities` at runtime rather than assuming every SQL database behaves the same. The semantics in this table are pinned by a shared conformance fixture exported from `@mantlejs/mantle` (`NESTED_QUERY_CASES`), which `@mantlejs/memory` executes as the reference implementation and the other adapters run as translator specs.
## Services with more than one repository
[Section titled “Services with more than one repository”](#services-with-more-than-one-repository)
`RepositoryService` covers one repository. When a service has to write to two stores — a record and its embedding, a record and an activity log — write a `Service` that receives both repositories. Writes across adapters aren’t atomic, so the rule is: the secondary write is an **idempotent upsert keyed on the source record’s id, safe to retry, and non-fatal on failure** — logged, never rethrown into the primary caller. [`@mantlejs/embeddings`](/packages/embeddings/)’s `embed()` hook is the reference implementation of that pattern.
# Agents (MCP)
> Expose services to AI agents over the Model Context Protocol — per-method tools or code mode — with scoped agent tokens and an audit trail, all through the same hook pipeline as every other caller.
Mantle treats an AI agent as one more transport. An agent connects over the [Model Context Protocol](https://modelcontextprotocol.io), and every call it makes runs the target service’s **full hook pipeline** — authentication, validation, events, auditing — exactly like a REST call. There is no agent-only path that skips your rules; specs assert that HTTP, MCP tool mode, and MCP code mode make identical accept/reject decisions for the same hook chain.
Four pieces work together:
| Piece | What it does |
| ------------------------------------------------ | --------------------------------------------------------------------------------- |
| [`@mantlejs/mcp`](/packages/mcp/) | An MCP server over your services: deny-by-default expose map, one tool per method |
| [`@mantlejs/mcp-code`](/packages/mcp-code/) | **Code mode**: a typed API the agent writes scripts against, run in a sandbox |
| [`@mantlejs/auth`](/packages/auth/#agent-tokens) | Agent tokens — short-lived, capability-scoped, revocable, delegated by a user |
| [`@mantlejs/audit`](/packages/audit/) | An audit-trail hook: who did what, as which identity, into any `Repository` |
## Tool mode
[Section titled “Tool mode”](#tool-mode)
```typescript
import { mcp } from "@mantlejs/mcp";
app.configure(
mcp({
transport: "http", // mounts POST /mcp on the app's HTTP transport; or "stdio" via startMcp(app)
services: {
articles: true, // every method registered in app.use()
users: ["find", "get"], // read-only over MCP
},
}),
);
```
Nothing is exposed unless the expose map says so; an unknown path or method fails at startup. Each exposed method becomes a tool (`articles_find`, `users_get`, …) whose input schema comes from the service’s schema and its adapter’s `describe().capabilities` — an agent is never offered a query operator the adapter would reject. Errors come back as MCP tool errors carrying `MantleError.toJSON()`, `hint` included, so the agent can correct itself.
## Code mode
[Section titled “Code mode”](#code-mode)
With many methods, one tool per method crowds the model’s context, and every intermediate result round-trips through the model. **Code mode** replaces the per-method tools with a typed TypeScript API and two tools:
* `search_api` returns the declarations for the services the agent needs, a piece at a time;
* `execute` runs a script against them in a QuickJS sandbox and returns only the final result.
```typescript
import { mcp } from "@mantlejs/mcp";
import { codeMode } from "@mantlejs/mcp-code";
app.configure(
mcp({
transport: "http",
services: { articles: ["find", "get"], comments: ["find"] },
codeMode: codeMode(), // mode defaults to "code"; "both" keeps the per-method tools too
}),
);
```
An agent’s script:
```javascript
const { data } = await mantle.articles.find({ where: { status: "published" }, limit: 100, select: ["id", "tags"] });
const counts = {};
for (const article of data) for (const tag of article.tags) counts[tag] = (counts[tag] ?? 0) + 1;
return counts;
```
Each `mantle..()` call is a real service call through the hook pipeline. The sandbox has no network, filesystem, `process`, or timers; every execution starts fresh; and time, memory, stack, call count, and output size are all capped with typed errors. Code mode is **experimental** — see the [package page](/packages/mcp-code/) for limits, TypeScript input, the security model, and custom executors.
## Agent tokens
[Section titled “Agent tokens”](#agent-tokens)
A user delegates a slice of their access to an agent with a scoped token:
```typescript
import type { AuthEngine } from "@mantlejs/auth";
const engine = app.get("auth");
const issued = await engine.issueAgentToken(
{ articles: ["find", "get"], comments: true }, // CapabilityScope: methods per path, or true for all
user.id, // the delegating user
{ expiresIn: "15m" },
);
// later, before it expires if needed:
await engine.revokeAgentToken(issued.id);
```
Routes opt in with the `authorizeAgent()` hook. It checks the token’s scope against the call’s path and method — the same deny-by-default matcher as the MCP expose map — and sets `context.agent = { id, scope, delegatingUserId }` for downstream hooks:
```typescript
import { authorizeAgent } from "@mantlejs/auth";
app.service("articles").hooks({ before: { all: [authorizeAgent()] } });
```
Agent tokens can’t be used as user JWTs: `authenticate("jwt")` rejects them outright, so a route without `authorizeAgent()` is unreachable with an agent token whatever its scope. In code mode, an agent session’s API listing is narrowed to its scope, and an out-of-scope call still fails in `authorizeAgent()` with the same catchable `Forbidden` tool mode returns.
## The audit trail
[Section titled “The audit trail”](#the-audit-trail)
```typescript
import { auditLog, type AuditRecord } from "@mantlejs/audit";
const audit = auditLog({ sink: new AuditRepository(app) }); // any Repository
app.service("articles").hooks({
after: { all: [audit] },
error: { all: [audit] },
});
```
Each call becomes a record — principal, agent id and scope when an agent made it, path, method, outcome — in a repository you choose, so “what did my agents do?” is a `find()` away. Calls from a code-mode script also carry the script’s `executionId` and a SHA-256 `scriptHash`, grouping a script’s calls together. A failed audit write is logged, never allowed to fail the call it records.
# Authentication
> JWT sessions with @mantlejs/auth, local email+password and OAuth sign-in, rotating refresh tokens, and what changes when you run more than one instance.
Authentication is split in two: **`@mantlejs/auth`** is the engine — JWT issuance and verification, the `POST /authentication` endpoint, and the hooks — and **strategy packages** plug sign-in methods into it.
| Strategy | Sign-in method |
| ------------------------------------------------------- | ----------------------------------------- |
| [`@mantlejs/auth-local`](/packages/auth-local/) | Email + password, hashed with Argon2id |
| [`@mantlejs/auth-google`](/packages/auth-google/) | Google (authorization code + PKCE) |
| [`@mantlejs/auth-github`](/packages/auth-github/) | GitHub |
| [`@mantlejs/auth-facebook`](/packages/auth-facebook/) | Facebook |
| [`@mantlejs/auth-apple`](/packages/auth-apple/) | Sign in with Apple (`form_post` callback) |
| [`@mantlejs/auth-microsoft`](/packages/auth-microsoft/) | Microsoft Entra ID (PKCE) |
| [`@mantlejs/auth-linkedin`](/packages/auth-linkedin/) | LinkedIn (OpenID Connect) |
| [`@mantlejs/auth-twitter`](/packages/auth-twitter/) | Sign in with X (PKCE) |
The OAuth strategies share one base, [`@mantlejs/auth-oauth`](/packages/auth-oauth/) — state handling, PKCE, and find-or-create of the local user — and none of them uses Passport.js.
## Local sign-in
[Section titled “Local sign-in”](#local-sign-in)
```typescript
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { auth, authenticate, sanitizeUser } from "@mantlejs/auth";
import { localStrategy, hashPassword } from "@mantlejs/auth-local";
const app = mantle()
.configure(express())
.configure(auth({ secret: process.env.JWT_SECRET! }))
.configure(localStrategy());
app.use("users", new UserService(new UserRepository(app)), {
methods: ["find", "get", "create", "patch", "remove"],
});
app.service("users").hooks({
before: {
create: [hashPassword()], // registration stays public; the password is stored as an Argon2id hash
patch: [authenticate("jwt"), hashPassword()],
find: [authenticate("jwt")],
get: [authenticate("jwt")],
remove: [authenticate("jwt")],
},
after: { all: [sanitizeUser()] }, // never send password hashes back
});
```
The flow over HTTP:
1. `POST /users` with `{ "email", "password" }` registers a user.
2. `POST /authentication` with `{ "strategy": "local", "email", "password" }` returns an access token, a refresh token, and the user.
3. Requests carry `Authorization: Bearer `; `authenticate("jwt")` verifies it and puts the decoded payload on `params.user`.
Internal calls — a service calling another service without a `provider` — skip `authenticate("jwt")`, which is how strategies look up users without tripping the guards they protect.
## OAuth sign-in
[Section titled “OAuth sign-in”](#oauth-sign-in)
```typescript
import { googleStrategy } from "@mantlejs/auth-google";
app.configure(
googleStrategy({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUrl: "https://app.example.com/",
}),
);
```
Send the browser to `GET /auth/google` with a full-page navigation (a link, not `fetch`). After consent the provider calls back, the strategy finds or creates the user, and — with `redirectUrl` set — redirects to your frontend with `#accessToken=…&refreshToken=…` in the URL **fragment**, so tokens never reach a server log or a `Referer` header. The [`auth-provider`](/blocks/auth-provider/) and [`oauth-buttons`](/blocks/oauth-buttons/) UI blocks handle both ends of that for you.
## Refresh tokens
[Section titled “Refresh tokens”](#refresh-tokens)
Every login returns an access + refresh token pair. Exchange the refresh token on the same endpoint:
```json
POST /authentication
{ "strategy": "refresh", "refreshToken": "eyJhbGciOiJIUzI1NiJ9..." }
```
Refresh tokens **rotate**: each exchange consumes the token and issues a new pair. Replaying a consumed token is treated as theft — every outstanding refresh token for that user is revoked. [`@mantlejs/client`](/packages/client/) does the exchange automatically on a `401`, with concurrent requests sharing a single refresh.
## Running more than one instance
[Section titled “Running more than one instance”](#running-more-than-one-instance)
The engine’s default stores are in memory: fine for one process, wrong behind a load balancer, where a refresh or an OAuth callback can land on a different instance than the one that issued the token or the state. Inject the Redis-backed stores from [`@mantlejs/auth-redis`](/packages/auth-redis/) — `redisRefreshTokenStore` into `auth({ refreshTokenStore })` and `redisStateStore` into each OAuth strategy’s `stateStore` option. Both consume entries atomically, so a replayed callback or a double-submitted refresh can’t succeed twice.
## Agents
[Section titled “Agents”](#agents)
Calls from AI agents get their own short-lived, capability-scoped tokens — distinct from user JWTs and opt-in per route. See [Agents](/guides/agents/).
# Deployment
> Running a Mantle app in production — Google Cloud Run as the reference target, and the settings that matter once there's more than one instance.
A Mantle app is a plain Node.js (≥ 22) HTTP server: `app.listen(port)` on any host that runs a container or a Node process. The reference target is **Google Cloud Run** — it scales to zero, and pairs naturally with Cloud SQL for PostgreSQL. The guidance below is what changes between “works on my machine” and a horizontally scaled deployment.
## Configuration per environment
[Section titled “Configuration per environment”](#configuration-per-environment)
[`@mantlejs/config`](/packages/config/) merges `config/default.json`, `config/.json`, and environment variables, optionally validated against a TypeBox schema, so a container can be configured without a rebuild:
```typescript
import { config } from "@mantlejs/config";
app.configure(config({ schema: AppConfigSchema }));
const port = app.get("port");
app.listen(Number(process.env.PORT ?? port)); // Cloud Run injects PORT
```
Keep secrets — `JWT_SECRET`, database URLs, OAuth client secrets — in environment variables or a secret manager, never in the config files.
## Logging
[Section titled “Logging”](#logging)
[`@mantlejs/logger`](/packages/logger/) writes structured JSON to stdout, which Cloud Run collects as-is. Pass `gcp: true` so levels map to Cloud Logging severities and fields stay structured in the Logs Explorer:
```typescript
import { logger, createLogger } from "@mantlejs/logger";
app.configure(logger(await createLogger({ gcp: process.env.NODE_ENV === "production" })));
```
## When there’s more than one instance
[Section titled “When there’s more than one instance”](#when-theres-more-than-one-instance)
Cloud Run adds instances under load, and any two requests may land on different ones. Everything that defaults to in-process memory has to move to a shared store:
| Concern | Default | Multi-instance setting |
| ----------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------- |
| Refresh tokens | in-memory | `auth({ refreshTokenStore: redisRefreshTokenStore(redis) })` — [`@mantlejs/auth-redis`](/packages/auth-redis/) |
| OAuth `state` / PKCE verifier | in-memory | each strategy’s `stateStore: redisStateStore(redis)` |
| Agent-token revocation | in-memory | inject a shared `AgentTokenStore` into `auth({ agentTokenStore })` |
| Realtime events | local process only | `sync({ adapter: redisAdapter(…) })` — [`@mantlejs/sync`](/packages/sync/) |
| Uploaded files | local disk | `s3Storage()` / `gcsStorage()` — instances have no shared, persistent disk |
Cloud Run’s filesystem is in-memory and per instance, so `diskStorage()` is for development only.
## Graceful shutdown
[Section titled “Graceful shutdown”](#graceful-shutdown)
Call `app.teardown()` on `SIGTERM` so adapters close their connection pools before the instance is stopped — Cloud Run sends `SIGTERM` and allows a grace period before killing the container:
```typescript
process.on("SIGTERM", () => {
void app.teardown().finally(() => process.exit(0));
});
```
# Realtime
> Service events over Socket.IO with channels, cross-instance fan-out with @mantlejs/sync, and live queries on the client.
Every successful `create`, `update`, `patch`, and `remove` emits a service event — `created`, `updated`, `patched`, `removed` (plus any custom events a service declares). Three packages carry those events to clients:
| Package | Role |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| [`@mantlejs/socketio`](/packages/socketio/) | Socket.IO transport: service calls over the socket, events out through channels |
| [`@mantlejs/sync`](/packages/sync/) | Relays events between app instances via Redis or Supabase Realtime |
| [`@mantlejs/client`](/packages/client/) / [`@mantlejs/react`](/packages/react/) | Subscribe on the client; React hooks keep queries fresh |
## Socket.IO and channels
[Section titled “Socket.IO and channels”](#socketio-and-channels)
```typescript
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { socketio } from "@mantlejs/socketio";
const app = mantle().configure(express()).configure(socketio());
// Who receives what: join each connection to channels, then publish events to channels.
app.on("connection", (connection) => {
app.channel("everyone").join(connection);
});
app.publish(() => app.channel("everyone"));
app.listen(3030);
```
Nothing is broadcast until a publisher says where an event goes — mutations still happen, but no client hears about them. Publishers can be global (`app.publish`) or per service (`app.service("messages").publish(…)`), and channels can be per user or filtered by a predicate, which is how you keep one user’s events away from another.
Calls made over the socket (`socket.emit("find", "messages", …)`) are ordinary service calls: they run the full hook pipeline with `provider: "socket.io"`.
## More than one instance
[Section titled “More than one instance”](#more-than-one-instance)
Behind a load balancer, a client connected to instance A must still hear about a write handled by instance B. [`@mantlejs/sync`](/packages/sync/) publishes every local service event to a shared channel and re-emits events from other instances locally, where the Socket.IO transport fans them out:
```typescript
import { sync, redisAdapter } from "@mantlejs/sync";
app.configure(sync({ adapter: redisAdapter({ url: process.env.REDIS_URL }) }));
```
Delivery is **at-most-once** — there’s no persistence or replay. Treat events as cache-invalidation hints, not as a source of truth, and refetch after a reconnect.
## On the client
[Section titled “On the client”](#on-the-client)
```typescript
import { mantle } from "@mantlejs/client";
const api = mantle({ url: "http://localhost:3030", socket: {} }); // socket.io-client is an optional peer
api.service("messages").on("created", (message) => console.log("new message", message));
api.on("reconnect", () => console.log("reconnected — refetch anything stale"));
```
In React, [`@mantlejs/react`](/packages/react/)’s `useFind` and `useGet` subscribe automatically when the client has a socket, invalidate the affected queries on each event, and `MantleProvider` invalidates every query after a reconnect — so missed events cost a refetch, not stale data. To update a list in place with no request at all, opt `useFind` into patch mode, which writes each event straight into its cached result (a `Paginated`’s `total` included):
```typescript
const { data } = useFind(
"comments",
{ query: { articleId, $sort: { createdAt: 1 } } },
{ realtime: { mode: "patch", matches: (comment) => comment.articleId === articleId, insert: "end" } },
);
```
`matches` mirrors the query’s filter and `insert` its sort, so a created record lands where a refetch would have put it. The [`realtime-list`](/blocks/realtime-list/) UI block is built on exactly this.
# File storage
> Multipart uploads through a before hook, stored on local disk, Amazon S3 (or any S3-compatible store), or Google Cloud Storage.
File uploads follow the same shape as everything else in Mantle: a plugin holds the configuration, and a hook does the work on exactly the service methods that accept files.
| Package | Storage |
| ------------------------------------------------- | --------------------------------------------------------------------- |
| [`@mantlejs/storage`](/packages/storage/) | The `upload()` plugin, the `handleUpload()` hook, and `diskStorage()` |
| [`@mantlejs/storage-s3`](/packages/storage-s3/) | `s3Storage()` — Amazon S3, MinIO, Cloudflare R2, other S3-compatibles |
| [`@mantlejs/storage-gcs`](/packages/storage-gcs/) | `gcsStorage()` — Google Cloud Storage |
## Accepting uploads
[Section titled “Accepting uploads”](#accepting-uploads)
```typescript
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { upload, diskStorage, handleUpload } from "@mantlejs/storage";
const app = mantle()
.configure(express())
.configure(
upload({
maxFileSize: 5 * 1024 * 1024,
allowedMimeTypes: ["image/jpeg", "image/png"],
storage: diskStorage({ destination: "./uploads" }),
}),
);
app.use("photos", new PhotoService(new PhotoRepository(app)));
app.service("photos").hooks({
before: { create: [handleUpload("photo", { required: true })] },
});
```
A `multipart/form-data` `POST /photos` with a `photo` part is parsed (with busboy) and stored before the service runs; `create` receives an `UploadedFile` descriptor in `context.data.photo` instead of the bytes — original name, MIME type, size, and the adapter’s `key` — plus any ordinary form fields. A file over `maxFileSize` or of a disallowed type fails the call with a `BadRequest`.
`handleUpload()` reads the raw request the Express and Koa transports attach to `params.request`, so uploads need [`@mantlejs/express`](/packages/express/) or [`@mantlejs/koa`](/packages/koa/); the zero-dependency `@mantlejs/http` transport doesn’t support them yet.
## Cloud storage
[Section titled “Cloud storage”](#cloud-storage)
Swap the adapter; the hook doesn’t change:
```typescript
import { s3Storage } from "@mantlejs/storage-s3";
app.configure(
upload({
storage: s3Storage({ bucket: process.env.S3_BUCKET!, region: process.env.AWS_REGION!, keyPrefix: "uploads/" }),
maxFileSize: 10 * 1024 * 1024,
}),
);
```
Every adapter implements `store()`, `retrieve()`, and `delete()`; cloud adapters also implement `getSignedUrl()` for time-limited direct downloads. Feature-detect it — local disk has no such concept:
```typescript
import type { UploadEngine } from "@mantlejs/storage";
const engine = app.get("upload");
if (engine.storage.getSignedUrl) {
const url = await engine.storage.getSignedUrl(file.key, { expiresIn: 300 });
}
```
## From the browser
[Section titled “From the browser”](#from-the-browser)
[`@mantlejs/client`](/packages/client/#file-uploads) sends uploads with `service.upload()` — a multipart `POST /:service` (or `PATCH /:service/:id` with `id`) carrying the same bearer auth, refresh-and-retry, and typed errors as every other call, with optional progress events:
```typescript
const attachment = await api.service("attachments").upload(file, {
fields: { articleId: "42" }, // merged into context.data by handleUpload()
onProgress: ({ percent }) => setProgress(percent ?? 0),
});
```
The [`upload-dropzone`](/blocks/upload-dropzone/) UI block wraps it — drag-and-drop or a file picker, client-side type and size checks, per-file progress, and the server’s typed errors.
# @mantlejs/audit
> Audit-trail hook for Mantle JS. Records who did what, as which identity, with what result on every service call — a queryable audit trail, not a grep through logs.
Audit-trail hook for [Mantle JS](https://github.com/mantlejs/mantle). Records *who did what, as which identity, with what result* on every service call — a queryable audit trail, not a grep through logs.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/audit
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### Not the same job as `@mantlejs/logger`
[Section titled “Not the same job as @mantlejs/logger”](#not-the-same-job-as-mantlejslogger)
`@mantlejs/logger`’s `logRequest`/`logError` hooks are structured *observability* — request duration, status, correlation ids, meant for a log aggregator. `@mantlejs/audit`’s `auditLog()` is a *compliance/security* record scoped to identity and outcome — who (a user or an [agent](/packages/auth/#agent-tokens)), what (path + method), and what happened. Use both; they answer different questions.
### The sink is a `Repository` you already have
[Section titled “The sink is a Repository\ you already have”](#the-sink-is-a-repositoryt-you-already-have)
`auditLog({ sink })` writes each entry via `sink.save(record)`. `sink` is any `Repository` — `@mantlejs/memory` for prototyping, or a real adapter (`@mantlejs/knex`, `@mantlejs/mongodb`, `@mantlejs/supabase`, …) backed by a table/collection the deployment already runs. No new storage concept, and audit records are themselves queryable through the same `find`/`get` a normal Mantle service would give you if you wrap the sink in one.
### One hook, two registrations
[Section titled “One hook, two registrations”](#one-hook-two-registrations)
`auditLog()` returns a single hook function. Register the *same* returned value in both `after.all` and `error.all` — it records exactly once per call, whichever phase actually reaches it:
```typescript
const audit = auditLog({ sink: auditRepository });
app.service("documents").hooks({
after: { all: [audit] },
error: { all: [audit] },
});
```
A successful call never reaches `error.all`; a thrown call never reaches `after.all` — so there’s no risk of double-recording, and no need for a `before.all` registration (unlike `logRequest`, which needs one to measure duration — `auditLog()` doesn’t record duration, so it doesn’t need one).
### What’s in a record, and what’s deliberately left out
[Section titled “What’s in a record, and what’s deliberately left out”](#whats-in-a-record-and-whats-deliberately-left-out)
```typescript
interface AuditRecord {
principal?: unknown; // ctx.params.user — undefined for an unauthenticated/internal call
agentId?: string; // set only when the call was authorized via an agent token
agentScope?: CapabilityScope; // present only alongside agentId
delegatingUserId?: string; // present only alongside agentId
path: string;
method: string;
params?: Record; // ctx.params.query — filters/pagination/sort/select
mcp?: Record; // ctx.params.mcp — MCP origin metadata (code mode: executionId, scriptHash)
status: "success" | "error";
resultSummary: string; // short outcome description, never the full result payload
timestamp: string; // ISO 8601
}
```
`params` is `ctx.params.query` only — **never** `ctx.params.headers` or the raw `ctx.params.user` object. Headers can carry bearer tokens; identity already has its own field (`principal`). `resultSummary` is deliberately a short description (record count, an id, or an error name+message), not the full result — avoids duplicating potentially large or sensitive payloads into the audit trail.
When the call was authorized via [`@mantlejs/auth`’s `authorizeAgent()`](/packages/auth/#agent-tokens) (`HookContext.agent` set), the entry additionally carries `agentId`, `agentScope`, and `delegatingUserId` — the “as which identity, delegated by whom” half of an agent-originated call.
When the call came from an agent script under [`@mantlejs/mcp-code`](/packages/mcp-code/)’s code mode, the entry also carries `mcp: { mode: "code", executionId, scriptHash }` (plus `script` when the deployment opts in with `auditScriptSource: true`). Every service call one script made shares the same `executionId`, so “what did this script do” is one `find({ where: { "mcp.executionId": id } })` on the sink (on adapters that support nested paths), or a filter in your own query.
### Sink failures never fail the primary operation
[Section titled “Sink failures never fail the primary operation”](#sink-failures-never-fail-the-primary-operation)
If `sink.save()` throws, `auditLog()` catches it — the service call that triggered the audit entry already succeeded (or already failed for its own reason) and that outcome is never changed by an audit-sink outage. By default the failure is logged via `app.get("logger")` when `@mantlejs/logger` is configured; pass `onSinkError` for custom handling (e.g. a metric).
***
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
import { http } from "@mantlejs/http";
import { auth, authorizeAgent } from "@mantlejs/auth";
import { auditLog } from "@mantlejs/audit";
import { MemoryRepository } from "@mantlejs/memory";
import type { AuditRecord } from "@mantlejs/audit";
const app = mantle()
.configure(http())
.configure(auth({ secret: process.env.JWT_SECRET! }));
const auditRepository = new MemoryRepository(); // swap for a real adapter in production
const audit = auditLog({ sink: auditRepository });
app.use("documents", new DocumentService(new DocumentRepository(app)));
app.service("documents").hooks({
before: { all: [authorizeAgent()] }, // optional — only if agents may call this route
after: { all: [audit] },
error: { all: [audit] },
});
app.listen(3030);
```
***
## API
[Section titled “API”](#api)
### `auditLog(options)`
[Section titled “auditLog(options)”](#auditlogoptions)
Returns a `HookFunction`. Register it in both `after.all` and `error.all` on the service(s) to audit.
```typescript
auditLog({
sink: auditRepository, // required — any Repository
onSinkError: (error, record, ctx) => {
// optional — called instead of the default app.get("logger") fallback
},
});
```
#### `AuditLogOptions`
[Section titled “AuditLogOptions”](#auditlogoptions-1)
| Field | Type | Default | Description |
| ------------- | ------------------------------ | ------------------------------------ | ------------------------------------------------------------------------ |
| `sink` | `Repository` | — | Where audit records are written (required) |
| `onSinkError` | `(error, record, ctx) => void` | logs via `app.get("logger")` | Called when `sink.save()` throws. The primary operation is never failed. |
### `AuditRecord`
[Section titled “AuditRecord”](#auditrecord)
See [What’s in a record](#whats-in-a-record-and-whats-deliberately-left-out) above for the full field-by-field breakdown.
***
## Types
[Section titled “Types”](#types)
```typescript
import type { AuditLogOptions, AuditRecord } from "@mantlejs/audit";
```
| Type | Description |
| ----------------- | ------------------------------------------------------ |
| `AuditLogOptions` | Options passed to `auditLog()` |
| `AuditRecord` | The shape of one recorded entry, and of the sink’s `T` |
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build audit # compile
npx nx test audit # run tests
npx nx lint audit # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
Build before publishing:
```bash
npx nx build audit
```
First publish (scoped packages require `--access public`):
```bash
cd packages/audit
npm publish --access public
```
Subsequent releases — bump `version` in `packages/audit/package.json`, then:
```bash
cd packages/audit
npm publish
```
### Testing locally with Verdaccio
[Section titled “Testing locally with Verdaccio”](#testing-locally-with-verdaccio)
```bash
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/audit
npm publish --registry http://localhost:4873
```
# @mantlejs/auth
> JWT authentication engine for Mantle JS. Provides token issuance, verification, a strategy runner, and hook utilities. Designed to be paired with a strategy package such as @mantlejs/auth-local.
JWT authentication engine for [Mantle JS](https://github.com/mantlejs/mantle). Provides token issuance, verification, a strategy runner, and hook utilities. Designed to be paired with a strategy package such as [`@mantlejs/auth-local`](/packages/auth-local/).
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/auth jsonwebtoken
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### The authentication engine
[Section titled “The authentication engine”](#the-authentication-engine)
`auth()` is a Mantle plugin. It registers an `AuthEngine` on the application and mounts a built-in `authentication` service at `POST /authentication`. The engine is the single source of truth for JWT configuration and strategy dispatch — strategy packages (like `auth-local`) register themselves into it.
### Strategies
[Section titled “Strategies”](#strategies)
A strategy is an object with a `name` and an `authenticate(data, params)` method. The engine calls the matching strategy when `POST /authentication` arrives with a `strategy` field in the body. Strategy packages call `engine.registerStrategy(strategy)` during their own plugin registration.
### Hooks
[Section titled “Hooks”](#hooks)
`authenticate('jwt')` is a `before` hook that reads `Authorization: Bearer ` from the request headers, verifies the token, and writes the decoded payload to `params.user`.
`authorizeAgent()` is a `before` hook for capability-scoped agent tokens (see [Agent tokens](#agent-tokens) below) — opt-in per route, and never a substitute for `authenticate('jwt')`.
`sanitizeUser()` is an `after` hook that strips sensitive fields (password, passwordHash) from service results before they leave the server.
### Internal calls
[Section titled “Internal calls”](#internal-calls)
Hooks registered with `authenticate('jwt')` automatically skip when the call has no `provider` (i.e. calls that originate within the server rather than from HTTP). This lets auth strategies query user services without tripping their own guards.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { auth, authenticate, sanitizeUser } from "@mantlejs/auth";
import { localStrategy, hashPassword } from "@mantlejs/auth-local";
const app = mantle()
.configure(express())
.configure(auth({ secret: process.env.JWT_SECRET! }))
.configure(localStrategy());
app.use("/users", new UserService(new UserRepository(app)), {
methods: ["find", "get", "create", "patch", "remove"],
});
app.service("users").hooks({
before: {
create: [hashPassword()],
find: [authenticate("jwt")],
get: [authenticate("jwt")],
patch: [authenticate("jwt")],
remove: [authenticate("jwt")],
},
after: {
all: [sanitizeUser()],
},
});
app.listen(3030);
```
**Register a user**
```http
POST /users
Content-Type: application/json
{ "email": "alice@example.com", "password": "hunter2" }
```
**Log in**
```http
POST /authentication
Content-Type: application/json
{ "strategy": "local", "email": "alice@example.com", "password": "hunter2" }
```
```json
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"user": { "id": 1, "email": "alice@example.com" }
}
```
**Call a protected endpoint**
```http
GET /users
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
```
***
## API
[Section titled “API”](#api)
### `auth(config)`
[Section titled “auth(config)”](#authconfig)
Returns a `MantlePlugin`. Call via `app.configure(auth(config))`.
```typescript
app.configure(
auth({
secret: process.env.JWT_SECRET!, // required — signing key
expiresIn: "7d", // optional — default "1d"
algorithms: ["HS256"], // optional — default ["HS256"]
issuer: "my-api", // optional
audience: "my-client", // optional
}),
);
```
Side effects:
* Stores the `AuthEngine` at `app.get("auth")`
* Registers `POST /authentication` via `app.use("authentication", ...)`
#### `AuthConfig`
[Section titled “AuthConfig”](#authconfig-1)
| Field | Type | Default | Description |
| ------------------- | -------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `secret` | `string` | — | JWT signing secret (required) |
| `expiresIn` | `string \| number` | `"1d"` | Access-token lifetime. Strings use [ms](https://github.com/vercel/ms) format (`"2h"`, `"7d"`). Numbers are seconds. |
| `algorithms` | `string[]` | `["HS256"]` | Verification algorithms |
| `issuer` | `string` | — | Sets and verifies the `iss` claim |
| `audience` | `string \| string[]` | — | Sets and verifies the `aud` claim |
| `refreshExpiresIn` | `string \| number` | `"30d"` | Refresh-token lifetime |
| `refreshTokenStore` | `RefreshTokenStore` | in-memory | Storage for outstanding refresh tokens. **Multi-instance deployments (Cloud Run) must inject a shared store** — the in-memory default cannot revoke tokens issued by another instance. Use [`redisRefreshTokenStore` from `@mantlejs/auth-redis`](/packages/auth-redis/). |
| `agentTokenStore` | `AgentTokenStore` | in-memory | Storage for outstanding agent tokens, enabling `revokeAgentToken()` before JWT expiry. Same multi-instance caveat as `refreshTokenStore`. |
***
### `authenticate(strategy)`
[Section titled “authenticate(strategy)”](#authenticatestrategy)
A `before` hook factory. Returns a hook that authenticates the incoming request.
```typescript
app.service("messages").hooks({
before: {
all: [authenticate("jwt")],
},
});
```
**`authenticate("jwt")`**
Reads `Authorization: Bearer ` from `params.headers`. On success writes the decoded JWT payload to `params.user`. On failure throws `NotAuthenticated`.
Silently skips (passes through) when `params.provider` is undefined — internal service calls are trusted.
**Resolving the full user record — `authenticate("jwt", { entity })`**
By default `params.user` is just the raw JWT payload (`{ sub, iat, exp, ... }`). Pass `entity` to resolve the actual user record instead:
```typescript
app.service("messages").hooks({
before: {
all: [authenticate("jwt", { entity: "users" })],
},
});
```
With `entity` set, the hook calls `app.service(entity).get(payload.sub)` internally (no `provider`, so the target service’s own auth guards are bypassed for this lookup) and writes the result to `params.user`. The raw JWT payload moves to `params.authPayload` instead. If the subject no longer resolves to a user (e.g. deleted after the token was issued), the hook throws `NotAuthenticated("User for this token no longer exists")`.
**`authenticate("custom")`**
For non-JWT strategies: delegates to `engine.authenticate(strategyName, context.data, context.params)` and writes the result to `params.user`. Skips for internal calls.
***
### `authorizeAgent()`
[Section titled “authorizeAgent()”](#authorizeagent)
A `before` hook factory. Returns a hook that authorizes a capability-scoped agent token — see [Agent tokens](#agent-tokens) for issuance and revocation.
```typescript
app.service("articles").hooks({
before: { all: [authorizeAgent()] },
});
```
Reads `Authorization: Bearer ` from `params.headers`, verifies it, and checks it’s an unrevoked `type: "agent"` token whose `scope` covers `HookContext.path`+`HookContext.method` (`@mantlejs/mantle`’s `matchesCapabilityScope` — the same deny-by-default matcher `@mantlejs/mcp`’s expose map uses). On success sets `HookContext.agent = { id, scope, delegatingUserId }`, additive to `params.user`. Throws `Forbidden` for an out-of-scope call, `NotAuthenticated` for anything else (missing/expired/malformed/non-agent/revoked token, or auth not configured).
Silently skips (passes through) when `params.provider` is undefined — internal service calls are trusted, same convention as `authenticate("jwt")`.
Opt-in per route: `authenticate("jwt")` rejects a `type: "agent"` payload outright, so a route with no `authorizeAgent()` hook attached can never be reached with an agent token.
***
### `sanitizeUser(fields?)`
[Section titled “sanitizeUser(fields?)”](#sanitizeuserfields)
An `after` hook that removes sensitive fields from the service result. Works with single objects, arrays, and paginated results.
```typescript
app.service("users").hooks({
after: {
all: [sanitizeUser()], // removes password, passwordHash, password_hash
get: [sanitizeUser(["secret", "apiKey"])], // custom field list
},
});
```
Default fields removed: `"password"`, `"passwordHash"`, `"password_hash"`.
***
### `AuthEngine` (advanced)
[Section titled “AuthEngine (advanced)”](#authengine-advanced)
Accessed via `app.get("auth")`. Useful when writing custom strategy packages.
```typescript
const engine = app.get("auth");
// Create a token
const token = engine.createJwt({ sub: String(user.id), role: "admin" });
// Verify a token
const payload = engine.verifyJwt(token); // throws if invalid
// Register a custom strategy
engine.registerStrategy({
name: "magic-link",
async authenticate(data, params) {
// verify magic link token, find user, issue JWT...
return { accessToken: engine.createJwt({ sub: userId }) };
},
});
```
***
### `POST /authentication`
[Section titled “POST /authentication”](#post-authentication)
The built-in authentication endpoint. Dispatches to a registered strategy based on the `strategy` field.
**Request**
```json
{
"strategy": "local",
"email": "alice@example.com",
"password": "hunter2"
}
```
**Response** (`201 Created`)
```json
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"user": { "id": 1, "email": "alice@example.com" }
}
```
**Errors**
| Status | Condition |
| ----------------------- | ---------------------------------------------------------------- |
| `400 Bad Request` | `strategy` field missing |
| `400 Bad Request` | Strategy name not registered |
| `401 Not Authenticated` | Strategy-specific failure (wrong password, user not found, etc.) |
***
### Refresh tokens
[Section titled “Refresh tokens”](#refresh-tokens)
Every login (local or OAuth) returns an access + refresh token pair issued by `engine.createTokenPair(sub)`. The refresh token carries `{ sub, type: "refresh", jti }`, lives `refreshExpiresIn` (default 30 days), and its `jti` is recorded in the `RefreshTokenStore`.
Exchange it for a fresh pair via the built-in `refresh` strategy — same endpoint, no extra route:
```json
POST /authentication
{ "strategy": "refresh", "refreshToken": "eyJhbGciOiJIUzI1NiJ9..." }
```
**Response** (`201 Created`): `{ "accessToken": "...", "refreshToken": "..." }` — no `user` field; a refresh proves possession of a token, not fresh credentials.
Refresh tokens **rotate**: each successful exchange consumes the submitted token and issues a new one. Replaying an already-consumed token is treated as theft — **all** outstanding refresh tokens for that subject are revoked and the request fails with `401 NotAuthenticated("Refresh token reuse detected")`. Expired tokens, access tokens submitted as refresh tokens, and unknown signatures all fail with `401 NotAuthenticated("Invalid refresh token")`.
Custom strategies should issue tokens through `engine.createTokenPair(sub, accessExtra?)` rather than `createJwt` directly, so their refresh tokens participate in rotation and revocation.
***
### Agent tokens
[Section titled “Agent tokens”](#agent-tokens)
Short-lived, capability-scoped tokens for AI-agent callers, delegated from a user rather than representing one. Distinct from user JWTs and refresh tokens — `authenticate("jwt")` rejects an agent token outright, and `authorizeAgent()` rejects anything that isn’t one.
**Issuing a token** — typically from your own route/service, after checking the delegating user is allowed to grant the requested scope:
```typescript
const engine = app.get("auth");
const issued = await engine.issueAgentToken(
{ articles: ["find", "get"], comments: true }, // CapabilityScope: methods per path, or `true` for all
user.id, // delegatingUserId
{ expiresIn: "15m" }, // optional, default "15m"
);
// issued: { accessToken, id, expiresAt }
```
**Authorizing agent calls** — attach `authorizeAgent()` to any route an agent may call. It’s opt-in per route: a route with no `authorizeAgent()` hook can never be reached with an agent token, no matter its scope, because `authenticate("jwt")` refuses `type: "agent"` payloads.
```typescript
app.service("articles").hooks({
before: { all: [authorizeAgent()] },
});
```
On success it sets `HookContext.agent = { id, scope, delegatingUserId }` — additive, `params.user` is untouched. On an out-of-scope `path`+`method` it throws `Forbidden`; on a missing, expired, malformed, non-agent, or revoked token it throws `NotAuthenticated`.
**Revoking a token** before its natural expiry:
```typescript
await engine.revokeAgentToken(issued.id);
```
Revocation is tracked in the `AgentTokenStore` (default: in-memory). **Multi-instance deployments must inject a shared store** — same rationale as `refreshTokenStore` above.
***
## Types
[Section titled “Types”](#types)
```typescript
import type {
AgentPrincipal,
AgentTokenOptions,
AgentTokenStore,
AuthConfig,
AuthEngine,
AuthResult,
AuthStrategy,
IssuedAgentToken,
JwtPayload,
RefreshTokenStore,
TokenPair,
} from "@mantlejs/auth";
```
| Type | Description |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `AuthConfig` | Options passed to `auth()` |
| `AuthEngine` | The engine stored at `app.get("auth")` |
| `AuthResult` | `{ accessToken: string; [key: string]: unknown }` — returned by strategies |
| `AuthStrategy` | Interface to implement for custom strategies |
| `JwtPayload` | Decoded JWT payload shape |
| `RefreshTokenStore` | `add(jti, sub, exp)` / `consume(jti)` / `revokeAll(sub)` — inject a shared implementation (e.g. `@mantlejs/auth-redis`) for multi-instance deployments |
| `TokenPair` | `{ accessToken, refreshToken }` — returned by `engine.createTokenPair()` |
| `AgentPrincipal` | `{ id, scope: CapabilityScope, delegatingUserId }` — an agent token’s decoded shape, and `HookContext.agent`’s type once `authorizeAgent()` accepts it |
| `AgentTokenOptions` | `{ expiresIn? }` — passed to `engine.issueAgentToken()` |
| `AgentTokenStore` | `add(id, exp)` / `isValid(id)` / `revoke(id)` — inject a shared implementation for multi-instance deployments |
| `IssuedAgentToken` | `{ accessToken, id, expiresAt }` — returned by `engine.issueAgentToken()` |
***
## Writing a custom strategy
[Section titled “Writing a custom strategy”](#writing-a-custom-strategy)
```typescript
import type { MantlePlugin } from "@mantlejs/mantle";
import type { AuthEngine, AuthStrategy } from "@mantlejs/auth";
export function magicLinkStrategy(): MantlePlugin {
return (app) => {
const engine = app.get("auth");
if (!engine) throw new Error("@mantlejs/auth must be configured first");
const strategy: AuthStrategy = {
name: "magic-link",
async authenticate(data) {
// 1. Validate data.token against your store
// 2. Look up the user
// 3. Return the JWT
const accessToken = engine.createJwt({ sub: String(userId) });
return { accessToken, user };
},
};
engine.registerStrategy(strategy);
};
}
```
Register it alongside your other plugins:
```typescript
app.configure(auth({ secret: process.env.JWT_SECRET! })).configure(magicLinkStrategy());
```
***
## Error reference
[Section titled “Error reference”](#error-reference)
All errors extend `MantleError` and serialize to JSON automatically.
| Error | Code | When thrown |
| ------------------ | ---- | ---------------------------------------------------- |
| `NotAuthenticated` | 401 | Missing/invalid/expired Bearer token |
| `BadRequest` | 400 | `strategy` field missing from `POST /authentication` |
| `BadRequest` | 400 | Named strategy not registered |
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build auth # compile
npx nx test auth # run tests
npx nx lint auth # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
Build before publishing:
```bash
npx nx build auth
```
First publish (scoped packages require `--access public`):
```bash
cd packages/auth
npm publish --access public
```
Subsequent releases — bump `version` in `packages/auth/package.json`, then:
```bash
cd packages/auth
npm publish
```
### Testing locally with Verdaccio
[Section titled “Testing locally with Verdaccio”](#testing-locally-with-verdaccio)
```bash
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/auth
npm publish --registry http://localhost:4873
# Install from it in another project
npm install @mantlejs/auth --registry http://localhost:4873
```
# @mantlejs/auth-apple
> Sign in with Apple strategy for Mantle JS. Implements the authorization code flow with responsemode=formpost — no Passport.js dependency. Registers GET /auth/apple and POST /auth/apple/callback on the HTTP transport, then finds or creates a user record and returns a Mantle JWT pair.
Sign in with Apple strategy for [Mantle JS](https://github.com/mantlejs/mantle). Implements the authorization code flow with `response_mode=form_post` — no Passport.js dependency. Registers `GET /auth/apple` and `POST /auth/apple/callback` on the HTTP transport, then finds or creates a user record and returns a Mantle JWT pair.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/auth-apple
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### No static client secret
[Section titled “No static client secret”](#no-static-client-secret)
Apple does not issue a client secret. Instead, every token exchange is authenticated with a short-lived ES256 JWT signed from your Sign in with Apple private key (the `.p8` file). [Arctic](https://arcticjs.dev) signs this JWT per exchange (see [ADR-002](https://github.com/MantleJs/mantle/blob/main/docs/decisions/adr-002-arctic-oauth-internals.md)) — you configure `teamId`, `keyId`, and `privateKey` instead of a `clientSecret`.
### POST callback
[Section titled “POST callback”](#post-callback)
Whenever the `name`/`email` scopes are requested, Apple requires `response_mode=form_post`: the callback arrives as an `application/x-www-form-urlencoded` **POST** rather than a GET redirect. CSRF protection is the `state` round-trip through the `OAuthStateStore` (Apple does not support PKCE for this flow).
### Profile from the id_token
[Section titled “Profile from the id_token”](#profile-from-the-id_token)
Apple has no userinfo endpoint. The profile comes from the id_token returned by the token exchange: `sub` becomes the user ID, `email` the email. The id_token is obtained directly from Apple’s token endpoint over TLS, so its payload is decoded without a JWKS round-trip.
### First-login name capture
[Section titled “First-login name capture”](#first-login-name-capture)
Apple sends the user’s name in a `user` form field on the **first** authorization only. The plugin reads it into the profile, and find-or-create persists it when the user record is created — subsequent logins without the field lose nothing.
### Find-or-create
[Section titled “Find-or-create”](#find-or-create)
On callback the plugin searches the configured user service for a record where `appleId` (configurable) matches the id_token’s `sub` claim. If no record is found, it creates one with `{ appleId, email, name }` from the profile. The same user is returned on every subsequent sign-in.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { auth } from "@mantlejs/auth";
import { appleStrategy } from "@mantlejs/auth-apple";
const app = mantle()
.configure(express())
.configure(auth({ secret: process.env.JWT_SECRET! }))
.configure(
appleStrategy({
clientId: process.env.APPLE_CLIENT_ID!, // Services ID, e.g. "com.example.app.web"
teamId: process.env.APPLE_TEAM_ID!,
keyId: process.env.APPLE_KEY_ID!,
privateKey: process.env.APPLE_PRIVATE_KEY!, // PEM contents of the .p8 key
}),
);
app.listen(3030);
```
**Sign-in flow:**
1. Redirect the browser to `GET /auth/apple`
2. User authenticates with Apple and consents
3. Apple POSTs `code`, `state` (and `user` on first login) to `/auth/apple/callback`
4. The plugin exchanges the code, decodes the id_token, finds or creates the user, and responds:
```json
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"user": { "id": "1", "appleId": "001234.abc...", "email": "alice@icloud.com", "name": "Alice Doe" }
}
```
***
## API
[Section titled “API”](#api)
### `appleStrategy(config)`
[Section titled “appleStrategy(config)”](#applestrategyconfig)
```typescript
function appleStrategy(config: AppleStrategyConfig): MantlePlugin;
type AppleStrategyConfig = {
clientId: string; // Services ID (the OAuth client_id)
teamId: string; // Apple Developer Team ID
keyId: string; // Key ID of the Sign in with Apple private key
privateKey: string; // PKCS#8 PEM contents of the .p8 key
callbackUrl?: string; // Default: '/auth/apple/callback'
scope?: string[]; // Default: ['name', 'email']
entity?: string; // Default: 'users'
entityIdField?: string; // Default: 'appleId'
};
```
| Field | Type | Default | Description |
| --------------- | ---------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clientId` | `string` | — | Services ID from the Apple Developer portal (required) |
| `teamId` | `string` | — | Apple Developer Team ID (required) |
| `keyId` | `string` | — | Key ID of the Sign in with Apple key (required) |
| `privateKey` | `string` | — | PEM contents of the downloaded `.p8` private key (required) |
| `callbackUrl` | `string` | `'/auth/apple/callback'` | Callback path — must match the Return URL registered with Apple |
| `scope` | `string[]` | `['name', 'email']` | Sign in with Apple scopes |
| `entity` | `string` | `'users'` | Mantle service used to find or create users |
| `entityIdField` | `string` | `'appleId'` | Field on the user record matched against the id_token’s `sub` claim |
| `redirectUrl` | `string` | none — returns JSON | Frontend URL to redirect to on completion, with tokens (or an error) in the URL fragment — see [`@mantlejs/auth-oauth`](/packages/auth-oauth/#redirecting-back-to-a-frontend-redirecturl) |
**Routes registered:**
| Method | Path | Description |
| ------ | ---------------------- | ---------------------------------------------------- |
| `GET` | `/auth/apple` | Redirect to Apple’s sign-in page |
| `POST` | `/auth/apple/callback` | Handle the form_post callback, issue Mantle JWT pair |
**Must be configured after** an HTTP transport and `auth()`.
***
## Types
[Section titled “Types”](#types)
```typescript
import type { AppleStrategyConfig } from "@mantlejs/auth-apple";
```
`AppleStrategyConfig` extends `OAuthPluginConfig` from `@mantlejs/auth-oauth`, replacing `clientSecret` with `teamId`/`keyId`/`privateKey`.
***
## Apple Developer setup
[Section titled “Apple Developer setup”](#apple-developer-setup)
1. In [developer.apple.com](https://developer.apple.com/account), create an **App ID** with the Sign in with Apple capability
2. Create a **Services ID** (this is your `clientId`) and enable Sign in with Apple for it
3. Register your domain and Return URL: `https://your-domain.com/auth/apple/callback` (Apple requires HTTPS — no localhost)
4. Create a **Sign in with Apple key**, download the `.p8` file (one-time download), and note its **Key ID**
5. Find your **Team ID** on the membership page
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build auth-apple # compile
npx nx test auth-apple # run tests
npx nx lint auth-apple # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
```bash
npx nx build auth-apple
cd packages/auth-apple
npm publish --access public
```
# @mantlejs/auth-facebook
> Facebook OAuth 2.0 strategy for Mantle JS. Implements the authorization code flow — no Passport.js dependency. Registers GET /auth/facebook and GET /auth/facebook/callback on the configured HTTP transport (@mantlejs/express, @mantlejs/koa, or @mantlejs/http), then finds or creates a user record and returns a Mantle JWT pair.
Facebook OAuth 2.0 strategy for [Mantle JS](https://github.com/mantlejs/mantle). Implements the authorization code flow — no Passport.js dependency. Registers `GET /auth/facebook` and `GET /auth/facebook/callback` on the configured HTTP transport (`@mantlejs/express`, `@mantlejs/koa`, or `@mantlejs/http`), then finds or creates a user record and returns a Mantle JWT pair.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/auth-facebook
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### Authorization code flow
[Section titled “Authorization code flow”](#authorization-code-flow)
Facebook Sign-In uses the standard OAuth 2.0 authorization code flow (without PKCE — Facebook does not require it for server-side apps). On redirect the plugin builds the authorization URL with a random `state` token to prevent CSRF. The state is verified on callback before the code is exchanged. URL construction and token exchange go through [Arctic](https://arcticjs.dev), which pins the Graph API version (see [ADR-002](https://github.com/MantleJs/mantle/blob/main/docs/decisions/adr-002-arctic-oauth-internals.md)).
### Find-or-create
[Section titled “Find-or-create”](#find-or-create)
On callback the plugin searches the configured user service for a record where `facebookId` (configurable) matches the `id` from Facebook’s Graph API `/me` endpoint. If no record is found, it creates one with `{ facebookId, email, name }` from the profile. The same user is returned on every subsequent sign-in.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { auth } from "@mantlejs/auth";
import { facebookStrategy } from "@mantlejs/auth-facebook";
const app = mantle()
.configure(express())
.configure(auth({ secret: process.env.JWT_SECRET! }))
.configure(
facebookStrategy({
clientId: process.env.FACEBOOK_APP_ID!,
clientSecret: process.env.FACEBOOK_APP_SECRET!,
}),
);
app.listen(3030);
```
**Sign-in flow:**
1. Redirect the browser to `GET /auth/facebook`
2. User authenticates on Facebook and consents
3. Facebook redirects to `GET /auth/facebook/callback?code=...&state=...`
4. The plugin exchanges the code, fetches the profile, finds or creates the user, and responds:
```json
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"user": { "id": "1", "facebookId": "123456789", "email": "alice@example.com", "name": "Alice" }
}
```
***
## API
[Section titled “API”](#api)
### `facebookStrategy(config)`
[Section titled “facebookStrategy(config)”](#facebookstrategyconfig)
```typescript
function facebookStrategy(config: FacebookStrategyConfig): MantlePlugin;
type FacebookStrategyConfig = {
clientId: string;
clientSecret: string;
callbackUrl?: string; // Default: '/auth/facebook/callback'
scope?: string[]; // Default: ['email', 'public_profile']
entity?: string; // Default: 'users'
entityIdField?: string; // Default: 'facebookId'
};
```
| Field | Type | Default | Description |
| --------------- | ---------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clientId` | `string` | — | Facebook App ID (required) |
| `clientSecret` | `string` | — | Facebook App Secret (required) |
| `callbackUrl` | `string` | `'/auth/facebook/callback'` | Callback path — must match the redirect URI registered in the Facebook Developer Console |
| `scope` | `string[]` | `['email', 'public_profile']` | Facebook OAuth permission scopes |
| `entity` | `string` | `'users'` | Mantle service used to find or create users |
| `entityIdField` | `string` | `'facebookId'` | Field on the user record matched against Facebook’s `id` field |
| `redirectUrl` | `string` | none — returns JSON | Frontend URL to redirect to on completion, with tokens (or an error) in the URL fragment — see [`@mantlejs/auth-oauth`](/packages/auth-oauth/#redirecting-back-to-a-frontend-redirecturl) |
**Routes registered:**
| Method | Path | Description |
| ------ | ------------------------- | -------------------------------------- |
| `GET` | `/auth/facebook` | Redirect to Facebook consent screen |
| `GET` | `/auth/facebook/callback` | Handle callback, issue Mantle JWT pair |
**Must be configured after** an HTTP transport (`@mantlejs/express`, `@mantlejs/koa`, or `@mantlejs/http`) and `auth()`.
***
## Types
[Section titled “Types”](#types)
```typescript
import type { FacebookStrategyConfig } from "@mantlejs/auth-facebook";
```
`FacebookStrategyConfig` is an alias for `OAuthPluginConfig` from `@mantlejs/auth-oauth`.
***
## Facebook Developer Console setup
[Section titled “Facebook Developer Console setup”](#facebook-developer-console-setup)
1. Go to [developers.facebook.com](https://developers.facebook.com) and create an app
2. Add the **Facebook Login** product to your app
3. Under **Facebook Login > Settings**, add the callback URL to **Valid OAuth Redirect URIs**: `https://your-domain.com/auth/facebook/callback`
4. Copy the **App ID** and **App Secret** from **Settings > Basic** into your environment
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build auth-facebook # compile
npx nx test auth-facebook # run tests
npx nx lint auth-facebook # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
```bash
npx nx build auth-facebook
cd packages/auth-facebook
npm publish --access public
```
# @mantlejs/auth-github
> GitHub OAuth 2.0 strategy for Mantle JS. Implements the authorization code flow — no Passport.js dependency. Registers GET /auth/github and GET /auth/github/callback on the configured HTTP transport (@mantlejs/express, @mantlejs/koa, or @mantlejs/http), then finds or creates a user record and returns a Mantle JWT pair.
GitHub OAuth 2.0 strategy for [Mantle JS](https://github.com/mantlejs/mantle). Implements the authorization code flow — no Passport.js dependency. Registers `GET /auth/github` and `GET /auth/github/callback` on the configured HTTP transport (`@mantlejs/express`, `@mantlejs/koa`, or `@mantlejs/http`), then finds or creates a user record and returns a Mantle JWT pair.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/auth-github
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### Authorization code flow
[Section titled “Authorization code flow”](#authorization-code-flow)
GitHub Sign-In uses the standard authorization code flow (without PKCE — GitHub does not support it for server-side apps). On each redirect request the plugin generates a random `state` token stored server-side for CSRF protection and passed through the OAuth exchange. URL construction and token exchange go through [Arctic](https://arcticjs.dev) (see [ADR-002](https://github.com/MantleJs/mantle/blob/main/docs/decisions/adr-002-arctic-oauth-internals.md)); the token request authenticates with HTTP Basic credentials.
### Find-or-create
[Section titled “Find-or-create”](#find-or-create)
On callback the plugin searches the configured user service for a record where `githubId` (configurable) matches the `id` field from GitHub’s `/user` endpoint. If no record is found, it creates one with `{ githubId, email, name }` from the profile. The same user is returned on every subsequent sign-in.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { auth } from "@mantlejs/auth";
import { githubStrategy } from "@mantlejs/auth-github";
const app = mantle()
.configure(express())
.configure(auth({ secret: process.env.JWT_SECRET! }))
.configure(
githubStrategy({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
);
app.listen(3030);
```
**Sign-in flow:**
1. Redirect the browser to `GET /auth/github`
2. User authenticates on GitHub and consents
3. GitHub redirects to `GET /auth/github/callback?code=...&state=...`
4. The plugin exchanges the code, fetches the profile, finds or creates the user, and responds:
```json
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"user": { "id": "1", "githubId": "12345", "email": "alice@github.com", "name": "Alice" }
}
```
***
## API
[Section titled “API”](#api)
### `githubStrategy(config)`
[Section titled “githubStrategy(config)”](#githubstrategyconfig)
```typescript
function githubStrategy(config: GitHubStrategyConfig): MantlePlugin;
type GitHubStrategyConfig = {
clientId: string;
clientSecret: string;
callbackUrl?: string; // Default: '/auth/github/callback'
scope?: string[]; // Default: ['read:user', 'user:email']
entity?: string; // Default: 'users'
entityIdField?: string; // Default: 'githubId'
};
```
| Field | Type | Default | Description |
| --------------- | ---------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clientId` | `string` | — | GitHub OAuth App client ID (required) |
| `clientSecret` | `string` | — | GitHub OAuth App client secret (required) |
| `callbackUrl` | `string` | `'/auth/github/callback'` | Callback path — must match the Authorization callback URL registered in your GitHub OAuth App |
| `scope` | `string[]` | `['read:user', 'user:email']` | GitHub OAuth scopes |
| `entity` | `string` | `'users'` | Mantle service used to find or create users |
| `entityIdField` | `string` | `'githubId'` | Field on the user record matched against GitHub’s `id` value |
| `redirectUrl` | `string` | none — returns JSON | Frontend URL to redirect to on completion, with tokens (or an error) in the URL fragment — see [`@mantlejs/auth-oauth`](/packages/auth-oauth/#redirecting-back-to-a-frontend-redirecturl) |
**Routes registered:**
| Method | Path | Description |
| ------ | ----------------------- | -------------------------------------- |
| `GET` | `/auth/github` | Redirect to GitHub consent screen |
| `GET` | `/auth/github/callback` | Handle callback, issue Mantle JWT pair |
**Must be configured after** an HTTP transport (`@mantlejs/express`, `@mantlejs/koa`, or `@mantlejs/http`) and `auth()`.
***
## Types
[Section titled “Types”](#types)
```typescript
import type { GitHubStrategyConfig } from "@mantlejs/auth-github";
```
`GitHubStrategyConfig` is an alias for `OAuthPluginConfig` from `@mantlejs/auth-oauth`.
***
## GitHub OAuth App setup
[Section titled “GitHub OAuth App setup”](#github-oauth-app-setup)
1. Go to **Settings → Developer settings → OAuth Apps** at [github.com](https://github.com)
2. Click **New OAuth App**
3. Set the **Authorization callback URL** to `https://your-domain.com/auth/github/callback`
4. Copy the **Client ID** and generate a **Client Secret**
5. Add both to your environment variables
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build auth-github # compile
npx nx test auth-github # run tests
npx nx lint auth-github # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
```bash
npx nx build auth-github
cd packages/auth-github
npm publish --access public
```
# @mantlejs/auth-google
> Google OAuth 2.0 strategy for Mantle JS. Implements the authorization code flow with PKCE — no Passport.js dependency. Registers GET /auth/google and GET /auth/google/callback on the configured HTTP transport (@mantlejs/express, @mantlejs/koa, or @mantlejs/http), then finds or creates a user record and returns a Mantle JWT pair.
Google OAuth 2.0 strategy for [Mantle JS](https://github.com/mantlejs/mantle). Implements the authorization code flow with PKCE — no Passport.js dependency. Registers `GET /auth/google` and `GET /auth/google/callback` on the configured HTTP transport (`@mantlejs/express`, `@mantlejs/koa`, or `@mantlejs/http`), then finds or creates a user record and returns a Mantle JWT pair.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/auth-google
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### PKCE
[Section titled “PKCE”](#pkce)
Google Sign-In uses the authorization code flow with PKCE (Proof Key for Code Exchange). On each redirect request the plugin generates a fresh `code_verifier` (via [Arctic](https://arcticjs.dev), which also derives the SHA-256 / base64url `code_challenge` — see [ADR-002](https://github.com/MantleJs/mantle/blob/main/docs/decisions/adr-002-arctic-oauth-internals.md)). The verifier is stored server-side against a random `state` token and passed to the token exchange on callback.
### Find-or-create
[Section titled “Find-or-create”](#find-or-create)
On callback the plugin searches the configured user service for a record where `googleId` (configurable) matches the `sub` claim from Google’s userinfo endpoint. If no record is found, it creates one with `{ googleId, email, name }` from the profile. The same user is returned on every subsequent sign-in.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { auth } from "@mantlejs/auth";
import { googleStrategy } from "@mantlejs/auth-google";
const app = mantle()
.configure(express())
.configure(auth({ secret: process.env.JWT_SECRET! }))
.configure(googleStrategy({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}));
app.listen(3030);
```
**Sign-in flow:**
1. Redirect the browser to `GET /auth/google`
2. User authenticates on Google and consents
3. Google redirects to `GET /auth/google/callback?code=...&state=...`
4. The plugin exchanges the code, fetches the profile, finds or creates the user, and responds:
```json
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"user": { "id": "1", "googleId": "108...", "email": "alice@gmail.com", "name": "Alice" }
}
```
***
## API
[Section titled “API”](#api)
### `googleStrategy(config)`
[Section titled “googleStrategy(config)”](#googlestrategyconfig)
```typescript
function googleStrategy(config: GoogleStrategyConfig): MantlePlugin;
type GoogleStrategyConfig = {
clientId: string;
clientSecret: string;
callbackUrl?: string; // Default: '/auth/google/callback'
scope?: string[]; // Default: ['openid', 'profile', 'email']
entity?: string; // Default: 'users'
entityIdField?: string; // Default: 'googleId'
};
```
| Field | Type | Default | Description |
| --------------- | ---------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clientId` | `string` | — | Google OAuth client ID (required) |
| `clientSecret` | `string` | — | Google OAuth client secret (required) |
| `callbackUrl` | `string` | `'/auth/google/callback'` | Callback path — must match the redirect URI registered in Google Cloud Console |
| `scope` | `string[]` | `['openid', 'profile', 'email']` | Google OAuth scopes |
| `entity` | `string` | `'users'` | Mantle service used to find or create users |
| `entityIdField` | `string` | `'googleId'` | Field on the user record matched against Google’s `sub` claim |
| `redirectUrl` | `string` | none — returns JSON | Frontend URL to redirect to on completion, with tokens (or an error) in the URL fragment — see [`@mantlejs/auth-oauth`](/packages/auth-oauth/#redirecting-back-to-a-frontend-redirecturl) |
**Routes registered:**
| Method | Path | Description |
| ------ | ----------------------- | -------------------------------------- |
| `GET` | `/auth/google` | Redirect to Google consent screen |
| `GET` | `/auth/google/callback` | Handle callback, issue Mantle JWT pair |
**Must be configured after** an HTTP transport (`@mantlejs/express`, `@mantlejs/koa`, or `@mantlejs/http`) and `auth()`.
***
## Types
[Section titled “Types”](#types)
```typescript
import type { GoogleStrategyConfig } from "@mantlejs/auth-google";
```
`GoogleStrategyConfig` is an alias for `OAuthPluginConfig` from `@mantlejs/auth-oauth`.
***
## Google Cloud Console setup
[Section titled “Google Cloud Console setup”](#google-cloud-console-setup)
No API needs to be enabled — this strategy calls Google’s OpenID Connect userinfo endpoint directly for the `openid`/`profile`/`email` scopes, not the (long since shut down) Google+ API or the People API.
1. Create a project at [console.cloud.google.com](https://console.cloud.google.com)
2. Under **Google Auth Platform → Branding**, configure the app name and support email, then under **Audience** set the publishing status. `openid`, `email`, and `profile` are non-sensitive scopes that don’t require verification. If the app stays in **Testing** mode, add your own Google account under **Audience → Test users** or sign-in will be blocked.
3. Under **Google Auth Platform → Clients**, create a client (application type **Web application**)
4. Add the callback URL to **Authorized redirect URIs**: `https://your-domain.com/auth/google/callback`
5. Copy the **Client ID** and **Client Secret** into your environment
(Google has renamed/reshuffled this console section before and may again — if these labels have drifted, look for “OAuth consent screen”/“Branding” and “Credentials”/“Clients”; the underlying steps are the same.)
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build auth-google # compile
npx nx test auth-google # run tests
npx nx lint auth-google # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
```bash
npx nx build auth-google
cd packages/auth-google
npm publish --access public
```
# @mantlejs/auth-linkedin
> LinkedIn Sign-In (OpenID Connect) strategy for Mantle JS. Implements the authorization code flow — no Passport.js dependency. Registers GET /auth/linkedin and GET /auth/linkedin/callback on the HTTP transport, then finds or creates a user record and returns a Mantle JWT pair.
LinkedIn Sign-In (OpenID Connect) strategy for [Mantle JS](https://github.com/mantlejs/mantle). Implements the authorization code flow — no Passport.js dependency. Registers `GET /auth/linkedin` and `GET /auth/linkedin/callback` on the HTTP transport, then finds or creates a user record and returns a Mantle JWT pair.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/auth-linkedin
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### OpenID Connect
[Section titled “OpenID Connect”](#openid-connect)
This strategy uses LinkedIn’s **“Sign In with LinkedIn using OpenID Connect”** product — the legacy `r_liteprofile`/v2 profile API is not touched. Enable this product on your LinkedIn developer app before using this package.
### No PKCE
[Section titled “No PKCE”](#no-pkce)
Unlike the Google and Microsoft strategies, LinkedIn’s authorization code flow authenticates the token exchange with the client secret only. CSRF protection comes from the `state` round-trip through the configured `OAuthStateStore` — the same posture as the GitHub and Facebook strategies.
### Find-or-create
[Section titled “Find-or-create”](#find-or-create)
On callback the plugin searches the configured user service for a record where `linkedinId` (configurable) matches the `sub` claim from LinkedIn’s OIDC userinfo endpoint. If no record is found, it creates one with `{ linkedinId, email, name }` from the profile. The same user is returned on every subsequent sign-in.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { auth } from "@mantlejs/auth";
import { linkedinStrategy } from "@mantlejs/auth-linkedin";
const app = mantle()
.configure(express())
.configure(auth({ secret: process.env.JWT_SECRET! }))
.configure(
linkedinStrategy({
clientId: process.env.LINKEDIN_CLIENT_ID!,
clientSecret: process.env.LINKEDIN_CLIENT_SECRET!,
}),
);
app.listen(3030);
```
**Sign-in flow:**
1. Redirect the browser to `GET /auth/linkedin`
2. User authenticates on LinkedIn and consents
3. LinkedIn redirects to `GET /auth/linkedin/callback?code=...&state=...`
4. The plugin exchanges the code, fetches the profile, finds or creates the user, and responds:
```json
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"user": { "id": "1", "linkedinId": "AAAA...", "email": "alice@example.com", "name": "Alice" }
}
```
***
## API
[Section titled “API”](#api)
### `linkedinStrategy(config)`
[Section titled “linkedinStrategy(config)”](#linkedinstrategyconfig)
```typescript
function linkedinStrategy(config: LinkedInStrategyConfig): MantlePlugin;
type LinkedInStrategyConfig = OAuthPluginConfig; // no provider-specific fields
```
| Field | Type | Default | Description |
| --------------- | ---------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clientId` | `string` | — | LinkedIn app client ID (required) |
| `clientSecret` | `string` | — | LinkedIn app client secret (required) |
| `callbackUrl` | `string` | `'/auth/linkedin/callback'` | Callback path — must match the redirect URI registered on the LinkedIn app |
| `scope` | `string[]` | `['openid', 'profile', 'email']` | LinkedIn OAuth scopes |
| `entity` | `string` | `'users'` | Mantle service used to find or create users |
| `entityIdField` | `string` | `'linkedinId'` | Field on the user record matched against LinkedIn’s `sub` claim |
| `redirectUrl` | `string` | none — returns JSON | Frontend URL to redirect to on completion, with tokens (or an error) in the URL fragment — see [`@mantlejs/auth-oauth`](/packages/auth-oauth/#redirecting-back-to-a-frontend-redirecturl) |
**Routes registered:**
| Method | Path | Description |
| ------ | ------------------------- | -------------------------------------- |
| `GET` | `/auth/linkedin` | Redirect to LinkedIn sign-in |
| `GET` | `/auth/linkedin/callback` | Handle callback, issue Mantle JWT pair |
**Must be configured after** the transport (e.g. `express()`) and `auth()`.
***
## Types
[Section titled “Types”](#types)
```typescript
import type { LinkedInStrategyConfig } from "@mantlejs/auth-linkedin";
```
`LinkedInStrategyConfig` is `OAuthPluginConfig` from `@mantlejs/auth-oauth` — LinkedIn has no provider-specific config.
***
## LinkedIn developer app setup
[Section titled “LinkedIn developer app setup”](#linkedin-developer-app-setup)
1. Create an app at [linkedin.com/developers/apps](https://www.linkedin.com/developers/apps)
2. Under **Products**, request access to **“Sign In with LinkedIn using OpenID Connect”**
3. Under **Auth**, add an **Authorized redirect URL**: `https://your-domain.com/auth/linkedin/callback`
4. Copy the **Client ID** and **Client Secret** into your environment
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build auth-linkedin # compile
npx nx test auth-linkedin # run tests
npx nx lint auth-linkedin # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
```bash
npx nx build auth-linkedin
cd packages/auth-linkedin
npm publish --access public
```
# @mantlejs/auth-local
> Local (email + password) authentication strategy for Mantle JS. Built on top of @mantlejs/auth. Passwords are hashed with Argon2id via @node-rs/argon2 — the OWASP-recommended algorithm.
Local (email + password) authentication strategy for [Mantle JS](https://github.com/mantlejs/mantle). Built on top of [`@mantlejs/auth`](/packages/auth/). Passwords are hashed with [Argon2id](https://github.com/nicowillis/argon2) via `@node-rs/argon2` — the OWASP-recommended algorithm.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/auth-local @mantlejs/auth @node-rs/argon2 jsonwebtoken
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### `localStrategy()`
[Section titled “localStrategy()”](#localstrategy)
A Mantle plugin that registers a `"local"` strategy with the auth engine. When `POST /authentication` is called with `{ strategy: "local", email, password }`, it:
1. Queries the entity service (default: `"users"`) for a record matching the `usernameField`
2. Verifies the submitted password against the stored Argon2id hash
3. Issues an access + refresh token pair via `engine.createTokenPair(sub)`
4. Returns `{ accessToken, refreshToken, user }`
The lookup is an internal call — it bypasses any `authenticate("jwt")` hooks on the users service.
### `hashPassword()`
[Section titled “hashPassword()”](#hashpassword)
A `before` hook that replaces a plain-text password field with its Argon2id hash. Run it on `create` (and `patch`/`update` if your app allows password changes) before the record reaches the database.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { knex } from "@mantlejs/knex";
import { auth, authenticate, sanitizeUser } from "@mantlejs/auth";
import { localStrategy, hashPassword } from "@mantlejs/auth-local";
const app = mantle()
.configure(express())
.configure(knex({ client: "pg", connection: process.env.DATABASE_URL }))
.configure(auth({ secret: process.env.JWT_SECRET! }))
.configure(localStrategy());
app.use("/users", new UserService(new UserRepository(app)), {
methods: ["find", "get", "create", "patch", "remove"],
});
app.service("users").hooks({
before: {
create: [hashPassword()], // hash on registration
patch: [authenticate("jwt"), hashPassword()], // protect + re-hash on update
find: [authenticate("jwt")],
get: [authenticate("jwt")],
remove: [authenticate("jwt")],
},
after: {
all: [sanitizeUser()], // strip password from all responses
},
});
app.listen(3030);
```
***
## Usage
[Section titled “Usage”](#usage)
### Register a user
[Section titled “Register a user”](#register-a-user)
```http
POST /users
Content-Type: application/json
{ "email": "alice@example.com", "password": "hunter2" }
```
The `hashPassword()` hook runs first, so `"hunter2"` is stored as an Argon2id hash.
```json
{ "id": 1, "email": "alice@example.com" }
```
### Log in
[Section titled “Log in”](#log-in)
```http
POST /authentication
Content-Type: application/json
{ "strategy": "local", "email": "alice@example.com", "password": "hunter2" }
```
```json
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"user": { "id": 1, "email": "alice@example.com" }
}
```
Store `accessToken` client-side and include it in subsequent requests:
```http
GET /users
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
```
***
## API
[Section titled “API”](#api)
### `localStrategy(config?)`
[Section titled “localStrategy(config?)”](#localstrategyconfig)
Returns a `MantlePlugin`. Must be called after `auth()`.
```typescript
app.configure(localStrategy({
usernameField: "email", // field in the request body to match against (default: "email")
passwordField: "password", // field in the request body containing the plain-text password (default: "password")
entityService: "users", // Mantle service name used to look up the entity (default: "users")
}));
```
#### `LocalStrategyConfig`
[Section titled “LocalStrategyConfig”](#localstrategyconfig-1)
| Field | Type | Default | Description |
| --------------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------- |
| `usernameField` | `string` | `"email"` | The field used to look up the user record. Must match the column name in your entity service. |
| `passwordField` | `string` | `"password"` | The field containing the submitted plain-text password. Also used to locate the stored hash in the user record. |
| `entityService` | `string` | `"users"` | Name of the registered Mantle service that holds user records. |
***
### `hashPassword(field?)`
[Section titled “hashPassword(field?)”](#hashpasswordfield)
A `before` hook that reads `context.data[field]`, hashes it with Argon2id, and writes the hash back in place.
```typescript
// Default: hashes context.data.password
app.service("users").hooks({
before: { create: [hashPassword()] },
});
// Custom field name
app.service("accounts").hooks({
before: { create: [hashPassword("passphrase")] },
});
```
The hook is a no-op when:
* `context.data` is `undefined`
* The target field is absent from `context.data`
* The target field value is not a string
***
## Plugin order
[Section titled “Plugin order”](#plugin-order)
`localStrategy()` must be configured after `auth()` because it calls `app.get("auth")` at registration time to grab the engine and register the strategy:
```typescript
// Correct
app
.configure(auth({ secret }))
.configure(localStrategy());
// Wrong — throws "@mantlejs/auth must be configured before @mantlejs/auth-local"
app
.configure(localStrategy())
.configure(auth({ secret }));
```
***
## Custom username field
[Section titled “Custom username field”](#custom-username-field)
Use any unique field as the login identifier:
```typescript
app.configure(localStrategy({ usernameField: "username" }));
```
The strategy will query `app.service("users").find({ query: { username: value } })`.
***
## Multiple user entity services
[Section titled “Multiple user entity services”](#multiple-user-entity-services)
If you have more than one kind of entity that can authenticate (e.g. `users` and `admins`), register multiple strategy instances under different names by implementing a custom strategy using the `AuthEngine` directly. `localStrategy` always registers as `"local"`, so two instances would overwrite each other.
***
## Security notes
[Section titled “Security notes”](#security-notes)
* **Argon2id** is used instead of bcrypt. Argon2id is the winner of the [Password Hashing Competition](https://www.password-hashing.net/) and is [OWASP-recommended](https://cheats.owasp.org/cheatsheets/password_storage_cheat_sheet). Unlike bcrypt, it has no 72-character password truncation limit.
* All authentication failures return the same `"Invalid credentials"` message regardless of whether the user was not found or the password was wrong. This prevents user enumeration.
* The `sanitizeUser()` hook (from `@mantlejs/auth`) strips the `password` and `passwordHash` fields from all service results. Always apply it on the `after: all` phase of your entity service.
***
## Error reference
[Section titled “Error reference”](#error-reference)
| Error | Code | When thrown |
| ------------------ | ---- | -------------------------------------------------------------- |
| `NotAuthenticated` | 401 | User not found |
| `NotAuthenticated` | 401 | Password does not match stored hash |
| `NotAuthenticated` | 401 | Credentials missing from request body |
| `Error` | — | `localStrategy()` called before `auth()` (config-time failure) |
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build auth-local # compile
npx nx test auth-local # run tests
npx nx lint auth-local # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
Build before publishing:
```bash
npx nx build auth-local
```
First publish (scoped packages require `--access public`):
```bash
cd packages/auth-local
npm publish --access public
```
Subsequent releases — bump `version` in `packages/auth-local/package.json`, then:
```bash
cd packages/auth-local
npm publish
```
### Testing locally with Verdaccio
[Section titled “Testing locally with Verdaccio”](#testing-locally-with-verdaccio)
```bash
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/auth-local
npm publish --registry http://localhost:4873
# Install from it in another project
npm install @mantlejs/auth-local --registry http://localhost:4873
```
# @mantlejs/auth-microsoft
> Microsoft (Entra ID) OAuth 2.0 strategy for Mantle JS. Implements the authorization code flow with PKCE — no Passport.js dependency. Registers GET /auth/microsoft and GET /auth/microsoft/callback on the HTTP transport, then finds or creates a user record and returns a Mantle JWT pair.
Microsoft (Entra ID) OAuth 2.0 strategy for [Mantle JS](https://github.com/mantlejs/mantle). Implements the authorization code flow with PKCE — no Passport.js dependency. Registers `GET /auth/microsoft` and `GET /auth/microsoft/callback` on the HTTP transport, then finds or creates a user record and returns a Mantle JWT pair.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/auth-microsoft
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### PKCE
[Section titled “PKCE”](#pkce)
Microsoft Sign-In uses the authorization code flow with PKCE (Proof Key for Code Exchange). On each redirect request the plugin generates a fresh `code_verifier` (via [Arctic](https://arcticjs.dev), which also derives the SHA-256 / base64url `code_challenge` — see [ADR-002](https://github.com/MantleJs/mantle/blob/main/docs/decisions/adr-002-arctic-oauth-internals.md)). The verifier is stored server-side against a random `state` token and passed to the token exchange on callback.
### Tenant
[Section titled “Tenant”](#tenant)
Entra ID scopes sign-in to a tenant. The default, `"common"`, accepts both work/school (Entra ID) and personal Microsoft accounts. Set `tenant` to `"organizations"` (work/school only), `"consumers"` (personal only), or a specific tenant ID to restrict who can sign in — it must match the **Supported account types** of your app registration.
### Find-or-create
[Section titled “Find-or-create”](#find-or-create)
On callback the plugin searches the configured user service for a record where `microsoftId` (configurable) matches the `sub` claim from Microsoft Graph’s OIDC userinfo endpoint. If no record is found, it creates one with `{ microsoftId, email, name }` from the profile. The same user is returned on every subsequent sign-in.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { auth } from "@mantlejs/auth";
import { microsoftStrategy } from "@mantlejs/auth-microsoft";
const app = mantle()
.configure(express())
.configure(auth({ secret: process.env.JWT_SECRET! }))
.configure(
microsoftStrategy({
clientId: process.env.MICROSOFT_CLIENT_ID!,
clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
}),
);
app.listen(3030);
```
**Sign-in flow:**
1. Redirect the browser to `GET /auth/microsoft`
2. User authenticates on Microsoft and consents
3. Microsoft redirects to `GET /auth/microsoft/callback?code=...&state=...`
4. The plugin exchanges the code, fetches the profile, finds or creates the user, and responds:
```json
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"user": { "id": "1", "microsoftId": "AAAA...", "email": "alice@outlook.com", "name": "Alice" }
}
```
***
## API
[Section titled “API”](#api)
### `microsoftStrategy(config)`
[Section titled “microsoftStrategy(config)”](#microsoftstrategyconfig)
```typescript
function microsoftStrategy(config: MicrosoftStrategyConfig): MantlePlugin;
interface MicrosoftStrategyConfig extends OAuthPluginConfig {
tenant?: string; // Default: 'common'
}
```
| Field | Type | Default | Description |
| --------------- | ---------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clientId` | `string` | — | Entra ID application (client) ID (required) |
| `clientSecret` | `string` | — | Entra ID client secret (required) |
| `tenant` | `string` | `'common'` | Entra tenant: `'common'`, `'organizations'`, `'consumers'`, or a tenant ID |
| `callbackUrl` | `string` | `'/auth/microsoft/callback'` | Callback path — must match the redirect URI registered in the app registration |
| `scope` | `string[]` | `['openid', 'profile', 'email']` | Microsoft OAuth scopes |
| `entity` | `string` | `'users'` | Mantle service used to find or create users |
| `entityIdField` | `string` | `'microsoftId'` | Field on the user record matched against Microsoft’s `sub` claim |
| `redirectUrl` | `string` | none — returns JSON | Frontend URL to redirect to on completion, with tokens (or an error) in the URL fragment — see [`@mantlejs/auth-oauth`](/packages/auth-oauth/#redirecting-back-to-a-frontend-redirecturl) |
**Routes registered:**
| Method | Path | Description |
| ------ | -------------------------- | -------------------------------------- |
| `GET` | `/auth/microsoft` | Redirect to Microsoft sign-in |
| `GET` | `/auth/microsoft/callback` | Handle callback, issue Mantle JWT pair |
**Must be configured after** the transport (e.g. `express()`) and `auth()`.
***
## Types
[Section titled “Types”](#types)
```typescript
import type { MicrosoftStrategyConfig } from "@mantlejs/auth-microsoft";
```
`MicrosoftStrategyConfig` extends `OAuthPluginConfig` from `@mantlejs/auth-oauth` with the optional `tenant` field.
***
## Microsoft Entra admin center setup
[Section titled “Microsoft Entra admin center setup”](#microsoft-entra-admin-center-setup)
1. Register an application at [entra.microsoft.com](https://entra.microsoft.com) → **App registrations** → **New registration**
2. Choose the **Supported account types** matching your `tenant` setting
3. Add a **Web** redirect URI: `https://your-domain.com/auth/microsoft/callback`
4. Under **Certificates & secrets**, create a client secret
5. Copy the **Application (client) ID** and the secret value into your environment
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build auth-microsoft # compile
npx nx test auth-microsoft # run tests
npx nx lint auth-microsoft # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
```bash
npx nx build auth-microsoft
cd packages/auth-microsoft
npm publish --access public
```
# @mantlejs/auth-oauth
> Shared OAuth 2.0 base for Mantle JS. Provides the state management, PKCE, find-or-create user logic, and route registration that all OAuth strategy packages (auth-google, auth-github, etc.) build on. Not used directly by application code.
Shared OAuth 2.0 base for [Mantle JS](https://github.com/mantlejs/mantle). Provides the state management, PKCE, find-or-create user logic, and route registration that all OAuth strategy packages (`auth-google`, `auth-github`, etc.) build on. Not used directly by application code.
Protocol plumbing — authorization URL construction, token exchange, PKCE derivation — is delegated to [Arctic](https://arcticjs.dev) as an implementation dependency (see [ADR-002](https://github.com/MantleJs/mantle/blob/main/docs/decisions/adr-002-arctic-oauth-internals.md)); strategy packages hand-write only profile normalization.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/auth-oauth
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### Provider contract
[Section titled “Provider contract”](#provider-contract)
Each strategy package implements the `OAuthProvider` interface — three methods covering the provider-specific parts of the authorization code flow:
```typescript
interface OAuthProvider {
usePkce: boolean;
defaultScope: string[];
callbackMethod?: "GET" | "POST"; // default "GET" — "POST" for form_post providers (e.g. Apple)
buildAuthUrl(params: AuthUrlParams): string;
exchangeCode(params: CodeExchangeParams): Promise; // returns provider access token
fetchProfile(accessToken: string, extras?: CallbackExtras): Promise; // returns { id, email?, name? }
}
```
`extras` is only populated for `callbackMethod: "POST"` providers — it carries the raw callback body (`{ body?: Record }`) for fields that only exist in a form-encoded POST payload, not the query string (e.g. Apple’s `user` JSON, only sent on first authorization).
### `createOAuthPlugin`
[Section titled “createOAuthPlugin”](#createoauthplugin)
The factory that wires a provider into Mantle. It registers two routes (`GET /auth/{providerKey}` and `/auth/{providerKey}/callback` — `GET` by default, or `POST` when `provider.callbackMethod` is `"POST"`) on the transport’s `http:router`, manages PKCE state (generating the code verifier via Arctic; the provider derives the challenge), performs find-or-create on the configured user service, and issues the Mantle JWT pair via `@mantlejs/auth`.
### State store
[Section titled “State store”](#state-store)
Pending OAuth state is kept in an `OAuthStateStore` keyed on the OAuth `state` parameter. Entries expire after 10 minutes and are cleaned up lazily on each redirect request. The `codeVerifier` (PKCE only) is stored alongside the state and passed to the token exchange. The callback handler consumes the entry via `consume(state)` — an atomic read-and-remove, not a separate `get()` then `delete()` — so a double-fired network request or a replayed callback URL for the same `state` can’t both pass the pending-state check before either removes it.
The default store is an in-process, in-memory `Map` — fine for a single instance, **wrong for multi-instance deployments** (Cloud Run, horizontal scaling behind a load balancer), where the callback request can land on a different instance than the one that issued the state. Inject the Redis-backed store from [`@mantlejs/auth-redis`](/packages/auth-redis/) instead (store methods are sync-or-async, so any shared backend fits):
```typescript
import { Redis } from "ioredis";
import { redisStateStore } from "@mantlejs/auth-redis";
const redis = new Redis(process.env.REDIS_URL!);
app.configure(googleStrategy({ clientId, clientSecret, stateStore: redisStateStore(redis) }));
```
### Find-or-create
[Section titled “Find-or-create”](#find-or-create)
On callback, the plugin calls `app.service(entity).find({ query: { [entityIdField]: profile.id } })`. If the result is empty, it calls `app.service(entity).create(...)` with the normalized profile fields. The resulting user record is used to mint the Mantle JWT pair.
***
## Quick start (writing a new strategy)
[Section titled “Quick start (writing a new strategy)”](#quick-start-writing-a-new-strategy)
Arctic ships clients for \~60 providers — pick the matching class (or fall back to its generic `OAuth2Client` with explicit endpoints) and hand-write only the profile normalization:
```typescript
import type { MantlePlugin } from "@mantlejs/mantle";
import { GeneralError } from "@mantlejs/mantle";
import { Discord } from "arctic"; // or OAuth2Client for providers Arctic doesn't ship
import { createOAuthPlugin } from "@mantlejs/auth-oauth";
import type { OAuthPluginConfig, OAuthProvider } from "@mantlejs/auth-oauth";
export type MyStrategyConfig = OAuthPluginConfig;
const myProvider: OAuthProvider = {
usePkce: false, // when true, buildAuthUrl receives a codeVerifier; Arctic derives the S256 challenge
defaultScope: ["identify", "email"],
buildAuthUrl({ clientId, redirectUri, scope, state }) {
const client = new Discord(clientId, "", redirectUri); // secret unused for URL construction
return client.createAuthorizationURL(state, scope).toString();
},
async exchangeCode({ code, clientId, clientSecret, redirectUri }) {
const client = new Discord(clientId, clientSecret, redirectUri);
try {
const tokens = await client.validateAuthorizationCode(code);
return tokens.accessToken();
} catch {
throw new GeneralError("Token exchange failed");
}
},
async fetchProfile(accessToken) {
const res = await fetch("https://discord.com/api/users/@me", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new GeneralError("Profile fetch failed");
const data = (await res.json()) as Record;
return { id: String(data["id"]), email: data["email"] as string };
},
};
export function myStrategy(config: MyStrategyConfig): MantlePlugin {
return createOAuthPlugin("myprovider", myProvider, {
...config,
entityIdField: config.entityIdField ?? "myProviderId",
});
}
```
Register alongside the other plugins:
```typescript
app
.configure(express())
.configure(auth({ secret: process.env.JWT_SECRET! }))
.configure(myStrategy({ clientId: "...", clientSecret: "..." }));
```
This registers `GET /auth/myprovider` and `GET /auth/myprovider/callback` on the Express app.
***
## API
[Section titled “API”](#api)
### `createOAuthPlugin(providerKey, provider, config)`
[Section titled “createOAuthPlugin(providerKey, provider, config)”](#createoauthpluginproviderkey-provider-config)
```typescript
function createOAuthPlugin(providerKey: string, provider: OAuthProvider, config: OAuthPluginConfig): MantlePlugin;
```
| Parameter | Description |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `providerKey` | Short identifier for the provider (`'google'`, `'github'`, `'facebook'`). Used in default route paths and the default `entityIdField`. |
| `provider` | The `OAuthProvider` implementation for this strategy. |
| `config` | User-supplied configuration (client ID, secret, optional overrides). |
**Throws** at plugin registration time if `@mantlejs/auth` or an HTTP transport (`@mantlejs/express`, `@mantlejs/koa`, or `@mantlejs/http`) is not configured before this plugin.
**Routes registered:**
| Method | Path | Description |
| ----------------- | --------------------- | ------------------------------------------------------------------------------------ |
| `GET` | `/auth/{providerKey}` | Redirect to provider consent screen. Generates state + PKCE (if `provider.usePkce`). |
| `GET` or `POST`\* | `config.callbackUrl` | Verify state, exchange code, fetch profile, find-or-create user, issue JWT pair. |
\* `POST` when `provider.callbackMethod === "POST"` (e.g. Apple’s `response_mode=form_post`); `GET` otherwise.
**Response on successful callback:**
```json
{
"accessToken": "...",
"refreshToken": "...",
"user": { "id": "...", "email": "...", "name": "..." }
}
```
### Redirecting back to a frontend (`redirectUrl`)
[Section titled “Redirecting back to a frontend (redirectUrl)”](#redirecting-back-to-a-frontend-redirecturl)
`/auth/{providerKey}` is meant to be opened via full-page navigation (an ``, not `fetch`) — the browser leaves your app to consent on the provider’s site and comes back on its own. Without `redirectUrl`, the callback’s JSON response above just strands the user on the API’s own origin. Set `redirectUrl` to send them back into the frontend instead:
```typescript
app.configure(googleStrategy({ clientId, clientSecret, redirectUrl: "https://app.example.com/" }));
```
* Success redirects to `${redirectUrl}#accessToken=...&refreshToken=...`
* Failure redirects to `${redirectUrl}#error=...`
Tokens travel in the URL **fragment**, never the query string, so they never reach a server log or a `Referer` header. On the frontend, read `window.location.hash` on load, hand the tokens to `@mantlejs/client`’s `client.setTokens({ accessToken, refreshToken })`, and strip the fragment with `history.replaceState`. See `examples/knowledge-base/web/src/pages/AuthPage.tsx` for a complete example.
#### `OAuthPluginConfig`
[Section titled “OAuthPluginConfig”](#oauthpluginconfig)
| Field | Type | Default | Description |
| --------------- | ----------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| `clientId` | `string` | — | OAuth application client ID (required) |
| `clientSecret` | `string` | — | OAuth application client secret (required) |
| `callbackUrl` | `string` | `/auth/{providerKey}/callback` | Path for the callback route |
| `scope` | `string[]` | `provider.defaultScope` | OAuth scopes to request |
| `entity` | `string` | `'users'` | Service used to find or create users |
| `entityIdField` | `string` | `'{providerKey}Id'` | Field matched against the provider’s user ID |
| `stateStore` | `OAuthStateStore` | in-memory | Store for pending OAuth state. Multi-instance deployments must inject a shared (e.g. Redis-backed) implementation |
| `redirectUrl` | `string` | none — returns JSON | Frontend URL to send the browser back to once the callback completes (see below) |
***
## Types
[Section titled “Types”](#types)
```typescript
import type {
OAuthProvider,
OAuthProfile,
OAuthPluginConfig,
OAuthStateStore,
OAuthStateData,
AuthUrlParams,
CodeExchangeParams,
CallbackExtras,
} from "@mantlejs/auth-oauth";
import { createStateStore } from "@mantlejs/auth-oauth";
```
| Type | Description |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OAuthProvider` | Interface that each strategy package implements |
| `OAuthProfile` | Normalized profile: `{ id: string; email?: string; name?: string }` |
| `OAuthPluginConfig` | Shared config options for all OAuth strategies |
| `OAuthStateStore` | Store for pending OAuth state — inject via `config.stateStore` |
| `OAuthStateData` | Stored entry: `{ codeVerifier?: string; expiresAt: number }` |
| `AuthUrlParams` | Params passed to `provider.buildAuthUrl()` |
| `CodeExchangeParams` | Params passed to `provider.exchangeCode()` |
| `CallbackExtras` | Raw callback extras passed to `provider.fetchProfile()`: `{ body?: Record }` — populated only for `callbackMethod: "POST"` providers |
`createStateStore(ttlMs?)` — factory behind the default in-memory `OAuthStateStore`, exported in case you want the default backend with a non-default TTL (default `600_000` — 10 minutes) instead of writing a custom store:
```typescript
app.configure(myStrategy({ clientId, clientSecret, stateStore: createStateStore(5 * 60 * 1000) }));
```
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build auth-oauth # compile
npx nx test auth-oauth # run tests
npx nx lint auth-oauth # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
```bash
npx nx build auth-oauth
cd packages/auth-oauth
npm publish --access public
```
# @mantlejs/auth-redis
> Redis-backed stores for Mantle JS authentication: a shared RefreshTokenStore for @mantlejs/auth and a shared OAuthStateStore for @mantlejs/auth-oauth. Both defaults are in-memory and single-instance only — in multi-instance deployments (Cloud Run, horizontal scaling behind a load balancer) every instance must see the same outstanding refresh tokens and pending OAuth states. This package backs both with Redis.
Redis-backed stores for [Mantle JS](https://github.com/mantlejs/mantle) authentication: a shared `RefreshTokenStore` for [`@mantlejs/auth`](/packages/auth/) and a shared `OAuthStateStore` for [`@mantlejs/auth-oauth`](/packages/auth-oauth/). Both defaults are in-memory and single-instance only — in multi-instance deployments (Cloud Run, horizontal scaling behind a load balancer) every instance must see the same outstanding refresh tokens and pending OAuth states. This package backs both with Redis.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/auth-redis ioredis
```
The package has no hard dependency on a Redis client — the stores accept any client matching the small `RedisClientLike` interface (a subset of Redis commands shaped to match [`ioredis`](https://github.com/redis/ioredis), which is the recommended client). Requires Redis ≥ 6.2 (`GETDEL`).
***
## Concepts
[Section titled “Concepts”](#concepts)
### Why a shared refresh-token store?
[Section titled “Why a shared refresh-token store?”](#why-a-shared-refresh-token-store)
`@mantlejs/auth` issues access + refresh token pairs and records each refresh token’s `jti` in a `RefreshTokenStore`. Rotation consumes the `jti` exactly once; a second consume of the same token is treated as theft and revokes the whole family. The in-memory default cannot see tokens issued by another instance, so rotation and revocation break fleet-wide. The Redis store keys each `jti` with a TTL matching the token’s own `exp` (Redis evicts stale entries itself — no sweeper) and uses `GETDEL` so concurrent consumers of the same token resolve atomically: exactly one wins.
### Why a shared OAuth state store?
[Section titled “Why a shared OAuth state store?”](#why-a-shared-oauth-state-store)
`@mantlejs/auth-oauth` stores the pending `state` (and PKCE code verifier) between the redirect to the provider and the callback. Behind a load balancer, the callback can land on a different instance than the one that started the flow. The Redis store writes each state with `SET … EX`, so entries expire server-side and `cleanup()` is a no-op.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { Redis } from "ioredis";
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { auth } from "@mantlejs/auth";
import { googleStrategy } from "@mantlejs/auth-google";
import { redisRefreshTokenStore, redisStateStore } from "@mantlejs/auth-redis";
const redis = new Redis(process.env.REDIS_URL!);
const app = mantle()
.configure(express())
.configure(
auth({
secret: process.env.JWT_SECRET!,
refreshTokenStore: redisRefreshTokenStore(redis),
}),
)
.configure(
googleStrategy({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
stateStore: redisStateStore(redis),
}),
);
app.listen(3030);
```
***
## API
[Section titled “API”](#api)
### `redisRefreshTokenStore(client, options?)`
[Section titled “redisRefreshTokenStore(client, options?)”](#redisrefreshtokenstoreclient-options)
Returns a `RefreshTokenStore` backed by Redis. Pass it as `refreshTokenStore` in the `auth()` config.
Layout: each `jti` is a string key holding its subject, expiring with the token (`SET … EX`); each subject keeps a set of its outstanding `jti`s so `revokeAll` can find them. The set’s own TTL is re-armed to the newest token’s expiry on every `add` — under a fixed `refreshExpiresIn` the newest token always expires last, so the set outlives all its members. Stale members left behind by an individual `jti` key’s expiry are harmless: `consume` and `revokeAll` both treat a missing `jti` key as already gone.
#### Options
[Section titled “Options”](#options)
| Option | Type | Default | Description |
| -------- | -------- | ------------------------ | ----------------------------------------------- |
| `prefix` | `string` | `"mantle:auth:refresh:"` | Prefix applied to all keys written by the store |
### `redisStateStore(client, options?)`
[Section titled “redisStateStore(client, options?)”](#redisstatestoreclient-options)
Returns an `OAuthStateStore` backed by Redis. Pass it as `stateStore` in any auth-oauth strategy config.
`consume(state)` uses `GETDEL` — the same atomic read-and-remove pattern `redisRefreshTokenStore` uses for rotation — so two concurrent OAuth callback requests for the same `state` (a double-fired network request, or a replayed callback URL) resolve atomically: exactly one sees the pending entry and proceeds, the other sees it already gone.
#### Options
[Section titled “Options”](#options-1)
| Option | Type | Default | Description |
| -------- | -------- | ----------------------- | ----------------------------------------------- |
| `ttlMs` | `number` | `600000` (10 minutes) | How long a pending authorization state is valid |
| `prefix` | `string` | `"mantle:oauth:state:"` | Prefix applied to all keys written by the store |
***
## Types
[Section titled “Types”](#types)
```typescript
import type { RedisClientLike, RedisRefreshTokenStoreOptions, RedisStateStoreOptions } from "@mantlejs/auth-redis";
```
| Type | Description |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `RedisClientLike` | The Redis command subset the stores need (`set`/`get`/`getdel`/`del`/`sadd`/`srem`/`smembers`/`expire`) — satisfied by an `ioredis` instance |
| `RedisRefreshTokenStoreOptions` | Options passed to `redisRefreshTokenStore()` |
| `RedisStateStoreOptions` | Options passed to `redisStateStore()` |
The stores implement `RefreshTokenStore` from `@mantlejs/auth` (`add(jti, sub, exp)` / `consume(jti)` / `revokeAll(sub)`) and `OAuthStateStore` from `@mantlejs/auth-oauth` (`set` / `get` / `delete` / `consume` / `cleanup`).
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build auth-redis # compile
npx nx test auth-redis # run tests (uses ioredis-mock — no Redis server needed)
npx nx lint auth-redis # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
Build before publishing:
```bash
npx nx build auth-redis
```
First publish (scoped packages require `--access public`):
```bash
cd packages/auth-redis
npm publish --access public
```
Subsequent releases — bump `version` in `packages/auth-redis/package.json`, then:
```bash
cd packages/auth-redis
npm publish
```
### Testing locally with Verdaccio
[Section titled “Testing locally with Verdaccio”](#testing-locally-with-verdaccio)
```bash
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/auth-redis
npm publish --registry http://localhost:4873
# Install from it in another project
npm install @mantlejs/auth-redis --registry http://localhost:4873
```
# @mantlejs/auth-twitter
> Sign in with X (Twitter) OAuth 2.0 strategy for Mantle JS. Implements the authorization code flow with PKCE — no Passport.js dependency. Registers GET /auth/twitter and GET /auth/twitter/callback on the HTTP transport, then finds or creates a user record and returns a Mantle JWT pair.
Sign in with X (Twitter) OAuth 2.0 strategy for [Mantle JS](https://github.com/mantlejs/mantle). Implements the authorization code flow with PKCE — no Passport.js dependency. Registers `GET /auth/twitter` and `GET /auth/twitter/callback` on the HTTP transport, then finds or creates a user record and returns a Mantle JWT pair.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/auth-twitter
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### PKCE
[Section titled “PKCE”](#pkce)
Unlike `auth-github`/`auth-facebook`/`auth-linkedin`, X’s OAuth 2.0 implementation requires PKCE (Proof Key for Code Exchange) — same posture as `auth-google`/`auth-microsoft`. On each redirect request the plugin generates a fresh `code_verifier` (via [Arctic](https://arcticjs.dev), which also derives the SHA-256 / base64url `code_challenge` — see [ADR-002](https://github.com/MantleJs/mantle/blob/main/docs/decisions/adr-002-arctic-oauth-internals.md)). The verifier is stored server-side against a random `state` token and passed to the token exchange on callback.
### Scope
[Section titled “Scope”](#scope)
Defaults to `["users.read", "tweet.read"]`. X’s own API reference lists `users.read` and `tweet.read` as alternative scopes for `GET /2/users/me`, but X’s developer community has repeatedly reported `403 Forbidden` from that endpoint with `users.read` alone — both are requested by default to match what’s actually been confirmed working, not just what the reference documents.
### No email
[Section titled “No email”](#no-email)
X’s `/2/users/me` endpoint only returns an email address via the `confirmed_email` field, gated behind an app-level email access grant that most apps don’t have, and never includes it by default. This strategy doesn’t request `user.fields=confirmed_email` (an unapproved field risks the whole call failing rather than being silently omitted), so a user record created via `twitterStrategy` never has an `email` — plan your `users` schema accordingly if X is one of several strategies you support.
### Find-or-create
[Section titled “Find-or-create”](#find-or-create)
On callback the plugin searches the configured user service for a record where `twitterId` (configurable) matches the `id` field from X’s `/2/users/me` response. If no record is found, it creates one with `{ twitterId, name }` from the profile (no `email` — see above). The same user is returned on every subsequent sign-in.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { auth } from "@mantlejs/auth";
import { twitterStrategy } from "@mantlejs/auth-twitter";
const app = mantle()
.configure(express())
.configure(auth({ secret: process.env.JWT_SECRET! }))
.configure(
twitterStrategy({
clientId: process.env.TWITTER_CLIENT_ID!,
clientSecret: process.env.TWITTER_CLIENT_SECRET!,
}),
);
app.listen(3030);
```
**Sign-in flow:**
1. Redirect the browser to `GET /auth/twitter`
2. User authenticates on X and consents
3. X redirects to `GET /auth/twitter/callback?code=...&state=...`
4. The plugin exchanges the code, fetches the profile, finds or creates the user, and responds:
```json
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"user": { "id": "1", "twitterId": "1234567890", "name": "Alice" }
}
```
***
## API
[Section titled “API”](#api)
### `twitterStrategy(config)`
[Section titled “twitterStrategy(config)”](#twitterstrategyconfig)
```typescript
function twitterStrategy(config: TwitterStrategyConfig): MantlePlugin;
type TwitterStrategyConfig = OAuthPluginConfig;
```
| Field | Type | Default | Description |
| --------------- | ---------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clientId` | `string` | — | X app’s OAuth 2.0 Client ID (required) |
| `clientSecret` | `string` | — | X app’s OAuth 2.0 Client Secret (required) |
| `callbackUrl` | `string` | `'/auth/twitter/callback'` | Callback path — must match a redirect URI registered in the X app’s settings |
| `scope` | `string[]` | `['users.read', 'tweet.read']` | X OAuth 2.0 scopes |
| `entity` | `string` | `'users'` | Mantle service used to find or create users |
| `entityIdField` | `string` | `'twitterId'` | Field on the user record matched against X’s `id` field |
| `redirectUrl` | `string` | none — returns JSON | Frontend URL to redirect to on completion, with tokens (or an error) in the URL fragment — see [`@mantlejs/auth-oauth`](/packages/auth-oauth/#redirecting-back-to-a-frontend-redirecturl) |
**Routes registered:**
| Method | Path | Description |
| ------ | ------------------------ | -------------------------------------- |
| `GET` | `/auth/twitter` | Redirect to X sign-in |
| `GET` | `/auth/twitter/callback` | Handle callback, issue Mantle JWT pair |
**Must be configured after** the transport (e.g. `express()`) and `auth()`.
***
## Types
[Section titled “Types”](#types)
```typescript
import type { TwitterStrategyConfig } from "@mantlejs/auth-twitter";
```
`TwitterStrategyConfig` is a plain alias of `OAuthPluginConfig` from `@mantlejs/auth-oauth` — X needs no strategy-specific fields (unlike `auth-microsoft`’s `tenant` or `auth-apple`’s Sign in with Apple key fields).
***
## X Developer Portal setup
[Section titled “X Developer Portal setup”](#x-developer-portal-setup)
1. Create a project and app at [developer.x.com](https://developer.x.com) → **Developer Portal**
2. Under your app’s **User authentication settings**, enable **OAuth 2.0** and set the app type to **Web App**
3. Add a callback URI: `https://your-domain.com/auth/twitter/callback`
4. Under **App permissions**, request at least **Read** access
5. Copy the **OAuth 2.0 Client ID** and generate a **Client Secret** into your environment
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build auth-twitter # compile
npx nx test auth-twitter # run tests
npx nx lint auth-twitter # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
```bash
npx nx build auth-twitter
cd packages/auth-twitter
npm publish --access public
```
# @mantlejs/cli
> Developer CLI for Mantle JS. Scaffold new projects and generate services, repositories, and hooks from the terminal.
Developer CLI for [Mantle JS](https://github.com/mantlejs/mantle). Scaffold new projects and generate services, repositories, and hooks from the terminal.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install --global @mantlejs/cli
```
Or run without installing:
```bash
npx @mantlejs/cli new my-api
```
***
## Concepts
[Section titled “Concepts”](#concepts)
`@mantlejs/cli` has three top-level commands:
* **`mantle new`** — scaffold a complete runnable Mantle project from scratch. Prompts for transport, database, auth strategy, and package manager, then writes all boilerplate files and runs install.
* **`mantle generate` (alias `g`)** — add generated code into an existing project. Generates services, hooks, and repositories following Mantle’s layer conventions.
* **`mantle add`** — install a Mantle package into an existing project and, for packages with known wiring, automatically add the import and `.configure()` call to `src/app.ts`.
Generated service tests use `@mantlejs/memory` so they run without a database.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```bash
# Scaffold a new project (interactive prompts)
mantle new my-api
# Scaffold with flags (non-interactive)
mantle new my-api --database pg --auth local --package-manager npm
# Scaffold with CORS and Redis-backed OAuth state/refresh-token storage
mantle new my-api --database mongodb --auth google --cors --redis --package-manager npm
# Generate a service, repository, schema, and spec
mantle g service users
# Generate a hook
mantle g hook authenticate
# Generate a repository only
mantle g repository users
```
***
## API
[Section titled “API”](#api)
### `mantle new `
[Section titled “mantle new \”](#mantle-new-project-name)
Scaffolds a new Mantle project in `.//`:
```plaintext
/
├── src/
│ ├── app.ts # Application bootstrap
│ ├── index.ts # Entry point — app.listen()
│ └── services/
│ └── .gitkeep
├── config/
│ ├── default.json
│ └── production.json
├── package.json
├── tsconfig.json
├── .env.example
├── .gitignore
└── README.md
```
**Options:**
| Option | Choices | Default | Description |
| ------------------- | -------------------------------------------------------------------------------------------- | --------- | ----------------------------------------------------------------------------------- |
| `--transport` | `express` | `express` | HTTP transport |
| `--database` | `pg`, `sqlite`, `mongodb`, `none` | prompted | Database adapter |
| `--auth` | `local`, `google`, `github`, `facebook`, `apple`, `microsoft`, `linkedin`, `twitter`, `none` | prompted | Auth strategy |
| `--cors` | — | `false` | Enable CORS on the transport (`@mantlejs/express`’s `cors` option) |
| `--redis` | — | `false` | Wire `@mantlejs/auth-redis` state/refresh-token stores (ignored when `--auth none`) |
| `--package-manager` | `npm`, `yarn`, `pnpm` | prompted | Package manager |
| `--skip-install` | — | `false` | Skip running install after scaffold |
When `--database`, `--auth`, or `--package-manager` are omitted, the CLI prompts interactively. `--cors` and `--redis` are flag-only (no prompt) — omit them to leave both off.
`sqlite` scaffolds use Knex’s `better-sqlite3` client, matching the `better-sqlite3` driver dependency it installs. Every `--auth` choice takes `clientId`/`clientSecret`; `apple` additionally takes `teamId`/`keyId`/`privateKey` in place of `clientSecret` (Sign in with Apple has no static client secret, but still uses `clientId` as the Services ID).
***
### `mantle add `
[Section titled “mantle add \”](#mantle-add-package)
Installs a Mantle package into an existing project and wires it up automatically where possible.
```bash
mantle add @mantlejs/auth-local
```
1. Detects the project’s package manager from its lockfile (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, otherwise npm) and installs ``.
2. If the package has known wiring (e.g. `@mantlejs/logger`, `@mantlejs/socketio`, `@mantlejs/koa`, `@mantlejs/auth` and its strategy packages, `@mantlejs/mongodb`, `@mantlejs/sync`, `@mantlejs/config`), rewrites `src/app.ts` to add the import and `.configure()` call, and prints any required `.env` additions.
3. For a package with no known wiring, or when `src/app.ts` can’t be automatically modified, prints the import and `.configure()` line to add manually instead of failing.
***
### `mantle generate ` (alias `g`)
[Section titled “mantle generate \ \ (alias g)”](#mantle-generate-generator-name-alias-g)
Generates code in `src/services//` by default. Override with `--directory `.
| Generator | Alias | Files generated |
| ---------------- | ------ | ----------------------------------------------------------------------------------------- |
| `service` | `s` | `.service.ts`, `.repository.ts`, `.schema.ts`, `.service.spec.ts` |
| `hook` | `h` | `.hook.ts`, `.hook.spec.ts` |
| `repository` | `r` | `.repository.ts` |
| `authentication` | `auth` | `src/authentication.ts` (detected auth strategy config) |
| `migration` | `m` | `migrations/_.ts` (requires `@mantlejs/knex`) |
`service`/`repository` detect the project’s database from `package.json` and generate a matching repository base class: `KnexRepository` (`@mantlejs/knex` installed), `MongoRepository` (`@mantlejs/mongodb` installed), or `MemoryRepository` as the fallback (`--database none`).
**Examples:**
```bash
mantle g service users
mantle g s user-profile
mantle g hook rate-limit --directory src/hooks
mantle g repository messages
```
**Generated service test pattern** (uses `@mantlejs/memory` — no database needed):
```typescript
import { MemoryRepository } from "@mantlejs/memory";
import { UsersService } from "./users.service.js";
describe("UsersService", () => {
let repo: MemoryRepository;
let service: UsersService;
beforeEach(() => {
repo = new MemoryRepository();
service = new UsersService(repo);
});
it("creates a record", async () => {
const record = await service.create({}, {});
expect(record.id).toBeDefined();
});
});
```
***
## Types
[Section titled “Types”](#types)
```typescript
import type { NewProjectOptions, GeneratorName } from "@mantlejs/cli";
```
| Type | Description |
| ------------------- | -------------------------------------------------------------------------------------------------------------- |
| `NewProjectOptions` | Options accepted by `newProject()` |
| `GeneratorName` | `"service" \| "hook" \| "repository" \| "authentication" \| "migration"` |
| `Transport` | `"express"` |
| `Database` | `"pg" \| "sqlite" \| "mongodb" \| "none"` |
| `Auth` | `"local" \| "google" \| "github" \| "facebook" \| "apple" \| "microsoft" \| "linkedin" \| "twitter" \| "none"` |
| `PackageManager` | `"npm" \| "yarn" \| "pnpm"` |
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build cli # compile
npx nx test cli # run tests
npx nx lint cli # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
Build before publishing:
```bash
npx nx build cli
```
First publish (scoped packages require `--access public`):
```bash
cd packages/cli
npm publish --access public
```
Subsequent releases — bump `version` in `packages/cli/package.json`, then:
```bash
cd packages/cli
npm publish
```
# @mantlejs/client
> Official JavaScript/TypeScript client SDK for Mantle JS — call Mantle services from browsers, Node.js (18+), and React Native over REST, with optional real-time events over Socket.IO.
Official JavaScript/TypeScript client SDK for [Mantle JS](https://github.com/mantlejs/mantle) — call Mantle services from browsers, Node.js (18+), and React Native over REST, with optional real-time events over Socket.IO.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/client
# Optional — only needed for real-time service events
npm install socket.io-client
```
The client has zero required dependencies: REST calls use the native Fetch API. `socket.io-client` is an optional peer dependency loaded lazily on the first `.on()` call.
***
## Concepts
[Section titled “Concepts”](#concepts)
### The same `Service` surface, on the wire
[Section titled “The same Service\ surface, on the wire”](#the-same-servicet-surface-on-the-wire)
A `ServiceClient` exposes the six method names the server-side `Service` contract uses — `find`, `get`, `create`, `update`, `patch`, `remove` — dispatched as REST calls (`GET/POST/PUT/PATCH/DELETE /:service`). Switching between server and client code involves no context switching.
### Query serialization
[Section titled “Query serialization”](#query-serialization)
`params.query` is serialized into the bracket-notation query string every Mantle HTTP transport parses back into the identical object (`parseQueryString` in `@mantlejs/mantle`):
```typescript
await api.service("users").find({ query: { age: { $gt: 21 }, $limit: 10, $sort: { name: "asc" } } });
// → GET /users?age[$gt]=21&$limit=10&$sort[name]=asc
```
Values arrive server-side as strings — pair services with `RepositoryService` and a schema for type coercion. `undefined` values are dropped; `null` serializes as an empty string, so `IS NULL` queries need server-side coercion.
### Authentication and token rotation
[Section titled “Authentication and token rotation”](#authentication-and-token-rotation)
`client.authenticate({ strategy, ...credentials })` posts to `/authentication` and stores the returned `accessToken`/`refreshToken` in the configured `TokenStorage` (default: `localStorage` in the browser, in-memory elsewhere). Every REST request carries `Authorization: Bearer `.
On a 401, the client attempts **one** token rotation — `POST /authentication` with `{ strategy: "refresh", refreshToken }` — then retries the original request. Concurrent 401s share a single refresh (the server’s rotation treats a reused refresh token as theft and revokes the whole family). If the refresh fails, tokens are cleared, `'logout'` is emitted, and the original 401 error is thrown.
### Restoring a session on startup
[Section titled “Restoring a session on startup”](#restoring-a-session-on-startup)
`getAccessToken()` is synchronous, reading only the **in-memory** copy — on a fresh page load (or a new `MantleClient` instance) that copy is empty until something hydrates it from `storage`, which normally only happens as a side effect of the first authenticated REST call. A UI that checks `getAccessToken()` before making any request — the natural thing to do to decide whether to render a logged-in or logged-out view on mount — will see `undefined` and show its logged-out state on every refresh, even with a valid session sitting in storage. Use `isAuthenticated()` instead, which hydrates first:
```typescript
const [authenticated, setAuthenticated] = useState(undefined); // undefined = "checking"
useEffect(() => {
void api.isAuthenticated().then(setAuthenticated);
}, []);
```
`setTokens({ accessToken, refreshToken })` hydrates the client from a token pair obtained outside `authenticate()` — e.g. an OAuth provider’s redirect-back callback landing tokens in a URL fragment. It stores them and emits `'authenticated'`, the same as a successful `authenticate()`.
### Real-time events
[Section titled “Real-time events”](#real-time-events)
When the `socket` option is configured, `service.on("created" | "updated" | "patched" | "removed", handler)` subscribes to the server’s Socket.IO broadcasts (`" "`). The socket connects lazily on the first `.on()` call, all services share one connection, and multiple handlers for the same event multiplex over a single underlying socket listener. Calling `.on()` without the `socket` option throws a `GeneralError`-shaped `MantleClientError`.
Event delivery is at-most-once (see the `@mantlejs/sync` README) — the client emits a `'reconnect'` event on every re-connect so callers (e.g. `@mantlejs/react`) can refetch and bound the staleness from any missed events.
### Batch coalescing
[Section titled “Batch coalescing”](#batch-coalescing)
With the `batch` option enabled, service calls made within the same coalescing window (default: the same microtask tick) are queued and sent as **one** `POST /batch` request instead of N separate REST calls — the transports mount that endpoint by default. Each caller’s promise still resolves or rejects independently from its own entry in the batched response, so application code is unchanged:
```typescript
const api = mantle({ url: "http://localhost:3030", batch: true });
// One HTTP round trip, three independent promises — the dominant AI-agent call pattern
const [user, posts, tags] = await Promise.all([
api.service("users").get(1),
api.service("posts").find({ query: { authorId: 1 } }),
api.service("tags").find(),
]);
```
`windowMs` widens the window (`setTimeout`-based) beyond the same tick; queues longer than `maxSize` (default 25 — match the server’s max batch size) split into multiple requests. Calls with per-request `headers` bypass coalescing and go out individually, as does `similar()`. If every entry fails with a 401 (expired token), the client performs its usual single token refresh and retries just those entries once.
### File uploads
[Section titled “File uploads”](#file-uploads)
`service.upload(file, options?)` sends a `multipart/form-data` request to a service whose `create` (or, with `options.id`, `patch`) runs `@mantlejs/storage`’s `handleUpload()`. It gets the same guarantees as every other call: bearer auth, one refresh-and-retry on a 401, typed `MantleClientError`s. Uploads are never batched.
```typescript
const attachment = await api.service("attachments").upload(file, {
fields: { articleId: "42" }, // ordinary form fields, merged into context.data by handleUpload()
onProgress: ({ percent }) => setProgress(percent ?? 0),
});
```
`fetch` can’t report upload progress, so when `onProgress` is set and `XMLHttpRequest` exists (browsers, React Native), the request goes over XHR instead. Elsewhere (Node), `fetch` is used and `onProgress` never fires. Pass `signal` to abort; the promise rejects with the signal’s reason.
### Errors
[Section titled “Errors”](#errors)
Non-2xx responses are deserialized into `MantleClientError` — `name` (`"BadRequest"`, `"NotFound"`, …), `code` (HTTP status), `data`, `errors`, and the server’s actionable `hint` when present. Non-JSON bodies (gateway errors) fall back to the HTTP status. Network failures propagate as the native fetch `TypeError`, unwrapped.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/client";
interface Message {
id: number;
text: string;
}
const api = mantle({ url: "http://localhost:3030", socket: {} });
// Authenticate (against @mantlejs/auth + a strategy such as auth-local)
await api.authenticate({ strategy: "local", email: "alice@example.com", password: "secret" });
// CRUD — same names as the server-side Service
const messages = api.service("messages");
const page = await messages.find({ query: { $limit: 10, $sort: { id: "desc" } } });
const one = await messages.get(1);
const created = await messages.create({ text: "Hello" });
// Real-time
messages.on("created", (message) => console.log("new message", message.text));
api.on("reconnect", () => console.log("socket reconnected — refetch anything stale"));
```
Vector search, against a service that registers the `similar` custom method (`VectorRepositoryService`):
```typescript
const hits = await api.service("docs").similar({ vector: [0.1, 0.2, 0.3], topK: 5 });
// hits: Array
```
***
## API
[Section titled “API”](#api)
### `mantle(options)`
[Section titled “mantle(options)”](#mantleoptions)
Creates a `MantleClient`. Throws `TypeError` if `url` is missing.
| Option | Type | Default | Description |
| --------- | ------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------- |
| `url` | `string` | — (required) | Base URL of the Mantle server, e.g. `"http://localhost:3030"` |
| `storage` | `TokenStorage` | `localStorage` browser / in-memory | Token persistence. Any object with `getItem`/`setItem`/`removeItem` (sync or async) works |
| `socket` | `SocketOptions` | `undefined` | Socket.IO connection options, passed to `io(url, options)`. Omit to disable real-time features |
| `headers` | `Record` | `{}` | Default headers appended to every REST request (per-request `params.headers` win) |
| `batch` | `boolean \| BatchOptions` | `false` | Coalesce same-window service calls into one `POST /batch` request (see Batch coalescing) |
`SocketOptions.io` optionally overrides the socket factory itself — inject a stub in tests, or supply a pre-bundled `io` when dynamic import of the optional peer is undesirable.
### `MantleClient`
[Section titled “MantleClient”](#mantleclient)
| Member | Description |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `service(path)` | Returns the (cached) `ServiceClient` for a service path |
| `authenticate(credentials)` | `POST /authentication`, stores tokens, emits `'authenticated'`, returns `AuthResult`. A 2xx without an `accessToken` throws a `GeneralError` and stores nothing |
| `setTokens(tokens)` | Stores a `{ accessToken, refreshToken? }` pair obtained outside `authenticate()`, emits `'authenticated'` |
| `isAuthenticated()` | `Promise` — hydrates from storage first if needed. Use on startup, not `getAccessToken()` (see “Restoring a session on startup”) |
| `logout()` | Clears tokens, emits `'logout'`, fires a best-effort `POST /authentication/logout` |
| `url` | The server’s base URL (trailing slashes removed) — for links to non-service routes, e.g. downloads |
| `getAccessToken()` | Current access token (synchronous, from the in-memory copy — may be `undefined` until something hydrates it, even with a valid session in storage) |
| `on(event, handler)` | Client events: `'authenticated'`, `'logout'`, `'reconnect'` |
| `off(event, handler)` | Remove a client event handler |
### `ServiceClient`
[Section titled “ServiceClient\”](#serviceclientt)
| Member | HTTP | Description |
| --------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `find(params?)` | `GET /:service` | `Promise>` |
| `get(id, params?)` | `GET /:service/:id` | `Promise` |
| `create(data, params?)` | `POST /:service` | `Promise` |
| `update(id, data, params?)` | `PUT /:service/:id` | `Promise` |
| `patch(id, data, params?)` | `PATCH /:service/:id` | `Promise` |
| `remove(id, params?)` | `DELETE /:service/:id` | `Promise` |
| `similar(data, params?)` | `POST /:service/similar` | Vector-search convention — `Promise>` |
| `upload(file, options?)` | `POST /:service` (or `PATCH /:service/:id` with `options.id`) | `multipart/form-data` for `handleUpload()` — see “File uploads” |
| `on(event, handler)` | — | Subscribe to `'created' \| 'updated' \| 'patched' \| 'removed'` |
| `off(event, handler)` | — | Unsubscribe; the socket listener detaches with the last handler |
| `realtime` | — | `true` when the client has the `socket` option — `on()`/`off()` are available |
***
## Types
[Section titled “Types”](#types)
```typescript
interface ClientParams {
query?: Record; // serialized into the URL
headers?: Record; // per-request header overrides
}
interface TokenStorage {
getItem(key: string): string | null | Promise;
setItem(key: string, value: string): void | Promise;
removeItem(key: string): void | Promise;
}
interface BatchOptions {
windowMs?: number; // coalescing window; 0 (default) = same microtask tick
maxSize?: number; // max calls per POST /batch; longer queues split. Default 25
}
interface AuthCredentials {
strategy: string;
[key: string]: unknown;
}
interface AuthResult {
accessToken: string;
refreshToken?: string;
user?: unknown;
}
interface Paginated {
total: number;
limit: number;
skip: number;
data: T[];
}
interface UploadOptions {
id?: Id; // PATCH /:service/:id instead of POST /:service
field?: string; // multipart field handleUpload(field) reads. Default "file"
fields?: Record; // ordinary form fields, sent before the file part
filename?: string; // default: the File's name, or "blob"
onProgress?: (progress: { loaded: number; total?: number; percent?: number }) => void; // XHR transport only
signal?: AbortSignal;
headers?: Record;
}
class MantleClientError extends Error {
code: number; // HTTP status
name: string; // server error class: "BadRequest", "NotFound", …
className?: string; // kebab-case class name from the wire format
data?: unknown;
errors?: unknown[];
hint?: string; // server's actionable guidance, when present
}
```
Tokens are stored under the keys `mantle-access-token` and `mantle-refresh-token`.
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build client # compile
npx nx test client # run tests
npx nx lint client # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
Build before publishing:
```bash
npx nx build client
```
First publish (scoped packages require `--access public`):
```bash
cd packages/client
npm publish --access public
```
Subsequent releases — bump `version` in `packages/client/package.json`, then:
```bash
cd packages/client
npm publish
```
### Testing locally with Verdaccio
[Section titled “Testing locally with Verdaccio”](#testing-locally-with-verdaccio)
```bash
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/client
npm publish --registry http://localhost:4873
# Install from it in another project
npm install @mantlejs/client --registry http://localhost:4873
```
# @mantlejs/config
> Environment-aware configuration management for Mantle JS. Loads JSON config files from a config/ directory, merges environment-specific overrides, applies MANTLE env var overrides, and optionally validates the result against a TypeBox schema at startup.
Environment-aware configuration management for [Mantle JS](https://github.com/mantlejs/mantle). Loads JSON config files from a `config/` directory, merges environment-specific overrides, applies `MANTLE_*` env var overrides, and optionally validates the result against a TypeBox schema at startup.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/config
```
For schema validation, also install the peer dependency:
```bash
npm install @sinclair/typebox
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### File loading order
[Section titled “File loading order”](#file-loading-order)
Config is assembled by merging three sources in order — later values override earlier ones:
1. `config/default.json` — base config present in all environments
2. `config/{NODE_ENV}.json` — environment-specific overrides (e.g. `config/production.json`). If `NODE_ENV` (or a custom `envVar`) is unset, this defaults to `"development"`, so `config/development.json` is the overlay loaded by default.
3. `MANTLE_*` environment variables — highest priority, applied last
### Environment variable overrides
[Section titled “Environment variable overrides”](#environment-variable-overrides)
Any env var prefixed with `MANTLE_` (configurable via `envPrefix`) maps to a config key. Double underscore (`__`) navigates nested objects:
```plaintext
MANTLE_PORT=8080 → config.port = 8080
MANTLE_DB__POOL__MAX=25 → config.db.pool.max = 25
```
Use `envPrefix` to namespace overrides to your own app name:
```plaintext
MYAPP_PORT=8080 → config.port = 8080
MYAPP_DB__POOL__MAX=25 → config.db.pool.max = 25
```
Values are coerced to `number` or `boolean` when the base config value has that type.
### Schema validation
[Section titled “Schema validation”](#schema-validation)
Pass a TypeBox schema via `options.schema` and the plugin validates the merged config at startup. A mismatch throws `GeneralError('Invalid configuration', { errors })` before the server accepts any requests — fail fast, fail loud.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```plaintext
config/
default.json
production.json
```
config/default.json
```json
{
"port": 3030,
"db": { "client": "pg", "pool": { "min": 2, "max": 10 } }
}
// config/production.json
{
"port": 8080,
"db": { "pool": { "max": 25 } }
}
```
```typescript
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { config } from "@mantlejs/config";
import { Type } from "@sinclair/typebox";
const AppConfigSchema = Type.Object({
port: Type.Number(),
db: Type.Object({
client: Type.String(),
pool: Type.Object({ min: Type.Number(), max: Type.Number() }),
}),
});
const app = mantle()
.configure(express())
.configure(config({ schema: AppConfigSchema }));
// Read the whole merged config
const cfg = app.get<{ port: number; db: { client: string } }>("config");
// Top-level keys are also set individually for convenience
const port = app.get("port"); // 3030 (or 8080 in production)
```
***
## API
[Section titled “API”](#api)
### `config(options?)`
[Section titled “config(options?)”](#configoptions)
Returns a `MantlePlugin`. Call via `app.configure(config(options))`.
```typescript
import { config } from "@mantlejs/config";
app.configure(
config({
directory: "config", // optional — default: process.cwd() + '/config'
schema: AppConfigSchema, // optional — TypeBox schema for startup validation
envVar: "NODE_ENV", // optional — env var that selects the overlay file
envPrefix: "MYAPP_", // optional — default: 'MANTLE_'
}),
);
```
Side effects:
* Sets `app.set('config', mergedConfig)` — the full merged object
* Sets `app.set(key, value)` for each top-level key in the merged config
#### `ConfigOptions`
[Section titled “ConfigOptions”](#configoptions-1)
| Field | Type | Default | Description |
| ----------- | --------- | --------------------------- | -------------------------------------------------------------------- |
| `directory` | `string` | `process.cwd() + '/config'` | Directory containing config JSON files |
| `schema` | `TSchema` | — | TypeBox schema; throws `GeneralError` if merged config is invalid |
| `envVar` | `string` | `"NODE_ENV"` | Env var used to select the environment overlay file |
| `envPrefix` | `string` | `"MANTLE_"` | Prefix for env var overrides — use your app name to avoid collisions |
***
## Accessing configuration
[Section titled “Accessing configuration”](#accessing-configuration)
```typescript
// Full merged config object
const cfg = app.get("config");
// Individual top-level keys (set as a convenience by the plugin)
const port = app.get("port");
const db = app.get("db"); // the whole "db" object — only top-level keys are set this way
// Nested values require reading the full config
const maxPool = app.get("config").db.pool.max;
```
***
## Environment variable overrides
[Section titled “Environment variable overrides”](#environment-variable-overrides-1)
| Env var | Maps to |
| -------------------------- | ----------------------------- |
| `MANTLE_PORT=8080` | `config.port = 8080` |
| `MANTLE_DB__CLIENT=mysql2` | `config.db.client = "mysql2"` |
| `MANTLE_DB__POOL__MAX=25` | `config.db.pool.max = 25` |
With `envPrefix: "MYAPP_"`:
| Env var | Maps to |
| ------------------------ | ------------------------- |
| `MYAPP_PORT=8080` | `config.port = 8080` |
| `MYAPP_DB__POOL__MAX=25` | `config.db.pool.max = 25` |
Key segments are lowercased. Values are coerced:
* If the existing value is a `number`, the env string is passed through `Number()`.
* If the existing value is a `boolean`, `"true"` and `"1"` become `true`; everything else becomes `false`.
* Otherwise the raw string is used.
***
## Types
[Section titled “Types”](#types)
```typescript
import type { ConfigOptions } from "@mantlejs/config";
```
| Type | Description |
| --------------- | ---------------------------- |
| `ConfigOptions` | Options passed to `config()` |
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build config # compile
npx nx test config # run tests
npx nx lint config # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
Build before publishing:
```bash
npx nx build config
```
First publish (scoped packages require `--access public`):
```bash
cd packages/config
npm publish --access public
```
Subsequent releases — bump `version` in `packages/config/package.json`, then:
```bash
cd packages/config
npm publish
```
# create-mantlejs
> Project initializer for Mantle JS — scaffold a new Mantle application with a single npm create command.
Project initializer for [Mantle JS](https://github.com/mantlejs/mantle) — scaffold a new Mantle application with a single `npm create` command.
***
## Installation
[Section titled “Installation”](#installation)
No installation needed. Run directly with your package manager:
```bash
npm create mantlejs@latest my-app
# or
npx create-mantlejs my-app
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### npm create convention
[Section titled “npm create convention”](#npm-create-convention)
`create-mantlejs` follows the npm initializer convention. Running `npm create mantlejs my-app` resolves to `create-mantlejs` on the npm registry and invokes the `create-mantlejs` bin. This means users never need to install the package globally.
`create-mantlejs` is a thin wrapper around `@mantlejs/cli`’s `newProject()` — see that package’s README for the full scaffold surface (database/auth/CORS/Redis options, what gets generated).
***
## Quick start
[Section titled “Quick start”](#quick-start)
```bash
# Scaffold a new project (interactive prompts)
npm create mantlejs@latest my-app
# Scaffold non-interactively (flags go after `--`)
npm create mantlejs@latest my-app -- --database pg --auth local --package-manager npm
# Move into the project
cd my-app
# Install dependencies
npm install
# Start the dev server
npm run dev
```
***
## API
[Section titled “API”](#api)
### `newProject(name, options)`
[Section titled “newProject(name, options)”](#newprojectname-options)
Re-exported from `@mantlejs/cli`; the bin’s programmatic entry point. Returns a `Promise` that resolves once the project has been scaffolded (and installed, unless `skipInstall` is set).
```typescript
import { newProject } from "create-mantlejs";
await newProject("my-app", {
database: "pg",
auth: "local",
packageManager: "npm",
});
```
#### CLI flags
[Section titled “CLI flags”](#cli-flags)
| Flag | Values | Default | Description |
| ------------------------ | -------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------ |
| `--database ` | `pg`, `sqlite`, `mongodb`, `none` | prompted | Database adapter |
| `--auth ` | `local`, `google`, `github`, `facebook`, `apple`, `microsoft`, `linkedin`, `twitter`, `none` | prompted | Auth strategy |
| `--cors` | — | `false` | Enable CORS on the transport |
| `--redis` | — | `false` | Wire `@mantlejs/auth-redis` state/refresh-token stores |
| `--package-manager ` | `npm`, `yarn`, `pnpm` | prompted | Package manager |
| `--skip-install` | — | `false` | Skip running install after scaffold |
***
## Types
[Section titled “Types”](#types)
```typescript
import type { NewProjectOptions, Database, Auth, PackageManager, Transport } from "create-mantlejs";
```
| Type | Description |
| ------------------- | -------------------------------------------------------------------------------------------------------------- |
| `NewProjectOptions` | Options accepted by `newProject()` |
| `Database` | `"pg" \| "sqlite" \| "mongodb" \| "none"` |
| `Auth` | `"local" \| "google" \| "github" \| "facebook" \| "apple" \| "microsoft" \| "linkedin" \| "twitter" \| "none"` |
| `PackageManager` | `"npm" \| "yarn" \| "pnpm"` |
| `Transport` | `"express"` |
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build create-mantlejs # compile
npx nx test create-mantlejs # run tests
npx nx lint create-mantlejs # lint
npx nx run create-mantlejs:e2e-scaffold # full scaffold → build → test → boot → CRUD → SIGTERM smoke test
```
***
## Publishing
[Section titled “Publishing”](#publishing)
Build before publishing:
```bash
npx nx build create-mantlejs
```
First publish:
```bash
cd packages/create-mantlejs
npm publish --access public
```
Subsequent releases — bump `version` in `packages/create-mantlejs/package.json`, then:
```bash
cd packages/create-mantlejs
npm publish
```
### Testing locally with Verdaccio
[Section titled “Testing locally with Verdaccio”](#testing-locally-with-verdaccio)
```bash
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/create-mantlejs
npm publish --registry http://localhost:4873
# Test the initializer
npm create mantlejs my-test-app --registry http://localhost:4873
```
# @mantlejs/dynamodb
> Amazon DynamoDB adapter for Mantle JS. Provides a dynamodb() Mantle plugin and an abstract DynamoDbRepository base class that implements the Repository interface using the AWS SDK v3.
Amazon DynamoDB adapter for [Mantle JS](https://github.com/mantlejs/mantle). Provides a `dynamodb()` Mantle plugin and an abstract `DynamoDbRepository` base class that implements the `Repository` interface using the AWS SDK v3.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/dynamodb @aws-sdk/client-dynamodb @aws-sdk/util-dynamodb
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### Plugin
[Section titled “Plugin”](#plugin)
`dynamodb()` is a Mantle plugin. It creates an `@aws-sdk/client-dynamodb` `DynamoDBClient` instance and stores it on the application at `app.get("dynamodb")`. When the application tears down, the client is destroyed automatically.
### Repository
[Section titled “Repository”](#repository)
`DynamoDbRepository` implements the Mantle `Repository` contract against DynamoDB. Extend it with a concrete class, declare `tableName`, and the full set of methods (`findAll`, `findById`, `save`, `saveAll`, `updateById`, `patchById`, `deleteById`, `count`) work out of the box.
The repository uses:
* **GetItem** for `findById`
* **PutItem** for `save`
* **BatchWriteItem** for `saveAll` (auto-chunked at 25 items per DynamoDB limit)
* **UpdateItem** with a condition expression for `updateById` and `patchById`
* **DeleteItem** with a condition expression for `deleteById`
* **Scan** for `findAll` (falls back to **Query** when a sort key is configured and the partition key is in the where clause)
* **Scan with SELECT COUNT** for `count`
### Query translation
[Section titled “Query translation”](#query-translation)
`dynamodbify()` converts a Mantle `QueryParams.where` clause into a DynamoDB `FilterExpression`. Supported operators:
| Operator | DynamoDB expression |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `{ field: value }` | `#n = :v` |
| `{ field: null }` | `(attribute_not_exists(#n) OR #n = :v)` — matches a missing attribute or an explicit DynamoDB `NULL`-type value |
| `{ field: { $gt } }` | `#n > :v` |
| `{ field: { $gte } }` | `#n >= :v` |
| `{ field: { $lt } }` | `#n < :v` |
| `{ field: { $lte } }` | `#n <= :v` |
| `{ field: { $ne: value } }` | `#n <> :v` |
| `{ field: { $ne: null } }` | `attribute_exists(#n)` |
| `{ field: { $in: [...] } }` | `#n IN (...)` |
| `{ field: [a, b] }` | `#n IN (...)` (shorthand) |
| `{ field: { $nin: [...] } }` | `NOT (#n IN (...))` |
| `{ field: { $begins: "prefix" } }` | `begins_with(#n, :v)` |
| `{ field: { $contains: "str" } }` | `contains(#n, :v)` — scalar: substring/element match |
| `{ field: { $contains: [...] } }` | `(contains(#n,:v0) AND contains(#n,:v1) ...)` — one ANDed `contains()` per element (every element required, matching the memory/supabase/knex-pg reference semantics) |
| `{ field: { $contains: {...} } }` | flattened into ANDed leaf conditions, one per nested path — DynamoDB has no native nested-object containment function, so a superset check on an object operand is expressed as “every leaf key/value matches at its path” |
| `{ "a.b.c": value }` | `#n0.#n1.#n2 = :v` — nested attribute path (each segment gets its own alias) |
| `{ $or: [...] }` | `(expr OR expr)` |
| `{ $and: [...] }` | `(expr AND expr)` |
`$like` is deliberately unsupported (DynamoDB has no wildcard matching) and throws `BadRequest`.
Nested dot-path fields (`"metadata.owner.name"`) address real nested attributes — DynamoDB supports this natively via multi-segment `ExpressionAttributeNames` aliases (`#n0.#n1.#n2`), and every operator above (including `$in`/`$nin`/null-checks) works the same whether the field is top-level or nested — DynamoDB’s expression language treats a nested document path like any other operand, unlike SQL dialects where `IN`/null-checks against a JSON path need special handling that isn’t always available.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { dynamodb, DynamoDbRepository } from "@mantlejs/dynamodb";
// ─── Domain entity ─────────────────────────────────────────────────────────
interface Post extends Record {
id: string;
title: string;
body: string;
status: "draft" | "published";
}
// ─── Infrastructure ────────────────────────────────────────────────────────
class PostRepository extends DynamoDbRepository {
readonly tableName = "posts";
// partitionKey defaults to "id"
/** Custom query: find all published posts */
async findPublished(): Promise {
return this.findAll({ where: { status: "published" } });
}
}
// ─── Application ───────────────────────────────────────────────────────────
const app = mantle()
.configure(express())
.configure(dynamodb({ region: "us-east-1" }));
const posts = new PostRepository(app);
app.use("/posts", new PostService(posts), {
methods: ["find", "get", "create", "update", "patch", "remove"],
});
app.listen(3030);
```
### Local development (DynamoDB Local)
[Section titled “Local development (DynamoDB Local)”](#local-development-dynamodb-local)
```typescript
const app = mantle().configure(
dynamodb({
clientConfig: {
region: "us-east-1",
endpoint: "http://localhost:8000",
credentials: { accessKeyId: "local", secretAccessKey: "local" },
},
}),
);
```
### Composite key (partition + sort key)
[Section titled “Composite key (partition + sort key)”](#composite-key-partition--sort-key)
```typescript
interface OrderItem extends Record {
orderId: string;
itemId: string;
quantity: number;
}
class OrderItemRepository extends DynamoDbRepository {
readonly tableName = "order-items";
override readonly partitionKey = "orderId";
override readonly sortKey = "itemId";
}
// findById with composite key
const item = await repo.findById({ pk: "ORDER#123", sk: "ITEM#456" });
// findAll — Query is used automatically when partitionKey is in the where clause
const items = await repo.findAll({ where: { orderId: "ORDER#123" } });
```
***
## API
[Section titled “API”](#api)
### `dynamodb(config?)`
[Section titled “dynamodb(config?)”](#dynamodbconfig)
Returns a `MantlePlugin`. Call via `app.configure(dynamodb(config))`.
```typescript
app.configure(dynamodb({
region: "us-east-1", // optional — AWS region
clientConfig: { ... }, // optional — full DynamoDBClientConfig (overrides region)
}));
```
Side effects:
* Stores the `DynamoDBClient` at `app.get("dynamodb")`
* Wraps `app.teardown()` to call `client.destroy()` on shutdown
#### `DynamoDbConfig`
[Section titled “DynamoDbConfig”](#dynamodbconfig-1)
| Field | Type | Default | Description |
| -------------- | ---------------------- | ------- | ------------------------------------------------------------ |
| `region` | `string` | — | AWS region. Falls back to `AWS_REGION` env var when omitted. |
| `clientConfig` | `DynamoDBClientConfig` | — | Full AWS SDK client config. Takes precedence over `region`. |
***
### `DynamoDbRepository`
[Section titled “DynamoDbRepository\”](#dynamodbrepositoryt-d)
Abstract base class. Extend it, declare `tableName`, and override optional properties.
```typescript
class UserRepository extends DynamoDbRepository {
readonly tableName = "users";
// Optional overrides:
override readonly partitionKey = "userId";
override readonly sortKey = "email";
override readonly timestamps = false;
}
```
#### Properties
[Section titled “Properties”](#properties)
| Property | Type | Default | Description |
| -------------- | --------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------- |
| `tableName` | `string` | — | DynamoDB table name **(required)** |
| `partitionKey` | `string` | `"id"` | Partition key attribute name |
| `sortKey` | `string \| undefined` | `undefined` | Sort key attribute name (composite key tables) |
| `timestamps` | `boolean` | `true` | Auto-write `createdAt` / `updatedAt` ISO-8601 strings |
| `lastKey` | `Record \| undefined` | `undefined` | **Deprecated** — use `findPage()`. `LastEvaluatedKey` from the most recent paginated `findAll()` call |
#### Methods (from `Repository`)
[Section titled “Methods (from Repository\)”](#methods-from-repositoryt)
| Method | Description |
| ---------------------- | --------------------------------------------------------------------- |
| `findAll(params?)` | Scan (or Query when PK is in where). Supports `skip`/`limit` offsets. |
| `findPage(params?)` | One page via native cursor pagination — see below |
| `findById(id)` | GetItem by partition key (or composite key) |
| `save(data)` | PutItem — auto-generates UUID when no `partitionKey` is set |
| `saveAll(data[])` | BatchWriteItem in chunks of 25 — auto-generates UUIDs as needed |
| `updateById(id, data)` | UpdateItem (full replacement, throws NotFound if missing) |
| `patchById(id, data)` | UpdateItem (partial, strips `undefined`, throws NotFound if missing) |
| `deleteById(id)` | DeleteItem (throws NotFound if missing) |
| `count(params?)` | Scan SELECT COUNT (paginates automatically) |
| `withTransaction(fn)` | Execute writes atomically via TransactWriteItems (100-item limit) |
#### Cursor pagination
[Section titled “Cursor pagination”](#cursor-pagination)
DynamoDB is natively cursor-paginated. Use `findPage()` — each page carries an opaque `cursor` (the `LastEvaluatedKey`, base64-JSON encoded); pass it back to fetch the next page:
```typescript
const repo = new PostRepository(app);
// First page
const page1 = await repo.findPage({ limit: 20 });
// Next page — pass the cursor from the previous page
if (page1.cursor) {
const page2 = await repo.findPage({ limit: 20, cursor: page1.cursor });
}
```
`findPage()` is stateless — the cursor lives entirely in the returned page, so concurrent calls on one repository instance never interfere. It uses Query when a `sortKey` is defined and the where clause pins the partition key, otherwise Scan. `skip` and `sort` are rejected with `BadRequest` (offsets and cross-page ordering don’t compose with cursors — use `findAll()` for those). Note that with a filtering `where`, DynamoDB applies `limit` *before* filtering, so a page may hold fewer than `limit` items while `cursor` is still set.
> **Deprecated:** the previous mechanism — reading `repo.lastKey` after `findAll()` and passing it back as `_startKey` — still works but shares mutable state on the repository instance. It will be removed in the next minor release.
`skip` on `findAll()` performs in-memory offset (scans all pages) — use `findPage()` for large tables.
#### Transactions
[Section titled “Transactions”](#transactions)
```typescript
await repo.withTransaction(async (tx) => {
await tx.save({ id: "order-1", status: "pending", total: 99.99 });
await tx.save({ id: "item-1", orderId: "order-1", qty: 2 });
});
```
In transaction mode, mutation methods return the input data immediately (DynamoDB `TransactWriteItems` does not support `ReturnValues`). All writes commit atomically when the callback resolves.
***
### `dynamodbify(where)`
[Section titled “dynamodbify(where)”](#dynamodbifywhere)
Converts a Mantle `where` clause to a DynamoDB `FilterExpression`. Useful when writing raw SDK commands inside a custom repository method.
```typescript
import { dynamodbify } from "@mantlejs/dynamodb";
const { expression, names, values } = dynamodbify({
status: "active",
age: { $gt: 18 },
});
// expression: "#n0 = :v0 AND #n1 > :v1"
```
### `buildKeyCondition(partitionKey, sortKey, where)`
[Section titled “buildKeyCondition(partitionKey, sortKey, where)”](#buildkeyconditionpartitionkey-sortkey-where)
Splits a where clause into a `KeyConditionExpression` and an optional `FilterExpression` for use in `QueryCommand`.
***
### Error mapping
[Section titled “Error mapping”](#error-mapping)
AWS SDK errors are translated to typed `MantleError` subclasses by exception name:
| DynamoDB exception name | Error thrown |
| -------------------------------------------------------------------------------------- | ------------------------ |
| Already a MantleError (e.g. thrown by a hook, or by a custom repository method) | passed through unchanged |
| `ResourceNotFoundException` | `NotFound` |
| `ConditionalCheckFailedException` | `NotFound` |
| `ProvisionedThroughputExceededException`, `RequestLimitExceeded`, `ServiceUnavailable` | `Unavailable` |
| `AccessDeniedException`, `UnauthorizedException` | `Forbidden` |
| `ValidationException` | `BadRequest` |
| `TransactionConflictException`, `TransactionCanceledException` | `Conflict` |
| other (including a non-`Error` throw) | `GeneralError` |
***
## Types
[Section titled “Types”](#types)
```typescript
import type { DynamoDbConfig } from "@mantlejs/dynamodb";
import type { FilterExpression, WhereClause } from "@mantlejs/dynamodb";
```
| Type | Description |
| ------------------ | ---------------------------------------------------------------- |
| `DynamoDbConfig` | Options passed to `dynamodb()` |
| `WhereClause` | Input type for `dynamodbify()` and `buildKeyCondition()` |
| `FilterExpression` | Return type of `dynamodbify()` — `{ expression, names, values }` |
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build dynamodb # compile
npx nx test dynamodb # run tests
npx nx lint dynamodb # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
Build before publishing:
```bash
npx nx build dynamodb
```
First publish (scoped packages require `--access public`):
```bash
cd packages/dynamodb
npm publish --access public
```
Subsequent releases — bump `version` in `packages/dynamodb/package.json`, then:
```bash
cd packages/dynamodb
npm publish
```
### Testing locally with Verdaccio
[Section titled “Testing locally with Verdaccio”](#testing-locally-with-verdaccio)
```bash
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/dynamodb
npm publish --registry http://localhost:4873
# Install from it in another project
npm install @mantlejs/dynamodb --registry http://localhost:4873
```
# @mantlejs/embeddings
> Auto-embed-on-write hook for Mantle JS. Calls a pluggable embedding provider and upserts the result into a VectorRepository after a create/update/patch — "one line instead of a hand-written trigger."
Auto-embed-on-write hook for [Mantle JS](https://github.com/mantlejs/mantle). Calls a pluggable embedding provider and upserts the result into a `VectorRepository` after a create/update/patch — “one line instead of a hand-written trigger.”
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/embeddings
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### Why this package exists
[Section titled “Why this package exists”](#why-this-package-exists)
`@mantlejs/pinecone`, `@mantlejs/qdrant`, `@mantlejs/mongodb`’s `MongoVectorRepository`, and `@mantlejs/knex`’s `KnexVectorRepository` are all deliberately model-agnostic: the caller must always supply an already-computed `number[]` vector — none of them generates an embedding from source text. `embed()` is the missing “text in, vector attached” step, as a hook rather than something every service hand-rolls in its own `create`/`update`/`patch` methods.
### Bring your own provider
[Section titled “Bring your own provider”](#bring-your-own-provider)
`@mantlejs/embeddings` never bundles a specific embedding model — no OpenAI/Cohere/etc. SDK dependency. `EmbeddingProvider` is a one-method interface:
```typescript
interface EmbeddingProvider {
dimensions?: number;
embed(text: string): Promise;
}
```
Wrap whichever provider you use behind it — an HTTP call to an embedding API, a local model, or (for demos) a deterministic hash-based stand-in.
### The cross-adapter write-consistency pattern, made concrete
[Section titled “The cross-adapter write-consistency pattern, made concrete”](#the-cross-adapter-write-consistency-pattern-made-concrete)
See the root [README’s “Services with multiple repositories”](https://github.com/MantleJs/mantle/blob/main/README.md#services-with-multiple-repositories) section for the general pattern; `embed()` is its reference implementation:
* **Idempotent, keyed on the source record’s id.** `upsertVector(id, vector, data)` is an upsert on every adapter — re-running `embed()` for the same id (a retry, or a second edit) replaces the vector at that id rather than duplicating it. This comes from the adapter contract, not from anything `embed()` does itself.
* **Safe to retry.** Because it’s an idempotent upsert, calling it again after a partial failure is always safe.
* **Non-fatal on failure.** A provider error or a vector-store error is caught inside the hook and never rethrown — the primary write already succeeded (or already failed for its own reason), and a degraded embedding provider must never turn that into a failed request for the caller.
### Attach after create/update/patch — never after find/get/remove
[Section titled “Attach after create/update/patch — never after find/get/remove”](#attach-after-createupdatepatch--never-after-findgetremove)
`embed()` reads the just-written record off `ctx.result`. Attach it to `after.create`/`after.update`/`after.patch`, where a freshly-written single record is guaranteed to be there. It’s a no-op (skips) if `ctx.result` is an array or a paginated page — i.e. it’s safe to *not* worry about accidentally attaching it to `after.find`, but there’s still nothing useful for it to do there.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
import { http } from "@mantlejs/http";
import { knex, KnexVectorRepository } from "@mantlejs/knex";
import { embed } from "@mantlejs/embeddings";
import type { EmbeddingProvider } from "@mantlejs/embeddings";
const app = mantle()
.configure(http())
.configure(knex({ client: "pg", connection: process.env.DATABASE_URL }));
class ArticleVectorRepository extends KnexVectorRepository {
readonly tableName = "articles";
readonly vectorColumn = "embedding";
}
const provider: EmbeddingProvider = {
dimensions: 1536,
async embed(text) {
const res = await fetch("https://api.openai.com/v1/embeddings", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ model: "text-embedding-3-small", input: text }),
});
const { data } = await res.json();
return data[0].embedding;
},
};
const articleVectors = new ArticleVectorRepository(app);
const embedArticle = embed({ vectors: articleVectors, provider, field: ["title", "body"] });
app.use("articles", new ArticleService(new ArticleRepository(app)));
app.service("articles").hooks({
after: {
create: [embedArticle],
update: [embedArticle],
patch: [embedArticle],
},
});
```
***
## API
[Section titled “API”](#api)
### `embed(options)`
[Section titled “embed(options)”](#embedoptions)
Returns a `HookFunction`.
| Field | Type | Default | Description |
| ---------- | ------------------------------------------------------ | ------------------------------------ | ------------------------------------------------------------------------------------------ |
| `vectors` | `VectorRepository` | — | Where the computed embedding is upserted (required) |
| `provider` | `EmbeddingProvider` | — | Called with the extracted text to produce the vector (required) |
| `field` | `keyof T \| Array \| ((record: T) => string)` | — | Source text: one field, several fields joined with `"\n"`, or a function (required) |
| `onError` | `(error, record, ctx) => void` | logs via `app.get("logger")` | Called when extraction, the provider, or the upsert throws. Never fails the primary write. |
### `EmbeddingProvider`
[Section titled “EmbeddingProvider”](#embeddingprovider)
```typescript
interface EmbeddingProvider {
dimensions?: number;
embed(text: string): Promise;
}
```
***
## Types
[Section titled “Types”](#types)
```typescript
import type { EmbedOptions, EmbeddingProvider } from "@mantlejs/embeddings";
```
| Type | Description |
| ------------------- | ------------------------------------- |
| `EmbedOptions` | Options passed to `embed()` |
| `EmbeddingProvider` | The pluggable text-to-vector contract |
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build embeddings # compile
npx nx test embeddings # run tests
npx nx lint embeddings # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
Build before publishing:
```bash
npx nx build embeddings
```
First publish (scoped packages require `--access public`):
```bash
cd packages/embeddings
npm publish --access public
```
Subsequent releases — bump `version` in `packages/embeddings/package.json`, then:
```bash
cd packages/embeddings
npm publish
```
### Testing locally with Verdaccio
[Section titled “Testing locally with Verdaccio”](#testing-locally-with-verdaccio)
```bash
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/embeddings
npm publish --registry http://localhost:4873
```
# @mantlejs/express
> Express HTTP transport adapter for Mantle JS. Mounts each registered service as a set of REST routes and converts MantleError instances to the correct HTTP status codes.
Express HTTP transport adapter for Mantle JS. Mounts each registered service as a set of REST routes and converts `MantleError` instances to the correct HTTP status codes.
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/express express
npm install --save-dev @types/express
```
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
const app = mantle().configure(express());
app.use("users", new UserService(), {
methods: ["find", "get", "create", "patch", "remove"],
});
app.listen(3030, () => console.log("Listening on port 3030"));
```
## API
[Section titled “API”](#api)
### `express(existingApp?, options?)`
[Section titled “express(existingApp?, options?)”](#expressexistingapp-options)
Returns a `MantlePlugin`. When applied via `app.configure(express())`, it:
* Creates (or reuses) an Express application and stores it under `app.get("http:router")` — the transport-neutral contract other Mantle packages (`@mantlejs/auth-oauth`, `@mantlejs/mcp`, `@mantlejs/openapi`) use to mount their own raw routes regardless of which HTTP transport is configured.
* Patches `app.use()` so that every service registration automatically mounts REST routes.
* Attaches an error handler after all routes are registered.
* Adds `app.listen(port, callback?)` as a shorthand for `expressApp.listen(...)`.
* Optionally mounts a `GET /_services` introspection endpoint (see below).
#### `ExpressOptions`
[Section titled “ExpressOptions”](#expressoptions)
| Field | Type | Default | Description |
| --------------- | ------------------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `introspection` | `boolean \| { path?: string }` | `false` | Mount the introspection endpoint; pass `{ path }` to customize it. |
| `batch` | `boolean \| { path?: string; maxSize?: number }` | `true` | Mount the `POST /batch` endpoint; `false` disables it, `{ path, maxSize }` configures it. |
| `cors` | `boolean \| CorsOptions` | `false` | Enable CORS via the `cors` npm package; `true` uses permissive defaults, an object customizes origin/methods/headers/credentials. |
Pass an existing Express app if you need to share middleware or configure Express yourself:
```typescript
import expressLib from "express";
import { express } from "@mantlejs/express";
const expressApp = expressLib();
expressApp.use(cors());
const app = mantle().configure(express(expressApp));
```
Add raw Express middleware by passing a function to `app.use()`:
```typescript
app.use(morgan("dev"));
```
***
### Route mapping
[Section titled “Route mapping”](#route-mapping)
Each method in `options.methods` is mapped to an HTTP route:
| Service method | HTTP route | Status |
| -------------- | -------------------- | ------ |
| `find` | `GET /path` | 200 |
| `get` | `GET /path/:id` | 200 |
| `create` | `POST /path` | 201 |
| `update` | `PUT /path/:id` | 200 |
| `patch` | `PATCH /path/:id` | 200 |
| `remove` | `DELETE /path/:id` | 200 |
| custom method | `POST /path/:method` | 200 |
Custom methods (any name beyond the six standard ones) are mounted as `POST /path/:method` and routed through `ServiceHandle.dispatch()`.
Query string parameters are passed to the service as `params.query`. Request headers are available as `params.headers`. `params.provider` is always `"rest"` for HTTP-originated calls.
***
### Introspection endpoint
[Section titled “Introspection endpoint”](#introspection-endpoint)
Opt in to a machine-readable service catalog:
```typescript
app.configure(express(undefined, { introspection: true }));
// or with a custom path:
app.configure(express(undefined, { introspection: { path: "/__meta" } }));
```
`GET /_services` (or the custom path) then returns a `ServiceDescriptor[]` — one entry per registered service with `path`, `methods`, `events`, `schema`, the repository’s `capabilities` (when the service exposes them, e.g. `RepositoryService`), and `authRequired`. Off by default; without the option the route 404s.
***
### Batch endpoint
[Section titled “Batch endpoint”](#batch-endpoint)
`POST /batch` (mounted by default) dispatches an array of calls through `app.batch()` in one round trip — useful for any caller that fires several related requests at once, especially AI agents constructing raw HTTP:
```bash
curl -X POST http://localhost:3030/batch -H "content-type: application/json" -d '[
{ "service": "users", "method": "get", "id": 1 },
{ "service": "messages", "method": "find", "params": { "query": { "$limit": 5 } } }
]'
```
Each call runs the target service’s **full hook pipeline** (including `authenticate("jwt")`) with the batch request’s headers — batch is not a way to bypass authentication or validation. The response is a `BatchResult[]` in the same order as the request array; calls execute concurrently and each entry independently reports `{ status: "success", result }` or `{ status: "error", error }` (no cross-call atomicity). Batches over `maxSize` (default 25) are rejected with `400 BadRequest` before any call executes.
```typescript
app.configure(express(undefined, { batch: false })); // disable
app.configure(express(undefined, { batch: { path: "/_batch", maxSize: 50 } }));
```
`@mantlejs/client` can coalesce same-tick service calls into this endpoint automatically — see its `batch` option.
***
### CORS
[Section titled “CORS”](#cors)
Disabled by default — consistent with Mantle’s secure-by-default posture elsewhere. Enable it with `cors: true` for permissive defaults, or pass a `CorsOptions` object to customize:
```typescript
app.configure(express(undefined, { cors: true })); // reflects Origin, allows GET/POST/PUT/PATCH/DELETE, no credentials
app.configure(
express(undefined, {
cors: { origin: ["https://app.example.com"], credentials: true },
}),
);
```
`CorsOptions` (exported from `@mantlejs/mantle`) is shared across `@mantlejs/express`, `@mantlejs/koa`, and `@mantlejs/http` so switching transports doesn’t require relearning CORS configuration:
| Field | Type | Default | Description |
| ---------------- | ------------------------------------------------------------------ | ------------------------------------------- | ---------------------------------------------------------- |
| `origin` | `boolean \| string \| string[] \| ((origin) => boolean \| string)` | `true` (reflect the request’s `Origin`) | Allowed origin(s) for `Access-Control-Allow-Origin`. |
| `methods` | `string[]` | `["GET", "POST", "PUT", "PATCH", "DELETE"]` | Allowed methods for `Access-Control-Allow-Methods`. |
| `allowedHeaders` | `string[]` | reflects `Access-Control-Request-Headers` | Allowed request headers. |
| `exposedHeaders` | `string[]` | none | Headers exposed to the browser. |
| `credentials` | `boolean` | `false` | Allow credentials (cookies, `Authorization`) cross-origin. |
| `maxAge` | `number` | none | Preflight cache duration in seconds. |
`@mantlejs/express` wraps the `cors` npm package internally — it’s installed as a dependency of `@mantlejs/express`, not something you need to add yourself.
***
### `errorHandler()`
[Section titled “errorHandler()”](#errorhandler)
Standalone Express error-handling middleware. It is attached automatically by `express()`, but can also be used directly if you manage your own Express app:
```typescript
import { errorHandler } from "@mantlejs/express";
expressApp.use(errorHandler());
```
`MantleError` subclasses are serialized to JSON with the correct HTTP status code. Any other error becomes a 500 response.
## Development
[Section titled “Development”](#development)
```bash
npx nx build express # compile
npx nx test express # run tests
npx nx lint express # lint
```
## Publishing
[Section titled “Publishing”](#publishing)
Build before publishing:
```bash
npx nx build express
```
First publish (scoped packages require `--access public`):
```bash
cd packages/express
npm publish --access public
```
Subsequent releases — bump `version` in `packages/express/package.json`, then:
```bash
cd packages/express
npm publish
```
### Testing locally with Verdaccio
[Section titled “Testing locally with Verdaccio”](#testing-locally-with-verdaccio)
```bash
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/express
npm publish --registry http://localhost:4873
# Install from it in another project
npm install @mantlejs/express --registry http://localhost:4873
```
# @mantlejs/http
> Zero-dependency HTTP transport adapter for Mantle JS. Exposes two handlers from a single http() plugin: a Node.js (req, res) handler for use with http.createServer, and a Fetch API (request: Request) => Promise handler for Cloudflare Workers, Vercel Edge Functions, AWS Lambda@Edge, and other edge runtimes. No Express or Koa required — route matching and body parsing are implemented from scratch using only Node.js built-ins.
Zero-dependency HTTP transport adapter for [Mantle JS](https://github.com/mantlejs/mantle). Exposes two handlers from a single `http()` plugin: a Node.js `(req, res)` handler for use with `http.createServer`, and a Fetch API `(request: Request) => Promise` handler for Cloudflare Workers, Vercel Edge Functions, AWS Lambda\@Edge, and other edge runtimes. No Express or Koa required — route matching and body parsing are implemented from scratch using only Node.js built-ins.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/http
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### Two handlers, one plugin
[Section titled “Two handlers, one plugin”](#two-handlers-one-plugin)
`http()` registers two handlers on the Mantle application:
* **`httpHandler`** (`NodeHttpHandler`) — a `(req: IncomingMessage, res: ServerResponse) => void` callback compatible with Node.js `http.createServer`. Calling `app.listen(port)` automatically creates a server using this handler.
* **`fetchHandler`** (`FetchHandler`) — a `(request: Request) => Promise` callback for edge runtimes that accept standard Fetch API objects.
Both handlers run the full Mantle hook pipeline and set `params.provider = 'http'`.
### Zero dependencies
[Section titled “Zero dependencies”](#zero-dependencies)
`@mantlejs/http` only depends on `@mantlejs/mantle` and Node.js built-ins. Route matching and JSON body parsing are implemented from scratch — no Express, Koa, or other HTTP framework is pulled in.
### Edge runtime compatibility
[Section titled “Edge runtime compatibility”](#edge-runtime-compatibility)
`fetchHandler` is compatible with any runtime that supports the [WinterCG](https://wintercg.org/) minimum common API — `Request`, `Response`, `URL`, and `crypto.randomUUID()`. Tested platforms:
| Platform | Runtime | Import style |
| ----------------------- | ---------- | ---------------- |
| Cloudflare Workers | V8 isolate | npm (bundled) |
| Vercel Edge Functions | V8 isolate | npm (bundled) |
| Supabase Edge Functions | Deno | `npm:` specifier |
| Deno Deploy | Deno | `npm:` specifier |
| AWS Lambda\@Edge | Node.js | npm |
| Bun | Bun | npm |
Edge runtimes are **stateless and ephemeral** — each isolate handles one request (or a small batch). This means `app.listen()` is irrelevant in these environments; retrieve the `fetchHandler` directly instead. Anything requiring a persistent connection (database pool, socket.io, Redis) must be re-established per request or routed through a connection proxy.
### Correlation IDs
[Section titled “Correlation IDs”](#correlation-ids)
Every request gets an `x-correlation-id` header in the response. If the incoming request supplies one, it is echoed back. The correlation ID is available inside hooks via `getContext().correlationId`.
***
## Quick start
[Section titled “Quick start”](#quick-start)
### Node.js server
[Section titled “Node.js server”](#nodejs-server)
```typescript
import { mantle } from "@mantlejs/mantle";
import { http } from "@mantlejs/http";
const app = mantle().configure(http());
app.use("users", new UserService(new UserRepository(app)));
app.listen(3030, () => console.log("Listening on :3030"));
```
### Cloudflare Workers
[Section titled “Cloudflare Workers”](#cloudflare-workers)
```typescript
import { mantle } from "@mantlejs/mantle";
import { http } from "@mantlejs/http";
import type { FetchHandler } from "@mantlejs/http";
const app = mantle().configure(http());
app.use("users", new UserService(new UserRepository(app)));
const handler = app.get("fetchHandler") as FetchHandler;
export default { fetch: handler };
```
### Vercel Edge Functions
[Section titled “Vercel Edge Functions”](#vercel-edge-functions)
app/api/users/route.ts
```typescript
import { mantle } from "@mantlejs/mantle";
import { http } from "@mantlejs/http";
import type { FetchHandler } from "@mantlejs/http";
export const runtime = "edge";
const app = mantle().configure(http());
app.use("users", new UserService(new UserRepository(app)));
const handler = app.get("fetchHandler") as FetchHandler;
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const DELETE = handler;
```
### Supabase Edge Functions
[Section titled “Supabase Edge Functions”](#supabase-edge-functions)
Supabase Edge Functions run on Deno. Import the packages using the `npm:` specifier:
supabase/functions/api/index.ts
```typescript
import { mantle } from "npm:@mantlejs/mantle";
import { http } from "npm:@mantlejs/http";
import type { FetchHandler } from "npm:@mantlejs/http";
const app = mantle().configure(http());
app.use("users", new UserService(new UserRepository(app)));
const handler = app.get("fetchHandler") as FetchHandler;
Deno.serve(handler);
```
### Custom Node.js server
[Section titled “Custom Node.js server”](#custom-nodejs-server)
```typescript
import { createServer } from "node:http";
import { mantle } from "@mantlejs/mantle";
import { http } from "@mantlejs/http";
import type { NodeHttpHandler } from "@mantlejs/http";
const app = mantle().configure(http());
app.use("users", new UserService(new UserRepository(app)));
const handler = app.get("httpHandler") as NodeHttpHandler;
createServer(handler).listen(3030);
```
***
## API
[Section titled “API”](#api)
### `http(options?)`
[Section titled “http(options?)”](#httpoptions)
Returns a `MantlePlugin` that registers the HTTP transport on the application.
#### `HttpOptions`
[Section titled “HttpOptions”](#httpoptions-1)
| Field | Type | Default | Description |
| --------------- | ------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `introspection` | `boolean \| { path?: string }` | `false` | Mount the introspection endpoint; pass `{ path }` to customize it. |
| `batch` | `boolean \| { path?: string; maxSize?: number }` | `true` | Mount the `POST /batch` endpoint; `false` disables it, `{ path, maxSize }` configures it. |
| `cors` | `boolean \| CorsOptions` | `false` | Enable CORS via hand-rolled header logic; `true` uses permissive defaults, an object customizes origin/methods/headers/credentials. |
With `introspection` enabled, `GET /_services` (or the custom path) returns a `ServiceDescriptor[]` — one entry per registered service with `path`, `methods`, `events`, `schema`, the repository’s `capabilities` (when the service exposes them, e.g. `RepositoryService`), and `authRequired`. Off by default; without the option the route 404s.
```typescript
app.configure(http({ introspection: true }));
```
After calling `app.configure(http())`:
| `app.get(key)` | Type | Description |
| ---------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `'httpHandler'` | `NodeHttpHandler` | Node.js `(req, res) => void` handler for `http.createServer` |
| `'fetchHandler'` | `FetchHandler` | Fetch API `(request) => Promise` handler for edge runtimes |
| `'http:router'` | `HttpRouterLike` | The transport-neutral contract other Mantle packages (`@mantlejs/auth-oauth`, `@mantlejs/mcp`, `@mantlejs/openapi`) use to mount their own raw routes regardless of which HTTP transport is configured |
| `'http:server'` | `http.Server` | Set after `app.listen()` is called |
### Route conventions
[Section titled “Route conventions”](#route-conventions)
| HTTP method | Path | Mantle method |
| ----------- | ------------------ | ------------- |
| GET | `/service` | `find` |
| GET | `/service/:id` | `get` |
| POST | `/service` | `create` |
| PUT | `/service/:id` | `update` |
| PATCH | `/service/:id` | `patch` |
| DELETE | `/service/:id` | `remove` |
| POST | `/service/:method` | custom method |
### Batch endpoint
[Section titled “Batch endpoint”](#batch-endpoint)
`POST /batch` (mounted by default, on both handlers) dispatches an array of calls through `app.batch()` in one round trip:
```bash
curl -X POST http://localhost:3030/batch -H "content-type: application/json" -d '[
{ "service": "users", "method": "get", "id": 1 },
{ "service": "messages", "method": "find", "params": { "query": { "$limit": 5 } } }
]'
```
Each call runs the target service’s **full hook pipeline** (including `authenticate("jwt")`) with the batch request’s headers — batch is not a way to bypass authentication or validation. The response is a `BatchResult[]` in the same order as the request array; calls execute concurrently and each entry independently reports `{ status: "success", result }` or `{ status: "error", error }` (no cross-call atomicity). Batches over `maxSize` (default 25) are rejected with `400 BadRequest` before any call executes.
```typescript
app.configure(http({ batch: false })); // disable
app.configure(http({ batch: { path: "/_batch", maxSize: 50 } }));
```
`@mantlejs/client` can coalesce same-tick service calls into this endpoint automatically — see its `batch` option.
### CORS
[Section titled “CORS”](#cors)
Disabled by default — consistent with Mantle’s secure-by-default posture elsewhere. Enable it with `cors: true` for permissive defaults, or pass a `CorsOptions` object to customize:
```typescript
app.configure(http({ cors: true })); // reflects Origin, allows GET/POST/PUT/PATCH/DELETE, no credentials
app.configure(
http({
cors: { origin: ["https://app.example.com"], credentials: true },
}),
);
```
`CorsOptions` (exported from `@mantlejs/mantle`) is shared across `@mantlejs/express`, `@mantlejs/koa`, and `@mantlejs/http` so switching transports doesn’t require relearning CORS configuration:
| Field | Type | Default | Description |
| ---------------- | ------------------------------------------------------------------ | ------------------------------------------- | ---------------------------------------------------------- |
| `origin` | `boolean \| string \| string[] \| ((origin) => boolean \| string)` | `true` (reflect the request’s `Origin`) | Allowed origin(s) for `Access-Control-Allow-Origin`. |
| `methods` | `string[]` | `["GET", "POST", "PUT", "PATCH", "DELETE"]` | Allowed methods for `Access-Control-Allow-Methods`. |
| `allowedHeaders` | `string[]` | reflects `Access-Control-Request-Headers` | Allowed request headers. |
| `exposedHeaders` | `string[]` | none | Headers exposed to the browser. |
| `credentials` | `boolean` | `false` | Allow credentials (cookies, `Authorization`) cross-origin. |
| `maxAge` | `number` | none | Preflight cache duration in seconds. |
`@mantlejs/http` has no framework to delegate to, so CORS is hand-rolled: headers are set on every response for an allowed origin, and an `OPTIONS` preflight short-circuits with a `204` (on both `httpHandler` and `fetchHandler`) before the request reaches the router.
### `toErrorResponse(err)`
[Section titled “toErrorResponse(err)”](#toerrorresponseerr)
Utility exported for use in other adapters or middleware. Maps a `MantleError` (or any error) to `{ status: number; body: Record }`.
***
## Types
[Section titled “Types”](#types)
```typescript
import type { NodeHttpHandler, FetchHandler, HttpOptions } from "@mantlejs/http";
// Node.js handler
type NodeHttpHandler = (req: IncomingMessage, res: ServerResponse) => void;
// Edge / Fetch API handler
type FetchHandler = (request: Request) => Promise;
// Options accepted by http()
interface HttpOptions {
introspection?: boolean | { path?: string };
batch?: boolean | { path?: string; maxSize?: number };
cors?: boolean | CorsOptions;
}
```
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build http # compile
npx nx test http # run tests
npx nx lint http # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
Build before publishing:
```bash
npx nx build http
```
First publish (scoped packages require `--access public`):
```bash
cd packages/http
npm publish --access public
```
Subsequent releases — bump `version` in `packages/http/package.json`, then:
```bash
cd packages/http
npm publish
```
# @mantlejs/knex
> SQL database adapter for Mantle JS via Knex.js. Provides a knex() plugin that registers a shared Knex instance, and KnexRepository — an abstract base class that implements Repository for any SQL table.
SQL database adapter for Mantle JS via [Knex.js](https://knexjs.org). Provides a `knex()` plugin that registers a shared Knex instance, and `KnexRepository` — an abstract base class that implements `Repository` for any SQL table.
Supported databases: **PostgreSQL** (primary), **MySQL/MariaDB**, **SQLite**, **MSSQL**.
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/knex knex
# Add the driver for your database:
npm install pg # PostgreSQL
npm install mysql2 # MySQL / MariaDB
npm install better-sqlite3 # SQLite
npm install tedious # MSSQL
```
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
import { knex, KnexRepository } from "@mantlejs/knex";
const app = mantle().configure(
knex({ client: "pg", connection: process.env.DATABASE_URL }),
);
class UserRepository extends KnexRepository {
readonly tableName = "users";
}
app.use("users", new UserService(new UserRepository(app)));
```
## API
[Section titled “API”](#api)
### `knex(config)`
[Section titled “knex(config)”](#knexconfig)
Returns a `MantlePlugin`. When applied via `app.configure(knex(...))`, it creates a Knex instance, stores it under `app.get("knex")`, and registers a teardown callback that destroys the connection pool on `app.teardown()`.
```typescript
interface KnexConfig {
client: string; // 'pg' | 'mysql2' | 'sqlite3' | 'mssql' | ...
connection: Knex.Config["connection"]; // connection string or object
pool?: { min?: number; max?: number }; // defaults: min 2, max 10
searchPath?: string | string[]; // PostgreSQL schema search path
}
```
Examples:
```typescript
// PostgreSQL
app.configure(knex({ client: "pg", connection: process.env.DATABASE_URL }));
// SQLite
app.configure(knex({ client: "sqlite3", connection: { filename: "./dev.db" } }));
// MySQL
app.configure(knex({ client: "mysql2", connection: { host: "localhost", user: "root", database: "myapp" } }));
```
***
### `KnexRepository`
[Section titled “KnexRepository\”](#knexrepositoryt-d)
Abstract base class implementing `Repository`. Extend it and set `tableName`.
```typescript
abstract class KnexRepository> implements Repository {
abstract readonly tableName: string;
readonly idField: string = "id"; // override to use a different PK column
readonly timestamps: boolean = true; // auto-manages createdAt / updatedAt
}
```
#### Methods
[Section titled “Methods”](#methods)
| Method | Description |
| ---------------------- | ----------------------------------------------- |
| `findAll(params?)` | Returns all rows matching `QueryParams` |
| `findById(id)` | Returns one row or `null` |
| `save(data)` | Inserts a row, returns the inserted record |
| `saveAll(data[])` | Bulk-inserts rows, returns all inserted records |
| `updateById(id, data)` | Replaces a row, throws `NotFound` if missing |
| `patchById(id, data)` | Merges into a row, throws `NotFound` if missing |
| `deleteById(id)` | Deletes a row, throws `NotFound` if missing |
| `count(params?)` | Returns the count matching `QueryParams` |
`save`, `saveAll`, `updateById`, and `patchById` use `RETURNING *` on PostgreSQL, SQLite, and MSSQL. On MySQL/MariaDB they fall back to a follow-up `SELECT`.
#### Custom queries
[Section titled “Custom queries”](#custom-queries)
Use the `db` getter for ad-hoc queries against the table:
```typescript
class UserRepository extends KnexRepository {
readonly tableName = "users";
async findByEmail(email: string): Promise {
return (await this.db.where({ email }).first()) ?? null;
}
}
```
#### Transactions
[Section titled “Transactions”](#transactions)
```typescript
const repo = new UserRepository(app);
await repo.withTransaction(async (txRepo) => {
const user = await txRepo.save({ name: "Alice", email: "alice@example.com" });
await txRepo.save({ userId: user.id, role: "admin" });
});
```
`withTransaction` creates a transaction-scoped copy of the repository. All calls inside the callback share the same transaction and roll back automatically on error.
***
### `KnexVectorRepository` (pgvector)
[Section titled “KnexVectorRepository\ (pgvector)”](#knexvectorrepositoryt-d-pgvector)
Extends `KnexRepository` with `VectorRepository` support on PostgreSQL via [pgvector](https://github.com/pgvector/pgvector). Set `vectorColumn` (default `"embedding"`) and optionally `distanceOperator` (`"<=>"` cosine, `"<->"` L2, `"<#>"` inner product).
Every `findSimilar` result carries the computed pgvector distance as `_score` — **lower is more similar** (it is a distance, not a similarity, unlike the Pinecone/Qdrant adapters where higher wins). The same value is mirrored to the deprecated `_distance` field for one release.
#### Vector methods
[Section titled “Vector methods”](#vector-methods)
| Method | Description |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `findSimilar(vector, topK, params?)` | Top-K nearest records ordered by `distanceOperator`; see `_score` note above. |
| `upsertVector(id, vector, data)` | Inserts or updates a record and its embedding via `ON CONFLICT (idField) MERGE` — a real upsert, unlike the base `KnexRepository.save()`, which is create-only and throws `Conflict` on a duplicate id. |
| `deleteVector(id)` | Deletes the record and its embedding by id. Delegates to `deleteById`. |
All three methods throw `GeneralError` if the repository isn’t connected to a PostgreSQL (`pg`) client.
***
### `QueryParams` — supported `where` operators
[Section titled “QueryParams — supported where operators”](#queryparams--supported-where-operators)
```typescript
// Equality
{ email: "alice@example.com" }
// Null check
{ deletedAt: null } // IS NULL
{ deletedAt: { $ne: null } } // IS NOT NULL
// Comparison
{ age: { $gt: 18 } } // $lt | $lte | $gt | $gte
// Inequality
{ status: { $ne: "banned" } }
// Inclusion
{ role: { $in: ["admin", "editor"] } }
{ role: { $nin: ["guest"] } }
// Pattern matching (all clients — Knex compiles LIKE/ILIKE per dialect)
{ name: { $like: "Alice%" } }
{ name: { $ilike: "alice%" } } // case-insensitive
{ name: { $notlike: "Bob%" } }
// JSON containment (PostgreSQL: jsonb @>; MySQL: JSON_CONTAINS — both via whereJsonSupersetOf)
{ tags: { $contains: "blue" } } // contains element
{ tags: { $contains: ["red", "blue"] } } // contains every element
{ metadata: { $contains: { plan: "pro" } } } // object superset
// On SQLite/MSSQL — no native JSON-superset function — $contains throws BadRequest naming the
// operator and the connected client.
// Nested (dot-path) fields — PostgreSQL, MySQL, SQLite, and MSSQL, via Knex's own
// cross-dialect whereJsonPath (jsonb_path_query_first / JSON_EXTRACT / json_extract / JSON_VALUE)
{ "metadata.owner.name": "alice" } // equality
{ "metadata.level": { $gt: 4 } } // $lt | $lte | $gt | $gte
{ "metadata.owner.name": { $ne: "alice" } } // $ne
{ "metadata.owner.name": { $like: "al%" } } // $like | $notlike (all four clients)
{ "metadata.owner.name": { $ilike: "AL%" } } // PostgreSQL only — not standard SQL
{ "metadata.tags": { $contains: "a" } } // PostgreSQL + MySQL only, same restriction as above
// $in/$nin and null-checks on a dot-path field have no clean SQL translation and throw
// BadRequest — restructure the schema, or filter in application code after fetching.
// Logical
{ $or: [{ role: "admin" }, { role: "editor" }] }
{ $and: [{ active: true }, { age: { $gte: 18 } }] }
```
`describe().capabilities` reports exactly which of the above the *connected* client supports — check it rather than assuming, since `$contains` and `$ilike`-on-a-dot-path in particular vary by client (see `CLAUDE.md`’s operator table for the full per-client breakdown).
***
### Error mapping
[Section titled “Error mapping”](#error-mapping)
Database-level errors are translated to typed `MantleError` subclasses:
| PostgreSQL code prefix | Error thrown |
| ---------------------- | --------------- |
| `08`, `57` | `Unavailable` |
| `22` | `BadRequest` |
| `23505` (unique) | `Conflict` |
| other `23` | `BadRequest` |
| `28` | `Forbidden` |
| `3D`, `3F`, `42` | `Unprocessable` |
| other | `GeneralError` |
## Development
[Section titled “Development”](#development)
```bash
npx nx build knex # compile
npx nx test knex # run tests
npx nx lint knex # lint
```
## Publishing
[Section titled “Publishing”](#publishing)
Build before publishing:
```bash
npx nx build knex
```
First publish (scoped packages require `--access public`):
```bash
cd packages/knex
npm publish --access public
```
Subsequent releases — bump `version` in `packages/knex/package.json`, then:
```bash
cd packages/knex
npm publish
```
### Testing locally with Verdaccio
[Section titled “Testing locally with Verdaccio”](#testing-locally-with-verdaccio)
```bash
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/knex
npm publish --registry http://localhost:4873
# Install from it in another project
npm install @mantlejs/knex --registry http://localhost:4873
```
# @mantlejs/koa
> Koa HTTP transport adapter for Mantle JS. Routes incoming Koa requests to registered Mantle services and sends back typed JSON responses.
Koa HTTP transport adapter for [Mantle JS](https://github.com/mantlejs/mantle). Routes incoming Koa requests to registered Mantle services and sends back typed JSON responses.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/koa koa @koa/router @koa/bodyparser
```
***
## Concepts
[Section titled “Concepts”](#concepts)
### Transport adapter
[Section titled “Transport adapter”](#transport-adapter)
`koa()` is a Mantle plugin. It wraps a Koa application, mounts a REST router for every service registered with `app.use()`, and serialises the Mantle hook pipeline result as a JSON response. All HTTP concerns stay inside this package — services and hooks remain transport-agnostic.
### Route mapping
[Section titled “Route mapping”](#route-mapping)
Each Mantle service method maps to an HTTP verb and path:
| Method | HTTP verb | Path |
| -------- | --------- | ------------------ |
| `find` | `GET` | `/path` |
| `get` | `GET` | `/path/:id` |
| `create` | `POST` | `/path` |
| `update` | `PUT` | `/path/:id` |
| `patch` | `PATCH` | `/path/:id` |
| `remove` | `DELETE` | `/path/:id` |
| custom | `POST` | `/path/methodName` |
Only methods explicitly listed in the `app.use()` options are mounted.
### Provider
[Section titled “Provider”](#provider)
When a request arrives from Koa, `params.provider` is set to `"koa"`. Hooks can inspect this to distinguish HTTP calls from internal service calls (where `provider` is `undefined`).
### Correlation ID
[Section titled “Correlation ID”](#correlation-id)
Every response includes an `x-correlation-id` header. If the request supplies the header, it is echoed back. Otherwise a random UUID is generated. The ID is available inside hooks via `getContext().correlationId`.
### Error handling
[Section titled “Error handling”](#error-handling)
`MantleError` subclasses (`NotFound`, `BadRequest`, etc.) are mapped to their correct HTTP status codes. Unknown errors produce a `500 General Error` response.
### `@mantlejs/socketio` compatibility
[Section titled “@mantlejs/socketio compatibility”](#mantlejssocketio-compatibility)
After `app.listen()` the underlying `http.Server` is stored at `app.get("http:server")`. Socket.IO adapters can attach to this server without needing access to the Koa instance.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
import { koa } from "@mantlejs/koa";
const app = mantle().configure(koa());
app.use("/messages", new MessageService(new MessageRepository(app)), {
methods: ["find", "get", "create", "patch", "remove"],
});
app.listen(3030);
```
```http
GET /messages
```
```json
[{ "id": 1, "text": "Hello, world!" }]
```
### Using an existing Koa instance
[Section titled “Using an existing Koa instance”](#using-an-existing-koa-instance)
```typescript
import Koa from "koa";
import { mantle } from "@mantlejs/mantle";
import { koa } from "@mantlejs/koa";
const koaApp = new Koa();
koaApp.use(myCustomMiddleware());
const app = mantle().configure(koa({ app: koaApp }));
app.use("users", new UserService());
app.listen(3030);
```
***
## API
[Section titled “API”](#api)
### `koa(options?)`
[Section titled “koa(options?)”](#koaoptions)
Returns a `MantlePlugin`. Call via `app.configure(koa(options))`.
```typescript
import Koa from "koa";
// Use a fresh Koa instance (default)
app.configure(koa());
// Bring your own Koa instance
app.configure(koa({ app: new Koa() }));
```
Side effects:
* Stores a `@koa/router`-backed adapter under `app.get("http:router")` — the transport-neutral contract other Mantle packages (`@mantlejs/auth-oauth`, `@mantlejs/mcp`, `@mantlejs/openapi`) use to mount their own raw routes regardless of which HTTP transport is configured.
* Stores the Koa application at `app.get("koa")`
* Stores the `@koa/router` Router at `app.get("koa:router")`
* Stores the `http.Server` at `app.get("http:server")` after `app.listen()`
#### `KoaOptions`
[Section titled “KoaOptions”](#koaoptions-1)
| Field | Type | Default | Description |
| --------------- | ------------------------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------- |
| `app` | `Koa` (Koa instance) | — | Existing Koa application. A new one is created if omitted. |
| `introspection` | `boolean \| { path?: string }` | `false` | Mount the introspection endpoint; pass `{ path }` to customize it. |
| `batch` | `boolean \| { path?: string; maxSize?: number }` | `true` | Mount the `POST /batch` endpoint; `false` disables it, `{ path, maxSize }` configures it. |
| `cors` | `boolean \| CorsOptions` | `false` | Enable CORS via `@koa/cors`; `true` uses permissive defaults, an object customizes origin/methods/headers/credentials. |
***
### Introspection endpoint
[Section titled “Introspection endpoint”](#introspection-endpoint)
Opt in to a machine-readable service catalog:
```typescript
app.configure(koa({ introspection: true }));
// or with a custom path:
app.configure(koa({ introspection: { path: "/__meta" } }));
```
`GET /_services` (or the custom path) then returns a `ServiceDescriptor[]` — one entry per registered service with `path`, `methods`, `events`, `schema`, the repository’s `capabilities` (when the service exposes them, e.g. `RepositoryService`), and `authRequired`. Off by default; without the option the route 404s.
***
### Batch endpoint
[Section titled “Batch endpoint”](#batch-endpoint)
`POST /batch` (mounted by default) dispatches an array of calls through `app.batch()` in one round trip:
```bash
curl -X POST http://localhost:3030/batch -H "content-type: application/json" -d '[
{ "service": "users", "method": "get", "id": 1 },
{ "service": "messages", "method": "find", "params": { "query": { "$limit": 5 } } }
]'
```
Each call runs the target service’s **full hook pipeline** (including `authenticate("jwt")`) with the batch request’s headers — batch is not a way to bypass authentication or validation. The response is a `BatchResult[]` in the same order as the request array; calls execute concurrently and each entry independently reports `{ status: "success", result }` or `{ status: "error", error }` (no cross-call atomicity). Batches over `maxSize` (default 25) are rejected with `400 BadRequest` before any call executes.
```typescript
app.configure(koa({ batch: false })); // disable
app.configure(koa({ batch: { path: "/_batch", maxSize: 50 } }));
```
`@mantlejs/client` can coalesce same-tick service calls into this endpoint automatically — see its `batch` option.
***
### CORS
[Section titled “CORS”](#cors)
Disabled by default — consistent with Mantle’s secure-by-default posture elsewhere. Enable it with `cors: true` for permissive defaults, or pass a `CorsOptions` object to customize:
```typescript
app.configure(koa({ cors: true })); // reflects Origin, allows GET/POST/PUT/PATCH/DELETE, no credentials
app.configure(
koa({
cors: { origin: ["https://app.example.com"], credentials: true },
}),
);
```
`CorsOptions` (exported from `@mantlejs/mantle`) is shared across `@mantlejs/express`, `@mantlejs/koa`, and `@mantlejs/http` so switching transports doesn’t require relearning CORS configuration:
| Field | Type | Default | Description |
| ---------------- | ------------------------------------------------------------------ | ------------------------------------------- | ---------------------------------------------------------- |
| `origin` | `boolean \| string \| string[] \| ((origin) => boolean \| string)` | `true` (reflect the request’s `Origin`) | Allowed origin(s) for `Access-Control-Allow-Origin`. |
| `methods` | `string[]` | `["GET", "POST", "PUT", "PATCH", "DELETE"]` | Allowed methods for `Access-Control-Allow-Methods`. |
| `allowedHeaders` | `string[]` | reflects `Access-Control-Request-Headers` | Allowed request headers. |
| `exposedHeaders` | `string[]` | none | Headers exposed to the browser. |
| `credentials` | `boolean` | `false` | Allow credentials (cookies, `Authorization`) cross-origin. |
| `maxAge` | `number` | none | Preflight cache duration in seconds. |
`@mantlejs/koa` wraps `@koa/cors` internally — it’s installed as a dependency of `@mantlejs/koa`, not something you need to add yourself.
***
### `errorHandler`
[Section titled “errorHandler”](#errorhandler)
Koa middleware that catches `MantleError` subclasses and maps them to HTTP status codes. Installed automatically by `koa()` — you only need this export if you are assembling the middleware stack manually.
```typescript
import { errorHandler } from "@mantlejs/koa";
koaApp.use(errorHandler);
```
***
## Types
[Section titled “Types”](#types)
```typescript
import type { KoaOptions } from "@mantlejs/koa";
```
| Type | Description |
| ------------ | ---------------------------------- |
| `KoaOptions` | Options object accepted by `koa()` |
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build koa # compile
npx nx test koa # run tests
npx nx lint koa # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
Build before publishing:
```bash
npx nx build koa
```
First publish (scoped packages require `--access public`):
```bash
cd packages/koa
npm publish --access public
```
Subsequent releases — bump `version` in `packages/koa/package.json`, then:
```bash
cd packages/koa
npm publish
```
### Testing locally with Verdaccio
[Section titled “Testing locally with Verdaccio”](#testing-locally-with-verdaccio)
```bash
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/koa
npm publish --registry http://localhost:4873
# Install from it in another project
npm install @mantlejs/koa --registry http://localhost:4873
```
# @mantlejs/logger
> Structured logging plugin for Mantle JS. Provides a pino adapter and two hook factories — logRequest and logError — that emit structured JSON records through any logger satisfying the Mantle Logger interface.
Structured logging plugin for [Mantle JS](https://github.com/mantlejs/mantle). Provides a pino adapter and two hook factories — `logRequest` and `logError` — that emit structured JSON records through any logger satisfying the Mantle `Logger` interface.
***
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/logger pino
```
`pino` is an optional peer dependency, loaded lazily by `createLogger()`. If you prefer winston, LogLayer, or another logger, skip it — see [Using a different logger](#using-a-different-logger). To pretty-print in development, also install `pino-pretty` (also optional — see [`createLogger(options?)`](#createloggeroptions)).
***
## Concepts
[Section titled “Concepts”](#concepts)
### The Logger interface
[Section titled “The Logger interface”](#the-logger-interface)
`@mantlejs/mantle` defines a minimal `Logger` interface with four methods: `debug`, `info`, `warn`, and `error`. Every method accepts a message string and an optional context object:
```typescript
interface Logger {
debug(msg: string, context?: Record): void;
info(msg: string, context?: Record): void;
warn(msg: string, context?: Record): void;
error(msg: string, context?: Record): void;
}
```
Any object with these four methods can be registered on the app. Mantle packages emit logs via `app.get('logger')?.debug(...)` — if no logger is configured, all internal logging is silently skipped.
### LOG_LEVEL
[Section titled “LOG_LEVEL”](#log_level)
Verbosity is controlled by the `LOG_LEVEL` environment variable, not a `DEBUG=*` namespace. Set it before starting your process:
```bash
LOG_LEVEL=debug npm start # debug + info + warn + error
LOG_LEVEL=info npm start # info + warn + error (recommended for production)
LOG_LEVEL=warn npm start # warn + error only
```
### Component field
[Section titled “Component field”](#component-field)
Records emitted by Mantle packages include a `component` field — currently `mantle:core` (service registration/teardown), `mantle:request` and `mantle:error` (this package’s `logRequest`/`logError` hooks), and `mantle:sync` (`@mantlejs/sync`). Use this field in your log aggregation tool (CloudWatch, Datadog, Grafana Loki) to filter framework-internal logs without restarting the process.
### Correlation ID
[Section titled “Correlation ID”](#correlation-id)
When you use `pinoAdapter` with the `express()` transport, every log record emitted during a request automatically includes a `correlationId` field. No manual threading required.
The `express()` plugin reads the `X-Correlation-ID` request header (or generates a UUID) and stores it in `@mantlejs/mantle`’s `AsyncLocalStorage` context via `withContext`. `pinoAdapter` calls `getContext()` on every log call and merges the result into the pino object.
```json
{
"correlationId": "a3f2c1d4-8b2e-4f1a-9c3d-1e2f3a4b5c6d",
"component": "mantle:request",
"method": "create",
"path": "users",
"provider": "rest",
"durationMs": 12,
"status": "ok"
}
```
The `X-Correlation-ID` is echoed back in the response header so callers can correlate their requests with server-side log entries.
Hooks and other code can read the correlation ID directly:
```typescript
import { getContext } from "@mantlejs/mantle";
const hook = (ctx) => {
const { correlationId } = getContext() ?? {};
// ...
return ctx;
};
```
### Output destination
[Section titled “Output destination”](#output-destination)
Destination is configured on the pino instance, not by Mantle. Common patterns:
```typescript
// stdout only — cloud-native default, recommended for EKS / Cloud Run
pino({ level: 'info' })
// file only
pino({}, pino.destination('/var/log/app.log'))
// stdout + file simultaneously
pino({ level: 'info' }, pino.multistream([
{ stream: process.stdout },
{ stream: pino.destination('/var/log/app.log') },
]))
```
> **Log rotation is not the application’s concern.** In containers, the kubelet and log agent (fluentd, fluent-bit) collect from stdout. If writing to files, use `logrotate` or a sidecar shipper.
***
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { logger, createLogger, logRequest, logError } from "@mantlejs/logger";
const app = mantle()
.configure(express())
.configure(logger(await createLogger({ gcp: process.env.NODE_ENV === "production" })));
const requestLogger = logRequest();
app.use("/users", new UserService(new UserRepository(app)));
app.service("users").hooks({
before: { all: [requestLogger] },
after: { all: [requestLogger] },
error: { all: [requestLogger, logError()] },
});
app.listen(3030);
```
***
## API
[Section titled “API”](#api)
### `logger(adapter)`
[Section titled “logger(adapter)”](#loggeradapter)
Plugin factory. Registers a `Logger` adapter on the application.
```typescript
function logger(adapter: Logger): MantlePlugin;
```
```typescript
app.configure(logger(pinoAdapter(pino({ level: "info" }))));
// Equivalent to: app.set("logger", adapter)
```
***
### `pinoAdapter(pinoInstance)`
[Section titled “pinoAdapter(pinoInstance)”](#pinoadapterpinoinstance)
Wraps a pino logger to satisfy the `Logger` interface. Pino uses `(object, message)` argument order; the `Logger` interface uses `(message, object)`. The adapter flips this internally.
On every log call the adapter reads `getContext()` from `@mantlejs/mantle` and merges the current `RequestContext` (including `correlationId`) into the pino object. Per-call context fields take precedence over request context fields. When called outside an Express request (e.g. a startup log), no context is present and the field is simply omitted.
```typescript
function pinoAdapter(pino: PinoLike): Logger;
```
```typescript
import pino from "pino";
import { pinoAdapter } from "@mantlejs/logger";
const adapter = pinoAdapter(pino({ level: process.env.LOG_LEVEL ?? "info" }));
```
`PinoLike` is a minimal duck-type — any object with `debug`, `info`, `warn`, and `error` methods accepting `(object, message)` will work. If the wrapped instance has a `child(bindings)` method (real pino instances do), the returned `Logger` gets one too — see [`Logger.child(bindings)`](#loggerchildbindings) below.
***
### `createLogger(options?)`
[Section titled “createLogger(options?)”](#createloggeroptions)
Builds a `Logger` backed by pino, with production-ready defaults baked in: environment-aware level, PII redaction, and Google Cloud severity mapping. `pino` is loaded lazily — nothing is required at module load time, only when `createLogger()` actually runs.
```typescript
function createLogger(options?: CreateLoggerOptions): Promise;
interface CreateLoggerOptions {
/** Default: "info" when NODE_ENV === "production", else "debug". */
level?: "debug" | "info" | "warn" | "error";
/** Pino redact paths. Default: SENSITIVE_PATHS. Pass [] to disable. */
redact?: string[];
/** Pretty-print via pino-pretty when available. Default: false. Ignored in production. */
pretty?: boolean;
/** Google Cloud structured logging: level -> `severity` labels, message key "message". Default: false. */
gcp?: boolean;
/** Extra pino options merged last (escape hatch). */
pino?: Record;
}
```
```typescript
import { logger, createLogger } from "@mantlejs/logger";
app.configure(
logger(
await createLogger({
pretty: process.env.NODE_ENV !== "production",
gcp: process.env.NODE_ENV === "production",
}),
),
);
```
* **`redact`** — defaults to the exported `SENSITIVE_PATHS` (`["password", "*.password", "*.accessToken", "*.refreshToken", "*.authorization", "*.cookie"]`), passed straight to pino’s `redact.paths` with `censor: "[Redacted]"`. Pass `[]` to disable pino-level redaction entirely (e.g. if you configure your own).
* **`pretty`** — when `true` and not in production, `createLogger` checks whether `pino-pretty` is resolvable before setting `transport: { target: "pino-pretty" }`. If it isn’t installed, logging still works — output falls back to plain JSON and a one-time `console.warn` names the missing package. `pretty` has no effect in production.
* **`gcp`** — sets `messageKey: "message"` and a `formatters.level` mapping (`debug`→`DEBUG`, `info`→`INFO`, `warn`→`WARNING`, `error`→`ERROR`) so [Cloud Logging](https://cloud.google.com/logging/docs/structured-logging) picks up the right severity and renders the message field. See [Deployment](#deployment) below.
* **`pino`** — an escape hatch merged into the built options object last, so it can override any computed default (including `level` or `redact`).
* If `pino` cannot be resolved (not installed), `createLogger()` throws a `GeneralError` naming the missing package, with `hint: "Install it with: npm install pino"`.
***
### `logRequest(options?)`
[Section titled “logRequest(options?)”](#logrequestoptions)
Returns a hook that logs service call duration. Register the **same returned function** in `before.all`, `after.all`, **and** `error.all`. The before phase records the start time; the after or error phase emits the record with elapsed duration and `status`.
```typescript
function logRequest(options?: LogRequestOptions): HookFunction;
interface LogRequestOptions {
/** Log level for calls. Default: 'debug' */
level?: "debug" | "info";
/**
* Include params in the log record. Default: false.
*
* WARNING: params can contain sensitive data — query strings, auth tokens,
* pagination secrets, or any value a hook has attached. Only enable this
* in development, or pair it with pino's `redact` option to strip known
* sensitive paths before they reach the transport.
*/
includeParams?: boolean;
/** Paths redacted from `params` when includeParams is true. Default: SENSITIVE_PATHS. */
redactParams?: string[];
}
```
`redactParams` runs a small own-code deep walk over `params` — replacing matched fields with `"[Redacted]"` — **before** the record reaches the logger. This works for every adapter, not just `pinoAdapter`; pino’s own `redact` option (configured via `createLogger`) remains defense in depth on top of it.
```typescript
const requestLogger = logRequest();
app.service("users").hooks({
before: { all: [requestLogger] },
after: { all: [requestLogger] },
error: { all: [requestLogger, logError()] },
});
```
**Success record** (message: `"Service call completed"`):
```json
{
"component": "mantle:request",
"method": "get",
"path": "users",
"provider": "rest",
"id": "42",
"durationMs": 12,
"status": "ok"
}
```
`id` is only present for `get`, `update`, `patch`, and `remove` calls. `correlationId` is automatically included by `pinoAdapter` when inside an Express request.
**Error record** (message: `"Service call failed"`):
```json
{
"component": "mantle:request",
"method": "create",
"path": "users",
"provider": "rest",
"durationMs": 1,
"status": "error"
}
```
***
### `logError(options?)`
[Section titled “logError(options?)”](#logerroroptions)
Error hook. Logs structured error details from `ctx.error`. Maps `4xx` errors to `warn`, `5xx` to `error` by default.
```typescript
function logError(options?: LogErrorOptions): HookFunction;
interface LogErrorOptions {
/** Log 4xx as 'warn', 5xx as 'error'. Default: true */
levelByCode?: boolean;
/** Include stack trace. Default: true when NODE_ENV !== 'production' */
includeStack?: boolean;
}
```
```typescript
app.service("users").hooks({
error: { all: [logError()] },
});
```
**Emitted record** (message: `"Service error"`):
```json
{
"component": "mantle:error",
"method": "create",
"path": "users",
"provider": "rest",
"code": 409,
"name": "Conflict",
"message": "Email already exists",
"data": { "field": "email" },
"stack": "..."
}
```
`data` (the `MantleError`’s `data` payload) is only present when the error carries one, and is redacted with the same `SENSITIVE_PATHS` walk used by `logRequest`’s `redactParams`.
***
### `Logger.child(bindings)`
[Section titled “Logger.child(bindings)”](#loggerchildbindings)
`Logger` (from `@mantlejs/mantle`) declares `child` as **optional** — implementations may omit it, and callers must feature-check before use:
```typescript
interface Logger {
debug(msg: string, context?: Record): void;
info(msg: string, context?: Record): void;
warn(msg: string, context?: Record): void;
error(msg: string, context?: Record): void;
child?(bindings: Record): Logger;
}
```
Both adapters this package ships implement it:
* **`pinoAdapter`** — delegates to the wrapped instance’s own `pino.child(bindings)`, re-wrapped through `pinoAdapter` so `RequestContext` merging (`correlationId`, etc.) keeps working on the child logger too. If the wrapped object has no `child` method, the returned `Logger` won’t have one either.
* **`loglayerAdapter`** — has no native LogLayer `child()` involved; bindings are closed over and folded into every subsequent `withMetadata()` call instead.
```typescript
const requestLog = log.child?.({ requestId: "abc-123" }) ?? log;
requestLog.info("handling request"); // every record includes requestId: "abc-123"
```
Mantle’s own hooks never call `child` unconditionally — it’s an opt-in for application code that wants scoped loggers (e.g. one per background job, per queue consumer).
***
### `loglayerAdapter(logLayerInstance)`
[Section titled “loglayerAdapter(logLayerInstance)”](#loglayeradapterloglayerinstance)
Wraps a [LogLayer](https://loglayer.dev) instance to satisfy the `Logger` interface. `loglayer` itself is **not** a dependency of any Mantle package — `LogLayerLike` is a duck-type, so any object shaped like a LogLayer instance works.
```typescript
function loglayerAdapter(log: LogLayerLike): Logger;
interface LogLayerLike {
withMetadata(meta: Record): {
debug(msg: string): void;
info(msg: string): void;
warn(msg: string): void;
error(msg: string): void;
};
child?(): unknown;
}
```
```typescript
import { LogLayer, ConsoleTransport } from "loglayer";
import { logger, loglayerAdapter } from "@mantlejs/logger";
const log = new LogLayer({ transport: new ConsoleTransport({ logger: console }) });
app.configure(logger(loglayerAdapter(log)));
```
Like `pinoAdapter`, every call merges the current `RequestContext` into the metadata passed to `withMetadata()`. `child(bindings)` closes over the merged bindings rather than calling LogLayer’s own `child()` (whose semantics differ) — bindings are folded into every subsequent `withMetadata()` call, and nested `child()` calls compose.
See [Using LogLayer](#using-loglayer) below for a transport fan-out example.
***
## PII and sensitive data
[Section titled “PII and sensitive data”](#pii-and-sensitive-data)
Mantle’s default log records are intentionally narrow — only service metadata (`method`, `path`, `provider`, `id`, `durationMs`, `status`) and error details reach the log output. The fields most likely to carry PII are **never logged by default**:
| Field | Logged? | Notes |
| ---------------------------- | ----------- | -------------------------------------------------------------- |
| `ctx.data` (request body) | No | Passwords, email addresses, payment info |
| `ctx.result` (response body) | No | Full entity records |
| `ctx.params` | No (opt-in) | Query strings, auth tokens — see `includeParams` warning above |
| `correlationId` | Yes | A UUID — not PII |
| `ctx.id` | Yes | A resource identifier — usually not PII |
### Redacting fields with `createLogger`
[Section titled “Redacting fields with createLogger”](#redacting-fields-with-createlogger)
`createLogger({ redact })` is the recommended approach when using pino — it configures pino’s built-in [`redact`](https://getpino.io/#/docs/redaction) option (`fast-redact` with JSONPath patterns, applied before serialisation) and defaults to the exported `SENSITIVE_PATHS`:
```typescript
import { logger, createLogger } from "@mantlejs/logger";
app.configure(
logger(
await createLogger({
redact: ["user.email", "user.password", "params.query.token", "*.creditCard"],
}),
),
);
```
This applies globally to every record without any changes to Mantle hooks or the `Logger` interface. If you build a pino instance yourself instead of using `createLogger`, pass the same `redact` option directly to `pino({ ... })` before wrapping it with `pinoAdapter`.
### Redacting with `logRequest`/`logError` (works with any adapter)
[Section titled “Redacting with logRequest/logError (works with any adapter)”](#redacting-with-logrequestlogerror-works-with-any-adapter)
`logRequest({ redactParams })` and `logError()` run their own small deep-walk redaction (see [`redactPaths`](#redactpathsvalue-paths) below) over `params` and `error.data` respectively, before the record reaches *any* logger — pino, LogLayer, winston, or a custom one. This is defense in depth on top of pino-level redaction, and the only redaction available to non-pino adapters.
### Redacting with winston or a custom logger
[Section titled “Redacting with winston or a custom logger”](#redacting-with-winston-or-a-custom-logger)
Configure redaction at the transport or formatter level in your logger of choice. Mantle does not add a redaction layer on top of the underlying logger — except via `logRequest`/`logError` as described above.
***
## Using a different logger
[Section titled “Using a different logger”](#using-a-different-logger)
### LogLayer
[Section titled “LogLayer”](#loglayer)
Use `loglayerAdapter` — see [`loglayerAdapter(logLayerInstance)`](#loglayeradapterloglayerinstance) above.
### Winston
[Section titled “Winston”](#winston)
Winston’s `debug`, `info`, `warn`, and `error` methods already match the `Logger` interface signature — no adapter needed. Skip installing `@mantlejs/logger` entirely if you only want winston:
```typescript
import winston from "winston";
const winstonLogger = winston.createLogger({
level: process.env.LOG_LEVEL ?? "info",
transports: [new winston.transports.Console()],
});
app.set("logger", winstonLogger);
```
You can still use the `logRequest()` and `logError()` hook factories from `@mantlejs/logger` alongside a winston logger — they read the logger from `app.get('logger')` regardless of implementation.
### Custom / console logger
[Section titled “Custom / console logger”](#custom--console-logger)
Any object satisfying the four-method `Logger` interface works:
```typescript
app.set("logger", {
debug: (msg, ctx) => console.debug(msg, ctx),
info: (msg, ctx) => console.info(msg, ctx),
warn: (msg, ctx) => console.warn(msg, ctx),
error: (msg, ctx) => console.error(msg, ctx),
});
```
***
### `redactPaths(value, paths?)`
[Section titled “redactPaths(value, paths?)”](#redactpathsvalue-paths)
The own-code deep-walk redaction helper used internally by `logRequest` and `logError`. Exported so application code can reuse it.
```typescript
function redactPaths(value: unknown, paths?: string[]): unknown; // default: SENSITIVE_PATHS
```
Path syntax is a small subset of pino’s `redact.paths`: a bare key (`"password"`) matches only at the top level; a `"*.key"` entry matches `key` at any depth. Returns a deep clone — the input is never mutated.
```typescript
import { redactPaths, SENSITIVE_PATHS } from "@mantlejs/logger";
redactPaths({ user: { password: "hunter2" } });
// => { user: { password: "[Redacted]" } }
SENSITIVE_PATHS; // ["password", "*.password", "*.accessToken", "*.refreshToken", "*.authorization", "*.cookie"]
```
***
## Types
[Section titled “Types”](#types)
```typescript
import type {
PinoLike,
LogLayerLike,
CreateLoggerOptions,
LogRequestOptions,
LogErrorOptions,
} from "@mantlejs/logger";
import type { Logger } from "@mantlejs/mantle";
```
| Type | Description |
| --------------------- | -------------------------------------------------------------------------- |
| `Logger` | Core interface — re-exported from `@mantlejs/mantle`. `child` is optional. |
| `PinoLike` | Duck-type accepted by `pinoAdapter()` |
| `LogLayerLike` | Duck-type accepted by `loglayerAdapter()` |
| `CreateLoggerOptions` | Options for `createLogger()` |
| `LogRequestOptions` | Options for `logRequest()` |
| `LogErrorOptions` | Options for `logError()` |
***
## Deployment
[Section titled “Deployment”](#deployment)
### Cloud Run
[Section titled “Cloud Run”](#cloud-run)
Pass `gcp: true` to `createLogger()` when running on Cloud Run (or any GKE/Cloud Logging environment). It maps Mantle’s four levels to Cloud Logging `severity` labels and renames the message field, so structured fields survive intact in the Logs Explorer instead of being flattened into `textPayload`:
```typescript
const log = await createLogger({ gcp: process.env.NODE_ENV === "production" });
```
Write to **stdout only** — Cloud Run collects container stdout/stderr automatically. Don’t configure file transports; there’s no persistent disk, and a second write target only adds latency. This is also why `pretty` is ignored in production: pretty-printed multi-line output does not parse as structured JSON by the log agent.
### Level configuration via `@mantlejs/config`
[Section titled “Level configuration via @mantlejs/config”](#level-configuration-via-mantlejsconfig)
Read the log level from `@mantlejs/config` instead of hardcoding it, so it can be overridden per environment without a redeploy. `config()` and `logger()` both call `app.set("logger", ...)` — `config()` sets it to the raw config object, `logger()` overwrites it with the actual `Logger` instance — so **configure `config()` before `logger()`**:
```typescript
import { config } from "@mantlejs/config";
import { logger, createLogger } from "@mantlejs/logger";
import type { CreateLoggerOptions } from "@mantlejs/logger";
app.configure(config()); // reads config/default.json + config/{NODE_ENV}.json + MANTLE_* env vars
const { level } = app.get<{ logger?: { level?: string } }>("config").logger ?? {};
app.configure(logger(await createLogger({ level: level as CreateLoggerOptions["level"] })));
```
config/default.json
```jsonc
{ "logger": { "level": "info" } }
```
```plaintext
MANTLE_LOGGER__LEVEL=debug npm start # env override, no redeploy
```
### Process-level error handlers
[Section titled “Process-level error handlers”](#process-level-error-handlers)
Pino/LogLayer only capture errors that flow through a service call. Wire the two Node.js process-level events too, so nothing crashes silently:
```typescript
const log = await createLogger();
process.on("uncaughtException", (err) => {
log.error("Uncaught exception", { component: "mantle:process", name: err.name, message: err.message, stack: err.stack });
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
const err = reason instanceof Error ? reason : new Error(String(reason));
log.error("Unhandled rejection", { component: "mantle:process", name: err.name, message: err.message, stack: err.stack });
});
```
### Log-based metrics
[Section titled “Log-based metrics”](#log-based-metrics)
Every record `logRequest`/`logError` emit carries a `component` field (`mantle:request` or `mantle:error`). Build log-based metrics/alerts (Cloud Monitoring, Datadog, Grafana Loki) filtered on `component`, without needing a separate metrics pipeline:
* `component="mantle:request" AND status="error"` — error rate by `path`/`method`
* `component="mantle:request"` — p50/p95 `durationMs` by `path`
* `component="mantle:error" AND code>=500` — 5xx volume, paged separately from 4xx noise
### Using LogLayer
[Section titled “Using LogLayer”](#using-loglayer)
`loglayerAdapter` lets LogLayer’s own transport fan-out do double duty — one Mantle `Logger` writing to multiple destinations at once:
```typescript
import { LogLayer, MultiTransport, ConsoleTransport, DatadogTransport } from "loglayer";
import { logger, loglayerAdapter } from "@mantlejs/logger";
const log = new LogLayer({
transport: new MultiTransport([
new ConsoleTransport({ logger: console }),
new DatadogTransport({ apiKey: process.env.DATADOG_API_KEY! }),
]),
});
app.configure(logger(loglayerAdapter(log)));
```
`logRequest` and `logError` work unchanged — they only depend on the `Logger` interface, never on LogLayer directly.
***
## Development
[Section titled “Development”](#development)
```bash
npx nx build logger # compile
npx nx test logger # run tests
npx nx lint logger # lint
```
***
## Publishing
[Section titled “Publishing”](#publishing)
Build before publishing:
```bash
npx nx build logger
```
First publish (scoped packages require `--access public`):
```bash
cd packages/logger
npm publish --access public
```
Subsequent releases — bump `version` in `packages/logger/package.json`, then:
```bash
cd packages/logger
npm publish
```
### Testing locally with Verdaccio
[Section titled “Testing locally with Verdaccio”](#testing-locally-with-verdaccio)
```bash
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/logger
npm publish --registry http://localhost:4873
# Install from it in another project
npm install @mantlejs/logger --registry http://localhost:4873
```
# @mantlejs/mantle
> The framework kernel for Mantle JS. Zero external dependencies. Defines all core interfaces, the application factory, and typed error classes used across every other package.
The framework kernel for Mantle JS. Zero external dependencies. Defines all core interfaces, the application factory, and typed error classes used across every other package.
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/mantle
```
## Quick start
[Section titled “Quick start”](#quick-start)
```typescript
import { mantle } from "@mantlejs/mantle";
const app = mantle();
app.use("users", new UserService());
app.service("users").hooks({
before: { create: [validateUser()] },
after: { all: [sanitize()] },
});
```
## API
[Section titled “API”](#api)
### `mantle(options?)`
[Section titled “mantle(options?)”](#mantleoptions)
Creates a `MantleApplication` instance.
```typescript
const app = mantle();
```
#### `app.use(path, service, options?)`
[Section titled “app.use(path, service, options?)”](#appusepath-service-options)
Registers a service at a path. `options.methods` restricts which of the six standard methods are exposed (defaults to all six).
```typescript
app.use("users", new UserService(), {
methods: ["find", "get", "create", "patch", "remove"],
});
```
#### `app.service(path)`
[Section titled “app.service(path)”](#appservicepath)
Returns the `ServiceHandle` for a registered path. Throws `NotFound` if the path is not registered.
```typescript
const result = await app.service("users").find({ query: { role: "admin" } });
```
#### `app.configure(plugin)`
[Section titled “app.configure(plugin)”](#appconfigureplugin)
Applies a plugin (e.g. `express()`, `knex()`) to the application.
```typescript
app.configure(express()).configure(knex({ client: "pg", connection: DATABASE_URL }));
```
#### `app.batch(calls, params?, options?)`
[Section titled “app.batch(calls, params?, options?)”](#appbatchcalls-params-options)
Dispatches many service calls concurrently (`Promise.allSettled` semantics) and returns one `BatchResult` per call, **in input order**. The HTTP transports expose this as `POST /batch`.
```typescript
const results = await app.batch([
{ service: "users", method: "get", id: 1 },
{ service: "messages", method: "find", params: { query: { $limit: 5 } } },
{ service: "messages", method: "create", data: { text: "Hello" } },
]);
// results[i] is { status: "success", result } or { status: "error", error: { name, message, code, … } }
```
* Every call runs the target service’s **full hook pipeline** — batch is not a way to bypass authentication or validation. `params` (typically the outer HTTP request’s `headers`, `user`, `provider`) is inherited by every call and merged with the call’s own `params.query`.
* One call failing does not fail the batch, and there is no cross-call atomicity — a batch can span services backed by different databases. Use `withTransaction()` inside a single custom service method when you need same-repository atomicity.
* Batches larger than `options.maxSize` (default 25, `DEFAULT_MAX_BATCH_SIZE`) are rejected with `BadRequest` before any call executes. Malformed entries (missing `service`, a method outside the six standard ones) become per-call `BadRequest` error entries.
#### `app.set(key, value)` / `app.get(key)`
[Section titled “app.set(key, value) / app.get(key)”](#appsetkey-value--appgetkey)
Typed key-value store used by plugins to share instances (e.g. the knex instance is stored under `"knex"`).
#### `app.teardown()`
[Section titled “app.teardown()”](#appteardown)
Gracefully shuts down all registered resources. Call on process exit.
***
### `Service`
[Section titled “Service\”](#servicet-d)
The contract every service must satisfy. All methods are optional — only implement what you need and restrict the rest via `options.methods`.
```typescript
interface Service> {
find(params?: ServiceParams): Promise>;
get(id: Id, params?: ServiceParams): Promise;
create(data: D, params?: ServiceParams): Promise;
update(id: Id, data: D, params?: ServiceParams): Promise;
patch(id: Id, data: D, params?: ServiceParams): Promise;
remove(id: Id, params?: ServiceParams): Promise;
}
```
Custom methods beyond these six must be explicitly listed in `options.methods` when calling `app.use()`.
***
### `Repository`
[Section titled “Repository\”](#repositoryt-d)
Interface for data access implementations (e.g. `KnexRepository`). Lives in the Infrastructure layer and is never imported by services directly.
```typescript
interface Repository> {
findAll(params?: QueryParams): Promise;
findById(id: Id): Promise;
save(data: D): Promise;
saveAll(data: D[]): Promise;
updateById(id: Id, data: D): Promise;
patchById(id: Id, data: D): Promise;
deleteById(id: Id): Promise;
count(params?: QueryParams): Promise;
findPage?(params?: QueryParams & { cursor?: string }): Promise>; // optional cursor pagination
describe?(): RepositoryCapabilities; // optional capability introspection
}
interface CursorPage {
data: T[];
cursor?: string; // opaque adapter token — pass back as params.cursor for the next page; absent on the last page
}
```
`findPage()` is implemented by adapters whose backend is natively cursored (DynamoDB, Qdrant, Pinecone) — `describe().pagination` reports `"cursor"` or `"both"` when it is available. It is stateless: the cursor lives entirely in the returned page, so concurrent calls on one repository instance never interfere.
***
### Introspection — `describe()`
[Section titled “Introspection — describe()”](#introspection--describe)
Machine-readable self-description at both layers, so tooling (OpenAPI generation, the transports’ opt-in `/_services` endpoint, AI agents) can discover capabilities instead of probing by trial and error.
#### `Repository.describe()` → `RepositoryCapabilities`
[Section titled “Repository.describe() → RepositoryCapabilities”](#repositorydescribe--repositorycapabilities)
Every @mantlejs adapter implements the optional `describe()`; user repositories may omit it.
```typescript
interface RepositoryCapabilities {
adapter: string; // package name, e.g. "@mantlejs/knex"
operators: string[]; // exactly the $-operators the adapter's where translator accepts
pagination: "offset" | "cursor" | "both";
fullTextSearch: boolean;
scanning?: (where: Record) => boolean; // e.g. DynamoDB: true when the where clause forces a full Scan
}
```
#### `ServiceHandle.describe()` → `ServiceDescriptor`
[Section titled “ServiceHandle.describe() → ServiceDescriptor”](#servicehandledescribe--servicedescriptor)
```typescript
const descriptor = app.service("users").describe();
interface ServiceDescriptor {
path: string;
methods: string[];
events: string[]; // standard created/updated/patched/removed filtered by methods, plus custom ServiceOptions.events
schema?: unknown; // the stored ServiceOptions.schema (JSON Schema — a TypeBox schema works)
capabilities?: RepositoryCapabilities; // present when the service exposes describe() — RepositoryService wires this through
authRequired?: boolean; // true when a before.all hook carries an authStrategy marker, e.g. authenticate("jwt")
}
```
The HTTP transports (`@mantlejs/express`, `@mantlejs/koa`, `@mantlejs/http`) can serve every registered service’s descriptor at `GET /_services` via their opt-in `introspection` option.
***
### `RepositoryService`
[Section titled “RepositoryService\”](#repositoryservicet-d)
A ready-made `Service` over any `Repository` — the framework-owned bridge from raw HTTP query strings to typed `QueryParams`. Use it when a service is a straight CRUD facade over a repository; add hooks for everything else.
```typescript
import { RepositoryService } from "@mantlejs/mantle";
app.use(
"users",
new RepositoryService(new UserRepository(app), {
schema: userSchema, // any object with { properties: { field: { type } } } — a TypeBox schema works
fields: ["name", "age", "active"], // optional whitelist of queryable fields
paginate: { default: 25, max: 100 },
}),
);
```
#### Query conventions
[Section titled “Query conventions”](#query-conventions)
Reserved keys inside `query` (everything else becomes the repository `where` clause):
| Key | Example | Meaning |
| --------- | ------------------ | --------------------------------------------- |
| `$limit` | `?$limit=10` | page size (capped at `paginate.max` when set) |
| `$skip` | `?$skip=20` | offset |
| `$sort` | `?$sort[name]=asc` | sort order — accepts `asc`/`desc`/`1`/`-1` |
| `$select` | `?$select[]=name` | field projection |
`GET /users?age[$gt]=21&$limit=10&$sort[name]=asc` becomes `repository.findAll({ where: { age: { $gt: 21 } }, limit: 10, sort: { name: "asc" } })`.
#### Behavior guarantees
[Section titled “Behavior guarantees”](#behavior-guarantees)
* **`find()` always returns `Paginated`** — `{ total, limit, skip, data }`, with `total` from `repository.count({ where })`. No `T[] | Paginated` branching for consumers.
* **Field whitelist** — with `options.fields`, a where/sort/select key outside the list throws `BadRequest` naming the field and the allowed set.
* **String coercion** — query values arrive as strings over HTTP. With `options.schema`, values are coerced to each field’s declared type (`number`, `integer`, `boolean`), including inside operator objects, `$in`/`$nin` arrays, and `$or`/`$and` branches. **Without a schema, strings pass through unchanged** — `age[$gt]=21` then compares against the string `"21"`, so provide a schema for any non-string field you query.
* `update`/`patch`/`remove` propagate the repository’s `NotFound` untouched; `get()` maps a `null` lookup to `NotFound`.
***
### `VectorRepositoryService` and the `similar` convention
[Section titled “VectorRepositoryService\ and the similar convention”](#vectorrepositoryservicet-d-and-the-similar-convention)
Services backed by a `VectorRepository` expose vector search as a **custom method named `similar`**, registered in `app.use()` so transports mount it (custom methods dispatch as `POST //similar` and run the full hook pipeline). `VectorRepositoryService` is a `RepositoryService` with the method pre-wired to `repository.findSimilar()`:
```typescript
import { VectorRepositoryService } from "@mantlejs/mantle";
app.use(
"docs",
new VectorRepositoryService(new DocRepository(app), {
fields: ["category", "tags"], // whitelist applies to similar()'s where too
topK: { default: 10, max: 100 }, // missing topK gets default; requests are capped at max
}),
{
methods: ["find", "get", "create", "update", "patch", "remove", "similar"],
},
);
```
```plaintext
POST /docs/similar
{ "vector": [0.12, -0.5, …], "topK": 5, "where": { "category": "tech" } }
```
Every result carries the adapter’s native match metric as `_score` — whether higher or lower means “more similar” is adapter-specific; see the adapter README. A missing/non-numeric `vector`, a non-positive `topK`, or a `where` key outside `options.fields` throws `BadRequest` with a hint.
Writing your own service instead? Follow the same convention: register `similar(data, params)` as a custom method and forward to `repository.findSimilar(data.vector, data.topK, { where: data.where })`.
***
### Hooks
[Section titled “Hooks”](#hooks)
Hooks are plain functions — no classes. They run in three phases: `before`, `after`, and `error`. The `all` key applies to every method in that phase.
```typescript
type HookFunction = (context: HookContext) => Promise> | HookContext;
```
Register hooks via `app.service(path).hooks(config)`:
```typescript
app.service("users").hooks({
before: {
all: [authenticate("jwt")],
create: [hashPassword()],
},
after: {
all: [sanitizeUser()],
},
error: {
all: [logError()],
},
});
```
#### `HookContext`
[Section titled “HookContext\”](#hookcontextt)
| Field | Type | Description |
| ---------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `app` | `MantleApplication` | The application instance |
| `service` | `Partial>` | The service being called |
| `path` | `string` | Registered path, e.g. `"users"` |
| `method` | `string` | Method name, e.g. `"create"` |
| `provider` | `string \| undefined` | The transport: `"rest"` (Express), `"koa"`, `"http"`, `"socket.io"`, `"mcp"`; `undefined` internally |
| `params` | `ServiceParams` | Query, headers, user, etc. |
| `data` | `Partial \| undefined` | Request body (write methods) |
| `id` | `Id \| undefined` | Record identifier (get/update/patch/remove) |
| `result` | `T \| T[] \| Paginated \| undefined` | Set by the service or an `after` hook |
| `error` | `Error \| undefined` | Set when an error occurs |
Setting `context.result` in a `before` hook skips the service call entirely.
***
### Error classes
[Section titled “Error classes”](#error-classes)
Always throw a typed error — never a plain `new Error()`.
| Class | HTTP status | `className` |
| ------------------ | ----------- | -------------------- |
| `BadRequest` | 400 | `bad-request` |
| `NotAuthenticated` | 401 | `not-authenticated` |
| `Forbidden` | 403 | `forbidden` |
| `NotFound` | 404 | `not-found` |
| `MethodNotAllowed` | 405 | `method-not-allowed` |
| `Conflict` | 409 | `conflict` |
| `Unprocessable` | 422 | `unprocessable` |
| `TooManyRequests` | 429 | `too-many-requests` |
| `GeneralError` | 500 | `general-error` |
| `NotImplemented` | 501 | `not-implemented` |
| `Unavailable` | 503 | `unavailable` |
All errors accept `(message?, data?, errors?, hint?)` and serialize via `.toJSON()`.
```typescript
throw new Conflict("Email already exists", { field: "email" });
```
The optional `hint` is actionable guidance for the caller — what to change so the request succeeds. It is included in `toJSON()`, so HTTP error responses carry it to clients (human or agent):
```typescript
throw new BadRequest(
"Operator $regex is not supported by knex",
undefined,
undefined,
"Rewrite the where clause using only the supported operators, or filter in application code.",
);
```
***
### `Paginated`
[Section titled “Paginated\”](#paginatedt)
```typescript
interface Paginated {
total: number;
limit: number;
skip: number;
data: T[];
}
```
Return this from `find()` when the caller requests a page.
***
### `QueryParams`
[Section titled “QueryParams”](#queryparams)
Used by `Repository` methods to filter, sort, and paginate queries.
```typescript
interface QueryParams {
where?: Record;
limit?: number;
skip?: number;
sort?: Record;
select?: string[];
}
```
## Development
[Section titled “Development”](#development)
```bash
npx nx build mantle # compile
npx nx test mantle # run tests
npx nx lint mantle # lint
```
## Publishing
[Section titled “Publishing”](#publishing)
Build before publishing:
```bash
npx nx build mantle
```
First publish (scoped packages require `--access public`):
```bash
cd packages/mantle
npm publish --access public
```
Subsequent releases — bump `version` in `packages/mantle/package.json`, then:
```bash
cd packages/mantle
npm publish
```
### Testing locally with Verdaccio
[Section titled “Testing locally with Verdaccio”](#testing-locally-with-verdaccio)
The workspace includes a local registry to smoke-test a publish before pushing to npm:
```bash
# Terminal 1 — start the local registry
npx nx run @mantle/source:local-registry
# Terminal 2 — publish to it
cd packages/mantle
npm publish --registry http://localhost:4873
# Install from it in another project
npm install @mantlejs/mantle --registry http://localhost:4873
```
# @mantlejs/mcp
> Expose Mantle services as Model Context Protocol tools — an AI agent gets one tool per exposed service method, with input schemas derived from your TypeBox schemas and query capabilities, and every call runs the service's full hook pipeline. There is no bypass: authentication, validation, and events apply to agents exactly as they do to REST callers.
Expose Mantle services as [Model Context Protocol](https://modelcontextprotocol.io) tools — an AI agent gets one tool per exposed service method, with input schemas derived from your TypeBox schemas and query capabilities, and every call runs the service’s **full hook pipeline**. There is no bypass: authentication, validation, and events apply to agents exactly as they do to REST callers.
## Installation
[Section titled “Installation”](#installation)
```bash
npm install @mantlejs/mcp
```
`@mantlejs/mantle` is a peer dependency. For `transport: "http"`, an HTTP transport (`@mantlejs/express`, `@mantlejs/koa`, or `@mantlejs/http`) must be configured first.
***
## Concepts
[Section titled “Concepts”](#concepts)
### Deny-by-default expose map
[Section titled “Deny-by-default expose map”](#deny-by-default-expose-map)
Nothing is exposed unless you say so. `services` is required and maps a service path to the methods that become tools:
```typescript
mcp({
transport: "http",
services: {
articles: true, // every method registered in app.use()
users: ["find", "get"], // read-only over MCP
},
});
```
* `true` exposes all of the service’s registered methods — never more than `app.use()` registered.
* An unknown path or method fails the boot (`listen()` / `startMcp()`) with a `BadRequest` naming it.
* `services: "*"` exposes every service registered **after** `mcp()` — a deliberate, greppable escape hatch for prototyping.
* With `events: true`, only expose-map services get an event resource; hidden services never leak.
### One tool per method
[Section titled “One tool per method”](#one-tool-per-method)
Tools are generated from `ServiceHandle.describe()` and named `{path}_{method}` (`users_find`, `blog_posts_create`, custom methods included):
* **`find`** takes a structured `query` (`where` / `limit` / `skip` / `sort` / `select`). The `where` operator set is constrained to the adapter’s `describe().capabilities.operators` — an agent is never offered `$ilike` against an adapter that rejects it.
* **`create`/`update`/`patch`** take `data` typed by the schema attached in `app.use()` (`patch` gets the all-optional variant); services without a schema still get tools with a generic object schema.
* **`get`/`remove`** take `id`; `update`/`remove` descriptions carry destructive-operation notes.
### Hook-pipeline dispatch
[Section titled “Hook-pipeline dispatch”](#hook-pipeline-dispatch)
Every tool call goes through `service.dispatch()` — the same entry point the HTTP transports use — with `params.provider = "mcp"`. Hooks can therefore apply agent-specific policy:
```typescript
app.service("articles").hooks({
before: {
remove: [
(ctx) => {
if (ctx.params.provider === "mcp") throw new Forbidden("Agents may not delete articles");
return ctx;
},
],
},
});
```
Errors come back as MCP **tool errors** carrying `MantleError.toJSON()` — name, message, code, `data`, and `hint` — so an agent can read what went wrong and adjust.
### Result limits
[Section titled “Result limits”](#result-limits)
`find` results are clamped: `limit = min(requested ?? defaultLimit, maxLimit)` (defaults 25 / 100, configurable via `query`). The clamp is advertised in the generated schema (`maximum`, default in the description), and a truncated page carries a note telling the agent to page with `skip`/`limit` and trim fields with `select`.
### Authentication
[Section titled “Authentication”](#authentication)
* **HTTP:** a `Bearer` token on the request is verified with the configured auth engine (`@mantlejs/auth`); the resolved user lands on `params.user` for every call in the session, and the `authorization` header is passed through so `authenticate("jwt")` hooks work unchanged. No/invalid token → the session is anonymous and each service’s own auth hooks reject. The MCP layer makes no auth decisions of its own.
* **stdio:** carries no HTTP request — pass `startMcp(app, { params: { headers: { authorization: "Bearer …" } } })` to authenticate the session, otherwise it is anonymous.
***
## Quick start
[Section titled “Quick start”](#quick-start)
### HTTP
[Section titled “HTTP”](#http)
```typescript
import { mantle, RepositoryService } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { mcp } from "@mantlejs/mcp";
const app = mantle()
.configure(express())
.configure(
mcp({
transport: "http", // mounts POST /mcp (path configurable)
services: { users: ["find", "get"], articles: true },
events: true, // mantle://events/{path} resources
}),
);
app.use("users", new RepositoryService(new UserRepository(app)), { schema: userSchema });
app.listen(3030);
```
Point an MCP client at `http://localhost:3030/mcp` (streamable HTTP, JSON responses; SSE streaming is not supported — poll event resources instead).
### stdio
[Section titled “stdio”](#stdio)
```typescript
// mcp.ts — separate entry point: build the app, don't listen()
import { startMcp } from "@mantlejs/mcp";
import { buildApp } from "./app.js";
const app = buildApp(); // configure(mcp({ transport: "stdio", services: { … } }))
await startMcp(app);
```
### Custom (composite) tools
[Section titled “Custom (composite) tools”](#custom-composite-tools)
Task-level tools beyond CRUD — chain service calls with the session’s identity; every inner `dispatch` runs the target’s hook pipeline:
```typescript
mcp({
transport: "http",
services: { articles: ["find", "get"] },
tools: [
{
name: "publish_article",
description: "Publish an article and record the activity.",
inputSchema: { type: "object", required: ["articleId"], properties: { articleId: { type: "string" } } },
handler: async (args, { app, params }) => {
const { articleId } = args as { articleId: string };
const article = await app.service("articles").dispatch("patch", { status: "published" }, articleId, params);
await app.service("activity").dispatch("create", { type: "published", articleId }, undefined, params);
return article;
},
},
],
});
```
A custom tool name colliding with a generated tool fails the boot.
### Custom resources
[Section titled “Custom resources”](#custom-resources)
Read-only reference content an agent should load as context rather than query as a tool — schema docs, enum values, usage guides. `read` receives the same session context as tools, so inner `dispatch()` calls run the full hook pipeline:
```typescript
mcp({
transport: "http",
services: { users: ["find"] },
resources: [
{
uri: "mantle://docs/usage",
name: "API usage guide",
mimeType: "text/markdown",
read: async () => readFile("docs/agent-guide.md", "utf8"),
},
],
});
```
The `mantle://events/` namespace is reserved for event resources; duplicate or reserved URIs fail at configure time. Custom resources have no update signal — subscribing is an accepted no-op (event resources keep live notifications on stdio).
### Prompts
[Section titled “Prompts”](#prompts)
Prompt templates that MCP clients surface as user-invokable commands (e.g. slash commands). `get` may return a plain string (shorthand for a single user message) or the full `{ description?, messages }` shape, and receives the session context:
```typescript
mcp({
transport: "http",
services: { articles: ["find"] },
prompts: [
{
name: "triage_articles",
description: "Review articles awaiting moderation.",
arguments: [{ name: "focus", required: false }],
get: async (args, { app, params }) => {
const page = await app.service("articles").dispatch("find", undefined, undefined, {
...params,
query: { status: "pending", $limit: 10 },
});
return `Review these pending articles${args["focus"] ? ` (focus: ${args["focus"]})` : ""}: ${JSON.stringify(page)}`;
},
},
],
});
```
The `prompts` capability is only declared when at least one prompt is defined; duplicate names fail at configure time.
### Code mode
[Section titled “Code mode”](#code-mode)
Instead of one tool per method, a server can give the agent a typed API it writes a short script against. Many service calls then run in one round trip, and intermediate results stay out of the model’s context. The sandbox lives in a separate package, **[`@mantlejs/mcp-code`](/packages/mcp-code/)** (experimental), which plugs in through the `codeMode` option — `mcp({ …, codeMode: codeMode() })`. `@mantlejs/mcp` itself has no sandbox dependency.
| `mode` | Tools listed |
| --------- | ------------------------------------------------------------------------------------------------------------- |
| `"tools"` | One generated tool per exposed method. The default when no `codeMode` provider is set, and today’s behavior. |
| `"code"` | The provider’s tools only; the generated per-method tools are suppressed. The default when `codeMode` is set. |
| `"both"` | Generated tools plus the provider’s tools. |
App-authored `tools`, `resources`, `prompts`, and event resources are registered in every mode. `mode: "code"` or `"both"` without a provider fails at configure time with a `BadRequest`.
A provider implements `McpCodeModeProvider`. Its `build()` runs once, when the expose map is resolved (at `listen()` for HTTP, at `startMcp()` for stdio):
```typescript
interface McpCodeModeProvider {
build(input: McpCodeModeInput): McpCodeModeSurface;
}
interface McpCodeModeInput {
app: MantleApplication;
services: McpExposedService[]; // resolved expose map: { path, methods, descriptor }, never "*"
query: { defaultLimit: number; maxLimit: number }; // effective find() guardrails
}
interface McpCodeModeSurface {
tools: McpToolDefinition[];
resources?: McpResourceDefinition[];
}
```
* **Same rules as app-authored tools and resources.** A provider tool whose name collides with any registered tool fails the boot, and so does a provider resource URI that duplicates another or uses `mantle://events/`. In `"code"` mode the generated tools aren’t registered, so their names are free.
* **Same metadata and dispatch as tool mode.** `describeServiceMethod(descriptor, method, query)` and `buildQuerySchema(operators, limits)` are exported — the exact helpers the generated tools are built from, so a provider’s typed API cannot drift from the tool schemas. `createServiceMethodRunner(app, descriptor, method, query)` is the runner every generated tool executes (find-limit clamp, structured-query translation, id checks); a provider dispatching through it behaves exactly like tool mode.
* **Per-session behavior lives in the handlers.** The surface is built once and shared by every session. Tool handlers and resource `read` functions receive the session’s `McpToolContext.params` on every call, which is where a provider narrows what a given agent sees. Enforcement still happens in the hook pipeline, because every inner `dispatch()` runs the target service’s hooks.
***
## API
[Section titled “API”](#api)
### `mcp(options: McpOptions): MantlePlugin`
[Section titled “mcp(options: McpOptions): MantlePlugin”](#mcpoptions-mcpoptions-mantleplugin)
| Option | Type | Default | Description |
| ------------ | ----------------------------------------- | ---------------------------------------- | -------------------------------------------------------------------------------- |
| `services` | `Record