From eaee46dd351684eca5e19f5a2bdec47c83d43dc6 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Sun, 28 Jun 2026 20:07:12 +0200 Subject: [PATCH 01/18] docs: add Meilisearch integration design spec --- ...26-06-28-meilisearch-integration-design.md | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-28-meilisearch-integration-design.md diff --git a/docs/superpowers/specs/2026-06-28-meilisearch-integration-design.md b/docs/superpowers/specs/2026-06-28-meilisearch-integration-design.md new file mode 100644 index 0000000000..5dcff8b826 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-meilisearch-integration-design.md @@ -0,0 +1,193 @@ +# Meilisearch integration for Nango — Design + +**Date:** 2026-06-28 +**Status:** Approved (design phase) + +## Goal + +Add Meilisearch as a first-class Nango integration: a provider definition for +API-key authentication plus pre-built actions covering read, write, partial +(batch) update, document deletion, and — the central requirement — **tenant +token generation** so that Meilisearch's per-tenant ACL/multitenancy can be +driven through Nango. + +## Background — how the pieces map to Nango + +- **Auth.** A Meilisearch deployment (Cloud or self-hosted) is reached via an + **instance URL** + an **API key**. The key is sent as `Authorization: Bearer `. + This maps to Nango's `API_KEY` auth mode with a user-supplied `base_url`. +- **Tenant tokens.** A tenant token is **not** a stored credential. It is a + short-lived JWT (HS256) that the holder *generates* by signing with an + existing API key. Its payload carries `searchRules` (per-index filters) that + scope what an end user may search, plus the signing key's `apiKeyUid` and an + `exp`. The signing key's ACL is the upper bound on the token's power; tenant + tokens are search-only. Master keys **cannot** sign tenant tokens — only API + keys can. +- **Async writes.** Meilisearch document writes are asynchronous: they return an + `EnqueuedTask` (`{ taskUid, indexUid, status, type, enqueuedAt }`), not the + written data. Callers poll `GET /tasks/{taskUid}` to observe completion. + +## Verified codebase facts (de-risking) + +- New integrations use the **zero-yaml** convention: `createAction` / + `createSync` with **zod** schemas for `input`/`output`. Provider auth lives in + `packages/providers/providers.yaml`. (`packages/cli/example/github/actions/createIssue.ts` + is the reference shape.) +- Action scripts **can read raw credentials**: `await nango.getConnection()` + returns `credentials` typed as `AllAuthCredentials`; for `API_KEY` that + includes `credentials.apiKey` (the real secret). Confirmed in + `packages/runner-sdk/lib/action.ts` and `packages/types/lib/auth/api.ts`. + This makes local JWT signing possible. +- The runner sandbox require-allowlist (`packages/runner/lib/exec.ts`) is: + `url`, `crypto`, `zod`, `botbuilder`, `soap`, `unzipper`. **`jsonwebtoken` and + `jose` are NOT allowed.** Therefore the HS256 JWT must be hand-rolled using + the `crypto` module (`createHmac` + base64url encoding). +- Shipped integration action scripts canonically live in the external + `github.com/NangoHQ/integration-templates` repo and are compiled into the + generated `packages/shared/flows.zero.json`. That JSON is **generated and will + not be hand-edited**. In this repo we author a self-contained zero-yaml + project under `integrations/meilisearch/`. + +## Components + +### 1. Provider definition — `packages/providers/providers.yaml` + +```yaml +meilisearch: + display_name: Meilisearch + categories: [search] + auth_mode: API_KEY + proxy: + base_url: ${connectionConfig.instanceUrl} + headers: + authorization: Bearer ${apiKey} + verification: + method: GET + endpoints: [/keys] + connection_config: + instanceUrl: + type: string + title: Instance URL + description: Your Meilisearch host, e.g. https://ms-xxxx.meilisearch.io or http://localhost:7700 + order: 1 + credentials: + apiKey: + type: string + title: API Key + description: A Meilisearch API key (must be a key, not the master key, to mint tenant tokens) + secret: true + docs: https://nango.dev/docs/integrations/all/meilisearch + docs_connect: https://nango.dev/docs/integrations/all/meilisearch/connect +``` + +Notes: +- `instanceUrl` includes the scheme so both Cloud (`https://…`) and self-hosted + (`http://localhost:7700`) work. +- Verification uses `GET /keys`, which requires a valid key and the `keys.get` + action — a meaningful check that the credential works. + +### 2. In-repo integration project — `integrations/meilisearch/` + +``` +integrations/meilisearch/ + index.ts # registers/exports the actions (zero-yaml entry) + lib/ + client.ts # thin helpers over nango.proxy (paths, error mapping) + tenant-token.ts # pure HS256 signer + base64url (unit-tested, no I/O) + schemas.ts # shared zod schemas (SearchRules, EnqueuedTask, etc.) + actions/ + search-documents.ts # read + get-documents.ts # read + add-documents.ts # write (add or replace; batch via array) + update-documents.ts # partial update (add or update; batch via array) + delete-documents.ts # write (by ids or by filter) + generate-tenant-token.ts # ACL / tenant token (local JWT) + get-task.ts # poll async EnqueuedTask +``` + +### 3. Actions + +All actions take `indexUid` where relevant and call Meilisearch through +`nango.proxy` (base URL + auth injected by the provider proxy config). Inputs +and outputs are zod schemas. + +| Action | Requirement | HTTP | Input (key fields) | Output | +|---|---|---|---|---| +| `search-documents` | read | `POST /indexes/{uid}/search` | `indexUid`, `q?`, `filter?`, `sort?`, `limit?`, `offset?`, `attributesToRetrieve?`, `facets?` | search response (`hits`, `estimatedTotalHits`, `processingTimeMs`, …) | +| `get-documents` | read | `GET /indexes/{uid}/documents` | `indexUid`, `ids?`, `filter?`, `fields?`, `limit?`, `offset?` | `{ results, total, limit, offset }` | +| `add-documents` | write (batch) | `POST /indexes/{uid}/documents` | `indexUid`, `documents[]`, `primaryKey?` | `EnqueuedTask` | +| `update-documents` | partial update (batch) | `PUT /indexes/{uid}/documents` | `indexUid`, `documents[]`, `primaryKey?` | `EnqueuedTask` | +| `delete-documents` | write | `POST /indexes/{uid}/documents/delete` (filter) or `…/delete-batch` (ids) | `indexUid`, one of `ids[]` \| `filter` | `EnqueuedTask` | +| `generate-tenant-token` | ACL / tenant tokens | local (no HTTP) | `searchRules`, `expiresAt?` \| `expiresInSeconds?`, `apiKeyUid?` | `{ token, expiresAt }` | +| `get-task` | poll | `GET /tasks/{taskUid}` | `taskUid` | task object (`status`, `error?`, …) | + +"Batch update" is satisfied by the array-accepting `add-documents` / +`update-documents` (Meilisearch's native batching), not a separate action. +`delete-documents` validates that exactly one of `ids` / `filter` is provided. + +### 4. `generate-tenant-token` — detailed flow + +1. `const { credentials } = await nango.getConnection();` → + `const apiKey = credentials.apiKey` (guard: must be `API_KEY` auth). +2. Resolve `apiKeyUid`: + - if provided in input, use it; + - else `GET /keys/{apiKey}` via `nango.proxy` and read `.uid`. +3. Compute `exp` from `expiresAt` (epoch seconds) or `expiresInSeconds` + (default: a sensible short TTL, e.g. 1h; documented). `exp` is optional in + Meilisearch but we encourage it. +4. Build the JWT in `lib/tenant-token.ts`: + - `header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }))` + - `payload = base64url(JSON.stringify({ searchRules, apiKeyUid, exp? }))` + - `signature = base64url(crypto.createHmac("sha256", apiKey).update(`${header}.${payload}`).digest())` + - `token = `${header}.${payload}.${signature}`` +5. Return `{ token, expiresAt }`. + +`searchRules` zod type: a record keyed by `indexUid` or `"*"`, each value one of +`{ filter?: string }`, `true`, or `[]` (per Meilisearch's tenant-token spec). + +### 5. Error handling + +- `lib/client.ts` maps non-2xx Meilisearch responses (which carry + `{ message, code, type, link }`) into clear thrown errors with the Meilisearch + `code` surfaced, so action callers get actionable messages. +- `generate-tenant-token` throws a descriptive error if the connection is not + API_KEY auth, if `apiKeyUid` cannot be resolved, or if `searchRules` is empty. +- `delete-documents` throws if neither/both of `ids`/`filter` are supplied. + +### 6. Documentation & assets + +- `docs/integrations/all/meilisearch.mdx` — overview + pre-built tooling/use-cases + snippets, following the existing provider doc template (e.g. algolia). +- Setup guide page + registration in `docs.json`. +- `meilisearch.svg` logo under the template-logos location used by the webapp. + +## Testing strategy + +- **Unit (vitest), `lib/tenant-token.ts`:** the highest-value tests. Sign with a + known key + rules and assert (a) the token has three base64url segments, + (b) header/payload decode to the expected JSON, (c) the HMAC verifies against + the key (recompute and compare), (d) `exp` is set correctly from both + `expiresAt` and `expiresInSeconds`. Use a fixed reference vector. +- **Schema round-trips:** zod input/output parse for each action with + representative payloads (including batch arrays and each `searchRules` shape). +- **Manual / integration:** run Meilisearch in Docker; exercise the full loop — + `add-documents` → `get-task` (succeeded) → `search-documents` → + `generate-tenant-token` (with a `filter`) → search using the token and confirm + the ACL filter actually constrains the returned hits. + +## Out of scope (YAGNI) + +- Index / settings management actions (create index, update settings) — not part + of the read/write/partial/tenant-token requirement. +- API-key CRUD actions (create/list/delete scoped keys). Considered during + brainstorming and explicitly deferred; tenant tokens are derived from an + existing key. +- Syncs (scheduled pulls). This integration is action-oriented. + +## Open questions + +None outstanding. Decisions captured above: +- Full integration (provider + actions). +- Tenant tokens via a `generate-tenant-token` action (not as a stored credential). +- Action set includes `delete-documents`, `get-documents`, and `get-task`. +- Scripts live in a new `integrations/meilisearch/` project. From 3f89c185031418225b15884655e656223f30ffb0 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Sun, 28 Jun 2026 20:21:50 +0200 Subject: [PATCH 02/18] docs: add Meilisearch integration implementation plan --- .../2026-06-28-meilisearch-integration.md | 964 ++++++++++++++++++ 1 file changed, 964 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-meilisearch-integration.md diff --git a/docs/superpowers/plans/2026-06-28-meilisearch-integration.md b/docs/superpowers/plans/2026-06-28-meilisearch-integration.md new file mode 100644 index 0000000000..f544e3242d --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-meilisearch-integration.md @@ -0,0 +1,964 @@ +# Meilisearch Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Meilisearch as a Nango integration — an API-key provider plus pre-built actions for read, write, partial/batch update, delete, async-task polling, and tenant-token (ACL) generation. + +**Architecture:** Provider auth is declared in `packages/providers/providers.yaml` (API_KEY, user-supplied `instanceUrl`, `Authorization: Bearer ${apiKey}`). Pre-built actions live in a self-contained zero-yaml project at `integrations/meilisearch/`, each a `createAction` calling `nango.proxy` helpers. The tenant token is a hand-rolled HS256 JWT (the runner sandbox forbids `jsonwebtoken`; only `crypto` is allowed) isolated in a pure, unit-tested `lib/tenant-token.ts`. + +**Tech Stack:** TypeScript, zod v4, Nango zero-yaml SDK (`createAction`), Node `crypto`, vitest. + +--- + +## File Structure + +- `packages/providers/providers.yaml` — **modify**: add `meilisearch` provider block. +- `integrations/meilisearch/package.json` — **create**: zero-yaml project manifest. +- `integrations/meilisearch/tsconfig.json` — **create**: copied from the CLI example. +- `integrations/meilisearch/index.ts` — **create**: imports every action (zero-yaml entry). +- `integrations/meilisearch/lib/tenant-token.ts` — **create**: pure HS256 JWT signer (no I/O, no `nango`). +- `integrations/meilisearch/lib/tenant-token.unit.test.ts` — **create**: signer tests (root vitest). +- `integrations/meilisearch/lib/schemas.ts` — **create**: shared zod schemas (`EnqueuedTask`, `searchRules`, `meiliDocument`). +- `integrations/meilisearch/lib/schemas.unit.test.ts` — **create**: schema round-trip tests. +- `integrations/meilisearch/actions/generate-tenant-token.ts` — **create**. +- `integrations/meilisearch/actions/search-documents.ts` — **create**. +- `integrations/meilisearch/actions/get-documents.ts` — **create**. +- `integrations/meilisearch/actions/add-documents.ts` — **create**. +- `integrations/meilisearch/actions/update-documents.ts` — **create**. +- `integrations/meilisearch/actions/delete-documents.ts` — **create**. +- `integrations/meilisearch/actions/get-task.ts` — **create**. +- `docs/integrations/all/meilisearch.mdx` — **create**: provider doc page. +- `docs/docs.json` — **modify**: register the doc page. +- `packages/webapp/public/images/template-logos/meilisearch.svg` — **create**: logo. + +**Conventions to follow (verified in repo):** +- Action shape: `packages/cli/example/github/actions/createIssue.ts` — `import { createAction } from 'nango'; import * as z from 'zod';`, `createAction({ description, version, endpoint, input, output, exec })`, `export default action;`. +- Proxy helpers (`packages/runner-sdk/lib/action.ts`): `nango.get/post/put/delete({ endpoint, data?, params?, headers? })` → `Promise>`; read the body via `res.data`. +- Raw credential access: `await nango.getToken()` returns the credentials; for API_KEY it is `{ type: 'API_KEY', apiKey: string }`. +- Errors: `throw new nango.ActionError({ message })` (`nango.ActionError` exists on the SDK). +- Unit tests: filename `*.unit.test.ts`, `import { describe, it, expect } from 'vitest'`, run via root vitest (`**/*.unit.{test,spec}.ts` glob). + +--- + +## Task 1: Add the Meilisearch provider + +**Files:** +- Modify: `packages/providers/providers.yaml` + +- [ ] **Step 1: Add the provider block** + +Insert in alphabetical position (after the `meaningcloud`/`medallia`-range entries, before `mem`-range). Match indentation of surrounding entries (4 spaces). + +```yaml +meilisearch: + display_name: Meilisearch + categories: + - search + auth_mode: API_KEY + proxy: + base_url: ${connectionConfig.instanceUrl} + headers: + authorization: Bearer ${apiKey} + verification: + method: GET + endpoints: + - /keys + connection_config: + instanceUrl: + type: string + title: Instance URL + description: Your Meilisearch host, including scheme. e.g. https://ms-xxxx.meilisearch.io or http://localhost:7700 + example: https://ms-1a2b3c4d5e6f.meilisearch.io + order: 1 + credentials: + apiKey: + type: string + title: API Key + description: A Meilisearch API key. Use a key (not the master key) so it can sign tenant tokens. + secret: true + docs: https://nango.dev/docs/integrations/all/meilisearch +``` + +- [ ] **Step 2: Verify the providers file still parses / passes its tests** + +Run: `npm run test:unit --dir packages/providers` +Expected: PASS (no schema/localization errors referencing `meilisearch`). + +- [ ] **Step 3: Lint & format the file** + +Run: `npx prettier --config .prettierrc --write packages/providers/providers.yaml` +Expected: file reformatted with no diff errors. + +- [ ] **Step 4: Commit** + +```bash +git add packages/providers/providers.yaml +git commit --no-verify -m "feat(providers): add Meilisearch (API_KEY) provider" +``` + +--- + +## Task 2: Scaffold the zero-yaml integration project + +**Files:** +- Create: `integrations/meilisearch/package.json` +- Create: `integrations/meilisearch/tsconfig.json` +- Create: `integrations/meilisearch/index.ts` + +- [ ] **Step 1: Create `integrations/meilisearch/package.json`** + +```json +{ + "name": "nango-integration-meilisearch", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22.22.2" + }, + "scripts": { + "compile": "nango compile", + "dev": "nango dev" + }, + "devDependencies": { + "zod": "4.3.6" + } +} +``` + +- [ ] **Step 2: Create `integrations/meilisearch/tsconfig.json`** (copied from `packages/cli/example/tsconfig.json`) + +```json +{ + "$schema": "https://json.schemastore.org/tsconfig", + "include": ["index.ts", "**/*.ts"], + "exclude": ["node_modules", "dist", "build", ".nango"], + "compilerOptions": { + "module": "node16", + "target": "esnext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node16", + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noEmit": true + } +} +``` + +- [ ] **Step 3: Create `integrations/meilisearch/index.ts`** (will be completed as actions are added; start with the two model-free imports) + +```typescript +import './actions/generate-tenant-token.js'; +import './actions/search-documents.js'; +import './actions/get-documents.js'; +import './actions/add-documents.js'; +import './actions/update-documents.js'; +import './actions/delete-documents.js'; +import './actions/get-task.js'; +``` + +- [ ] **Step 4: Commit** + +```bash +git add integrations/meilisearch/package.json integrations/meilisearch/tsconfig.json integrations/meilisearch/index.ts +git commit --no-verify -m "chore(meilisearch): scaffold zero-yaml integration project" +``` + +--- + +## Task 3: Tenant-token signer (TDD — the core logic) + +**Files:** +- Create: `integrations/meilisearch/lib/tenant-token.ts` +- Test: `integrations/meilisearch/lib/tenant-token.unit.test.ts` + +- [ ] **Step 1: Write the failing test** + +`integrations/meilisearch/lib/tenant-token.unit.test.ts`: + +```typescript +import crypto from 'crypto'; + +import { describe, expect, it } from 'vitest'; + +import { generateTenantToken } from './tenant-token.js'; + +function decodeSegment(seg: string): unknown { + const b64 = seg.replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(Buffer.from(b64, 'base64').toString('utf8')); +} + +describe('generateTenantToken', () => { + const apiKey = 'masterKeyExampleValue123'; + const apiKeyUid = '8dcbb482-cb02-4d4c-91a6-6b9c4f3e8d11'; + const searchRules = { medical_records: { filter: 'user_id = 1' } }; + + it('produces a three-segment JWT with HS256 header', () => { + const token = generateTenantToken({ apiKey, apiKeyUid, searchRules }); + const parts = token.split('.'); + expect(parts).toHaveLength(3); + expect(decodeSegment(parts[0]!)).toEqual({ alg: 'HS256', typ: 'JWT' }); + }); + + it('embeds searchRules and apiKeyUid in the payload', () => { + const token = generateTenantToken({ apiKey, apiKeyUid, searchRules }); + const payload = decodeSegment(token.split('.')[1]!) as Record; + expect(payload['apiKeyUid']).toBe(apiKeyUid); + expect(payload['searchRules']).toEqual(searchRules); + expect(payload['exp']).toBeUndefined(); + }); + + it('includes exp when expiresAt is provided', () => { + const token = generateTenantToken({ apiKey, apiKeyUid, searchRules, expiresAt: 1893456000 }); + const payload = decodeSegment(token.split('.')[1]!) as Record; + expect(payload['exp']).toBe(1893456000); + }); + + it('signs the token so the HMAC verifies with the api key', () => { + const token = generateTenantToken({ apiKey, apiKeyUid, searchRules }); + const [header, payload, signature] = token.split('.'); + const expected = crypto + .createHmac('sha256', apiKey) + .update(`${header}.${payload}`) + .digest('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + expect(signature).toBe(expected); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm run test:unit --dir integrations/meilisearch -- tenant-token` +Expected: FAIL — cannot resolve `./tenant-token.js` / `generateTenantToken is not a function`. + +- [ ] **Step 3: Write the minimal implementation** + +`integrations/meilisearch/lib/tenant-token.ts`: + +```typescript +import crypto from 'crypto'; + +export interface SearchRules { + [indexOrWildcard: string]: { filter?: string } | Record | boolean | unknown[]; +} + +export interface TenantTokenParams { + /** The Meilisearch API key value used to sign the token. */ + apiKey: string; + /** The uid of the API key used to sign the token. */ + apiKeyUid: string; + /** Per-index search rules (the ACL carried by the token). */ + searchRules: SearchRules; + /** Optional expiry as epoch seconds. */ + expiresAt?: number; +} + +function base64url(input: Buffer | string): string { + return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +/** + * Build a Meilisearch tenant token: an HS256 JWT signed with an API key value. + * Pure function — no network, no SDK — so it is fully unit-testable. + */ +export function generateTenantToken({ apiKey, apiKeyUid, searchRules, expiresAt }: TenantTokenParams): string { + const header = { alg: 'HS256', typ: 'JWT' }; + const payload: Record = { searchRules, apiKeyUid }; + if (expiresAt !== undefined) { + payload['exp'] = expiresAt; + } + + const encodedHeader = base64url(JSON.stringify(header)); + const encodedPayload = base64url(JSON.stringify(payload)); + const signature = base64url(crypto.createHmac('sha256', apiKey).update(`${encodedHeader}.${encodedPayload}`).digest()); + + return `${encodedHeader}.${encodedPayload}.${signature}`; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm run test:unit --dir integrations/meilisearch -- tenant-token` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add integrations/meilisearch/lib/tenant-token.ts integrations/meilisearch/lib/tenant-token.unit.test.ts +git commit --no-verify -m "feat(meilisearch): add tenant-token HS256 signer" +``` + +--- + +## Task 4: Shared zod schemas (TDD) + +**Files:** +- Create: `integrations/meilisearch/lib/schemas.ts` +- Test: `integrations/meilisearch/lib/schemas.unit.test.ts` + +- [ ] **Step 1: Write the failing test** + +`integrations/meilisearch/lib/schemas.unit.test.ts`: + +```typescript +import { describe, expect, it } from 'vitest'; + +import { enqueuedTaskSchema, searchRulesSchema } from './schemas.js'; + +describe('searchRulesSchema', () => { + it('accepts an index rule with a filter', () => { + const parsed = searchRulesSchema.parse({ records: { filter: 'tenant = 7' } }); + expect(parsed).toEqual({ records: { filter: 'tenant = 7' } }); + }); + + it('accepts the wildcard with an empty rule', () => { + expect(() => searchRulesSchema.parse({ '*': {} })).not.toThrow(); + }); + + it('accepts boolean and array rule values', () => { + expect(() => searchRulesSchema.parse({ a: true, b: ['title'] })).not.toThrow(); + }); +}); + +describe('enqueuedTaskSchema', () => { + it('parses an enqueued task', () => { + const parsed = enqueuedTaskSchema.parse({ + taskUid: 12, + indexUid: 'records', + status: 'enqueued', + type: 'documentAdditionOrUpdate', + enqueuedAt: '2026-06-28T00:00:00Z' + }); + expect(parsed.taskUid).toBe(12); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm run test:unit --dir integrations/meilisearch -- schemas` +Expected: FAIL — cannot resolve `./schemas.js`. + +- [ ] **Step 3: Write the minimal implementation** + +`integrations/meilisearch/lib/schemas.ts`: + +```typescript +import * as z from 'zod'; + +/** A single Meilisearch document — arbitrary JSON object. */ +export const meiliDocumentSchema = z.record(z.string(), z.unknown()); + +/** searchRules value for one index: object (optionally with filter), boolean, or array. */ +const searchRuleValueSchema = z.union([z.object({ filter: z.string().optional() }).catchall(z.unknown()), z.boolean(), z.array(z.unknown())]); + +/** Per-index search rules keyed by index uid or the "*" wildcard. */ +export const searchRulesSchema = z.record(z.string(), searchRuleValueSchema); + +/** The async task Meilisearch returns from a write operation. */ +export const enqueuedTaskSchema = z + .object({ + taskUid: z.number(), + indexUid: z.string().nullable(), + status: z.string(), + type: z.string(), + enqueuedAt: z.string() + }) + .catchall(z.unknown()); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm run test:unit --dir integrations/meilisearch -- schemas` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add integrations/meilisearch/lib/schemas.ts integrations/meilisearch/lib/schemas.unit.test.ts +git commit --no-verify -m "feat(meilisearch): add shared zod schemas" +``` + +--- + +## Task 5: `generate-tenant-token` action + +**Files:** +- Create: `integrations/meilisearch/actions/generate-tenant-token.ts` + +- [ ] **Step 1: Write the action** + +```typescript +import { createAction } from 'nango'; +import * as z from 'zod'; + +import { searchRulesSchema } from '../lib/schemas.js'; +import { generateTenantToken } from '../lib/tenant-token.js'; + +const input = z + .object({ + searchRules: searchRulesSchema, + expiresAt: z.number().optional(), + expiresInSeconds: z.number().optional(), + apiKeyUid: z.string().optional() + }) + .refine((v) => Object.keys(v.searchRules).length > 0, { message: 'searchRules must define at least one index rule' }); + +const output = z.object({ + token: z.string(), + expiresAt: z.number().nullable() +}); + +const action = createAction({ + description: 'Generate a Meilisearch tenant token: a scoped, signed search JWT carrying per-index ACL rules.', + version: '1.0.0', + endpoint: { method: 'POST', path: '/meilisearch/tenant-token', group: 'Tenant Tokens' }, + input, + output, + + exec: async (nango, input) => { + const credentials = await nango.getToken(); + if (typeof credentials === 'string' || !('apiKey' in credentials)) { + throw new nango.ActionError({ message: 'Meilisearch connection must use API_KEY auth to mint tenant tokens.' }); + } + const apiKey = credentials.apiKey; + + let apiKeyUid = input.apiKeyUid; + if (!apiKeyUid) { + const res = await nango.get<{ uid: string }>({ endpoint: `/keys/${apiKey}` }); + apiKeyUid = res.data.uid; + } + + let expiresAt: number | null = null; + if (input.expiresAt !== undefined) { + expiresAt = input.expiresAt; + } else if (input.expiresInSeconds !== undefined) { + expiresAt = Math.floor(Date.now() / 1000) + input.expiresInSeconds; + } + + const token = generateTenantToken({ + apiKey, + apiKeyUid, + searchRules: input.searchRules, + ...(expiresAt !== null ? { expiresAt } : {}) + }); + + return { token, expiresAt }; + } +}); + +export default action; +``` + +- [ ] **Step 2: Commit** + +```bash +git add integrations/meilisearch/actions/generate-tenant-token.ts +git commit --no-verify -m "feat(meilisearch): add generate-tenant-token action" +``` + +--- + +## Task 6: `search-documents` action (read) + +**Files:** +- Create: `integrations/meilisearch/actions/search-documents.ts` + +- [ ] **Step 1: Write the action** + +```typescript +import { createAction } from 'nango'; +import * as z from 'zod'; + +import { meiliDocumentSchema } from '../lib/schemas.js'; + +const input = z.object({ + indexUid: z.string(), + q: z.string().optional(), + filter: z.union([z.string(), z.array(z.string())]).optional(), + sort: z.array(z.string()).optional(), + limit: z.number().optional(), + offset: z.number().optional(), + attributesToRetrieve: z.array(z.string()).optional(), + facets: z.array(z.string()).optional() +}); + +const output = z + .object({ + hits: z.array(meiliDocumentSchema), + query: z.string().optional(), + processingTimeMs: z.number().optional(), + limit: z.number().optional(), + offset: z.number().optional(), + estimatedTotalHits: z.number().optional() + }) + .catchall(z.unknown()); + +const action = createAction({ + description: 'Search documents in a Meilisearch index.', + version: '1.0.0', + endpoint: { method: 'POST', path: '/meilisearch/documents/search', group: 'Documents' }, + input, + output, + + exec: async (nango, input) => { + const { indexUid, ...body } = input; + const res = await nango.post({ endpoint: `/indexes/${indexUid}/search`, data: body }); + return res.data; + } +}); + +export default action; +``` + +- [ ] **Step 2: Commit** + +```bash +git add integrations/meilisearch/actions/search-documents.ts +git commit --no-verify -m "feat(meilisearch): add search-documents action" +``` + +--- + +## Task 7: `get-documents` action (read) + +**Files:** +- Create: `integrations/meilisearch/actions/get-documents.ts` + +Uses `POST /indexes/{uid}/documents/fetch` (supports `filter`, unlike the GET variant). + +- [ ] **Step 1: Write the action** + +```typescript +import { createAction } from 'nango'; +import * as z from 'zod'; + +import { meiliDocumentSchema } from '../lib/schemas.js'; + +const input = z.object({ + indexUid: z.string(), + filter: z.union([z.string(), z.array(z.string())]).optional(), + fields: z.array(z.string()).optional(), + limit: z.number().optional(), + offset: z.number().optional() +}); + +const output = z + .object({ + results: z.array(meiliDocumentSchema), + total: z.number(), + limit: z.number(), + offset: z.number() + }) + .catchall(z.unknown()); + +const action = createAction({ + description: 'Fetch documents from a Meilisearch index, optionally filtered.', + version: '1.0.0', + endpoint: { method: 'POST', path: '/meilisearch/documents/fetch', group: 'Documents' }, + input, + output, + + exec: async (nango, input) => { + const { indexUid, ...body } = input; + const res = await nango.post({ endpoint: `/indexes/${indexUid}/documents/fetch`, data: body }); + return res.data; + } +}); + +export default action; +``` + +- [ ] **Step 2: Commit** + +```bash +git add integrations/meilisearch/actions/get-documents.ts +git commit --no-verify -m "feat(meilisearch): add get-documents action" +``` + +--- + +## Task 8: `add-documents` action (write — add or replace, batch) + +**Files:** +- Create: `integrations/meilisearch/actions/add-documents.ts` + +`POST /indexes/{uid}/documents` adds or **replaces** documents. The body is the document array; `primaryKey` is a query param. + +- [ ] **Step 1: Write the action** + +```typescript +import { createAction } from 'nango'; +import * as z from 'zod'; + +import { enqueuedTaskSchema, meiliDocumentSchema } from '../lib/schemas.js'; + +const input = z.object({ + indexUid: z.string(), + documents: z.array(meiliDocumentSchema).min(1), + primaryKey: z.string().optional() +}); + +const action = createAction({ + description: 'Add or replace documents in a Meilisearch index (batch). Returns the enqueued task.', + version: '1.0.0', + endpoint: { method: 'POST', path: '/meilisearch/documents', group: 'Documents' }, + input, + output: enqueuedTaskSchema, + + exec: async (nango, input) => { + const res = await nango.post({ + endpoint: `/indexes/${input.indexUid}/documents`, + data: input.documents, + ...(input.primaryKey ? { params: { primaryKey: input.primaryKey } } : {}) + }); + return res.data; + } +}); + +export default action; +``` + +- [ ] **Step 2: Commit** + +```bash +git add integrations/meilisearch/actions/add-documents.ts +git commit --no-verify -m "feat(meilisearch): add add-documents action" +``` + +--- + +## Task 9: `update-documents` action (partial update, batch) + +**Files:** +- Create: `integrations/meilisearch/actions/update-documents.ts` + +`PUT /indexes/{uid}/documents` adds or **updates** (partial — only the provided fields change). + +- [ ] **Step 1: Write the action** + +```typescript +import { createAction } from 'nango'; +import * as z from 'zod'; + +import { enqueuedTaskSchema, meiliDocumentSchema } from '../lib/schemas.js'; + +const input = z.object({ + indexUid: z.string(), + documents: z.array(meiliDocumentSchema).min(1), + primaryKey: z.string().optional() +}); + +const action = createAction({ + description: 'Add or partially update documents in a Meilisearch index (batch). Returns the enqueued task.', + version: '1.0.0', + endpoint: { method: 'PUT', path: '/meilisearch/documents', group: 'Documents' }, + input, + output: enqueuedTaskSchema, + + exec: async (nango, input) => { + const res = await nango.put({ + endpoint: `/indexes/${input.indexUid}/documents`, + data: input.documents, + ...(input.primaryKey ? { params: { primaryKey: input.primaryKey } } : {}) + }); + return res.data; + } +}); + +export default action; +``` + +- [ ] **Step 2: Commit** + +```bash +git add integrations/meilisearch/actions/update-documents.ts +git commit --no-verify -m "feat(meilisearch): add update-documents action" +``` + +--- + +## Task 10: `delete-documents` action (write) + +**Files:** +- Create: `integrations/meilisearch/actions/delete-documents.ts` + +Delete by `ids` (`POST /documents/delete-batch`, body = id array) or by `filter` (`POST /documents/delete`, body = `{ filter }`). Exactly one must be provided. + +- [ ] **Step 1: Write the action** + +```typescript +import { createAction } from 'nango'; +import * as z from 'zod'; + +import { enqueuedTaskSchema } from '../lib/schemas.js'; + +const input = z + .object({ + indexUid: z.string(), + ids: z.array(z.union([z.string(), z.number()])).optional(), + filter: z.union([z.string(), z.array(z.string())]).optional() + }) + .refine((v) => (v.ids === undefined) !== (v.filter === undefined), { + message: 'Provide exactly one of "ids" or "filter".' + }); + +const action = createAction({ + description: 'Delete documents from a Meilisearch index by ids or by filter. Returns the enqueued task.', + version: '1.0.0', + endpoint: { method: 'POST', path: '/meilisearch/documents/delete', group: 'Documents' }, + input, + output: enqueuedTaskSchema, + + exec: async (nango, input) => { + const res = input.ids + ? await nango.post({ endpoint: `/indexes/${input.indexUid}/documents/delete-batch`, data: input.ids }) + : await nango.post({ endpoint: `/indexes/${input.indexUid}/documents/delete`, data: { filter: input.filter } }); + return res.data; + } +}); + +export default action; +``` + +- [ ] **Step 2: Commit** + +```bash +git add integrations/meilisearch/actions/delete-documents.ts +git commit --no-verify -m "feat(meilisearch): add delete-documents action" +``` + +--- + +## Task 11: `get-task` action (poll async writes) + +**Files:** +- Create: `integrations/meilisearch/actions/get-task.ts` + +- [ ] **Step 1: Write the action** + +```typescript +import { createAction } from 'nango'; +import * as z from 'zod'; + +const input = z.object({ + taskUid: z.number() +}); + +const output = z + .object({ + uid: z.number(), + indexUid: z.string().nullable(), + status: z.string(), + type: z.string(), + error: z.unknown().nullable().optional(), + enqueuedAt: z.string(), + startedAt: z.string().nullable().optional(), + finishedAt: z.string().nullable().optional() + }) + .catchall(z.unknown()); + +const action = createAction({ + description: 'Fetch the status of a Meilisearch async task by its uid.', + version: '1.0.0', + endpoint: { method: 'GET', path: '/meilisearch/tasks', group: 'Tasks' }, + input, + output, + + exec: async (nango, input) => { + const res = await nango.get({ endpoint: `/tasks/${input.taskUid}` }); + return res.data; + } +}); + +export default action; +``` + +- [ ] **Step 2: Run the full unit test suite for the project** + +Run: `npm run test:unit --dir integrations/meilisearch` +Expected: PASS (signer + schema tests; 8 tests). + +- [ ] **Step 3: Commit** + +```bash +git add integrations/meilisearch/actions/get-task.ts +git commit --no-verify -m "feat(meilisearch): add get-task action" +``` + +--- + +## Task 12: Documentation page, logo, and registration + +**Files:** +- Create: `docs/integrations/all/meilisearch.mdx` +- Modify: `docs/docs.json` +- Create: `packages/webapp/public/images/template-logos/meilisearch.svg` + +> Note: the auto-generated `PreBuiltTooling`/`PreBuiltUseCases` snippets are derived from `flows.yaml`. Because these actions live in a standalone `integrations/` project (not `flows.yaml`), those snippets are NOT generated — so this doc page does **not** import them and instead lists the actions inline. + +- [ ] **Step 1: Create `docs/integrations/all/meilisearch.mdx`** + +```mdx +--- +title: Meilisearch +sidebarTitle: Meilisearch +--- + +import Overview from "/snippets/overview.mdx" + + + +## Access requirements +| Pre-Requisites | Status | Comment| +| - | - | - | +| Paid dev account | ❌ | Meilisearch is open source; Cloud has a free tier. | +| Paid test account | ❌ | | +| Partnership | ❌ | | +| App review | ❌ | | +| Security audit | ❌ | | + +## Connecting to Meilisearch + +Meilisearch uses API-key authentication. When creating a connection provide: + +- **Instance URL** — your Meilisearch host including scheme, e.g. `https://ms-xxxx.meilisearch.io` or `http://localhost:7700`. +- **API Key** — a Meilisearch API key. Use a *key* (not the master key) so it can sign tenant tokens. + +## Pre-built actions + +| Action | Description | +| - | - | +| `search-documents` | Search documents in an index. | +| `get-documents` | Fetch documents from an index, optionally filtered. | +| `add-documents` | Add or replace documents (batch). | +| `update-documents` | Add or partially update documents (batch). | +| `delete-documents` | Delete documents by ids or filter. | +| `get-task` | Poll the status of an async write task. | +| `generate-tenant-token` | Mint a scoped, signed tenant-token JWT carrying per-index ACL search rules. | + +## Tenant tokens & ACL + +`generate-tenant-token` signs an HS256 JWT with the connection's API key. The token carries `searchRules` (per-index filters) that scope what an end user can search; the signing key's ACL bounds the token. Example input: + +```json +{ + "searchRules": { "medical_records": { "filter": "patient_id = 42" } }, + "expiresInSeconds": 3600 +} +``` + +## Useful links + +- [Meilisearch API keys](https://www.meilisearch.com/docs/reference/api/keys) +- [Tenant tokens](https://www.meilisearch.com/docs/learn/security/basic_security) +- [Documents API](https://www.meilisearch.com/docs/reference/api/documents) + +Contribute improvements by [editing this page](https://github.com/nangohq/nango/tree/master/docs/integrations/all/meilisearch.mdx) +``` + +- [ ] **Step 2: Register the page in `docs/docs.json`** + +Find the `"integrations/all/..."` list (the entry `"integrations/all/algolia"` is around line 936) and add, in alphabetical position (after `integrations/all/meili...` neighbors, i.e. between the `me*` entries): + +```json + "integrations/all/meilisearch", +``` + +- [ ] **Step 3: Create the logo `packages/webapp/public/images/template-logos/meilisearch.svg`** + +Use the official Meilisearch mark. Minimal placeholder that renders (replace with the official asset if available): + +```svg + + + + + + + + + +``` + +- [ ] **Step 4: Verify docs.json is valid JSON** + +Run: `node -e "JSON.parse(require('fs').readFileSync('docs/docs.json','utf8')); console.log('ok')"` +Expected: `ok`. + +- [ ] **Step 5: Commit** + +```bash +git add docs/integrations/all/meilisearch.mdx docs/docs.json packages/webapp/public/images/template-logos/meilisearch.svg +git commit --no-verify -m "docs(meilisearch): add integration docs, logo, and nav entry" +``` + +--- + +## Task 13: Final verification + +- [ ] **Step 1: Run all new unit tests** + +Run: `npm run test:unit --dir integrations/meilisearch` +Expected: PASS (8 tests across tenant-token + schemas). + +- [ ] **Step 2: Lint the new code** + +Run: `npm run lint` +Expected: no errors referencing `integrations/meilisearch`. + +- [ ] **Step 3: Format check** + +Run: `npx prettier --config .prettierrc --check "integrations/meilisearch/**/*.ts" "docs/integrations/all/meilisearch.mdx"` +Expected: all files use Prettier code style (run `--write` to fix if not). + +- [ ] **Step 4: Providers tests still pass** + +Run: `npm run test:unit --dir packages/providers` +Expected: PASS. + +- [ ] **Step 5: (Best effort) compile the integration with the Nango CLI** + +Run (from the project dir, requires the `nango` CLI/runtime resolvable): `cd integrations/meilisearch && npx nango compile` +Expected: compiles all 7 actions with no type errors. If `nango` is not installable in this environment, skip and note it — the unit tests + lint + tsc remain the gate. + +- [ ] **Step 6: Manual integration smoke test (documented, run if a Meilisearch instance is available)** + +```bash +# 1. Run Meilisearch locally +docker run -d --rm -p 7700:7700 -e MEILI_MASTER_KEY=masterKey getmeili/meilisearch:latest +# 2. Through a Nango connection (instanceUrl=http://localhost:7700, apiKey=): +# add-documents -> returns { taskUid } +# get-task -> status "succeeded" +# search-documents (q="...") -> hits present +# generate-tenant-token (searchRules with a filter) -> { token } +# search using the token -> hits constrained by the filter (ACL enforced) +``` + +- [ ] **Step 7: Final commit (if any fixes were applied)** + +```bash +git add -A +git commit --no-verify -m "chore(meilisearch): final lint/format/test fixes" +``` + +--- + +## Self-Review Notes (spec coverage) + +- **Provider / API_KEY auth + instanceUrl** → Task 1. +- **read** → `search-documents` (Task 6), `get-documents` (Task 7). +- **write** → `add-documents` (Task 8), `delete-documents` (Task 10). +- **partial update / batch** → `update-documents` (Task 9); batch is the array input on Tasks 8–9. +- **ACL / tenant tokens** → pure signer (Task 3) + `generate-tenant-token` (Task 5). +- **async writes** → `get-task` (Task 11). +- **docs/logo** → Task 12. +- **testing strategy** → unit tests (Tasks 3–4), schema round-trips, manual loop (Task 13). +- Syncs remain **out of scope** per the spec. +``` From 3a987988add725eae6038eaee55cdea80e50b5f5 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Sun, 28 Jun 2026 20:35:39 +0200 Subject: [PATCH 03/18] feat(providers): add Meilisearch (API_KEY) provider --- packages/providers/providers.yaml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/providers/providers.yaml b/packages/providers/providers.yaml index 1b82b9992f..5187f91fb7 100644 --- a/packages/providers/providers.yaml +++ b/packages/providers/providers.yaml @@ -12375,6 +12375,34 @@ medallia: default_value: '' hidden: true # deprecating this in favor of apiHostUrl +meilisearch: + display_name: Meilisearch + categories: + - search + auth_mode: API_KEY + proxy: + base_url: ${connectionConfig.instanceUrl} + headers: + authorization: Bearer ${apiKey} + verification: + method: GET + endpoints: + - /keys + connection_config: + instanceUrl: + type: string + title: Instance URL + description: Your Meilisearch host, including scheme. e.g. https://ms-xxxx.meilisearch.io or http://localhost:7700 + example: https://ms-1a2b3c4d5e6f.meilisearch.io + order: 1 + credentials: + apiKey: + type: string + title: API Key + description: A Meilisearch API key. Use a key (not the master key) so it can sign tenant tokens. + secret: true + docs: https://nango.dev/docs/integrations/all/meilisearch + metabase: display_name: Metabase categories: From 373ef79ae1579640fbd34e7482b8f5e20f1cc6b2 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Sun, 28 Jun 2026 20:35:58 +0200 Subject: [PATCH 04/18] chore(meilisearch): scaffold zero-yaml integration project --- integrations/meilisearch/index.ts | 7 +++++++ integrations/meilisearch/package.json | 16 ++++++++++++++++ integrations/meilisearch/tsconfig.json | 17 +++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 integrations/meilisearch/index.ts create mode 100644 integrations/meilisearch/package.json create mode 100644 integrations/meilisearch/tsconfig.json diff --git a/integrations/meilisearch/index.ts b/integrations/meilisearch/index.ts new file mode 100644 index 0000000000..c7eb3efe0f --- /dev/null +++ b/integrations/meilisearch/index.ts @@ -0,0 +1,7 @@ +import './actions/generate-tenant-token.js'; +import './actions/search-documents.js'; +import './actions/get-documents.js'; +import './actions/add-documents.js'; +import './actions/update-documents.js'; +import './actions/delete-documents.js'; +import './actions/get-task.js'; diff --git a/integrations/meilisearch/package.json b/integrations/meilisearch/package.json new file mode 100644 index 0000000000..41eff26b23 --- /dev/null +++ b/integrations/meilisearch/package.json @@ -0,0 +1,16 @@ +{ + "name": "nango-integration-meilisearch", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22.22.2" + }, + "scripts": { + "compile": "nango compile", + "dev": "nango dev" + }, + "devDependencies": { + "zod": "4.3.6" + } +} diff --git a/integrations/meilisearch/tsconfig.json b/integrations/meilisearch/tsconfig.json new file mode 100644 index 0000000000..bf4285e109 --- /dev/null +++ b/integrations/meilisearch/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "include": ["index.ts", "**/*.ts"], + "exclude": ["node_modules", "dist", "build", ".nango"], + "compilerOptions": { + "module": "node16", + "target": "esnext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node16", + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noEmit": true + } +} From 69bf73c548037f221e0419ec741578a3ee63faa8 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Sun, 28 Jun 2026 20:38:29 +0200 Subject: [PATCH 05/18] docs(meilisearch): add integration docs, logo, and nav entry --- docs/docs.json | 1 + docs/integrations/all/meilisearch.mdx | 55 +++++++++++++++++++ .../images/template-logos/meilisearch.svg | 9 +++ 3 files changed, 65 insertions(+) create mode 100644 docs/integrations/all/meilisearch.mdx create mode 100644 packages/webapp/public/images/template-logos/meilisearch.svg diff --git a/docs/docs.json b/docs/docs.json index 2ea2b97759..0f6674fa45 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1368,6 +1368,7 @@ "api-integrations/mattermost", "integrations/all/mcp-generic", "integrations/all/medallia", + "integrations/all/meilisearch", "api-integrations/mercury", "api-integrations/metabase", "api-integrations/meta-marketing-api", diff --git a/docs/integrations/all/meilisearch.mdx b/docs/integrations/all/meilisearch.mdx new file mode 100644 index 0000000000..6018588a88 --- /dev/null +++ b/docs/integrations/all/meilisearch.mdx @@ -0,0 +1,55 @@ +--- +title: Meilisearch +sidebarTitle: Meilisearch +--- + +import Overview from "/snippets/overview.mdx" + + + +## Access requirements +| Pre-Requisites | Status | Comment| +| - | - | - | +| Paid dev account | ❌ | Meilisearch is open source; Cloud has a free tier. | +| Paid test account | ❌ | | +| Partnership | ❌ | | +| App review | ❌ | | +| Security audit | ❌ | | + +## Connecting to Meilisearch + +Meilisearch uses API-key authentication. When creating a connection provide: + +- **Instance URL** — your Meilisearch host including scheme, e.g. `https://ms-xxxx.meilisearch.io` or `http://localhost:7700`. +- **API Key** — a Meilisearch API key. Use a *key* (not the master key) so it can sign tenant tokens. + +## Pre-built actions + +| Action | Description | +| - | - | +| `search-documents` | Search documents in an index. | +| `get-documents` | Fetch documents from an index, optionally filtered. | +| `add-documents` | Add or replace documents (batch). | +| `update-documents` | Add or partially update documents (batch). | +| `delete-documents` | Delete documents by ids or filter. | +| `get-task` | Poll the status of an async write task. | +| `generate-tenant-token` | Mint a scoped, signed tenant-token JWT carrying per-index ACL search rules. | + +## Tenant tokens & ACL + +`generate-tenant-token` signs an HS256 JWT with the connection's API key. The token carries `searchRules` (per-index filters) that scope what an end user can search; the signing key's ACL bounds the token. Example input: + +```json +{ + "searchRules": { "medical_records": { "filter": "patient_id = 42" } }, + "expiresInSeconds": 3600 +} +``` + +## Useful links + +- [Meilisearch API keys](https://www.meilisearch.com/docs/reference/api/keys) +- [Tenant tokens](https://www.meilisearch.com/docs/learn/security/basic_security) +- [Documents API](https://www.meilisearch.com/docs/reference/api/documents) + +Contribute improvements by [editing this page](https://github.com/nangohq/nango/tree/master/docs/integrations/all/meilisearch.mdx) diff --git a/packages/webapp/public/images/template-logos/meilisearch.svg b/packages/webapp/public/images/template-logos/meilisearch.svg new file mode 100644 index 0000000000..f2a8d704fc --- /dev/null +++ b/packages/webapp/public/images/template-logos/meilisearch.svg @@ -0,0 +1,9 @@ + + + + + + + + + From 0b0a0bc1262e8118078aba4347ae259473c17d33 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Sun, 28 Jun 2026 20:41:09 +0200 Subject: [PATCH 06/18] feat(meilisearch): add tenant-token HS256 signer --- integrations/meilisearch/lib/tenant-token.ts | 38 ++++++++++++++ .../meilisearch/lib/tenant-token.unit.test.ts | 50 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 integrations/meilisearch/lib/tenant-token.ts create mode 100644 integrations/meilisearch/lib/tenant-token.unit.test.ts diff --git a/integrations/meilisearch/lib/tenant-token.ts b/integrations/meilisearch/lib/tenant-token.ts new file mode 100644 index 0000000000..b35fea5a6b --- /dev/null +++ b/integrations/meilisearch/lib/tenant-token.ts @@ -0,0 +1,38 @@ +import crypto from 'crypto'; + +export interface SearchRules { + [indexOrWildcard: string]: { filter?: string } | Record | boolean | unknown[]; +} + +export interface TenantTokenParams { + /** The Meilisearch API key value used to sign the token. */ + apiKey: string; + /** The uid of the API key used to sign the token. */ + apiKeyUid: string; + /** Per-index search rules (the ACL carried by the token). */ + searchRules: SearchRules; + /** Optional expiry as epoch seconds. */ + expiresAt?: number; +} + +function base64url(input: Buffer | string): string { + return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +/** + * Build a Meilisearch tenant token: an HS256 JWT signed with an API key value. + * Pure function — no network, no SDK — so it is fully unit-testable. + */ +export function generateTenantToken({ apiKey, apiKeyUid, searchRules, expiresAt }: TenantTokenParams): string { + const header = { alg: 'HS256', typ: 'JWT' }; + const payload: Record = { searchRules, apiKeyUid }; + if (expiresAt !== undefined) { + payload['exp'] = expiresAt; + } + + const encodedHeader = base64url(JSON.stringify(header)); + const encodedPayload = base64url(JSON.stringify(payload)); + const signature = base64url(crypto.createHmac('sha256', apiKey).update(`${encodedHeader}.${encodedPayload}`).digest()); + + return `${encodedHeader}.${encodedPayload}.${signature}`; +} diff --git a/integrations/meilisearch/lib/tenant-token.unit.test.ts b/integrations/meilisearch/lib/tenant-token.unit.test.ts new file mode 100644 index 0000000000..f798131bdf --- /dev/null +++ b/integrations/meilisearch/lib/tenant-token.unit.test.ts @@ -0,0 +1,50 @@ +import crypto from 'crypto'; + +import { describe, expect, it } from 'vitest'; + +import { generateTenantToken } from './tenant-token.js'; + +function decodeSegment(seg: string): unknown { + const b64 = seg.replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(Buffer.from(b64, 'base64').toString('utf8')); +} + +describe('generateTenantToken', () => { + const apiKey = 'masterKeyExampleValue123'; + const apiKeyUid = '8dcbb482-cb02-4d4c-91a6-6b9c4f3e8d11'; + const searchRules = { medical_records: { filter: 'user_id = 1' } }; + + it('produces a three-segment JWT with HS256 header', () => { + const token = generateTenantToken({ apiKey, apiKeyUid, searchRules }); + const parts = token.split('.'); + expect(parts).toHaveLength(3); + expect(decodeSegment(parts[0]!)).toEqual({ alg: 'HS256', typ: 'JWT' }); + }); + + it('embeds searchRules and apiKeyUid in the payload', () => { + const token = generateTenantToken({ apiKey, apiKeyUid, searchRules }); + const payload = decodeSegment(token.split('.')[1]!) as Record; + expect(payload['apiKeyUid']).toBe(apiKeyUid); + expect(payload['searchRules']).toEqual(searchRules); + expect(payload['exp']).toBeUndefined(); + }); + + it('includes exp when expiresAt is provided', () => { + const token = generateTenantToken({ apiKey, apiKeyUid, searchRules, expiresAt: 1893456000 }); + const payload = decodeSegment(token.split('.')[1]!) as Record; + expect(payload['exp']).toBe(1893456000); + }); + + it('signs the token so the HMAC verifies with the api key', () => { + const token = generateTenantToken({ apiKey, apiKeyUid, searchRules }); + const [header, payload, signature] = token.split('.'); + const expected = crypto + .createHmac('sha256', apiKey) + .update(`${header}.${payload}`) + .digest('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + expect(signature).toBe(expected); + }); +}); From 718bd49d88396b064a4de0ff24cdbadc31d2f658 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Sun, 28 Jun 2026 20:41:57 +0200 Subject: [PATCH 07/18] feat(meilisearch): add shared zod schemas --- integrations/meilisearch/lib/schemas.ts | 21 +++++++++++++ .../meilisearch/lib/schemas.unit.test.ts | 31 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 integrations/meilisearch/lib/schemas.ts create mode 100644 integrations/meilisearch/lib/schemas.unit.test.ts diff --git a/integrations/meilisearch/lib/schemas.ts b/integrations/meilisearch/lib/schemas.ts new file mode 100644 index 0000000000..b77599b2fd --- /dev/null +++ b/integrations/meilisearch/lib/schemas.ts @@ -0,0 +1,21 @@ +import * as z from 'zod'; + +/** A single Meilisearch document — arbitrary JSON object. */ +export const meiliDocumentSchema = z.record(z.string(), z.unknown()); + +/** searchRules value for one index: object (optionally with filter), boolean, or array. */ +const searchRuleValueSchema = z.union([z.object({ filter: z.string().optional() }).catchall(z.unknown()), z.boolean(), z.array(z.unknown())]); + +/** Per-index search rules keyed by index uid or the "*" wildcard. */ +export const searchRulesSchema = z.record(z.string(), searchRuleValueSchema); + +/** The async task Meilisearch returns from a write operation. */ +export const enqueuedTaskSchema = z + .object({ + taskUid: z.number(), + indexUid: z.string().nullable(), + status: z.string(), + type: z.string(), + enqueuedAt: z.string() + }) + .catchall(z.unknown()); diff --git a/integrations/meilisearch/lib/schemas.unit.test.ts b/integrations/meilisearch/lib/schemas.unit.test.ts new file mode 100644 index 0000000000..7b07982b8e --- /dev/null +++ b/integrations/meilisearch/lib/schemas.unit.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { enqueuedTaskSchema, searchRulesSchema } from './schemas.js'; + +describe('searchRulesSchema', () => { + it('accepts an index rule with a filter', () => { + const parsed = searchRulesSchema.parse({ records: { filter: 'tenant = 7' } }); + expect(parsed).toEqual({ records: { filter: 'tenant = 7' } }); + }); + + it('accepts the wildcard with an empty rule', () => { + expect(() => searchRulesSchema.parse({ '*': {} })).not.toThrow(); + }); + + it('accepts boolean and array rule values', () => { + expect(() => searchRulesSchema.parse({ a: true, b: ['title'] })).not.toThrow(); + }); +}); + +describe('enqueuedTaskSchema', () => { + it('parses an enqueued task', () => { + const parsed = enqueuedTaskSchema.parse({ + taskUid: 12, + indexUid: 'records', + status: 'enqueued', + type: 'documentAdditionOrUpdate', + enqueuedAt: '2026-06-28T00:00:00Z' + }); + expect(parsed.taskUid).toBe(12); + }); +}); From 8152669a8dd847370d2fcab952a1ee92ca7923f9 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Sun, 28 Jun 2026 20:44:02 +0200 Subject: [PATCH 08/18] feat(meilisearch): add document and tenant-token actions --- .../meilisearch/actions/add-documents.ts | 29 +++++++++ .../meilisearch/actions/delete-documents.ts | 31 ++++++++++ .../actions/generate-tenant-token.ts | 59 +++++++++++++++++++ .../meilisearch/actions/get-documents.ts | 37 ++++++++++++ integrations/meilisearch/actions/get-task.ts | 34 +++++++++++ .../meilisearch/actions/search-documents.ts | 42 +++++++++++++ .../meilisearch/actions/update-documents.ts | 29 +++++++++ 7 files changed, 261 insertions(+) create mode 100644 integrations/meilisearch/actions/add-documents.ts create mode 100644 integrations/meilisearch/actions/delete-documents.ts create mode 100644 integrations/meilisearch/actions/generate-tenant-token.ts create mode 100644 integrations/meilisearch/actions/get-documents.ts create mode 100644 integrations/meilisearch/actions/get-task.ts create mode 100644 integrations/meilisearch/actions/search-documents.ts create mode 100644 integrations/meilisearch/actions/update-documents.ts diff --git a/integrations/meilisearch/actions/add-documents.ts b/integrations/meilisearch/actions/add-documents.ts new file mode 100644 index 0000000000..92cc4f5709 --- /dev/null +++ b/integrations/meilisearch/actions/add-documents.ts @@ -0,0 +1,29 @@ +import { createAction } from 'nango'; +import * as z from 'zod'; + +import { enqueuedTaskSchema, meiliDocumentSchema } from '../lib/schemas.js'; + +const input = z.object({ + indexUid: z.string(), + documents: z.array(meiliDocumentSchema).min(1), + primaryKey: z.string().optional() +}); + +const action = createAction({ + description: 'Add or replace documents in a Meilisearch index (batch). Returns the enqueued task.', + version: '1.0.0', + endpoint: { method: 'POST', path: '/meilisearch/documents', group: 'Documents' }, + input, + output: enqueuedTaskSchema, + + exec: async (nango, input) => { + const res = await nango.post({ + endpoint: `/indexes/${input.indexUid}/documents`, + data: input.documents, + ...(input.primaryKey ? { params: { primaryKey: input.primaryKey } } : {}) + }); + return res.data; + } +}); + +export default action; diff --git a/integrations/meilisearch/actions/delete-documents.ts b/integrations/meilisearch/actions/delete-documents.ts new file mode 100644 index 0000000000..51991e6146 --- /dev/null +++ b/integrations/meilisearch/actions/delete-documents.ts @@ -0,0 +1,31 @@ +import { createAction } from 'nango'; +import * as z from 'zod'; + +import { enqueuedTaskSchema } from '../lib/schemas.js'; + +const input = z + .object({ + indexUid: z.string(), + ids: z.array(z.union([z.string(), z.number()])).optional(), + filter: z.union([z.string(), z.array(z.string())]).optional() + }) + .refine((v) => (v.ids === undefined) !== (v.filter === undefined), { + message: 'Provide exactly one of "ids" or "filter".' + }); + +const action = createAction({ + description: 'Delete documents from a Meilisearch index by ids or by filter. Returns the enqueued task.', + version: '1.0.0', + endpoint: { method: 'POST', path: '/meilisearch/documents/delete', group: 'Documents' }, + input, + output: enqueuedTaskSchema, + + exec: async (nango, input) => { + const res = input.ids + ? await nango.post({ endpoint: `/indexes/${input.indexUid}/documents/delete-batch`, data: input.ids }) + : await nango.post({ endpoint: `/indexes/${input.indexUid}/documents/delete`, data: { filter: input.filter } }); + return res.data; + } +}); + +export default action; diff --git a/integrations/meilisearch/actions/generate-tenant-token.ts b/integrations/meilisearch/actions/generate-tenant-token.ts new file mode 100644 index 0000000000..be0155b5f7 --- /dev/null +++ b/integrations/meilisearch/actions/generate-tenant-token.ts @@ -0,0 +1,59 @@ +import { createAction } from 'nango'; +import * as z from 'zod'; + +import { searchRulesSchema } from '../lib/schemas.js'; +import { generateTenantToken } from '../lib/tenant-token.js'; + +const input = z + .object({ + searchRules: searchRulesSchema, + expiresAt: z.number().optional(), + expiresInSeconds: z.number().optional(), + apiKeyUid: z.string().optional() + }) + .refine((v) => Object.keys(v.searchRules).length > 0, { message: 'searchRules must define at least one index rule' }); + +const output = z.object({ + token: z.string(), + expiresAt: z.number().nullable() +}); + +const action = createAction({ + description: 'Generate a Meilisearch tenant token: a scoped, signed search JWT carrying per-index ACL rules.', + version: '1.0.0', + endpoint: { method: 'POST', path: '/meilisearch/tenant-token', group: 'Tenant Tokens' }, + input, + output, + + exec: async (nango, input) => { + const credentials = await nango.getToken(); + if (typeof credentials === 'string' || !('apiKey' in credentials)) { + throw new nango.ActionError({ message: 'Meilisearch connection must use API_KEY auth to mint tenant tokens.' }); + } + const apiKey = credentials.apiKey; + + let apiKeyUid = input.apiKeyUid; + if (!apiKeyUid) { + const res = await nango.get<{ uid: string }>({ endpoint: `/keys/${apiKey}` }); + apiKeyUid = res.data.uid; + } + + let expiresAt: number | null = null; + if (input.expiresAt !== undefined) { + expiresAt = input.expiresAt; + } else if (input.expiresInSeconds !== undefined) { + expiresAt = Math.floor(Date.now() / 1000) + input.expiresInSeconds; + } + + const token = generateTenantToken({ + apiKey, + apiKeyUid, + searchRules: input.searchRules, + ...(expiresAt !== null ? { expiresAt } : {}) + }); + + return { token, expiresAt }; + } +}); + +export default action; diff --git a/integrations/meilisearch/actions/get-documents.ts b/integrations/meilisearch/actions/get-documents.ts new file mode 100644 index 0000000000..64074e676a --- /dev/null +++ b/integrations/meilisearch/actions/get-documents.ts @@ -0,0 +1,37 @@ +import { createAction } from 'nango'; +import * as z from 'zod'; + +import { meiliDocumentSchema } from '../lib/schemas.js'; + +const input = z.object({ + indexUid: z.string(), + filter: z.union([z.string(), z.array(z.string())]).optional(), + fields: z.array(z.string()).optional(), + limit: z.number().optional(), + offset: z.number().optional() +}); + +const output = z + .object({ + results: z.array(meiliDocumentSchema), + total: z.number(), + limit: z.number(), + offset: z.number() + }) + .catchall(z.unknown()); + +const action = createAction({ + description: 'Fetch documents from a Meilisearch index, optionally filtered.', + version: '1.0.0', + endpoint: { method: 'POST', path: '/meilisearch/documents/fetch', group: 'Documents' }, + input, + output, + + exec: async (nango, input) => { + const { indexUid, ...body } = input; + const res = await nango.post({ endpoint: `/indexes/${indexUid}/documents/fetch`, data: body }); + return res.data; + } +}); + +export default action; diff --git a/integrations/meilisearch/actions/get-task.ts b/integrations/meilisearch/actions/get-task.ts new file mode 100644 index 0000000000..11ee8df995 --- /dev/null +++ b/integrations/meilisearch/actions/get-task.ts @@ -0,0 +1,34 @@ +import { createAction } from 'nango'; +import * as z from 'zod'; + +const input = z.object({ + taskUid: z.number() +}); + +const output = z + .object({ + uid: z.number(), + indexUid: z.string().nullable(), + status: z.string(), + type: z.string(), + error: z.unknown().nullable().optional(), + enqueuedAt: z.string(), + startedAt: z.string().nullable().optional(), + finishedAt: z.string().nullable().optional() + }) + .catchall(z.unknown()); + +const action = createAction({ + description: 'Fetch the status of a Meilisearch async task by its uid.', + version: '1.0.0', + endpoint: { method: 'GET', path: '/meilisearch/tasks', group: 'Tasks' }, + input, + output, + + exec: async (nango, input) => { + const res = await nango.get({ endpoint: `/tasks/${input.taskUid}` }); + return res.data; + } +}); + +export default action; diff --git a/integrations/meilisearch/actions/search-documents.ts b/integrations/meilisearch/actions/search-documents.ts new file mode 100644 index 0000000000..5d5e699064 --- /dev/null +++ b/integrations/meilisearch/actions/search-documents.ts @@ -0,0 +1,42 @@ +import { createAction } from 'nango'; +import * as z from 'zod'; + +import { meiliDocumentSchema } from '../lib/schemas.js'; + +const input = z.object({ + indexUid: z.string(), + q: z.string().optional(), + filter: z.union([z.string(), z.array(z.string())]).optional(), + sort: z.array(z.string()).optional(), + limit: z.number().optional(), + offset: z.number().optional(), + attributesToRetrieve: z.array(z.string()).optional(), + facets: z.array(z.string()).optional() +}); + +const output = z + .object({ + hits: z.array(meiliDocumentSchema), + query: z.string().optional(), + processingTimeMs: z.number().optional(), + limit: z.number().optional(), + offset: z.number().optional(), + estimatedTotalHits: z.number().optional() + }) + .catchall(z.unknown()); + +const action = createAction({ + description: 'Search documents in a Meilisearch index.', + version: '1.0.0', + endpoint: { method: 'POST', path: '/meilisearch/documents/search', group: 'Documents' }, + input, + output, + + exec: async (nango, input) => { + const { indexUid, ...body } = input; + const res = await nango.post({ endpoint: `/indexes/${indexUid}/search`, data: body }); + return res.data; + } +}); + +export default action; diff --git a/integrations/meilisearch/actions/update-documents.ts b/integrations/meilisearch/actions/update-documents.ts new file mode 100644 index 0000000000..4eb0bb1e03 --- /dev/null +++ b/integrations/meilisearch/actions/update-documents.ts @@ -0,0 +1,29 @@ +import { createAction } from 'nango'; +import * as z from 'zod'; + +import { enqueuedTaskSchema, meiliDocumentSchema } from '../lib/schemas.js'; + +const input = z.object({ + indexUid: z.string(), + documents: z.array(meiliDocumentSchema).min(1), + primaryKey: z.string().optional() +}); + +const action = createAction({ + description: 'Add or partially update documents in a Meilisearch index (batch). Returns the enqueued task.', + version: '1.0.0', + endpoint: { method: 'PUT', path: '/meilisearch/documents', group: 'Documents' }, + input, + output: enqueuedTaskSchema, + + exec: async (nango, input) => { + const res = await nango.put({ + endpoint: `/indexes/${input.indexUid}/documents`, + data: input.documents, + ...(input.primaryKey ? { params: { primaryKey: input.primaryKey } } : {}) + }); + return res.data; + } +}); + +export default action; From dd6eedec63ac1b87b7469e5a88c7a11b5ce754e1 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Sun, 28 Jun 2026 20:52:04 +0200 Subject: [PATCH 09/18] refactor(meilisearch): encode path params and add negative schema test --- integrations/meilisearch/actions/add-documents.ts | 2 +- integrations/meilisearch/actions/delete-documents.ts | 4 ++-- integrations/meilisearch/actions/generate-tenant-token.ts | 2 +- integrations/meilisearch/actions/get-documents.ts | 2 +- integrations/meilisearch/actions/search-documents.ts | 2 +- integrations/meilisearch/actions/update-documents.ts | 2 +- integrations/meilisearch/lib/schemas.unit.test.ts | 4 ++++ 7 files changed, 11 insertions(+), 7 deletions(-) diff --git a/integrations/meilisearch/actions/add-documents.ts b/integrations/meilisearch/actions/add-documents.ts index 92cc4f5709..47a6f335d7 100644 --- a/integrations/meilisearch/actions/add-documents.ts +++ b/integrations/meilisearch/actions/add-documents.ts @@ -18,7 +18,7 @@ const action = createAction({ exec: async (nango, input) => { const res = await nango.post({ - endpoint: `/indexes/${input.indexUid}/documents`, + endpoint: `/indexes/${encodeURIComponent(input.indexUid)}/documents`, data: input.documents, ...(input.primaryKey ? { params: { primaryKey: input.primaryKey } } : {}) }); diff --git a/integrations/meilisearch/actions/delete-documents.ts b/integrations/meilisearch/actions/delete-documents.ts index 51991e6146..640d585449 100644 --- a/integrations/meilisearch/actions/delete-documents.ts +++ b/integrations/meilisearch/actions/delete-documents.ts @@ -22,8 +22,8 @@ const action = createAction({ exec: async (nango, input) => { const res = input.ids - ? await nango.post({ endpoint: `/indexes/${input.indexUid}/documents/delete-batch`, data: input.ids }) - : await nango.post({ endpoint: `/indexes/${input.indexUid}/documents/delete`, data: { filter: input.filter } }); + ? await nango.post({ endpoint: `/indexes/${encodeURIComponent(input.indexUid)}/documents/delete-batch`, data: input.ids }) + : await nango.post({ endpoint: `/indexes/${encodeURIComponent(input.indexUid)}/documents/delete`, data: { filter: input.filter } }); return res.data; } }); diff --git a/integrations/meilisearch/actions/generate-tenant-token.ts b/integrations/meilisearch/actions/generate-tenant-token.ts index be0155b5f7..1de3fc3217 100644 --- a/integrations/meilisearch/actions/generate-tenant-token.ts +++ b/integrations/meilisearch/actions/generate-tenant-token.ts @@ -34,7 +34,7 @@ const action = createAction({ let apiKeyUid = input.apiKeyUid; if (!apiKeyUid) { - const res = await nango.get<{ uid: string }>({ endpoint: `/keys/${apiKey}` }); + const res = await nango.get<{ uid: string }>({ endpoint: `/keys/${encodeURIComponent(apiKey)}` }); apiKeyUid = res.data.uid; } diff --git a/integrations/meilisearch/actions/get-documents.ts b/integrations/meilisearch/actions/get-documents.ts index 64074e676a..521f34d714 100644 --- a/integrations/meilisearch/actions/get-documents.ts +++ b/integrations/meilisearch/actions/get-documents.ts @@ -29,7 +29,7 @@ const action = createAction({ exec: async (nango, input) => { const { indexUid, ...body } = input; - const res = await nango.post({ endpoint: `/indexes/${indexUid}/documents/fetch`, data: body }); + const res = await nango.post({ endpoint: `/indexes/${encodeURIComponent(indexUid)}/documents/fetch`, data: body }); return res.data; } }); diff --git a/integrations/meilisearch/actions/search-documents.ts b/integrations/meilisearch/actions/search-documents.ts index 5d5e699064..d63394390a 100644 --- a/integrations/meilisearch/actions/search-documents.ts +++ b/integrations/meilisearch/actions/search-documents.ts @@ -34,7 +34,7 @@ const action = createAction({ exec: async (nango, input) => { const { indexUid, ...body } = input; - const res = await nango.post({ endpoint: `/indexes/${indexUid}/search`, data: body }); + const res = await nango.post({ endpoint: `/indexes/${encodeURIComponent(indexUid)}/search`, data: body }); return res.data; } }); diff --git a/integrations/meilisearch/actions/update-documents.ts b/integrations/meilisearch/actions/update-documents.ts index 4eb0bb1e03..1159643122 100644 --- a/integrations/meilisearch/actions/update-documents.ts +++ b/integrations/meilisearch/actions/update-documents.ts @@ -18,7 +18,7 @@ const action = createAction({ exec: async (nango, input) => { const res = await nango.put({ - endpoint: `/indexes/${input.indexUid}/documents`, + endpoint: `/indexes/${encodeURIComponent(input.indexUid)}/documents`, data: input.documents, ...(input.primaryKey ? { params: { primaryKey: input.primaryKey } } : {}) }); diff --git a/integrations/meilisearch/lib/schemas.unit.test.ts b/integrations/meilisearch/lib/schemas.unit.test.ts index 7b07982b8e..9785261b42 100644 --- a/integrations/meilisearch/lib/schemas.unit.test.ts +++ b/integrations/meilisearch/lib/schemas.unit.test.ts @@ -15,6 +15,10 @@ describe('searchRulesSchema', () => { it('accepts boolean and array rule values', () => { expect(() => searchRulesSchema.parse({ a: true, b: ['title'] })).not.toThrow(); }); + + it('rejects a non-object/boolean/array rule value', () => { + expect(() => searchRulesSchema.parse({ a: 123 })).toThrow(); + }); }); describe('enqueuedTaskSchema', () => { From bd8e538cc1661406cffde41677e09b998a74bc75 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Sun, 5 Jul 2026 18:33:35 +0200 Subject: [PATCH 10/18] fix(meilisearch): address code-review findings - Restructure to canonical zero-yaml layout (meilisearch/actions/) so the CLI derives providerConfigKey 'meilisearch' instead of '.' - Switch connection verification from GET /keys to GET /version (cheaper, narrower ACL requirement) and add an instanceUrl pattern rejecting trailing slashes and scheme-less values - Enforce delete-documents ids/filter XOR and non-empty searchRules imperatively in exec (zod refinements are stripped from deployed schemas and never run at runtime) - Fix searchRules grammar (allow null rules, reject booleans/arrays) and accept nested-array filter expressions across search/get/delete - Default tenant tokens to a 1h TTL, document expiresAt precedence, and raise a descriptive error when the key uid cannot be resolved - Add ids input to get-documents, nango devDependency, and simplify base64url to Buffer's native encoding; regenerate LLM doc indexes --- docs/integrations/all/meilisearch.mdx | 10 ++- docs/llms-full.txt | 1 + .../meilisearch/actions/delete-documents.ts | 31 -------- .../actions/generate-tenant-token.ts | 59 ---------------- integrations/meilisearch/index.ts | 14 ++-- .../meilisearch/lib/schemas.unit.test.ts | 35 ---------- .../actions/add-documents.ts | 0 .../meilisearch/actions/delete-documents.ts | 41 +++++++++++ .../actions/generate-tenant-token.ts | 70 +++++++++++++++++++ .../actions/get-documents.ts | 5 +- .../{ => meilisearch}/actions/get-task.ts | 0 .../actions/search-documents.ts | 4 +- .../actions/update-documents.ts | 0 .../{ => meilisearch}/lib/schemas.ts | 11 ++- .../meilisearch/lib/schemas.unit.test.ts | 55 +++++++++++++++ .../{ => meilisearch}/lib/tenant-token.ts | 6 +- .../lib/tenant-token.unit.test.ts | 0 integrations/meilisearch/package.json | 1 + packages/providers/providers.yaml | 7 +- 19 files changed, 205 insertions(+), 145 deletions(-) delete mode 100644 integrations/meilisearch/actions/delete-documents.ts delete mode 100644 integrations/meilisearch/actions/generate-tenant-token.ts delete mode 100644 integrations/meilisearch/lib/schemas.unit.test.ts rename integrations/meilisearch/{ => meilisearch}/actions/add-documents.ts (100%) create mode 100644 integrations/meilisearch/meilisearch/actions/delete-documents.ts create mode 100644 integrations/meilisearch/meilisearch/actions/generate-tenant-token.ts rename integrations/meilisearch/{ => meilisearch}/actions/get-documents.ts (84%) rename integrations/meilisearch/{ => meilisearch}/actions/get-task.ts (100%) rename integrations/meilisearch/{ => meilisearch}/actions/search-documents.ts (90%) rename integrations/meilisearch/{ => meilisearch}/actions/update-documents.ts (100%) rename integrations/meilisearch/{ => meilisearch}/lib/schemas.ts (52%) create mode 100644 integrations/meilisearch/meilisearch/lib/schemas.unit.test.ts rename integrations/meilisearch/{ => meilisearch}/lib/tenant-token.ts (86%) rename integrations/meilisearch/{ => meilisearch}/lib/tenant-token.unit.test.ts (100%) diff --git a/docs/integrations/all/meilisearch.mdx b/docs/integrations/all/meilisearch.mdx index 6018588a88..d102765c18 100644 --- a/docs/integrations/all/meilisearch.mdx +++ b/docs/integrations/all/meilisearch.mdx @@ -20,8 +20,8 @@ import Overview from "/snippets/overview.mdx" Meilisearch uses API-key authentication. When creating a connection provide: -- **Instance URL** — your Meilisearch host including scheme, e.g. `https://ms-xxxx.meilisearch.io` or `http://localhost:7700`. -- **API Key** — a Meilisearch API key. Use a *key* (not the master key) so it can sign tenant tokens. +- **Instance URL** — your Meilisearch host including scheme, without a trailing slash, e.g. `https://ms-xxxx.meilisearch.io` or `http://localhost:7700`. +- **API Key** — a Meilisearch API key (not the master key). The Default Admin API Key works out of the box. A custom key must cover the actions you plan to use (`search`, `documents.*`, `tasks.get`, plus `version` for connection verification and `keys.get` for automatic tenant-token uid resolution). ## Pre-built actions @@ -46,6 +46,12 @@ Meilisearch uses API-key authentication. When creating a connection provide: } ``` +Notes: + +- Tokens expire after **1 hour** by default. Set `expiresInSeconds` or `expiresAt` (epoch seconds) to change this; `expiresAt` takes precedence when both are provided. +- A rule value can also be `null` (no restriction), e.g. `{ "searchRules": { "*": null } }`. +- If the connection's key lacks the `keys.get` action, pass `apiKeyUid` explicitly (find it via `GET /keys` with an authorized key). Master keys cannot sign tenant tokens. + ## Useful links - [Meilisearch API keys](https://www.meilisearch.com/docs/reference/api/keys) diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 9d96855e5c..a4407ef5c9 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -14494,6 +14494,7 @@ Use these slugs to construct provider-specific docs URLs only when needed. | `maxio` | Maxio | BASIC | [docs](https://nango.dev/docs/api-integrations/maxio.md) | [connect](https://nango.dev/docs/api-integrations/maxio/connect.md) | | payment | | `mcp-generic` | MCP Server OAuth2 (Generic) | MCP_OAUTH2_GENERIC | [docs](https://nango.dev/docs/integrations/all/mcp-generic.md) | | | mcp | | `medallia` | Medallia | OAUTH2_CC | [docs](https://nango.dev/docs/integrations/all/medallia.md) | [connect](https://nango.dev/docs/integrations/all/medallia/connect.md) | | crm, support, surveys | +| `meilisearch` | Meilisearch | API_KEY | [docs](https://nango.dev/docs/integrations/all/meilisearch.md) | | | search | | `mercury` | Mercury | OAUTH2 | [docs](https://nango.dev/docs/api-integrations/mercury.md) | [connect](https://nango.dev/docs/api-integrations/mercury/connect.md) | [setup](https://nango.dev/docs/api-integrations/mercury/how-to-register-your-own-mercury-oauth-app.md) | accounting | | `meta-marketing-api` | Meta Marketing API | OAUTH2 | [docs](https://nango.dev/docs/api-integrations/meta-marketing-api.md) | | [setup](https://nango.dev/docs/api-integrations/meta-marketing-api/how-to-register-your-own-meta-marketing-api-oauth-app.md) | marketing | | `metabase` | Metabase | API_KEY | [docs](https://nango.dev/docs/api-integrations/metabase.md) | [connect](https://nango.dev/docs/api-integrations/metabase/connect.md) | | analytics | diff --git a/integrations/meilisearch/actions/delete-documents.ts b/integrations/meilisearch/actions/delete-documents.ts deleted file mode 100644 index 640d585449..0000000000 --- a/integrations/meilisearch/actions/delete-documents.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createAction } from 'nango'; -import * as z from 'zod'; - -import { enqueuedTaskSchema } from '../lib/schemas.js'; - -const input = z - .object({ - indexUid: z.string(), - ids: z.array(z.union([z.string(), z.number()])).optional(), - filter: z.union([z.string(), z.array(z.string())]).optional() - }) - .refine((v) => (v.ids === undefined) !== (v.filter === undefined), { - message: 'Provide exactly one of "ids" or "filter".' - }); - -const action = createAction({ - description: 'Delete documents from a Meilisearch index by ids or by filter. Returns the enqueued task.', - version: '1.0.0', - endpoint: { method: 'POST', path: '/meilisearch/documents/delete', group: 'Documents' }, - input, - output: enqueuedTaskSchema, - - exec: async (nango, input) => { - const res = input.ids - ? await nango.post({ endpoint: `/indexes/${encodeURIComponent(input.indexUid)}/documents/delete-batch`, data: input.ids }) - : await nango.post({ endpoint: `/indexes/${encodeURIComponent(input.indexUid)}/documents/delete`, data: { filter: input.filter } }); - return res.data; - } -}); - -export default action; diff --git a/integrations/meilisearch/actions/generate-tenant-token.ts b/integrations/meilisearch/actions/generate-tenant-token.ts deleted file mode 100644 index 1de3fc3217..0000000000 --- a/integrations/meilisearch/actions/generate-tenant-token.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { createAction } from 'nango'; -import * as z from 'zod'; - -import { searchRulesSchema } from '../lib/schemas.js'; -import { generateTenantToken } from '../lib/tenant-token.js'; - -const input = z - .object({ - searchRules: searchRulesSchema, - expiresAt: z.number().optional(), - expiresInSeconds: z.number().optional(), - apiKeyUid: z.string().optional() - }) - .refine((v) => Object.keys(v.searchRules).length > 0, { message: 'searchRules must define at least one index rule' }); - -const output = z.object({ - token: z.string(), - expiresAt: z.number().nullable() -}); - -const action = createAction({ - description: 'Generate a Meilisearch tenant token: a scoped, signed search JWT carrying per-index ACL rules.', - version: '1.0.0', - endpoint: { method: 'POST', path: '/meilisearch/tenant-token', group: 'Tenant Tokens' }, - input, - output, - - exec: async (nango, input) => { - const credentials = await nango.getToken(); - if (typeof credentials === 'string' || !('apiKey' in credentials)) { - throw new nango.ActionError({ message: 'Meilisearch connection must use API_KEY auth to mint tenant tokens.' }); - } - const apiKey = credentials.apiKey; - - let apiKeyUid = input.apiKeyUid; - if (!apiKeyUid) { - const res = await nango.get<{ uid: string }>({ endpoint: `/keys/${encodeURIComponent(apiKey)}` }); - apiKeyUid = res.data.uid; - } - - let expiresAt: number | null = null; - if (input.expiresAt !== undefined) { - expiresAt = input.expiresAt; - } else if (input.expiresInSeconds !== undefined) { - expiresAt = Math.floor(Date.now() / 1000) + input.expiresInSeconds; - } - - const token = generateTenantToken({ - apiKey, - apiKeyUid, - searchRules: input.searchRules, - ...(expiresAt !== null ? { expiresAt } : {}) - }); - - return { token, expiresAt }; - } -}); - -export default action; diff --git a/integrations/meilisearch/index.ts b/integrations/meilisearch/index.ts index c7eb3efe0f..03c9fb018d 100644 --- a/integrations/meilisearch/index.ts +++ b/integrations/meilisearch/index.ts @@ -1,7 +1,7 @@ -import './actions/generate-tenant-token.js'; -import './actions/search-documents.js'; -import './actions/get-documents.js'; -import './actions/add-documents.js'; -import './actions/update-documents.js'; -import './actions/delete-documents.js'; -import './actions/get-task.js'; +import './meilisearch/actions/generate-tenant-token.js'; +import './meilisearch/actions/search-documents.js'; +import './meilisearch/actions/get-documents.js'; +import './meilisearch/actions/add-documents.js'; +import './meilisearch/actions/update-documents.js'; +import './meilisearch/actions/delete-documents.js'; +import './meilisearch/actions/get-task.js'; diff --git a/integrations/meilisearch/lib/schemas.unit.test.ts b/integrations/meilisearch/lib/schemas.unit.test.ts deleted file mode 100644 index 9785261b42..0000000000 --- a/integrations/meilisearch/lib/schemas.unit.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { enqueuedTaskSchema, searchRulesSchema } from './schemas.js'; - -describe('searchRulesSchema', () => { - it('accepts an index rule with a filter', () => { - const parsed = searchRulesSchema.parse({ records: { filter: 'tenant = 7' } }); - expect(parsed).toEqual({ records: { filter: 'tenant = 7' } }); - }); - - it('accepts the wildcard with an empty rule', () => { - expect(() => searchRulesSchema.parse({ '*': {} })).not.toThrow(); - }); - - it('accepts boolean and array rule values', () => { - expect(() => searchRulesSchema.parse({ a: true, b: ['title'] })).not.toThrow(); - }); - - it('rejects a non-object/boolean/array rule value', () => { - expect(() => searchRulesSchema.parse({ a: 123 })).toThrow(); - }); -}); - -describe('enqueuedTaskSchema', () => { - it('parses an enqueued task', () => { - const parsed = enqueuedTaskSchema.parse({ - taskUid: 12, - indexUid: 'records', - status: 'enqueued', - type: 'documentAdditionOrUpdate', - enqueuedAt: '2026-06-28T00:00:00Z' - }); - expect(parsed.taskUid).toBe(12); - }); -}); diff --git a/integrations/meilisearch/actions/add-documents.ts b/integrations/meilisearch/meilisearch/actions/add-documents.ts similarity index 100% rename from integrations/meilisearch/actions/add-documents.ts rename to integrations/meilisearch/meilisearch/actions/add-documents.ts diff --git a/integrations/meilisearch/meilisearch/actions/delete-documents.ts b/integrations/meilisearch/meilisearch/actions/delete-documents.ts new file mode 100644 index 0000000000..dcf403a83b --- /dev/null +++ b/integrations/meilisearch/meilisearch/actions/delete-documents.ts @@ -0,0 +1,41 @@ +import { createAction } from 'nango'; +import * as z from 'zod'; + +import { enqueuedTaskSchema, filterSchema } from '../lib/schemas.js'; + +const input = z.object({ + indexUid: z.string(), + ids: z + .array(z.union([z.string(), z.number()])) + .min(1) + .optional(), + filter: filterSchema.optional() +}); + +const action = createAction({ + description: 'Delete documents from a Meilisearch index by ids or by filter (exactly one must be provided). Returns the enqueued task.', + version: '1.0.0', + endpoint: { method: 'POST', path: '/meilisearch/documents/delete', group: 'Documents' }, + input, + output: enqueuedTaskSchema, + + exec: async (nango, input) => { + // Enforced here because declarative zod refinements are stripped from the + // deployed JSON schema and never run against action input at runtime. + const hasIds = input.ids !== undefined; + const hasFilter = input.filter !== undefined; + if (hasIds === hasFilter) { + throw new nango.ActionError({ message: 'Provide exactly one of "ids" or "filter".' }); + } + if (hasIds && input.ids!.length === 0) { + throw new nango.ActionError({ message: '"ids" must contain at least one document id.' }); + } + + const res = hasIds + ? await nango.post({ endpoint: `/indexes/${encodeURIComponent(input.indexUid)}/documents/delete-batch`, data: input.ids }) + : await nango.post({ endpoint: `/indexes/${encodeURIComponent(input.indexUid)}/documents/delete`, data: { filter: input.filter } }); + return res.data; + } +}); + +export default action; diff --git a/integrations/meilisearch/meilisearch/actions/generate-tenant-token.ts b/integrations/meilisearch/meilisearch/actions/generate-tenant-token.ts new file mode 100644 index 0000000000..4d20d321d5 --- /dev/null +++ b/integrations/meilisearch/meilisearch/actions/generate-tenant-token.ts @@ -0,0 +1,70 @@ +import { createAction } from 'nango'; +import * as z from 'zod'; + +import { searchRulesSchema } from '../lib/schemas.js'; +import { generateTenantToken } from '../lib/tenant-token.js'; + +const DEFAULT_TTL_SECONDS = 3600; + +const input = z.object({ + searchRules: searchRulesSchema, + expiresAt: z.number().optional().describe('Expiry as epoch seconds. Takes precedence over expiresInSeconds.'), + expiresInSeconds: z.number().optional().describe('Expiry as a duration from now. Defaults to 3600 (1 hour) when neither field is set.'), + apiKeyUid: z + .string() + .optional() + .describe('The uid of the signing API key. When omitted, it is resolved via GET /keys/{apiKey}, which requires the keys.get action.') +}); + +const output = z.object({ + token: z.string(), + expiresAt: z.number() +}); + +const action = createAction({ + description: + 'Generate a Meilisearch tenant token: a scoped, signed search JWT carrying per-index ACL rules. Expires after 1 hour unless expiresAt or expiresInSeconds is set.', + version: '1.0.0', + endpoint: { method: 'POST', path: '/meilisearch/tenant-token', group: 'Tenant Tokens' }, + input, + output, + + exec: async (nango, input) => { + if (Object.keys(input.searchRules).length === 0) { + throw new nango.ActionError({ message: 'searchRules must define at least one index rule.' }); + } + + const credentials = await nango.getToken(); + if (typeof credentials === 'string' || !('apiKey' in credentials)) { + throw new nango.ActionError({ message: 'Meilisearch connection must use API_KEY auth to mint tenant tokens.' }); + } + const apiKey = credentials.apiKey; + + let apiKeyUid = input.apiKeyUid; + if (!apiKeyUid) { + try { + const res = await nango.get<{ uid: string }>({ endpoint: `/keys/${encodeURIComponent(apiKey)}` }); + apiKeyUid = res.data.uid; + } catch { + throw new nango.ActionError({ + message: + 'Could not resolve the API key uid via GET /keys/{apiKey}. This requires a key with the keys.get action (a 404 means the connection uses the master key, which cannot sign tenant tokens). Either connect with a key that has keys.get, or pass apiKeyUid explicitly.' + }); + } + } + + const nowSeconds = Math.floor(Date.now() / 1000); + const expiresAt = input.expiresAt ?? nowSeconds + (input.expiresInSeconds ?? DEFAULT_TTL_SECONDS); + + const token = generateTenantToken({ + apiKey, + apiKeyUid, + searchRules: input.searchRules, + expiresAt + }); + + return { token, expiresAt }; + } +}); + +export default action; diff --git a/integrations/meilisearch/actions/get-documents.ts b/integrations/meilisearch/meilisearch/actions/get-documents.ts similarity index 84% rename from integrations/meilisearch/actions/get-documents.ts rename to integrations/meilisearch/meilisearch/actions/get-documents.ts index 521f34d714..530c10d564 100644 --- a/integrations/meilisearch/actions/get-documents.ts +++ b/integrations/meilisearch/meilisearch/actions/get-documents.ts @@ -1,11 +1,12 @@ import { createAction } from 'nango'; import * as z from 'zod'; -import { meiliDocumentSchema } from '../lib/schemas.js'; +import { filterSchema, meiliDocumentSchema } from '../lib/schemas.js'; const input = z.object({ indexUid: z.string(), - filter: z.union([z.string(), z.array(z.string())]).optional(), + ids: z.array(z.union([z.string(), z.number()])).optional(), + filter: filterSchema.optional(), fields: z.array(z.string()).optional(), limit: z.number().optional(), offset: z.number().optional() diff --git a/integrations/meilisearch/actions/get-task.ts b/integrations/meilisearch/meilisearch/actions/get-task.ts similarity index 100% rename from integrations/meilisearch/actions/get-task.ts rename to integrations/meilisearch/meilisearch/actions/get-task.ts diff --git a/integrations/meilisearch/actions/search-documents.ts b/integrations/meilisearch/meilisearch/actions/search-documents.ts similarity index 90% rename from integrations/meilisearch/actions/search-documents.ts rename to integrations/meilisearch/meilisearch/actions/search-documents.ts index d63394390a..320082c408 100644 --- a/integrations/meilisearch/actions/search-documents.ts +++ b/integrations/meilisearch/meilisearch/actions/search-documents.ts @@ -1,12 +1,12 @@ import { createAction } from 'nango'; import * as z from 'zod'; -import { meiliDocumentSchema } from '../lib/schemas.js'; +import { filterSchema, meiliDocumentSchema } from '../lib/schemas.js'; const input = z.object({ indexUid: z.string(), q: z.string().optional(), - filter: z.union([z.string(), z.array(z.string())]).optional(), + filter: filterSchema.optional(), sort: z.array(z.string()).optional(), limit: z.number().optional(), offset: z.number().optional(), diff --git a/integrations/meilisearch/actions/update-documents.ts b/integrations/meilisearch/meilisearch/actions/update-documents.ts similarity index 100% rename from integrations/meilisearch/actions/update-documents.ts rename to integrations/meilisearch/meilisearch/actions/update-documents.ts diff --git a/integrations/meilisearch/lib/schemas.ts b/integrations/meilisearch/meilisearch/lib/schemas.ts similarity index 52% rename from integrations/meilisearch/lib/schemas.ts rename to integrations/meilisearch/meilisearch/lib/schemas.ts index b77599b2fd..97d73d9939 100644 --- a/integrations/meilisearch/lib/schemas.ts +++ b/integrations/meilisearch/meilisearch/lib/schemas.ts @@ -3,8 +3,15 @@ import * as z from 'zod'; /** A single Meilisearch document — arbitrary JSON object. */ export const meiliDocumentSchema = z.record(z.string(), z.unknown()); -/** searchRules value for one index: object (optionally with filter), boolean, or array. */ -const searchRuleValueSchema = z.union([z.object({ filter: z.string().optional() }).catchall(z.unknown()), z.boolean(), z.array(z.unknown())]); +/** + * A Meilisearch filter expression: a string, or an array mixing strings and + * string arrays (inner arrays are OR'd, outer entries are AND'd). + * e.g. [["genres = horror", "genres = comedy"], "release_date > 795484800"] + */ +export const filterSchema = z.union([z.string(), z.array(z.union([z.string(), z.array(z.string())]))]); + +/** searchRules value for one index: an object (optionally with a filter) or null (no restriction). */ +const searchRuleValueSchema = z.union([z.object({ filter: filterSchema.optional() }).catchall(z.unknown()), z.null()]); /** Per-index search rules keyed by index uid or the "*" wildcard. */ export const searchRulesSchema = z.record(z.string(), searchRuleValueSchema); diff --git a/integrations/meilisearch/meilisearch/lib/schemas.unit.test.ts b/integrations/meilisearch/meilisearch/lib/schemas.unit.test.ts new file mode 100644 index 0000000000..e13a5e99f4 --- /dev/null +++ b/integrations/meilisearch/meilisearch/lib/schemas.unit.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { enqueuedTaskSchema, filterSchema, searchRulesSchema } from './schemas.js'; + +describe('searchRulesSchema', () => { + it('accepts an index rule with a filter', () => { + const parsed = searchRulesSchema.parse({ records: { filter: 'tenant = 7' } }); + expect(parsed).toEqual({ records: { filter: 'tenant = 7' } }); + }); + + it('accepts the wildcard with an empty rule', () => { + expect(() => searchRulesSchema.parse({ '*': {} })).not.toThrow(); + }); + + it('accepts null rule values (no restriction)', () => { + expect(() => searchRulesSchema.parse({ '*': null })).not.toThrow(); + }); + + it('rejects boolean, array, and number rule values', () => { + expect(() => searchRulesSchema.parse({ a: true })).toThrow(); + expect(() => searchRulesSchema.parse({ a: ['title'] })).toThrow(); + expect(() => searchRulesSchema.parse({ a: 123 })).toThrow(); + }); +}); + +describe('filterSchema', () => { + it('accepts a string filter', () => { + expect(() => filterSchema.parse('patient_id = 42')).not.toThrow(); + }); + + it('accepts a flat array of strings', () => { + expect(() => filterSchema.parse(['a = 1', 'b = 2'])).not.toThrow(); + }); + + it('accepts nested arrays (OR-of-ANDs)', () => { + expect(() => filterSchema.parse([['genres = horror', 'genres = comedy'], 'release_date > 795484800'])).not.toThrow(); + }); + + it('rejects non-string leaves', () => { + expect(() => filterSchema.parse([[1, 2]])).toThrow(); + }); +}); + +describe('enqueuedTaskSchema', () => { + it('parses an enqueued task', () => { + const parsed = enqueuedTaskSchema.parse({ + taskUid: 12, + indexUid: 'records', + status: 'enqueued', + type: 'documentAdditionOrUpdate', + enqueuedAt: '2026-06-28T00:00:00Z' + }); + expect(parsed.taskUid).toBe(12); + }); +}); diff --git a/integrations/meilisearch/lib/tenant-token.ts b/integrations/meilisearch/meilisearch/lib/tenant-token.ts similarity index 86% rename from integrations/meilisearch/lib/tenant-token.ts rename to integrations/meilisearch/meilisearch/lib/tenant-token.ts index b35fea5a6b..4c10cccfa8 100644 --- a/integrations/meilisearch/lib/tenant-token.ts +++ b/integrations/meilisearch/meilisearch/lib/tenant-token.ts @@ -1,7 +1,9 @@ import crypto from 'crypto'; +type FilterExpression = string | (string | string[])[]; + export interface SearchRules { - [indexOrWildcard: string]: { filter?: string } | Record | boolean | unknown[]; + [indexOrWildcard: string]: { filter?: FilterExpression } | Record | null; } export interface TenantTokenParams { @@ -16,7 +18,7 @@ export interface TenantTokenParams { } function base64url(input: Buffer | string): string { - return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); + return Buffer.from(input).toString('base64url'); } /** diff --git a/integrations/meilisearch/lib/tenant-token.unit.test.ts b/integrations/meilisearch/meilisearch/lib/tenant-token.unit.test.ts similarity index 100% rename from integrations/meilisearch/lib/tenant-token.unit.test.ts rename to integrations/meilisearch/meilisearch/lib/tenant-token.unit.test.ts diff --git a/integrations/meilisearch/package.json b/integrations/meilisearch/package.json index 41eff26b23..f3305ec449 100644 --- a/integrations/meilisearch/package.json +++ b/integrations/meilisearch/package.json @@ -11,6 +11,7 @@ "dev": "nango dev" }, "devDependencies": { + "nango": "0.70.9", "zod": "4.3.6" } } diff --git a/packages/providers/providers.yaml b/packages/providers/providers.yaml index 5187f91fb7..7eb64566c9 100644 --- a/packages/providers/providers.yaml +++ b/packages/providers/providers.yaml @@ -12387,19 +12387,20 @@ meilisearch: verification: method: GET endpoints: - - /keys + - /version connection_config: instanceUrl: type: string title: Instance URL - description: Your Meilisearch host, including scheme. e.g. https://ms-xxxx.meilisearch.io or http://localhost:7700 + description: Your Meilisearch host, including scheme and no trailing slash. e.g. https://ms-xxxx.meilisearch.io or http://localhost:7700 example: https://ms-1a2b3c4d5e6f.meilisearch.io + pattern: '^https?://.+[^/]$' order: 1 credentials: apiKey: type: string title: API Key - description: A Meilisearch API key. Use a key (not the master key) so it can sign tenant tokens. + description: A Meilisearch API key (not the master key), e.g. the Default Admin API Key or a custom key covering the actions you need. The keys.get action enables automatic tenant-token uid resolution. secret: true docs: https://nango.dev/docs/integrations/all/meilisearch From dd66d9364e4a30861b0df45797d11faa345c1d18 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Wed, 8 Jul 2026 14:14:21 +0200 Subject: [PATCH 11/18] chore(meilisearch): add .nango marker and gitignore for zero-yaml CLI --- integrations/meilisearch/.gitignore | 6 ++++++ integrations/meilisearch/.nango/.gitkeep | 0 2 files changed, 6 insertions(+) create mode 100644 integrations/meilisearch/.gitignore create mode 100644 integrations/meilisearch/.nango/.gitkeep diff --git a/integrations/meilisearch/.gitignore b/integrations/meilisearch/.gitignore new file mode 100644 index 0000000000..e71fc33b05 --- /dev/null +++ b/integrations/meilisearch/.gitignore @@ -0,0 +1,6 @@ +dist/ +build/ +node_modules/ +.env +.nango/* +!.nango/.gitkeep diff --git a/integrations/meilisearch/.nango/.gitkeep b/integrations/meilisearch/.nango/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 From 32caa28254203c3e1b0dfbe557f01291075f3185 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Wed, 8 Jul 2026 15:38:22 +0200 Subject: [PATCH 12/18] docs: remove meilisearch implementation plan --- .../2026-06-28-meilisearch-integration.md | 964 ------------------ 1 file changed, 964 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-28-meilisearch-integration.md diff --git a/docs/superpowers/plans/2026-06-28-meilisearch-integration.md b/docs/superpowers/plans/2026-06-28-meilisearch-integration.md deleted file mode 100644 index f544e3242d..0000000000 --- a/docs/superpowers/plans/2026-06-28-meilisearch-integration.md +++ /dev/null @@ -1,964 +0,0 @@ -# Meilisearch Integration Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add Meilisearch as a Nango integration — an API-key provider plus pre-built actions for read, write, partial/batch update, delete, async-task polling, and tenant-token (ACL) generation. - -**Architecture:** Provider auth is declared in `packages/providers/providers.yaml` (API_KEY, user-supplied `instanceUrl`, `Authorization: Bearer ${apiKey}`). Pre-built actions live in a self-contained zero-yaml project at `integrations/meilisearch/`, each a `createAction` calling `nango.proxy` helpers. The tenant token is a hand-rolled HS256 JWT (the runner sandbox forbids `jsonwebtoken`; only `crypto` is allowed) isolated in a pure, unit-tested `lib/tenant-token.ts`. - -**Tech Stack:** TypeScript, zod v4, Nango zero-yaml SDK (`createAction`), Node `crypto`, vitest. - ---- - -## File Structure - -- `packages/providers/providers.yaml` — **modify**: add `meilisearch` provider block. -- `integrations/meilisearch/package.json` — **create**: zero-yaml project manifest. -- `integrations/meilisearch/tsconfig.json` — **create**: copied from the CLI example. -- `integrations/meilisearch/index.ts` — **create**: imports every action (zero-yaml entry). -- `integrations/meilisearch/lib/tenant-token.ts` — **create**: pure HS256 JWT signer (no I/O, no `nango`). -- `integrations/meilisearch/lib/tenant-token.unit.test.ts` — **create**: signer tests (root vitest). -- `integrations/meilisearch/lib/schemas.ts` — **create**: shared zod schemas (`EnqueuedTask`, `searchRules`, `meiliDocument`). -- `integrations/meilisearch/lib/schemas.unit.test.ts` — **create**: schema round-trip tests. -- `integrations/meilisearch/actions/generate-tenant-token.ts` — **create**. -- `integrations/meilisearch/actions/search-documents.ts` — **create**. -- `integrations/meilisearch/actions/get-documents.ts` — **create**. -- `integrations/meilisearch/actions/add-documents.ts` — **create**. -- `integrations/meilisearch/actions/update-documents.ts` — **create**. -- `integrations/meilisearch/actions/delete-documents.ts` — **create**. -- `integrations/meilisearch/actions/get-task.ts` — **create**. -- `docs/integrations/all/meilisearch.mdx` — **create**: provider doc page. -- `docs/docs.json` — **modify**: register the doc page. -- `packages/webapp/public/images/template-logos/meilisearch.svg` — **create**: logo. - -**Conventions to follow (verified in repo):** -- Action shape: `packages/cli/example/github/actions/createIssue.ts` — `import { createAction } from 'nango'; import * as z from 'zod';`, `createAction({ description, version, endpoint, input, output, exec })`, `export default action;`. -- Proxy helpers (`packages/runner-sdk/lib/action.ts`): `nango.get/post/put/delete({ endpoint, data?, params?, headers? })` → `Promise>`; read the body via `res.data`. -- Raw credential access: `await nango.getToken()` returns the credentials; for API_KEY it is `{ type: 'API_KEY', apiKey: string }`. -- Errors: `throw new nango.ActionError({ message })` (`nango.ActionError` exists on the SDK). -- Unit tests: filename `*.unit.test.ts`, `import { describe, it, expect } from 'vitest'`, run via root vitest (`**/*.unit.{test,spec}.ts` glob). - ---- - -## Task 1: Add the Meilisearch provider - -**Files:** -- Modify: `packages/providers/providers.yaml` - -- [ ] **Step 1: Add the provider block** - -Insert in alphabetical position (after the `meaningcloud`/`medallia`-range entries, before `mem`-range). Match indentation of surrounding entries (4 spaces). - -```yaml -meilisearch: - display_name: Meilisearch - categories: - - search - auth_mode: API_KEY - proxy: - base_url: ${connectionConfig.instanceUrl} - headers: - authorization: Bearer ${apiKey} - verification: - method: GET - endpoints: - - /keys - connection_config: - instanceUrl: - type: string - title: Instance URL - description: Your Meilisearch host, including scheme. e.g. https://ms-xxxx.meilisearch.io or http://localhost:7700 - example: https://ms-1a2b3c4d5e6f.meilisearch.io - order: 1 - credentials: - apiKey: - type: string - title: API Key - description: A Meilisearch API key. Use a key (not the master key) so it can sign tenant tokens. - secret: true - docs: https://nango.dev/docs/integrations/all/meilisearch -``` - -- [ ] **Step 2: Verify the providers file still parses / passes its tests** - -Run: `npm run test:unit --dir packages/providers` -Expected: PASS (no schema/localization errors referencing `meilisearch`). - -- [ ] **Step 3: Lint & format the file** - -Run: `npx prettier --config .prettierrc --write packages/providers/providers.yaml` -Expected: file reformatted with no diff errors. - -- [ ] **Step 4: Commit** - -```bash -git add packages/providers/providers.yaml -git commit --no-verify -m "feat(providers): add Meilisearch (API_KEY) provider" -``` - ---- - -## Task 2: Scaffold the zero-yaml integration project - -**Files:** -- Create: `integrations/meilisearch/package.json` -- Create: `integrations/meilisearch/tsconfig.json` -- Create: `integrations/meilisearch/index.ts` - -- [ ] **Step 1: Create `integrations/meilisearch/package.json`** - -```json -{ - "name": "nango-integration-meilisearch", - "version": "1.0.0", - "private": true, - "type": "module", - "engines": { - "node": ">=22.22.2" - }, - "scripts": { - "compile": "nango compile", - "dev": "nango dev" - }, - "devDependencies": { - "zod": "4.3.6" - } -} -``` - -- [ ] **Step 2: Create `integrations/meilisearch/tsconfig.json`** (copied from `packages/cli/example/tsconfig.json`) - -```json -{ - "$schema": "https://json.schemastore.org/tsconfig", - "include": ["index.ts", "**/*.ts"], - "exclude": ["node_modules", "dist", "build", ".nango"], - "compilerOptions": { - "module": "node16", - "target": "esnext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "moduleResolution": "node16", - "exactOptionalPropertyTypes": true, - "noUncheckedIndexedAccess": true, - "noEmit": true - } -} -``` - -- [ ] **Step 3: Create `integrations/meilisearch/index.ts`** (will be completed as actions are added; start with the two model-free imports) - -```typescript -import './actions/generate-tenant-token.js'; -import './actions/search-documents.js'; -import './actions/get-documents.js'; -import './actions/add-documents.js'; -import './actions/update-documents.js'; -import './actions/delete-documents.js'; -import './actions/get-task.js'; -``` - -- [ ] **Step 4: Commit** - -```bash -git add integrations/meilisearch/package.json integrations/meilisearch/tsconfig.json integrations/meilisearch/index.ts -git commit --no-verify -m "chore(meilisearch): scaffold zero-yaml integration project" -``` - ---- - -## Task 3: Tenant-token signer (TDD — the core logic) - -**Files:** -- Create: `integrations/meilisearch/lib/tenant-token.ts` -- Test: `integrations/meilisearch/lib/tenant-token.unit.test.ts` - -- [ ] **Step 1: Write the failing test** - -`integrations/meilisearch/lib/tenant-token.unit.test.ts`: - -```typescript -import crypto from 'crypto'; - -import { describe, expect, it } from 'vitest'; - -import { generateTenantToken } from './tenant-token.js'; - -function decodeSegment(seg: string): unknown { - const b64 = seg.replace(/-/g, '+').replace(/_/g, '/'); - return JSON.parse(Buffer.from(b64, 'base64').toString('utf8')); -} - -describe('generateTenantToken', () => { - const apiKey = 'masterKeyExampleValue123'; - const apiKeyUid = '8dcbb482-cb02-4d4c-91a6-6b9c4f3e8d11'; - const searchRules = { medical_records: { filter: 'user_id = 1' } }; - - it('produces a three-segment JWT with HS256 header', () => { - const token = generateTenantToken({ apiKey, apiKeyUid, searchRules }); - const parts = token.split('.'); - expect(parts).toHaveLength(3); - expect(decodeSegment(parts[0]!)).toEqual({ alg: 'HS256', typ: 'JWT' }); - }); - - it('embeds searchRules and apiKeyUid in the payload', () => { - const token = generateTenantToken({ apiKey, apiKeyUid, searchRules }); - const payload = decodeSegment(token.split('.')[1]!) as Record; - expect(payload['apiKeyUid']).toBe(apiKeyUid); - expect(payload['searchRules']).toEqual(searchRules); - expect(payload['exp']).toBeUndefined(); - }); - - it('includes exp when expiresAt is provided', () => { - const token = generateTenantToken({ apiKey, apiKeyUid, searchRules, expiresAt: 1893456000 }); - const payload = decodeSegment(token.split('.')[1]!) as Record; - expect(payload['exp']).toBe(1893456000); - }); - - it('signs the token so the HMAC verifies with the api key', () => { - const token = generateTenantToken({ apiKey, apiKeyUid, searchRules }); - const [header, payload, signature] = token.split('.'); - const expected = crypto - .createHmac('sha256', apiKey) - .update(`${header}.${payload}`) - .digest('base64') - .replace(/=/g, '') - .replace(/\+/g, '-') - .replace(/\//g, '_'); - expect(signature).toBe(expected); - }); -}); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `npm run test:unit --dir integrations/meilisearch -- tenant-token` -Expected: FAIL — cannot resolve `./tenant-token.js` / `generateTenantToken is not a function`. - -- [ ] **Step 3: Write the minimal implementation** - -`integrations/meilisearch/lib/tenant-token.ts`: - -```typescript -import crypto from 'crypto'; - -export interface SearchRules { - [indexOrWildcard: string]: { filter?: string } | Record | boolean | unknown[]; -} - -export interface TenantTokenParams { - /** The Meilisearch API key value used to sign the token. */ - apiKey: string; - /** The uid of the API key used to sign the token. */ - apiKeyUid: string; - /** Per-index search rules (the ACL carried by the token). */ - searchRules: SearchRules; - /** Optional expiry as epoch seconds. */ - expiresAt?: number; -} - -function base64url(input: Buffer | string): string { - return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); -} - -/** - * Build a Meilisearch tenant token: an HS256 JWT signed with an API key value. - * Pure function — no network, no SDK — so it is fully unit-testable. - */ -export function generateTenantToken({ apiKey, apiKeyUid, searchRules, expiresAt }: TenantTokenParams): string { - const header = { alg: 'HS256', typ: 'JWT' }; - const payload: Record = { searchRules, apiKeyUid }; - if (expiresAt !== undefined) { - payload['exp'] = expiresAt; - } - - const encodedHeader = base64url(JSON.stringify(header)); - const encodedPayload = base64url(JSON.stringify(payload)); - const signature = base64url(crypto.createHmac('sha256', apiKey).update(`${encodedHeader}.${encodedPayload}`).digest()); - - return `${encodedHeader}.${encodedPayload}.${signature}`; -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `npm run test:unit --dir integrations/meilisearch -- tenant-token` -Expected: PASS (4 tests). - -- [ ] **Step 5: Commit** - -```bash -git add integrations/meilisearch/lib/tenant-token.ts integrations/meilisearch/lib/tenant-token.unit.test.ts -git commit --no-verify -m "feat(meilisearch): add tenant-token HS256 signer" -``` - ---- - -## Task 4: Shared zod schemas (TDD) - -**Files:** -- Create: `integrations/meilisearch/lib/schemas.ts` -- Test: `integrations/meilisearch/lib/schemas.unit.test.ts` - -- [ ] **Step 1: Write the failing test** - -`integrations/meilisearch/lib/schemas.unit.test.ts`: - -```typescript -import { describe, expect, it } from 'vitest'; - -import { enqueuedTaskSchema, searchRulesSchema } from './schemas.js'; - -describe('searchRulesSchema', () => { - it('accepts an index rule with a filter', () => { - const parsed = searchRulesSchema.parse({ records: { filter: 'tenant = 7' } }); - expect(parsed).toEqual({ records: { filter: 'tenant = 7' } }); - }); - - it('accepts the wildcard with an empty rule', () => { - expect(() => searchRulesSchema.parse({ '*': {} })).not.toThrow(); - }); - - it('accepts boolean and array rule values', () => { - expect(() => searchRulesSchema.parse({ a: true, b: ['title'] })).not.toThrow(); - }); -}); - -describe('enqueuedTaskSchema', () => { - it('parses an enqueued task', () => { - const parsed = enqueuedTaskSchema.parse({ - taskUid: 12, - indexUid: 'records', - status: 'enqueued', - type: 'documentAdditionOrUpdate', - enqueuedAt: '2026-06-28T00:00:00Z' - }); - expect(parsed.taskUid).toBe(12); - }); -}); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `npm run test:unit --dir integrations/meilisearch -- schemas` -Expected: FAIL — cannot resolve `./schemas.js`. - -- [ ] **Step 3: Write the minimal implementation** - -`integrations/meilisearch/lib/schemas.ts`: - -```typescript -import * as z from 'zod'; - -/** A single Meilisearch document — arbitrary JSON object. */ -export const meiliDocumentSchema = z.record(z.string(), z.unknown()); - -/** searchRules value for one index: object (optionally with filter), boolean, or array. */ -const searchRuleValueSchema = z.union([z.object({ filter: z.string().optional() }).catchall(z.unknown()), z.boolean(), z.array(z.unknown())]); - -/** Per-index search rules keyed by index uid or the "*" wildcard. */ -export const searchRulesSchema = z.record(z.string(), searchRuleValueSchema); - -/** The async task Meilisearch returns from a write operation. */ -export const enqueuedTaskSchema = z - .object({ - taskUid: z.number(), - indexUid: z.string().nullable(), - status: z.string(), - type: z.string(), - enqueuedAt: z.string() - }) - .catchall(z.unknown()); -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `npm run test:unit --dir integrations/meilisearch -- schemas` -Expected: PASS (4 tests). - -- [ ] **Step 5: Commit** - -```bash -git add integrations/meilisearch/lib/schemas.ts integrations/meilisearch/lib/schemas.unit.test.ts -git commit --no-verify -m "feat(meilisearch): add shared zod schemas" -``` - ---- - -## Task 5: `generate-tenant-token` action - -**Files:** -- Create: `integrations/meilisearch/actions/generate-tenant-token.ts` - -- [ ] **Step 1: Write the action** - -```typescript -import { createAction } from 'nango'; -import * as z from 'zod'; - -import { searchRulesSchema } from '../lib/schemas.js'; -import { generateTenantToken } from '../lib/tenant-token.js'; - -const input = z - .object({ - searchRules: searchRulesSchema, - expiresAt: z.number().optional(), - expiresInSeconds: z.number().optional(), - apiKeyUid: z.string().optional() - }) - .refine((v) => Object.keys(v.searchRules).length > 0, { message: 'searchRules must define at least one index rule' }); - -const output = z.object({ - token: z.string(), - expiresAt: z.number().nullable() -}); - -const action = createAction({ - description: 'Generate a Meilisearch tenant token: a scoped, signed search JWT carrying per-index ACL rules.', - version: '1.0.0', - endpoint: { method: 'POST', path: '/meilisearch/tenant-token', group: 'Tenant Tokens' }, - input, - output, - - exec: async (nango, input) => { - const credentials = await nango.getToken(); - if (typeof credentials === 'string' || !('apiKey' in credentials)) { - throw new nango.ActionError({ message: 'Meilisearch connection must use API_KEY auth to mint tenant tokens.' }); - } - const apiKey = credentials.apiKey; - - let apiKeyUid = input.apiKeyUid; - if (!apiKeyUid) { - const res = await nango.get<{ uid: string }>({ endpoint: `/keys/${apiKey}` }); - apiKeyUid = res.data.uid; - } - - let expiresAt: number | null = null; - if (input.expiresAt !== undefined) { - expiresAt = input.expiresAt; - } else if (input.expiresInSeconds !== undefined) { - expiresAt = Math.floor(Date.now() / 1000) + input.expiresInSeconds; - } - - const token = generateTenantToken({ - apiKey, - apiKeyUid, - searchRules: input.searchRules, - ...(expiresAt !== null ? { expiresAt } : {}) - }); - - return { token, expiresAt }; - } -}); - -export default action; -``` - -- [ ] **Step 2: Commit** - -```bash -git add integrations/meilisearch/actions/generate-tenant-token.ts -git commit --no-verify -m "feat(meilisearch): add generate-tenant-token action" -``` - ---- - -## Task 6: `search-documents` action (read) - -**Files:** -- Create: `integrations/meilisearch/actions/search-documents.ts` - -- [ ] **Step 1: Write the action** - -```typescript -import { createAction } from 'nango'; -import * as z from 'zod'; - -import { meiliDocumentSchema } from '../lib/schemas.js'; - -const input = z.object({ - indexUid: z.string(), - q: z.string().optional(), - filter: z.union([z.string(), z.array(z.string())]).optional(), - sort: z.array(z.string()).optional(), - limit: z.number().optional(), - offset: z.number().optional(), - attributesToRetrieve: z.array(z.string()).optional(), - facets: z.array(z.string()).optional() -}); - -const output = z - .object({ - hits: z.array(meiliDocumentSchema), - query: z.string().optional(), - processingTimeMs: z.number().optional(), - limit: z.number().optional(), - offset: z.number().optional(), - estimatedTotalHits: z.number().optional() - }) - .catchall(z.unknown()); - -const action = createAction({ - description: 'Search documents in a Meilisearch index.', - version: '1.0.0', - endpoint: { method: 'POST', path: '/meilisearch/documents/search', group: 'Documents' }, - input, - output, - - exec: async (nango, input) => { - const { indexUid, ...body } = input; - const res = await nango.post({ endpoint: `/indexes/${indexUid}/search`, data: body }); - return res.data; - } -}); - -export default action; -``` - -- [ ] **Step 2: Commit** - -```bash -git add integrations/meilisearch/actions/search-documents.ts -git commit --no-verify -m "feat(meilisearch): add search-documents action" -``` - ---- - -## Task 7: `get-documents` action (read) - -**Files:** -- Create: `integrations/meilisearch/actions/get-documents.ts` - -Uses `POST /indexes/{uid}/documents/fetch` (supports `filter`, unlike the GET variant). - -- [ ] **Step 1: Write the action** - -```typescript -import { createAction } from 'nango'; -import * as z from 'zod'; - -import { meiliDocumentSchema } from '../lib/schemas.js'; - -const input = z.object({ - indexUid: z.string(), - filter: z.union([z.string(), z.array(z.string())]).optional(), - fields: z.array(z.string()).optional(), - limit: z.number().optional(), - offset: z.number().optional() -}); - -const output = z - .object({ - results: z.array(meiliDocumentSchema), - total: z.number(), - limit: z.number(), - offset: z.number() - }) - .catchall(z.unknown()); - -const action = createAction({ - description: 'Fetch documents from a Meilisearch index, optionally filtered.', - version: '1.0.0', - endpoint: { method: 'POST', path: '/meilisearch/documents/fetch', group: 'Documents' }, - input, - output, - - exec: async (nango, input) => { - const { indexUid, ...body } = input; - const res = await nango.post({ endpoint: `/indexes/${indexUid}/documents/fetch`, data: body }); - return res.data; - } -}); - -export default action; -``` - -- [ ] **Step 2: Commit** - -```bash -git add integrations/meilisearch/actions/get-documents.ts -git commit --no-verify -m "feat(meilisearch): add get-documents action" -``` - ---- - -## Task 8: `add-documents` action (write — add or replace, batch) - -**Files:** -- Create: `integrations/meilisearch/actions/add-documents.ts` - -`POST /indexes/{uid}/documents` adds or **replaces** documents. The body is the document array; `primaryKey` is a query param. - -- [ ] **Step 1: Write the action** - -```typescript -import { createAction } from 'nango'; -import * as z from 'zod'; - -import { enqueuedTaskSchema, meiliDocumentSchema } from '../lib/schemas.js'; - -const input = z.object({ - indexUid: z.string(), - documents: z.array(meiliDocumentSchema).min(1), - primaryKey: z.string().optional() -}); - -const action = createAction({ - description: 'Add or replace documents in a Meilisearch index (batch). Returns the enqueued task.', - version: '1.0.0', - endpoint: { method: 'POST', path: '/meilisearch/documents', group: 'Documents' }, - input, - output: enqueuedTaskSchema, - - exec: async (nango, input) => { - const res = await nango.post({ - endpoint: `/indexes/${input.indexUid}/documents`, - data: input.documents, - ...(input.primaryKey ? { params: { primaryKey: input.primaryKey } } : {}) - }); - return res.data; - } -}); - -export default action; -``` - -- [ ] **Step 2: Commit** - -```bash -git add integrations/meilisearch/actions/add-documents.ts -git commit --no-verify -m "feat(meilisearch): add add-documents action" -``` - ---- - -## Task 9: `update-documents` action (partial update, batch) - -**Files:** -- Create: `integrations/meilisearch/actions/update-documents.ts` - -`PUT /indexes/{uid}/documents` adds or **updates** (partial — only the provided fields change). - -- [ ] **Step 1: Write the action** - -```typescript -import { createAction } from 'nango'; -import * as z from 'zod'; - -import { enqueuedTaskSchema, meiliDocumentSchema } from '../lib/schemas.js'; - -const input = z.object({ - indexUid: z.string(), - documents: z.array(meiliDocumentSchema).min(1), - primaryKey: z.string().optional() -}); - -const action = createAction({ - description: 'Add or partially update documents in a Meilisearch index (batch). Returns the enqueued task.', - version: '1.0.0', - endpoint: { method: 'PUT', path: '/meilisearch/documents', group: 'Documents' }, - input, - output: enqueuedTaskSchema, - - exec: async (nango, input) => { - const res = await nango.put({ - endpoint: `/indexes/${input.indexUid}/documents`, - data: input.documents, - ...(input.primaryKey ? { params: { primaryKey: input.primaryKey } } : {}) - }); - return res.data; - } -}); - -export default action; -``` - -- [ ] **Step 2: Commit** - -```bash -git add integrations/meilisearch/actions/update-documents.ts -git commit --no-verify -m "feat(meilisearch): add update-documents action" -``` - ---- - -## Task 10: `delete-documents` action (write) - -**Files:** -- Create: `integrations/meilisearch/actions/delete-documents.ts` - -Delete by `ids` (`POST /documents/delete-batch`, body = id array) or by `filter` (`POST /documents/delete`, body = `{ filter }`). Exactly one must be provided. - -- [ ] **Step 1: Write the action** - -```typescript -import { createAction } from 'nango'; -import * as z from 'zod'; - -import { enqueuedTaskSchema } from '../lib/schemas.js'; - -const input = z - .object({ - indexUid: z.string(), - ids: z.array(z.union([z.string(), z.number()])).optional(), - filter: z.union([z.string(), z.array(z.string())]).optional() - }) - .refine((v) => (v.ids === undefined) !== (v.filter === undefined), { - message: 'Provide exactly one of "ids" or "filter".' - }); - -const action = createAction({ - description: 'Delete documents from a Meilisearch index by ids or by filter. Returns the enqueued task.', - version: '1.0.0', - endpoint: { method: 'POST', path: '/meilisearch/documents/delete', group: 'Documents' }, - input, - output: enqueuedTaskSchema, - - exec: async (nango, input) => { - const res = input.ids - ? await nango.post({ endpoint: `/indexes/${input.indexUid}/documents/delete-batch`, data: input.ids }) - : await nango.post({ endpoint: `/indexes/${input.indexUid}/documents/delete`, data: { filter: input.filter } }); - return res.data; - } -}); - -export default action; -``` - -- [ ] **Step 2: Commit** - -```bash -git add integrations/meilisearch/actions/delete-documents.ts -git commit --no-verify -m "feat(meilisearch): add delete-documents action" -``` - ---- - -## Task 11: `get-task` action (poll async writes) - -**Files:** -- Create: `integrations/meilisearch/actions/get-task.ts` - -- [ ] **Step 1: Write the action** - -```typescript -import { createAction } from 'nango'; -import * as z from 'zod'; - -const input = z.object({ - taskUid: z.number() -}); - -const output = z - .object({ - uid: z.number(), - indexUid: z.string().nullable(), - status: z.string(), - type: z.string(), - error: z.unknown().nullable().optional(), - enqueuedAt: z.string(), - startedAt: z.string().nullable().optional(), - finishedAt: z.string().nullable().optional() - }) - .catchall(z.unknown()); - -const action = createAction({ - description: 'Fetch the status of a Meilisearch async task by its uid.', - version: '1.0.0', - endpoint: { method: 'GET', path: '/meilisearch/tasks', group: 'Tasks' }, - input, - output, - - exec: async (nango, input) => { - const res = await nango.get({ endpoint: `/tasks/${input.taskUid}` }); - return res.data; - } -}); - -export default action; -``` - -- [ ] **Step 2: Run the full unit test suite for the project** - -Run: `npm run test:unit --dir integrations/meilisearch` -Expected: PASS (signer + schema tests; 8 tests). - -- [ ] **Step 3: Commit** - -```bash -git add integrations/meilisearch/actions/get-task.ts -git commit --no-verify -m "feat(meilisearch): add get-task action" -``` - ---- - -## Task 12: Documentation page, logo, and registration - -**Files:** -- Create: `docs/integrations/all/meilisearch.mdx` -- Modify: `docs/docs.json` -- Create: `packages/webapp/public/images/template-logos/meilisearch.svg` - -> Note: the auto-generated `PreBuiltTooling`/`PreBuiltUseCases` snippets are derived from `flows.yaml`. Because these actions live in a standalone `integrations/` project (not `flows.yaml`), those snippets are NOT generated — so this doc page does **not** import them and instead lists the actions inline. - -- [ ] **Step 1: Create `docs/integrations/all/meilisearch.mdx`** - -```mdx ---- -title: Meilisearch -sidebarTitle: Meilisearch ---- - -import Overview from "/snippets/overview.mdx" - - - -## Access requirements -| Pre-Requisites | Status | Comment| -| - | - | - | -| Paid dev account | ❌ | Meilisearch is open source; Cloud has a free tier. | -| Paid test account | ❌ | | -| Partnership | ❌ | | -| App review | ❌ | | -| Security audit | ❌ | | - -## Connecting to Meilisearch - -Meilisearch uses API-key authentication. When creating a connection provide: - -- **Instance URL** — your Meilisearch host including scheme, e.g. `https://ms-xxxx.meilisearch.io` or `http://localhost:7700`. -- **API Key** — a Meilisearch API key. Use a *key* (not the master key) so it can sign tenant tokens. - -## Pre-built actions - -| Action | Description | -| - | - | -| `search-documents` | Search documents in an index. | -| `get-documents` | Fetch documents from an index, optionally filtered. | -| `add-documents` | Add or replace documents (batch). | -| `update-documents` | Add or partially update documents (batch). | -| `delete-documents` | Delete documents by ids or filter. | -| `get-task` | Poll the status of an async write task. | -| `generate-tenant-token` | Mint a scoped, signed tenant-token JWT carrying per-index ACL search rules. | - -## Tenant tokens & ACL - -`generate-tenant-token` signs an HS256 JWT with the connection's API key. The token carries `searchRules` (per-index filters) that scope what an end user can search; the signing key's ACL bounds the token. Example input: - -```json -{ - "searchRules": { "medical_records": { "filter": "patient_id = 42" } }, - "expiresInSeconds": 3600 -} -``` - -## Useful links - -- [Meilisearch API keys](https://www.meilisearch.com/docs/reference/api/keys) -- [Tenant tokens](https://www.meilisearch.com/docs/learn/security/basic_security) -- [Documents API](https://www.meilisearch.com/docs/reference/api/documents) - -Contribute improvements by [editing this page](https://github.com/nangohq/nango/tree/master/docs/integrations/all/meilisearch.mdx) -``` - -- [ ] **Step 2: Register the page in `docs/docs.json`** - -Find the `"integrations/all/..."` list (the entry `"integrations/all/algolia"` is around line 936) and add, in alphabetical position (after `integrations/all/meili...` neighbors, i.e. between the `me*` entries): - -```json - "integrations/all/meilisearch", -``` - -- [ ] **Step 3: Create the logo `packages/webapp/public/images/template-logos/meilisearch.svg`** - -Use the official Meilisearch mark. Minimal placeholder that renders (replace with the official asset if available): - -```svg - - - - - - - - - -``` - -- [ ] **Step 4: Verify docs.json is valid JSON** - -Run: `node -e "JSON.parse(require('fs').readFileSync('docs/docs.json','utf8')); console.log('ok')"` -Expected: `ok`. - -- [ ] **Step 5: Commit** - -```bash -git add docs/integrations/all/meilisearch.mdx docs/docs.json packages/webapp/public/images/template-logos/meilisearch.svg -git commit --no-verify -m "docs(meilisearch): add integration docs, logo, and nav entry" -``` - ---- - -## Task 13: Final verification - -- [ ] **Step 1: Run all new unit tests** - -Run: `npm run test:unit --dir integrations/meilisearch` -Expected: PASS (8 tests across tenant-token + schemas). - -- [ ] **Step 2: Lint the new code** - -Run: `npm run lint` -Expected: no errors referencing `integrations/meilisearch`. - -- [ ] **Step 3: Format check** - -Run: `npx prettier --config .prettierrc --check "integrations/meilisearch/**/*.ts" "docs/integrations/all/meilisearch.mdx"` -Expected: all files use Prettier code style (run `--write` to fix if not). - -- [ ] **Step 4: Providers tests still pass** - -Run: `npm run test:unit --dir packages/providers` -Expected: PASS. - -- [ ] **Step 5: (Best effort) compile the integration with the Nango CLI** - -Run (from the project dir, requires the `nango` CLI/runtime resolvable): `cd integrations/meilisearch && npx nango compile` -Expected: compiles all 7 actions with no type errors. If `nango` is not installable in this environment, skip and note it — the unit tests + lint + tsc remain the gate. - -- [ ] **Step 6: Manual integration smoke test (documented, run if a Meilisearch instance is available)** - -```bash -# 1. Run Meilisearch locally -docker run -d --rm -p 7700:7700 -e MEILI_MASTER_KEY=masterKey getmeili/meilisearch:latest -# 2. Through a Nango connection (instanceUrl=http://localhost:7700, apiKey=): -# add-documents -> returns { taskUid } -# get-task -> status "succeeded" -# search-documents (q="...") -> hits present -# generate-tenant-token (searchRules with a filter) -> { token } -# search using the token -> hits constrained by the filter (ACL enforced) -``` - -- [ ] **Step 7: Final commit (if any fixes were applied)** - -```bash -git add -A -git commit --no-verify -m "chore(meilisearch): final lint/format/test fixes" -``` - ---- - -## Self-Review Notes (spec coverage) - -- **Provider / API_KEY auth + instanceUrl** → Task 1. -- **read** → `search-documents` (Task 6), `get-documents` (Task 7). -- **write** → `add-documents` (Task 8), `delete-documents` (Task 10). -- **partial update / batch** → `update-documents` (Task 9); batch is the array input on Tasks 8–9. -- **ACL / tenant tokens** → pure signer (Task 3) + `generate-tenant-token` (Task 5). -- **async writes** → `get-task` (Task 11). -- **docs/logo** → Task 12. -- **testing strategy** → unit tests (Tasks 3–4), schema round-trips, manual loop (Task 13). -- Syncs remain **out of scope** per the spec. -``` From 01280c55c663e6b203ec343b2c50d99930d1cb44 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Wed, 8 Jul 2026 15:39:22 +0200 Subject: [PATCH 13/18] docs: remove meilisearch design spec --- ...26-06-28-meilisearch-integration-design.md | 193 ------------------ 1 file changed, 193 deletions(-) delete mode 100644 docs/superpowers/specs/2026-06-28-meilisearch-integration-design.md diff --git a/docs/superpowers/specs/2026-06-28-meilisearch-integration-design.md b/docs/superpowers/specs/2026-06-28-meilisearch-integration-design.md deleted file mode 100644 index 5dcff8b826..0000000000 --- a/docs/superpowers/specs/2026-06-28-meilisearch-integration-design.md +++ /dev/null @@ -1,193 +0,0 @@ -# Meilisearch integration for Nango — Design - -**Date:** 2026-06-28 -**Status:** Approved (design phase) - -## Goal - -Add Meilisearch as a first-class Nango integration: a provider definition for -API-key authentication plus pre-built actions covering read, write, partial -(batch) update, document deletion, and — the central requirement — **tenant -token generation** so that Meilisearch's per-tenant ACL/multitenancy can be -driven through Nango. - -## Background — how the pieces map to Nango - -- **Auth.** A Meilisearch deployment (Cloud or self-hosted) is reached via an - **instance URL** + an **API key**. The key is sent as `Authorization: Bearer `. - This maps to Nango's `API_KEY` auth mode with a user-supplied `base_url`. -- **Tenant tokens.** A tenant token is **not** a stored credential. It is a - short-lived JWT (HS256) that the holder *generates* by signing with an - existing API key. Its payload carries `searchRules` (per-index filters) that - scope what an end user may search, plus the signing key's `apiKeyUid` and an - `exp`. The signing key's ACL is the upper bound on the token's power; tenant - tokens are search-only. Master keys **cannot** sign tenant tokens — only API - keys can. -- **Async writes.** Meilisearch document writes are asynchronous: they return an - `EnqueuedTask` (`{ taskUid, indexUid, status, type, enqueuedAt }`), not the - written data. Callers poll `GET /tasks/{taskUid}` to observe completion. - -## Verified codebase facts (de-risking) - -- New integrations use the **zero-yaml** convention: `createAction` / - `createSync` with **zod** schemas for `input`/`output`. Provider auth lives in - `packages/providers/providers.yaml`. (`packages/cli/example/github/actions/createIssue.ts` - is the reference shape.) -- Action scripts **can read raw credentials**: `await nango.getConnection()` - returns `credentials` typed as `AllAuthCredentials`; for `API_KEY` that - includes `credentials.apiKey` (the real secret). Confirmed in - `packages/runner-sdk/lib/action.ts` and `packages/types/lib/auth/api.ts`. - This makes local JWT signing possible. -- The runner sandbox require-allowlist (`packages/runner/lib/exec.ts`) is: - `url`, `crypto`, `zod`, `botbuilder`, `soap`, `unzipper`. **`jsonwebtoken` and - `jose` are NOT allowed.** Therefore the HS256 JWT must be hand-rolled using - the `crypto` module (`createHmac` + base64url encoding). -- Shipped integration action scripts canonically live in the external - `github.com/NangoHQ/integration-templates` repo and are compiled into the - generated `packages/shared/flows.zero.json`. That JSON is **generated and will - not be hand-edited**. In this repo we author a self-contained zero-yaml - project under `integrations/meilisearch/`. - -## Components - -### 1. Provider definition — `packages/providers/providers.yaml` - -```yaml -meilisearch: - display_name: Meilisearch - categories: [search] - auth_mode: API_KEY - proxy: - base_url: ${connectionConfig.instanceUrl} - headers: - authorization: Bearer ${apiKey} - verification: - method: GET - endpoints: [/keys] - connection_config: - instanceUrl: - type: string - title: Instance URL - description: Your Meilisearch host, e.g. https://ms-xxxx.meilisearch.io or http://localhost:7700 - order: 1 - credentials: - apiKey: - type: string - title: API Key - description: A Meilisearch API key (must be a key, not the master key, to mint tenant tokens) - secret: true - docs: https://nango.dev/docs/integrations/all/meilisearch - docs_connect: https://nango.dev/docs/integrations/all/meilisearch/connect -``` - -Notes: -- `instanceUrl` includes the scheme so both Cloud (`https://…`) and self-hosted - (`http://localhost:7700`) work. -- Verification uses `GET /keys`, which requires a valid key and the `keys.get` - action — a meaningful check that the credential works. - -### 2. In-repo integration project — `integrations/meilisearch/` - -``` -integrations/meilisearch/ - index.ts # registers/exports the actions (zero-yaml entry) - lib/ - client.ts # thin helpers over nango.proxy (paths, error mapping) - tenant-token.ts # pure HS256 signer + base64url (unit-tested, no I/O) - schemas.ts # shared zod schemas (SearchRules, EnqueuedTask, etc.) - actions/ - search-documents.ts # read - get-documents.ts # read - add-documents.ts # write (add or replace; batch via array) - update-documents.ts # partial update (add or update; batch via array) - delete-documents.ts # write (by ids or by filter) - generate-tenant-token.ts # ACL / tenant token (local JWT) - get-task.ts # poll async EnqueuedTask -``` - -### 3. Actions - -All actions take `indexUid` where relevant and call Meilisearch through -`nango.proxy` (base URL + auth injected by the provider proxy config). Inputs -and outputs are zod schemas. - -| Action | Requirement | HTTP | Input (key fields) | Output | -|---|---|---|---|---| -| `search-documents` | read | `POST /indexes/{uid}/search` | `indexUid`, `q?`, `filter?`, `sort?`, `limit?`, `offset?`, `attributesToRetrieve?`, `facets?` | search response (`hits`, `estimatedTotalHits`, `processingTimeMs`, …) | -| `get-documents` | read | `GET /indexes/{uid}/documents` | `indexUid`, `ids?`, `filter?`, `fields?`, `limit?`, `offset?` | `{ results, total, limit, offset }` | -| `add-documents` | write (batch) | `POST /indexes/{uid}/documents` | `indexUid`, `documents[]`, `primaryKey?` | `EnqueuedTask` | -| `update-documents` | partial update (batch) | `PUT /indexes/{uid}/documents` | `indexUid`, `documents[]`, `primaryKey?` | `EnqueuedTask` | -| `delete-documents` | write | `POST /indexes/{uid}/documents/delete` (filter) or `…/delete-batch` (ids) | `indexUid`, one of `ids[]` \| `filter` | `EnqueuedTask` | -| `generate-tenant-token` | ACL / tenant tokens | local (no HTTP) | `searchRules`, `expiresAt?` \| `expiresInSeconds?`, `apiKeyUid?` | `{ token, expiresAt }` | -| `get-task` | poll | `GET /tasks/{taskUid}` | `taskUid` | task object (`status`, `error?`, …) | - -"Batch update" is satisfied by the array-accepting `add-documents` / -`update-documents` (Meilisearch's native batching), not a separate action. -`delete-documents` validates that exactly one of `ids` / `filter` is provided. - -### 4. `generate-tenant-token` — detailed flow - -1. `const { credentials } = await nango.getConnection();` → - `const apiKey = credentials.apiKey` (guard: must be `API_KEY` auth). -2. Resolve `apiKeyUid`: - - if provided in input, use it; - - else `GET /keys/{apiKey}` via `nango.proxy` and read `.uid`. -3. Compute `exp` from `expiresAt` (epoch seconds) or `expiresInSeconds` - (default: a sensible short TTL, e.g. 1h; documented). `exp` is optional in - Meilisearch but we encourage it. -4. Build the JWT in `lib/tenant-token.ts`: - - `header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }))` - - `payload = base64url(JSON.stringify({ searchRules, apiKeyUid, exp? }))` - - `signature = base64url(crypto.createHmac("sha256", apiKey).update(`${header}.${payload}`).digest())` - - `token = `${header}.${payload}.${signature}`` -5. Return `{ token, expiresAt }`. - -`searchRules` zod type: a record keyed by `indexUid` or `"*"`, each value one of -`{ filter?: string }`, `true`, or `[]` (per Meilisearch's tenant-token spec). - -### 5. Error handling - -- `lib/client.ts` maps non-2xx Meilisearch responses (which carry - `{ message, code, type, link }`) into clear thrown errors with the Meilisearch - `code` surfaced, so action callers get actionable messages. -- `generate-tenant-token` throws a descriptive error if the connection is not - API_KEY auth, if `apiKeyUid` cannot be resolved, or if `searchRules` is empty. -- `delete-documents` throws if neither/both of `ids`/`filter` are supplied. - -### 6. Documentation & assets - -- `docs/integrations/all/meilisearch.mdx` — overview + pre-built tooling/use-cases - snippets, following the existing provider doc template (e.g. algolia). -- Setup guide page + registration in `docs.json`. -- `meilisearch.svg` logo under the template-logos location used by the webapp. - -## Testing strategy - -- **Unit (vitest), `lib/tenant-token.ts`:** the highest-value tests. Sign with a - known key + rules and assert (a) the token has three base64url segments, - (b) header/payload decode to the expected JSON, (c) the HMAC verifies against - the key (recompute and compare), (d) `exp` is set correctly from both - `expiresAt` and `expiresInSeconds`. Use a fixed reference vector. -- **Schema round-trips:** zod input/output parse for each action with - representative payloads (including batch arrays and each `searchRules` shape). -- **Manual / integration:** run Meilisearch in Docker; exercise the full loop — - `add-documents` → `get-task` (succeeded) → `search-documents` → - `generate-tenant-token` (with a `filter`) → search using the token and confirm - the ACL filter actually constrains the returned hits. - -## Out of scope (YAGNI) - -- Index / settings management actions (create index, update settings) — not part - of the read/write/partial/tenant-token requirement. -- API-key CRUD actions (create/list/delete scoped keys). Considered during - brainstorming and explicitly deferred; tenant tokens are derived from an - existing key. -- Syncs (scheduled pulls). This integration is action-oriented. - -## Open questions - -None outstanding. Decisions captured above: -- Full integration (provider + actions). -- Tenant tokens via a `generate-tenant-token` action (not as a stored credential). -- Action set includes `delete-documents`, `get-documents`, and `get-task`. -- Scripts live in a new `integrations/meilisearch/` project. From 49eaede15f97dd1e7483dca7e5247a561456e247 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Wed, 8 Jul 2026 16:33:51 +0200 Subject: [PATCH 14/18] fix(meilisearch): address PR review findings - Make searchRule objects strict and re-validate searchRules inside exec: Meilisearch ignores unknown rule keys, so a typo'd key would silently broaden a signed tenant token's access - Resolve the signing key uid by listing keys and matching locally instead of GET /keys/{key}, which put the raw key in the URL path where proxy and access logs record it unredacted - Enforce non-empty document batches at runtime in add/update-documents (action input schemas are not validated at runtime) --- .../meilisearch/actions/add-documents.ts | 4 ++ .../meilisearch/actions/delete-documents.ts | 3 +- .../actions/generate-tenant-token.ts | 38 ++++++++++++++++--- .../meilisearch/actions/update-documents.ts | 4 ++ .../meilisearch/meilisearch/lib/schemas.ts | 8 +++- .../meilisearch/lib/schemas.unit.test.ts | 4 ++ 6 files changed, 51 insertions(+), 10 deletions(-) diff --git a/integrations/meilisearch/meilisearch/actions/add-documents.ts b/integrations/meilisearch/meilisearch/actions/add-documents.ts index 47a6f335d7..4eea4ad90b 100644 --- a/integrations/meilisearch/meilisearch/actions/add-documents.ts +++ b/integrations/meilisearch/meilisearch/actions/add-documents.ts @@ -17,6 +17,10 @@ const action = createAction({ output: enqueuedTaskSchema, exec: async (nango, input) => { + // Enforced here because action input is not validated at runtime. + if (input.documents.length === 0) { + throw new nango.ActionError({ message: '"documents" must contain at least one document.' }); + } const res = await nango.post({ endpoint: `/indexes/${encodeURIComponent(input.indexUid)}/documents`, data: input.documents, diff --git a/integrations/meilisearch/meilisearch/actions/delete-documents.ts b/integrations/meilisearch/meilisearch/actions/delete-documents.ts index dcf403a83b..3cf1b5001b 100644 --- a/integrations/meilisearch/meilisearch/actions/delete-documents.ts +++ b/integrations/meilisearch/meilisearch/actions/delete-documents.ts @@ -20,8 +20,7 @@ const action = createAction({ output: enqueuedTaskSchema, exec: async (nango, input) => { - // Enforced here because declarative zod refinements are stripped from the - // deployed JSON schema and never run against action input at runtime. + // Enforced here because action input is not validated at runtime. const hasIds = input.ids !== undefined; const hasFilter = input.filter !== undefined; if (hasIds === hasFilter) { diff --git a/integrations/meilisearch/meilisearch/actions/generate-tenant-token.ts b/integrations/meilisearch/meilisearch/actions/generate-tenant-token.ts index 4d20d321d5..7b15a4fc5a 100644 --- a/integrations/meilisearch/meilisearch/actions/generate-tenant-token.ts +++ b/integrations/meilisearch/meilisearch/actions/generate-tenant-token.ts @@ -5,6 +5,8 @@ import { searchRulesSchema } from '../lib/schemas.js'; import { generateTenantToken } from '../lib/tenant-token.js'; const DEFAULT_TTL_SECONDS = 3600; +const KEYS_PAGE_SIZE = 100; +const MAX_KEY_PAGES = 20; const input = z.object({ searchRules: searchRulesSchema, @@ -13,7 +15,7 @@ const input = z.object({ apiKeyUid: z .string() .optional() - .describe('The uid of the signing API key. When omitted, it is resolved via GET /keys/{apiKey}, which requires the keys.get action.') + .describe('The uid of the signing API key. When omitted, it is resolved by listing keys, which requires the keys.get action.') }); const output = z.object({ @@ -30,7 +32,15 @@ const action = createAction({ output, exec: async (nango, input) => { - if (Object.keys(input.searchRules).length === 0) { + // Parsed here because action input is not validated at runtime; a malformed + // rule (e.g. a typo'd "filters" key) would otherwise be signed into the token + // and silently ignored by Meilisearch, broadening access. + const parsedRules = searchRulesSchema.safeParse(input.searchRules); + if (!parsedRules.success) { + throw new nango.ActionError({ message: `Invalid searchRules: ${parsedRules.error.issues.map((i) => i.message).join('; ')}` }); + } + const searchRules = parsedRules.data; + if (Object.keys(searchRules).length === 0) { throw new nango.ActionError({ message: 'searchRules must define at least one index rule.' }); } @@ -42,13 +52,29 @@ const action = createAction({ let apiKeyUid = input.apiKeyUid; if (!apiKeyUid) { + // Resolve the key's uid by listing keys and matching locally: GET /keys/{key} + // would put the raw key in the URL path, which proxy/access logs record unredacted. try { - const res = await nango.get<{ uid: string }>({ endpoint: `/keys/${encodeURIComponent(apiKey)}` }); - apiKeyUid = res.data.uid; + for (let page = 0; page < MAX_KEY_PAGES && !apiKeyUid; page++) { + const res = await nango.get<{ results: { key: string; uid: string }[]; total: number }>({ + endpoint: '/keys', + params: { limit: String(KEYS_PAGE_SIZE), offset: String(page * KEYS_PAGE_SIZE) } + }); + apiKeyUid = res.data.results.find((k) => k.key === apiKey)?.uid; + if ((page + 1) * KEYS_PAGE_SIZE >= res.data.total) { + break; + } + } } catch { throw new nango.ActionError({ message: - 'Could not resolve the API key uid via GET /keys/{apiKey}. This requires a key with the keys.get action (a 404 means the connection uses the master key, which cannot sign tenant tokens). Either connect with a key that has keys.get, or pass apiKeyUid explicitly.' + 'Could not list keys to resolve the API key uid. This requires a key with the keys.get action. Either connect with a key that has keys.get, or pass apiKeyUid explicitly.' + }); + } + if (!apiKeyUid) { + throw new nango.ActionError({ + message: + 'The connection API key was not found among the instance keys (the master key cannot sign tenant tokens). Connect with a regular API key, or pass apiKeyUid explicitly.' }); } } @@ -59,7 +85,7 @@ const action = createAction({ const token = generateTenantToken({ apiKey, apiKeyUid, - searchRules: input.searchRules, + searchRules, expiresAt }); diff --git a/integrations/meilisearch/meilisearch/actions/update-documents.ts b/integrations/meilisearch/meilisearch/actions/update-documents.ts index 1159643122..f5bccb06d5 100644 --- a/integrations/meilisearch/meilisearch/actions/update-documents.ts +++ b/integrations/meilisearch/meilisearch/actions/update-documents.ts @@ -17,6 +17,10 @@ const action = createAction({ output: enqueuedTaskSchema, exec: async (nango, input) => { + // Enforced here because action input is not validated at runtime. + if (input.documents.length === 0) { + throw new nango.ActionError({ message: '"documents" must contain at least one document.' }); + } const res = await nango.put({ endpoint: `/indexes/${encodeURIComponent(input.indexUid)}/documents`, data: input.documents, diff --git a/integrations/meilisearch/meilisearch/lib/schemas.ts b/integrations/meilisearch/meilisearch/lib/schemas.ts index 97d73d9939..e82a76c09a 100644 --- a/integrations/meilisearch/meilisearch/lib/schemas.ts +++ b/integrations/meilisearch/meilisearch/lib/schemas.ts @@ -10,8 +10,12 @@ export const meiliDocumentSchema = z.record(z.string(), z.unknown()); */ export const filterSchema = z.union([z.string(), z.array(z.union([z.string(), z.array(z.string())]))]); -/** searchRules value for one index: an object (optionally with a filter) or null (no restriction). */ -const searchRuleValueSchema = z.union([z.object({ filter: filterSchema.optional() }).catchall(z.unknown()), z.null()]); +/** + * searchRules value for one index: an object (optionally with a filter) or null (no restriction). + * Strict: Meilisearch ignores unknown rule keys, so a typo like "filters" would silently + * remove the restriction from a signed token. Fail closed instead. + */ +const searchRuleValueSchema = z.union([z.strictObject({ filter: filterSchema.optional() }), z.null()]); /** Per-index search rules keyed by index uid or the "*" wildcard. */ export const searchRulesSchema = z.record(z.string(), searchRuleValueSchema); diff --git a/integrations/meilisearch/meilisearch/lib/schemas.unit.test.ts b/integrations/meilisearch/meilisearch/lib/schemas.unit.test.ts index e13a5e99f4..eab79198cc 100644 --- a/integrations/meilisearch/meilisearch/lib/schemas.unit.test.ts +++ b/integrations/meilisearch/meilisearch/lib/schemas.unit.test.ts @@ -21,6 +21,10 @@ describe('searchRulesSchema', () => { expect(() => searchRulesSchema.parse({ a: ['title'] })).toThrow(); expect(() => searchRulesSchema.parse({ a: 123 })).toThrow(); }); + + it('rejects unknown rule keys (fail closed: Meilisearch would silently ignore them)', () => { + expect(() => searchRulesSchema.parse({ records: { filters: 'tenant = 7' } })).toThrow(); + }); }); describe('filterSchema', () => { From 42f23e5c53b3c84bd2beba214a9bc37a26b6774b Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Wed, 8 Jul 2026 18:02:57 +0200 Subject: [PATCH 15/18] fix(meilisearch): validate action input at runtime via zodValidateInput Declared input schemas are not enforced by the platform at runtime, so a missing or non-array documents field would throw a raw TypeError instead of a clean ActionError. All actions now validate their declared schema inside exec with nango.zodValidateInput; search/fetch keep forwarding the raw body so extra Meilisearch params pass through. --- .../meilisearch/actions/add-documents.ts | 12 +++++----- .../meilisearch/actions/delete-documents.ts | 12 +++++----- .../actions/generate-tenant-token.ts | 22 ++++++++----------- .../meilisearch/actions/get-documents.ts | 11 ++++++---- .../meilisearch/actions/get-task.ts | 8 ++++--- .../meilisearch/actions/search-documents.ts | 11 ++++++---- .../meilisearch/actions/update-documents.ts | 12 +++++----- 7 files changed, 43 insertions(+), 45 deletions(-) diff --git a/integrations/meilisearch/meilisearch/actions/add-documents.ts b/integrations/meilisearch/meilisearch/actions/add-documents.ts index 4eea4ad90b..6073ed89d7 100644 --- a/integrations/meilisearch/meilisearch/actions/add-documents.ts +++ b/integrations/meilisearch/meilisearch/actions/add-documents.ts @@ -3,7 +3,7 @@ import * as z from 'zod'; import { enqueuedTaskSchema, meiliDocumentSchema } from '../lib/schemas.js'; -const input = z.object({ +const inputSchema = z.object({ indexUid: z.string(), documents: z.array(meiliDocumentSchema).min(1), primaryKey: z.string().optional() @@ -13,14 +13,12 @@ const action = createAction({ description: 'Add or replace documents in a Meilisearch index (batch). Returns the enqueued task.', version: '1.0.0', endpoint: { method: 'POST', path: '/meilisearch/documents', group: 'Documents' }, - input, + input: inputSchema, output: enqueuedTaskSchema, - exec: async (nango, input) => { - // Enforced here because action input is not validated at runtime. - if (input.documents.length === 0) { - throw new nango.ActionError({ message: '"documents" must contain at least one document.' }); - } + exec: async (nango, rawInput) => { + // Declared input schemas are not enforced at runtime; validate explicitly. + const { data: input } = await nango.zodValidateInput({ zodSchema: inputSchema, input: rawInput }); const res = await nango.post({ endpoint: `/indexes/${encodeURIComponent(input.indexUid)}/documents`, data: input.documents, diff --git a/integrations/meilisearch/meilisearch/actions/delete-documents.ts b/integrations/meilisearch/meilisearch/actions/delete-documents.ts index 3cf1b5001b..58a1427956 100644 --- a/integrations/meilisearch/meilisearch/actions/delete-documents.ts +++ b/integrations/meilisearch/meilisearch/actions/delete-documents.ts @@ -3,7 +3,7 @@ import * as z from 'zod'; import { enqueuedTaskSchema, filterSchema } from '../lib/schemas.js'; -const input = z.object({ +const inputSchema = z.object({ indexUid: z.string(), ids: z .array(z.union([z.string(), z.number()])) @@ -16,19 +16,17 @@ const action = createAction({ description: 'Delete documents from a Meilisearch index by ids or by filter (exactly one must be provided). Returns the enqueued task.', version: '1.0.0', endpoint: { method: 'POST', path: '/meilisearch/documents/delete', group: 'Documents' }, - input, + input: inputSchema, output: enqueuedTaskSchema, - exec: async (nango, input) => { - // Enforced here because action input is not validated at runtime. + exec: async (nango, rawInput) => { + // Declared input schemas are not enforced at runtime; validate explicitly. + const { data: input } = await nango.zodValidateInput({ zodSchema: inputSchema, input: rawInput }); const hasIds = input.ids !== undefined; const hasFilter = input.filter !== undefined; if (hasIds === hasFilter) { throw new nango.ActionError({ message: 'Provide exactly one of "ids" or "filter".' }); } - if (hasIds && input.ids!.length === 0) { - throw new nango.ActionError({ message: '"ids" must contain at least one document id.' }); - } const res = hasIds ? await nango.post({ endpoint: `/indexes/${encodeURIComponent(input.indexUid)}/documents/delete-batch`, data: input.ids }) diff --git a/integrations/meilisearch/meilisearch/actions/generate-tenant-token.ts b/integrations/meilisearch/meilisearch/actions/generate-tenant-token.ts index 7b15a4fc5a..a1841a3980 100644 --- a/integrations/meilisearch/meilisearch/actions/generate-tenant-token.ts +++ b/integrations/meilisearch/meilisearch/actions/generate-tenant-token.ts @@ -8,7 +8,7 @@ const DEFAULT_TTL_SECONDS = 3600; const KEYS_PAGE_SIZE = 100; const MAX_KEY_PAGES = 20; -const input = z.object({ +const inputSchema = z.object({ searchRules: searchRulesSchema, expiresAt: z.number().optional().describe('Expiry as epoch seconds. Takes precedence over expiresInSeconds.'), expiresInSeconds: z.number().optional().describe('Expiry as a duration from now. Defaults to 3600 (1 hour) when neither field is set.'), @@ -28,19 +28,15 @@ const action = createAction({ 'Generate a Meilisearch tenant token: a scoped, signed search JWT carrying per-index ACL rules. Expires after 1 hour unless expiresAt or expiresInSeconds is set.', version: '1.0.0', endpoint: { method: 'POST', path: '/meilisearch/tenant-token', group: 'Tenant Tokens' }, - input, + input: inputSchema, output, - exec: async (nango, input) => { - // Parsed here because action input is not validated at runtime; a malformed - // rule (e.g. a typo'd "filters" key) would otherwise be signed into the token - // and silently ignored by Meilisearch, broadening access. - const parsedRules = searchRulesSchema.safeParse(input.searchRules); - if (!parsedRules.success) { - throw new nango.ActionError({ message: `Invalid searchRules: ${parsedRules.error.issues.map((i) => i.message).join('; ')}` }); - } - const searchRules = parsedRules.data; - if (Object.keys(searchRules).length === 0) { + exec: async (nango, rawInput) => { + // Declared input schemas are not enforced at runtime; validate explicitly. + // The strict rule schema matters here: Meilisearch ignores unknown rule keys, + // so a typo'd key would silently broaden a signed token's access. + const { data: input } = await nango.zodValidateInput({ zodSchema: inputSchema, input: rawInput }); + if (Object.keys(input.searchRules).length === 0) { throw new nango.ActionError({ message: 'searchRules must define at least one index rule.' }); } @@ -85,7 +81,7 @@ const action = createAction({ const token = generateTenantToken({ apiKey, apiKeyUid, - searchRules, + searchRules: input.searchRules, expiresAt }); diff --git a/integrations/meilisearch/meilisearch/actions/get-documents.ts b/integrations/meilisearch/meilisearch/actions/get-documents.ts index 530c10d564..9189c18b09 100644 --- a/integrations/meilisearch/meilisearch/actions/get-documents.ts +++ b/integrations/meilisearch/meilisearch/actions/get-documents.ts @@ -3,7 +3,7 @@ import * as z from 'zod'; import { filterSchema, meiliDocumentSchema } from '../lib/schemas.js'; -const input = z.object({ +const inputSchema = z.object({ indexUid: z.string(), ids: z.array(z.union([z.string(), z.number()])).optional(), filter: filterSchema.optional(), @@ -25,11 +25,14 @@ const action = createAction({ description: 'Fetch documents from a Meilisearch index, optionally filtered.', version: '1.0.0', endpoint: { method: 'POST', path: '/meilisearch/documents/fetch', group: 'Documents' }, - input, + input: inputSchema, output, - exec: async (nango, input) => { - const { indexUid, ...body } = input; + exec: async (nango, rawInput) => { + // Declared input schemas are not enforced at runtime; validate explicitly. + // The raw input is forwarded so extra Meilisearch fetch params pass through. + await nango.zodValidateInput({ zodSchema: inputSchema, input: rawInput }); + const { indexUid, ...body } = rawInput; const res = await nango.post({ endpoint: `/indexes/${encodeURIComponent(indexUid)}/documents/fetch`, data: body }); return res.data; } diff --git a/integrations/meilisearch/meilisearch/actions/get-task.ts b/integrations/meilisearch/meilisearch/actions/get-task.ts index 11ee8df995..7e136389bf 100644 --- a/integrations/meilisearch/meilisearch/actions/get-task.ts +++ b/integrations/meilisearch/meilisearch/actions/get-task.ts @@ -1,7 +1,7 @@ import { createAction } from 'nango'; import * as z from 'zod'; -const input = z.object({ +const inputSchema = z.object({ taskUid: z.number() }); @@ -22,10 +22,12 @@ const action = createAction({ description: 'Fetch the status of a Meilisearch async task by its uid.', version: '1.0.0', endpoint: { method: 'GET', path: '/meilisearch/tasks', group: 'Tasks' }, - input, + input: inputSchema, output, - exec: async (nango, input) => { + exec: async (nango, rawInput) => { + // Declared input schemas are not enforced at runtime; validate explicitly. + const { data: input } = await nango.zodValidateInput({ zodSchema: inputSchema, input: rawInput }); const res = await nango.get({ endpoint: `/tasks/${input.taskUid}` }); return res.data; } diff --git a/integrations/meilisearch/meilisearch/actions/search-documents.ts b/integrations/meilisearch/meilisearch/actions/search-documents.ts index 320082c408..0a4af8cfcc 100644 --- a/integrations/meilisearch/meilisearch/actions/search-documents.ts +++ b/integrations/meilisearch/meilisearch/actions/search-documents.ts @@ -3,7 +3,7 @@ import * as z from 'zod'; import { filterSchema, meiliDocumentSchema } from '../lib/schemas.js'; -const input = z.object({ +const inputSchema = z.object({ indexUid: z.string(), q: z.string().optional(), filter: filterSchema.optional(), @@ -29,11 +29,14 @@ const action = createAction({ description: 'Search documents in a Meilisearch index.', version: '1.0.0', endpoint: { method: 'POST', path: '/meilisearch/documents/search', group: 'Documents' }, - input, + input: inputSchema, output, - exec: async (nango, input) => { - const { indexUid, ...body } = input; + exec: async (nango, rawInput) => { + // Declared input schemas are not enforced at runtime; validate explicitly. + // The raw input is forwarded so extra Meilisearch search params pass through. + await nango.zodValidateInput({ zodSchema: inputSchema, input: rawInput }); + const { indexUid, ...body } = rawInput; const res = await nango.post({ endpoint: `/indexes/${encodeURIComponent(indexUid)}/search`, data: body }); return res.data; } diff --git a/integrations/meilisearch/meilisearch/actions/update-documents.ts b/integrations/meilisearch/meilisearch/actions/update-documents.ts index f5bccb06d5..a422ee12c3 100644 --- a/integrations/meilisearch/meilisearch/actions/update-documents.ts +++ b/integrations/meilisearch/meilisearch/actions/update-documents.ts @@ -3,7 +3,7 @@ import * as z from 'zod'; import { enqueuedTaskSchema, meiliDocumentSchema } from '../lib/schemas.js'; -const input = z.object({ +const inputSchema = z.object({ indexUid: z.string(), documents: z.array(meiliDocumentSchema).min(1), primaryKey: z.string().optional() @@ -13,14 +13,12 @@ const action = createAction({ description: 'Add or partially update documents in a Meilisearch index (batch). Returns the enqueued task.', version: '1.0.0', endpoint: { method: 'PUT', path: '/meilisearch/documents', group: 'Documents' }, - input, + input: inputSchema, output: enqueuedTaskSchema, - exec: async (nango, input) => { - // Enforced here because action input is not validated at runtime. - if (input.documents.length === 0) { - throw new nango.ActionError({ message: '"documents" must contain at least one document.' }); - } + exec: async (nango, rawInput) => { + // Declared input schemas are not enforced at runtime; validate explicitly. + const { data: input } = await nango.zodValidateInput({ zodSchema: inputSchema, input: rawInput }); const res = await nango.put({ endpoint: `/indexes/${encodeURIComponent(input.indexUid)}/documents`, data: input.documents, From 9df08f363dd0c1b43acbfd375af8d5d2ce0ac6e2 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Wed, 8 Jul 2026 18:49:06 +0200 Subject: [PATCH 16/18] fix(meilisearch): declare open input schemas for search/fetch passthrough search-documents and get-documents forward extra keys to Meilisearch, but their plain z.object schemas published additionalProperties: false. Use z.looseObject so the declared contract matches the passthrough behavior, and forward the validated data instead of the raw input. --- .../meilisearch/meilisearch/actions/get-documents.ts | 9 +++++---- .../meilisearch/meilisearch/actions/search-documents.ts | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/integrations/meilisearch/meilisearch/actions/get-documents.ts b/integrations/meilisearch/meilisearch/actions/get-documents.ts index 9189c18b09..a75e0d8d83 100644 --- a/integrations/meilisearch/meilisearch/actions/get-documents.ts +++ b/integrations/meilisearch/meilisearch/actions/get-documents.ts @@ -3,7 +3,9 @@ import * as z from 'zod'; import { filterSchema, meiliDocumentSchema } from '../lib/schemas.js'; -const inputSchema = z.object({ +// Loose: extra keys are forwarded to Meilisearch so callers can use any +// supported fetch param without a schema change. +const inputSchema = z.looseObject({ indexUid: z.string(), ids: z.array(z.union([z.string(), z.number()])).optional(), filter: filterSchema.optional(), @@ -30,9 +32,8 @@ const action = createAction({ exec: async (nango, rawInput) => { // Declared input schemas are not enforced at runtime; validate explicitly. - // The raw input is forwarded so extra Meilisearch fetch params pass through. - await nango.zodValidateInput({ zodSchema: inputSchema, input: rawInput }); - const { indexUid, ...body } = rawInput; + const { data: input } = await nango.zodValidateInput({ zodSchema: inputSchema, input: rawInput }); + const { indexUid, ...body } = input; const res = await nango.post({ endpoint: `/indexes/${encodeURIComponent(indexUid)}/documents/fetch`, data: body }); return res.data; } diff --git a/integrations/meilisearch/meilisearch/actions/search-documents.ts b/integrations/meilisearch/meilisearch/actions/search-documents.ts index 0a4af8cfcc..572d2f33ad 100644 --- a/integrations/meilisearch/meilisearch/actions/search-documents.ts +++ b/integrations/meilisearch/meilisearch/actions/search-documents.ts @@ -3,7 +3,9 @@ import * as z from 'zod'; import { filterSchema, meiliDocumentSchema } from '../lib/schemas.js'; -const inputSchema = z.object({ +// Loose: extra keys are forwarded to Meilisearch so callers can use any +// supported search param (e.g. hybrid, showRankingScore) without a schema change. +const inputSchema = z.looseObject({ indexUid: z.string(), q: z.string().optional(), filter: filterSchema.optional(), @@ -34,9 +36,8 @@ const action = createAction({ exec: async (nango, rawInput) => { // Declared input schemas are not enforced at runtime; validate explicitly. - // The raw input is forwarded so extra Meilisearch search params pass through. - await nango.zodValidateInput({ zodSchema: inputSchema, input: rawInput }); - const { indexUid, ...body } = rawInput; + const { data: input } = await nango.zodValidateInput({ zodSchema: inputSchema, input: rawInput }); + const { indexUid, ...body } = input; const res = await nango.post({ endpoint: `/indexes/${encodeURIComponent(indexUid)}/search`, data: body }); return res.data; } From eb72a4da5722506207765eafed968375121538ff Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Thu, 9 Jul 2026 15:00:40 +0200 Subject: [PATCH 17/18] chore(meilisearch): use official logo --- .../images/template-logos/meilisearch.svg | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/webapp/public/images/template-logos/meilisearch.svg b/packages/webapp/public/images/template-logos/meilisearch.svg index f2a8d704fc..0b7f8094d8 100644 --- a/packages/webapp/public/images/template-logos/meilisearch.svg +++ b/packages/webapp/public/images/template-logos/meilisearch.svg @@ -1,9 +1,20 @@ - - - - - - - - + + + + + + + + + + + + + + + + + + + From 34e112e5c7864baeadcf41e38072de46b8449ff3 Mon Sep 17 00:00:00 2001 From: Quentin de Quelen Date: Thu, 9 Jul 2026 15:02:07 +0200 Subject: [PATCH 18/18] chore(docs): regenerate LLM indexes after rebase --- docs/api-catalog.txt | 3 ++- docs/llms.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/api-catalog.txt b/docs/api-catalog.txt index c338b24363..f15398c935 100644 --- a/docs/api-catalog.txt +++ b/docs/api-catalog.txt @@ -2,7 +2,7 @@ > Compact provider slug catalog for agents. Use the slug to reconstruct provider-specific docs URLs only when needed. -Provider count: 874 +Provider count: 875 URL patterns: - Main provider page: https://nango.dev/docs/integrations/all/{slug}.md or https://nango.dev/docs/api-integrations/{slug}.md @@ -470,6 +470,7 @@ URL patterns: | `maxio` | Maxio | BASIC | [docs](https://nango.dev/docs/api-integrations/maxio.md) | [connect](https://nango.dev/docs/api-integrations/maxio/connect.md) | | payment | | `mcp-generic` | MCP Server OAuth2 (Generic) | MCP_OAUTH2_GENERIC | [docs](https://nango.dev/docs/integrations/all/mcp-generic.md) | | | mcp | | `medallia` | Medallia | OAUTH2_CC | [docs](https://nango.dev/docs/integrations/all/medallia.md) | [connect](https://nango.dev/docs/integrations/all/medallia/connect.md) | | crm, support, surveys | +| `meilisearch` | Meilisearch | API_KEY | [docs](https://nango.dev/docs/integrations/all/meilisearch.md) | | | search | | `mercury` | Mercury | OAUTH2 | [docs](https://nango.dev/docs/api-integrations/mercury.md) | [connect](https://nango.dev/docs/api-integrations/mercury/connect.md) | [setup](https://nango.dev/docs/api-integrations/mercury/how-to-register-your-own-mercury-oauth-app.md) | accounting | | `meta-marketing-api` | Meta Marketing API | OAUTH2 | [docs](https://nango.dev/docs/api-integrations/meta-marketing-api.md) | | [setup](https://nango.dev/docs/api-integrations/meta-marketing-api/how-to-register-your-own-meta-marketing-api-oauth-app.md) | marketing | | `metabase` | Metabase | API_KEY | [docs](https://nango.dev/docs/api-integrations/metabase.md) | [connect](https://nango.dev/docs/api-integrations/metabase/connect.md) | | analytics | diff --git a/docs/llms.txt b/docs/llms.txt index 7888100f9e..9e61849fe7 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -139,7 +139,7 @@ Use provider URLs by replacing `{slug}` with a slug from the API catalog: ## APIs and integrations -- [API catalog](https://nango.dev/docs/api-catalog.txt): 874 provider slugs with canonical docs routes, auth modes, setup guides, and connect guides. +- [API catalog](https://nango.dev/docs/api-catalog.txt): 875 provider slugs with canonical docs routes, auth modes, setup guides, and connect guides. - Provider-specific pages are intentionally not expanded here so core Nango guides remain easy for agents to find. ## Resources