diff --git a/.claude/skills/n8n-export/SKILL.md b/.claude/skills/n8n-export/SKILL.md new file mode 100644 index 0000000..4870d31 --- /dev/null +++ b/.claude/skills/n8n-export/SKILL.md @@ -0,0 +1,40 @@ +--- +name: n8n-export +description: Export all workflows from the local n8n instance into version-controlled JSON under apps/automation/workflows/. Use when the user asks to export, pull, snapshot, or sync workflows from the local n8n. +--- + +# n8n-export + +Fetches every workflow from the local n8n instance via the REST API and writes each one to `apps/automation/workflows//workflow.json` with instance-specific fields stripped for clean diffs. + +## Preconditions + +- n8n is running locally (`pnpm --filter automation n8n:ps` shows it). +- `apps/automation/.env` has `N8N_API_KEY` set. If missing, stop and direct the user to create one at and paste into `.env`. + +## Steps + +1. **Pre-flight checks:** + ```bash + grep -E '^N8N_API_KEY=.+' apps/automation/.env >/dev/null || echo 'MISSING N8N_API_KEY' + curl -sf http://localhost:5678/healthz >/dev/null || echo 'n8n not reachable' + ``` + +2. **Run the export:** + ```bash + pnpm --filter automation workflows:export + ``` + +3. **Report the summary** to the user (added / updated / removed / skipped-with-notes counts printed by the script). + +## What's stripped + +Instance-specific fields (`id`, `versionId`, `createdAt`, `updatedAt`, `triggerCount`, `active`, `shared`, `meta`, `pinData`) are removed before writing. Tag IDs/timestamps are stripped too; tag names are kept. + +## Preserved across exports + +If `apps/automation/workflows//README.md` exists and is non-empty, the script will NOT delete that directory even if the workflow has been removed from n8n. It warns instead, so you notice the dangling directory and can resolve it intentionally. + +## Credentials + +Credentials are never exported. Only the reference (name + id) appears in the JSON; secrets stay in n8n's encrypted store. diff --git a/.claude/skills/n8n-import/SKILL.md b/.claude/skills/n8n-import/SKILL.md new file mode 100644 index 0000000..f0f1682 --- /dev/null +++ b/.claude/skills/n8n-import/SKILL.md @@ -0,0 +1,38 @@ +--- +name: n8n-import +description: Import workflows from apps/automation/workflows/ into the local n8n instance. Use when the user asks to import, push, load, or sync workflow JSON files to the local n8n. +--- + +# n8n-import + +Reads every `apps/automation/workflows//workflow.json` and creates or updates the matching workflow on the local n8n instance. Matching is by workflow `name`. + +## Preconditions + +- n8n is running locally. +- `apps/automation/.env` has `N8N_API_KEY` set. + +## Steps + +1. **Pre-flight checks:** + ```bash + grep -E '^N8N_API_KEY=.+' apps/automation/.env >/dev/null || echo 'MISSING N8N_API_KEY' + curl -sf http://localhost:5678/healthz >/dev/null || echo 'n8n not reachable' + ``` + +2. **Run the import:** + ```bash + pnpm --filter automation workflows:import + ``` + +3. **Report the summary** to the user (created / updated / failed counts). + +## Behavior + +- **Match by name:** if a workflow with the same `name` already exists on the instance, it's updated in place via `PUT /api/v1/workflows/{id}`. The instance-side id and any activations are preserved. +- **Imported inactive:** the `active` field is stripped on export, so nothing is auto-activated on import. The user activates workflows manually in the UI. +- **Credentials:** dangling credential references are warned per-node. Recreate the credentials in the n8n UI to clear the warning — secrets are never in the JSON. + +## Sanitization + +Only whitelisted fields (`name`, `nodes`, `connections`, `settings`, `staticData`) are sent to the API. Tags aren't associated on import (n8n manages tag links via a separate endpoint; that's intentionally out of scope for now). diff --git a/.claude/skills/n8n-restart/SKILL.md b/.claude/skills/n8n-restart/SKILL.md new file mode 100644 index 0000000..5741e7e --- /dev/null +++ b/.claude/skills/n8n-restart/SKILL.md @@ -0,0 +1,38 @@ +--- +name: n8n-restart +description: Restart the local n8n automation stack, preserving whatever profile (plain or mcp) is currently running. Use when the user asks to restart, reload, or reboot n8n. +--- + +# n8n-restart + +Restarts the running services in place. This is faster than `down`/`up` because volumes stay mounted. It also preserves the currently active compose profile — if `n8n-mcp` was up, it stays up. + +## Steps + +1. **Detect whether the mcp profile is active:** + ```bash + cd apps/automation + if ./scripts/compose.sh ps --services --status running | grep -q '^n8n-mcp$'; then + MCP_RUNNING=1 + else + MCP_RUNNING=0 + fi + ``` + +2. **Restart the appropriate set of services:** + ```bash + if [ "$MCP_RUNNING" = "1" ]; then + pnpm --filter automation n8n:restart:mcp + else + pnpm --filter automation n8n:restart + fi + ``` + +3. **Verify:** + ```bash + pnpm --filter automation n8n:ps + ``` + +## Notes + +- If nothing is currently running, `n8n-restart` will do nothing useful. Use `n8n-start` to bring the stack up instead. diff --git a/.claude/skills/n8n-start/SKILL.md b/.claude/skills/n8n-start/SKILL.md new file mode 100644 index 0000000..442d8d0 --- /dev/null +++ b/.claude/skills/n8n-start/SKILL.md @@ -0,0 +1,55 @@ +--- +name: n8n-start +description: Start the local n8n instance for the Strapi community hub. Use when the user asks to start, launch, bring up, or run n8n or the automation stack. Pass --mcp to also bring up n8n-mcp (requires N8N_API_KEY and MCP_AUTH_TOKEN in .env). +--- + +# n8n-start + +Starts the n8n container(s) defined in `apps/automation/docker-compose.yml` via the engine-agnostic `scripts/compose.sh` wrapper (autodetects Docker or Podman). + +## Steps + +1. **Change to the automation directory.** All commands below run from `apps/automation/`. + +2. **Ensure `.env` exists:** + ```bash + test -f apps/automation/.env || cp apps/automation/.env.example apps/automation/.env + ``` + If it was just created, tell the user and point them at `apps/automation/.env.example`. + +3. **Decide on MCP.** If the user's request didn't specify, ask whether to include `n8n-mcp`. Default is no. + +4. **If starting with MCP, pre-check required env vars:** + ```bash + grep -E '^(N8N_API_KEY|MCP_AUTH_TOKEN)=.+' apps/automation/.env + ``` + Both must be non-empty. If either is missing, stop and explain: + - `MCP_AUTH_TOKEN` — generate with `openssl rand -hex 32`. + - `N8N_API_KEY` — chicken-and-egg: start n8n first (without `--mcp`), create the key in the UI at , paste it into `.env`, then run `n8n-start --mcp`. + +5. **Start the stack:** + ```bash + # without MCP + pnpm --filter automation n8n:up + + # with MCP + pnpm --filter automation n8n:up:mcp + ``` + +6. **Tail startup logs briefly** to confirm health: + ```bash + pnpm --filter automation n8n:logs --tail=20 & + # (stop after a few seconds) + ``` + Or run `pnpm --filter automation n8n:ps` to confirm the containers are `running` / `healthy`. + +7. **Report URLs to the user:** + - n8n UI: + - n8n-mcp endpoint (if started): + +8. **If the `mcp` profile was started**, also remind the user that Claude Code must be registered against the MCP server to actually use it. Point them at the "Connecting Claude Code to the local n8n-mcp" section of `apps/automation/README.md`. The one-line summary: + + - Project-scoped: `claude mcp add --scope project --transport http n8n-mcp http://localhost:3100/mcp --header "Authorization: Bearer \${MCP_AUTH_TOKEN}"` (writes `.mcp.json`; export `MCP_AUTH_TOKEN` before launching `claude`). + - User-scoped: same command without `--scope project` and with the literal token inlined. + + Skip this reminder if the user has clearly registered it already (e.g. they're invoking an `n8n-*` tool and it's responding). diff --git a/.claude/skills/n8n-status/SKILL.md b/.claude/skills/n8n-status/SKILL.md new file mode 100644 index 0000000..72ee7b2 --- /dev/null +++ b/.claude/skills/n8n-status/SKILL.md @@ -0,0 +1,35 @@ +--- +name: n8n-status +description: Show the health and status of the local n8n automation stack. Use when the user asks about n8n status, health, whether it is running, or which containers are up. +--- + +# n8n-status + +Prints container status plus health probes for n8n and (if running) n8n-mcp. + +## Steps + +1. **List containers:** + ```bash + pnpm --filter automation n8n:ps + ``` + +2. **Probe n8n health:** + ```bash + curl -sf -o /dev/null -w '%{http_code}\n' http://localhost:5678/healthz || echo 'unreachable' + ``` + A `200` means healthy. Anything else means n8n isn't ready (or isn't running). + +3. **Probe n8n-mcp health (only if its container is running):** + ```bash + curl -sf -o /dev/null -w '%{http_code}\n' http://localhost:3100/health || echo 'unreachable' + ``` + +4. **Summarize** per service in one line each: + - engine (docker compose vs podman-compose, from `apps/automation/scripts/compose.sh`) + - container state (running / exited / healthy) + - local URL and probe result + +## Notes + +- If both probes fail but containers show as running, it usually means the service is still booting. Wait ~20s and retry, or inspect logs: `pnpm --filter automation n8n:logs`. diff --git a/.claude/skills/n8n-stop/SKILL.md b/.claude/skills/n8n-stop/SKILL.md new file mode 100644 index 0000000..d272ed2 --- /dev/null +++ b/.claude/skills/n8n-stop/SKILL.md @@ -0,0 +1,25 @@ +--- +name: n8n-stop +description: Stop the local n8n automation stack. Use when the user asks to stop, shut down, halt, or bring down n8n. Data in the n8n_data volume persists across stops. +--- + +# n8n-stop + +Stops and removes the automation containers. The named volume `n8n_data` (workflows, credentials, settings) is preserved. + +## Steps + +1. **Run `down` from the workspace root:** + ```bash + pnpm --filter automation n8n:down + ``` + +2. **Confirm nothing is running:** + ```bash + pnpm --filter automation n8n:ps + ``` + +## Safety notes + +- **Never pass `-v`** here. `down -v` deletes the `n8n_data` volume and all workflows/credentials would be lost. The `n8n:down` package script deliberately omits it. +- If the user asks to wipe state, confirm explicitly before running `./scripts/compose.sh down -v` from `apps/automation/`. diff --git a/.claude/skills/n8n-workflow-builder/SKILL.md b/.claude/skills/n8n-workflow-builder/SKILL.md new file mode 100644 index 0000000..a722a63 --- /dev/null +++ b/.claude/skills/n8n-workflow-builder/SKILL.md @@ -0,0 +1,122 @@ +--- +name: n8n-workflow-builder +description: Domain-expertise guide for designing, building, validating, and modifying n8n workflows using the n8n-mcp tools. Use whenever the user asks to build, create, design, edit, or validate an n8n workflow. Requires the n8n-mcp server to be running (see n8n-start). +--- + +# n8n Workflow Builder (via n8n-mcp) + +You are an n8n automation expert. Use the **n8n-mcp** tools to design, build, and validate workflows with maximum accuracy and minimum token cost. + +## Core principles + +1. **Silent execution.** Run tool calls without per-call commentary. Summarize only after the batch completes. +2. **Parallel when independent.** Fire off independent `search_*` / `get_node` / `validate_node` calls in a single tool-use block. +3. **Templates first.** With 2,700+ curated templates available, always try `search_templates` before building from scratch. +4. **Multi-level validation.** `validate_node(mode='minimal')` → `validate_node(mode='full')` → `validate_workflow`. +5. **Never trust defaults.** The #1 source of runtime failures. Explicitly set every parameter that controls node behavior. + +## Process + +1. **Bootstrap** — `tools_documentation()` once, to pull current best-practice guidance from the MCP server. + +2. **Template discovery** (parallel when searching multiple axes): + - `search_templates({searchMode:'by_metadata', complexity:'simple', maxSetupMinutes:30})` + - `search_templates({searchMode:'by_task', task:'webhook_processing'})` + - `search_templates({searchMode:'by_nodes', nodeTypes:['n8n-nodes-base.slack']})` + - `search_templates({query:'slack notification'})` for keyword search. + +3. **Node discovery** (if no suitable template): + - `search_nodes({query:'...', includeExamples:true})` — returns real configs from templates. + - Before configuring, present the intended architecture to the user for approval. + +4. **Configuration** (parallel for multiple nodes): + - `get_node({nodeType, detail:'standard', includeExamples:true})` — default. + - `detail:'minimal'` (~200 tokens) for quick metadata, `detail:'full'` (~3-8k tokens) only when needed. + - `mode:'search_properties', propertyQuery:'auth'` to locate a specific property. + - `mode:'docs'` for the human-readable node docs. + +5. **Node validation** (parallel): + - `validate_node({nodeType, config, mode:'minimal'})` — quick required-fields check. + - `validate_node({nodeType, config, mode:'full', profile:'runtime'})` — full with auto-fixes. + - Fix every error before continuing. + +6. **Build:** + - Using a template: `get_template(id, {mode:'full'})`. **Attribution is mandatory** — always cite the template author and link: `Based on template by **** (@). View: `. + - Set every meaningful parameter explicitly. Wire nodes correctly. Add error handling. Use n8n expression syntax (`$json`, `$node["Name"].json`). + - Avoid the **Code node** unless no standard node fits. + - Remember: any node can be used as an AI agent tool, not just nodes marked as such. + +7. **Workflow validation** (before deployment): + - `validate_workflow(workflow)` — full sweep. + - `validate_workflow_connections(workflow)` / `validate_workflow_expressions(workflow)` for targeted checks. + +8. **Deployment** (if the n8n API is configured and the user wants it pushed): + - `n8n_create_workflow(workflow)` to deploy. + - `n8n_validate_workflow({id})` to post-check. + - `n8n_update_partial_workflow({id, operations:[...]})` — **always batch** multiple operations into one call. + - `n8n_test_workflow({workflowId})` to run it. + +## Critical pitfalls + +### `addConnection` requires four **separate string** parameters + +```json +{ + "type": "addConnection", + "source": "Source Node", + "target": "Target Node", + "sourcePort": "main", + "targetPort": "main" +} +``` + +Not an object, not a tuple — four discrete string fields. See [n8n-mcp issue #327](https://github.com/czlonkowski/n8n-mcp/issues/327). + +### IF-node branches + +`addConnection` on an IF node must specify `branch: "true"` or `branch: "false"`. Without it, both connections may land on the same output and the logic silently breaks. + +```json +{ "type":"addConnection", "source":"If", "target":"OnTrue", "sourcePort":"main", "targetPort":"main", "branch":"true" } +{ "type":"addConnection", "source":"If", "target":"OnFalse", "sourcePort":"main", "targetPort":"main", "branch":"false" } +``` + +### Batch partial updates + +One `n8n_update_partial_workflow` with an array of operations beats N calls. Don't split. + +### Explicit parameters + +``` +BAD: { resource:"message", operation:"post", text:"hi" } +GOOD: { resource:"message", operation:"post", select:"channel", channelId:"C123", text:"hi" } +``` + +The "BAD" form fails at runtime because default selections aren't filled in. + +## Popular node types (exact IDs) + +Use `n8n-nodes-base.` for core nodes and `@n8n/n8n-nodes-langchain.` for LangChain nodes. + +- `n8n-nodes-base.code` — JS/Python (avoid when possible) +- `n8n-nodes-base.httpRequest` +- `n8n-nodes-base.webhook`, `n8n-nodes-base.respondToWebhook` +- `n8n-nodes-base.set`, `n8n-nodes-base.merge`, `n8n-nodes-base.splitInBatches` +- `n8n-nodes-base.if`, `n8n-nodes-base.switch` +- `n8n-nodes-base.manualTrigger`, `n8n-nodes-base.scheduleTrigger`, `n8n-nodes-base.executeWorkflowTrigger` +- `n8n-nodes-base.googleSheets`, `n8n-nodes-base.gmail`, `n8n-nodes-base.telegram` +- `n8n-nodes-base.stickyNote` — documentation inside the canvas +- `@n8n/n8n-nodes-langchain.agent`, `@n8n/n8n-nodes-langchain.lmChatOpenAi` + +## Response format + +After a batch of tool calls, report concisely: + +``` +Created workflow: Webhook → Validate → Slack +- Trigger: POST /webhook/contact-form +- Validation: If email present → Slack #support +- All nodes validated (0 errors, 0 warnings) +``` + +Defer long explanations until asked. diff --git a/.gitignore b/.gitignore index 4006871..77ef7b9 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,10 @@ yarn-error.log* # Misc .DS_Store *.pem + +# Local scratch (specs, plans, ad-hoc notes — not committed) +tmp/ + +# Claude Code personal settings (permissions, env, hooks — not shared) +.claude/settings.json +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..86da6ca --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,52 @@ +# Claude Code Guide — Strapi Community + +pnpm + turbo monorepo for the Strapi community hub. + +## Layout + +- `apps/automation/` — Local n8n instance and version-controlled workflows +- `apps/cms/` — Strapi application +- `apps/search/` — Search service +- `apps/web/` — Web app +- `packages/biome-config/` — Shared Biome (lint/format) config +- `packages/strapi-client/` — Strapi client SDK +- `packages/typescript-config/` — Shared TypeScript configs + +## Common commands + +- `pnpm install` — install all workspace deps +- `pnpm dev` — run all app `dev` scripts via turbo +- `pnpm build` / `pnpm lint` / `pnpm test` / `pnpm check-types` — turbo tasks + +## Automation stack (n8n) + +Local n8n plus optional `n8n-mcp` managed by an engine-agnostic compose setup under `apps/automation/`. Works with either Docker or Podman — the wrapper script picks whichever is installed. + +See [`apps/automation/README.md`](apps/automation/README.md) for full setup. + +### Skills (under `.claude/skills/`) + +| Skill | Purpose | +| --- | --- | +| `n8n-start` | Start n8n (pass `--mcp` to also start n8n-mcp). | +| `n8n-stop` | Stop the automation stack. The `n8n_data` volume is preserved. | +| `n8n-restart` | Restart the running services, preserving the active profile. | +| `n8n-status` | Show container state, ports, and health probes. | +| `n8n-export` | Pull workflows from the local n8n instance into `apps/automation/workflows//workflow.json`. | +| `n8n-import` | Push every `workflows//workflow.json` back into the local n8n instance. | +| `n8n-workflow-builder` | Domain-expertise prompt for designing, building, and validating n8n workflows via `n8n-mcp` tools. Invoke whenever the user asks to build or modify a workflow. | + +### Connecting to n8n-mcp + +The `n8n-workflow-builder` skill assumes Claude Code is already connected to the local `n8n-mcp` server. If its tools are unavailable: + +1. Ensure the `mcp` profile is running: `pnpm --filter automation n8n:ps` should show `n8n-mcp` healthy. +2. Register the MCP server with Claude Code. See **"Connecting Claude Code to the local n8n-mcp"** in [`apps/automation/README.md`](apps/automation/README.md) for the exact `claude mcp add` command and `.mcp.json` shape. +3. Remember to `export MCP_AUTH_TOKEN=...` (from `apps/automation/.env`) in the shell that launches `claude`. + +## Conventions + +- **`tmp/`** is gitignored local scratch (specs, plans, ad-hoc notes). Do not commit any content placed under `tmp/`. +- **`.claude/settings.json`** and **`.claude/settings.local.json`** are gitignored — they hold personal permissions, env vars, and hooks. Do not commit them. +- **`.claude/skills/**`** is committed and shared with the team. +- The team self-hosted n8n instance is **not** reachable from this repo's tooling. `n8n-export` / `n8n-import` target `http://localhost:5678` only. Team deployments are manual and out of scope for the scripts in this repo. diff --git a/apps/automation/.env.example b/apps/automation/.env.example new file mode 100644 index 0000000..8465a55 --- /dev/null +++ b/apps/automation/.env.example @@ -0,0 +1,14 @@ +# n8n API key — generate inside the n8n UI at http://localhost:5678/settings/api +# after first startup. Required for n8n-mcp and for the export/import scripts. +N8N_API_KEY= + +# n8n-mcp authentication token. Generate with: +# openssl rand -hex 32 +# Required only when starting with the `mcp` profile. +MCP_AUTH_TOKEN= + +# Optional: override the local n8n URL used by export/import scripts. +# N8N_URL=http://localhost:5678 + +# Optional: IANA timezone for n8n (default: UTC). +GENERIC_TIMEZONE=UTC diff --git a/apps/automation/README.md b/apps/automation/README.md new file mode 100644 index 0000000..8084b09 --- /dev/null +++ b/apps/automation/README.md @@ -0,0 +1,214 @@ +# Automation (n8n) + +Local n8n instance for authoring and testing workflows used by the Strapi community hub. Optionally runs [n8n-mcp](https://github.com/czlonkowski/n8n-mcp) alongside n8n so Claude Code (or any MCP client) can build and validate workflows programmatically. + +Engine-agnostic: works with either Docker or Podman. All scripts detect your installed engine automatically. + +## Prerequisites + +One of: + +- **Docker Desktop** (includes the `docker compose` v2 plugin), or +- **Podman + podman-compose** — `pip install --user podman-compose` + +Node.js 20+ is required for the workflow export/import scripts (uses built-in `fetch`). + +## Ports + +| Service | Port | URL | +| --------- | ----- | -------------------------------- | +| n8n | 5678 | | +| n8n-mcp | 3100 | | + +## First-time setup + +```bash +cd apps/automation +cp .env.example .env +``` + +For n8n alone, the defaults in `.env` are fine. For **n8n-mcp** you'll also need: + +1. Generate an MCP auth token: + + ```bash + openssl rand -hex 32 + ``` + + Paste it into `.env` as `MCP_AUTH_TOKEN`. + +2. Start n8n first so you can create an n8n API key: + + ```bash + pnpm --filter automation n8n:up + ``` + + Visit , create the owner account, then Settings → n8n API → create a new API key. Paste the value into `.env` as `N8N_API_KEY`. + +3. Bring up n8n-mcp: + + ```bash + pnpm --filter automation n8n:up:mcp + ``` + +## Daily use + +All commands run from the repo root: + +```bash +pnpm --filter automation n8n:up # n8n only +pnpm --filter automation n8n:up:mcp # n8n + n8n-mcp +pnpm --filter automation n8n:down # stop everything (data persists) +pnpm --filter automation n8n:restart # restart running services +pnpm --filter automation n8n:ps # container status +pnpm --filter automation n8n:logs # tail service logs +``` + +Data persists in the `n8n_data` named volume across `down` / `up`. Volumes are only removed if you explicitly run `docker compose down -v` or `podman-compose down -v`. + +## Workflow version control + +Workflows live under `workflows//`: + +```text +workflows/ +└── my-workflow/ + ├── workflow.json # Auto-managed by export/import + └── README.md # Optional human context; preserved across re-exports +``` + +### Export local workflows to the repo + +```bash +pnpm --filter automation workflows:export +``` + +Fetches all workflows from , strips instance-specific fields (ids, timestamps, runtime state, pin data), and writes them to `workflows//workflow.json`. Slug is derived from the workflow name. + +If you deleted a workflow in n8n, its `/` directory is removed on the next export — **unless** it contains a non-empty `README.md`, in which case it's skipped with a warning so human notes aren't silently lost. + +### Import workflows from the repo + +```bash +pnpm --filter automation workflows:import +``` + +Reads every `workflows/*/workflow.json` and creates or updates the matching workflow on the local n8n instance (matched by `name`). Workflows are always imported as **inactive** — activate them manually in the n8n UI. + +Credentials are never exported (only the reference by name/id is kept in the JSON). When importing to a fresh instance, recreate the credentials in the n8n UI first; dangling credential refs will be flagged by n8n at runtime. + +### Team self-hosted instance + +The export/import scripts target `http://localhost:5678` only. Deploying workflows to the team self-hosted n8n is a separate manual process and not covered by this tooling. + +## Using with Claude Code + +Several skills under `.claude/skills/` wrap this stack: + +- `n8n-start`, `n8n-stop`, `n8n-restart`, `n8n-status` — lifecycle +- `n8n-export`, `n8n-import` — workflow sync +- `n8n-workflow-builder` — domain-expertise prompt for building workflows via n8n-mcp tools + +### Connecting Claude Code to the local n8n-mcp + +Once `n8n-mcp` is running (the `mcp` profile is up), Claude Code can connect to it at: + +- **URL:** `http://localhost:3100/mcp` +- **Auth:** `Authorization: Bearer ` (the same value in `apps/automation/.env`) + +Register the server **once per scope** using `claude mcp add`. There are three scopes to choose from; pick the one that fits how broadly you want this server available. + +#### Which scope? + +| Scope | Command flag | Stored in | Available from | Best when | +| --- | --- | --- | --- | --- | +| **local** (default) | *(no flag)* | your user config, keyed to this project path | this repo only | You want n8n-mcp to activate only when you're working in this repo and you don't need to share the config with others. | +| **user** | `--scope user` | your user config | every project you open with `claude` | You plan to use n8n-mcp outside this repo too — e.g. wiring workflows from other codebases or ad-hoc sessions. | +| **project** | `--scope project` | `.mcp.json` at the repo root (committed) | this repo, for every teammate who clones it | The team wants one shared config that travels with the repo. | + +All three assume n8n-mcp is running at `http://localhost:3100/mcp` on the machine where Claude Code is running. If you need a different layout later, change the `url`. + +#### Local scope (project-specific, personal) + +```bash +claude mcp add --transport http n8n-mcp \ + http://localhost:3100/mcp \ + --header "Authorization: Bearer $(grep '^MCP_AUTH_TOKEN=' apps/automation/.env | cut -d= -f2-)" +``` + +The literal token is stored in your user config, attached to this project path. Nothing is written to the repo. + +#### User scope (available everywhere) + +```bash +claude mcp add --scope user --transport http n8n-mcp \ + http://localhost:3100/mcp \ + --header "Authorization: Bearer $(grep '^MCP_AUTH_TOKEN=' apps/automation/.env | cut -d= -f2-)" +``` + +Same storage mechanism as local, but the server is exposed in every project you open. You only need to re-run this if the token rotates. + +#### Project scope (committed, shared with the team) + +```bash +claude mcp add --scope project --transport http n8n-mcp \ + http://localhost:3100/mcp \ + --header "Authorization: Bearer \${MCP_AUTH_TOKEN}" +``` + +This writes a `.mcp.json` at the repo root: + +```json +{ + "mcpServers": { + "n8n-mcp": { + "type": "http", + "url": "http://localhost:3100/mcp", + "headers": { + "Authorization": "Bearer ${MCP_AUTH_TOKEN}" + } + } + } +} +``` + +`${MCP_AUTH_TOKEN}` is resolved from the shell environment at Claude Code launch, so the secret itself is never committed. Before starting `claude`: + +```bash +export MCP_AUTH_TOKEN="$(grep '^MCP_AUTH_TOKEN=' apps/automation/.env | cut -d= -f2-)" +claude +``` + +Gotchas: + +- If `MCP_AUTH_TOKEN` is unset when Claude Code parses `.mcp.json`, the config fails — there's no fallback. Export it (or use direnv / a shell alias) before launching `claude`. +- Project-scoped servers require workspace-trust approval on first use; Claude Code will prompt. + +#### Verify the connection + +After running `claude mcp add`, confirm the server is reachable: + +```bash +claude mcp list | grep n8n-mcp +# → n8n-mcp: http://localhost:3100/mcp (HTTP) - ✓ Connected +``` + +You can also probe the endpoint directly: + +```bash +curl -sf -H "Authorization: Bearer $MCP_AUTH_TOKEN" http://localhost:3100/health +# → HTTP 200 +``` + +#### Use the server in your current session + +`claude mcp add` writes the config, but an **already-running** Claude Code session only loads MCP servers at startup. After registering, exit and relaunch `claude` for the `n8n-mcp` tools to appear in the tool list. + +If you rotate `MCP_AUTH_TOKEN` in `.env`, also re-run `claude mcp add` (for local / user scope) or update your shell export (for project scope). + +## Troubleshooting + +- **Port 5678 or 3100 already in use** — another container or process is bound. Stop it or edit the port mapping in `docker-compose.yml`. (n8n-mcp uses 3100 on the host to avoid clashing with the Next.js dev server on 3000.) +- **n8n-mcp refuses to start** — check `MCP_AUTH_TOKEN` is at least 32 chars and `N8N_API_KEY` is set. Both are required for the `mcp` profile. +- **`podman-compose` not found** — `pip install --user podman-compose`, then ensure `~/.local/bin` is on your `PATH`. +- **Can't reach n8n from n8n-mcp** — n8n-mcp uses `http://n8n:5678` (service DNS inside the compose network). Don't set `N8N_API_URL` to `localhost` in `.env`; it's overridden in the compose file. diff --git a/apps/automation/docker-compose.yml b/apps/automation/docker-compose.yml index 6b15575..b215aae 100644 --- a/apps/automation/docker-compose.yml +++ b/apps/automation/docker-compose.yml @@ -1,17 +1,46 @@ +name: strapi-automation + services: n8n: image: n8nio/n8n:latest - container_name: n8n - environment: - - GENERIC_TIMEZONE=Europe/Amsterdam - - NODE_ENV=production - - N8N_SECURE_COOKIE=false + restart: unless-stopped ports: - "5678:5678" + environment: + GENERIC_TIMEZONE: ${GENERIC_TIMEZONE:-UTC} + TZ: ${GENERIC_TIMEZONE:-UTC} + NODE_ENV: production + N8N_SECURE_COOKIE: "false" volumes: - n8n_data:/home/node/.n8n + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:5678/healthz >/dev/null 2>&1 || exit 1"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 30s + + n8n-mcp: + image: ghcr.io/czlonkowski/n8n-mcp:latest + pull_policy: always + profiles: + - mcp restart: unless-stopped + depends_on: + n8n: + condition: service_healthy + ports: + - "3100:3000" + environment: + N8N_MODE: "true" + MCP_MODE: http + N8N_API_URL: http://n8n:5678 + N8N_API_KEY: ${N8N_API_KEY} + MCP_AUTH_TOKEN: ${MCP_AUTH_TOKEN} + AUTH_TOKEN: ${MCP_AUTH_TOKEN} + PORT: "3000" + LOG_LEVEL: info volumes: n8n_data: - name: n8n_data \ No newline at end of file + name: n8n_data diff --git a/apps/automation/package.json b/apps/automation/package.json index 2c08b07..5e207c1 100644 --- a/apps/automation/package.json +++ b/apps/automation/package.json @@ -2,9 +2,18 @@ "name": "automation", "version": "0.0.0", "private": true, - "description": "Local n8n instance for workflow automation", + "description": "Local n8n instance and workflow version control for the Strapi community hub. Engine-agnostic (Docker or Podman).", "scripts": { - "dev": "docker-compose up", - "start": "docker-compose up" + "dev": "./scripts/compose.sh up -d", + "start": "./scripts/compose.sh up -d", + "n8n:up": "./scripts/compose.sh up -d", + "n8n:up:mcp": "./scripts/compose.sh --profile mcp up -d", + "n8n:down": "./scripts/compose.sh down", + "n8n:restart": "./scripts/compose.sh restart", + "n8n:restart:mcp": "./scripts/compose.sh --profile mcp restart", + "n8n:ps": "./scripts/compose.sh ps", + "n8n:logs": "./scripts/compose.sh logs -f", + "workflows:export": "node ./scripts/export-workflows.mjs", + "workflows:import": "node ./scripts/import-workflows.mjs" } } diff --git a/apps/automation/scripts/compose.sh b/apps/automation/scripts/compose.sh new file mode 100755 index 0000000..8b84d63 --- /dev/null +++ b/apps/automation/scripts/compose.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Engine-agnostic compose wrapper. Resolves compose file relative to this script +# so it works regardless of the caller's CWD. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE_FILE="$SCRIPT_DIR/../docker-compose.yml" + +resolve_engine() { + if command -v podman-compose >/dev/null 2>&1 && command -v podman >/dev/null 2>&1; then + echo "podman-compose" + return + fi + if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + echo "docker compose" + return + fi + if command -v docker-compose >/dev/null 2>&1; then + echo "docker-compose" + return + fi + cat >&2 <<'EOF' +Error: no compose engine found. + +Install one of: + - Docker Desktop (includes the "docker compose" v2 plugin) + - Podman + podman-compose: + pip install --user podman-compose +EOF + exit 1 +} + +ENGINE="$(resolve_engine)" + +# Idempotency guard: if the user is running `up` (or `up -d`) and at least one +# service in this compose file is already running, report and exit 0 instead of +# triggering a redundant reconcile (which can conflict with a foreground `dev` +# task that's already tailing logs). +if [[ "${1:-}" == "up" ]]; then + # shellcheck disable=SC2086 + RUNNING_IDS="$($ENGINE -f "$COMPOSE_FILE" ps -q 2>/dev/null || true)" + if [[ -n "$RUNNING_IDS" ]]; then + echo "[compose.sh] Stack is already running — skipping 'up'." + # shellcheck disable=SC2086 + $ENGINE -f "$COMPOSE_FILE" ps + exit 0 + fi +fi + +# shellcheck disable=SC2086 +exec $ENGINE -f "$COMPOSE_FILE" "$@" diff --git a/apps/automation/scripts/export-workflows.mjs b/apps/automation/scripts/export-workflows.mjs new file mode 100755 index 0000000..ddf7dd5 --- /dev/null +++ b/apps/automation/scripts/export-workflows.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node +// Export workflows from the local n8n instance into apps/automation/workflows//workflow.json. +// Strips instance-specific fields so diffs are clean. Preserves /README.md across re-exports. + +import { + readFileSync, + writeFileSync, + mkdirSync, + readdirSync, + rmSync, + existsSync, + statSync, +} from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const AUTOMATION_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const WORKFLOWS_DIR = join(AUTOMATION_ROOT, 'workflows'); + +loadDotEnv(join(AUTOMATION_ROOT, '.env')); + +const N8N_URL = process.env.N8N_URL || 'http://localhost:5678'; +const API_KEY = process.env.N8N_API_KEY; + +if (!API_KEY) { + console.error('Error: N8N_API_KEY is not set in apps/automation/.env'); + console.error(`Generate a key in the n8n UI: ${N8N_URL}/settings/api`); + process.exit(1); +} + +const headers = { + 'X-N8N-API-KEY': API_KEY, + Accept: 'application/json', +}; + +const STRIP_WORKFLOW_FIELDS = [ + 'id', + 'versionId', + 'createdAt', + 'updatedAt', + 'triggerCount', + 'active', + 'shared', + 'meta', + 'pinData', + 'homeProject', + 'scopes', + 'isArchived', +]; +const STRIP_TAG_FIELDS = ['id', 'createdAt', 'updatedAt']; + +function slugify(name) { + return ( + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'untitled' + ); +} + +function stripWorkflow(wf) { + const out = { ...wf }; + for (const f of STRIP_WORKFLOW_FIELDS) delete out[f]; + if (Array.isArray(out.tags)) { + out.tags = out.tags.map((t) => { + const copy = { ...t }; + for (const f of STRIP_TAG_FIELDS) delete copy[f]; + return copy; + }); + } + return out; +} + +async function listWorkflows() { + const all = []; + let cursor; + do { + const url = new URL(`${N8N_URL}/api/v1/workflows`); + url.searchParams.set('limit', '100'); + if (cursor) url.searchParams.set('cursor', cursor); + const r = await fetch(url, { headers }); + if (!r.ok) { + throw new Error(`List workflows failed: ${r.status} ${await r.text()}`); + } + const body = await r.json(); + all.push(...(body.data ?? [])); + cursor = body.nextCursor; + } while (cursor); + return all; +} + +async function getWorkflow(id) { + const r = await fetch(`${N8N_URL}/api/v1/workflows/${id}`, { headers }); + if (!r.ok) { + throw new Error(`Get workflow ${id} failed: ${r.status} ${await r.text()}`); + } + return r.json(); +} + +function loadDotEnv(path) { + if (!existsSync(path)) return; + for (const line of readFileSync(path, 'utf8').split('\n')) { + const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/); + if (!m) continue; + const val = m[2].replace(/^["']|["']$/g, ''); + if (!(m[1] in process.env)) process.env[m[1]] = val; + } +} + +async function main() { + mkdirSync(WORKFLOWS_DIR, { recursive: true }); + + console.log(`Fetching workflows from ${N8N_URL}...`); + const summaries = await listWorkflows(); + console.log(`Found ${summaries.length} workflow(s).`); + + const seenSlugs = new Set(); + const livingSlugs = new Set(); + let added = 0; + let updated = 0; + + for (const summary of summaries) { + const wf = await getWorkflow(summary.id); + let slug = slugify(wf.name); + const base = slug; + let suffix = 1; + while (seenSlugs.has(slug)) slug = `${base}-${++suffix}`; + seenSlugs.add(slug); + livingSlugs.add(slug); + + const dir = join(WORKFLOWS_DIR, slug); + mkdirSync(dir, { recursive: true }); + const out = join(dir, 'workflow.json'); + const existed = existsSync(out); + writeFileSync(out, `${JSON.stringify(stripWorkflow(wf), null, 2)}\n`); + if (existed) updated++; + else added++; + console.log(` ${existed ? 'updated' : 'added '} ${slug}/workflow.json`); + } + + let removed = 0; + let skippedWithNotes = 0; + if (existsSync(WORKFLOWS_DIR)) { + for (const entry of readdirSync(WORKFLOWS_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (livingSlugs.has(entry.name)) continue; + const dir = join(WORKFLOWS_DIR, entry.name); + const readmePath = join(dir, 'README.md'); + if (existsSync(readmePath) && statSync(readmePath).size > 0) { + console.log(` skipped ${entry.name}/ (has README.md)`); + skippedWithNotes++; + continue; + } + rmSync(dir, { recursive: true, force: true }); + console.log(` removed ${entry.name}/`); + removed++; + } + } + + console.log( + `\nSummary: ${added} added, ${updated} updated, ${removed} removed, ${skippedWithNotes} skipped-with-notes.` + ); +} + +main().catch((e) => { + console.error(e.stack ?? e.message); + process.exit(1); +}); diff --git a/apps/automation/scripts/import-workflows.mjs b/apps/automation/scripts/import-workflows.mjs new file mode 100755 index 0000000..006aace --- /dev/null +++ b/apps/automation/scripts/import-workflows.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +// Import workflows from apps/automation/workflows//workflow.json into the local n8n instance. +// Creates new workflows or updates existing ones in place (matched by name). Always imports as inactive. + +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const AUTOMATION_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const WORKFLOWS_DIR = join(AUTOMATION_ROOT, 'workflows'); + +loadDotEnv(join(AUTOMATION_ROOT, '.env')); + +const N8N_URL = process.env.N8N_URL || 'http://localhost:5678'; +const API_KEY = process.env.N8N_API_KEY; + +if (!API_KEY) { + console.error('Error: N8N_API_KEY is not set in apps/automation/.env'); + console.error(`Generate a key in the n8n UI: ${N8N_URL}/settings/api`); + process.exit(1); +} + +const headers = { + 'X-N8N-API-KEY': API_KEY, + Accept: 'application/json', + 'Content-Type': 'application/json', +}; + +// n8n's workflow API accepts this whitelist. Tag associations are managed separately and skipped here. +const ALLOWED_FIELDS = ['name', 'nodes', 'connections', 'settings', 'staticData']; + +function sanitizePayload(wf) { + const out = {}; + for (const f of ALLOWED_FIELDS) { + if (wf[f] !== undefined) out[f] = wf[f]; + } + // n8n requires a settings object even if empty. + if (!out.settings || typeof out.settings !== 'object') out.settings = {}; + return out; +} + +async function listWorkflows() { + const all = []; + let cursor; + do { + const url = new URL(`${N8N_URL}/api/v1/workflows`); + url.searchParams.set('limit', '100'); + if (cursor) url.searchParams.set('cursor', cursor); + const r = await fetch(url, { headers }); + if (!r.ok) { + throw new Error(`List workflows failed: ${r.status} ${await r.text()}`); + } + const body = await r.json(); + all.push(...(body.data ?? [])); + cursor = body.nextCursor; + } while (cursor); + return all; +} + +async function createWorkflow(payload) { + const r = await fetch(`${N8N_URL}/api/v1/workflows`, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + if (!r.ok) throw new Error(`Create failed: ${r.status} ${await r.text()}`); + return r.json(); +} + +async function updateWorkflow(id, payload) { + const r = await fetch(`${N8N_URL}/api/v1/workflows/${id}`, { + method: 'PUT', + headers, + body: JSON.stringify(payload), + }); + if (!r.ok) throw new Error(`Update ${id} failed: ${r.status} ${await r.text()}`); + return r.json(); +} + +function warnAboutCredentials(wf) { + for (const node of wf.nodes ?? []) { + if (!node.credentials) continue; + for (const [type, ref] of Object.entries(node.credentials)) { + const label = ref?.name || ref?.id || '(unknown)'; + console.warn( + ` note: node "${node.name}" references credential "${label}" (${type}) — verify it exists in the instance` + ); + } + } +} + +function loadDotEnv(path) { + if (!existsSync(path)) return; + for (const line of readFileSync(path, 'utf8').split('\n')) { + const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/); + if (!m) continue; + const val = m[2].replace(/^["']|["']$/g, ''); + if (!(m[1] in process.env)) process.env[m[1]] = val; + } +} + +async function main() { + if (!existsSync(WORKFLOWS_DIR)) { + console.log(`No workflows/ directory at ${WORKFLOWS_DIR}. Nothing to import.`); + return; + } + + const dirs = readdirSync(WORKFLOWS_DIR, { withFileTypes: true }).filter((e) => e.isDirectory()); + if (dirs.length === 0) { + console.log('No workflow directories to import.'); + return; + } + + console.log(`Looking up existing workflows at ${N8N_URL}...`); + const existing = await listWorkflows(); + const byName = new Map(existing.map((wf) => [wf.name, wf])); + + let created = 0; + let updated = 0; + let failed = 0; + + for (const entry of dirs) { + const path = join(WORKFLOWS_DIR, entry.name, 'workflow.json'); + if (!existsSync(path)) continue; + + let wf; + try { + wf = JSON.parse(readFileSync(path, 'utf8')); + } catch (e) { + console.error(` failed ${entry.name}/: parse error — ${e.message}`); + failed++; + continue; + } + + const payload = sanitizePayload(wf); + if (!payload.name) { + console.error(` failed ${entry.name}/: workflow.json missing "name"`); + failed++; + continue; + } + + try { + const match = byName.get(payload.name); + if (match) { + await updateWorkflow(match.id, payload); + console.log(` updated ${payload.name}`); + updated++; + } else { + await createWorkflow(payload); + console.log(` created ${payload.name}`); + created++; + } + warnAboutCredentials(wf); + } catch (e) { + console.error(` failed ${payload.name}: ${e.message}`); + failed++; + } + } + + console.log(`\nSummary: ${created} created, ${updated} updated, ${failed} failed.`); + if (failed > 0) process.exit(1); +} + +main().catch((e) => { + console.error(e.stack ?? e.message); + process.exit(1); +}); diff --git a/apps/automation/workflows/.gitkeep b/apps/automation/workflows/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/automation/workflows/cleanup-new-label-after-60d/workflow.json b/apps/automation/workflows/cleanup-new-label-after-60d/workflow.json new file mode 100644 index 0000000..dba400a --- /dev/null +++ b/apps/automation/workflows/cleanup-new-label-after-60d/workflow.json @@ -0,0 +1,222 @@ +{ + "name": "cleanup-new-label-after-60d", + "description": null, + "nodes": [ + { + "id": "sched-1", + "name": "Daily at 03:00 UTC", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 240, + 300 + ], + "parameters": { + "rule": { + "interval": [ + { + "field": "cronExpression", + "expression": "0 3 * * *" + } + ] + } + }, + "notes": "Runs once a day. Offset from package-info plugin's 02:00 stats sync so we don't hammer Strapi at the same time." + }, + { + "id": "compute-cutoff-1", + "name": "Compute Cutoff Date", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 460, + 300 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "c1", + "name": "cutoff_iso", + "type": "string", + "value": "={{ $now.minus({days: 60}).toISO() }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + }, + "notes": "60 days = 2 months per issue #21." + }, + { + "id": "fetch-stale-1", + "name": "Fetch Stale 'New' Packages", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 680, + 300 + ], + "parameters": { + "method": "GET", + "url": "={{ $env.STRAPI_BASE_URL }}/api/packages", + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "filters[labels][is_new][$eq]", + "value": "true" + }, + { + "name": "filters[publishedAt][$lt]", + "value": "={{ $json.cutoff_iso }}" + }, + { + "name": "fields[0]", + "value": "id" + }, + { + "name": "fields[1]", + "value": "name" + }, + { + "name": "fields[2]", + "value": "publishedAt" + }, + { + "name": "populate[labels]", + "value": "true" + }, + { + "name": "pagination[limit]", + "value": "100" + } + ] + }, + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "=Bearer {{ $env.STRAPI_API_TOKEN }}" + } + ] + }, + "options": {} + }, + "retryOnFail": true, + "notes": "TODO: add pagination loop once we expect >100 stale packages. Depends on #21's 'is_new' field being added to the package 'labels' component (not yet present in the schema)." + }, + { + "id": "split-1", + "name": "Split Into Items", + "type": "n8n-nodes-base.splitOut", + "typeVersion": 1, + "position": [ + 900, + 300 + ], + "parameters": { + "fieldToSplitOut": "data", + "options": {} + } + }, + { + "id": "patch-1", + "name": "Strip 'is_new' Label", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 1120, + 300 + ], + "parameters": { + "method": "PUT", + "url": "={{ $env.STRAPI_BASE_URL }}/api/packages/{{ $json.id }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "=Bearer {{ $env.STRAPI_API_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "contentType": "json", + "jsonBody": "={\n \"data\": {\n \"labels\": {\n \"is_new\": false\n }\n }\n}", + "options": {} + }, + "retryOnFail": true, + "onError": "continueRegularOutput", + "notes": "Continue on error so one bad entry doesn't block the rest of the batch." + } + ], + "connections": { + "Daily at 03:00 UTC": { + "main": [ + [ + { + "node": "Compute Cutoff Date", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compute Cutoff Date": { + "main": [ + [ + { + "node": "Fetch Stale 'New' Packages", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Stale 'New' Packages": { + "main": [ + [ + { + "node": "Split Into Items", + "type": "main", + "index": 0 + } + ] + ] + }, + "Split Into Items": { + "main": [ + [ + { + "node": "Strip 'is_new' Label", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true, + "saveExecutionProgress": true, + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "errorWorkflow": "SYm72hu6NXus7iah" + }, + "staticData": null, + "activeVersionId": null, + "versionCounter": 1, + "tags": [], + "activeVersion": null +} diff --git a/apps/automation/workflows/error-handler/workflow.json b/apps/automation/workflows/error-handler/workflow.json new file mode 100644 index 0000000..ff702dd --- /dev/null +++ b/apps/automation/workflows/error-handler/workflow.json @@ -0,0 +1,152 @@ +{ + "name": "error-handler", + "description": null, + "nodes": [ + { + "id": "sticky-1", + "name": "About", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 120, + 60 + ], + "parameters": { + "content": "## Shared Error Handler — Community Hub Scope Only\n\n**Opt-in routing:** this workflow only fires for workflows that explicitly reference its id via `settings.errorWorkflow`. The Error Trigger node does NOT broadcast-receive from other workflows on the same n8n instance.\n\n**Defensive allowlist:** the `Scope Check` node additionally filters on workflow name against the community-hub allowlist below. Any failure from a workflow outside this list is silently dropped — so accidental or copy-paste wiring from unrelated workflows can't spam `#integration-marketplace`. Update the allowlist in the Scope Check node when adding new community-hub workflows.\n\n**Behaviour:** formats a concise Slack message with the failed workflow's name, the failing node, the error message, and a link to the execution in the n8n UI — then posts it to `#integration-marketplace`.\n\nStrapi side-effects (e.g. marking a stuck scan as failed) are handled by `scan-timeout-sweeper`, not here — keeps the error handler's dependencies minimal so it rarely fails itself.", + "height": 380, + "width": 760, + "color": 4 + } + }, + { + "id": "error-trigger-1", + "name": "On Workflow Error", + "type": "n8n-nodes-base.errorTrigger", + "typeVersion": 1, + "position": [ + 160, + 500 + ], + "parameters": {} + }, + { + "id": "scope-check-1", + "name": "Scope Check", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 380, + 500 + ], + "parameters": { + "language": "javaScript", + "jsCode": "// Allowlist of community-hub workflow names. Keep in sync with apps/automation/workflows/*.\n// Failures from any workflow NOT in this list are silently dropped — defensive filter so\n// unrelated workflows on the same n8n instance can't accidentally alert into our channel.\nconst ALLOWLIST = new Set([\n 'security-scan',\n 'scan-timeout-sweeper',\n 'plugin-submission-received',\n 'plugin-approved',\n 'plugin-declined',\n 'plugin-changes-requested',\n 'template-submission-received',\n 'template-approved',\n 'template-declined',\n 'cleanup-new-label-after-60d',\n 'render-email (shared sub-workflow)',\n]);\n\nconst payload = $input.first().json;\nconst name = payload?.workflow?.name;\n\nif (!name || !ALLOWLIST.has(name)) {\n // Silently drop — return no items, downstream nodes don't run.\n return [];\n}\nreturn [{ json: payload }];" + }, + "onError": "continueRegularOutput", + "notes": "Allowlist filter. Update the ALLOWLIST when adding new workflows to the community-hub scope." + }, + { + "id": "format-1", + "name": "Format Alert", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 600, + 500 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "e1", + "name": "text", + "type": "string", + "value": "=:rotating_light: *Workflow failure*: `{{ $json.workflow.name }}`\nNode: `{{ $json.execution.lastNodeExecuted || 'unknown' }}`\nError: {{ $json.execution.error.message || 'no message' }}\nMode: {{ $json.execution.mode }}\nExecution: {{ $json.execution.url }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "slack-1", + "name": "Post to #integration-marketplace", + "type": "n8n-nodes-base.slack", + "typeVersion": 2.4, + "position": [ + 820, + 500 + ], + "parameters": { + "resource": "message", + "operation": "post", + "select": "channel", + "channelId": { + "__rl": true, + "mode": "name", + "value": "integration-marketplace" + }, + "text": "={{ $json.text }}", + "otherOptions": {} + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "notes": "If Slack is down the alert is silently dropped — no cascading failures. Execution history in n8n still records the error.", + "webhookId": "c3a8e4f1-7d9b-4c2a-b510-6a2e3f5c8b0a" + } + ], + "connections": { + "On Workflow Error": { + "main": [ + [ + { + "node": "Scope Check", + "type": "main", + "index": 0 + } + ] + ] + }, + "Scope Check": { + "main": [ + [ + { + "node": "Format Alert", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Alert": { + "main": [ + [ + { + "node": "Post to #integration-marketplace", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true, + "saveExecutionProgress": true, + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false + }, + "staticData": null, + "activeVersionId": null, + "versionCounter": 1, + "tags": [], + "activeVersion": null +} diff --git a/apps/automation/workflows/plugin-approved/workflow.json b/apps/automation/workflows/plugin-approved/workflow.json new file mode 100644 index 0000000..b23e03b --- /dev/null +++ b/apps/automation/workflows/plugin-approved/workflow.json @@ -0,0 +1,270 @@ +{ + "name": "plugin-approved", + "description": null, + "nodes": [ + { + "id": "webhook-1", + "name": "Webhook: Package Approved", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 240, + 300 + ], + "parameters": { + "httpMethod": "POST", + "path": "strapi/plugin-approved", + "authentication": "headerAuth", + "responseMode": "onReceived", + "responseData": "noData", + "options": {} + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true, + "notes": "Fired by the CMS moderation plugin after plugin-submission.promoteToPackage. Payload includes package_id + package_slug (may be null).", + "webhookId": "5b4adc60-df90-4ca1-aca8-c2ea4ca513d7" + }, + { + "id": "extract-1", + "name": "Extract Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 460, + 300 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "a1", + "name": "package_id", + "type": "string", + "value": "={{ $json.body.package_id }}" + }, + { + "id": "a2", + "name": "package_name", + "type": "string", + "value": "={{ $json.body.plugin_name }}" + }, + { + "id": "a3", + "name": "package_slug", + "type": "string", + "value": "={{ $json.body.package_slug || '' }}" + }, + { + "id": "a4", + "name": "author_email", + "type": "string", + "value": "={{ $json.body.owner_email }}" + }, + { + "id": "a5", + "name": "author_name", + "type": "string", + "value": "={{ $json.body.owner_name }}" + }, + { + "id": "a6", + "name": "marketplace_link", + "type": "string", + "value": "={{ $json.body.marketplace_link || '' }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "build-email-1", + "name": "Build Email Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 680, + 200 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "b1", + "name": "template_key", + "type": "string", + "value": "plugin-approved" + }, + { + "id": "b2", + "name": "to_email", + "type": "string", + "value": "={{ $json.author_email }}" + }, + { + "id": "b3", + "name": "to_name", + "type": "string", + "value": "={{ $json.author_name }}" + }, + { + "id": "b4", + "name": "variables", + "type": "object", + "value": "={{ { package_name: $json.package_name, author_name: $json.author_name, marketplace_link: $json.marketplace_link } }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "send-email-1", + "name": "Send Developer Email", + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.1, + "position": [ + 900, + 200 + ], + "parameters": { + "source": "database", + "workflowId": { + "__rl": true, + "mode": "id", + "value": "1MgLRIjI37T2Kd5W" + }, + "mode": "once", + "options": {} + }, + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000 + }, + { + "id": "build-slack-1", + "name": "Build Slack Message", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 680, + 400 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "s1", + "name": "text", + "type": "string", + "value": "=:white_check_mark: *Plugin* approved & published: *{{ $json.package_name }}* by {{ $json.author_name }}\nLive: {{ $json.marketplace_link }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "slack-1", + "name": "Notify #integration-marketplace", + "type": "n8n-nodes-base.slack", + "typeVersion": 2.4, + "position": [ + 900, + 400 + ], + "parameters": { + "resource": "message", + "operation": "post", + "select": "channel", + "channelId": { + "__rl": true, + "mode": "name", + "value": "integration-marketplace" + }, + "text": "={{ $json.text }}", + "otherOptions": {} + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "webhookId": "8d87996f-de91-4a91-b61c-cefe6cc3bb1c" + } + ], + "connections": { + "Webhook: Package Approved": { + "main": [ + [ + { + "node": "Extract Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Payload": { + "main": [ + [ + { + "node": "Build Email Payload", + "type": "main", + "index": 0 + }, + { + "node": "Build Slack Message", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Email Payload": { + "main": [ + [ + { + "node": "Send Developer Email", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Slack Message": { + "main": [ + [ + { + "node": "Notify #integration-marketplace", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true, + "saveExecutionProgress": true, + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "errorWorkflow": "SYm72hu6NXus7iah" + }, + "staticData": null, + "activeVersionId": null, + "versionCounter": 1, + "tags": [], + "activeVersion": null +} diff --git a/apps/automation/workflows/plugin-changes-requested/workflow.json b/apps/automation/workflows/plugin-changes-requested/workflow.json new file mode 100644 index 0000000..d760800 --- /dev/null +++ b/apps/automation/workflows/plugin-changes-requested/workflow.json @@ -0,0 +1,201 @@ +{ + "name": "plugin-changes-requested", + "description": null, + "nodes": [ + { + "id": "webhook-1", + "name": "Webhook: Changes Requested", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 240, + 300 + ], + "parameters": { + "httpMethod": "POST", + "path": "strapi/plugin-changes-requested", + "authentication": "headerAuth", + "responseMode": "onReceived", + "responseData": "noData", + "options": {} + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true, + "notes": "Fired by the CMS moderation plugin after plugin-submission.rejectOrRequestChanges with status='changes_requested'. Payload: flat { submissionId, plugin_name, owner_email, reason, feedback, dashboard_link, ... }.", + "webhookId": "2f6e1cec-6e97-4e6d-b9f6-8b3fbda1e310" + }, + { + "id": "extract-1", + "name": "Extract Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 460, + 300 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "a1", + "name": "package_id", + "type": "string", + "value": "={{ $json.body.submissionId }}" + }, + { + "id": "a2", + "name": "package_name", + "type": "string", + "value": "={{ $json.body.plugin_name }}" + }, + { + "id": "a3", + "name": "author_email", + "type": "string", + "value": "={{ $json.body.owner_email }}" + }, + { + "id": "a4", + "name": "author_name", + "type": "string", + "value": "={{ $json.body.owner_name }}" + }, + { + "id": "a5", + "name": "reviewer_feedback", + "type": "string", + "value": "={{ $json.body.feedback || $json.body.reason || '' }}" + }, + { + "id": "a6", + "name": "dashboard_link", + "type": "string", + "value": "={{ $json.body.dashboard_link }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "build-email-1", + "name": "Build Email Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 680, + 300 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "b1", + "name": "template_key", + "type": "string", + "value": "plugin-changes-requested" + }, + { + "id": "b2", + "name": "to_email", + "type": "string", + "value": "={{ $json.author_email }}" + }, + { + "id": "b3", + "name": "to_name", + "type": "string", + "value": "={{ $json.author_name }}" + }, + { + "id": "b4", + "name": "variables", + "type": "object", + "value": "={{ { package_name: $json.package_name, author_name: $json.author_name, reviewer_feedback: $json.reviewer_feedback } }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "send-email-1", + "name": "Send Developer Email", + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.1, + "position": [ + 900, + 300 + ], + "parameters": { + "source": "database", + "workflowId": { + "__rl": true, + "mode": "id", + "value": "1MgLRIjI37T2Kd5W" + }, + "mode": "once", + "options": {} + }, + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000 + } + ], + "connections": { + "Webhook: Changes Requested": { + "main": [ + [ + { + "node": "Extract Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Payload": { + "main": [ + [ + { + "node": "Build Email Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Email Payload": { + "main": [ + [ + { + "node": "Send Developer Email", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true, + "saveExecutionProgress": true, + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "errorWorkflow": "SYm72hu6NXus7iah" + }, + "staticData": null, + "activeVersionId": null, + "versionCounter": 1, + "tags": [], + "activeVersion": null +} diff --git a/apps/automation/workflows/plugin-declined/workflow.json b/apps/automation/workflows/plugin-declined/workflow.json new file mode 100644 index 0000000..2d59b45 --- /dev/null +++ b/apps/automation/workflows/plugin-declined/workflow.json @@ -0,0 +1,189 @@ +{ + "name": "plugin-declined", + "description": null, + "nodes": [ + { + "id": "webhook-1", + "name": "Webhook: Package Declined", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 240, + 300 + ], + "parameters": { + "httpMethod": "POST", + "path": "strapi/plugin-declined", + "authentication": "headerAuth", + "responseMode": "onReceived", + "responseData": "noData", + "options": {} + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true, + "notes": "Fired by the CMS moderation plugin after plugin-submission.rejectOrRequestChanges with status='rejected'. Payload: flat { submissionId, plugin_name, owner_email, reason, feedback, ... }.", + "webhookId": "37f3f735-2a1c-4c22-89e0-237bb2b5a245" + }, + { + "id": "extract-1", + "name": "Extract Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 460, + 300 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "a1", + "name": "package_name", + "type": "string", + "value": "={{ $json.body.plugin_name }}" + }, + { + "id": "a2", + "name": "author_email", + "type": "string", + "value": "={{ $json.body.owner_email }}" + }, + { + "id": "a3", + "name": "author_name", + "type": "string", + "value": "={{ $json.body.owner_name }}" + }, + { + "id": "a4", + "name": "decline_reason", + "type": "string", + "value": "={{ $json.body.reason || $json.body.feedback || '' }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "build-email-1", + "name": "Build Email Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 680, + 300 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "b1", + "name": "template_key", + "type": "string", + "value": "plugin-declined" + }, + { + "id": "b2", + "name": "to_email", + "type": "string", + "value": "={{ $json.author_email }}" + }, + { + "id": "b3", + "name": "to_name", + "type": "string", + "value": "={{ $json.author_name }}" + }, + { + "id": "b4", + "name": "variables", + "type": "object", + "value": "={{ { package_name: $json.package_name, author_name: $json.author_name, decline_reason: $json.decline_reason } }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "send-email-1", + "name": "Send Developer Email", + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.1, + "position": [ + 900, + 300 + ], + "parameters": { + "source": "database", + "workflowId": { + "__rl": true, + "mode": "id", + "value": "1MgLRIjI37T2Kd5W" + }, + "mode": "once", + "options": {} + }, + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000 + } + ], + "connections": { + "Webhook: Package Declined": { + "main": [ + [ + { + "node": "Extract Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Payload": { + "main": [ + [ + { + "node": "Build Email Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Email Payload": { + "main": [ + [ + { + "node": "Send Developer Email", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true, + "saveExecutionProgress": true, + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "errorWorkflow": "SYm72hu6NXus7iah" + }, + "staticData": null, + "activeVersionId": null, + "versionCounter": 1, + "tags": [], + "activeVersion": null +} diff --git a/apps/automation/workflows/plugin-submission-received/workflow.json b/apps/automation/workflows/plugin-submission-received/workflow.json new file mode 100644 index 0000000..ee83707 --- /dev/null +++ b/apps/automation/workflows/plugin-submission-received/workflow.json @@ -0,0 +1,271 @@ +{ + "name": "plugin-submission-received", + "description": null, + "nodes": [ + { + "id": "webhook-1", + "name": "Webhook: Submission Received", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 240, + 300 + ], + "parameters": { + "httpMethod": "POST", + "path": "strapi/plugin-submission-received", + "authentication": "headerAuth", + "responseMode": "onReceived", + "responseData": "noData", + "options": {} + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true, + "notes": "Fired by the CMS moderation plugin after plugin-submission.createSubmission. Payload: flat { submissionId, plugin_name, owner_name, owner_email, repository_url, dashboard_link, ... }.", + "webhookId": "e9741c13-e5b0-40a4-99a5-9a30031ea8a7" + }, + { + "id": "extract-1", + "name": "Extract Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 460, + 300 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "a1", + "name": "package_id", + "type": "string", + "value": "={{ $json.body.submissionId }}" + }, + { + "id": "a2", + "name": "package_name", + "type": "string", + "value": "={{ $json.body.plugin_name }}" + }, + { + "id": "a3", + "name": "git_repository", + "type": "string", + "value": "={{ $json.body.repository_url }}" + }, + { + "id": "a4", + "name": "author_email", + "type": "string", + "value": "={{ $json.body.owner_email }}" + }, + { + "id": "a5", + "name": "author_name", + "type": "string", + "value": "={{ $json.body.owner_name }}" + }, + { + "id": "a6", + "name": "dashboard_link", + "type": "string", + "value": "={{ $json.body.dashboard_link }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "build-email-1", + "name": "Build Email Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 680, + 200 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "b1", + "name": "template_key", + "type": "string", + "value": "plugin-submission-received" + }, + { + "id": "b2", + "name": "to_email", + "type": "string", + "value": "={{ $json.author_email }}" + }, + { + "id": "b3", + "name": "to_name", + "type": "string", + "value": "={{ $json.author_name }}" + }, + { + "id": "b4", + "name": "variables", + "type": "object", + "value": "={{ { package_name: $json.package_name, author_name: $json.author_name, git_repository: $json.git_repository } }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "send-email-1", + "name": "Send Developer Email", + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.1, + "position": [ + 900, + 200 + ], + "parameters": { + "source": "database", + "workflowId": { + "__rl": true, + "mode": "id", + "value": "1MgLRIjI37T2Kd5W" + }, + "mode": "once", + "options": {} + }, + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "notes": "Calls render-email sub-workflow. TODO when n8n UI available: bump to typeVersion 1.3 and use resourceMapper for typed inputs." + }, + { + "id": "build-slack-1", + "name": "Build Slack Message", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 680, + 400 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "s1", + "name": "text", + "type": "string", + "value": "=:package: New *plugin* submission received: *{{ $json.package_name }}* by {{ $json.author_name }}\nRepo: {{ $json.git_repository }}\nReview: {{ $json.dashboard_link }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "slack-1", + "name": "Notify #integration-marketplace", + "type": "n8n-nodes-base.slack", + "typeVersion": 2.4, + "position": [ + 900, + 400 + ], + "parameters": { + "resource": "message", + "operation": "post", + "select": "channel", + "channelId": { + "__rl": true, + "mode": "name", + "value": "integration-marketplace" + }, + "text": "={{ $json.text }}", + "otherOptions": {} + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "webhookId": "e70d036e-994f-4429-ab41-1c5e88539b7d" + } + ], + "connections": { + "Webhook: Submission Received": { + "main": [ + [ + { + "node": "Extract Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Payload": { + "main": [ + [ + { + "node": "Build Email Payload", + "type": "main", + "index": 0 + }, + { + "node": "Build Slack Message", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Email Payload": { + "main": [ + [ + { + "node": "Send Developer Email", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Slack Message": { + "main": [ + [ + { + "node": "Notify #integration-marketplace", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true, + "saveExecutionProgress": true, + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "errorWorkflow": "SYm72hu6NXus7iah" + }, + "staticData": null, + "activeVersionId": null, + "versionCounter": 1, + "tags": [], + "activeVersion": null +} diff --git a/apps/automation/workflows/render-email-shared-sub-workflow/workflow.json b/apps/automation/workflows/render-email-shared-sub-workflow/workflow.json new file mode 100644 index 0000000..98ebe58 --- /dev/null +++ b/apps/automation/workflows/render-email-shared-sub-workflow/workflow.json @@ -0,0 +1,193 @@ +{ + "name": "render-email (shared sub-workflow)", + "description": null, + "nodes": [ + { + "id": "trig-1", + "name": "When Called by Another Workflow", + "type": "n8n-nodes-base.executeWorkflowTrigger", + "typeVersion": 1.1, + "position": [ + 240, + 300 + ], + "parameters": { + "inputSource": "passthrough", + "workflowInputs": { + "values": [ + { + "name": "template_key", + "type": "string" + }, + { + "name": "to_email", + "type": "string" + }, + { + "name": "to_name", + "type": "string" + }, + { + "name": "variables", + "type": "object" + } + ] + } + } + }, + { + "id": "fetch-1", + "name": "Fetch Template From Strapi", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.4, + "position": [ + 460, + 300 + ], + "parameters": { + "method": "GET", + "url": "={{ $env.STRAPI_BASE_URL }}/api/email-templates", + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "filters[key][$eq]", + "value": "={{ $json.template_key }}" + }, + { + "name": "pagination[limit]", + "value": "1" + } + ] + }, + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "=Bearer {{ $env.STRAPI_API_TOKEN }}" + } + ] + }, + "options": {} + }, + "retryOnFail": true, + "notes": "TODO: swap env-var auth for an n8n 'Strapi' credential once the CMS token is issued" + }, + { + "id": "render-1", + "name": "Render Template", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 680, + 300 + ], + "parameters": { + "language": "javaScript", + "jsCode": "const input = $('When Called by Another Workflow').first().json;\nconst response = $input.first().json;\nconst tpl = (response.data && response.data[0]) || null;\n\nif (!tpl) {\n throw new Error(`No email-template found with key=${input.template_key}`);\n}\n\nconst attrs = tpl.attributes || tpl;\nconst vars = input.variables || {};\n\nfunction interpolate(str) {\n if (typeof str !== 'string') return str;\n return str.replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (_, key) => {\n const val = key.split('.').reduce((acc, k) => (acc == null ? undefined : acc[k]), vars);\n return val == null ? '' : String(val);\n });\n}\n\nreturn [{\n json: {\n to_email: input.to_email,\n to_name: input.to_name,\n subject: interpolate(attrs.subject),\n body: interpolate(typeof attrs.body === 'string' ? attrs.body : JSON.stringify(attrs.body)),\n from_name: attrs.from_name || 'Strapi Community',\n reply_to: attrs.reply_to || null,\n template_key: input.template_key,\n },\n}];" + } + }, + { + "id": "wrap-1", + "name": "Wrap In Branded Shell", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 900, + 300 + ], + "parameters": { + "language": "javaScript", + "jsCode": "const item = $input.first().json;\n\nconst shell = `\n\n\n
\n Strapi Community\n
\n
${item.body}
\n
\n You're receiving this because you interacted with the Strapi community hub.\n
\n\n`;\n\nreturn [{ json: { ...item, html_body: shell } }];" + } + }, + { + "id": "send-1", + "name": "Send via SendGrid", + "type": "n8n-nodes-base.sendGrid", + "typeVersion": 1, + "position": [ + 1120, + 300 + ], + "parameters": { + "resource": "mail", + "operation": "send", + "fromEmail": "community@strapi.io", + "fromName": "={{ $json.from_name }}", + "toEmail": "={{ $json.to_email }}", + "subject": "={{ $json.subject }}", + "dynamicTemplate": false, + "contentType": "text/html", + "contentValue": "={{ $json.html_body }}", + "additionalFields": { + "replyToEmail": "={{ $json.reply_to }}" + } + }, + "retryOnFail": true, + "notes": "Uses community@strapi.io sender. Requires SendGrid credential configured in n8n." + } + ], + "connections": { + "When Called by Another Workflow": { + "main": [ + [ + { + "node": "Fetch Template From Strapi", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Template From Strapi": { + "main": [ + [ + { + "node": "Render Template", + "type": "main", + "index": 0 + } + ] + ] + }, + "Render Template": { + "main": [ + [ + { + "node": "Wrap In Branded Shell", + "type": "main", + "index": 0 + } + ] + ] + }, + "Wrap In Branded Shell": { + "main": [ + [ + { + "node": "Send via SendGrid", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true, + "saveExecutionProgress": true, + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "errorWorkflow": "SYm72hu6NXus7iah" + }, + "staticData": null, + "activeVersionId": null, + "versionCounter": 2, + "tags": [], + "activeVersion": null +} diff --git a/apps/automation/workflows/scan-timeout-sweeper/workflow.json b/apps/automation/workflows/scan-timeout-sweeper/workflow.json new file mode 100644 index 0000000..9fd2448 --- /dev/null +++ b/apps/automation/workflows/scan-timeout-sweeper/workflow.json @@ -0,0 +1,317 @@ +{ + "name": "scan-timeout-sweeper", + "description": null, + "nodes": [ + { + "id": "sticky-1", + "name": "About", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 120, + 60 + ], + "parameters": { + "content": "## Scan Timeout Sweeper\n\nRuns every 15 minutes. Finds submissions whose `security_scan_status='running'` with `updatedAt` older than 30 minutes \u2014 assumes they're stuck (workflow killed mid-run, n8n container restart, unrecoverable crash) \u2014 and PATCHes each to `security_scan_status='failed'` via the moderation content-api write-back route.\n\nHandles both plugin-submissions and template-submissions. Posts a Slack summary to `#integration-marketplace` when any stuck scans are swept, silent otherwise.\n\n**Required n8n env vars:**\n- `STRAPI_API_URL`\n\n**Required credentials:**\n- `httpHeaderAuth` \u2014 Strapi API token (shared with security-scan)", + "height": 300, + "width": 760, + "color": 6 + } + }, + { + "id": "schedule-1", + "name": "Every 15 Minutes", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 160, + 460 + ], + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes", + "minutesInterval": 15 + } + ] + } + } + }, + { + "id": "compute-cutoff-1", + "name": "Compute Cutoff", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 380, + 460 + ], + "parameters": { + "language": "javaScript", + "jsCode": "try {\n const cutoff = new Date(Date.now() - 30 * 60 * 1000).toISOString();\n return [{ json: { cutoff } }];\n} catch (err) {\n return [{ json: { cutoff: null, _node_error: err.message || String(err) } }];\n}" + }, + "onError": "continueRegularOutput" + }, + { + "id": "fetch-plugins-1", + "name": "Find Stale Plugin Scans", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 600, + 360 + ], + "parameters": { + "method": "GET", + "url": "={{ $env.STRAPI_API_URL }}/api/moderation/plugin-submissions/stale-scans", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "cutoff", + "value": "={{ $json.cutoff }}" + } + ] + }, + "options": { + "timeout": 15000, + "response": { + "response": { + "neverError": true, + "responseFormat": "json" + } + } + } + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "notes": "Content-api route (API-token auth) added to the moderation plugin specifically for the sweeper. Filters by security_scan_status='running' + security_scan_started_at < cutoff." + }, + { + "id": "fetch-templates-1", + "name": "Find Stale Template Scans", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 600, + 560 + ], + "parameters": { + "method": "GET", + "url": "={{ $env.STRAPI_API_URL }}/api/moderation/template-submissions/stale-scans", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "cutoff", + "value": "={{ $('Compute Cutoff').item.json.cutoff }}" + } + ] + }, + "options": { + "timeout": 15000, + "response": { + "response": { + "neverError": true, + "responseFormat": "json" + } + } + } + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000 + }, + { + "id": "collect-1", + "name": "Collect Stuck Scans", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 820, + 460 + ], + "parameters": { + "language": "javaScript", + "jsCode": "try {\n const pluginRes = $('Find Stale Plugin Scans').first().json || {};\n const templateRes = $('Find Stale Template Scans').first().json || {};\n\n function extract(res, kind) {\n const items = Array.isArray(res?.data) ? res.data : [];\n return items\n .map((item) => ({\n kind,\n documentId: item.documentId || item.id,\n name: item.plugin_name || item.template_name || 'unknown',\n started_at: item.security_scan_started_at || null,\n }))\n .filter((x) => x.documentId);\n }\n\n const stuck = [\n ...extract(pluginRes, 'plugin'),\n ...extract(templateRes, 'template'),\n ];\n\n if (stuck.length === 0) return [];\n return stuck.map((s) => ({ json: s }));\n} catch (err) {\n return [{ json: { _node_error: err.message || String(err) } }];\n}" + }, + "onError": "continueRegularOutput", + "notes": "Returns one item per stuck submission so downstream runs once per. Returns [] (no items) when everything's healthy \u2014 downstream branches short-circuit." + }, + { + "id": "mark-failed-1", + "name": "Mark Failed in Strapi", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1040, + 460 + ], + "parameters": { + "method": "POST", + "url": "={{ $env.STRAPI_API_URL }}/api/moderation/{{ $json.kind === 'template' ? 'template-submissions' : 'plugin-submissions' }}/{{ $json.documentId }}/security-scan-result", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendBody": true, + "contentType": "json", + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ stage: 'summary', status: 'failed', result: { runAt: new Date().toISOString(), error: 'Scan exceeded the 30-minute timeout and was reaped by scan-timeout-sweeper.', passed: false, reaped_at: new Date().toISOString(), started_at: $json.started_at } }) }}", + "options": { + "timeout": 15000 + } + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000 + }, + { + "id": "aggregate-1", + "name": "Aggregate Swept Count", + "type": "n8n-nodes-base.aggregate", + "typeVersion": 1, + "position": [ + 1260, + 460 + ], + "parameters": { + "aggregate": "aggregateAllItemData", + "options": {} + } + }, + { + "id": "slack-1", + "name": "Notify #integration-marketplace", + "type": "n8n-nodes-base.slack", + "typeVersion": 2.4, + "position": [ + 1480, + 460 + ], + "parameters": { + "resource": "message", + "operation": "post", + "select": "channel", + "channelId": { + "__rl": true, + "mode": "name", + "value": "integration-marketplace" + }, + "text": "=:broom: Swept {{ $json.data.length }} stuck security scan(s) past the 30-minute timeout.\n{{ $json.data.map(s => `\u2022 ${s.kind}: ${s.name} (${s.documentId}) \u2014 started ${s.started_at}`).join('\\n') }}", + "otherOptions": {} + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "webhookId": "77a3ebc1-9f2d-4e58-b1c3-0fa4d9e7b2c1" + } + ], + "connections": { + "Every 15 Minutes": { + "main": [ + [ + { + "node": "Compute Cutoff", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compute Cutoff": { + "main": [ + [ + { + "node": "Find Stale Plugin Scans", + "type": "main", + "index": 0 + }, + { + "node": "Find Stale Template Scans", + "type": "main", + "index": 0 + } + ] + ] + }, + "Find Stale Plugin Scans": { + "main": [ + [ + { + "node": "Collect Stuck Scans", + "type": "main", + "index": 0 + } + ] + ] + }, + "Find Stale Template Scans": { + "main": [ + [ + { + "node": "Collect Stuck Scans", + "type": "main", + "index": 0 + } + ] + ] + }, + "Collect Stuck Scans": { + "main": [ + [ + { + "node": "Mark Failed in Strapi", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mark Failed in Strapi": { + "main": [ + [ + { + "node": "Aggregate Swept Count", + "type": "main", + "index": 0 + } + ] + ] + }, + "Aggregate Swept Count": { + "main": [ + [ + { + "node": "Notify #integration-marketplace", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true, + "saveExecutionProgress": true, + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "errorWorkflow": "SYm72hu6NXus7iah" + }, + "staticData": null, + "activeVersionId": null, + "versionCounter": 1, + "tags": [], + "activeVersion": null +} diff --git a/apps/automation/workflows/security-scan/workflow.json b/apps/automation/workflows/security-scan/workflow.json new file mode 100644 index 0000000..0ba3859 --- /dev/null +++ b/apps/automation/workflows/security-scan/workflow.json @@ -0,0 +1,568 @@ +{ + "name": "security-scan", + "description": null, + "nodes": [ + { + "id": "sticky-1", + "name": "About", + "type": "n8n-nodes-base.stickyNote", + "typeVersion": 1, + "position": [ + 120, + 60 + ], + "parameters": { + "content": "## Security Scan (issue #10)\n\nManually triggered from the Strapi moderation admin (cost control \u2014 not automatic on submit).\n\n**Payload is pre-enriched by the CMS.** The moderation plugin's `get-package-security-info.js` service reuses `package-info`'s registry extract patterns to fetch metadata before firing this webhook \u2014 so this workflow does not hit any registry directly.\n\nSupports both `plugin` and `template` submissions:\n- **plugin:** `package_info` is populated when a registry URL is provided (phase 1 = npm only; packagist/pypi/rubygems/nuget stubbed with `notImplemented: true`)\n- **template:** `package_info` is always null; scan is repo-only + AI\n\nTwo parallel branches:\n1. **Package scan** \u2014 OSV.dev vuln lookup + repo package.json cross-check + install-script flag\n2. **AI analysis** \u2014 Claude Haiku 4.5 reads the published README (falls back to submitted description) and flags supply-chain risks\n\nPer-stage results PATCH back via `POST /api/moderation/-submissions/:documentId/security-scan-result`. Final summary sets `security_scan_status=completed`.\n\n**Inputs (POST body):**\n`{ submissionId, submission_kind: 'plugin'|'template', plugin_name, package_location, repository_url, readme, package_info | null }`\n\n**Required n8n credentials:**\n- `httpHeaderAuth` for the webhook (matches `N8N_WEBHOOK_AUTH_*` in CMS .env)\n- `httpHeaderAuth` separate for Strapi write-back (Strapi API token as `Authorization: Bearer ...`)\n- `httpHeaderAuth` separate for Anthropic API (`x-api-key: ...`)\n\n**Required n8n env vars:**\n- `STRAPI_API_URL` (base URL of the CMS)", + "height": 500, + "width": 980, + "color": 5 + } + }, + { + "id": "webhook-1", + "name": "Webhook: Security Scan", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 160, + 640 + ], + "parameters": { + "httpMethod": "POST", + "path": "strapi/security-scan", + "authentication": "headerAuth", + "responseMode": "onReceived", + "responseData": "noData", + "options": {} + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true, + "notes": "Fired by the Strapi moderation admin. CMS has already set security_scan_status='running' before this request.", + "webhookId": "9f3c2a87-1e4b-4d6c-a2f8-5c6b7d8e9a11" + }, + { + "id": "extract-1", + "name": "Extract Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 380, + 640 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "a1", + "name": "submissionId", + "type": "string", + "value": "={{ $json.body.submissionId }}" + }, + { + "id": "a2", + "name": "submission_kind", + "type": "string", + "value": "={{ $json.body.submission_kind || 'plugin' }}" + }, + { + "id": "a3", + "name": "plugin_name", + "type": "string", + "value": "={{ $json.body.plugin_name }}" + }, + { + "id": "a4", + "name": "package_location", + "type": "string", + "value": "={{ $json.body.package_location || '' }}" + }, + { + "id": "a5", + "name": "repository_url", + "type": "string", + "value": "={{ $json.body.repository_url }}" + }, + { + "id": "a6", + "name": "readme", + "type": "string", + "value": "={{ $json.body.readme || '' }}" + }, + { + "id": "a7", + "name": "package_info", + "type": "object", + "value": "={{ $json.body.package_info }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "parse-repo-1", + "name": "Parse Repo URL", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 600, + 640 + ], + "parameters": { + "language": "javaScript", + "jsCode": "try {\nconst item = $input.first().json;\nconst raw = (item.repository_url || '').trim();\n\nfunction parse(url) {\n if (!url) return { provider: 'unknown' };\n let href = url;\n if (!/^https?:\\/\\//i.test(href)) href = 'https://' + href;\n let u;\n try { u = new URL(href); } catch { return { provider: 'unknown' }; }\n const host = u.hostname.replace(/^www\\./i, '').toLowerCase();\n const path = u.pathname.replace(/^\\//, '').replace(/\\.git$/, '').replace(/\\/$/, '');\n if (host === 'github.com') {\n const [owner, repo] = path.split('/');\n if (!owner || !repo) return { provider: 'unknown' };\n return { provider: 'github', owner, repo };\n }\n if (host === 'gitlab.com' || host.endsWith('.gitlab.com')) {\n if (!path) return { provider: 'unknown' };\n return { provider: 'gitlab', projectPath: path, encoded: encodeURIComponent(path) };\n }\n return { provider: 'unknown' };\n}\n\nreturn [{ json: { ...item, repo_info: parse(raw) } }];\n} catch (err) {\n const errMsg = err && err.message ? err.message : String(err);\n // Fallback shape so downstream nodes can proceed.\n // Stage: parse-repo-1\n return [{ json: { ...$input.first().json, repo_info: { provider: \"unknown\" }, _node_error: errMsg } }];\n}" + }, + "notes": "Parses repository_url to extract provider/owner/repo \u2014 used only by the repo cross-check fetch below. GitLab / unknown providers cause the cross-check to fall through.", + "onError": "continueRegularOutput" + }, + { + "id": "deps-fetch-1", + "name": "Fetch Repo package.json", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 820, + 480 + ], + "parameters": { + "method": "GET", + "url": "=https://raw.githubusercontent.com/{{ $json.repo_info.owner || 'unknown' }}/{{ $json.repo_info.repo || 'unknown' }}/HEAD/package.json", + "options": { + "timeout": 15000, + "response": { + "response": { + "neverError": true, + "responseFormat": "json" + } + } + } + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 2000, + "notes": "Cross-check only: fetches source repo's package.json to compare against the published artifact. GitLab / unknown providers return null; scan falls back to package-only analysis." + }, + { + "id": "osv-build-1", + "name": "Build Package Scan Data", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1040, + 480 + ], + "parameters": { + "language": "javaScript", + "jsCode": "try {\nconst repoPkg = $input.first().json;\nconst payload = $('Parse Repo URL').item.json;\nconst info = payload.package_info || null;\nconst kind = payload.submission_kind || 'plugin';\n\nconst repoValid = repoPkg && typeof repoPkg === 'object' && !Array.isArray(repoPkg) && !repoPkg.error && Object.keys(repoPkg).length > 0;\nconst pkgAvailable = !!(info && info.available);\n\n// Dependencies source: published package first, repo fallback.\nconst pkgDeps = pkgAvailable ? { ...(info.dependencies || {}), ...(info.peerDependencies || {}) } : {};\nconst repoDeps = repoValid ? { ...(repoPkg.dependencies || {}), ...(repoPkg.peerDependencies || {}) } : {};\nconst depsSource = pkgAvailable ? 'registry' : (repoValid ? 'repo' : 'none');\nconst deps = depsSource === 'registry' ? pkgDeps : (depsSource === 'repo' ? repoDeps : {});\n\n// OSV ecosystem tag from the pre-detected registry, default to npm.\nconst ecosystem = info?.ecosystem || 'npm';\n\nfunction cleanVersion(raw) {\n if (typeof raw !== 'string') return null;\n const match = raw.match(/\\d+\\.\\d+\\.\\d+/);\n return match ? match[0] : null;\n}\n\nconst queries = Object.entries(deps)\n .map(([name, version]) => {\n const v = cleanVersion(version);\n if (!v) return null;\n return { package: { name, ecosystem }, version: v };\n })\n .filter(Boolean)\n .slice(0, 100);\n\n// Cross-check published vs repo (only when BOTH are present).\nconst mismatches = [];\nif (pkgAvailable && repoValid) {\n ['preinstall', 'install', 'postinstall'].forEach((k) => {\n const pkgVal = info.scripts?.[k] || null;\n const repoVal = repoPkg.scripts?.[k] || null;\n if (pkgVal !== repoVal) {\n mismatches.push({ kind: 'install_script_divergence', script: k, published: pkgVal, repo: repoVal });\n }\n });\n const pkgSet = new Set(Object.keys(pkgDeps));\n const repoSet = new Set(Object.keys(repoDeps));\n const onlyInPkg = [...pkgSet].filter((n) => !repoSet.has(n));\n const onlyInRepo = [...repoSet].filter((n) => !pkgSet.has(n));\n if (onlyInPkg.length > 0) mismatches.push({ kind: 'deps_only_in_published', names: onlyInPkg.slice(0, 20) });\n if (onlyInRepo.length > 0) mismatches.push({ kind: 'deps_only_in_repo', names: onlyInRepo.slice(0, 20) });\n const submitted = (payload.repository_url || '').toLowerCase();\n const declared = (info.declaredRepository || '').toLowerCase();\n const owner = payload.repo_info?.owner?.toLowerCase();\n if (declared && submitted && owner && !declared.includes(owner)) {\n mismatches.push({ kind: 'declared_repo_vs_submitted', submitted, declared });\n }\n}\n\nconst hasInstallScripts = pkgAvailable && Object.keys(info.installScripts || {}).length > 0;\n\nreturn [{\n json: {\n queries,\n _skipped: queries.length === 0,\n _reason: queries.length === 0 ? `No dependencies to scan (source: ${depsSource}).` : null,\n ecosystem,\n depsSource,\n kind,\n hasInstallScripts,\n install_scripts: pkgAvailable ? info.installScripts || {} : {},\n mismatches,\n registry_available: pkgAvailable,\n not_implemented: info?.notImplemented === true,\n registry: info?.registry || null,\n packageName: info?.packageName || null,\n version: info?.version || null,\n dist: info?.dist || null,\n },\n}];\n} catch (err) {\n const errMsg = err && err.message ? err.message : String(err);\n // Fallback shape so downstream nodes can proceed.\n // Stage: osv-build-1\n return [{ json: { queries: [], _skipped: true, _reason: \"Build Package Scan Data threw: \" + errMsg, ecosystem: \"npm\", depsSource: \"none\", kind: null, hasInstallScripts: false, install_scripts: {}, mismatches: [], registry_available: false, not_implemented: false, registry: null, packageName: null, version: null, dist: null, _node_error: errMsg } }];\n}" + }, + "notes": "Consolidates the pre-fetched package_info with the repo cross-check. Uses the package_info.ecosystem for the OSV ecosystem tag. For template submissions (no package_info), only repo-side deps feed OSV.", + "onError": "continueRegularOutput" + }, + { + "id": "osv-http-1", + "name": "OSV.dev Batch Query", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1260, + 480 + ], + "parameters": { + "method": "POST", + "url": "https://api.osv.dev/v1/querybatch", + "sendBody": true, + "contentType": "json", + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ queries: $json.queries }) }}", + "options": { + "timeout": 30000 + } + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "notes": "Free, unauthenticated. Supports all 5 ecosystems (npm, Packagist, PyPI, RubyGems, NuGet)." + }, + { + "id": "deps-format-1", + "name": "Format Package Scan Result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1480, + 480 + ], + "parameters": { + "language": "javaScript", + "jsCode": "try {\nconst osv = $input.first().json || {};\nconst batch = $('Build Package Scan Data').item.json;\nconst results = Array.isArray(osv.results) ? osv.results : [];\n\nconst common = {\n runAt: new Date().toISOString(),\n deps_source: batch.depsSource,\n ecosystem: batch.ecosystem,\n registry: batch.registry,\n registry_available: batch.registry_available,\n not_implemented: batch.not_implemented,\n package_name: batch.packageName,\n version: batch.version,\n dist: batch.dist,\n install_scripts: batch.install_scripts,\n has_install_scripts: batch.hasInstallScripts,\n cross_check_mismatches: batch.mismatches,\n};\n\nif (batch._skipped) {\n return [{\n json: {\n stage: 'dependencies',\n result: {\n ...common,\n skipped: true,\n message: batch._reason || 'No dependencies to scan.',\n },\n },\n }];\n}\n\nconst vulnerable = [];\nresults.forEach((r, idx) => {\n if (r && Array.isArray(r.vulns) && r.vulns.length > 0) {\n const q = batch.queries[idx];\n vulnerable.push({\n package: q.package.name,\n version: q.version,\n vulnIds: r.vulns.map((v) => v.id),\n });\n }\n});\n\nconst passed = vulnerable.length === 0 && !batch.hasInstallScripts && batch.mismatches.length === 0;\n\nreturn [{\n json: {\n stage: 'dependencies',\n result: {\n ...common,\n scanned: batch.queries.length,\n vulnerable_count: vulnerable.length,\n vulnerable,\n passed,\n },\n },\n}];\n} catch (err) {\n const errMsg = err && err.message ? err.message : String(err);\n // Fallback shape so downstream nodes can proceed.\n // Stage: deps-format-1\n return [{ json: { stage: \"dependencies\", result: { runAt: new Date().toISOString(), error: errMsg, passed: false, skipped: true, message: \"Format Package Scan Result threw: \" + errMsg } } }];\n}" + }, + "onError": "continueRegularOutput" + }, + { + "id": "deps-patch-1", + "name": "PATCH Scan Result", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1700, + 480 + ], + "parameters": { + "method": "POST", + "url": "={{ $env.STRAPI_API_URL }}/api/moderation/{{ $('Extract Payload').item.json.submission_kind === 'template' ? 'template-submissions' : 'plugin-submissions' }}/{{ $('Extract Payload').item.json.submissionId }}/security-scan-result", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendBody": true, + "contentType": "json", + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ stage: $json.stage, result: $json.result }) }}", + "options": { + "timeout": 15000 + } + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "notes": "Writes the package scan stage result. URL path flips between plugin-submissions and template-submissions based on the submission_kind field." + }, + { + "id": "fetch-code-1", + "name": "Fetch Package Code", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 820, + 800 + ], + "parameters": { + "language": "javaScript", + "jsCode": "try {\nconst payload = $input.first().json;\nconst info = payload.package_info || null;\nconst repo = payload.repo_info || {};\n\nconst ALLOW_EXT = /\\.(js|ts|mjs|cjs|jsx|tsx)$/;\nconst SKIP_PATTERNS = [/node_modules/, /\\.git/, /(^|\\/)test[s]?\\//, /\\.test\\./, /\\.spec\\./, /\\.d\\.ts$/, /\\.min\\.(js|css)$/, /\\.map$/, /(^|\\/)examples?\\//, /(^|\\/)docs?\\//, /(^|\\/)fixtures?\\//];\nconst PER_FILE_CAP = 10000;\nconst TOTAL_BUDGET = 100000;\nconst MAX_FILES = 20;\n\nfunction keepPath(path) {\n if (!ALLOW_EXT.test(path)) return false;\n return !SKIP_PATTERNS.some((r) => r.test(path));\n}\n\nfunction escRe(s) {\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nasync function loadNpmTree(name, version) {\n try {\n const res = await fetch(`https://data.jsdelivr.com/v1/package/npm/${encodeURIComponent(name)}@${encodeURIComponent(version)}`);\n if (!res.ok) return null;\n const tree = await res.json();\n const files = [];\n function walk(node, prefix) {\n if (node.type === 'file') {\n const full = prefix + '/' + node.name;\n if (keepPath(full)) files.push({ path: full, size: node.size || 0 });\n } else if (Array.isArray(node.files)) {\n node.files.forEach((c) => walk(c, prefix + '/' + node.name));\n }\n }\n if (Array.isArray(tree.files)) tree.files.forEach((f) => walk(f, ''));\n return {\n source: 'npm:jsdelivr',\n files,\n fetchUrl: (p) => `https://cdn.jsdelivr.net/npm/${encodeURIComponent(name)}@${encodeURIComponent(version)}${p}`,\n };\n } catch {\n return null;\n }\n}\n\nasync function loadGitHubTree(owner, repoName) {\n try {\n const res = await fetch(`https://api.github.com/repos/${owner}/${repoName}/git/trees/HEAD?recursive=1`, {\n headers: { Accept: 'application/vnd.github+json' },\n });\n if (!res.ok) return null;\n const tree = await res.json();\n const files = (tree.tree || [])\n .filter((n) => n.type === 'blob')\n .map((n) => ({ path: '/' + n.path, size: n.size || 0 }))\n .filter((f) => keepPath(f.path));\n return {\n source: 'github:raw',\n files,\n fetchUrl: (p) => `https://raw.githubusercontent.com/${owner}/${repoName}/HEAD${p}`,\n };\n } catch {\n return null;\n }\n}\n\nlet scanner = null;\nif (info?.available && info.registry === 'npm') {\n scanner = await loadNpmTree(info.packageName, info.version);\n}\nif (!scanner && repo.provider === 'github') {\n scanner = await loadGitHubTree(repo.owner, repo.repo);\n}\n\nif (!scanner) {\n return [{ json: { ...payload, package_code: null, files_scanned: 0, files_available: 0, bytes_scanned: 0, scan_source: null } }];\n}\n\n// Prioritisation: install scripts, main entry, bin/, common entry names, src/, then small files.\nconst priorityRegex = [];\nObject.values(info?.installScripts || {}).forEach((s) => {\n const m = typeof s === 'string' ? s.match(/\\S+\\.(?:js|ts|mjs|cjs)/) : null;\n if (m) priorityRegex.push(new RegExp(escRe(m[0]) + '$'));\n});\npriorityRegex.push(/\\/bin\\//);\npriorityRegex.push(/^\\/(index|main|entry|cli)\\.(js|ts|mjs|cjs)$/);\npriorityRegex.push(/^\\/src\\//);\n\nfunction priority(p) {\n for (let i = 0; i < priorityRegex.length; i++) if (priorityRegex[i].test(p)) return i;\n return 999;\n}\n\nscanner.files.sort((a, b) => priority(a.path) - priority(b.path) || a.size - b.size);\nconst candidates = scanner.files.slice(0, MAX_FILES);\n\nconst contents = await Promise.all(candidates.map(async (f) => {\n try {\n const res = await fetch(scanner.fetchUrl(f.path));\n if (!res.ok) return null;\n const text = await res.text();\n return { path: f.path, body: text.slice(0, PER_FILE_CAP), bytes: Math.min(text.length, PER_FILE_CAP) };\n } catch {\n return null;\n }\n}));\n\nconst included = [];\nlet used = 0;\nfor (const c of contents) {\n if (!c) continue;\n if (used + c.bytes > TOTAL_BUDGET) break;\n included.push(c);\n used += c.bytes;\n}\n\nconst code = included.map((c) => `// ==== ${c.path} (${c.bytes} bytes) ====\\n${c.body}`).join('\\n\\n');\n\nreturn [{\n json: {\n ...payload,\n package_code: code || null,\n files_scanned: included.length,\n files_available: scanner.files.length,\n bytes_scanned: used,\n scan_source: scanner.source,\n },\n}];\n} catch (err) {\n const errMsg = err && err.message ? err.message : String(err);\n // Fallback shape so downstream nodes can proceed.\n // Stage: fetch-code-1\n return [{ json: { ...$input.first().json, package_code: null, files_scanned: 0, files_available: 0, bytes_scanned: 0, scan_source: null, _node_error: errMsg } }];\n}" + }, + "onError": "continueRegularOutput", + "notes": "Fetches up to 20 source files from the PUBLISHED package (npm via jsDelivr CDN) or the source repo (GitHub for templates / repo fallback). JS/TS only; skips tests / docs / min / node_modules. 10KB per file, 100KB total \u2014 feeds into Claude's user message so the AI can review actual runtime code, not just README/metadata." + }, + { + "id": "ai-http-1", + "name": "Claude Haiku Security Analysis", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1040, + 800 + ], + "parameters": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "anthropic-version", + "value": "2023-06-01" + } + ] + }, + "sendBody": true, + "contentType": "json", + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 1024, system: 'You are a security auditor reviewing a Strapi community submission. You are given package metadata, install-time scripts, the README, and ACTUAL SOURCE FILES from the published package (or source repo for templates). Read the source files carefully \u2014 runtime exfiltration, credential theft, obfuscation, and suspicious network calls often hide in main entry files or bin scripts, not the README. Prioritise supply-chain signals: install-time scripts, typosquatting, mismatches between the published artifact and its source repo, obfuscated or minified code, dynamic eval / Function constructor use, network calls to unknown hosts, filesystem writes outside package dir. Respond ONLY with valid JSON matching this schema: {\"risk_level\":\"low|medium|high\",\"summary\":\"\",\"concerns\":[\"\",...],\"red_flags\":[\"\",...],\"recommendation\":\"approve|request_changes|reject\"}. Do not include explanation text outside the JSON.', messages: [{ role: 'user', content: `Submission kind: ${$json.submission_kind}\\nName: ${$json.plugin_name}\\npackage_location: ${$json.package_location || '(none \u2014 template or no registry URL)'}\\nregistry: ${$json.package_info?.registry || 'n/a'}\\nregistry_available: ${$json.package_info?.available ? 'yes' : 'no'}${$json.package_info?.notImplemented ? ' (deep scan not yet implemented for this registry)' : ''}\\nVersion: ${$json.package_info?.version || 'n/a'}\\nSubmitted repo: ${$json.repository_url}\\nDeclared repo on published artifact: ${$json.package_info?.declaredRepository || '(none declared)'}\\n\\nInstall-time scripts on the published artifact (immediate red flag if any are non-trivial):\\n${JSON.stringify($json.package_info?.installScripts || {}, null, 2)}\\n\\nSource files scanned: ${$json.files_scanned} of ${$json.files_available} (${$json.bytes_scanned} bytes, source: ${$json.scan_source || 'none'}).\\n\\nREADME:\\n---\\n${(($json.package_info?.readme || $json.readme) || '(no README available)').toString().slice(0, 8000)}\\n---\\n\\nSOURCE FILES (truncated per-file + overall):\\n---\\n${$json.package_code || '(no source files could be fetched)'}\\n---\\n\\nReview the code above against the schema. Output JSON only.` }] }) }}", + "options": { + "timeout": 45000 + } + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 5000, + "notes": "Uses Claude Haiku 4.5 (claude-haiku-4-5). Prompt includes supply-chain context: install scripts, published vs submitted repo, package version. Structured JSON parsed in the next node." + }, + { + "id": "ai-format-1", + "name": "Format AI Result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1260, + 800 + ], + "parameters": { + "language": "javaScript", + "jsCode": "try {\nconst res = $input.first().json || {};\nconst fetchCodeOut = $('Fetch Package Code').item.json;\nconst text = res?.content?.[0]?.text || '';\nlet parsed = null;\nlet parseError = null;\ntry {\n const match = text.match(/\\{[\\s\\S]*\\}/);\n if (match) parsed = JSON.parse(match[0]);\n} catch (err) {\n parseError = err.message;\n}\n\nreturn [{\n json: {\n stage: 'ai_analysis',\n result: {\n runAt: new Date().toISOString(),\n model: 'claude-haiku-4-5',\n raw_text: text,\n parsed,\n parse_error: parseError,\n usage: res?.usage || null,\n files_scanned: fetchCodeOut?.files_scanned ?? 0,\n files_available: fetchCodeOut?.files_available ?? 0,\n bytes_scanned: fetchCodeOut?.bytes_scanned ?? 0,\n scan_source: fetchCodeOut?.scan_source || null,\n },\n },\n}];\n} catch (err) {\n const errMsg = err && err.message ? err.message : String(err);\n // Fallback shape so downstream nodes can proceed.\n // Stage: ai-format-1\n return [{ json: { stage: \"ai_analysis\", result: { runAt: new Date().toISOString(), error: errMsg, parsed: null, parse_error: errMsg, model: \"claude-haiku-4-5\", raw_text: null } } }];\n}" + }, + "onError": "continueRegularOutput" + }, + { + "id": "ai-patch-1", + "name": "PATCH AI Result", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1480, + 800 + ], + "parameters": { + "method": "POST", + "url": "={{ $env.STRAPI_API_URL }}/api/moderation/{{ $('Extract Payload').item.json.submission_kind === 'template' ? 'template-submissions' : 'plugin-submissions' }}/{{ $('Extract Payload').item.json.submissionId }}/security-scan-result", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendBody": true, + "contentType": "json", + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ stage: $json.stage, result: $json.result }) }}", + "options": { + "timeout": 15000 + } + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000 + }, + { + "id": "merge-1", + "name": "Wait for All Stages", + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 1920, + 640 + ], + "parameters": { + "mode": "combine", + "combineBy": "combineByPosition", + "numberInputs": 2, + "options": { + "includeUnpaired": true + } + }, + "notes": "Waits for both branches (package scan + AI) to emit per-stage results, then combines them into one item with fields from each branch." + }, + { + "id": "summary-1", + "name": "Build Summary", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2140, + 640 + ], + "parameters": { + "language": "javaScript", + "jsCode": "try {\nconst merged = $input.first().json;\n\nfunction pick(stageName) {\n const stages = Array.isArray(merged.stage) ? merged.stage : [merged.stage];\n const results = Array.isArray(merged.result) ? merged.result : [merged.result];\n for (let i = 0; i < stages.length; i++) {\n if (stages[i] === stageName) return results[i];\n }\n return null;\n}\n\nconst depsR = pick('dependencies');\nconst aiR = pick('ai_analysis');\n\nfunction stagePassed(r) {\n if (!r) return null;\n if (r.skipped) return null;\n if (typeof r.passed === 'boolean') return r.passed;\n return null;\n}\n\nconst summary = {\n runAt: new Date().toISOString(),\n dependencies_passed: stagePassed(depsR),\n ai_risk_level: aiR?.parsed?.risk_level ?? null,\n ai_recommendation: aiR?.parsed?.recommendation ?? null,\n ai_files_scanned: aiR?.files_scanned ?? null,\n ai_files_available: aiR?.files_available ?? null,\n ai_scan_source: aiR?.scan_source ?? null,\n ai_bytes_scanned: aiR?.bytes_scanned ?? null,\n vulnerable_dep_count: depsR?.vulnerable_count ?? null,\n ai_concern_count: Array.isArray(aiR?.parsed?.concerns) ? aiR.parsed.concerns.length : null,\n registry: depsR?.registry ?? null,\n registry_available: depsR?.registry_available ?? null,\n not_implemented: depsR?.not_implemented ?? null,\n package_name: depsR?.package_name ?? null,\n version: depsR?.version ?? null,\n has_install_scripts: depsR?.has_install_scripts ?? null,\n install_scripts: depsR?.install_scripts ?? null,\n cross_check_mismatch_count: Array.isArray(depsR?.cross_check_mismatches) ? depsR.cross_check_mismatches.length : null,\n};\n\nreturn [{ json: { stage: 'summary', result: summary, status: 'completed' } }];\n} catch (err) {\n const errMsg = err && err.message ? err.message : String(err);\n // Fallback shape so downstream nodes can proceed.\n // Stage: summary-1\n return [{ json: { stage: \"summary\", result: { runAt: new Date().toISOString(), error: errMsg, passed: false }, status: \"failed\" } }];\n}" + }, + "onError": "continueRegularOutput" + }, + { + "id": "summary-patch-1", + "name": "PATCH Summary + Complete", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2360, + 640 + ], + "parameters": { + "method": "POST", + "url": "={{ $env.STRAPI_API_URL }}/api/moderation/{{ $('Extract Payload').item.json.submission_kind === 'template' ? 'template-submissions' : 'plugin-submissions' }}/{{ $('Extract Payload').item.json.submissionId }}/security-scan-result", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendBody": true, + "contentType": "json", + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ stage: $json.stage, result: $json.result, status: $json.status }) }}", + "options": { + "timeout": 15000 + } + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "notes": "Final write-back: stores summary blob and flips security_scan_status to 'completed'." + } + ], + "connections": { + "Webhook: Security Scan": { + "main": [ + [ + { + "node": "Extract Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Payload": { + "main": [ + [ + { + "node": "Parse Repo URL", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Repo URL": { + "main": [ + [ + { + "node": "Fetch Repo package.json", + "type": "main", + "index": 0 + }, + { + "node": "Fetch Package Code", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Package Code": { + "main": [ + [ + { + "node": "Claude Haiku Security Analysis", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Repo package.json": { + "main": [ + [ + { + "node": "Build Package Scan Data", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Package Scan Data": { + "main": [ + [ + { + "node": "OSV.dev Batch Query", + "type": "main", + "index": 0 + } + ] + ] + }, + "OSV.dev Batch Query": { + "main": [ + [ + { + "node": "Format Package Scan Result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format Package Scan Result": { + "main": [ + [ + { + "node": "PATCH Scan Result", + "type": "main", + "index": 0 + } + ] + ] + }, + "PATCH Scan Result": { + "main": [ + [ + { + "node": "Wait for All Stages", + "type": "main", + "index": 0 + } + ] + ] + }, + "Claude Haiku Security Analysis": { + "main": [ + [ + { + "node": "Format AI Result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Format AI Result": { + "main": [ + [ + { + "node": "PATCH AI Result", + "type": "main", + "index": 0 + } + ] + ] + }, + "PATCH AI Result": { + "main": [ + [ + { + "node": "Wait for All Stages", + "type": "main", + "index": 1 + } + ] + ] + }, + "Wait for All Stages": { + "main": [ + [ + { + "node": "Build Summary", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Summary": { + "main": [ + [ + { + "node": "PATCH Summary + Complete", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true, + "saveExecutionProgress": true, + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "errorWorkflow": "SYm72hu6NXus7iah" + }, + "staticData": null, + "activeVersionId": null, + "versionCounter": 1, + "tags": [], + "activeVersion": null +} diff --git a/apps/automation/workflows/template-approved/workflow.json b/apps/automation/workflows/template-approved/workflow.json new file mode 100644 index 0000000..7262f18 --- /dev/null +++ b/apps/automation/workflows/template-approved/workflow.json @@ -0,0 +1,270 @@ +{ + "name": "template-approved", + "description": null, + "nodes": [ + { + "id": "webhook-1", + "name": "Webhook: Package Approved", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 240, + 300 + ], + "parameters": { + "httpMethod": "POST", + "path": "strapi/template-approved", + "authentication": "headerAuth", + "responseMode": "onReceived", + "responseData": "noData", + "options": {} + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true, + "notes": "Fired by the CMS moderation plugin after template-submission.decide with status='approved'.", + "webhookId": "b6fa08e1-08dc-4a91-beaa-0aab4b4f39f8" + }, + { + "id": "extract-1", + "name": "Extract Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 460, + 300 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "a1", + "name": "package_id", + "type": "string", + "value": "={{ $json.body.submissionId }}" + }, + { + "id": "a2", + "name": "package_name", + "type": "string", + "value": "={{ $json.body.template_name }}" + }, + { + "id": "a3", + "name": "package_slug", + "type": "string", + "value": "" + }, + { + "id": "a4", + "name": "author_email", + "type": "string", + "value": "={{ $json.body.owner_email }}" + }, + { + "id": "a5", + "name": "author_name", + "type": "string", + "value": "={{ $json.body.owner_name }}" + }, + { + "id": "a6", + "name": "marketplace_link", + "type": "string", + "value": "={{ $json.body.demo_url || $json.body.repository_url }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "build-email-1", + "name": "Build Email Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 680, + 200 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "b1", + "name": "template_key", + "type": "string", + "value": "template-approved" + }, + { + "id": "b2", + "name": "to_email", + "type": "string", + "value": "={{ $json.author_email }}" + }, + { + "id": "b3", + "name": "to_name", + "type": "string", + "value": "={{ $json.author_name }}" + }, + { + "id": "b4", + "name": "variables", + "type": "object", + "value": "={{ { package_name: $json.package_name, author_name: $json.author_name, marketplace_link: $json.marketplace_link } }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "send-email-1", + "name": "Send Developer Email", + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.1, + "position": [ + 900, + 200 + ], + "parameters": { + "source": "database", + "workflowId": { + "__rl": true, + "mode": "id", + "value": "1MgLRIjI37T2Kd5W" + }, + "mode": "once", + "options": {} + }, + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000 + }, + { + "id": "build-slack-1", + "name": "Build Slack Message", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 680, + 400 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "s1", + "name": "text", + "type": "string", + "value": "=:white_check_mark: *Template* approved: *{{ $json.package_name }}* by {{ $json.author_name }}\nLive: {{ $json.marketplace_link }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "slack-1", + "name": "Notify #integration-marketplace", + "type": "n8n-nodes-base.slack", + "typeVersion": 2.4, + "position": [ + 900, + 400 + ], + "parameters": { + "resource": "message", + "operation": "post", + "select": "channel", + "channelId": { + "__rl": true, + "mode": "name", + "value": "integration-marketplace" + }, + "text": "={{ $json.text }}", + "otherOptions": {} + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "webhookId": "a41686e3-5d7a-4d21-b75b-09698c7fb293" + } + ], + "connections": { + "Webhook: Package Approved": { + "main": [ + [ + { + "node": "Extract Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Payload": { + "main": [ + [ + { + "node": "Build Email Payload", + "type": "main", + "index": 0 + }, + { + "node": "Build Slack Message", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Email Payload": { + "main": [ + [ + { + "node": "Send Developer Email", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Slack Message": { + "main": [ + [ + { + "node": "Notify #integration-marketplace", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true, + "saveExecutionProgress": true, + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "errorWorkflow": "SYm72hu6NXus7iah" + }, + "staticData": null, + "activeVersionId": null, + "versionCounter": 1, + "tags": [], + "activeVersion": null +} diff --git a/apps/automation/workflows/template-declined/workflow.json b/apps/automation/workflows/template-declined/workflow.json new file mode 100644 index 0000000..aa48783 --- /dev/null +++ b/apps/automation/workflows/template-declined/workflow.json @@ -0,0 +1,189 @@ +{ + "name": "template-declined", + "description": null, + "nodes": [ + { + "id": "webhook-1", + "name": "Webhook: Package Declined", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 240, + 300 + ], + "parameters": { + "httpMethod": "POST", + "path": "strapi/template-declined", + "authentication": "headerAuth", + "responseMode": "onReceived", + "responseData": "noData", + "options": {} + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true, + "notes": "Fired by the CMS moderation plugin after template-submission.decide with status='rejected'. Payload: flat { submissionId, template_name, owner_email, reason, feedback, ... }.", + "webhookId": "7caa3379-0c9e-42cb-8dc2-40c77b17e800" + }, + { + "id": "extract-1", + "name": "Extract Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 460, + 300 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "a1", + "name": "package_name", + "type": "string", + "value": "={{ $json.body.template_name }}" + }, + { + "id": "a2", + "name": "author_email", + "type": "string", + "value": "={{ $json.body.owner_email }}" + }, + { + "id": "a3", + "name": "author_name", + "type": "string", + "value": "={{ $json.body.owner_name }}" + }, + { + "id": "a4", + "name": "decline_reason", + "type": "string", + "value": "={{ $json.body.reason || $json.body.feedback || '' }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "build-email-1", + "name": "Build Email Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 680, + 300 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "b1", + "name": "template_key", + "type": "string", + "value": "template-declined" + }, + { + "id": "b2", + "name": "to_email", + "type": "string", + "value": "={{ $json.author_email }}" + }, + { + "id": "b3", + "name": "to_name", + "type": "string", + "value": "={{ $json.author_name }}" + }, + { + "id": "b4", + "name": "variables", + "type": "object", + "value": "={{ { package_name: $json.package_name, author_name: $json.author_name, decline_reason: $json.decline_reason } }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "send-email-1", + "name": "Send Developer Email", + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.1, + "position": [ + 900, + 300 + ], + "parameters": { + "source": "database", + "workflowId": { + "__rl": true, + "mode": "id", + "value": "1MgLRIjI37T2Kd5W" + }, + "mode": "once", + "options": {} + }, + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000 + } + ], + "connections": { + "Webhook: Package Declined": { + "main": [ + [ + { + "node": "Extract Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Payload": { + "main": [ + [ + { + "node": "Build Email Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Email Payload": { + "main": [ + [ + { + "node": "Send Developer Email", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true, + "saveExecutionProgress": true, + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "errorWorkflow": "SYm72hu6NXus7iah" + }, + "staticData": null, + "activeVersionId": null, + "versionCounter": 1, + "tags": [], + "activeVersion": null +} diff --git a/apps/automation/workflows/template-submission-received/workflow.json b/apps/automation/workflows/template-submission-received/workflow.json new file mode 100644 index 0000000..a3aa193 --- /dev/null +++ b/apps/automation/workflows/template-submission-received/workflow.json @@ -0,0 +1,271 @@ +{ + "name": "template-submission-received", + "description": null, + "nodes": [ + { + "id": "webhook-1", + "name": "Webhook: Submission Received", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 240, + 300 + ], + "parameters": { + "httpMethod": "POST", + "path": "strapi/template-submission-received", + "authentication": "headerAuth", + "responseMode": "onReceived", + "responseData": "noData", + "options": {} + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true, + "notes": "Fired by the CMS moderation plugin after template-submission.createSubmission. Payload: flat { submissionId, template_name, owner_name, owner_email, repository_url, dashboard_link, ... }.", + "webhookId": "5d81dc37-06f1-44c7-a80b-df2d1e38ee1f" + }, + { + "id": "extract-1", + "name": "Extract Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 460, + 300 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "a1", + "name": "package_id", + "type": "string", + "value": "={{ $json.body.submissionId }}" + }, + { + "id": "a2", + "name": "package_name", + "type": "string", + "value": "={{ $json.body.template_name }}" + }, + { + "id": "a3", + "name": "git_repository", + "type": "string", + "value": "={{ $json.body.repository_url }}" + }, + { + "id": "a4", + "name": "author_email", + "type": "string", + "value": "={{ $json.body.owner_email }}" + }, + { + "id": "a5", + "name": "author_name", + "type": "string", + "value": "={{ $json.body.owner_name }}" + }, + { + "id": "a6", + "name": "dashboard_link", + "type": "string", + "value": "={{ $json.body.dashboard_link }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "build-email-1", + "name": "Build Email Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 680, + 200 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "b1", + "name": "template_key", + "type": "string", + "value": "template-submission-received" + }, + { + "id": "b2", + "name": "to_email", + "type": "string", + "value": "={{ $json.author_email }}" + }, + { + "id": "b3", + "name": "to_name", + "type": "string", + "value": "={{ $json.author_name }}" + }, + { + "id": "b4", + "name": "variables", + "type": "object", + "value": "={{ { package_name: $json.package_name, author_name: $json.author_name, git_repository: $json.git_repository } }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "send-email-1", + "name": "Send Developer Email", + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.1, + "position": [ + 900, + 200 + ], + "parameters": { + "source": "database", + "workflowId": { + "__rl": true, + "mode": "id", + "value": "1MgLRIjI37T2Kd5W" + }, + "mode": "once", + "options": {} + }, + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "notes": "Calls render-email sub-workflow. TODO when n8n UI available: bump to typeVersion 1.3 and use resourceMapper for typed inputs." + }, + { + "id": "build-slack-1", + "name": "Build Slack Message", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 680, + 400 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "s1", + "name": "text", + "type": "string", + "value": "=:clipboard: New *template* submission received: *{{ $json.package_name }}* by {{ $json.author_name }}\nRepo: {{ $json.git_repository }}\nReview: {{ $json.dashboard_link }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "slack-1", + "name": "Notify #integration-marketplace", + "type": "n8n-nodes-base.slack", + "typeVersion": 2.4, + "position": [ + 900, + 400 + ], + "parameters": { + "resource": "message", + "operation": "post", + "select": "channel", + "channelId": { + "__rl": true, + "mode": "name", + "value": "integration-marketplace" + }, + "text": "={{ $json.text }}", + "otherOptions": {} + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "webhookId": "693a18d9-9d21-43bc-b24e-4397280404dd" + } + ], + "connections": { + "Webhook: Submission Received": { + "main": [ + [ + { + "node": "Extract Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Payload": { + "main": [ + [ + { + "node": "Build Email Payload", + "type": "main", + "index": 0 + }, + { + "node": "Build Slack Message", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Email Payload": { + "main": [ + [ + { + "node": "Send Developer Email", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Slack Message": { + "main": [ + [ + { + "node": "Notify #integration-marketplace", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true, + "saveExecutionProgress": true, + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "errorWorkflow": "SYm72hu6NXus7iah" + }, + "staticData": null, + "activeVersionId": null, + "versionCounter": 1, + "tags": [], + "activeVersion": null +} diff --git a/apps/cms/.env.example b/apps/cms/.env.example index 1ed561b..b187bd6 100644 --- a/apps/cms/.env.example +++ b/apps/cms/.env.example @@ -1,5 +1,12 @@ HOST=0.0.0.0 PORT=1337 + +# Public base URL of this Strapi instance (e.g. https://abc123.strapiapp.com). +# On Strapi Cloud this is injected automatically as CLOUD_APP_URL — no action +# needed. Used to build the `dashboard_link` field in lifecycle webhook payloads, +# which is rendered into internal Slack messages to #integration-marketplace +# only (reviewer-facing) — never in outbound emails to submitters. +CLOUD_APP_URL=http://localhost:1337 APP_KEYS="toBeModified1,toBeModified2" API_TOKEN_SALT=tobemodified ADMIN_JWT_SECRET=tobemodified @@ -36,4 +43,23 @@ ENABLE_MIGRATION=false GITHUB_ACCESS_TOKEN=tobemodified -SCREENSHOTONE_KEY=tobemodified \ No newline at end of file +SCREENSHOTONE_KEY=tobemodified + +# SendGrid — used for Strapi-native emails (password resets, admin invites). +# Automation emails (submission received, approved, etc.) run through n8n with +# a separate SendGrid key. See github.com/strapi/community/issues/54. +SENDGRID_API_KEY= +EMAIL_DEFAULT_FROM=community@strapi.io +EMAIL_DEFAULT_REPLY_TO=community@strapi.io + +# n8n webhook base URL + mode. +# MODE=test uses n8n's test listener path (/webhook-test/...) which only fires +# while "Listen for test event" is open in the n8n UI. Use for local dev. +# MODE=production uses the always-on path (/webhook/...). Use when the workflow +# is active on the target n8n instance. +# All lifecycle + scan webhooks share this base + mode; paths are hardcoded per +# webhook key in apps/cms/src/plugins/moderation/server/services/n8n-webhook.js. +N8N_WEBHOOK_BASE_URL=http://localhost:5678 +N8N_WEBHOOK_MODE=production +N8N_WEBHOOK_AUTH_HEADER=X-N8N-Auth +N8N_WEBHOOK_AUTH_VALUE= diff --git a/apps/cms/config/plugins.ts b/apps/cms/config/plugins.ts index c41de57..166d49e 100644 --- a/apps/cms/config/plugins.ts +++ b/apps/cms/config/plugins.ts @@ -3,9 +3,16 @@ export default ({ env }) => ({ config: env("NODE_ENV") === "production" ? { + provider: "sendgrid", + providerOptions: { + apiKey: env("SENDGRID_API_KEY"), + }, settings: { - defaultFrom: env("EMAIL_FROM", "noreply@strapi.io"), - defaultReplyTo: env("EMAIL_FROM", "noreply@strapi.io"), + defaultFrom: env("EMAIL_DEFAULT_FROM", "community@strapi.io"), + defaultReplyTo: env( + "EMAIL_DEFAULT_REPLY_TO", + "community@strapi.io", + ), }, } : { @@ -19,10 +26,21 @@ export default ({ env }) => ({ }, }, }, + upload: { + config: { + security: { + strictSsrf: true, + }, + }, + }, "owner-selector": { enabled: true, resolve: "./src/plugins/owner-selector", }, + moderation: { + enabled: true, + resolve: "./src/plugins/moderation", + }, "package-info": { enabled: true, resolve: "./src/plugins/package-info", diff --git a/apps/cms/package.json b/apps/cms/package.json index 6e2c562..52dce36 100644 --- a/apps/cms/package.json +++ b/apps/cms/package.json @@ -27,6 +27,7 @@ "@strapi/design-system": "^2.0.0", "@strapi/icons": "^2.0.0", "@strapi/plugin-cloud": "catalog:strapi", + "@strapi/provider-email-sendgrid": "catalog:strapi", "@strapi/strapi": "catalog:strapi", "@strapi/types": "catalog:strapi", "@strapi/typescript-utils": "catalog:strapi", diff --git a/apps/cms/src/api/email-template/content-types/email-template/schema.json b/apps/cms/src/api/email-template/content-types/email-template/schema.json new file mode 100644 index 0000000..e996da0 --- /dev/null +++ b/apps/cms/src/api/email-template/content-types/email-template/schema.json @@ -0,0 +1,36 @@ +{ + "kind": "collectionType", + "collectionName": "email_templates", + "info": { + "singularName": "email-template", + "pluralName": "email-templates", + "displayName": "Email Templates", + "description": "Templates rendered by n8n automation workflows (submission received, approved, declined, changes requested)." + }, + "options": { + "draftAndPublish": false + }, + "attributes": { + "key": { + "type": "uid", + "required": true + }, + "subject": { + "type": "string", + "required": true + }, + "body": { + "type": "richtext", + "required": true + }, + "description": { + "type": "text" + }, + "from_name": { + "type": "string" + }, + "reply_to": { + "type": "email" + } + } +} diff --git a/apps/cms/src/api/email-template/controllers/email-template.ts b/apps/cms/src/api/email-template/controllers/email-template.ts new file mode 100644 index 0000000..c726904 --- /dev/null +++ b/apps/cms/src/api/email-template/controllers/email-template.ts @@ -0,0 +1,5 @@ +import { factories } from "@strapi/strapi"; + +export default factories.createCoreController( + "api::email-template.email-template", +); diff --git a/apps/cms/src/api/email-template/routes/email-template.ts b/apps/cms/src/api/email-template/routes/email-template.ts new file mode 100644 index 0000000..4304d5f --- /dev/null +++ b/apps/cms/src/api/email-template/routes/email-template.ts @@ -0,0 +1,3 @@ +import { factories } from "@strapi/strapi"; + +export default factories.createCoreRouter("api::email-template.email-template"); diff --git a/apps/cms/src/api/email-template/services/email-template.ts b/apps/cms/src/api/email-template/services/email-template.ts new file mode 100644 index 0000000..193130d --- /dev/null +++ b/apps/cms/src/api/email-template/services/email-template.ts @@ -0,0 +1,5 @@ +import { factories } from "@strapi/strapi"; + +export default factories.createCoreService( + "api::email-template.email-template", +); diff --git a/apps/cms/src/api/package/content-types/package/schema.json b/apps/cms/src/api/package/content-types/package/schema.json index b75464f..9da7c3f 100644 --- a/apps/cms/src/api/package/content-types/package/schema.json +++ b/apps/cms/src/api/package/content-types/package/schema.json @@ -101,6 +101,68 @@ "targetField": "name", "required": true, "regex": "^[A-Za-z0-9-_.~@]*$" + }, + "overall_status": { + "type": "enumeration", + "enum": [ + "submitted", + "under_review", + "changes_requested", + "rejected", + "approved" + ] + }, + "business_review_status": { + "type": "enumeration", + "enum": ["pending", "approved", "rejected"] + }, + "security_review_status": { + "type": "enumeration", + "enum": ["pending", "approved", "rejected"] + }, + "reviewer_feedback": { + "type": "text" + }, + "rejection_reason": { + "type": "text" + }, + "business_review_notes": { + "type": "text" + }, + "security_review_notes": { + "type": "text" + }, + "automated_check_results": { + "type": "json" + }, + "security_scan_status": { + "type": "enumeration", + "enum": ["pending", "running", "completed", "failed"] + }, + "security_scan_started_at": { + "type": "datetime" + }, + "security_scan_run_at": { + "type": "datetime" + }, + "security_scan_dependencies": { + "type": "json" + }, + "security_scan_ai_analysis": { + "type": "json" + }, + "security_scan_summary": { + "type": "json" + }, + "submitter_ip": { + "type": "string" + }, + "submitter_agreed_to_terms": { + "type": "boolean", + "default": false + }, + "submission_notes": { + "type": "text" } } } diff --git a/apps/cms/src/api/template/content-types/template/schema.json b/apps/cms/src/api/template/content-types/template/schema.json index 1a8981e..d435984 100644 --- a/apps/cms/src/api/template/content-types/template/schema.json +++ b/apps/cms/src/api/template/content-types/template/schema.json @@ -90,6 +90,68 @@ "targetField": "name", "required": true, "regex": "^[A-Za-z0-9-_.~@]*$" + }, + "overall_status": { + "type": "enumeration", + "enum": [ + "submitted", + "under_review", + "changes_requested", + "rejected", + "approved" + ] + }, + "business_review_status": { + "type": "enumeration", + "enum": ["pending", "approved", "rejected"] + }, + "security_review_status": { + "type": "enumeration", + "enum": ["pending", "approved", "rejected"] + }, + "reviewer_feedback": { + "type": "text" + }, + "rejection_reason": { + "type": "text" + }, + "business_review_notes": { + "type": "text" + }, + "security_review_notes": { + "type": "text" + }, + "automated_check_results": { + "type": "json" + }, + "security_scan_status": { + "type": "enumeration", + "enum": ["pending", "running", "completed", "failed"] + }, + "security_scan_started_at": { + "type": "datetime" + }, + "security_scan_run_at": { + "type": "datetime" + }, + "security_scan_dependencies": { + "type": "json" + }, + "security_scan_ai_analysis": { + "type": "json" + }, + "security_scan_summary": { + "type": "json" + }, + "submitter_ip": { + "type": "string" + }, + "submitter_agreed_to_terms": { + "type": "boolean", + "default": false + }, + "submission_notes": { + "type": "text" } } } diff --git a/apps/cms/src/index.ts b/apps/cms/src/index.ts index b1707cf..730e201 100644 --- a/apps/cms/src/index.ts +++ b/apps/cms/src/index.ts @@ -1,27 +1,17 @@ -// import type { Core } from '@strapi/strapi'; +import type { Core } from "@strapi/strapi"; import { migrateIntegrations } from "./migration/integrations"; import { migratePartners } from "./migration/partners"; import { migratePlugins } from "./migration/plugins"; import { migrateProviders } from "./migration/providers"; import { migrateShowcases } from "./migration/showcases"; +import { seedEmailTemplates } from "./seed/email-templates"; export default { - /** - * An asynchronous register function that runs before - * your application is initialized. - * - * This gives you an opportunity to extend code. - */ register(/* { strapi }: { strapi: Core.Strapi } */) {}, - /** - * An asynchronous bootstrap function that runs before - * your application gets started. - * - * This gives you an opportunity to set up your data model, - * run jobs, or perform some special logic. - */ - async bootstrap(/* { strapi }: { strapi: Core.Strapi } */) { + async bootstrap({ strapi }: { strapi: Core.Strapi }) { + await seedEmailTemplates(strapi); + if (process.env.ENABLE_MIGRATION !== "true") { return; } diff --git a/apps/cms/src/plugins/moderation/admin/custom.d.ts b/apps/cms/src/plugins/moderation/admin/custom.d.ts new file mode 100644 index 0000000..5b0b4a0 --- /dev/null +++ b/apps/cms/src/plugins/moderation/admin/custom.d.ts @@ -0,0 +1,26 @@ +import type { StrapiTheme } from "@strapi/design-system"; + +declare module "styled-components" { + export interface DefaultTheme extends StrapiTheme {} +} + +export interface BrowserStrapi { + backendURL: string; + isEE: boolean; + features: { + SSO: "sso"; + AUDIT_LOGS: "audit-logs"; + REVIEW_WORKFLOWS: "review-workflows"; + isEnabled: (featureName?: string) => boolean; + }; + isTrialLicense: boolean; + flags: { promoteEE?: boolean; nps?: boolean }; + projectType: "Community" | "Enterprise"; + telemetryDisabled: boolean; +} + +declare global { + interface Window { + strapi: BrowserStrapi; + } +} diff --git a/apps/cms/src/plugins/moderation/admin/src/components/ReviewPanel/index.tsx b/apps/cms/src/plugins/moderation/admin/src/components/ReviewPanel/index.tsx new file mode 100644 index 0000000..eb2e771 --- /dev/null +++ b/apps/cms/src/plugins/moderation/admin/src/components/ReviewPanel/index.tsx @@ -0,0 +1,590 @@ +import { + Box, + Button, + Field, + Flex, + SingleSelect, + SingleSelectOption, + Textarea, + Typography, +} from "@strapi/design-system"; +import { ChevronDown } from "@strapi/icons"; +import { useFetchClient, useNotification } from "@strapi/strapi/admin"; +import { useState } from "react"; +import { useMutation } from "react-query"; + +interface Props { + documentId: string; + initialBusinessStatus: "pending" | "approved" | "rejected"; + initialSecurityStatus: "pending" | "approved" | "rejected"; + initialOverallStatus: string; + initialFeedback?: string; + initialRejectionReason?: string; + initialBusinessNotes?: string; + initialSecurityNotes?: string; + onSaved: () => void; +} + +type ReviewStatus = "pending" | "approved" | "rejected"; + +const REVIEW_STYLES: Record< + ReviewStatus, + { bg: string; text: string; dot: string } +> = { + pending: { bg: "neutral150", text: "neutral600", dot: "#c0c0c0" }, + approved: { bg: "success100", text: "success600", dot: "#27ae60" }, + rejected: { bg: "danger100", text: "danger600", dot: "#e74c3c" }, +}; + +const StatusPill = ({ status }: { status: ReviewStatus }) => { + const s = REVIEW_STYLES[status]; + return ( + + + + + {status} + + + + ); +}; + +export const ReviewPanel = ({ + documentId, + initialBusinessStatus, + initialSecurityStatus, + initialOverallStatus, + initialFeedback, + initialRejectionReason, + initialBusinessNotes, + initialSecurityNotes, + onSaved, +}: Props) => { + const { put, post } = useFetchClient(); + const { toggleNotification } = useNotification(); + + const [businessStatus, setBusinessStatus] = useState( + initialBusinessStatus, + ); + const [securityStatus, setSecurityStatus] = useState( + initialSecurityStatus, + ); + const [businessNotes, setBusinessNotes] = useState( + initialBusinessNotes || "", + ); + const [securityNotes, setSecurityNotes] = useState( + initialSecurityNotes || "", + ); + const [feedback, setFeedback] = useState(initialFeedback || ""); + const [rejectionReason, setRejectionReason] = useState( + initialRejectionReason || "", + ); + const [openSection, setOpenSection] = useState< + "business" | "security" | null + >(null); + + const toggleSection = (section: "business" | "security") => + setOpenSection((prev) => (prev === section ? null : section)); + + const bothApproved = + businessStatus === "approved" && securityStatus === "approved"; + const isAlreadyApproved = initialOverallStatus === "approved"; + const isAlreadyRejected = initialOverallStatus === "rejected"; + const isLocked = isAlreadyApproved || isAlreadyRejected; + + const { mutate: saveReview, isLoading: saving } = useMutation( + async () => { + await put(`/moderation/submissions/${documentId}/review`, { + data: { + business_review_status: businessStatus, + security_review_status: securityStatus, + business_review_notes: businessNotes, + security_review_notes: securityNotes, + reviewer_feedback: feedback, + rejection_reason: rejectionReason, + }, + }); + }, + { + onSuccess() { + toggleNotification({ type: "success", message: "Review saved." }); + onSaved(); + }, + onError(err: unknown) { + toggleNotification({ + type: "danger", + message: + err instanceof Error ? err.message : "Failed to save review.", + }); + }, + }, + ); + + const { mutate: approve, isLoading: approving } = useMutation( + async () => { + await put(`/moderation/submissions/${documentId}/review`, { + data: { + business_review_status: businessStatus, + security_review_status: securityStatus, + business_review_notes: businessNotes, + security_review_notes: securityNotes, + overall_status: "approved", + }, + }); + }, + { + onSuccess() { + toggleNotification({ + type: "success", + message: "Submission approved.", + }); + onSaved(); + }, + onError(err: unknown) { + toggleNotification({ + type: "danger", + message: err instanceof Error ? err.message : "Failed to approve.", + }); + }, + }, + ); + + const { mutate: promote, isLoading: promoting } = useMutation( + async () => { + await post(`/moderation/submissions/${documentId}/promote`, {}); + }, + { + onSuccess() { + toggleNotification({ + type: "success", + message: "Package entry created. Open Content Manager to publish.", + }); + onSaved(); + }, + onError(err: unknown) { + toggleNotification({ + type: "danger", + message: err instanceof Error ? err.message : "Promotion failed.", + }); + }, + }, + ); + + const { mutate: reject, isLoading: rejecting } = useMutation( + async () => { + await post(`/moderation/submissions/${documentId}/decide`, { + data: { status: "rejected", reason: rejectionReason, feedback }, + }); + }, + { + onSuccess() { + toggleNotification({ + type: "success", + message: "Submission rejected.", + }); + onSaved(); + }, + onError(err: unknown) { + toggleNotification({ + type: "danger", + message: err instanceof Error ? err.message : "Failed to reject.", + }); + }, + }, + ); + + const { mutate: requestChanges, isLoading: requesting } = useMutation( + async () => { + await post(`/moderation/submissions/${documentId}/decide`, { + data: { status: "changes_requested", feedback }, + }); + }, + { + onSuccess() { + toggleNotification({ type: "success", message: "Changes requested." }); + onSaved(); + }, + onError(err: unknown) { + toggleNotification({ + type: "danger", + message: + err instanceof Error ? err.message : "Failed to request changes.", + }); + }, + }, + ); + + return ( + + {/* Panel header */} + + + Review Decision + {!isLocked && ( + + )} + + + + {/* Business Review — accordion */} + + + + {openSection === "business" && ( + + + + + Status + setBusinessStatus(val as ReviewStatus)} + disabled={isLocked} + > + + Pending + + + Approved + + + Rejected + + + + + + Internal Notes +