{
  "name": "Aiopta Addon Marketplace",
  "generatedAt": "2026-08-14T13:32:52.684Z",
  "registry": "Aiopta/aiopta-addons",
  "pricingTiersSchemaVersion": 1,
  "count": 77,
  "addons": [
    {
      "id": "accounting",
      "uuid": "4891152c-ab52-479f-aea5-68fd61faf29d",
      "name": "Accounting (Einnahmen/Ausgaben/Belege/USt/DATEV)",
      "version": "1.2.1",
      "description": "Per-User Buchhaltung: Einnahmen & Ausgaben, Belege, USt-Voranmeldung und DATEV-CSV-Export. Integriert lesend mit dem payments-Addon (bezahlte Rechnungen fließen als steuerpflichtige Erlöse in die USt ein, ohne payments zu verändern). Lokale SQLite ist die Quelle der Wahrheit (ADR-054).",
      "author": "Aiopta",
      "icon": "🧾",
      "capabilities": [
        "accounting",
        "income",
        "expenses",
        "receipts",
        "vat",
        "reports",
        "datev-export"
      ],
      "tags": [],
      "download": "/artifacts/accounting-1.2.1.zip",
      "sha256": "df6fce4940b52feaa4373dce4b790d6698ac95da056daa82055be007be611fbc",
      "size": 152301,
      "page": {
        "id": "accounting-page",
        "path": "/addons/accounting",
        "name": "Accounting",
        "icon": "🧾",
        "section": "Money",
        "component": "@addons/accounting/src/components/AccountingPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/accounting/transactions",
        "/api/v1/addons/accounting/transactions/:id",
        "/api/v1/addons/accounting/receipts",
        "/api/v1/addons/accounting/reports/summary",
        "/api/v1/addons/accounting/vat",
        "/api/v1/addons/accounting/datev"
      ],
      "skills": [
        {
          "name": "accounting-read",
          "displayName": "Accounting: Buchungen, Belege & USt lesen",
          "description": "Listet Einnahmen/Ausgaben-Buchungen, Belege, die Monatsübersicht und die USt-Voranmeldung für den Nutzer. Reiner Lesezugriff — bucht nichts.",
          "permissions": [
            "addons/accounting/transactions",
            "addons/accounting/receipts",
            "addons/accounting/reports",
            "addons/accounting/vat"
          ],
          "endpoint": "/api/v1/addons/accounting/reports/summary",
          "scripts": []
        },
        {
          "name": "accounting-book",
          "displayName": "Accounting: Buchungen & Belege erfassen",
          "description": "Erfasst Einnahmen-/Ausgaben-Buchungen und Belege und löscht Buchungen für den Nutzer (per-User gebucht).",
          "permissions": [
            "addons/accounting/transactions",
            "addons/accounting/receipts"
          ],
          "endpoint": "/api/v1/addons/accounting/transactions",
          "scripts": []
        },
        {
          "name": "accounting-export",
          "displayName": "Accounting: DATEV-CSV-Export",
          "description": "Erzeugt einen DATEV-CSV-Buchungsstapel (Jahr) aus den Buchungen des Nutzers.",
          "permissions": [
            "addons/accounting/datev"
          ],
          "endpoint": "/api/v1/addons/accounting/datev",
          "scripts": []
        }
      ],
      "mcpServers": [
        {
          "id": "accounting-tools",
          "displayName": "Accounting tools",
          "description": "Read-only accounting math for agents: German VAT (USt/Vorsteuer) report aggregation, gross/net conversion and money formatting — the same pure functions the accounting addon uses for its VAT report and DATEV export (issue #400, epic #399). Books nothing.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "accounting-tools/server.mjs",
          "permissions": [
            "addons/accounting/reports"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [],
          "credentialRefs": [],
          "archiveEntry": "accounting/mcp/servers/accounting-tools/server.mjs",
          "sha256": "75405d9c7ab616072de7de7abe8c08132361ad8e31039872d2df5573def66134",
          "size": 18789,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>🧾 Accounting (Einnahmen/Ausgaben/Belege/USt/DATEV)</h1>\n<p>Per-User Buchhaltung: Einnahmen &amp; Ausgaben, Belege, USt-Voranmeldung und DATEV-CSV-Export. Integriert lesend mit dem payments-Addon (bezahlte Rechnungen fließen als steuerpflichtige Erlöse in die USt ein, ohne payments zu verändern). Lokale SQLite ist die Quelle der Wahrheit (ADR-054).</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>accounting</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> accounting, income, expenses, receipts, vat, reports, datev-export</li><li><strong>UI-Seite:</strong> <code>/addons/accounting</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| POST | <code>/api/v1/addons/accounting/datev</code> |<br />| GET, POST | <code>/api/v1/addons/accounting/receipts</code> |<br />| GET | <code>/api/v1/addons/accounting/reports</code> |<br />| GET, POST | <code>/api/v1/addons/accounting/transactions</code> |<br />| DELETE | <code>/api/v1/addons/accounting/transactions/[id]</code> |<br />| GET | <code>/api/v1/addons/accounting/vat</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>Keine bekannten Einschränkungen.</li></ul>\n<blockquote><p>README aus <code>addon.json</code> + vorhandenen Routen generiert (<code>scripts/generate-addon-readmes.js</code>). Bei manuellen Ergänzungen den Generator-Hinweis entfernen.</p></blockquote>",
          "byteSize": 1197
        }
      }
    },
    {
      "id": "aiopta-base-skill",
      "uuid": "ebbb02af-670b-49a4-811b-9990e9da2420",
      "name": "Aiopta Basisskill",
      "version": "1.2.1",
      "description": "Versioned, secret-free meta-skill that every newly created agent receives across UI, API, Template, Onboarding and Import/Seed creation paths. Single canonical source of truth for auth API, error handling, permission request and MCP tool usage — never re-implemented per controller (issues #105, #398).",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "agent-skills",
        "base-skill",
        "meta-skill"
      ],
      "tags": [],
      "download": "/artifacts/aiopta-base-skill-1.2.1.zip",
      "sha256": "cda43ffd26d8ba9d2f2f245d53efa4e48e28f02f3f8bf052f904a88be9bf1404",
      "size": 25355,
      "page": null,
      "deprecated": false,
      "endpoints": [],
      "skills": [
        {
          "name": "aiopta-base",
          "displayName": "Aiopta Base Skill (meta)",
          "description": "Versioned, secret-free meta-skill: describes how the agent authenticates against the Aiopta API, how it reports errors, how it requests permissions and how it discovers and uses MCP tools — both addon-delivered servers and the three core-MCP surfaces (read-only, mutating, and the dedicated Tier-3 surface for sensitive/destructive tools) — including the permission-denied contract. Reused across UI / API / Template / Onboarding / Import / Seed creation paths; existing agents are migrated idempotently to the latest version (issues #105, #398). Meta-skill — declares no executable scripts and no addon-specific endpoint.",
          "permissions": [],
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Aiopta Base Skill (issues #105/#398)</h1>\n<p>This addon is the <strong>single versioned source of truth</strong> for the<br /><em>Aiopta Basisskill</em> — the secret-free meta-skill that every newly created<br />agent receives across <strong>UI / API / Template / Onboarding / Import / Seed</strong><br />creation paths.</p>\n<p>| Property | Value |<br />|----------|-------|<br />| Addon id | <code>aiopta-base-skill</code> |<br />| Skill name | <code>aiopta-base</code> |<br />| Current version | 1.2.0 |<br />| Skill scripts | none (meta-skill, not a capability) |<br />| Endpoint | none (no runtime, doc-only descriptor) |<br />| Secrets / tokens | <strong>none</strong> — explicitly forbidden by the validator |<br />| Hard-coded URLs | <strong>none</strong> — agent fills the instance URL from its own config |</p>\n<h2>Why this addon exists</h2>\n<p>Without a canonical base skill, each agent-creation controller (UI form,<br /><code>POST /api/v1/agents</code>, template clone, onboarding wizard, seed import)<br />would have to hand-roll its own auth/error/permission boilerplate. That<br />guarantees drift — one controller forgets to forward the bearer token,<br />another forgets to translate a <code>429</code> into a retry, and the agents stop<br />behaving consistently.</p>\n<p>The base skill ships the <strong>description</strong> of how an agent authenticates,<br />handles errors, asks for permissions and uses MCP tools. The agent (a<br />separate runtime,<br />out of scope for this addon) consumes that description and renders it<br />into the same prompt / system-message across every creation path.</p>\n<p>Existing agents are migrated <strong>idempotently</strong>: the migration script<br />(<code>scripts/migrate-base-skill.cjs</code>) reads the addon catalog, compares<br />the on-file version against the version each agent was provisioned<br />with, and either upgrades the reference or leaves the agent untouched<br />(user customisations are never overwritten — see the<br />Migration section below).</p>\n<h2>What is in the skill</h2>\n<p>The single <code>skills[]</code> entry on this addon (<code>name: aiopta-base</code>) carries<br />the documented behaviour. It is a <strong>descriptor only</strong> — no scripts, no<br />endpoint, no runtime. The descriptor is consumed by core&#39;s<br /><code>src/lib/agents/provision.ts</code> (linked Core-PR, out of scope for this<br />addon repo).</p>\n<p>Four things the descriptor specifies (see <code>SKILL.md</code> for the full text<br />the agent actually receives):</p>\n<p>1. <strong>Auth API</strong> — every agent calls the Aiopta API with<br />   <code>Authorization: Bearer &lt;its own key&gt;</code>, never with an addon secret.<br />   The key is per-agent and provisioned by the canonical<br />   <code>createAgent(...)</code> helper on the core side, not by this addon.<br />2. <strong>Error handling</strong> — <code>4xx</code> is reported verbatim (status + body);<br />   <code>429</code> triggers a single retry with exponential backoff (max 3);<br />   <code>5xx</code> is surfaced to the user with the request trace id; no silent<br />   fallback that hides a permanent failure.<br />3. <strong>Permission request</strong> — the agent enumerates the permissions it<br />   needs <em>before</em> the first call, asks the user once, and stores the<br />   answer in the agent&#39;s permission record. Per-call <code>403</code> is treated<br />   as &quot;permission missing → request it&quot;, never as a fatal error.<br />4. <strong>MCP tools</strong> (issue #398) — the agent uses only the MCP tools that<br />   core projects into its tool registry (capability-filtered,<br />   aiopta-core#417). This covers both addon-delivered servers and the<br />   three shipped <strong>core-MCP</strong> surfaces (aiopta-core#467/#468/#480,<br />   docs/CORE-MCP.md): <code>mcp:core:read:*</code> (15 read-only tools),<br />   <code>mcp:core:write:*</code> (11 mutating tools) and the dedicated Tier-3<br />   surface <code>mcp:core:tier3:*</code> (4 sensitive/destructive tools). §4.4 of<br />   <code>SKILL.md</code> documents the surface, that destructive/Tier-3 tools need an<br />   exact <code>core/…</code> grant (no wildcard), and that the permission-denied<br />   contract for core tools equals §4.3 — a <code>denied</code> core call is never<br />   retried, escalated, or side-stepped via the raw route; the agent<br />   informs the user once and stops.</p>\n<h2>Migration</h2>\n<pre><code class=\"lang-bash\"># Verify (default — read-only, exits non-zero if invariants violated)\n./scripts/migrate-base-skill.sh --check</code></pre>\n<p># Apply (idempotent — second run reports &quot;no change&quot;)<br />./scripts/migrate-base-skill.sh --apply<br />```</p>\n<p>The script:</p>\n<p>1. Reads <code>addons/aiopta-base-skill/addon.json</code> and confirms the skill<br />   shape (one skill, no scripts, no endpoint, secret-free).<br />2. Reads <code>SKILL.md</code> and runs the secret / URL / host literal guard.<br />3. Reads <code>dist/marketplace/catalog.json</code> (if present) and confirms the<br />   base skill is discoverable under id <code>aiopta-base-skill</code>.<br />4. Writes the current &quot;applied&quot; state to<br />   <code>dist/migrate-base-skill.json</code>. A subsequent run with the same<br />   inputs is a no-op.</p>\n<p>On the <strong>addons repo</strong> the migration is purely catalog-side: the source<br />of truth for &quot;which base-skill version is current&quot; is this addon&#39;s<br /><code>addon.json</code>. On the <strong>core repo</strong> (out of scope) the same script is<br />invoked from <code>src/lib/agents/provision.ts</code> to backfill existing agents.</p>\n<h2>Provenance / version visibility</h2>\n<p>The catalog entry for this addon (built by <code>npm run build:catalog</code>)<br />exposes the version under <code>addons[].version</code>. The core API surfaces<br />the same version per agent under<br /><code>GET /api/v1/agents/:id → skills[][].addonVersion</code>, so an operator can<br />always tell which base-skill a given agent is running.</p>\n<h2>Hard negatives</h2>\n<p>The base skill must NEVER contain:</p>\n<p>- API keys, bearer tokens, OAuth refresh tokens, basic-auth credentials<br />  of any shape (the validator scans the descriptor body for these).<br />- Hard-coded instance URLs (the agent fills the URL from its own<br />  config; baking one in would tie every agent to a single deployment).<br />- Hard-coded host names or IPs (same reason).<br />- Addon-specific skills (the base skill is meta — it points the agent<br />  at the per-addon skill, it does not embed one).<br />- Duplicates — there is exactly one base skill version active at any<br />  time; older versions are upgraded, not stacked.</p>",
          "byteSize": 5686
        },
        "changelog": {
          "filename": "CHANGELOG.md",
          "sourcePath": "CHANGELOG.md",
          "rendered": "<h1>Changelog — aiopta-base-skill</h1>\n<p>All notable changes to the Aiopta Basisskill are documented here. The base<br />skill follows semantic versioning: a <code>MAJOR</code> bump means every agent must be<br />re-provisioned (auth API or permission model changed), a <code>MINOR</code> bump means<br />advisable but non-breaking (a new §3 rule, a new error class), a <code>PATCH</code> bump<br />means documentation / typo fixes only.</p>\n<h2>1.2.0 — 2026-07-07 (issue #398)</h2>\n<p>MINOR — advisable but non-breaking. Existing agents are upgraded idempotently<br />via <code>scripts/migrate-base-skill.sh --apply</code> (the #105 hook); user<br />customisations are never overwritten.</p>\n<p>- §4 now documents the <strong>concrete</strong> core-MCP tool surface (the generic §4<br />  from 1.1.0 said &quot;once it ships&quot; — it has now shipped: aiopta-core<br />  #467/#468/#480 are merged, docs/CORE-MCP.md + ADR-075). New §4.4<br />  &quot;Core-MCP tools (Aiopta itself, shipped)&quot;:<br />  - The three core surfaces and their tool-id namespaces —<br />    <code>mcp:core:read:*</code> (15 read-only tools, #467), <code>mcp:core:write:*</code><br />    (11 mutating tools, #468) and the dedicated Tier-3 surface<br />    <code>mcp:core:tier3:*</code> (4 sensitive/destructive tools, #480). Reserved<br />    addon id <code>core</code>; each tool is a thin wrapper over one existing Aiopta<br />    route.<br />  - How the agent discovers/uses them: identical to any addon MCP tool<br />    (§4.1–§4.3) — projected into the registry capability-filtered, called by<br />    id, never probed/spawned, inputs carry no credential/URL/identity.<br />  - Why some core tools are harder to obtain: destructive write tools need<br />    the exact <code>core/…</code> grant verbatim (no <code>*</code>/<code>core/*</code>), and every Tier-3<br />    tool needs its own exact capability (a <code>mcp:core:write</code> grant unlocks<br />    none) and is owner-only to delegate.<br />  - The permission-denied contract for core tools is the §4.3 contract,<br />    stated explicitly for the core surface: a <code>denied</code>/<code>not_available</code> core<br />    result is never retried, never escalated, never side-stepped via the raw<br />    <code>/api/v1/...</code> route, and never worked around by obtaining a<br />    destructive/Tier-3 grant — the agent informs the user once and stops.<br />    Core enforces server-side on every call and audits denials + every<br />    mutation (<code>core.mcp.tool.denied</code> / <code>core.mcp.tool.ok</code>).<br />- Unchanged: auth API (§1), error handling (§2), permission request (§3),<br />  the addon-MCP rules (§4.1–§4.3) and the domain rule — no hard-coded<br />  URLs/hosts; the agent fills the instance URL from its own runtime config.<br />- <code>EXPECTED_BASESKILL_VERSION</code> bumped to <code>1.2.0</code> in<br />  <code>scripts/migrate-base-skill.cjs</code>.</p>\n<h2>1.1.0 — 2026-07-06 (issue #398)</h2>\n<p>MINOR — advisable but non-breaking. Existing agents are upgraded idempotently<br />via <code>scripts/migrate-base-skill.sh --apply</code> (the #105 hook); user<br />customisations are never overwritten.</p>\n<p>- New §4 &quot;MCP tools (addon-delivered and core)&quot;: how the agent discovers<br />  addon-delivered MCP tools (capability-filtered projection into the<br />  canonical tool registry, aiopta-core#417), how it uses them (server-side<br />  re-authorization per call, fail-closed, no credentials/URLs in tool<br />  arguments), and that the core-MCP (aiopta-core#466/#467) follows exactly<br />  the same rules once it ships.<br />- New §4.3 permission-denied contract: a denied MCP tool call is never<br />  retried, never escalated or worked around via another tool; the agent<br />  informs the user once and stops attempting the call.<br />- Renamed the legacy &quot;Aiopta API&quot; wording to &quot;Aiopta API&quot; (naming only, no<br />  semantic change).<br />- Sections renumbered: Provenance is now §5, Hard-negative scope is now §6.<br />- Unchanged: auth API (§1), error handling (§2), permission request (§3)<br />  and the domain rule — no hard-coded URLs/hosts; the agent fills the<br />  instance URL from its own runtime config.<br />- <code>EXPECTED_BASESKILL_VERSION</code> bumped to <code>1.1.0</code> in<br />  <code>scripts/migrate-base-skill.cjs</code>.</p>\n<h2>1.0.0 — 2026-06-23 (issue #105)</h2>\n<p>Initial release. Defines the single, versioned, secret-free Aiopta Basisskill:</p>\n<p>- One skill descriptor: <code>name: aiopta-base</code>, no scripts, no endpoint, no<br />  permissions of its own (meta-skill).<br />- Three documented behaviours: auth API, error handling, permission request.<br />- Hard-negative scope (validator-enforced): no secrets, no hard-coded URLs,<br />  no hard-coded hosts, no addon-specific skills, no scripts.<br />- Migration script <code>scripts/migrate-base-skill.cjs</code> (+ <code>.sh</code> wrapper + tests)<br />  for idempotent backfill of existing agents and upgrade on version bumps.<br />- Catalog entry under id <code>aiopta-base-skill</code> for marketplace discovery.</p>",
          "byteSize": 4457
        }
      }
    },
    {
      "id": "analytics",
      "uuid": "689f3fef-a773-47da-905f-5e037954ad69",
      "name": "Analytics",
      "version": "1.0.7",
      "description": "Usage analytics, cost tracking, performance metrics, and report generation.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "analytics",
        "reports",
        "usage-events",
        "cost-tracking",
        "observability"
      ],
      "tags": [],
      "download": "/artifacts/analytics-1.0.7.zip",
      "sha256": "4839ede3b0f5b08d297184011a1e0884cbe38989bc24799549f04f473985f20f",
      "size": 270613,
      "page": {
        "id": "analytics",
        "path": "/addons/analytics",
        "name": "Analytics",
        "icon": "📈",
        "section": "Overview",
        "component": "@addons/analytics/src/components/AnalyticsPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/analytics/activity",
        "/api/v1/addons/analytics/costs",
        "/api/v1/addons/analytics/dashboard",
        "/api/v1/addons/analytics/events",
        "/api/v1/addons/analytics/observability",
        "/api/v1/addons/analytics/performance",
        "/api/v1/addons/analytics/reports",
        "/api/v1/addons/analytics/reports/{id}",
        "/api/v1/addons/analytics/reports/export"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Analytics</h1>\n<p>Usage analytics, cost tracking, performance metrics, report generation, and a central<br /><strong>Observability-Dashboard</strong> (C5, ADR-033).</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>analytics</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> analytics, reports, usage-events, cost-tracking, observability</li><li><strong>UI-Seite:</strong> <code>/analytics</code> (Tab <strong>Observability</strong> für Nutzung/Kosten/Fehler/Kontingent)</li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET | <code>/api/v1/addons/analytics/activity</code> |<br />| GET | <code>/api/v1/addons/analytics/costs</code> |<br />| GET | <code>/api/v1/addons/analytics/dashboard</code> |<br />| GET, POST | <code>/api/v1/addons/analytics/events</code> |<br />| GET | <code>/api/v1/addons/analytics/observability</code> |<br />| GET | <code>/api/v1/addons/analytics/performance</code> |<br />| GET, POST | <code>/api/v1/addons/analytics/reports</code> |<br />| GET, PUT, DELETE | <code>/api/v1/addons/analytics/reports/[id]</code> |<br />| GET | <code>/api/v1/addons/analytics/reports/export</code> |</p>\n<h2>Observability-Dashboard (C5, ADR-033)</h2>\n<p>Zentrale, <strong>read-only</strong> Aggregation bereits vorhandener Daten über einen wählbaren Zeitraum<br />(24 h / 7 / 30 / 90 Tage):</p>\n<p>- <strong>Agenten-/LLM-Nutzung</strong> — Anzahl + Token-Schätzung aus Phase-Q <code>usage_counters</code>.<br />- <strong>Kosten</strong> — aus den erfassten <code>cost_entries</code> (klar als <strong>Schätzung</strong> markiert, keine Abrechnung).<br />- <strong>Fehler/Aktivität</strong> — aus dem <code>audit_log</code>, gruppiert nach <code>action</code> (die Tabelle hat <strong>keine</strong><br />  Severity-Spalte → Fehler werden anhand des Aktionsnamens erkannt, z. B. <code>*.failed</code>/<code>*.denied</code>).<br />- <strong>Kontingent-Status</strong> — Verbrauch vs. Tarif-Limit aus den Entitlements/Quotas.</p>\n<p>Scope ist rollenbewusst: ein normaler Benutzer sieht <strong>nur eigene</strong> Daten, ein Admin standardmäßig die<br /><strong>ganze Instanz</strong> (mit Opt-down auf <code>?scope=user</code>). Die Route ist die Autorität — ein Benutzer kann den<br />Scope nicht erweitern. KPIs, Charts (hand-gezeichnetes SVG/CSS — <strong>keine</strong> neue Charting-Dependency) und<br />Tabellen. Server liest <code>getDb</code>; die Tab-Komponente nutzt ausschließlich <code>apiFetch</code> (kein <code>getDb</code>/<code>fs</code> im<br />Client). SQL ist durchgehend parametrisiert; es werden keine Secrets gelesen oder geloggt.</p>\n<pre><code class=\"lang-bash\"># Instanzweit (Admin), 30-Tage-Fenster\ncurl -s -H &quot;Authorization: Bearer $AIOPTA_API_KEY&quot; \\\n  &quot;https://&lt;instance&gt;/api/v1/addons/analytics/observability?scope=instance&amp;windowDays=30&quot;</code></pre>\n<p># Nur eigene Daten<br />curl -s -H &quot;Authorization: Bearer $AIOPTA_API_KEY&quot; \\<br />  &quot;<a href=\"https://&amp;lt;instance&amp;gt;/api/v1/addons/analytics/observability?scope=user&amp;amp;windowDays=7&amp;quot;\" rel=\"noopener noreferrer\">https://&amp;lt;instance&amp;gt;/api/v1/addons/analytics/observability?scope=user&amp;amp;windowDays=7&amp;quot;</a><br />```</p>\n<h2>Event-Bus-Contributor-Feed (#142 / INBOX-65, ADR-053 + ADR-057-Addendum)</h2>\n<p>Zusätzlich zum direkten Schreibpfad (<code>POST /events</code> → <code>recordUsageEvent</code>) speist analytics<br />Agent-Aktivitäts-Metriken <strong>passiv über den in-process Event-Bus</strong>: der Collector<br />(<code>src/lib/activity-collector.ts</code>) abonniert <strong>lazy + idempotent</strong> im Addon-<code>install()</code> (globalThis-<br />Singleton, <strong>kein</strong> Boot-Hook — spiegelt den artefakt-workspace-Collector) und schreibt für jedes<br />Agent-Output-Event eine per-User-<code>usage_events</code>-Zeile (<code>event_type=&#39;agent.activity&#39;</code>), sobald es<br />passiert. So füllen sich die bestehenden Dashboard-Metriken (Top-Agenten, Trends) aus echter<br />Agent-Aktivität — additiv, die Bestandspfade bleiben unverändert.</p>\n<p>- <strong>Abonnierte Events:</strong> <code>agent.output.produced</code> (kanonisches generisches Signal) und<br />  <code>development.session.completed</code> (trägt <code>userId</code> + <code>status</code>). Ein <strong>fehlgeschlagener</strong> Run<br />  wird als Error markiert (<code>metadata.isError</code>).<br />- <strong>Ownership (ADR-054):</strong> der Besitzer kommt IMMER aus dem Event-Payload (<code>userId</code>/<code>user_id</code>/<br />  <code>ownerId</code>), NIE aus einem Client-Wert; ein Event ohne erkennbaren User wird <strong>übersprungen</strong><br />  (geloggt), nicht geraten. Der Write läuft über <code>scopedStore(userId)</code> ⇒ <code>user_id</code> server-seitig<br />  erzwungen.<br />- <strong>Per-User-Feed:</strong> <code>GET /api/v1/addons/analytics/activity</code> (<code>userRoute</code>) liefert die <strong>eigenen</strong><br />  <code>agent.activity</code>-Zeilen (Filter <code>event_type</code>/<code>agent_id</code> + Pagination); B&#39;s Aktivität erscheint nie<br />  in A&#39;s Feed. Die instanzweiten Aggregate (dashboard/observability) bleiben admin-gegated.<br />- <strong>Gap (Park-Kandidat):</strong> <code>business-operator.action.queued</code> und <code>payments.invoice.paid</code> tragen keinen<br />  User-Owner und werden <strong>nicht</strong> abonniert (per-User-Recording würde einen Owner erfinden — ADR-054-<br />  verboten). Per-User-Attribution erfordert eine additive Emit-Anreicherung an der Quelle (Owner-<br />  Entscheidung). <code>agent.output.produced</code> hat noch keinen in-tree-Emitter (Vorwärts-Signal).</p>\n<pre><code class=\"lang-bash\"># Eigener Agent-Aktivitäts-Feed (event-getrieben)\ncurl -s -H &quot;Authorization: Bearer $AIOPTA_API_KEY&quot; \\\n  &quot;https://&lt;instance&gt;/api/v1/addons/analytics/activity?page=1&amp;page_size=60&quot;</code></pre>\n<h2>Bekannte Grenzen</h2>\n<p>- <strong>Kosten = Schätzung</strong> aus erfassten Nutzungsdaten, keine abgerechnete Summe (präzises Pricing geparkt,<br />  OPEN-DECISIONS #52).<br />- <strong>Fehler</strong> werden heuristisch aus dem Aktionsnamen im <code>audit_log</code> abgeleitet (keine Severity-Spalte).<br />- <strong>Nutzungszähler</strong> sind Fenster-Buckets (Tag/Monat/lifetime) → die Nutzung spiegelt die <strong>aktuellen</strong><br />  Zähler, nicht einen beliebigen historischen Bereich.<br />- Kein Export zu externen Observability-/APM-Systemen (Prometheus/Grafana/Datadog) — geparkt<br />  (OPEN-DECISIONS #51).</p>",
          "byteSize": 5160
        }
      }
    },
    {
      "id": "artefakt-workspace",
      "uuid": "d8bda831-16ba-4dac-b545-1747a02e313c",
      "name": "Artefakt-Workspace",
      "version": "1.0.6",
      "description": "A typed, auto-collected gallery of ALL agent outputs — subscribes to the in-process event-bus and turns every produced agent output (image/file/code/text/link/data) into a per-user artefact, then presents a filterable grid to view, open, and delete.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "artefacts",
        "agent-output-gallery",
        "event-bus-consumer"
      ],
      "tags": [],
      "download": "/artifacts/artefakt-workspace-1.0.6.zip",
      "sha256": "8fbe9310c50b7328fb6612b7955d89ff477b4bb3f59dec9bf245128646cfd29d",
      "size": 97994,
      "page": {
        "id": "artefakt-workspace",
        "path": "/addons/artefakt-workspace",
        "name": "Artefakt-Workspace",
        "icon": "🗂️",
        "section": "Data",
        "component": "@addons/artefakt-workspace/src/components/ArtefaktWorkspacePage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/artefakt-workspace",
        "/api/v1/addons/artefakt-workspace/facets",
        "/api/v1/addons/artefakt-workspace/{id}"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Artefakt-Workspace</h1>\n<p>A typed, <strong>auto-collected</strong> gallery of ALL agent outputs. The addon subscribes to the in-process<br />event-bus (ADR-053) and turns every produced agent output into a per-user artefact — passively, with<br />zero manual upload — then presents a filterable grid to view, open, and delete.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>artefakt-workspace</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> artefacts, agent-output-gallery, event-bus-consumer</li><li><strong>UI-Seite:</strong> <code>/addons/artefakt-workspace</code> (Bereich: Data)</li><li><strong>ADR:</strong> ADR-057 · Audit-Zeile <strong>#152</strong> · Issue <strong>#148 / INBOX-71</strong></li></ul>\n<h2>Wie es funktioniert (passiv, event-getrieben)</h2>\n<p>1. Ein Agent erzeugt einen Output (z. B. eine Coding-Session läuft fertig).<br />2. Der Emitter publiziert ein Event auf dem in-process Event-Bus, das die <strong>erzeugende <code>userId</code></strong><br />   (server-seitig gestempelt) und den Output trägt.<br />3. Der <strong>Collector</strong> (<code>src/lib/collector.ts</code>) abonniert die Agent-Output-Events, klassifiziert den<br />   Output nach Typ und legt eine Artefakt-Zeile an — <strong>gestempelt auf den erzeugenden User</strong>.<br />4. Die Galerie zeigt jedem User <strong>nur seine eigenen</strong> Artefakte.</p>\n<p>Abonnierte Events:</p>\n<p>| Event | Quelle | Trägt |<br />|---|---|---|<br />| <code>agent.output.produced</code> | beliebig (kanonisches Signal) | <code>userId</code> + Output (<code>content</code>/<code>url</code>/<code>path</code>/<code>kind</code>/<code>mime</code>/<code>title</code>) |<br />| <code>development.session.completed</code> | development | <code>userId</code> + <code>result</code>/<code>text</code> (additiv ergänzt, #148) |</p>\n<blockquote><p><strong>Ownership-Regel (ADR-054):</strong> Der Besitzer kommt IMMER aus dem Event-Payload-User-Context</p><p>(<code>userId</code>/<code>user_id</code>/<code>ownerId</code>) — nie aus einem Client-Wert. Ein Event ohne erkennbaren User wird</p><p><strong>übersprungen</strong> (geloggt), nicht geraten.</p></blockquote>\n<h2>Typ-Klassifikation</h2>\n<p>Sechs Typen, first-match-wins (am spezifischsten → am allgemeinsten):</p>\n<p>| Typ | Erkannt an |<br />|---|---|<br />| <code>image</code> 🖼️ | Image-MIME, oder url/path mit Bild-Endung |<br />| <code>file</code> 📄 | ein <code>path</code>-Verweis, eine Quelldatei-url, oder ein nicht-Bild-Binär-MIME |<br />| <code>code</code> 💻 | ein <code>language</code>-Hinweis oder ein ```<code> </code>`<code> </code>```-Codeblock |<br />| <code>text</code> 📝 | Fallback für Prosa |<br />| <code>link</code> 🔗 | eine nackte http(s)-url |<br />| <code>data</code> 📊 | Inhalt, der als JSON-Objekt/Array parst |</p>\n<p>Ein expliziter, gültiger <code>kind</code>-Hinweis im Payload gewinnt immer über die Inferenz.</p>\n<h2>API-Endpunkte</h2>\n<p>Basis: <code>/api/v1/addons/artefakt-workspace</code>. Auth: Aiopta-API-Key<br />(<code>-H &quot;Authorization: Bearer $AIOPTA_API_KEY&quot;</code>). Antworten: <code>{ success, data | error }</code>.</p>\n<p>| Methode(n) | Pfad | Zweck |<br />|---|---|---|<br />| GET | <code>/api/v1/addons/artefakt-workspace</code> | Eigene Artefakte listen (Filter: <code>type</code>, <code>source</code>, <code>since</code>, <code>until</code>, <code>page</code>, <code>page_size</code>) |<br />| GET | <code>/api/v1/addons/artefakt-workspace/facets</code> | Pro-Typ-Zähler + Quellen-Liste für die Filterleiste |<br />| GET | <code>/api/v1/addons/artefakt-workspace/[id]</code> | Ein eigenes Artefakt holen (fremde/unbekannte id → 404) |<br />| DELETE | <code>/api/v1/addons/artefakt-workspace/[id]</code> | Ein eigenes Artefakt löschen (owner-scoped; fremd → 404) |</p>\n<h3>Beispiel</h3>\n<pre><code class=\"lang-bash\"># Eigene Code-Artefakte der letzten Woche listen\ncurl -s -H &quot;Authorization: Bearer $AIOPTA_API_KEY&quot; \\\n  &quot;https://&lt;instance&gt;/api/v1/addons/artefakt-workspace?type=code&amp;since=2026-06-09&quot;</code></pre>\n<p># Facetten (für die Filter-Chips)<br />curl -s -H &quot;Authorization: Bearer $AIOPTA_API_KEY&quot; \\<br />  &quot;<a href=\"https://&amp;lt;instance&amp;gt;/api/v1/addons/artefakt-workspace/facets&amp;quot;\" rel=\"noopener noreferrer\">https://&amp;lt;instance&amp;gt;/api/v1/addons/artefakt-workspace/facets&amp;quot;</a></p>\n<p># Ein Artefakt löschen<br />curl -s -X DELETE -H &quot;Authorization: Bearer $AIOPTA_API_KEY&quot; \\<br />  &quot;<a href=\"https://&amp;lt;instance&amp;gt;/api/v1/addons/artefakt-workspace/&amp;lt;id&amp;gt;&amp;quot;\" rel=\"noopener noreferrer\">https://&amp;lt;instance&amp;gt;/api/v1/addons/artefakt-workspace/&amp;lt;id&amp;gt;&amp;quot;</a><br />```</p>\n<h2>Sicherheit</h2>\n<p>- <strong>Per-User isoliert (ADR-054):</strong> Jedes Artefakt gehört dem erzeugenden User; <code>addon_artefacts</code><br />  trägt <code>user_id TEXT NOT NULL</code>, jeder Zugriff läuft über <code>scopedStore(userId)</code>. Man sieht und<br />  löscht nur die EIGENEN Artefakte; eine fremde/unbekannte <code>{id}</code> liefert <strong>404</strong> (nie 403, nie die<br />  fremde Zeile).<br />- <strong>Route-Auth (ADR-056):</strong> Alle Routen sind <code>userRoute</code>/<code>ownedResourceRoute</code>-gewrappt; die<br />  Lösch-Mutation ist owner-scoped.<br />- <strong>Keine Blobs / kein FS-Write:</strong> Es werden nur Metadaten + eine Referenz (url/path) und ein<br />  gekappter Inhalts-Vorschautext (max. 20 000 Zeichen) gespeichert.</p>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — das Addon sammelt automatisch, sobald es installiert/aktiviert ist<br />(die Event-Bus-Subskription wird im <code>install()</code> lazy + idempotent aufgesetzt).</p>",
          "byteSize": 4314
        }
      }
    },
    {
      "id": "backups",
      "uuid": "0816ec0d-e322-4517-99ac-28c6b8ed5e7d",
      "name": "Backups",
      "version": "1.4.1",
      "description": "Full-instance backup and restore: create a single archive containing a logical dump of every database table (incl. app_settings/license/config) plus the files under DATA_DIR plus a manifest with a SHA-256 checksum, download it, and restore from a stored, uploaded, or remote-pulled archive. Archives are ENCRYPTED AT REST (AES-256-GCM, self-managed key) and can be copied off-site to an S3, SFTP, or NAS (WebDAV) remote target (owner creds; gracefully inactive when unset); a restore can pull the archive back from that target. A retention prune keeps the last N / N days (never the last copy). Backup schedules auto-fire (create + prune) on the durable in-process scheduler. Restore is destructive, admin-gated, requires an explicit confirm, takes a pre-restore safety snapshot first, and is never auto-run; extraction is zip-slip/zip-bomb-safe.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "backups",
        "restore",
        "schedules"
      ],
      "tags": [],
      "download": "/artifacts/backups-1.4.1.zip",
      "sha256": "1d6194437665e28f23c38614c5a33ad24ccc773a591a0682911a976711f39a50",
      "size": 360386,
      "page": {
        "id": "backups",
        "path": "/addons/backups",
        "name": "Backups",
        "icon": "💾",
        "section": "Admin",
        "component": "@addons/backups/src/components/BackupPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/backups",
        "/api/v1/addons/backups/{id}",
        "/api/v1/addons/backups/{id}/download",
        "/api/v1/addons/backups/{id}/restore",
        "/api/v1/addons/backups/restore",
        "/api/v1/addons/backups/config",
        "/api/v1/addons/backups/schedules",
        "/api/v1/addons/backups/schedules/{id}"
      ],
      "skills": [
        {
          "name": "backup-create",
          "displayName": "Backups: Create & list full-instance backups",
          "description": "Create a full-instance backup (a single ZIP with a logical dump of every DB table incl. app_settings/license/config, the files under DATA_DIR, and a manifest with a SHA-256 checksum) and list existing backups with their size/date/contents. A backup contains secret settings by nature, so this requires the addon.manage capability. Pass excludeSecrets:true for a sanitized dump.",
          "permissions": [
            "addons/backups/backups"
          ],
          "endpoint": "/api/v1/addons/backups",
          "scripts": []
        },
        {
          "name": "backup-restore",
          "displayName": "Backups: Restore the instance (destructive)",
          "description": "Restore the instance from a stored backup (POST /api/v1/addons/backups/:id/restore), an uploaded archive (POST /api/v1/addons/backups/restore, multipart), or an archive pulled from the configured remote/NAS target (POST /api/v1/addons/backups/restore, JSON { remoteFile, confirm }). DESTRUCTIVE: it overwrites the DB rows + data files. It requires the addon.manage capability AND an explicit confirm flag (never auto-restores), validates the manifest+checksum+version first, and takes a pre-restore safety snapshot so a bad restore is recoverable. Extraction is zip-slip/zip-bomb-safe.",
          "permissions": [
            "addons/backups/backups",
            "addons/backups/restore"
          ],
          "endpoint": "/api/v1/addons/backups/restore",
          "scripts": []
        },
        {
          "name": "backup-schedules",
          "displayName": "Backups: Manage backup schedules",
          "description": "List/create/update/delete scheduled-backup definitions (name, type, cron or interval_seconds, retention). Requires the addon.manage capability. Enabled schedules AUTO-FIRE on the durable in-process scheduler (#124): each due fire creates an encrypted backup and applies the retention prune restart-persistently. Auto-fire only ever creates/prunes — it never restores.",
          "permissions": [
            "addons/backups/schedules"
          ],
          "endpoint": "/api/v1/addons/backups/schedules",
          "scripts": []
        },
        {
          "name": "backup-config",
          "displayName": "Backups: Configure encryption / remote target / retention",
          "description": "Read (masked) and update the backups configuration: the off-site remote target (none|s3|sftp|nas, endpoint/bucket/region/path or WebDAV URL + owner-secret creds), and the retention policy (keep N / N days). The nas target uses WebDAV (Synology/QNAP/TrueNAS/Nextcloud) over plain HTTP[S] with no extra dependency. Trigger a non-destructive remote connectivity test (POST ?action=test-remote) or an immediate retention prune (POST ?action=prune). At-rest AES-256-GCM encryption uses a self-managed key (auto-generated, masked, never returned). Requires the addon.manage capability; secret values are never echoed.",
          "permissions": [
            "addons/backups/backups"
          ],
          "endpoint": "/api/v1/addons/backups/config",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Backups</h1>\n<p>Full-instance <strong>backup + restore</strong> (C4, ADR-032).</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>backups</code></li><li><strong>Version:</strong> 1.2.0</li><li><strong>Fähigkeiten:</strong> backups, restore, schedules</li><li><strong>UI-Seite:</strong> <code>/backups</code> (Admin-Sektion)</li></ul>\n<p>An admin can create a full-instance backup, download it, and restore from a stored or uploaded archive.<br />A backup is a single ZIP containing a <strong>logical dump of every database table</strong> (including<br /><code>app_settings</code> with its secret/license/config rows and all addon-local tables), the **files under<br /><code>DATA_DIR</code><strong>, and a </strong>manifest with a SHA-256 checksum**. The archive is built with a dependency-free<br />ZIP writer (Node <code>zlib</code>) and is read back by Aiopta&#39;s hardened, zip-slip/zip-bomb-safe extractor.</p>\n<p>Backups are <strong>encrypted at rest</strong> (AES-256-GCM), can be copied <strong>off-site</strong> to an **S3, SFTP, or NAS<br />(WebDAV)<strong> remote target — and a restore can </strong>pull the archive back** from that target — are pruned by a<br /><strong>retention policy</strong>, and enabled <strong>schedules auto-fire</strong> on the durable in-process scheduler<br />(#124 + #138, ADR-032 addendum).</p>\n<h2>Einrichtung</h2>\n<p>Funktioniert sofort ohne Credentials: das lokale Backup wird <strong>at-rest verschlüsselt</strong> mit einem<br />selbst-verwalteten 32-Byte-Schlüssel, der beim ersten Backup automatisch erzeugt und in der Addon-Config<br />als Secret (<code>BACKUP_ENC_KEY</code>, <code>is_secret=1</code>, maskiert, nie geloggt) abgelegt wird — kein Owner nötig.</p>\n<p>Optional (Off-site, Owner-Creds, OPEN-DECISIONS #48) über <code>PUT /api/v1/addons/backups/config</code> bzw. die<br />Addon-Config:</p>\n<p>| Key | Zweck |<br />|---|---|<br />| <code>BACKUP_REMOTE_TYPE</code> | <code>none</code> (Default) · <code>s3</code> · <code>sftp</code> · <code>nas</code> |<br />| <code>BACKUP_REMOTE_PATH</code> | Key-/Pfad-Präfix im Ziel (Unterordner) |<br />| <code>BACKUP_S3_BUCKET</code> / <code>BACKUP_S3_REGION</code> / <code>BACKUP_S3_ENDPOINT</code> | S3-Ziel (Endpoint optional für S3-kompatibel) |<br />| <code>BACKUP_S3_ACCESS_KEY_ID</code> / <code>BACKUP_S3_SECRET_ACCESS_KEY</code> | S3-Creds (<strong>Secret</strong>) |<br />| <code>BACKUP_SFTP_HOST</code> / <code>BACKUP_SFTP_PORT</code> / <code>BACKUP_SFTP_USERNAME</code> | SFTP-Ziel |<br />| <code>BACKUP_SFTP_PASSWORD</code> / <code>BACKUP_SFTP_PRIVATE_KEY</code> | SFTP-Creds (<strong>Secret</strong>) |<br />| <code>BACKUP_NAS_URL</code> | <strong>NAS via WebDAV</strong> — Collection-Basis-URL (z. B. <code>https://nas.local:5006/remote.php/dav/files/mc</code>); Synology/QNAP/TrueNAS/Nextcloud, reines HTTP[S], keine neue Dependency |<br />| <code>BACKUP_NAS_USERNAME</code> / <code>BACKUP_NAS_PASSWORD</code> | NAS/WebDAV-Creds (Passwort <strong>Secret</strong>) |<br />| <code>BACKUP_RETENTION_KEEP</code> / <code>BACKUP_RETENTION_DAYS</code> | Retention (Default 10 / 30) |</p>\n<p>Solange Creds fehlen ist das Remote-Ziel <strong>graceful inaktiv</strong> (&quot;remote target not configured&quot;) — das<br />lokale (verschlüsselte) Backup läuft trotzdem, es gibt keinen Fake-Upload und keinen Crash. Alle Routen<br />sind <strong>admin-only</strong> (<code>addon.manage</code>), weil ein Backup naturgemäß Secret-Einstellungen enthält.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad | Zweck |<br />|---|---|---|<br />| GET | <code>/api/v1/addons/backups</code> | Backups auflisten (admin) |<br />| POST | <code>/api/v1/addons/backups</code> | Voll-Backup erstellen — Body <code>{ name?, excludeSecrets? }</code> (admin) |<br />| GET, DELETE | <code>/api/v1/addons/backups/[id]</code> | Backup-Metadaten / löschen (das letzte Backup ist geschützt) |<br />| GET | <code>/api/v1/addons/backups/[id]/download</code> | Archiv als Attachment streamen (admin) |<br />| POST | <code>/api/v1/addons/backups/[id]/restore</code> | Aus gespeichertem Backup wiederherstellen — Body <code>{ confirm: true }</code> (admin, destruktiv) |<br />| POST | <code>/api/v1/addons/backups/restore</code> | Aus <strong>hochgeladenem</strong> Archiv wiederherstellen — multipart <code>file</code> + <code>confirm=true</code> (admin, destruktiv); <code>dryRun=true</code> validiert nur. <strong>Oder</strong> aus dem <strong>Remote-/NAS-Ziel ziehen</strong> — JSON-Body <code>{ remoteFile, confirm: true }</code> (zieht zuerst, dann identischer Safe-Restore). Entschlüsselt verschlüsselte Archive automatisch |<br />| GET, PUT, POST | <code>/api/v1/addons/backups/config</code> | Config lesen (maskiert) / speichern; <code>POST ?action=test-remote</code> (Verbindungstest) · <code>POST ?action=prune</code> (Retention jetzt anwenden) (admin) |<br />| GET, POST | <code>/api/v1/addons/backups/schedules</code> | Backup-Zeitpläne auflisten/erstellen — Body akzeptiert <code>interval_seconds?</code> (admin) |<br />| GET, PATCH, DELETE | <code>/api/v1/addons/backups/schedules/[id]</code> | Zeitplan lesen/ändern/löschen (admin) |</p>\n<h2>Sicherheit</h2>\n<p>- <strong>Destruktiv &amp; abgesichert:</strong> Restore läuft nie implizit — explizites <code>confirm</code> erforderlich; das Manifest<br />  + die Prüfsumme + die Format-Version werden <strong>vor</strong> jedem Schreiben validiert; vorab wird ein<br />  <strong>Pre-Restore-Safety-Snapshot</strong> des aktuellen Stands erstellt; die DB wird in <strong>einer Transaktion</strong><br />  (atomar) wiederhergestellt; die Extraktion ist <strong>zip-slip/zip-bomb-sicher</strong> (geteilter Extractor).<br />- <strong>Encryption-at-rest:</strong> Archive werden mit <strong>AES-256-GCM</strong> in einem authentifizierten Envelope<br />  (<code>MCBK1</code>-Magic + IV + Auth-Tag) verschlüsselt; der 32-Byte-Schlüssel wird lazy erzeugt und als Secret<br />  in der Addon-Config gehalten (nie geloggt). <strong>Rückwärtskompatibel:</strong> alte Plaintext-ZIPs werden weiter<br />  restauriert; der Download entschlüsselt on-the-fly zu einem portablen ZIP.<br />- <strong>Secrets:</strong> Ein Backup enthält Secret-<code>app_settings</code> (Lizenz/Auth/Config) — deshalb sind alle Routen<br />  admin-only und Secret-<strong>Werte</strong> (inkl. Backup-Key und Remote-Creds) werden nie geloggt.<br />  <code>excludeSecrets: true</code> erzeugt einen bereinigten Dump (ohne <code>is_secret</code>-Zeilen). **Ein Restore<br />  überschreibt auch die Secrets.**<br />- <strong>Retention:</strong> löscht nur Archive, die <strong>beide</strong> Fenster (Anzahl <strong>und</strong> Alter) verletzen, und **nie das<br />  letzte verbleibende Backup**.<br />- <strong>Auto-Fire-Sicherheit:</strong> der Scheduler erstellt + pruned nur — ein **destruktiver Restore wird nie<br />  automatisch ausgeführt**.</p>\n<h2>Bekannte Grenzen / geparkt</h2>\n<p>- <strong>Remote-Aktivierung</strong> wartet auf Owner-Creds (OPEN-DECISIONS #48 / Block H; das NAS-Ziel ist #138) —<br />  Mechanismus + UI sind gebaut, das Ziel ist ohne Creds graceful inaktiv. <code>ssh2</code> (SFTP) wird dynamisch<br />  geladen; fehlt es, meldet der Upload &quot;dependency missing&quot; statt zu crashen. <strong>NAS</strong> nutzt WebDAV über den<br />  globalen <code>fetch</code> (keine neue Dependency); Creds gehen nur in den <code>Authorization: Basic …</code>-Header und<br />  werden nie geloggt/zurückgegeben.<br />- <strong>Cron mit fixer Uhrzeit:</strong> der durable Scheduler arbeitet intervallbasiert; Cron-Ausdrücke werden auf<br />  eine feste Kadenz gemappt (stündlich/täglich/wöchentlich/alle-N-Min/Std), eine exakte Wall-Clock-Zeit<br />  ist eine Folgearbeit (OPEN-DECISIONS #50, &quot;simpel jetzt&quot; wie der restliche Scheduler).</p>\n<blockquote><p>Siehe <code>docs/architecture/ADR-032-backup-restore.md</code> (Addendum #124) und <code>SKILL.md</code>.</p></blockquote>",
          "byteSize": 6388
        }
      }
    },
    {
      "id": "bahn-bet",
      "uuid": "cba4f478-4516-457d-986b-4ded28cf8e4a",
      "name": "BahnBet",
      "version": "1.0.6",
      "description": "Live BahnBet monitor for public train-delay markets, platform stats, and leaderboard highlights.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "train-delay-markets",
        "leaderboard",
        "web-scraping"
      ],
      "tags": [],
      "download": "/artifacts/bahn-bet-1.0.6.zip",
      "sha256": "6d920c998b26f7b86c73b29f4919d5e6f6e93ec783056a4decc92ba41f48b353",
      "size": 77639,
      "page": {
        "path": "/addons/bahn-bet",
        "name": "BahnBet",
        "icon": "🚆",
        "section": "Experiments",
        "component": "@addons/bahn-bet/src/components/BahnBetDashboard"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/bahn-bet/summary",
        "/api/v1/addons/bahn-bet/leaderboard"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>BahnBet</h1>\n<p>Live BahnBet monitor for public train-delay markets, platform stats, and leaderboard highlights.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>bahn-bet</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> train-delay-markets, leaderboard, web-scraping</li><li><strong>UI-Seite:</strong> <code>/addons/bahn-bet</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials.</p>\n<p>npm-Abhängigkeiten (werden bei Installation aufgelöst): <code>cheerio</code>.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET | <code>/api/v1/addons/bahn-bet/leaderboard</code> |<br />| GET | <code>/api/v1/addons/bahn-bet/status</code> |<br />| GET | <code>/api/v1/addons/bahn-bet/summary</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>Keine bekannten Einschränkungen.</li></ul>\n<blockquote><p>README aus <code>addon.json</code> + vorhandenen Routen generiert (<code>scripts/generate-addon-readmes.js</code>). Bei manuellen Ergänzungen den Generator-Hinweis entfernen.</p></blockquote>",
          "byteSize": 837
        }
      }
    },
    {
      "id": "blender",
      "uuid": "3c63ddcb-87fa-4b10-b516-8f7926ef8a9e",
      "name": "Blender",
      "version": "0.3.4",
      "description": "Agent-driven Blender via a BlenderMCP-style socket backend, fronted by a capability-enforcing Aiopta proxy (addons#815, core#1078 F2). Curated semantic tools (scene inspect/op, material, render, asset import/export) map to elevated dotted capabilities (addons.blender.scene.*/.asset.*); a separately gated raw-Python hatch (execute_blender_code) maps to the highest-tier addons.blender.python.exec. No raw passthrough: every tool call is gated against server-resolved grants and audited. Two backends: local-first (native-app bridge to the user's Blender on loopback :9876) and a headless sandbox container (workspace-only, no host network, render caps) for the safe autonomous path. Future/backlog — not dispatched until after the Enterprise-Clean programme (#1020).",
      "author": "Aiopta",
      "icon": "🧊",
      "capabilities": [
        "blender",
        "3d",
        "rendering",
        "mcp",
        "agent-tools"
      ],
      "tags": [],
      "download": "/artifacts/blender-0.3.4.zip",
      "sha256": "f07e0c6aab66ee8ee96d7a98fbd1674bee89e48dc4c07652e4eeb4356e69c5ee",
      "size": 162839,
      "page": null,
      "deprecated": false,
      "endpoints": [],
      "skills": [],
      "mcpServers": [
        {
          "id": "blender-tools",
          "displayName": "Blender tools",
          "description": "Capability-enforcing MCP proxy in front of a BlenderMCP-style socket backend (addons#815 / core#1078 F2). The agent never speaks the Blender socket protocol directly: it sees curated semantic tools (scene inspect/op, material, render, asset import/export) plus a separately gated raw-Python hatch. Each tool maps to a dotted capability (scene/asset = elevated, python/exec = highest); calls are gated against server-resolved grants (core#871 gateway seam, stubbed) and audited. Credentials for asset providers travel only via scoped tickets (addons#813), never as tool arguments. Local backend talks loopback-only; sandbox backend has no host network.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "blender-tools/server.mjs",
          "permissions": [
            "addons/blender/asset/export",
            "addons/blender/asset/import",
            "addons/blender/scene/op",
            "addons/blender/scene/read",
            "addons/blender/scene/render"
          ],
          "toolPolicy": {
            "mode": "write",
            "allowDestructive": false
          },
          "configRefs": [
            "mode"
          ],
          "credentialRefs": [
            "sketchfabApiKey"
          ],
          "archiveEntry": "blender/mcp/servers/blender-tools/server.mjs",
          "sha256": "b53146b7547d898f8841c598860f7610cd159983de4f9b887ff7dadd4e221f10",
          "size": 30392,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>🎨 Blender (Agent-gesteuertes Rendering via MCP-Proxy)</h1>\n<p>Agent-driven Blender über einen BlenderMCP-ähnlichen Socket-Backend, frontiert<br />durch einen capability-erzwingenden Aiopta-Proxy (addons#815, core#1078 F2).<br />Kuratierte semantische Werkzeuge (Szene inspizieren/ändern, Material, Render,<br />Asset-Import/Export) mappen auf elevated dotted Capabilities<br />(<code>addons.blender.scene.*</code>, <code>addons.blender.asset.*</code>); eine separat gegatete<br />Raw-Python-Klappe (<code>execute_blender_code</code>) mappt auf die Highest-Tier-Capability<br /><code>addons.blender.python.exec</code>. Kein Raw-Passthrough: Jeder Tool-Aufruf wird gegen<br />server-seitig aufgelöste Grants geprüft und auditiert.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>blender</code></li><li><strong>Version:</strong> 0.3.2</li><li><strong>Fähigkeiten:</strong> blender, 3d, rendering, mcp, agent-tools</li><li><strong>UI-Seite:</strong> <code>/addons/blender</code></li><li><strong>MCP-Server:</strong> <code>aiopta-blender-tools</code> (kuratierte, capability-gegatete Tools)</li></ul>\n<h2>Zwei Backends</h2>\n<p>| Backend | Zweck | Netz |<br />|---|---|---|<br />| <code>local</code> | Native-App-Bridge zur Blender-Instanz des Nutzers (Loopback :9876) | nur loopback |<br />| <code>sandbox</code> | Headless-Sandbox-Container (workspace-only, kein Host-Netz, Render-Caps) für den sicheren autonomen Pfad | isoliert |</p>\n<p>Der aktive Modus wird vom Proxy gemeldet (<code>mode: local | sandbox</code>) und ist pro<br />Aufruf sichtbar — der Sandbox-Pfad ist der sichere Standard für autonome<br />Abläufe.</p>\n<h2>Rechte / Capabilities</h2>\n<p>Enforcement ist server-seitig und unabhängig davon, was ein Agent glaubt zu<br />dürfen (core#871):</p>\n<ul><li><strong>elevated:</strong> <code>addons.blender.scene.*</code>, <code>addons.blender.asset.*</code> (kuratierte Tools)</li><li><strong>highest:</strong> <code>addons.blender.python.exec</code> (Raw-Python-Klappe)</li></ul>\n<p>Vor jeder Nutzung: <code>blender_capabilities</code> abfragen (ungated) — es liefert pro<br />Capability die Tier und ob sie für den aktuellen Caller <strong>effective</strong> ist.</p>\n<h2>Einrichtung</h2>\n<p>BYOK: der <code>local</code>-Modus erwartet den BlenderMCP-Socket-Bridge auf Loopback<br /><code>:9876</code> (Native-App-Bridge zur Blender-Instanz des Nutzers). Der <code>sandbox</code>-Modus<br />braucht keine Host-Blender-Instanz — der Container rendert workspace-only.</p>\n<h2>Bekannte Grenzen</h2>\n<p>- Future/Backlog-Features (erweiterte Szenen-Ops) werden erst nach dem<br />  Enterprise-Clean-Programm (#1020) dispatched.<br />- Raw-Python (<code>execute_blender_code</code>) ist bewusst die Ausnahme — kuratierte<br />  Tools sind der Standard-Workflow (siehe SKILL.md).</p>\n<blockquote><p>README manuell gepflegt (addon.json + Routen als Quelle).</p></blockquote>",
          "byteSize": 2361
        }
      }
    },
    {
      "id": "booking",
      "uuid": "603d9ba4-21d6-4cdb-b8e5-ef3c0536548d",
      "name": "Booking",
      "version": "1.0.3",
      "description": "Per-user booking addon: owner-configured bookable resources, weekly availability and time slots, a calendar of bookings, plus a public booking page where visitors pick an open slot and book — with confirmation/reminder emails.",
      "author": "Aiopta",
      "icon": "📆",
      "capabilities": [
        "booking",
        "scheduling",
        "calendar"
      ],
      "tags": [],
      "download": "/artifacts/booking-1.0.3.zip",
      "sha256": "cd8ca59fc8185eb4fc2a7c014a2570b4c135369bac9ac8e807901b7b75460d63",
      "size": 132094,
      "page": null,
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/booking"
      ],
      "skills": [
        {
          "name": "booking-manage",
          "displayName": "Manage bookings",
          "description": "List, create and update the user's own bookable resources, availability and bookings.",
          "permissions": [
            "addons/booking/read",
            "addons/booking/write"
          ],
          "endpoint": "/api/v1/addons/booking",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Booking Addon</h1>\n<p>Per-user booking for Aiopta (issue #485 / INBOX-5). An owner publishes<br />bookable resources with weekly availability; the addon computes concrete open<br />time slots and exposes a <strong>public booking page</strong> where external visitors pick a<br />slot and book. Confirmation (and reminder) emails are sent best-effort.</p>\n<p>Built user-scoped from day one on the ADR-054 foundation: every durable record<br />carries <code>user_id</code> and is reached only through <code>scopedStore(ownerId)</code>.</p>\n<h2>Capabilities (acceptance criteria)</h2>\n<p>- <strong>Bookable resources</strong> (event types): name, slug, duration, buffer, active.<br />- <strong>Availability</strong>: weekly recurring windows per resource (weekday + start/end).<br />- <strong>Slots / calendar</strong>: open slots are computed from availability minus existing<br />  bookings (buffer-aware), over a configurable horizon, with a minimum lead time.<br />- <strong>Public booking page</strong>: read-only listing of an owner&#39;s published resources +<br />  open slots, and a constrained &quot;create a pending booking&quot; action.<br />- <strong>Confirmation / reminder emails</strong>: pure renderers + an SMTP transport (lazy<br />  <code>nodemailer</code>, instance addon-config); reminder timing is computed (<code>reminderDueAt</code>)<br />  for a cron/worker — the addon holds no live timers.</p>\n<h2>Public-vs-owner security model</h2>\n<p>This is the security-critical boundary. Two distinct surfaces:</p>\n<p>| Surface | Auth | Wrapper | Owner identity | What it can do |<br />| --- | --- | --- | --- | --- |<br />| Owner CRUD (<code>resources</code>, <code>availability</code>, <code>bookings</code>, <code>settings</code>) | authenticated | <code>userRoute</code> | <code>auth.id</code> (server) | full read/write of <strong>own</strong> data only |<br />| Public page (<code>public/[owner]</code>, <code>…/availability</code>, <code>…/book</code>) | <strong>none</strong> | <code>route</code> | resolved from the <strong>public slug</strong> | read published slots; create <strong>one pending</strong> booking for that owner |</p>\n<p>Key invariants:</p>\n<p>1. <strong>The owner id is never taken from the request body.</strong> Owner CRUD stamps the<br />   owner from <code>auth.id</code>; the public path resolves it from the public slug via<br />   <code>resolveOwnerByPublicSlug</code> and forces it into <code>createBooking</code>. There is no<br />   parameter by which a caller can write to a <em>different</em> owner.<br />2. <strong>The public path is not a cross-user write primitive.</strong> The only mutation an<br />   anonymous visitor can perform is appending a <code>pending</code> booking to the single<br />   owner the slug names, for a slot that owner <em>published</em>, validated against that<br />   owner&#39;s availability + existing bookings, and bounded by a per-key sliding<br />   rate limit. Owners review/confirm pending bookings via the authed CRUD.<br />3. <strong>No cross-user reads.</strong> The public projection exposes only <code>active</code> resources<br />   and computed slots for the resolved owner — never another owner&#39;s data, never<br />   private fields (resource ids, visitor PII of other bookings, owner id).<br />4. <strong>Unauthenticated reachability.</strong> The addon dispatch defers unauthenticated<br />   callers to the route&#39;s own auth, so the public <code>route()</code>-wrapped endpoints are<br />   reachable without a session while the owner endpoints require one.<br />5. <strong><code>resolveOwnerByPublicSlug</code> is the only cross-user read</strong> — it returns just an<br />   owner id from a public slug (no private data); everything after it is normal<br />   owner-scoped access for that one resolved owner.</p>\n<h2>Data model (all <code>user_id</code>-scoped, FK-free)</h2>\n<ul><li><code>booking_resources(id, user_id, slug, name, description, duration_minutes, buffer_minutes, active, …)</code> — <code>UNIQUE(user_id, slug)</code></li><li><code>booking_availability(id, user_id, resource_id, weekday, start_minute, end_minute, …)</code></li><li><code>booking_bookings(id, user_id, resource_id, booking_date, start_minute, end_minute, visitor_name, visitor_email, notes, status, source, …)</code></li><li><code>booking_settings(user_id PK, public_slug UNIQUE, display_name, timezone, min_lead_minutes, horizon_days, accepting_bookings, …)</code></li></ul>\n<p>The <code>user_id</code> column is intentionally <strong>FK-free</strong> (<code>TEXT NOT NULL</code>, no<br /><code>REFERENCES users(id)</code>): a REFERENCES FK resolves at prepare time and throws on a<br />bare addon-only test DB. Ownership is enforced in SQL by the user-scope layer<br />regardless, and <code>registerScopedTable</code> is best-effort (try/catch).</p>\n<h2>API</h2>\n<p>Owner (authenticated):<br />- <code>GET/POST /api/v1/addons/booking/resources</code>, <code>GET/PUT/DELETE …/resources/[id]</code><br />- <code>GET/POST /api/v1/addons/booking/availability</code>, <code>DELETE …/availability/[id]</code><br />- <code>GET/POST /api/v1/addons/booking/bookings</code>, <code>GET/PATCH/DELETE …/bookings/[id]</code><br />- <code>GET/PUT /api/v1/addons/booking/settings</code> (mail status is masked — no secret)<br />- <code>GET /api/v1/addons/booking/status</code></p>\n<p>Public (unauthenticated, keyed to the owner&#39;s public slug):<br />- <code>GET /api/v1/addons/booking/public/[owner]</code> — published resources + page meta<br />- <code>GET /api/v1/addons/booking/public/[owner]/availability?resource=&lt;slug&gt;</code> — open slots<br />- <code>POST /api/v1/addons/booking/public/[owner]/book</code> — create a pending booking</p>\n<h2>Email config</h2>\n<p>SMTP is read from instance addon-config (<code>SMTP_HOST/PORT/USER/PASS/SECURE/FROM</code>)<br />via <code>getAddonConfiguration(&quot;booking&quot;)</code>, never the manifest, never logged. The<br />settings/status endpoints expose only a <strong>masked</strong> view (presence flags + the<br />public From address).</p>",
          "byteSize": 4991
        }
      }
    },
    {
      "id": "browser",
      "uuid": "999f26ee-fc80-49a1-bbd8-8a3110ecb2a9",
      "name": "Browser",
      "version": "1.4.2",
      "description": "Container-local browser automation addon with screenshot and page snapshot APIs.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "browser-automation",
        "screenshots",
        "page-snapshots"
      ],
      "tags": [],
      "download": "/artifacts/browser-1.4.2.zip",
      "sha256": "e4964f95ec6a931e29a2225ceebbe32c57804af5b6f7c77ab81e512b3012e7fc",
      "size": 121852,
      "page": {
        "path": "/addons/browser",
        "name": "Browser",
        "icon": "🌐",
        "section": "Tools",
        "component": "@addons/browser/src/ui/BrowserSettings"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/browser/config",
        "/api/v1/addons/browser/health",
        "/api/v1/addons/browser/status",
        "/api/v1/addons/browser/start",
        "/api/v1/addons/browser/stop",
        "/api/v1/addons/browser/snapshot",
        "/api/v1/addons/browser/screenshot",
        "/api/v1/addons/browser/document"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Browser</h1>\n<p>Container-local browser automation addon with screenshot and page snapshot APIs.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>browser</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> browser-automation, screenshots, page-snapshots</li><li><strong>UI-Seite:</strong> <code>/addons/browser</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials.</p>\n<p>npm-Abhängigkeiten (werden bei Installation aufgelöst): <code>puppeteer</code>.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET | <code>/api/v1/addons/browser/config</code> |<br />| GET | <code>/api/v1/addons/browser/health</code> |<br />| POST | <code>/api/v1/addons/browser/install-runtime</code> |<br />| POST | <code>/api/v1/addons/browser/screenshot</code> |<br />| POST | <code>/api/v1/addons/browser/snapshot</code> |<br />| POST | <code>/api/v1/addons/browser/start</code> |<br />| GET | <code>/api/v1/addons/browser/status</code> |<br />| POST | <code>/api/v1/addons/browser/stop</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>Keine bekannten Einschränkungen.</li></ul>\n<blockquote><p>README aus <code>addon.json</code> + vorhandenen Routen generiert (<code>scripts/generate-addon-readmes.js</code>). Bei manuellen Ergänzungen den Generator-Hinweis entfernen.</p></blockquote>",
          "byteSize": 1040
        }
      }
    },
    {
      "id": "budget",
      "uuid": "1f4a5e3f-8f9a-4f8a-8c3c-24a07f957465",
      "name": "Budget (Konten/Kategorien/Budgets/wiederkehrend/Charts)",
      "version": "1.2.1",
      "description": "Per-User Budgeting: Konten, Kategorien, Budgets, wiederkehrende Transaktionen und Charts (Ausgaben über Zeit, Kategorie-Aufschlüsselung, Budget vs Ist). Integriert lesend mit den trading- + payments-Addons (realisierte PnL / bezahlte Rechnungen fließen in die Reports), ohne diese zu verändern. Lokale SQLite ist die Quelle der Wahrheit (ADR-054).",
      "author": "Aiopta",
      "icon": "💰",
      "capabilities": [
        "budget",
        "accounts",
        "categories",
        "budgets",
        "transactions",
        "recurring",
        "reports",
        "charts"
      ],
      "tags": [],
      "download": "/artifacts/budget-1.2.1.zip",
      "sha256": "113ccbaf904e20a625031b9b7fcd6b1f6b6c3ddb12dcd768509c91e4e2205578",
      "size": 198591,
      "page": {
        "id": "budget-page",
        "path": "/addons/budget",
        "name": "Budget",
        "icon": "💰",
        "section": "Money",
        "component": "@addons/budget/src/components/BudgetPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/budget/accounts",
        "/api/v1/addons/budget/accounts/:id",
        "/api/v1/addons/budget/categories",
        "/api/v1/addons/budget/categories/:id",
        "/api/v1/addons/budget/budgets",
        "/api/v1/addons/budget/budgets/:id",
        "/api/v1/addons/budget/transactions",
        "/api/v1/addons/budget/transactions/:id",
        "/api/v1/addons/budget/recurring",
        "/api/v1/addons/budget/recurring/:id",
        "/api/v1/addons/budget/reports/bundle",
        "/api/v1/addons/budget/reports/summary"
      ],
      "skills": [
        {
          "name": "budget-read",
          "displayName": "Budget: Konten, Budgets & Reports lesen",
          "description": "Listet Konten, Kategorien, Budgets, Transaktionen und wiederkehrende Regeln des Nutzers sowie die Charts (Ausgaben über Zeit, Kategorie-Aufschlüsselung, Budget vs Ist). Reiner Lesezugriff — bucht nichts.",
          "permissions": [
            "addons/budget/accounts",
            "addons/budget/categories",
            "addons/budget/budgets",
            "addons/budget/transactions",
            "addons/budget/recurring",
            "addons/budget/reports"
          ],
          "endpoint": "/api/v1/addons/budget/reports/bundle",
          "scripts": []
        },
        {
          "name": "budget-write",
          "displayName": "Budget: Transaktionen, Budgets & Regeln erfassen",
          "description": "Erfasst Konten, Kategorien, Budgets, Transaktionen und wiederkehrende Regeln für den Nutzer (per-User gebucht) und löscht diese.",
          "permissions": [
            "addons/budget/accounts",
            "addons/budget/categories",
            "addons/budget/budgets",
            "addons/budget/transactions",
            "addons/budget/recurring"
          ],
          "endpoint": "/api/v1/addons/budget/transactions",
          "scripts": []
        }
      ],
      "mcpServers": [
        {
          "id": "budget-tools",
          "displayName": "Budget tools",
          "description": "Read-only budget report math for agents: spending over time, category breakdown, budget vs actual, account balances, recurring-rule expansion and money formatting — the same pure functions the budget addon's charts use (issue #405, epic #399). Books nothing.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "budget-tools/server.mjs",
          "permissions": [
            "addons/budget/reports"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [],
          "credentialRefs": [],
          "archiveEntry": "budget/mcp/servers/budget-tools/server.mjs",
          "sha256": "e4c8f779bb4dc1a7a813d6fbd2785cc3b15ba211c84679a9229c8d0e683e8c6a",
          "size": 27143,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>💰 Budget (Konten/Kategorien/Budgets/wiederkehrend/Charts)</h1>\n<p>Per-User Budgeting: Konten, Kategorien, Budgets, wiederkehrende Transaktionen und<br />Charts (Ausgaben über Zeit, Kategorie-Aufschlüsselung, Budget vs Ist). Integriert<br />lesend mit den trading- + payments-Addons (realisierte PnL / bezahlte Rechnungen<br />fließen in die Reports), ohne diese zu verändern. Lokale SQLite ist die Quelle<br />der Wahrheit (ADR-054).</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>budget</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> budget, accounts, categories, budgets, transactions, recurring, reports, charts</li><li><strong>UI-Seite:</strong> <code>/addons/budget</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials. Die<br />trading/payments-Integration ist optional und aktiviert sich automatisch, sobald<br />die Addons installiert sind.</p>\n<h2>Datenmodell</h2>\n<ul><li><code>budget_accounts</code> — Konten (Name, Typ, Eröffnungsbilanz, Währung)</li><li><code>budget_categories</code> — Kategorien (Name, Gruppe)</li><li><code>budget_budgets</code> — Ausgabenlimits (Kategorie + Periode <code>YYYY-MM</code>, Betrag)</li><li><code>budget_transactions</code> — Einnahmen/Ausgaben (Konto, Kategorie, Betrag)</li><li><code>budget_recurring</code> — Wiederkehrende Regeln (Frequenz, Zeitraum, Betrag)</li></ul>\n<p>Alle Tabellen sind per <code>user_id</code> scoped (ADR-054).</p>",
          "byteSize": 1238
        }
      }
    },
    {
      "id": "business-operator",
      "uuid": "ad6479f7-69ab-4e97-ac02-8c9da48c6e00",
      "name": "Autonomous Business-Operator",
      "version": "1.2.1",
      "description": "An autonomous business operator that pursues configured business goals by running agent-driven cycles which orchestrate the existing money/business addons (dropshipping, seo-content, lead-gen, wordpress) and the workflow-builder through the MC API/gateway. Mandatory guardrails: dry-run by default (propose, don't execute) until explicitly armed; a per-operator addon allow-list; approval-required for any spend/publish/outreach action (a pending-actions queue + approve/reject); optional budget + per-day caps; optional per-user voting/quorum snapshot (#95) so a queued action only proceeds after N eligible voters have approved. It drives the tools — it never re-implements them, never calls external services (ADR-009), and never auto-runs an approval-required action.",
      "author": "Aiopta",
      "icon": "🧭",
      "capabilities": [
        "business-goals",
        "operator-cycles",
        "action-allow-list",
        "approval-queue",
        "spend-day-caps",
        "voting-quorum"
      ],
      "tags": [],
      "download": "/artifacts/business-operator-1.2.1.zip",
      "sha256": "c2ceee4e3e1e81bd31ace7f18e8980a0cf5953e328bc1fb1d70f262c82f73c4a",
      "size": 439966,
      "page": {
        "id": "business-operator-page",
        "path": "/addons/business-operator",
        "name": "Business-Operator",
        "icon": "🧭",
        "section": "Automation",
        "component": "@addons/business-operator/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/business-operator/status",
        "/api/v1/addons/business-operator/operators",
        "/api/v1/addons/business-operator/operators/:id",
        "/api/v1/addons/business-operator/operators/:id/run",
        "/api/v1/addons/business-operator/operators/:id/runs",
        "/api/v1/addons/business-operator/actions",
        "/api/v1/addons/business-operator/actions/:id/approve",
        "/api/v1/addons/business-operator/actions/:id/reject",
        "/api/v1/addons/business-operator/actions/:id/votes",
        "/api/v1/addons/business-operator/actions/:id/quorum",
        "/api/v1/addons/business-operator/widget"
      ],
      "skills": [
        {
          "name": "operator-manage",
          "displayName": "Operator: Define & manage business operators",
          "description": "List/create/edit/delete autonomous business operators (goal + KPI target, addon allow-list, guardrails, dry-run/armed mode). Arming an operator and editing its guardrails requires the addon.manage capability. Pure config — it does not run a cycle.",
          "permissions": [
            "addons/business-operator/operators",
            "addons/business-operator/status"
          ],
          "endpoint": "/api/v1/addons/business-operator/operators",
          "scripts": []
        },
        {
          "name": "operator-run",
          "displayName": "Operator: Run a cycle & inspect history",
          "description": "Run one operator cycle (gather addon state, ask the configured MC agent via the gateway for the next best action constrained to the allow-list, then execute ONLY safe actions when armed and QUEUE every spend/publish/outreach action for human approval) and read the cycle history. A dry-run operator only proposes. Never auto-runs an approval-required action; never touches an addon outside the allow-list.",
          "permissions": [
            "addons/business-operator/operators",
            "addons/business-operator/status"
          ],
          "endpoint": "/api/v1/addons/business-operator/operators/:id/run",
          "scripts": []
        },
        {
          "name": "operator-approvals",
          "displayName": "Operator: Approval inbox (approve/reject)",
          "description": "List the side-effecting actions an operator's cycles have queued and approve or reject them. Approve is the ONLY path that carries out a spend/publish/outreach action — it can never happen automatically — and still runs under the approver's RBAC + the underlying addon's own guardrails. Requires the addon.manage capability.",
          "permissions": [
            "addons/business-operator/actions",
            "addons/business-operator/status"
          ],
          "endpoint": "/api/v1/addons/business-operator/actions",
          "scripts": [
            {
              "id": "operator-approvals-cli",
              "name": "operator-approvals-cli",
              "path": "operator-approvals/cli.sh",
              "sha256": "226fa65bd655e00dce342c98a8f18aa59577cbdf51247532de2215bcfde2279f",
              "interpreter": "bash",
              "timeoutMs": 10000,
              "permissions": [
                "addons/business-operator/actions",
                "addons/business-operator/status"
              ],
              "inputSchema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "limit": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 100,
                    "default": 20
                  },
                  "timeoutMs": {
                    "type": "integer",
                    "minimum": 100,
                    "maximum": 300000
                  },
                  "input": {
                    "type": "object",
                    "description": "Request-Body als JSON — niemals Secrets"
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": false,
                    "description": "Request validieren ohne Mutation — kein API-Call"
                  }
                }
              },
              "outputSchema": {
                "type": "object",
                "required": [
                  "ok"
                ],
                "properties": {
                  "ok": {
                    "type": "boolean"
                  },
                  "status": {
                    "type": "integer"
                  },
                  "data": {},
                  "error": {
                    "type": "string"
                  },
                  "truncated": {
                    "type": "boolean"
                  }
                }
              },
              "size": 12147
            }
          ]
        },
        {
          "name": "operator-voting",
          "displayName": "Operator: Vote on a pending action (quorum)",
          "description": "Cast an `approve` or `reject` vote on a queued pending action; the action transitions to `approved`/`rejected` atomically the moment the running tally crosses the queued snapshot's threshold (#95). Each voter may cast at most one vote per action; only the eligibility snapshot (operator owner + the explicit `eligibleVoterIds` from the operator's `guardrails.quorum` block) may vote. Requires the addon.manage capability.",
          "permissions": [
            "addons/business-operator/actions",
            "addons/business-operator/status"
          ],
          "endpoint": "/api/v1/addons/business-operator/actions/:id/votes",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Autonomous Business-Operator</h1>\n<p>The higher-level <strong>brain</strong> that pursues configured business <strong>goals</strong> by running agent-driven **operator<br />cycles*<em> which orchestrate the </em>existing* money/business addons (dropshipping, seo-content, lead-gen,<br />wordpress) and the workflow-builder (#45) through the Aiopta API/gateway.</p>\n<p>It <strong>drives</strong> those tools — it never re-implements them and <strong>never calls external services directly</strong><br />(ADR-009). Every action is an Aiopta-internal <code>/api/v1/addons/...</code> call (same-origin guarded); planning goes<br />through the Aiopta gateway.</p>\n<h2>Model</h2>\n<ul><li><strong>Operator</strong> = <code>{ id, name, goal, target, status, mode, addons[], guardrails, agentId?, schedule? }</code>.</li><li><strong>goal</strong> is free-text; <strong>target</strong> is a small KPI <code>{ kpi?, target?, current? }</code>.</li><li><strong>mode</strong> ∈ <code>dry-run</code> (default) | <code>armed</code>; <strong>status</strong> ∈ <code>idle</code> | <code>running</code> | <code>paused</code>.</li><li><strong>addons[]</strong> is the <strong>allow-list</strong> — the operator may orchestrate ONLY these.</li><li><strong>guardrails</strong> = <code>{ maxActionsPerCycle (≤10), maxActionsPerDay?, budgetCapCents? }</code>.</li></ul>\n<p>A <strong>cycle</strong> (<code>POST …/operators/{id}/run</code>) = gather state from the allow-listed addons → ask the<br />configured Aiopta agent for the next best action constrained to the allow-list → apply guardrails → record.</p>\n<h2>Safety (the point of this addon)</h2>\n<p>- <strong>Dry-run by default</strong> — a new operator only <strong>proposes</strong>; it runs NOTHING until explicitly <strong>armed</strong>.<br />- <strong>Per-operator allow-list</strong> — an action for an addon outside <code>addons[]</code> is <strong>refused</strong>, never run.<br />- <strong>Approval-required for side-effects</strong> — every <strong>spend / publish / outreach</strong> action is <strong>queued</strong> as a<br />  pending action and is <strong>never auto-run, even when armed</strong>. Only a human <strong>approve</strong> carries it out — and<br />  it still runs under the approver&#39;s RBAC + the target addon&#39;s own guardrails (e.g. lead-gen&#39;s anti-spam<br />  <code>confirm</code>/suppression).<br />- <strong>Budget + per-day + per-cycle caps</strong> bound an armed operator.<br />- <strong>Same-origin only / no eval</strong> — <code>assertSameOrigin</code> re-checks every call; nothing is ever <code>eval</code>&#39;d.<br />- <strong>Capability-gated</strong> — create/edit/<strong>arm</strong>/run/approve require the <code>addon.manage</code> RBAC capability.</p>\n<h2>Approval notifications (C7, ADR-035)</h2>\n<p>Optionally, every <strong>newly-queued</strong> approval can ALSO be <strong>pushed</strong> through the <code>notifications</code> addon<br />(Discord/email/push/webhook) — <strong>in addition to</strong> the in-UI approvals inbox, never replacing it.</p>\n<p>- <strong>Opt-in, default OFF.</strong> Per operator: <code>notify = { enabled, channelId? }</code>. Disabled/unconfigured ⇒<br />  behaviour is unchanged (only the UI queue). When enabled, an optional <code>channelId</code> targets one channel;<br />  omitted ⇒ broadcast to all enabled channels.<br />- <strong>Aiopta-API only (ADR-009).</strong> The bridge (<code>src/lib/notify-bridge.ts</code>) POSTs to the Aiopta-internal<br />  <code>POST /api/v1/addons/notifications/send</code> (<code>assertSameOrigin</code> + a canonical-origin check, forwarded auth)<br />  — it <strong>never</strong> calls Discord/email/an external host directly; the notifications addon owns the channel<br />  credentials.<br />- <strong>Best-effort &amp; idempotent.</strong> Fired <strong>once</strong> per pending action at queue time (not every cycle); a<br />  notify failure <strong>never blocks/breaks the cycle</strong> and <strong>never weakens the approval gate</strong> (approval still<br />  needs the human <code>approve</code>). The message carries the operator name, the action + risk, and a <strong>deep-link</strong><br />  to the approvals inbox. No secret/webhook URL is ever built or logged.<br />- <strong>UI.</strong> A <strong>&quot;Freigaben auch per Benachrichtigung&quot;</strong> toggle + channel picker in the operator editor.</p>\n<h2>Persistence</h2>\n<p>Three addon-owned tables (created in <code>install()</code>, dropped in <code>uninstall()</code> via Aiopta core&#39;s <code>getDb()</code>):<br /><code>business_operator_operators</code>, <code>business_operator_runs</code>, <code>business_operator_pending_actions</code> (the approval<br />queue). The addon never touches the core schema; no secret is stored.</p>\n<h2>UI</h2>\n<p>A control panel: operator list, a per-operator editor (goal/KPI, <strong>allow-list checkboxes</strong> with per-action<br />risk, <strong>guardrails</strong>, <strong>Arm/Pause</strong> toggle, <strong>Run cycle</strong>), a <strong>plan/activity log</strong>, and a<br /><strong>pending-approvals inbox</strong> (approve/reject). The dry-run/armed state and approval gates are made visually<br />unmistakable.</p>\n<p>Durable auto-scheduling of cycles and a fully-autonomous (no-approval) spend mode are parked owner/risk<br />decisions — see <code>docs/OPEN-DECISIONS.md</code>.</p>\n<p>See <code>SKILL.md</code> for the agent-facing endpoint cheat-sheet and<br /><code>docs/architecture/ADR-025-business-operator.md</code> for the design.</p>",
          "byteSize": 4321
        }
      }
    },
    {
      "id": "calendar",
      "uuid": "fc94a478-e5d9-46c8-93ef-d7dd856e8fbb",
      "name": "Calendar",
      "version": "1.1.5",
      "description": "Calendar UI and calendar integration API as an installable addon.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "calendar",
        "scheduling"
      ],
      "tags": [],
      "download": "/artifacts/calendar-1.1.5.zip",
      "sha256": "a8d63f1aad0aaff0f59535f9bb2432437977572d176b4871085d2d89f19b5155",
      "size": 120554,
      "page": {
        "id": "calendar",
        "path": "/addons/calendar",
        "name": "Calendar",
        "icon": "📅",
        "section": "Work",
        "component": "@addons/calendar/src/components/calendar/CalendarView"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/calendar"
      ],
      "skills": [],
      "mcpServers": [
        {
          "id": "calendar-tools",
          "displayName": "Calendar tools",
          "description": "Read-only calendar domain tools for agents: filter events by date/range, expand cron schedules into concrete dates, month bounds and event summaries — the same semantics as the calendar addon's own store/route logic (issue #407, epic #399).",
          "transport": "stdio",
          "runtime": "node",
          "entry": "calendar-tools/server.mjs",
          "permissions": [
            "addons/calendar/read"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [],
          "credentialRefs": [],
          "archiveEntry": "calendar/mcp/servers/calendar-tools/server.mjs",
          "sha256": "20c71786b79e4057ea92f7fd1525c248d8ee96aab632fdaf350c81ae5cbc6710",
          "size": 15864,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Calendar</h1>\n<p>Calendar UI and calendar integration API as an installable addon.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>calendar</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> calendar, scheduling</li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET, POST | <code>/api/v1/addons/calendar</code> |<br />| GET | <code>/api/v1/addons/calendar/status</code> |</p>\n<h2>Sicherheit — Pro-Benutzer-Isolation (#142 / INBOX-59, ADR-054)</h2>\n<p>Manuell erstellte Kalender-Events sind <strong>pro Benutzer</strong> isoliert: sie liegen in der Tabelle<br /><code>calendar_events</code> mit <code>user_id NOT NULL</code> und werden ausschließlich über <code>scopedStore(auth.id)</code> gelesen/<br />geschrieben (Owner server-seitig aus dem authentifizierten Aufrufer gestempelt, ein Client-Owner wird<br />ignoriert). Ein Benutzer sieht und erstellt nur seine <strong>eigenen</strong> Events. Die daneben angezeigten<br />Aufgaben- (<code>project_tasks</code>), Backup-Zeitplan- (<code>backup_schedules</code>) und verbundenen Kalender-Einträge<br />(<code>integrations</code>) sind <strong>schreibgeschützte Projektionen</strong> der Daten anderer Addons und werden hier nicht<br />neu partitioniert. Eine einmalige idempotente Migration hebt Alt-Events aus dem <code>addon_settings</code>-JSON-Blob<br />in die neue Tabelle (Owner = der Benutzer der Settings-Zeile).</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>Keine bekannten Einschränkungen.</li></ul>",
          "byteSize": 1314
        }
      }
    },
    {
      "id": "chat",
      "uuid": "539ae329-7bed-4281-a007-1b1ca5c5c3f7",
      "name": "Chat",
      "version": "1.17.8",
      "description": "Agent chat sessions, messages, streaming responses, agent selection, group chat, plus chat I/O: file upload (+drag&drop), voice-to-text and agent file-returns (download + preview, mirrored to artefakt-workspace), a responsive owner-scoped Kontext-Sidebar for session Ausgaben (artifacts) and Quellen (sources), and a versioned server-authoritative subagent/task status stream (snapshot + monotone deltas) reconciled by an idempotent client reducer.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "chat",
        "sessions",
        "messages",
        "streaming",
        "groups",
        "attachments",
        "voice-input",
        "artifacts"
      ],
      "tags": [
        "onboarding"
      ],
      "download": "/artifacts/chat-1.17.8.zip",
      "sha256": "f5a5c7c0c77b9847017bab91a47dc956a8b51e5b39df3dbb8f195e5d99011dd3",
      "size": 1350797,
      "page": {
        "id": "chat",
        "path": "/addons/chat",
        "name": "Chat",
        "icon": "💬",
        "section": "Agents",
        "component": "@addons/chat/src/components/chat/ChatPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/chat/agents",
        "/api/v1/addons/chat/sessions",
        "/api/v1/addons/chat/sessions/{id}",
        "/api/v1/addons/chat/sessions/{id}/messages",
        "/api/v1/addons/chat/sessions/{id}/stream",
        "/api/v1/addons/chat/sessions/{id}/compact",
        "/api/v1/addons/chat/sessions/{id}/attachments",
        "/api/v1/addons/chat/sessions/{id}/attachments/{attachmentId}/serve",
        "/api/v1/addons/chat/sessions/{id}/artifacts",
        "/api/v1/addons/chat/sessions/{id}/artifacts/{artifactId}/serve",
        "/api/v1/addons/chat/sessions/{id}/sources",
        "/api/v1/addons/chat/stt-status",
        "/api/v1/addons/chat/plan-limits",
        "/api/v1/addons/chat/groups",
        "/api/v1/addons/chat/groups/{id}/members",
        "/api/v1/addons/chat/groups/{id}/members/{memberId}",
        "/api/v1/addons/chat/groups/{id}/messages",
        "/api/v1/addons/chat/sessions/{id}/browser",
        "/api/v1/addons/chat/sessions/{id}/browser/navigate"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Chat</h1>\n<p>Agent chat sessions, messages, streaming responses, chat agent selection — plus a<br /><strong>model-independent slash-command system</strong>, a full-width layout with a context sidebar, and in-app<br />deep-links (ADR-011, Phase U Block 4 / G10).</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>chat</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> chat, sessions, messages, streaming</li><li><strong>UI-Seite:</strong> <code>/chat</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials.</p>\n<h2>Slash-Commands</h2>\n<p>Eine Nachricht, die mit <code>/</code> beginnt, ist ein <strong>Command</strong> und <strong>kein Prompt</strong>. Commands werden<br />client-seitig abgefangen und ausgeführt — sie erreichen <strong>nie</strong> das Modell und funktionieren auch<br />dann, wenn kein Modell/Backend erreichbar ist (modellunabhängige Interception, ADR-011).</p>\n<p>Beim Tippen von <code>/</code> öffnet sich ein Autocomplete-Popover (↑/↓ wählen, Enter/Tab übernehmen, Esc<br />schließen). Unbekannte <code>/x</code>-Eingaben zeigen einen Inline-Hinweis mit Vorschlägen — ebenfalls ohne<br />Modell-Aufruf.</p>\n<p>| Command | Beschreibung | Offline |<br />|---|---|---|<br />| <code>/help</code> (<code>/commands</code>) | Alle Commands auflisten | ✅ |<br />| <code>/model [provider/model]</code> | Modell anzeigen / für die Session wechseln (Provider, Status, Fallbacks) | — |<br />| <code>/skills [toggle &lt;name&gt;]</code> | Skills des aktiven Agenten auflisten / (de)aktivieren | — |<br />| <code>/mcp</code> | MCP-Server &amp; Tools auflisten | — |<br />| <code>/tools</code> | Für den Agent-Key freigegebene Tools/Endpunkte | — |<br />| <code>/permissions</code> (<code>/perms</code>) | Allow/Deny-Patterns des Agenten | — |<br />| <code>/agent [name]</code> | Aktiven Agenten anzeigen / (neue Session mit) Agent wechseln | ✅ |<br />| <code>/system</code> (<code>/soul</code>) | System-Prompt / Soul des Agenten anzeigen | — |<br />| <code>/compact</code> (<code>/summarize</code>) | Konversation zusammenfassen/komprimieren | — |<br />| <code>/memory</code> | Memory-Status anzeigen | — |<br />| <code>/clear</code> (<code>/new</code>, <code>/reset</code>) | Session zurücksetzen / neuen Chat | ✅ |<br />| <code>/retry</code> (<code>/regenerate</code>) | Letzte Nachricht erneut senden | ✅ |<br />| <code>/stop</code> (<code>/abort</code>, <code>/cancel</code>) | Laufende Antwort abbrechen | ✅ |<br />| <code>/export</code> | Chat als Markdown exportieren | ✅ |</p>\n<p>Die Registry ist erweiterbar: <code>registerSlashCommand(spec)</code> in<br /><code>addons/chat/src/lib/slash-commands.ts</code>. Der Parser <code>parseSlashCommand()</code> ist rein und unit-getestet<br />(<code>addons/chat/tests/unit/slash-commands.test.ts</code>).</p>\n<h2>Layout &amp; Deep-Links</h2>\n<p>- <strong>Volle Breite</strong>: Die Nachrichtenspalte hat eine großzügige Lese-Maximalbreite, der Chat nutzt die<br />  volle verfügbare Breite. Auf Desktop gibt es eine <strong>rechte Kontext-Sidebar</strong> (Agent,<br />  Workspace-Status, Modell/Backend, Token/Kontext/Kosten, Quota, freigegebene Skills, laufende<br />  Commands — DEVELOPMENT.md §7), auf Mobile ein <strong>Drawer</strong>.<br />- <strong>Deep-Links</strong>: Interne Links in Chat-/Fehler-/Empty-States öffnen <strong>keinen neuen Tab</strong> und laden<br />  die App <strong>nicht neu</strong>, sondern springen über den Client-Router direkt zur Zielsektion (z. B.<br />  <code>/admin?section=providers</code>). Helfer: <code>addons/chat/src/lib/deep-links.ts</code>.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET | <code>/api/v1/addons/chat/agents</code> |<br />| GET | <code>/api/v1/addons/chat/plan-limits</code> |<br />| GET, POST | <code>/api/v1/addons/chat/sessions</code> |<br />| DELETE | <code>/api/v1/addons/chat/sessions/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/chat/sessions/[id]/messages</code> |<br />| POST | <code>/api/v1/addons/chat/sessions/[id]/stream</code> |<br />| POST | <code>/api/v1/addons/chat/sessions/[id]/compact</code> |<br />| GET, POST | <code>/api/v1/addons/chat/groups</code> |<br />| GET, POST | <code>/api/v1/addons/chat/groups/[id]/members</code> |<br />| DELETE | <code>/api/v1/addons/chat/groups/[id]/members/[memberId]</code> |<br />| GET, POST | <code>/api/v1/addons/chat/groups/[id]/messages</code> |</p>\n<blockquote><p>Die Core-Routen unter <code>/api/v1/chat/...</code> sind dünne Proxies, die diese Addon-Handler</p><p>re-exportieren; die UI ruft die Core-Pfade auf.</p></blockquote>\n<h2>Gruppen-Chats (#147 / INBOX-70, ADR-057)</h2>\n<p>Ein <strong>Gruppen-Chat</strong> ist eine Session mit einer <strong>Mitglieder-Liste</strong>: mehrere<br /><code>user_id</code>s + mehrere <code>agent_id</code>s teilen eine Konversation. Additiv zum 1:1-Chat — die<br />strikte 1:1-Isolation (#148 / ADR-054) bleibt <strong>unverändert</strong>.</p>\n<p>- <strong>Datenmodell:</strong> neue Addon-Tabelle <code>chat_session_members</code><br />  (<code>session_id</code>, <code>member_type</code> user|agent, <code>member_id</code>, <code>role</code> owner|member, <code>added_by</code>).<br />  Die Session bleibt im Besitz von <code>user_sessions.user_id</code> (dem Ersteller). Nachrichten<br />  bleiben per <code>session_id</code>-FK an der Session — kein neues Owner-Feld pro Nachricht.<br />- <strong>Zugriff = Eigentümer ODER Nutzer-Mitglied</strong> (spiegelt das task-board-Muster exakt):<br />  <code>getAccessibleSession(userId, id)</code> liefert die Session nur für Eigentümer <strong>oder</strong> ein<br />  eingetragenes Nutzer-Mitglied; ein Nicht-Mitglied bekommt <strong>404</strong> (nicht 403, kein<br />  Existenz-Leak, <strong>kein Admin-Bypass</strong>). Eine 1:1-Session hat <strong>keine</strong> Mitglieder-Zeilen,<br />  daher reduziert sich <code>getAccessibleSession</code> dort auf den Eigentümer-Check<br />  (<code>getOwnedSession</code>) — Gruppen können eine 1:1-Session <strong>nie</strong> aufweiten. Die bestehenden<br />  <code>sessions/[id]/*</code>-Routen nutzen weiter <code>getOwnedSession</code>.<br />- <strong>@-Mention-Routing:</strong> In einer Gruppe antwortet ein Agent <strong>nur</strong>, wenn er per<br />  <code>@Name</code> erwähnt wird (mehrere erwähnte Agenten antworten je einmal, in Roster-Reihenfolge).<br />  <strong>Keine Erwähnung ⇒ kein Agent antwortet</strong> (verhindert N-Agenten-Sturm). Quoting für<br />  Mehrwort-Namen: <code>@&quot;Ada Lovelace&quot;</code>. Der mentionierte Agent wird wie heute über das Gateway<br />  invoziert. Ein zwischenzeitlich entferntes Agent-Mitglied antwortet auch bei Erwähnung<br />  nicht (Mitgliedschaft wird vor dem Senden erneut geprüft).<br />- <strong>Permission-gated:</strong> <strong>Nur der Eigentümer</strong> darf Mitglieder hinzufügen/entfernen<br />  (Mitglied-aber-nicht-Eigentümer ⇒ 403, Fremder ⇒ 404). Einen <strong>Agenten</strong> einzuladen<br />  erfordert, dass der Eigentümer diesen Agenten <strong>besitzt</strong> (cross-user Agent-ID<br />  machtlos — derselbe Check wie <code>createSession</code>). Agent-Antworten respektieren die<br />  Rechte-Obergrenze Eigentümer ∩ Agent (<code>effectiveAgentCapabilities</code>, ADR-055): der Agent<br />  wurde vom Eigentümer eingeladen, der ihn besitzt, also ist seine Cap-Obergrenze durch den<br />  Eigentümer begrenzt.<br />- <strong>Evil-User-Tests:</strong> <code>addons/chat/tests/unit/group-chat-isolation.test.ts</code> (reale<br />  in-memory better-sqlite3, User A/B/C, Agenten/Gateway gemockt) + die reine<br />  <code>mentions.test.ts</code>.</p>\n<h2>Per-User-Isolation (#148 / ADR-054)</h2>\n<p>Chat-Sessions + Nachrichten sind <strong>strikt pro Nutzer isoliert</strong>. Die „Session&quot; ist<br />die Core-Tabelle <code>user_sessions</code> (<code>user_id NOT NULL</code>); Nachrichten (<code>chat_messages</code>)<br />hängen per <code>session_id</code>-FK an der Session. Sämtlicher Zugriff läuft über<br /><code>addons/chat/src/lib/chat-store.ts</code>, das <code>user_sessions</code> über die Scope-Layer<br />(<code>scopedStore</code>/<code>registerScopedTable</code>) führt.</p>\n<p>- <strong>Server-stamped Owner:</strong> Beim Anlegen wird <code>user_id = auth.id</code> gesetzt; ein vom<br />  Client mitgeschickter Owner wird ignoriert. Eine Session kann nur an <strong>eigene</strong><br />  Agenten gebunden werden.<br />- <strong>404 statt 403, kein Admin-Bypass:</strong> Jede <code>sessions/[id]/*</code>-Route<br />  (route/messages/compact/stream) lädt die Session via <code>getOwnedSession(auth.id, id)</code>.<br />  Gehört sie einem anderen Nutzer (oder existiert nicht), kommt <strong>404</strong> zurück —<br />  nicht der Inhalt, nicht 403, und <strong>auch ein Admin</strong> liest/streamt/komprimiert die<br />  fremde Konversation nicht. Bei <code>stream</code> greift die 404 <strong>bevor</strong> der SSE-Stream<br />  öffnet, bei <code>messages</code>/<code>compact</code> <strong>bevor</strong> irgendein Modell-Call passiert.<br />- <strong>gateway/LLM-Routing unberührt:</strong> Nur die Session/Message-Persistenz ist<br />  isoliert; Streaming + Compaction bleiben für den Eigentümer voll funktional.<br />- <code>agents</code> ist auf die eigenen Agenten gescoped; <code>plan-limits</code> liest<br />  instanzweite Provider-Limits (kein Per-User-Geheimnis).</p>\n<h2>Backend-Agent-Pfad, kein Passthrough (#550)</h2>\n<p>Jede Chat-Nachricht läuft <strong>durch den echten Backend-Agent-Pfad</strong><br />(hermes/openclaw) — niemals als roher Passthrough an ein LLM. Die Stream-Route<br />(<code>sessions/[id]/stream/route.ts</code>) ruft den Agenten-Gateway<br /><code>chatCompletionWithFallbacks(modelCandidates, opts, { backendId })</code> auf: Sie löst<br />die Modell-Fallback-Kette des Agenten auf (Primary zuerst), baut den SOUL-Prompt<br />und übergibt den Turn an den aktiven Backend-Driver. Selbst ohne explizit<br />aktiven Backend fällt das Gateway auf den verwalteten Default-Driver (openclaw)<br />zurück — ein Turn wird <strong>nie</strong> silent zu einem raw Provider-Call.</p>\n<p>- <strong>Verifizierbare Evidenz pro Turn:</strong> <code>addons/chat/src/lib/chat-agent-trace.ts</code><br />  baut einen reinen Trace-Record (backend, agent, model, candidates, tools, via)<br />  und schreibt <strong>eine</strong> grep-bare <code>[chat:agent-path]</code>-Zeile pro Turn ins Core-Log<br />  (<code>tail</code> zeigt backend + agent + model + tools + via für jede Nachricht). Zusätzlich<br />  emittiert die Route ein <code>trace</code>-SSE-Event mit demselben Record.<br />- <strong><code>via</code> ist das Evidence-Flag:</strong> <code>&quot;backend&quot;</code> = der gebundene Backend-Driver<br />  hat geantwortet (echter Backend-Agent); <code>&quot;provider&quot;</code> = ein explizit gewählter<br />  roher Provider (aufgezeichnet, nicht silent); <code>&quot;fallback&quot;</code>/<code>&quot;unknown&quot;</code> = Degradation.<br />- <strong>UI-Transparenz:</strong> Der Chat-Header zeigt <code>via &lt;backend&gt; · &lt;model&gt; · &lt;via&gt; · 🛠N</code><br />  (Tools); der Standalone-Hook <code>useChatStream</code> exponiert <code>activeBackend</code>,<br />  <code>activeModel</code> und <code>agentPath</code>.<br />- <strong>Per-User-Isolation unberührt:</strong> Der Trace trägt die <code>userId</code> des Eigentümers;<br />  der gesamte Pfad bleibt durch <code>getOwnedSession</code> scope-gate-geschützt.</p>\n<p>Tests: <code>addons/chat/tests/unit/chat-agent-trace.test.ts</code> (reine Trace-Shape- &amp;<br />Log-Evidenz) + <code>addons/chat/tests/unit/chat-agent-path.test.ts</code> (Routing durch<br />den Agenten-Gateway mit Backend-Binding, Trace-Emission, Per-User-404).</p>\n<h2>Bekannte Grenzen</h2>\n<p>- <code>/compact</code> ist modellabhängig (Zusammenfassung): bei nicht erreichbarem Modell kommt ein 503 und<br />  der Command zeigt einen Hinweis.<br />- Der Markdown-Renderer ist absichtlich minimal (Code-Blöcke, Inline-Code, Fett/Kursiv, Listen,<br />  Links); kein vollständiger Markdown-Parser.</p>",
          "byteSize": 9669
        }
      }
    },
    {
      "id": "comfyui-web-app",
      "uuid": "402c8853-030a-40b4-b78d-f98a9a51e7db",
      "name": "ComfyUI Web App",
      "version": "1.0.3",
      "description": "Aiopta addon wrapper for the ComfyUI Web App with embedded UI, connection config, and passthrough API endpoints.",
      "author": "Aiopta",
      "icon": "🎛️",
      "capabilities": [
        "comfyui-embed",
        "workflow-management",
        "job-execution",
        "api-proxy"
      ],
      "tags": [],
      "download": "/artifacts/comfyui-web-app-1.0.3.zip",
      "sha256": "02d4fc5112c9053d403d40ee20a086ffe23c3c1aea5f435eeb3a8c31d301776d",
      "size": 52500,
      "page": {
        "id": "comfyui-web-app",
        "name": "ComfyUI Web App",
        "path": "/addons/comfyui-web-app",
        "icon": "🎛️",
        "section": "Media",
        "component": "@addons/comfyui-web-app/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/comfyui-web-app/config",
        "/api/v1/addons/comfyui-web-app/status",
        "/api/v1/addons/comfyui-web-app/workflows",
        "/api/v1/addons/comfyui-web-app/jobs",
        "/api/v1/addons/comfyui-web-app/execute"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>🎛️ ComfyUI Web App</h1>\n<p>Aiopta addon wrapper for the ComfyUI Web App with embedded UI, connection config, and passthrough API endpoints.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>comfyui-web-app</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> comfyui-embed, workflow-management, job-execution, api-proxy</li><li><strong>UI-Seite:</strong> <code>/addons/comfyui-web-app</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET, POST | <code>/api/v1/addons/comfyui-web-app/config</code> |<br />| POST | <code>/api/v1/addons/comfyui-web-app/execute</code> |<br />| GET | <code>/api/v1/addons/comfyui-web-app/jobs</code> |<br />| GET | <code>/api/v1/addons/comfyui-web-app/status</code> |<br />| GET | <code>/api/v1/addons/comfyui-web-app/workflows</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>Keine bekannten Einschränkungen.</li></ul>\n<blockquote><p>README aus <code>addon.json</code> + vorhandenen Routen generiert (<code>scripts/generate-addon-readmes.js</code>). Bei manuellen Ergänzungen den Generator-Hinweis entfernen.</p></blockquote>",
          "byteSize": 951
        }
      }
    },
    {
      "id": "commerce",
      "uuid": "5ba1c6f2-d305-4007-a736-3e5d6595ef70",
      "name": "Commerce",
      "version": "0.6.3",
      "description": "Commerce consolidation hub (Slices 2–5): the canonical connector framework + shared Order/Inventory model that the channel addons migrate into. Shopify (Slice 2, #588), Etsy (Slice 3, #589), Dropshipping/POD (Slice 4, #590) and Marketplace-Automation (Slice 5, #591) are integrated as connectors. Each connector is per-user BYOK (ADR-054) with its own credentials; sync/fulfillment is always an explicit call. External SaaS APIs — no container.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "commerce",
        "commerce-connector-framework",
        "commerce-connectors",
        "commerce-sync",
        "commerce-shopify",
        "commerce-etsy",
        "commerce-dropshipping",
        "commerce-dropshipping-margins",
        "commerce-pod-printful",
        "commerce-pod-printify",
        "commerce-marketplace",
        "commerce-marketplace-accounts",
        "commerce-marketplace-listings",
        "commerce-marketplace-publish"
      ],
      "tags": [],
      "download": "/artifacts/commerce-0.6.3.zip",
      "sha256": "f05534b28562d9ddb761333101f40cdf24a79b9bbb98c2be3aeeccf01b766463",
      "size": 392340,
      "page": {
        "id": "commerce-page",
        "path": "/addons/commerce",
        "name": "Commerce",
        "icon": "🛒",
        "section": "Money",
        "component": "@addons/commerce/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/commerce/status",
        "/api/v1/addons/commerce/connectors",
        "/api/v1/addons/commerce/shopify/status",
        "/api/v1/addons/commerce/shopify/config",
        "/api/v1/addons/commerce/shopify/products",
        "/api/v1/addons/commerce/shopify/stock",
        "/api/v1/addons/commerce/shopify/orders",
        "/api/v1/addons/commerce/shopify/sync",
        "/api/v1/addons/commerce/etsy/status",
        "/api/v1/addons/commerce/etsy/config",
        "/api/v1/addons/commerce/etsy/products",
        "/api/v1/addons/commerce/etsy/stock",
        "/api/v1/addons/commerce/etsy/orders",
        "/api/v1/addons/commerce/etsy/sync",
        "/api/v1/addons/commerce/dropshipping/status",
        "/api/v1/addons/commerce/dropshipping/config",
        "/api/v1/addons/commerce/dropshipping/products",
        "/api/v1/addons/commerce/dropshipping/orders",
        "/api/v1/addons/commerce/dropshipping/orders/:id",
        "/api/v1/addons/commerce/dropshipping/orders/:id/fulfill",
        "/api/v1/addons/commerce/dropshipping/fulfillments",
        "/api/v1/addons/commerce/dropshipping/margins",
        "/api/v1/addons/commerce/dropshipping/sync",
        "/api/v1/addons/commerce/marketplace/status",
        "/api/v1/addons/commerce/marketplace/accounts",
        "/api/v1/addons/commerce/marketplace/accounts/:id",
        "/api/v1/addons/commerce/marketplace/listings",
        "/api/v1/addons/commerce/marketplace/listings/:id",
        "/api/v1/addons/commerce/marketplace/listings/:id/publish",
        "/api/v1/addons/commerce/marketplace/jobs"
      ],
      "skills": [
        {
          "name": "commerce-status",
          "displayName": "Commerce: Connector overview",
          "description": "List the registered commerce connectors and which the caller has configured (BYOK).",
          "permissions": [
            "addons/commerce/read"
          ],
          "endpoint": "/api/v1/addons/commerce/status",
          "scripts": []
        },
        {
          "name": "commerce-shopify-sync",
          "displayName": "Commerce/Shopify: Sync products / stock / orders",
          "description": "Trigger an explicit sync of the caller's Shopify shop (products, stock and orders) via the Admin API using the user's BYOK access token. Migrated from standalone shopify addon (Slice 2, #588).",
          "permissions": [
            "addons/commerce/shopify/sync",
            "addons/commerce/shopify/products",
            "addons/commerce/shopify/orders"
          ],
          "endpoint": "/api/v1/addons/commerce/shopify/sync",
          "scripts": []
        },
        {
          "name": "commerce-shopify-products",
          "displayName": "Commerce/Shopify: Products & Stock",
          "description": "List the caller's cached Shopify products and stock levels, or write back a stock level to Shopify. Migrated from standalone shopify addon (Slice 2, #588).",
          "permissions": [
            "addons/commerce/shopify/products",
            "addons/commerce/shopify/stock"
          ],
          "endpoint": "/api/v1/addons/commerce/shopify/products",
          "scripts": []
        },
        {
          "name": "commerce-shopify-orders",
          "displayName": "Commerce/Shopify: Orders",
          "description": "List the caller's cached Shopify orders. Migrated from standalone shopify addon (Slice 2, #588).",
          "permissions": [
            "addons/commerce/shopify/orders"
          ],
          "endpoint": "/api/v1/addons/commerce/shopify/orders",
          "scripts": []
        },
        {
          "name": "commerce-etsy-sync",
          "displayName": "Commerce/Etsy: Sync listings / stock / orders",
          "description": "Trigger an explicit sync of the caller's Etsy shop (listings, stock and orders/receipts) via the OpenAPI v3 API. Migrated from standalone etsy addon (Slice 3, #589).",
          "permissions": [
            "addons/commerce/etsy/sync",
            "addons/commerce/etsy/products",
            "addons/commerce/etsy/orders"
          ],
          "endpoint": "/api/v1/addons/commerce/etsy/sync",
          "scripts": []
        },
        {
          "name": "commerce-etsy-products",
          "displayName": "Commerce/Etsy: Listings & Stock",
          "description": "List the caller's cached Etsy listings and stock levels, or write back a stock level to Etsy. Migrated from standalone etsy addon (Slice 3, #589).",
          "permissions": [
            "addons/commerce/etsy/products",
            "addons/commerce/etsy/stock"
          ],
          "endpoint": "/api/v1/addons/commerce/etsy/products",
          "scripts": []
        },
        {
          "name": "commerce-etsy-orders",
          "displayName": "Commerce/Etsy: Orders",
          "description": "List the caller's cached Etsy orders/receipts. Migrated from standalone etsy addon (Slice 3, #589).",
          "permissions": [
            "addons/commerce/etsy/orders"
          ],
          "endpoint": "/api/v1/addons/commerce/etsy/orders",
          "scripts": []
        },
        {
          "name": "commerce-dropshipping-catalog",
          "displayName": "Commerce/Dropshipping: Catalog & Import",
          "description": "List Printful/Printify catalog products (with margin-applied retail prices) and import one into the shop. Migrated from standalone dropshipping addon (Slice 4, #590).",
          "permissions": [
            "addons/commerce/dropshipping/products",
            "addons/commerce/dropshipping/status"
          ],
          "endpoint": "/api/v1/addons/commerce/dropshipping/products",
          "scripts": []
        },
        {
          "name": "commerce-dropshipping-orders",
          "displayName": "Commerce/Dropshipping: Orders & Fulfillment",
          "description": "List/create draft orders and explicitly fulfill them (forward to the supplier for production). Fulfillment moves money and is never implicit. Migrated from standalone dropshipping addon (Slice 4, #590).",
          "permissions": [
            "addons/commerce/dropshipping/orders",
            "addons/commerce/dropshipping/fulfillment"
          ],
          "endpoint": "/api/v1/addons/commerce/dropshipping/orders",
          "scripts": []
        },
        {
          "name": "commerce-dropshipping-margins",
          "displayName": "Commerce/Dropshipping: Margin Rules",
          "description": "Read or set per-user margin rules (percent + fixed, charm-rounding) that turn supplier base cost into retail price. Migrated from standalone dropshipping addon (Slice 4, #590).",
          "permissions": [
            "addons/commerce/dropshipping/margins"
          ],
          "endpoint": "/api/v1/addons/commerce/dropshipping/margins",
          "scripts": []
        },
        {
          "name": "commerce-marketplace-accounts",
          "displayName": "Commerce/Marketplace: Accounts",
          "description": "List, create, update and delete the caller's connected marketplace accounts (Kleinanzeigen, eBay, Vinted). Migrated from standalone marketplace-automation addon (Slice 5, #591).",
          "permissions": [
            "addons/commerce/marketplace/accounts"
          ],
          "endpoint": "/api/v1/addons/commerce/marketplace/accounts",
          "scripts": []
        },
        {
          "name": "commerce-marketplace-listings",
          "displayName": "Commerce/Marketplace: Listings",
          "description": "List, create and update listing drafts for marketplace publication. Migrated from standalone marketplace-automation addon (Slice 5, #591).",
          "permissions": [
            "addons/commerce/marketplace/listings"
          ],
          "endpoint": "/api/v1/addons/commerce/marketplace/listings",
          "scripts": []
        },
        {
          "name": "commerce-marketplace-publish",
          "displayName": "Commerce/Marketplace: Publish & Jobs",
          "description": "Trigger an explicit publish of a listing to its target providers and track publish jobs. Publish is always an explicit call (ADR-013). Migrated from standalone marketplace-automation addon (Slice 5, #591).",
          "permissions": [
            "addons/commerce/marketplace/publish",
            "addons/commerce/marketplace/jobs"
          ],
          "endpoint": "/api/v1/addons/commerce/marketplace/jobs",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": null
    },
    {
      "id": "container-orchestrator",
      "uuid": "d4e8ceba-d31e-4b2f-aa0b-f7ae3f23b557",
      "name": "Container Orchestrator (Lifecycle + Resource Limits + Health + Ephemeral Leases + Prometheus Metrics)",
      "version": "0.8.5",
      "description": "Aiopta Container Orchestrator — slices 1+2+3+4 of issues #354/#355/#356/#357. Slice 1/4 establishes the public API schema (ContainerRequest, ContainerStatus, ContainerAction), the pure-function action handlers (start/stop/restart with drift protection + state-transition matrix), and the request-shape validator. Slice 2/4 wires the docker-manager adapter behind the same seam (fake adapter for tests, real adapter behind feature flag) and adds the scoped operation store, drift detection, audit log, and preview. Slice 3/4 adds resource-limits (CPU/RAM/PIDs/Disk) + health monitoring (HTTP/TCP/exec probes) + cursor-paginated metrics + integration glue. Slice 4/4 adds the ephemeral-agent-container surface: lease-model + GC + crash-recovery + idempotent renew/release. Cross-user lookups map to 404 (no enumeration leak). The orchestrator does NOT expose delete/prune/exec/image-pull — those remain in the underlying docker-manager addon.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "container-lifecycle",
        "container-start",
        "container-stop",
        "container-restart",
        "container-status",
        "container-pure-handler",
        "container-request-validation",
        "container-drift-protection",
        "container-state-transition-matrix",
        "container-noop-on-redundant",
        "container-secret-shape-rejection",
        "container-log-injection-guard",
        "container-destroy",
        "container-state-machine",
        "container-drift-detection",
        "container-idempotency",
        "container-audit",
        "container-health-probe-stub",
        "fake-adapter-seam",
        "container-resource-limits",
        "container-resource-limits-validation",
        "container-resource-limits-host-capacity-check",
        "container-resource-limits-idempotent",
        "container-health-probe",
        "container-health-probe-http",
        "container-health-probe-tcp",
        "container-health-probe-exec",
        "container-health-probe-timeout",
        "container-health-probe-job-cancellation",
        "container-health-event-log",
        "container-health-event-severity-warning",
        "container-metrics-aggregation",
        "container-metrics-cursor-pagination",
        "container-metrics-user-isolation",
        "container-ephemeral-create",
        "container-ephemeral-renew",
        "container-ephemeral-release",
        "container-ephemeral-lease-model",
        "container-ephemeral-lease-gc",
        "container-ephemeral-crash-recovery",
        "container-ephemeral-default-quotas",
        "container-ephemeral-idempotent-replay",
        "container-ephemeral-per-user-isolation",
        "container-resource-sample",
        "container-resource-sample-quota-relative",
        "container-resource-sample-cursor-pagination",
        "container-prometheus-metrics-export",
        "container-prometheus-metrics-label-scoping",
        "container-prometheus-metrics-per-user-isolation",
        "container-standard-dashboard"
      ],
      "tags": [],
      "download": "/artifacts/container-orchestrator-0.8.5.zip",
      "sha256": "1f970ce33d73d976f77cfdff9d64b199e3457bf4a98cefb272591f38e8a9927e",
      "size": 587208,
      "page": null,
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/container-orchestrator/health",
        "/api/v1/addons/container-orchestrator/containers/:id/status",
        "/api/v1/addons/container-orchestrator/containers/:id/preview",
        "/api/v1/addons/container-orchestrator/containers/:id/start",
        "/api/v1/addons/container-orchestrator/containers/:id/stop",
        "/api/v1/addons/container-orchestrator/containers/:id/restart",
        "/api/v1/addons/container-orchestrator/containers/:id/resource-limits",
        "/api/v1/addons/container-orchestrator/containers/:id/health",
        "/api/v1/addons/container-orchestrator/containers/:id/metrics",
        "/api/v1/addons/container-orchestrator/metrics",
        "/api/v1/addons/container-orchestrator/containers/:id/resource-samples",
        "/api/v1/addons/container-orchestrator/ephemeral",
        "/api/v1/addons/container-orchestrator/ephemeral/lease",
        "/api/v1/addons/container-orchestrator/ephemeral/release"
      ],
      "skills": [
        {
          "name": "container-orchestrator-view",
          "displayName": "Container Orchestrator: Container-Status lesen",
          "description": "Liest den aktuellen Lifecycle-State eines Containers (running | stopped | restarting | exited | paused | unknown). Read-only, idempotent.",
          "permissions": [
            "addons/container-orchestrator/containers/status"
          ],
          "endpoint": "/api/v1/addons/container-orchestrator/containers/:id/status",
          "scripts": []
        },
        {
          "name": "container-orchestrator-manage",
          "displayName": "Container Orchestrator: Container-Actions (start/stop/restart)",
          "description": "Wendet start | stop | restart auf einen Container an. Drift-Schutz via snapshotHash, idempotencyKey erforderlich, explizite noop/drift/timeout/rejected Results. Audit-Log ohne Secrets.",
          "permissions": [
            "addons/container-orchestrator/containers/start",
            "addons/container-orchestrator/containers/stop",
            "addons/container-orchestrator/containers/restart"
          ],
          "endpoint": "/api/v1/addons/container-orchestrator/containers/:id/start",
          "scripts": []
        },
        {
          "name": "container-orchestrator-resource-limits",
          "displayName": "Container Orchestrator: Resource-Limits setzen",
          "description": "Setzt CPU/RAM/PIDs/Disk-Quotas für einen Container. Validiert gegen Host-Kapazität, idempotent (zweiter Aufruf mit gleichem Payload ist noop). QUOTA_EXCEEDS_HOST → 400. Slice 3/4 von #356.",
          "permissions": [
            "addons/container-orchestrator/containers/resource-limits"
          ],
          "endpoint": "/api/v1/addons/container-orchestrator/containers/:id/resource-limits",
          "scripts": []
        },
        {
          "name": "container-orchestrator-health",
          "displayName": "Container Orchestrator: Health-Probe",
          "description": "Führt eine einzelne Health-Probe (HTTP/TCP/exec) gegen einen Container aus. Status healthy | unhealthy | unknown, Latenz in ms. Slice 3/4 von #356.",
          "permissions": [
            "addons/container-orchestrator/containers/health"
          ],
          "endpoint": "/api/v1/addons/container-orchestrator/containers/:id/health",
          "scripts": [
            {
              "id": "container-orchestrator-health-cli",
              "name": "container-orchestrator-health-cli",
              "path": "container-orchestrator-health/cli.sh",
              "sha256": "11f51a07d8253c8c52b63c79273ef051846210f6aaa011919bde774ff5c57703",
              "interpreter": "bash",
              "timeoutMs": 10000,
              "permissions": [
                "addons/container-orchestrator/containers/health"
              ],
              "inputSchema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "limit": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 100,
                    "default": 20
                  },
                  "timeoutMs": {
                    "type": "integer",
                    "minimum": 100,
                    "maximum": 300000
                  },
                  "id": {
                    "type": "string",
                    "pattern": "^[A-Za-z0-9._-]+$",
                    "description": "Resource-Id — ersetzt :id im Endpoint"
                  }
                },
                "required": [
                  "id"
                ]
              },
              "outputSchema": {
                "type": "object",
                "required": [
                  "ok"
                ],
                "properties": {
                  "ok": {
                    "type": "boolean"
                  },
                  "status": {
                    "type": "integer"
                  },
                  "data": {},
                  "error": {
                    "type": "string"
                  },
                  "truncated": {
                    "type": "boolean"
                  }
                }
              },
              "size": 12248
            }
          ]
        },
        {
          "name": "container-orchestrator-metrics",
          "displayName": "Container Orchestrator: Health-Metriken",
          "description": "Cursor-paginierte Health-Samples pro Container (default limit=20, max=100), inkl. successRate + p50/p95 Latency. Pro-User isoliert. Slice 3/4 von #356.",
          "permissions": [
            "addons/container-orchestrator/containers/metrics"
          ],
          "endpoint": "/api/v1/addons/container-orchestrator/containers/:id/metrics",
          "scripts": []
        },
        {
          "name": "container-orchestrator-resource-samples",
          "displayName": "Container Orchestrator: Resource-Samples (CPU/RAM/Netz/IO)",
          "description": "Cursor-paginierte ResourceSample-Rows pro Container (CPU/RAM/Netz/IO, default limit=20, max=100), quota-relativiert gegen memoryLimitBytes (ADR-CO-07). Pro-User isoliert. ADR-CO-06 (#573).",
          "permissions": [
            "addons/container-orchestrator/containers/resource-samples"
          ],
          "endpoint": "/api/v1/addons/container-orchestrator/containers/:id/resource-samples",
          "scripts": []
        },
        {
          "name": "container-orchestrator-prometheus-metrics",
          "displayName": "Container Orchestrator: Prometheus /metrics Export",
          "description": "Pull-basierter Prometheus-Textformat-Export (/metrics) über Health- + Resource-Samples. container_id-Label immer, owner-Label nur für Admin (per-User gefiltert, Isolation erhalten). ADR-CO-06 Option D (#573).",
          "permissions": [
            "addons/container-orchestrator/metrics"
          ],
          "endpoint": "/api/v1/addons/container-orchestrator/metrics",
          "scripts": []
        },
        {
          "name": "container-orchestrator-ephemeral-create",
          "displayName": "Container Orchestrator: Ephemeral-Container erstellen (lease-modell)",
          "description": "Erstellt einen ephemeren Container mit definiertem Lease (TTL 1..86400s). Container wird automatisch gestoppt nach Lease-Ablauf, GC sweep alle 60s. Idempotent auf leaseKey. Slice 4/4 von #357.",
          "permissions": [
            "addons/container-orchestrator/ephemeral"
          ],
          "endpoint": "/api/v1/addons/container-orchestrator/ephemeral",
          "scripts": []
        },
        {
          "name": "container-orchestrator-ephemeral-renew",
          "displayName": "Container Orchestrator: Ephemeral-Lease erneuern",
          "description": "Verlängert das Lease eines ephemeren Containers um leaseTtlSeconds (absolut, nicht additiv). Idempotent: gleicher leaseKey mit gleichem TTL gibt dieselbe Response ohne doppelte Verlängerung. Slice 4/4 von #357.",
          "permissions": [
            "addons/container-orchestrator/ephemeral/lease"
          ],
          "endpoint": "/api/v1/addons/container-orchestrator/ephemeral/lease",
          "scripts": []
        },
        {
          "name": "container-orchestrator-ephemeral-release",
          "displayName": "Container Orchestrator: Ephemeral-Container explizit freigeben",
          "description": "Setzt Lease-Status auf `released` und stoppt den Container sofort. Idempotent: replay nach erfolgreichem Release gibt noop zurück. Slice 4/4 von #357.",
          "permissions": [
            "addons/container-orchestrator/ephemeral/release"
          ],
          "endpoint": "/api/v1/addons/container-orchestrator/ephemeral/release",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Container Orchestrator — Slice 2/4 (Issue #355)</h1>\n<p>Container lifecycle management for the Aiopta platform. Slice 2 ships the<br /><strong>start / stop / restart / destroy</strong> mutating surface with idempotent<br />semantics, drift detection, scoped operation store, and an audit log.</p>\n<h2>Design-Entscheidungen (ADR)</h2>\n<p>Die offenen Architektur-/Produktentscheidungen der Container-Orchestrator-API<br />(Epic #207, gesammelte Owner-Runde #390) sind als ADR-Vorlagen (Kontext,<br />Optionen mit Trade-offs, begründete Empfehlung) in<br /><code>docs/adr/</code> dokumentiert (Serie <code>ADR-CO-01</code> … <code>ADR-CO-08</code>,<br />Status <code>Proposed</code> — Owner-Entscheidung ausstehend).</p>\n<h2>Slices</h2>\n<p>| # | Slice | Status |<br />|---|---|---|<br />| 1 | Manifest schema (slice #354) | prerequisite |<br />| 2 | Container lifecycle (this slice) | ✅ |<br />| 3 | Health probes + resource quotas | upcoming |<br />| 4 | Ephemeral agent containers | upcoming |</p>\n<h2>Architecture</h2>\n<p>The slice is a <strong>bounded context</strong>. It does NOT import from <code>docker-manager</code><br />or <code>development</code> — the patterns from those addons are mirrored<br />shape-for-shape but kept independent (see issue #355 Architektur-Vorgabe:<br />&quot;Pattern, kein Import&quot;).</p>\n<pre><code>src/lib/\n├── types.ts            Domain types (Container, ContainerState, ...)\n├── state-machine.ts    Pure transition table (start|stop|restart|destroy)\n├── store.ts            In-memory scoped container registry\n├── adapter.ts          FakeContainerAdapter (deterministic, hermetic)\n├── operation-store.ts  Idempotency-key store (7-day TTL)\n├── drift.ts            Snapshot hash + drift detection + user-scope guard\n├── preview.ts          Preview pipeline + dispatch-log audit\n├── handlers.ts         runStart / runStop / runRestart / runDestroy\n├── health.ts           Addon-level health probe (slice-3 will add per-container)\n└── manifest.ts         Public export surface</code></pre>\n<h2>Acceptance Criteria → Test mapping</h2>\n<p>| AC | Test |<br />|---|---|<br />| <code>container_stop</code> on stopped → 200 idempotentReplay (no double-persist) | <code>tests/unit/idempotency.test.ts</code> |<br />| Cross-user → 404 (no enumeration leak) | <code>tests/unit/user-isolation.test.ts</code> |<br />| Drift detected → 409 DRIFT_DETECTED | <code>tests/unit/drift.test.ts</code>, <code>tests/unit/stop.test.ts</code> |<br />| Adapter timeout &gt; 30s → pending, retry re-attaches | <code>tests/unit/timeout.test.ts</code> |<br />| Container orphan during operation → state: orphaned | <code>tests/unit/orphan-recovery.test.ts</code> |<br />| <code>restart</code> on exited is equivalent to <code>start</code> | <code>tests/unit/restart.test.ts</code> |<br />| <code>start</code> on running → 200 idempotentReplay | <code>tests/unit/start.test.ts</code> |<br />| 7-day retention (no auto-delete &lt; 7d) | <code>tests/unit/operation-store.test.ts</code> |<br />| Audit log with latencyMs | <code>tests/unit/audit.test.ts</code> |<br />| State machine correctness (32 transitions) | <code>tests/unit/state-machine.test.ts</code> |</p>\n<h2>Tests</h2>\n<pre><code class=\"lang-bash\">npm test</code></pre>\n<p>59 unit tests across 11 suites. All passing as of slice 2/4.</p>\n<h2>Dependencies</h2>\n<ul><li><code>node &gt;= 20</code> (uses <code>node --experimental-strip-types</code> for tests).</li><li>No external runtime deps (CLAUDE.md: dependency-free addons).</li><li><code>crypto</code> (built-in) for snapshot hashing.</li></ul>\n<h2>Out of scope (slice 2)</h2>\n<ul><li>Real adapter integration (<code>RemoteAgentAdapter</code>) — lands with parent #39.</li><li>Health probes (slice 3).</li><li>Resource quotas (slice 3).</li><li>Ephemeral agent containers (slice 4).</li><li>Image updates (separate slice, parent #159 pattern).</li></ul>",
          "byteSize": 3356
        }
      }
    },
    {
      "id": "copilot",
      "uuid": "67928067-1c5c-4754-bd23-facb873afd88",
      "name": "Aiopta Copilot",
      "version": "1.1.8",
      "description": "The admin/setup copilot: an agent that controls, manages, sets up and configures Aiopta itself via natural language. It drives the platform's own management operations through the agent-capable Live-OpenAPI (diagnose, config, addons, agents, build dashboards/workflows). Every tool is gated by the owner/admin-only mc.manage capability (default-deny), audited via logAudit (secrets masked), and — when sensitive/irreversible — queued for the owner's approval, never auto-run. No container: it reuses the platform's own agent/backend infrastructure (connector/internal pattern). Idempotently seeded as a system-agent after onboarding.",
      "author": "Aiopta",
      "icon": "🛠️",
      "capabilities": [
        "mc-management",
        "copilot-console",
        "management-toolset",
        "approval-queue",
        "audited-tools"
      ],
      "tags": [
        "onboarding"
      ],
      "download": "/artifacts/copilot-1.1.8.zip",
      "sha256": "ab747a4e9d7b9354b538e33f62955c71769997d967be0d835f3ac4a978a5a029",
      "size": 269145,
      "page": {
        "id": "copilot-page",
        "path": "/addons/copilot",
        "name": "Aiopta Copilot",
        "icon": "🛠️",
        "section": "Automation",
        "component": "@addons/copilot/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/copilot/status",
        "/api/v1/addons/copilot/sessions",
        "/api/v1/addons/copilot/sessions/:id",
        "/api/v1/addons/copilot/sessions/:id/message",
        "/api/v1/addons/copilot/actions",
        "/api/v1/addons/copilot/actions/:id/approve",
        "/api/v1/addons/copilot/actions/:id/reject",
        "/api/v1/addons/copilot/seed",
        "/api/v1/addons/copilot/widget"
      ],
      "skills": [
        {
          "name": "mc-diagnose",
          "displayName": "Diagnose (read-only)",
          "description": "Read platform health/status, list configured providers and the active backend, list addons and agents, and surface problems with proposed fixes. Read-only diagnostics. Requires the owner/admin-only mc.manage capability; every call is audited.",
          "permissions": [
            "addons/copilot/status",
            "addons/copilot/sessions"
          ],
          "endpoint": "/api/v1/addons/copilot/sessions",
          "scripts": []
        },
        {
          "name": "mc-configure",
          "displayName": "Configure (settings, providers, backend, addons, agents)",
          "description": "Get/set Aiopta settings, set or rotate LLM/provider keys, select the active backend, install/enable/disable/configure addons, and create/edit/delete agents — all through the platform API. Requires the owner/admin-only mc.manage capability. Sensitive/irreversible actions (secret rotation, backend selection, addon enable/disable, agent delete) are NEVER auto-run: they are queued for the owner's approval. Every call is audited and secret values are masked.",
          "permissions": [
            "addons/copilot/sessions",
            "addons/copilot/actions"
          ],
          "endpoint": "/api/v1/addons/copilot/sessions",
          "scripts": []
        },
        {
          "name": "mc-build",
          "displayName": "Build (dashboards & workflows)",
          "description": "Create dashboards/widgets and workflows (via the workflow-builder) through the platform API. Requires the owner/admin-only mc.manage capability; every call is audited.",
          "permissions": [
            "addons/copilot/sessions"
          ],
          "endpoint": "/api/v1/addons/copilot/sessions",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Aiopta Copilot</h1>\n<p>The <strong>flagship</strong> admin/setup copilot: an agent that controls, manages, sets up and configures Aiopta<br /><em>itself</em> via natural language — available right after onboarding (the core of the &quot;2-minute<br />aha&quot;). It <strong>drives the platform&#39;s own management operations</strong> through the agent-capable<br />Live-OpenAPI (ADR-022); there is <strong>no container</strong> — it reuses the platform&#39;s own agent/backend<br />infrastructure (connector/internal pattern, ADR-009).</p>\n<p>This is powerful and dangerous, so the <strong>security model is the most important part</strong>.</p>\n<h2>What it can do (the management toolset)</h2>\n<p>- <strong>Diagnose (read-only):</strong> read health/status, list configured providers + the active backend, list<br />  addons + agents, and surface problems with proposed fixes.<br />- <strong>Config:</strong> get/set settings (public URL, mail, connections), set/rotate LLM/provider keys, select the<br />  active backend.<br />- <strong>Addons:</strong> list, enable/disable, configure.<br />- <strong>Agents:</strong> list, create, edit, delete.<br />- <strong>Build:</strong> create a dashboard/widget, create a workflow (via the workflow-builder).</p>\n<p>Each tool wraps an <strong>existing platform-API endpoint</strong> — the copilot never re-implements core and never<br />calls an external service.</p>\n<h2>Security model (the point of this addon)</h2>\n<p>- <strong><code>mc.manage</code> capability, default-deny.</strong> Every management tool is gated by the owner/admin-only<br />  <code>mc.manage</code> RBAC capability (ADR-005, Phase S). A plain user — and a normal user-scoped agent key<br />  (which resolves to role <code>user</code>) — is <strong>denied 403 with no side effect</strong>. The route enforces it via<br />  <code>requireCapability(request, &quot;mc.manage&quot;)</code>, and the engine re-asserts <code>hasCapability</code> (defence in depth).<br />- <strong>Sensitive ⇒ approval, never direct.</strong> Delete / grant-root / move-money / external-post / secret-<br />  rotate / addon-enable are <strong>never executed directly</strong> — they are <strong>queued</strong> as a pending action and run<br />  ONLY after a human approves (delivered to the approvals panel + optionally a notification). Read/diagnose<br />  + reversible config run directly.<br />- <strong>Audited + masked.</strong> Every tool invocation writes a <code>logAudit</code> row (actor, action, target, params);<br />  secret-bearing fields are masked — a secret value is never logged in clear.<br />- <strong>Same-origin only.</strong> Every tool path is a literal <code>/api/v1/...</code> path, re-checked at request time —<br />  the copilot can never reach an external host. The owner&#39;s auth is forwarded to each internal call so<br />  platform RBAC re-applies server-side.<br />- <strong>Agent-key scoping (S1).</strong> The copilot&#39;s own agent key is provisioned least-privilege; it does not by<br />  itself carry <code>mc.manage</code>. The copilot acts on the <strong>owner&#39;s</strong> forwarded auth — the audited trust<br />  boundary — so an out-of-scope or capabilityless caller is denied.</p>\n<h2>Approval queue</h2>\n<p>Reuses the existing approval+notify mechanism established by business-operator (ADR-025) and the<br />notifications-approval delivery (C7 / ADR-035). A sensitive action creates a pending row the owner<br />confirms in the console&#39;s <strong>Freigaben</strong> panel (and optionally via the notifications addon). On approval<br />the action runs (and is audited as <code>copilot.action.approved</code>); on rejection it is dropped (audited as<br /><code>copilot.action.rejected</code>).</p>\n<h2>Surfaces</h2>\n<p>- <strong>Page</strong> <code>/addons/copilot</code> — the <strong>Aiopta Copilot</strong> console (chat + quick-action<br />  suggestions + a tool catalog with approval gates + the approvals panel). Renders a graceful<br />  &quot;connect a backend / seed the copilot&quot; state when not configured.<br />- <strong>Widget</strong> <code>copilot.ask</code> — <strong>&quot;Frag Aiopta&quot;</strong>: a prompt box that opens the console with the<br />  question prefilled, plus readiness + open-approval count.</p>\n<h2>Seeding</h2>\n<p><code>seedCopilotAgent(owner)</code> (in <code>src/lib/seed.ts</code>) provisions the copilot as an idempotent<br /><strong>system-agent</strong> (soul, model from the active backend, the management skills) via the shared<br /><code>provisionAgentFromSpec</code>. The onboarding-completion hook (#96) calls it at onboarding end; an admin can<br />also seed it now via <code>POST /api/v1/addons/copilot/seed</code>.</p>\n<h2>Backend-native</h2>\n<p>The conversation turn runs through <code>gatewayChatCompletion</code> with <code>{ needsTools: true }</code> (ADR-038): when<br />the active backend supports native tool-calling it runs there, otherwise the gateway falls back to the<br />default path. Memory/Tools/Orchestration are reused via the gateway, never reimplemented.</p>\n<p>See ADR-040 and <code>SKILL.md</code> for the full contract.</p>",
          "byteSize": 4333
        }
      }
    },
    {
      "id": "crm",
      "uuid": "d60d52fa-dfbc-4ebe-bdcf-061003d13e14",
      "name": "CRM (Kontakte, Firmen, Pipeline, Deals, Aktivitäten)",
      "version": "1.4.2",
      "description": "Kanonisches CRM-Addon und Ziel der CRM-Konsolidierung (crm ⊃ lead-gen, ADR-008 / Issue #491 / Epic #490): Vollständiges CRM auf der lead-gen-CRM-lite (ADR-015): Firmen (Organisationen), Kontakte (Personen, optional einer Firma zugeordnet), eine Deal-Pipeline (Stages → Deals), Aktivitäten (Anrufe/Notizen/Aufgaben/Meetings, an Kontakte/Deals gehängt) und eine E-Mail-/Touchpoint-Timeline. Lokale SQLite ist die Quelle der Wahrheit; reine Daten — KEIN externer Container, KEIN Provider, KEIN Secret. Jede Zeile ist pro Nutzer isoliert (user_id, ADR-054): Nutzer B sieht/ändert nie die Daten von Nutzer A. Das (jetzt als deprecated markierte) lead-gen-Addon wird hierher konsolidiert: seine erfassten Leads werden per Import verlustfrei zu CRM-Kontakten (lose Verknüpfung über die E-Mail, kein Hard-Coupling; die lead_gen_*-Tabellen bleiben unangetastet). ADR-054.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "crm-companies",
        "crm-contacts",
        "crm-deals",
        "crm-pipeline",
        "crm-activities",
        "crm-touchpoints",
        "lead-gen-import",
        "lead-gen-consolidation",
        "lead-scraping",
        "lead-enrichment",
        "outreach-sequences",
        "outreach-sending",
        "outreach-suppression"
      ],
      "tags": [],
      "download": "/artifacts/crm-1.4.2.zip",
      "sha256": "fe7e687594757c10752d013fbbf9d2d0d6d97b2aaa8e5446405d2a918ad4d17c",
      "size": 367738,
      "page": {
        "id": "crm-page",
        "path": "/addons/crm",
        "name": "CRM",
        "icon": "🤝",
        "section": "Money",
        "component": "@addons/crm/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/crm/status",
        "/api/v1/addons/crm/companies",
        "/api/v1/addons/crm/companies/:id",
        "/api/v1/addons/crm/contacts",
        "/api/v1/addons/crm/contacts/:id",
        "/api/v1/addons/crm/contacts/:id/detail",
        "/api/v1/addons/crm/contacts/:id/touchpoints",
        "/api/v1/addons/crm/deals",
        "/api/v1/addons/crm/deals/:id",
        "/api/v1/addons/crm/deals/:id/stage",
        "/api/v1/addons/crm/activities",
        "/api/v1/addons/crm/activities/:id",
        "/api/v1/addons/crm/import/leads",
        "/api/v1/addons/crm/widget",
        "/api/v1/addons/crm/outreach/status",
        "/api/v1/addons/crm/outreach/contacts",
        "/api/v1/addons/crm/outreach/contacts/:id",
        "/api/v1/addons/crm/outreach/pipeline",
        "/api/v1/addons/crm/outreach/enrich",
        "/api/v1/addons/crm/outreach/sequences",
        "/api/v1/addons/crm/outreach/sequences/:id/generate",
        "/api/v1/addons/crm/outreach/sequences/:id/send",
        "/api/v1/addons/crm/outreach/suppression",
        "/api/v1/addons/crm/outreach/widget"
      ],
      "skills": [
        {
          "name": "crm-overview",
          "displayName": "CRM: Status & Übersicht",
          "description": "Liest die CRM-Übersicht des Nutzers (Firmen, Kontakte, offene/gewonnene Deals, offene Pipeline-Summe, offene Aufgaben, Deals je Stage). Reiner Lesezugriff — ändert nichts.",
          "permissions": [
            "addons/crm/status"
          ],
          "endpoint": "/api/v1/addons/crm/status",
          "scripts": [
            {
              "id": "crm-overview-cli",
              "name": "crm-overview-cli",
              "path": "crm-overview/cli.sh",
              "sha256": "1c9b2db4177e316cbc978045891f2899a6b913baee09a2d27d4bebfdc42e085a",
              "interpreter": "bash",
              "timeoutMs": 10000,
              "permissions": [
                "addons/crm/status"
              ],
              "inputSchema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "limit": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 100,
                    "default": 20
                  },
                  "timeoutMs": {
                    "type": "integer",
                    "minimum": 100,
                    "maximum": 300000
                  }
                }
              },
              "outputSchema": {
                "type": "object",
                "required": [
                  "ok"
                ],
                "properties": {
                  "ok": {
                    "type": "boolean"
                  },
                  "status": {
                    "type": "integer"
                  },
                  "data": {},
                  "error": {
                    "type": "string"
                  },
                  "truncated": {
                    "type": "boolean"
                  }
                }
              },
              "size": 12045
            }
          ]
        },
        {
          "name": "crm-companies",
          "displayName": "CRM: Firmen",
          "description": "Listet/erstellt/bearbeitet Firmen (Organisationen) mit Domain/Branche/Website. Kontakte und Deals können einer Firma zugeordnet werden.",
          "permissions": [
            "addons/crm/companies"
          ],
          "endpoint": "/api/v1/addons/crm/companies",
          "scripts": []
        },
        {
          "name": "crm-contacts",
          "displayName": "CRM: Kontakte",
          "description": "Listet/erstellt/bearbeitet Kontakte (Personen, optional einer Firma zugeordnet) und liest die Kontakt-Detailansicht mit Deals, Aktivitäten und der Touchpoint-Timeline. De-dupliziert per E-Mail.",
          "permissions": [
            "addons/crm/contacts",
            "addons/crm/companies"
          ],
          "endpoint": "/api/v1/addons/crm/contacts",
          "scripts": []
        },
        {
          "name": "crm-deals",
          "displayName": "CRM: Deals & Pipeline",
          "description": "Listet/erstellt/bearbeitet Deals und bewegt sie durch die Pipeline (lead→qualified→proposal→negotiation→won/lost). Die Stage-Maschine ist erzwungen; won/lost sind terminal.",
          "permissions": [
            "addons/crm/deals",
            "addons/crm/contacts"
          ],
          "endpoint": "/api/v1/addons/crm/deals",
          "scripts": []
        },
        {
          "name": "crm-activities",
          "displayName": "CRM: Aktivitäten",
          "description": "Protokolliert Aktivitäten (Anruf/Notiz/Aufgabe/Meeting/E-Mail) gegen einen Kontakt und/oder Deal und schließt Aufgaben ab. Reine Daten — versendet nichts.",
          "permissions": [
            "addons/crm/activities",
            "addons/crm/contacts",
            "addons/crm/deals"
          ],
          "endpoint": "/api/v1/addons/crm/activities",
          "scripts": []
        },
        {
          "name": "crm-import-leads",
          "displayName": "CRM: lead-gen-Leads importieren",
          "description": "Importiert die EIGENEN lead-gen-Kontakte des Nutzers als CRM-Kontakte (lose Verknüpfung über die E-Mail). Idempotent; verändert die lead-gen-Daten nicht.",
          "permissions": [
            "addons/crm/import",
            "addons/crm/contacts"
          ],
          "endpoint": "/api/v1/addons/crm/import/leads",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>CRM (Kontakte, Firmen, Pipeline, Deals, Aktivitäten)</h1>\n<p>Vollständiges CRM auf der lead-gen-CRM-lite (ADR-015): Firmen (Organisationen), Kontakte (Personen, optional einer Firma zugeordnet), eine Deal-Pipeline (Stages → Deals), Aktivitäten (Anrufe/Notizen/Aufgaben/Meetings, an Kontakte/Deals gehängt) und eine E-Mail-/Touchpoint-Timeline. Lokale SQLite ist die Quelle der Wahrheit; reine Daten — KEIN externer Container, KEIN Provider, KEIN Secret. Jede Zeile ist pro Nutzer isoliert (user_id, ADR-054): Nutzer B sieht/ändert nie die Daten von Nutzer A. Ein erfasster lead-gen-Lead kann per Import zu einem CRM-Kontakt werden (lose Verknüpfung über die E-Mail, kein Hard-Coupling). ADR-054.</p>\n<h2>CRM-Konsolidierung (crm ⊃ lead-gen, ADR-008 / #491, Epic #490)</h2>\n<p><code>crm</code> ist das <strong>kanonische Ziel-Addon</strong> der CRM-Konsolidierung. Das <code>lead-gen</code>-Addon (Lead-Capture +<br />Cold Outreach) ist als <strong>deprecated</strong> markiert (<code>replacedBy: crm</code>, Sunset <strong>2026-12-31</strong>) und wird<br />hierher zusammengeführt. Die gewählte Umsetzung ist <strong>datenerhaltend und rückwärtskompatibel</strong>:</p>\n<p>- <strong>Ein Ziel-Addon:</strong> <code>crm</code> ist die durable Relationship-Ebene, auf der lead-gen laut ADR-015 ohnehin<br />  aufsetzt. lead-gens Fähigkeiten bleiben im Alias-Fenster erreichbar; die Nachfolge zeigt auf <code>crm</code>.<br />- <strong>Verlustfreie Migration:</strong> die per-Nutzer-Brücke <code>POST /api/v1/addons/crm/import/leads</code><br />  (<code>importLeads</code>) liest lead-gens <strong>eigenen, bereits nutzer-isolierten</strong> Store und legt jeden Lead als<br />  CRM-Kontakt an (idempotent, per E-Mail verknüpft, <code>leadEmail</code>-Rückreferenz). Es wird **keine Zeile<br />  kopiert oder gelöscht** — die <code>lead_gen_*</code>-Tabellen bleiben unverändert bestehen (loss-free „by<br />  construction“, analog zum Core-Boot-Reconcile).<br />- <strong>Alt-ID-Redirect:</strong> auf <strong>Addon-Ebene</strong> über lead-gens Metadaten (<code>replacedBy: crm</code>, <code>sunset</code>,<br />  <code>settingsPagePath: /addons/crm</code>) — Marketplace/Settings-Hub zeigen den Nachfolger; auf<br />  <strong>Datensatz-Ebene</strong> löst der kanonische Resolver <code>getContactByLeadEmail(userId, email)</code><br />  (<code>src/lib/lead-redirect.ts</code>) eine alte lead-gen-Lead-Identität (per E-Mail) auf den migrierten<br />  CRM-Kontakt auf.<br />- <strong>Bewusst NICHT (Owner-Entscheidung):</strong> das schwere Outreach-Maschinenwerk (Scraper-Container,<br />  AI-Sequenzen, garantierter Versand, Suppression) wird <strong>nicht</strong> in einem Schritt physisch nach <code>crm</code><br />  verschoben — das würde eine riskante Zusammenführung zweier unterschiedlicher Kontakt-/Pipeline-<br />  Datenmodelle bedeuten. lead-gens Routen bleiben unverändert (deprecated, aber funktional); die<br />  Code-Relocation zu reinen Re-Export-Shims samt HTTP-Redirect-Headern ist der Folge-Schritt vor dem<br />  Entfernen (ADR-008 „one PR per cluster“).</p>\n<h3>Lizenz-/Pricing- und Permission-Review (AC3)</h3>\n<p>- <strong>Lizenz:</strong> beide Addons sind <code>UNLICENSED</code>/first-party (author <code>Aiopta</code>); die Konsolidierung ändert<br />  keine Drittlizenz. Keine neue Abhängigkeit.<br />- <strong>Pricing:</strong> lead-gen ist „Money-Stufe-1“; <code>crm</code> ist reine Daten ohne Container/Secret. Es wird **kein<br />  neuer bezahlter Flow** eingeführt. Der garantierte E-Mail-Versand (der einzige kostenwirksame Pfad)<br />  bleibt im deprecated lead-gen hinter seinen Guardrails; <code>crm</code> selbst versendet nichts.<br />- <strong>Permissions:</strong> <code>crm</code> bleibt least-privilege und rein nutzer-isoliert (ADR-054, jede Zeile<br />  <code>user_id</code>-gestempelt). Die Brücke liest ausschließlich die <strong>eigenen</strong> lead-gen-Daten des Aufrufers<br />  über dessen bereits gescopten Store — kein Cross-User-Zugriff, keine neue Berechtigung. lead-gens<br />  Skills (inkl. des separaten Sende-Skills) bleiben unverändert.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>crm</code></li><li><strong>Version:</strong> 1.2.0</li><li><strong>Fähigkeiten:</strong> crm-companies, crm-contacts, crm-deals, crm-pipeline, crm-activities, crm-touchpoints, lead-gen-import, lead-gen-consolidation</li><li><strong>UI-Seite:</strong> <code>/addons/crm</code></li><li><strong>Dashboard-Widgets:</strong> <code>crm.summary</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET, POST | <code>/api/v1/addons/crm/activities</code> |<br />| GET, PUT, DELETE | <code>/api/v1/addons/crm/activities/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/crm/companies</code> |<br />| GET, PUT, DELETE | <code>/api/v1/addons/crm/companies/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/crm/contacts</code> |<br />| GET, PUT, DELETE | <code>/api/v1/addons/crm/contacts/[id]</code> |<br />| GET | <code>/api/v1/addons/crm/contacts/[id]/detail</code> |<br />| GET, POST | <code>/api/v1/addons/crm/contacts/[id]/touchpoints</code> |<br />| GET, POST | <code>/api/v1/addons/crm/deals</code> |<br />| GET, PUT, DELETE | <code>/api/v1/addons/crm/deals/[id]</code> |<br />| PUT | <code>/api/v1/addons/crm/deals/[id]/stage</code> |<br />| POST | <code>/api/v1/addons/crm/import/leads</code> |<br />| GET | <code>/api/v1/addons/crm/status</code> |<br />| GET | <code>/api/v1/addons/crm/widget</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>Keine bekannten Einschränkungen.</li></ul>\n<blockquote><p>README aus <code>addon.json</code> + vorhandenen Routen generiert (<code>scripts/generate-addon-readmes.js</code>). Bei manuellen Ergänzungen den Generator-Hinweis entfernen.</p></blockquote>",
          "byteSize": 4882
        }
      }
    },
    {
      "id": "cubee3d-hive",
      "uuid": "3ebf6c8a-6232-4d6b-b6bc-9fb4b41bf0a2",
      "name": "Cubee3D Hive",
      "version": "1.1.2",
      "description": "Cubee3D Hive catalog reader — lists catalogs, products and 3D-designer assets from the Wix-hosted storefront using an authenticated session or BYOK API key. Automated Wix credential login remains separately tracked until Cubee3D provides a stable supported contract. Requires the WordPress & WooCommerce addon for downstream product sync.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "3d-products",
        "hive-catalog"
      ],
      "tags": [],
      "download": "/artifacts/cubee3d-hive-1.1.2.zip",
      "sha256": "622662bc07a7d16ffa7a6bcb5dae17a0d210cf28020e9b11994e888242b96a41",
      "size": 133124,
      "page": {
        "id": "cubee3d-hive-page",
        "path": "/addons/cubee3d-hive",
        "name": "Cubee3D Hive",
        "icon": "🎲",
        "section": "Tools",
        "component": "@addons/cubee3d-hive/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/cubee3d-hive/catalogs",
        "/api/v1/addons/cubee3d-hive/products",
        "/api/v1/addons/cubee3d-hive/credentials"
      ],
      "skills": [],
      "mcpServers": [
        {
          "id": "cubeehive-tools",
          "displayName": "Cubee3D Hive tools",
          "description": "Read-only Cubee3D/Hive catalog tools for agents: list catalogs, list products, get a single product. Read-only — no sync/mutation tools (those stay in the addon's authenticated write API).",
          "transport": "stdio",
          "runtime": "node",
          "entry": "cubeehive-tools/server.mjs",
          "permissions": [
            "addons/cubee3d-hive/read"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "archiveEntry": "cubee3d-hive/mcp/servers/cubeehive-tools/server.mjs",
          "sha256": "5d53de6517b5676094a698efce633b08a844e5a410cf5e2a9ba6501e559bcd32",
          "size": 12085,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>🎲 Cubee3D Hive (Katalog-Leser für 3D-Designer-Assets)</h1>\n<p>Cubee3D Hive Katalog-Leser — listet Kataloge, Produkte und 3D-Designer-Assets<br />des Wix-gehosteten Storefronts, über eine authentifizierte Session oder BYOK<br />API-Key. Nur lesend; für den Downstream-Produkt-Sync wird das WordPress- &amp;<br />WooCommerce-Addon benötigt.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>cubee3d-hive</code></li><li><strong>Version:</strong> 1.1.0</li><li><strong>Fähigkeiten:</strong> 3d-products, hive-catalog</li><li><strong>UI-Seite:</strong> <code>/addons/cubee3d-hive</code></li><li><strong>Abhängigkeit:</strong> <code>wordpress &gt;= 1.2.0</code> (Downstream-Produkt-Sync)</li></ul>\n<h2>Einrichtung</h2>\n<p>BYOK: im Frontend oder via <code>POST /credentials</code> einen API-Key hinterlegen<br />(<code>mode: apiKey</code>) bzw. eine authentifizierte Wix-Session (<code>mode: cookies</code>). Der<br />Credential-Status ist per-User gescoped; die Werte selbst werden nie in<br />Antworten gespiegelt, nicht einmal maskiert (#1056 S1) — <code>GET /credentials</code><br />liefert nur <code>{ configured, mode }</code>.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET | <code>/api/v1/addons/cubee3d-hive/catalogs</code> |<br />| GET | <code>/api/v1/addons/cubee3d-hive/products</code> |<br />| GET, POST | <code>/api/v1/addons/cubee3d-hive/credentials</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<p>- Reiner Katalog-Leser (Slice 1): Schreib-Operationen auf den Storefront<br />  sind nicht Teil des Addons.<br />- Automatisierter Wix-Credential-Login bleibt separat getrackt, bis Cubee3D<br />  einen stabilen unterstützten Vertrag liefert.</p>\n<blockquote><p>README manuell gepflegt (addon.json + Routen als Quelle).</p></blockquote>",
          "byteSize": 1417
        }
      }
    },
    {
      "id": "dataannotation-bot",
      "uuid": "303118cb-e901-4b42-9f80-29e7ae9df28f",
      "name": "DataAnnotation Bot",
      "version": "1.0.2",
      "description": "Human-like task automation for DataAnnotation.tech — browser automation (Puppeteer), Bezier-curve mouse movements, scheduling, and reporting. Runs as a Aiopta extension.",
      "author": "Aiopta",
      "icon": "🤖",
      "capabilities": [
        "automation",
        "browser",
        "puppeteer",
        "scheduling",
        "human-like-input"
      ],
      "tags": [],
      "download": "/artifacts/dataannotation-bot-1.0.2.zip",
      "sha256": "b09f1ddd547b701f2c6205683a9f9ad041026093533e707de700faeae7250b18",
      "size": 54321,
      "page": null,
      "deprecated": false,
      "endpoints": [
        "/api/dataannotation-bot/status"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>DataAnnotation Bot — Aiopta Addon</h1>\n<p>Human-like task automation for DataAnnotation.tech, built as an Aiopta extension.</p>\n<h2>Features</h2>\n<p>### 🖱️ Browser Automation (Puppeteer)<br />- Full headless/headed browser control via Puppeteer<br />- Realistic viewport (1920×1080) and user agent<br />- Anti-detection: removes <code>navigator.webdriver</code>, fakes plugins/languages<br />- Disables <code>AutomationControlled</code> Chrome feature</p>\n<p>### 🎯 Human-like Mouse Movements (Bezier Curves)<br />- <strong>Quadratic Bezier curves</strong> with random control points for natural cursor paths<br />- <strong>Eased interpolation</strong> — slower at start/end, faster in middle (mimics real hand acceleration)<br />- <strong>Micro-jitter</strong> — ±1-2px random offset on each step for realism<br />- <strong>Variable step count</strong> based on distance (more steps for longer moves)<br />- <strong>Pre/post-click pauses</strong> (100-300ms and 50-150ms)<br />- <strong>Hover dwell time</strong> — random 200-800ms hover before action</p>\n<p>### ⌨️ Human-like Typing<br />- Variable keystroke delay (50-200ms per character)<br />- <strong>3% typo rate</strong> — occasional double-char with backspace correction<br />- Natural backspace timing (100-300ms)</p>\n<p>### 📅 Human Scheduling<br />- <strong>Weekday evenings</strong>: 19:00–23:00<br />- <strong>Early mornings</strong>: 06:00–08:00<br />- <strong>Weekends</strong>: 10:00–22:00<br />- <strong>Never during business hours</strong> (9-17 weekdays)<br />- <strong>15% random day-off chance</strong></p>\n<p>### 📊 Reporting<br />- Session logging (tasks, earnings, duration)<br />- Daily reports with effective hourly rate<br />- 7-day rolling history</p>\n<h2>Architecture</h2>\n<pre><code>addons/dataannotation-bot/\n├── src/\n│   ├── index.ts                    # Addon registration\n│   ├── bot/\n│   │   ├── human-bot.ts            # Main bot orchestrator\n│   │   ├── browser-automation.ts   # Puppeteer wrapper\n│   │   ├── mouse-movements.ts      # Bezier curve mouse simulation\n│   │   ├── schedule.ts             # Work-hour scheduler\n│   │   └── report.ts               # Session reporting\n│   ├── api/\n│   │   ├── bot-status.route.ts     # GET /api/dataannotation-bot/status\n│   │   ├── bot-control.route.ts    # POST /api/dataannotation-bot/control\n│   │   └── bot-reports.route.ts    # GET /api/dataannotation-bot/reports\n│   ├── components/\n│   │   ├── BotControlPanel.tsx     # Start/stop/pause UI\n│   │   ├── BotStatusCard.tsx       # Compact status display\n│   │   └── HumanPatternConfig.tsx  # Config sliders\n│   ├── pages/dataannotation/\n│   │   └── page.tsx                # Full bot page\n│   └── config/\n│       └── addon.config.ts         # Addon metadata\n├── package.json\n├── tsconfig.json\n└── README.md</code></pre>\n<h2>API</h2>\n<p>| Endpoint | Method | Description |<br />|----------|--------|-------------|<br />| <code>/api/dataannotation-bot/status</code> | GET | Current bot status |<br />| <code>/api/dataannotation-bot/control</code> | POST | Send action (<code>start</code>, <code>stop</code>, <code>pause</code>, <code>resume</code>) |<br />| <code>/api/dataannotation-bot/reports?days=7</code> | GET | Daily reports |</p>\n<h2>Environment Variables</h2>\n<p>| Variable | Required | Description |<br />|----------|----------|-------------|<br />| <code>DATAANNOTATION_EMAIL</code> | Yes | Login email |<br />| <code>DATAANNOTATION_PASSWORD</code> | Yes | Login password |</p>\n<h2>Review of Original Scripts</h2>\n<p>The original <code>dataannotation-bot/</code> scripts were analyzed:<br />- <strong><code>human-bot.js</code></strong>: Uses Puppeteer with human-like typing, random delays, and anti-detection. <strong>Missing</strong>: mouse movement simulation.<br />- <strong><code>schedule.js</code></strong>: Human work-hour scheduling. Fully ported.<br />- <strong><code>report.js</code></strong>: Simple daily report generator. Enhanced with session logging.<br />- <strong><code>package.json</code></strong>: Dependencies: <code>puppeteer</code> ^24.41.0, <code>node-cron</code> ^4.2.1.</p>\n<p>### Key Improvements in Addon Version<br />1. <strong>Bezier curve mouse movements</strong> (original had none)<br />2. <strong>Eased interpolation</strong> for natural acceleration<br />3. <strong>Micro-jitter</strong> on cursor path<br />4. <strong>TypeScript</strong> throughout<br />5. <strong>React control panel</strong> with live status<br />6. <strong>Aiopta addon architecture</strong> (no core changes)</p>",
          "byteSize": 3959
        }
      }
    },
    {
      "id": "development",
      "uuid": "1c7801ec-ead1-4468-9b32-369f57a83904",
      "name": "Development",
      "version": "2.2.4",
      "description": "Lets agents start, steer, inspect, and terminate tmux-backed coding sessions (Claude Code, opencode, codex) via a pluggable engine-adapter registry, with per-session git-worktree isolation so parallel sessions on one repo never corrupt each other, plus per-session engine config (model, permissions, tools, system prompt) and custom provider environment overrides, plus per-user isolated workspaces with idempotent workspace + worktree cleanup (#151) and orphan recovery on startup, plus deterministic per-session project-instructions (AGENTS.md + global + explicit) load with full provenance (#131), plus server-side per-(user, session) session-start validation of engine, model id, provider, and credential ownership with adapter-scoped env allowlists (#111), plus a portable Skills + Commands catalog with provenance, hash, permissions and per-workspace materialisation to engine-native paths (#132), plus owner-scoped, versioned role profiles for architect / planner / coder / qa / research / docs with explicit permission presets, hardwired refusal of admin patterns, and immutable per-run snapshot/audit binding (#135), plus a multi-engine extension adapter (#236) with engine × kind support matrix, per-engine deterministic materializers (opencode / claude-code / codex), dry-run diff, fail-closed on unsupported entries, user-file preservation, partial-rollback on materialise failure, npm coordinate pinning and secret-stripping on markdown, plus a CodingProject domain (#139) that bundles repositories (code / tooling / docs), host, and lifecycle (draft / provisioning / ready / degraded / detached / deleting / failed) so sessions can OPTIONALLY anchor on a project (legacy sessions without projectId keep their row-only behaviour), plus a Git-Host-Adapter layer (#140) that registers per-user forge-host profiles (forgejo, generic-git), probes them with a token-redacted, SSRF-guarded connectivity check, and exposes typed argv builders for clone/fetch/push/ls-remote, plus versioned installer recipes with license, terms, hash and supply-chain policy for the future per-project Toolchain-Runtime (#145), plus capability-driven session-supervision for child-sessions, todos, diffs, forks, summaries and permission responses with per-operation support tags, autonomy-policy gating and audited decisions (#130), plus curated MCP-Server-Source-Management (local-binary / npm / docker / remote) with per-kind allowlist, secret-redacted health-check + tools/list probe, owner-defaults via adminRoute, and per-(user, owner) scoped persistence (#235), plus trusted plugin profiles with hook/tool boundaries, hash-pinned npm or project-local sources, owner-bound allowlist, role intersection, secret-redacting audit log, session binding, and OpenCode materialisation that preserves user files (#134).",
      "author": "Aiopta",
      "icon": "🧠",
      "capabilities": [
        "coding-sessions",
        "tmux-control",
        "agent-steering",
        "custom-llm-routing",
        "terminal-automation",
        "git-worktree-isolation",
        "engine-adapters",
        "parallel-orchestration",
        "per-session-engine-config",
        "per-user-workspace-isolation",
        "workspace-cleanup",
        "orphan-recovery",
        "project-instructions",
        "session-start-validation",
        "env-allowlist",
        "coding-resources",
        "skills-catalog",
        "commands-catalog",
        "engine-native-materialisation",
        "role-profiles",
        "permission-presets",
        "admin-pattern-refusal",
        "snapshot-audit-binding",
        "git-host-adapters",
        "forge-host-probe",
        "ssrf-guard",
        "coding-projects",
        "project-profiles",
        "repository-bundle",
        "project-lifecycle",
        "toolchain-installer-recipes",
        "recipe-schema",
        "recipe-validator",
        "license-policy",
        "supply-chain-policy",
        "owner-acknowledgement",
        "sbom-provenance",
        "session-supervision",
        "supervision-children",
        "supervision-todos",
        "supervision-diff",
        "supervision-fork",
        "supervision-summarize",
        "supervision-permissions",
        "permission-policy-gating",
        "supervision-audit",
        "extension-multi-engine-adapter",
        "extension-engine-materializer",
        "extension-engine-support-matrix",
        "extension-dry-run-diff",
        "extension-npm-pinning",
        "lsp-profile-management",
        "lsp-sources",
        "lsp-allowlist",
        "lsp-engine-intersection",
        "lsp-materializers",
        "lsp-health-check",
        "lsp-per-user-isolation",
        "lsp-workspace-cascade",
        "mcp-source-management",
        "mcp-source-local-binary",
        "mcp-source-npm",
        "mcp-source-docker",
        "mcp-source-remote",
        "mcp-source-allowlist",
        "mcp-source-probe",
        "mcp-source-health-check",
        "mcp-source-owner-defaults",
        "parallel-session-ui",
        "cursor-stream-reconnect",
        "output-buffer-dedupe",
        "plugin-profile-management",
        "plugin-profile-allowlist",
        "plugin-profile-versioning",
        "plugin-profile-hash-pinning",
        "plugin-hook-boundaries",
        "plugin-tool-collisions",
        "plugin-secret-redaction",
        "plugin-session-binding",
        "plugin-engine-intersection",
        "plugin-opencode-materialization",
        "plugin-uninstall-cleanup",
        "plugin-audit-log",
        "tool-binding-profiles",
        "tool-binding-custom-tools",
        "tool-binding-session-snapshot",
        "tool-binding-permission-intersection",
        "tool-binding-collision-resolution",
        "tool-binding-engine-materialisation",
        "tool-binding-redacted-status-view",
        "task-graph",
        "task-graph-validator",
        "task-graph-scheduler",
        "task-graph-completion-gate",
        "task-graph-handoff",
        "task-graph-restart-safe",
        "task-graph-role-handoff",
        "project-provisioning",
        "provision-dry-run",
        "provision-apply",
        "provision-rollback",
        "provision-policy",
        "provision-capability-check",
        "agent-tool-api",
        "agent-tool-session-create",
        "agent-tool-session-list",
        "agent-tool-session-get",
        "agent-tool-session-prompt-async",
        "agent-tool-session-events",
        "agent-tool-session-abort",
        "agent-tool-least-privilege",
        "agent-tool-cursor-pagination",
        "agent-tool-secret-redaction",
        "agent-tool-idempotency",
        "agent-tool-audit",
        "supervision-ui",
        "orchestration-overview",
        "orchestration-actions",
        "orchestration-graph-detail",
        "orchestration-cursor-reconnect",
        "orchestration-stale-banner",
        "orchestration-unsupported-explain",
        "toolchain-runtime",
        "toolchain-runtime-policy",
        "toolchain-lock",
        "toolchain-lock-hash",
        "toolchain-base-image-digest",
        "toolchain-snapshot",
        "toolchain-mount-allowlist",
        "toolchain-cpu-ram-pid-policy",
        "toolchain-network-policy",
        "toolchain-shared-snapshot",
        "toolchain-session-binding",
        "toolchain-fake-core-runtime",
        "toolchain-engine-probe-in-runtime",
        "toolchain-upgrade-rollback",
        "toolchain-audit-log",
        "toolchain-gc",
        "toolchain-no-host-path-fallback",
        "toolchain-no-shared-writable-home",
        "toolchain-revision-bumping",
        "project-setup-wizard",
        "project-health-dashboard",
        "project-dashboard-resource-graph",
        "project-dashboard-version-drift",
        "project-dashboard-typed-confirmation",
        "project-dashboard-rbac",
        "proprietary-coding-tools",
        "proprietary-coding-tools-claude-code",
        "proprietary-coding-tools-owner-rbac",
        "proprietary-coding-tools-acknowledgement",
        "proprietary-coding-tools-session-gate",
        "proprietary-coding-tools-cache-eviction",
        "proprietary-coding-tools-redirect-resolver",
        "proprietary-coding-tools-no-redistribute",
        "proprietary-coding-tools-no-auto-accept",
        "coding-control",
        "plan-orchestration",
        "agent-bridge"
      ],
      "tags": [],
      "download": "/artifacts/development-2.2.4.zip",
      "sha256": "4d66322b4740de8effcd59442d54410c84b419ce7a77aed87127a713cbf6081f",
      "size": 4540654,
      "page": {
        "id": "development",
        "name": "Development",
        "path": "/addons/development",
        "icon": "🧠",
        "section": "Tools",
        "component": "@addons/development/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/development/status",
        "/api/v1/addons/development/engines",
        "/api/v1/addons/development/providers",
        "/api/v1/addons/development/auth",
        "/api/v1/addons/development/sessions",
        "/api/v1/addons/development/sessions/:id/output",
        "/api/v1/addons/development/sessions/:id/input",
        "/api/v1/addons/development/sessions/:id/run",
        "/api/v1/addons/development/sessions/:id/terminate",
        "/api/v1/addons/development/sessions/:id/delete",
        "/api/v1/addons/development/role-profiles",
        "/api/v1/addons/development/role-profiles/:role",
        "/api/v1/addons/development/role-profiles/:role/fallback",
        "/api/v1/addons/development/projects",
        "/api/v1/addons/development/projects/:id",
        "/api/v1/addons/development/projects/:id/adopt",
        "/api/v1/addons/development/projects/:id/detach",
        "/api/v1/addons/development/sessions/:id/supervision/children",
        "/api/v1/addons/development/sessions/:id/supervision/todos",
        "/api/v1/addons/development/sessions/:id/supervision/diff",
        "/api/v1/addons/development/sessions/:id/supervision/fork",
        "/api/v1/addons/development/sessions/:id/supervision/summarize",
        "/api/v1/addons/development/sessions/:id/supervision/permissions",
        "/api/v1/addons/development/sessions/:id/supervision/permissions/:permissionId",
        "/api/v1/addons/development/extensions",
        "/api/v1/addons/development/extensions/:id/support",
        "/api/v1/addons/development/extensions/:id/diff",
        "/api/v1/addons/development/extensions/:id/materialize",
        "/api/v1/addons/development/workspaces/:ws/lsp",
        "/api/v1/addons/development/workspaces/:ws/lsp/:id",
        "/api/v1/addons/development/workspaces/:ws/lsp/:id/health",
        "/api/v1/addons/development/mcp-sources",
        "/api/v1/addons/development/mcp-sources/:id",
        "/api/v1/addons/development/mcp-sources/:id/health",
        "/api/v1/addons/development/mcp-sources/:id/probe",
        "/api/v1/addons/development/admin/mcp-sources",
        "/api/v1/addons/development/plugin-profiles",
        "/api/v1/addons/development/plugin-profiles/:id",
        "/api/v1/addons/development/plugin-profiles/:id/bindings/uninstall",
        "/api/v1/addons/development/sessions/:id/plugins",
        "/api/v1/addons/development/task-graphs",
        "/api/v1/addons/development/task-graphs/:id",
        "/api/v1/addons/development/task-graphs/:id/handoffs",
        "/api/v1/addons/development/projects/:id/provision",
        "/api/v1/addons/development/projects/:id/provision/plan",
        "/api/v1/addons/development/projects/:id/provision/apply",
        "/api/v1/addons/development/projects/:id/provision/rollback",
        "/api/v1/addons/development/projects/:id/health",
        "/api/v1/addons/development/projects/:id/upgrade",
        "/api/v1/addons/development/agent-tools",
        "/api/v1/addons/development/orchestration/overview",
        "/api/v1/addons/development/orchestration/graphs/:planId",
        "/api/v1/addons/development/orchestration/actions",
        "/api/v1/addons/development/admin/proprietary-tools",
        "/api/v1/addons/development/admin/proprietary-tools/:id",
        "/api/v1/addons/development/admin/proprietary-tools/:id/preview",
        "/api/v1/addons/development/admin/proprietary-tools/:id/acknowledge",
        "/api/v1/addons/development/admin/proprietary-tools/:id/acknowledgements"
      ],
      "skills": [
        {
          "name": "coding-session",
          "displayName": "Coding: Start & Steer Sessions",
          "description": "Start, inspect, steer and terminate isolated coding sessions (e.g. Claude Code) with provider/model env overrides",
          "permissions": [
            "addons/development/sessions",
            "addons/development/status"
          ],
          "endpoint": "/api/v1/addons/development/sessions",
          "scripts": []
        },
        {
          "name": "ci-check",
          "displayName": "Coding: CI Check (token-sparing)",
          "description": "Run the repo lint/type-check/test gate via ci-check.sh inside a session before committing — a cheap deterministic quality gate that avoids burning tokens on trial-and-error",
          "permissions": [
            "addons/development/sessions"
          ],
          "endpoint": "/api/v1/addons/development/sessions",
          "scripts": []
        },
        {
          "name": "coding-resources",
          "displayName": "Coding: Skills & Commands Catalog (#132)",
          "description": "Discover, validate and materialise portable Skills and re-usable Commands from project, addon and global sources into engine-native per-workspace paths (OpenCode: .opencode/skills + .agents/skills + .claude/commands). Other engines report unsupported. No network install, no auto-execute, no overwrite of existing project files.",
          "permissions": [
            "addons/development/status"
          ],
          "scripts": []
        },
        {
          "name": "coding-projects",
          "displayName": "Coding: Projects (#139) — Workspace Profiles & Repository Bundles",
          "description": "Manage CodingProjects — the stable bundle of repositories (code / tooling / docs), git host reference, and lifecycle (draft / provisioning / ready / degraded / detached / deleting / failed). Sessions can OPTIONALLY anchor on a project (legacy sessions without projectId keep their row-only behaviour). Adopt is idempotent on identical metadata; detach removes Aiopta-side references only (no provider-side deletes in this slice).",
          "permissions": [
            "addons/development/projects"
          ],
          "endpoint": "/api/v1/addons/development/projects",
          "scripts": []
        },
        {
          "name": "toolchain-installer-recipes",
          "displayName": "Coding: Toolchain Installer Recipes (#145)",
          "description": "Inspect the typed installer-recipe catalog (bundled / package-manager / upstream-reference / owner-url / manual), validate a recipe fail-closed (HTTPS-only, allowlist, size, timeout, redirect, SSRF, hash) and emit an audit-able InstallPlan (download source, version, hash/signature, license/terms, target runtime, network policy, argv). Owner acknowledgements are bound to recipe hash + terms URL/version + license SPDX id; any change invalidates the existing ack. No curl|bash, no silent license defaults, no execution on the Core host.",
          "permissions": [
            "addons/development/status"
          ],
          "scripts": []
        },
        {
          "name": "coding-supervision",
          "displayName": "Coding: Session-Supervision (#130)",
          "description": "Observe and steer long-lived coding sessions: list child sessions, read the in-flight todo list with revision cursors, read paginated file diffs, fork a child session, summarise session state, list pending permission requests and answer them with autonomy-policy gating + audit. Read-only callers can observe but cannot fork or answer permissions. Engines without a supervision surface (Claude Code, Codex today) report unsupported for every operation.",
          "permissions": [
            "addons/development/sessions"
          ],
          "endpoint": "/api/v1/addons/development/sessions/:id/supervision",
          "scripts": []
        },
        {
          "name": "coding-lsp-profiles",
          "displayName": "Coding: LSP-Server-Verwaltung (#234)",
          "description": "Manage LSP server profiles per workspace with three source kinds (local-binary, npm, docker). Owner-allowlist is mandatory (fail-closed); profiles not allowlisted stay in 'draft' state. Materializers write engine-native manifests (OpenCode `.opencode/config.json`, Claude Code `.lsp.json`, Codex `config.toml` lsp_servers block) idempotently; the effective LSP set is the intersection of workspace profiles, owner allowlist, and engine support. A 3 s health check aborts session start with `LSP_UNHEALTHY`; per-user isolation enforced via the scoped store (foreign profiles return 404).",
          "permissions": [
            "addons/development/workspaces"
          ],
          "endpoint": "/api/v1/addons/development/workspaces/:ws/lsp",
          "scripts": []
        },
        {
          "name": "coding-plugin-profiles",
          "displayName": "Coding: Plugin-Profile mit Hook-/Tool-Grenzen (#134)",
          "description": "Manage trusted plugin profiles (npm-pinned or project-local). Owner-allowlist is mandatory (fail-closed); profiles not allowlisted stay in 'draft' state. Each profile is hash-pinned (SHA-256), declares the allowed hook events + tool names + secrets + roles, and binds to a session via a frozen snapshot. OpenCode loads TS/npm wrappers from `.opencode/plugins/`; existing user files at the target path are preserved (Aiopta writes to a sibling `.aiopta-<id>.ts` fallback). Hash drift, tool-collision, host-path escape and undeclared secret access all fail closed. Hook/tool calls are audited with redacted secrets; non-declared env-var access marks the session degraded.",
          "permissions": [
            "addons/development/plugin-profiles",
            "addons/development/sessions"
          ],
          "endpoint": "/api/v1/addons/development/plugin-profiles",
          "scripts": []
        },
        {
          "name": "coding-tool-bindings",
          "displayName": "Coding: Tool-Bindings (MCP + Custom Tools) per Role/Session (#133)",
          "description": "Provider-neutral tool-binding contract: owner-curated profiles reference MCP-Server sources (#235) and Custom Tools (exec/http/function), the session-start gate resolves a frozen per-session snapshot = intersection of profile × role × engine capability × agent permission, tool-name collisions are namespaced deterministically, OAuth/header secrets are emitted as ${REF:...} placeholders (never literal), the redacted status view surfaces effective tool names + health + engine support without credentials, and the OpenCode adapter materialises engine-native config (Claude Code + Codex report unsupported until a follow-up).",
          "permissions": [
            "addons/development/tool-bindings"
          ],
          "endpoint": "/api/v1/addons/development/tool-bindings",
          "scripts": []
        },
        {
          "name": "coding-task-graph",
          "displayName": "Coding: Persistent Task-Graph with Parallel Child Sessions (#136)",
          "description": "Accept a DAG-shaped plan (tasks with role + doneDefinition + scope + tests + dependsOn), validate it (no cycles, deps resolve, bounded size), schedule ready tasks in isolated child sessions per role, route structured handoff artifacts between roles, and gate run completion on an independent mandatory QA step that depends on every mandatory coder task. Restart-safe: persisted state survives crashes; the scheduler refuses to start a second attempt for the same task while one is active; orphaned attempts are reaped on the next tick. Reuses the leases module (race-safe single-writer), the worktree module (per-session isolation), and the green-gate (merge-back is green-gated).",
          "permissions": [
            "addons/development/task-graphs"
          ],
          "endpoint": "/api/v1/addons/development/task-graphs",
          "scripts": []
        },
        {
          "name": "coding-project-provisioning",
          "displayName": "Coding: Project Provisioning (#141) — Idempotent Repo Bundle",
          "description": "Provision a CodingProject's repository bundle (code + tooling + optional docs) via the Git-Host-Adapter layer (#140). Build a versioned policy (default branch, label taxonomy, branch protection), dry-run the plan (per-resource capability + rollback classification), then apply with persisted operation ledger keyed on (project, resource_key, desired_hash). Idempotent re-runs are no-ops; partial failures are retryable per resource; rollback deletes only newly-created empty resources on explicit confirmation. No provider-side deletes of pre-existing repos, no merge/branch-protection override, no Aiopta constants in the policy.",
          "permissions": [
            "addons/development/projects"
          ],
          "endpoint": "/api/v1/addons/development/projects/:id/provision",
          "scripts": []
        },
        {
          "name": "agent-tools",
          "displayName": "Coding: Agent-Tool-API für Sessionsteuerung (#113, Least Privilege)",
          "description": "Provider-neutral tool surface for authorised agents to manage coding sessions end-to-end: session_create / session_list / session_get / session_prompt_async / session_events / session_abort. Same AuthZ + status machine as the UI; every mutating call is idempotent (replay-safe under the same idempotency key) and audited. Owner-scoped — foreign session ids return 404 (no existence leak). Responses are paginated/cursor-based (nextCursor/hasMore) and secret-redacted; engine verdicts (`supported` / `unsupported` / `unavailable`) are reported honestly (no fake-green). No tool for arbitrary host shell, free host paths, credential values, merge or foreign session ids. Supervision (children/todos/diff/fork/summarize/permissions) stays in #130.",
          "permissions": [
            "addons/development/sessions"
          ],
          "endpoint": "/api/v1/addons/development/agent-tools",
          "scripts": []
        },
        {
          "name": "coding-orchestration-ui",
          "displayName": "Coding: Orchestration-UI für Rollen, Task-Graph, Child-Sessions und QS-Evidenz (#137)",
          "description": "Observe and steer active runs: render the canonical task-graph (role, status, dependencies, attempt, session, runtime, blocker), the parent/child session tree, and a per-task supervision detail (handoffs, todos, diff summary, QS evidence, pending permissions, audit timeline). Actions (pause / resume / cancel / approve / deny) are validated server-side; a stale action returns 409 and refreshes the view (no optimistic mutation, no fake-green unsupported panel). Snapshots are revision-tagged so a reconnect resumes from the last cursor without duplication.",
          "permissions": [
            "addons/development/task-graphs",
            "addons/development/sessions"
          ],
          "endpoint": "/api/v1/addons/development/orchestration/overview",
          "scripts": [
            {
              "id": "coding-orchestration-ui-cli",
              "name": "coding-orchestration-ui-cli",
              "path": "coding-orchestration-ui/cli.sh",
              "sha256": "2a5478bdac7e2f91b336c807e6611593874890006b44624690d9bbfc1d924416",
              "interpreter": "bash",
              "timeoutMs": 10000,
              "permissions": [
                "addons/development/sessions",
                "addons/development/task-graphs"
              ],
              "inputSchema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "limit": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 100,
                    "default": 20
                  },
                  "timeoutMs": {
                    "type": "integer",
                    "minimum": 100,
                    "maximum": 300000
                  },
                  "input": {
                    "type": "object",
                    "description": "Request-Body als JSON — niemals Secrets"
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": false,
                    "description": "Request validieren ohne Mutation — kein API-Call"
                  }
                }
              },
              "outputSchema": {
                "type": "object",
                "required": [
                  "ok"
                ],
                "properties": {
                  "ok": {
                    "type": "boolean"
                  },
                  "status": {
                    "type": "integer"
                  },
                  "data": {},
                  "error": {
                    "type": "string"
                  },
                  "truncated": {
                    "type": "boolean"
                  }
                }
              },
              "size": 12176
            }
          ]
        },
        {
          "name": "coding-project-wizard-dashboard",
          "displayName": "Coding: Project Setup-Wizard + Health-Dashboard (#149)",
          "description": "Guided Setup-Wizard (profile lite/managed/hosted → source existing/existing-forgejo/new-hosted-forgejo → repo → review with capability/mutation plan + license + typed-name confirmation → apply) plus a Health-Dashboard that surfaces the resource graph, image/tool versions + drift, credentials (configured boolean only, never the value), and the configured/installed/healthy/degraded/drifted health lens with the last-probe timestamp. Detach/Delete stay behind the typed-name gate; the server re-validates the confirm flag. All state lives server-side; reload/tab-switch never re-fires a half-applied create.",
          "permissions": [
            "addons/development/projects"
          ],
          "endpoint": "/api/v1/addons/development/projects/:id/health",
          "scripts": []
        },
        {
          "name": "proprietary-coding-tools",
          "displayName": "Coding: Proprietäre & benutzerdefinierte Coding-Tools (#147)",
          "description": "Manage proprietary / closed-source coding-tool install references (Claude Code as first curated upstream example). Owner-only flow: instance owner pins official upstream URL OR uploads artefact hash, sees terms text + license URL + hash + cache policy + install-plan preview (resolved redirect chain, argv, mounts, env names) and approves explicitly. Reference-only mode forbids rehost/mirror/cache; cachePolicy is locked to ephemeral|none; bytes are deleted after the isolated build (no registry/backup/tooling-repo copy); session-start is gated on probe + credential/subscription + terms-drift success. Owner acknowledgement binds to recipe hash + terms URL/version + license SPDX id; any drift invalidates the ack.",
          "permissions": [
            "addons/development/admin/proprietary-tools"
          ],
          "endpoint": "/api/v1/addons/development/admin/proprietary-tools",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>🧠 Development</h1>\n<p>Lets agents start, steer, inspect, and terminate tmux-backed coding sessions (Claude Code, opencode, codex) via a pluggable engine-adapter registry, with per-session git-worktree isolation so parallel sessions on one repo never corrupt each other, plus custom model and provider environment overrides, plus deterministic per-session project-instructions (AGENTS.md + global + explicit) load with full provenance, plus a portable Skills + Commands catalog with provenance, hash, permissions and per-workspace materialisation to engine-native paths (#132), plus versioned installer recipes with license, terms, hash and supply-chain policy for the future per-project Toolchain-Runtime (#145).</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>development</code></li><li><strong>Version:</strong> 1.6.0</li><li><strong>Fähigkeiten:</strong> coding-sessions, tmux-control, agent-steering, custom-llm-routing, terminal-automation, git-worktree-isolation, engine-adapters, parallel-orchestration, project-instructions, coding-resources (#132), skills-catalog, commands-catalog, engine-native-materialisation, <strong>toolchain-installer-recipes</strong> (#145), recipe-schema, recipe-validator, license-policy, supply-chain-policy, owner-acknowledgement, sbom-provenance</li><li><strong>UI-Seite:</strong> <code>/addons/development</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET | <code>/api/v1/addons/development/engines</code> |<br />| GET | <code>/api/v1/addons/development/providers</code> |<br />| GET, POST | <code>/api/v1/addons/development/auth</code> |<br />| GET, POST | <code>/api/v1/addons/development/sessions</code> |<br />| POST | <code>/api/v1/addons/development/sessions/[id]/input</code> |<br />| GET | <code>/api/v1/addons/development/sessions/[id]/output</code> |<br />| POST | <code>/api/v1/addons/development/sessions/[id]/run</code> |<br />| POST | <code>/api/v1/addons/development/sessions/[id]/terminate</code> |<br />| GET | <code>/api/v1/addons/development/status</code> |<br />| GET, POST | <code>/api/v1/addons/development/role-profiles</code> |<br />| GET, DELETE | <code>/api/v1/addons/development/role-profiles/[role]</code> |<br />| GET, POST, DELETE | <code>/api/v1/addons/development/role-profiles/[role]/fallback</code> |</p>\n<h2>Rollenprofile (Issue #135)</h2>\n<p>Add-on bindet sechs Rollen an separate Engine-/Modell-/Instructions-/<br />Skill-/Tool-/Permissions-Bundles, versioniert pro Owner:</p>\n<ul><li><code>architect</code> · <code>planner</code> · <code>coder</code> · <code>qa</code> · <code>research</code> · <code>docs</code>.</li></ul>\n<p>Jedes Profil deklariert explizit:</p>\n<p>- <strong>Engine + Modell</strong> — kein impliziter Default, jede Konfigurationslücke<br />  schlägt fehl (INBOX-86).<br />- <strong>Permissions</strong> — `canRead / canEdit / canBash / canTask / canSkill /<br />  canTool / canCommit / canMerge`; die ISSUE verbietet mehrere Kombinationen<br />  implizit (siehe „Negative-Scope&quot;, unten).<br />- <strong>Limits</strong> — <code>MaxSteps / TimeoutSeconds / Budget ( + WarnAt)</code>.<br />- <strong>Handoffs</strong> — gerichtete Kanten zu anderen Rollen + erlaubte Artefakte<br />  (<code>validated-plan</code>, <code>released-task-bundle</code>, <code>diff-bundle</code>, <code>test-evidence</code>,<br />  <code>acceptance-rationale</code>, <code>run-audit</code>).</p>\n<p>Der <strong>Resolver</strong> materialisiert pro Rollen-Snapshot das konkrete<br />Engine-Setup, prüft Skills/Tools gegen die Registry und friert das<br />Ergebnis als unveränderlichen <strong>Snapshot</strong> ein (SHA-256 über Profil,<br />Instructions, Modell). Läuft der Agent, hält er seinen Snapshot für die<br />gesamte Laufzeit — eine Profiländerung mitten im Lauf ändert nichts am<br />laufenden Run, sondern erst am nächsten.</p>\n<h3>Negative-Scope (verbindliche Defaults)</h3>\n<p>- <code>qa</code> darf <strong>nicht edit / commit / merge</strong> und <strong>nicht</strong> Skills aufrufen —<br />  der Owner-Muss-Override (<code>permissions.ownerGranted=true</code>) wird<br />  auditiert.<br />- <code>coder</code> darf <strong>nicht</strong> Acceptance-Kriterien als bestanden markieren<br />  (<code>canTask:false</code>).<br />- Kein Modell/Engine-Silent-Swap: ein nicht verfügbares Modell / eine nicht<br />  verfügbare Engine wird <strong>entweder</strong> über den explizit registrierten<br />  Fallback (<code>POST /role-profiles/{role}/fallback</code>) ersetzt <strong>oder</strong> der Run<br />  schlägt fehl (<code>RoleProfileUnavailableError</code>).<br />- Skill-/Tool-IDs, die nicht im Add-on-Registry existieren, schlagen mit<br />  einem konkreten <code>resource_not_found</code>-Fehler fehl — kein stilles Ignorieren.</p>\n<h3>Beispiel</h3>\n<pre><code class=\"lang-bash\"># Aktive Profile aller Rollen lesen\ncurl -s -H &quot;Authorization: Bearer $AIOPTA_API_KEY&quot; \\\n  https://&lt;instance&gt;/api/v1/addons/development/role-profiles</code></pre>\n<p># Neuen Planner-Snapshot speichern (Version 2)<br />curl -s -X POST -H &quot;Authorization: Bearer $AIOPTA_API_KEY&quot; -H &#39;Content-Type: application/json&#39; \\<br />  -d &#39;{<br />    &quot;role&quot;: &quot;planner&quot;,<br />    &quot;version&quot;: 2,<br />    &quot;engine&quot;: &quot;claude-code&quot;,<br />    &quot;model&quot;: &quot;anthropic/claude-opus-4-7&quot;,<br />    &quot;instructions&quot;: &quot;You are the PLANNER agent ...&quot;,<br />    &quot;skills&quot;: [],<br />    &quot;tools&quot;: [],<br />    &quot;permissions&quot;: {<br />      &quot;canRead&quot;: true, &quot;canEdit&quot;: true, &quot;canSkill&quot;: true,<br />      &quot;canBash&quot;: false, &quot;canTask&quot;: false, &quot;canTool&quot;: true,<br />      &quot;canCommit&quot;: false, &quot;canMerge&quot;: false<br />    },<br />    &quot;limits&quot;: { &quot;maxSteps&quot;: 6, &quot;timeoutSeconds&quot;: 1500, &quot;budget&quot;: 4000 },<br />    &quot;handoffsOut&quot;: [<br />      { &quot;from&quot;: &quot;planner&quot;, &quot;to&quot;: &quot;coder&quot;, &quot;artifacts&quot;: [<br />          { &quot;kind&quot;: &quot;validated-plan&quot; }, { &quot;kind&quot;: &quot;released-task-bundle&quot; } ] }<br />    ]<br />  }&#39; \\<br />  <a href=\"https://&amp;lt;instance&amp;gt;/api/v1/addons/development/role-profiles\" rel=\"noopener noreferrer\">https://&amp;lt;instance&amp;gt;/api/v1/addons/development/role-profiles</a><br />```</p>\n<h2>Session-Erstellung (INBOX-58)</h2>\n<p>- <strong>Arbeitsverzeichnis (BUG1):</strong> <code>cwd</code> weglassen ⇒ auto-erzeugter Default-Workspace<br />  (<code>.aiopta-coding-worktrees</code>). Fehlender Pfad ⇒ klarer Fehler mit „create it?&quot;-Angebot<br />  (<code>createCwdIfMissing</code>); Isolation braucht ein existierendes Git-Repo.<br />- <strong>Provider/Modell-Dropdowns (GAP3):</strong> <code>GET /providers</code> liefert konfigurierte<br />  Provider + Modelle für Engine→Provider→Modell-Auswahl (kein Env-Tippen); Roh-Env<br />  bleibt als „Advanced&quot;.<br />- <strong>Auth pro Engine (GAP2):</strong> <code>GET/POST /auth</code> — je Engine „API-Key | Login&quot;<br />  (Claude setup-token/OAuth, codex ChatGPT-OAuth); Token sicher pro (User,Engine),<br />  nie geloggt; nicht-konfiguriert ⇒ graziöser Hinweis (Owner-Aktivierung Block H).<br />- <strong>Engines + Verfügbarkeit (#130):</strong> <code>GET /engines</code> liefert je Engine die<br />  Capability-Flags <strong>und</strong> ein <code>available</code>-Flag. Claude Code + opencode gaten über<br />  ihre CLI auf PATH; <strong>Codex (OpenAI)</strong> ist die 3. Engine und ist <strong>key-aware</strong> —<br />  <code>available:false</code> bis der Owner einen Codex/OpenAI-Key setzt (<code>CODEX_API_KEY</code> /<br />  <code>OPENAI_API_KEY</code> in der Addon-Config oder einen konfigurierten<br />  <code>openai</code>/<code>openai-codex-oauth</code> Provider) oder ein ChatGPT-Login verbindet. Ein<br />  Headless-<code>run</code> auf nicht-konfiguriertem Codex liefert ein sauberes<br />  „nicht konfiguriert&quot;-Ergebnis (kein Crash, kein Spawn). Key/Endpoint +<br />  CLI-Container-vs-API: OPEN-DECISIONS Block H #76 (ADR-061, Addendum zu ADR-046).</p>\n<h2>Skills + Commands Katalog (#132)</h2>\n<p>Der Coding-Orchestrator verwaltet einen <strong>kanonischen Resource-Katalog</strong> aus<br />drei Quellen, in fester Präzedenz (<code>project &gt; addon &gt; global</code>):</p>\n<p>- <strong>Skills</strong> liegen unter <code>&lt;root&gt;/skills/&lt;name&gt;/SKILL.md</code> (Markdown mit<br />  YAML-Frontmatter: <code>name</code>, <code>description</code>, optional <code>version</code>, <code>permissions</code>).<br />- <strong>Commands</strong> liegen unter <code>&lt;root&gt;/commands/&lt;name&gt;.md</code> (Frontmatter: <code>name</code>,<br />  <code>description</code>, <code>prompt</code>, optional <code>role</code>, <code>engine</code>, <code>subtask</code>,<br />  <code>permissions</code>).</p>\n<p>Bestehende Projektdateien werden bevorzugt gelesen statt kopiert — der<br /><code>project</code>-Source-Lookup reicht in <code>.opencode/skills/</code> und nutzt OpenCode-<br />konforme Layouts ohne Migration.</p>\n<h3>Validierung (Sicherheits-Gate)</h3>\n<p>Die Validierung lehnt ab:</p>\n<p>- Unbekannte Frontmatter-Felder (jede <code>script:</code>/<code>command:</code>/<code>exec</code>-Field auf<br />  einer Skill wird abgelehnt, <strong>nicht</strong> automatisch ausgeführt).<br />- Path-Traversal in <code>originPath</code> (kein <code>..</code>, kein Backslash auf POSIX).<br />- Dateien &gt; 64 KiB oder Prompt mit NUL/Control-Bytes.<br />- Permissions ausserhalb des bekannten Vokabulars (<code>files:read</code>, <code>git:write</code>,<br />  <code>mcp:invoke</code>, …) — keine impliziten Grants.</p>\n<h3>Materialisierung</h3>\n<p>Pro Session materialisiert der Orchestrator freigegebene Resources in<br /><strong>engine-native Pfade innerhalb des Workspace</strong> (nie in den Projektbaum):</p>\n<p>| Engine | Skills | Commands |<br />|---|---|---|<br />| <code>opencode</code> | <code>.opencode/skills/&lt;name&gt;/SKILL.md</code> + <code>.agents/skills/&lt;name&gt;/SKILL.md</code> (Mirror) | <code>.aiopta-resources/opencode/commands/&lt;name&gt;.md</code> |<br />| <code>claude-code</code> | <code>unsupported</code> (Mapping noch zu spezifizieren) | <code>unsupported</code> |<br />| <code>codex</code> | <code>unsupported</code> (Mapping noch zu spezifizieren) | <code>unsupported</code> |</p>\n<p>Regeln:</p>\n<p>- Materialisierung <strong>überschreibt nie</strong> eine existierende Projektdatei.<br />- Skill-Inhalte werden <strong>nie ausgeführt</strong> — nur als opaque Markdown geschrieben.<br />- Cleanup entfernt <strong>nur</strong> die im Manifest gelisteten Dateien.<br />- Engine-Support-Verdikte werden in <code>MaterializeResult.refused</code> mit Reason<br />  protokolliert; <code>describeActiveResources()</code> macht die Teilfähigkeit im<br />  Session-Status sichtbar.</p>\n<h3>Bekannte Grenzen (Coding-Resources)</h3>\n<p>- <strong>Claude Code + Codex:</strong> Materialisierung ist als <code>unsupported</code> markiert,<br />  bis ein getestetes Mapping vorliegt. Der <code>unsupported</code>-Verdikt ist<br />  dokumentiert und niemals ein stilles Degradieren.</p>\n<h2>Bekannte Grenzen</h2>\n<p>- <strong>Per-User-Workspace-FS-Isolation (GAP4):</strong> zurückgestellt auf die<br />  #136-Isolation-Foundation. Sessions sind bereits user-scoped (<code>user_id</code>); nur die<br />  per-User-Dateisystem-Sandbox wird dort gebaut.</p>\n<h2>Project-Instructions (#131)</h2>\n<p>Beim Session-Start wird deterministisch ein Project-Context aus der<br /><code>AGENTS.md</code>-Hierarchie des Workspaces, dem globalen Aiopta-Context<br />(<code>CODING_ORCHESTRATOR_GLOBAL_AGENTS_FILE</code> / <code>~/.config/aiopta/AGENTS.md</code>) &lt;!-- security-allow: env-var name (CODING_ORCHESTRATOR_*), not a secret --&gt;<br />und einer optionalen expliziten Datei-Liste<br />(<code>CODING_ORCHESTRATOR_INSTRUCTION_FILES</code>, addon-config) geladen. Die &lt;!-- security-allow: env-var name (CODING_ORCHESTRATOR_*), not a secret --&gt;<br />kanonische, deterministisch geordnete Inhaltsmenge wird in den<br /><code>appendSystemPrompt</code> der Engine gespleißt — **identisch für Claude Code,<br />opencode und Codex**, die jeweilige Engine entscheidet nur, ob sie<br /><code>systemPrompt</code> als Flag/Env unterstützt.</p>\n<p>| Eigenschaft | Verhalten |<br />|---|---|<br />| Präzedenz (niedrig → hoch) | global → repo-root → tieferes <code>AGENTS.md</code> → explizit |<br />| Hash je Datei | SHA-256, im Provenance-Datensatz persistiert |<br />| Per-File-Limit | 64 KiB (Default) — größer ⇒ Reject + Warnung |<br />| Total-Budget | 256 KiB (Default) — Überschreitung ⇒ <code>truncated</code> |<br />| Binary-Erkennung | NUL-Byte in den ersten 8 KiB ⇒ Reject |<br />| Symlink-Escape | <code>realpath</code> muss innerhalb des Workspace-Roots bleiben |<br />| Traversal | <code>..</code>-Komponenten und nicht-absolute explizite Pfade ⇒ Reject |<br />| Request-Kontrolle | HTTP-Body kann KEINE zusätzlichen Instructions-Dateien benennen |<br />| Provenance-Endpoint | <code>GET /sessions/:id</code> ⇒ <code>instructions</code> (paths, hashes, status, warnings) |</p>\n<h2>Toolchain Installer Recipes (#145)</h2>\n<p>Der Coding-Orchestrator definiert ein versioniertes Rezept-Schema für Coding-Tools<br />(Claude Code, opencode, ripgrep, …), das die zukünftige per-Project Toolchain-Runtime<br />(#144) mit einer prüfbaren Installationsbasis versorgt. Der Slice in #145 liefert<br />Schema + Validator + Planner + Provenance; der Runtime-Executor bleibt #144.</p>\n<h3>Fünf Quell-Kinds</h3>\n<p>| Kind                        | Download                       | Owner-Approval | Hash    | Netzwerk-Policy | Auto-Exec |<br />|-----------------------------|--------------------------------|----------------|---------|-----------------|-----------|<br />| <code>bundled</code>                   | aus Image-Layer                | nein           | ja      | nein            | n/a       |<br />| <code>package-manager</code>           | aus System-Paket-Manager       | nein           | n/a     | nein            | ja        |<br />| <code>upstream-reference</code>        | aus Upstream-URL               | nein           | <strong>ja</strong>  | ja              | ja        |<br />| <code>owner-url</code>                 | aus Owner-freigegebener URL    | <strong>ja</strong>         | <strong>ja</strong>  | ja              | ja        |<br />| <code>manual</code>                    | kein Download (Runbook-Text)   | <strong>ja</strong>         | n/a     | nein            | <strong>nein</strong>  |</p>\n<h3>Hard Rules (Negativanforderungen)</h3>\n<p>- Keine unversionierten Pakete / Images / URLs — <code>version</code> ist Pflichtfeld.<br />- Kein Hash-Bypass bei URL-Artefakten — <code>digest.sha256</code> ist Pflicht für<br />  <code>upstream-reference</code> und <code>owner-url</code>.<br />- Kein <code>curl|bash</code> / <code>irm|iex</code> — <code>manual</code>-Steps, die wie eine piped-payload aussehen,<br />  werden abgelehnt.<br />- Keine automatische Terms-Akzeptanz — <code>owner-confirmation</code>-Lizenzen und<br />  <code>owner-url</code> / <code>manual</code>-Rezepte benötigen ein <code>Acknowledgement</code>, das an<br />  Rezept-Hash, Terms-URL+Version und Lizenz-SPDX-Id gebunden ist. Änderungen an<br />  einem dieser Felder invalidieren die vorhandene Zustimmung.<br />- Keine Ausführung auf dem Core-Host — der Planner emittiert nur den<br />  <code>InstallPlan</code>; die Runtime (#144) führt ihn aus.</p>\n<h3>Public API (issue #145)</h3>\n<p>Unter <code>addons/development/src/lib/toolchains/installers/</code>:</p>\n<p>- <code>types.ts</code> — <code>Recipe</code> (diskriminierte Union über 5 kinds), <code>InstallPlan</code>,<br />  <code>Acknowledgement</code>, <code>NetworkPolicy</code>, <code>ProvenanceAttestation</code>, <code>SbomEntry</code>,<br />  <code>ToolchainLockSlice</code>.<br />- <code>validator.ts</code> — <code>validateRecipe</code> (fail-closed, sammelt alle Issues),<br />  <code>isHostAllowedByPolicy</code>, <code>isAcknowledgementApplicable</code>.<br />- <code>network-guard.ts</code> — <code>assertAllowedDownload</code> (HTTPS-only, SSRF-Guard gegen<br />  private/loopback/link-local/multicast-IPv4 + IPv6, Host-Allowlist,<br />  Größe/Timeout-Oberflächen) und <code>assertRedirectAllowed</code><br />  (none/same-host/same-allowlist).<br />- <code>acknowledgement.ts</code> — <code>recordAcknowledgement</code>, <code>revokeAcknowledgement</code>,<br />  <code>isAcknowledgementFresh</code>.<br />- <code>catalog.ts</code> — <code>createRecipeCatalog</code>, <code>addRecipe</code> (Validator-fail-closed),<br />  <code>listRecipes</code>, <code>findRecipesByTool</code>, <code>findRecipeFor</code>, <code>listHashDriftEntries</code>.<br />- <code>plan.ts</code> — <code>buildPlan</code> (emittiert den InstallPlan inkl. Quell-URL,<br />  Hash/Signatur, Lizenz/Terms, Zielruntime, Netzwerk-Policy und argv),<br />  <code>deriveCachePlan</code> (Cache-/Redistribution-Policy für Uninstall/Upgrade).<br />- <code>provenance.ts</code> — <code>buildProvenance</code> (SLSA-Provenanz-Attestation),<br />  <code>buildSbom</code>, <code>buildToolchainLockSlice</code>, <code>verifyToolchainLockSlice</code>.</p>\n<h3>Tests</h3>\n<p><code>addons/development/tests/unit/toolchain-installers-*.test.ts</code>:</p>\n<p>- <code>types</code> — Konstruktion + Recipe-Hash-Stabilität (Approval-Metadaten zählt<br />  nicht zum Hash).<br />- <code>validator</code> — fail-closed für jeden Kind; Security-Fixtures für bösartige<br />  URL/redirect/private-IP/Oversize/Hash-Mismatch/Shell-Injection.<br />- <code>network</code> — SSRF-Guard (IPv4 reservierte Bereiche + IPv6 reserved),<br />  Allowlist-Wildcards inkl. Suffix-Angriff, Redirect-Policies.<br />- <code>acknowledgement</code> — Terms-/Hash-/License-Änderungen invalidieren<br />  die Zustimmung (Akzeptanzkriterium &quot;Quelle/Hash/Terms ändern sich -&gt;<br />  bisherige Zustimmung ungültig&quot;).<br />- <code>plan</code> — golden Plans für <code>package-manager</code>, <code>upstream-reference</code> und<br />  <code>manual</code>; Cache-Policy-Surface.<br />- <code>provenance</code> — SLSA-Attestation + SBOM + Lock-Slice-Verifikation<br />  (inkl. Reject bei Recipe-Drift).</p>\n<p>Ausführen: <code>npx vitest run addons/development/tests/unit/toolchain-installers-*.test.ts</code>.</p>",
          "byteSize": 14725
        }
      }
    },
    {
      "id": "dive-log",
      "uuid": "7e0cbe39-e48e-4ffe-9158-d64336f386eb",
      "name": "Dive Log (Tauchgänge erfassen, importieren, auswerten)",
      "version": "1.2.3",
      "description": "Per-User Tauchgangslogbuch: CRUD für eigene Dives, CSV- + Subsurface-XML-Import mit Vorschau, Deduplizierung, expliziter Zeitzonen-/Einheiten-Normalisierung sowie Statistiken (Anzahl, Gesamtzeit, Max-/Durchschnittstiefe, Monatsverlauf) und Karte mit geclusterten Punkten. Lokale SQLite ist die Quelle der Wahrheit (ADR-054). SI-Einheiten intern (Meter/Sekunde/UTC).",
      "author": "Aiopta",
      "icon": "🤿",
      "capabilities": [
        "dive-log",
        "dives",
        "import",
        "csv",
        "subsurface",
        "stats",
        "map"
      ],
      "tags": [],
      "download": "/artifacts/dive-log-1.2.3.zip",
      "sha256": "56b4dafb2111737dface0b3f58b026565b0f0e03bc964cfdd9d217b4012b2618",
      "size": 237423,
      "page": {
        "id": "dive-log-page",
        "path": "/addons/dive-log",
        "name": "Dive Log",
        "icon": "🤿",
        "section": "Lifestyle",
        "component": "@addons/dive-log/src/components/DiveLogPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/dive-log/dives",
        "/api/v1/addons/dive-log/dives/:id",
        "/api/v1/addons/dive-log/imports/preview",
        "/api/v1/addons/dive-log/imports/:id",
        "/api/v1/addons/dive-log/imports/:id/confirm",
        "/api/v1/addons/dive-log/stats"
      ],
      "skills": [
        {
          "name": "dive-log-read",
          "displayName": "Dive Log: Tauchgänge lesen",
          "description": "Listet die Tauchgänge des Nutzers und liest einzelne Einträge. Reiner Lesezugriff — schreibt nichts.",
          "permissions": [
            "addons/dive-log/dives"
          ],
          "endpoint": "/api/v1/addons/dive-log/dives",
          "scripts": []
        },
        {
          "name": "dive-log-write",
          "displayName": "Dive Log: Tauchgänge erfassen & importieren",
          "description": "Erfasst, löscht und importiert Tauchgänge für den Nutzer (per-User gebucht) inkl. CSV-/Subsurface-Preview + Commit.",
          "permissions": [
            "addons/dive-log/dives",
            "addons/dive-log/imports"
          ],
          "endpoint": "/api/v1/addons/dive-log/imports/preview",
          "scripts": []
        },
        {
          "name": "dive-log-stats",
          "displayName": "Dive Log: Statistiken & Karte",
          "description": "Berechnet Kennzahlen (Anzahl, Gesamtzeit, Max-/Ø-Tiefe, Monatsverlauf) und projiziert Dives mit gültigen Koordinaten auf eine Karte. Reiner Lesezugriff — verändert weder Dives noch gespeicherte SI-Werte.",
          "permissions": [
            "addons/dive-log/dives",
            "addons/dive-log/stats"
          ],
          "endpoint": "/api/v1/addons/dive-log/stats",
          "scripts": []
        }
      ],
      "mcpServers": [
        {
          "id": "dive-log-tools",
          "displayName": "Dive Log tools",
          "description": "Read-only dive-log statistics and map projection for agents: headline + monthly stats, coordinate filtering and map-point/bounds derivation — the same pure functions the dive-log stats page uses (issue #413, epic #399). Reads nothing external.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "dive-log-tools/server.mjs",
          "permissions": [
            "addons/dive-log/stats"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [],
          "credentialRefs": [],
          "archiveEntry": "dive-log/mcp/servers/dive-log-tools/server.mjs",
          "sha256": "97056877b4e9902ae462565ddbc33f8446f4c3ad1225ca877c824303b28eef8f",
          "size": 18942,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Dive Log (aiopta-addons)</h1>\n<p>Per-User Tauchgangslogbuch. Erfasst eigene Tauchgänge, importiert CSV- und<br />Subsurface-XML-Bestände mit Vorschau und Deduplizierung, berechnet<br />Kennzahlen (Anzahl, Gesamtzeit, Max-/Durchschnittstiefe, Monatsverlauf) und<br />zeigt Tauchgänge mit gültigen Koordinaten auf einer Karte (geclustert, mit<br />Inline-SVG-Fallback wenn kein Map-Provider konfiguriert ist). Alle Daten<br />in einem per-User scoped Schema (ADR-054). SI-Einheiten intern (Meter,<br />Sekunde, UTC); Zeitzonen + Einheiten werden beim Import explizit normalisiert<br />und nicht stillschweigend umgerechnet.</p>\n<p>Issue #35 liefert das CRUD-Fundament, #123 den Import-Layer, #124 die<br />Statistiken + Karte.</p>\n<h2>Endpoints</h2>\n<p>Alle Routen sind per-User scoped (<code>auth.id</code> aus <code>mc-session</code>). Eine fremde<br />ID liefert 404 ohne Existenz-Leak.</p>\n<p>| Methode | Pfad | Beschreibung |<br />|---|---|---|<br />| <code>GET</code>    | <code>/api/v1/addons/dive-log/dives</code> | Eigene Dives (Filter: <code>from</code>, <code>to</code>, <code>site</code>) |<br />| <code>POST</code>   | <code>/api/v1/addons/dive-log/dives</code> | Neuen Dive erfassen |<br />| <code>GET</code>    | <code>/api/v1/addons/dive-log/dives/[id]</code> | Einen Dive lesen |<br />| <code>DELETE</code> | <code>/api/v1/addons/dive-log/dives/[id]</code> | Einen Dive löschen |<br />| <code>POST</code>   | <code>/api/v1/addons/dive-log/imports/preview</code> | CSV / Subsurface-XML einlesen + Vorschau |<br />| <code>GET</code>    | <code>/api/v1/addons/dive-log/imports/[id]</code> | Vorschau-Status abrufen |<br />| <code>DELETE</code> | <code>/api/v1/addons/dive-log/imports/[id]</code> | Vorschau verwerfen |<br />| <code>POST</code>   | <code>/api/v1/addons/dive-log/imports/[id]/confirm</code> | Vorschau übernehmen |<br />| <code>GET</code>    | <code>/api/v1/addons/dive-log/stats</code> | Statistiken + Map-Punkte (Filter: <code>from</code>, <code>to</code>, <code>site</code>) |</p>\n<h2>Statistiken + Karte (<code>GET /stats</code>)</h2>\n<p>Liefert für die per-User gefilterte Dive-Menge:</p>\n<p>- <strong>Stat-Kennzahlen</strong>: <code>totalDives</code>, <code>totalDurationS</code>, <code>maxDepthM</code>,<br />  <code>avgDepthM</code>, <code>longestDiveS</code>, <code>avgDurationS</code>, <code>deepestSite</code>,<br />  <code>divesWithCoordinates</code>, <code>earliestDiveAt</code>, <code>latestDiveAt</code>.<br />- <strong>Monatsverlauf</strong>: <code>monthly[]</code> — Aggregate pro <code>YYYY-MM</code> (UTC, aufsteigend)<br />  mit <code>diveCount</code>, <code>totalDurationS</code>, <code>avgDepthM</code>, <code>maxDepthM</code>.<br />- <strong>Karte</strong>: <code>mapPoints[]</code> (nur Dives mit gültigen Koordinaten) plus<br />  <code>bounds</code> (Bounding-Box). Filter wirkt IDENTISCH auf Stats und Karte<br />  (<code>buildStatsResponse</code> leitet beide aus derselben <code>filterDives()</code>-Menge ab).<br />- <strong>Map-Provider</strong>: <code>provider.name</code> ist <code>&quot;fallback&quot;</code> (Inline-SVG, keine<br />  externe Verbindung) oder <code>&quot;iframe&quot;</code> (Operator hat <code>DIVE_LOG_MAP_URL_TEMPLATE</code><br />  gesetzt). <code>providerUrl</code> ist <code>null</code> im Fallback-Pfad → UI rendert die SVG.</p>\n<h3>Formeln (in <code>src/lib/stats.ts</code> + golden-fixture-getestet)</h3>\n<pre><code>totalDives        = filtered.length\ntotalDurationS    = Σ d.durationS\nmaxDepthM         = max(d.maxDepthM)\navgDepthM         = mean(d.maxDepthM)\nlongestDiveS      = max(d.durationS)\navgDurationS      = round(mean(d.durationS))\ndeepestSite       = site of the dive with max depth\ndivesWithCoordinates = count of dives with valid lat/lon\nmonthly[i]        = aggregate of dives in YYYY-MM (UTC), ascending\nmapPoints         = dives with finite lat ∈ [-90,90] AND finite lon ∈ [-180,180]\nbounds            = min/max of mapPoints (or null when empty)</code></pre>\n<p>Display-Unit (metrisch/imperial) ist <strong>Anzeige</strong>-Sache. Gespeicherte SI-Werte<br />werden NICHT verändert (per AC: &quot;metric/imperial Anzeige ändert nicht<br />gespeicherte SI-Werte&quot;). Die <code>StatsPanel</code>-Komponente konvertiert beim<br />Rendern, nicht beim Schreiben.</p>\n<h2>Map-Provider</h2>\n<p>Standardpfad: <strong>Inline-SVG mit geclusterten Punkten</strong>. Kein externer Call,<br />kein Key im Browserbundle, keine Drittanbieter-Verbindung. Die SVG-Fallback<br />zeichnet ein Equator-/Meridian-Raster und projiziert die Punkte über die<br />ermittelten Bounds. Cluster werden mit einem 1.5°-Grid gebildet (gleicher<br />Algorithmus wie <code>src/lib/map-provider.ts</code>).</p>\n<p>Operator-Knopf: <code>DIVE_LOG_MAP_URL_TEMPLATE</code> (env). Akzeptiert die<br />Platzhalter <code>{lat}</code>, <code>{lon}</code>, <code>{zoom}</code>. Wird er gesetzt, liefert der<br /><code>/stats</code>-Endpoint eine voll aufgelöste <code>providerUrl</code>, die das UI in einem<br />sandboxed Iframe rendert. Der Placeholder-Substitution ist opt-in:<br />unbekannte Platzhalter wie <code>{apiKey}</code> werden NICHT stillschweigend entfernt<br />(defensiv — der Operator steuert das Template bewusst).</p>\n<h2>CSV-Vorlage</h2>\n<p>Komma-getrennt, UTF-8, Header in der ersten Zeile. Spaltennamen sind<br />case-insensitive. Die Spalten <code>started_at</code>, <code>duration</code>, <code>depth</code> sind<br />Pflicht; alle anderen sind optional.</p>\n<pre><code>started_at,timezone,duration,duration_unit,depth,depth_unit,site,lat,lon,buddy,gas_mix,notes\n2024-06-15T10:30,Europe/Berlin,45,min,18,m,Lake Zurich,47.123,8.456,Hans,Air,Great viz\n2024-06-15T11:25,Europe/Berlin,52,min,22.4,m,Lake Zurich,47.123,8.456,Hans,EAN32,</code></pre>\n<p>Einheiten:</p>\n<p>- <code>duration</code> / <code>duration_unit</code>: <code>s</code> (Sekunden) oder <code>min</code> (Minuten). Default <code>min</code>.<br />- <code>depth</code> / <code>depth_unit</code>: <code>m</code> (Meter) oder <code>ft</code> (Fuß). Default <code>m</code>.<br />- <code>started_at</code> darf ISO-8601 mit Z, mit <code>±HH:MM</code> Offset, oder naive Wand-Uhr<br />  in der gewählten <code>timezone</code> sein.</p>\n<h2>Subsurface XML</h2>\n<p><code>&lt;divelog&gt;</code> mit einem oder mehreren <code>&lt;dive&gt;</code>-Blöcken. Gelesen werden<br /><code>&lt;date&gt;</code> (YYYY-MM-DD), <code>&lt;time&gt;</code> (HH:MM), <code>&lt;duration&gt;</code> (Sekunden), <code>&lt;depth&gt;</code><br />(Meter), <code>&lt;location&gt;</code>, <code>&lt;gps&gt;</code> (<code>lat,lon</code>), <code>&lt;buddy&gt;</code>, <code>&lt;cylinder&gt;&lt;description&gt;</code><br />(Gas Mix) und <code>&lt;notes&gt;</code>. Namespaces, CDATA, HTML-Entities ausser <code>&amp;amp;</code><br /><code>&amp;lt;</code> <code>&amp;gt;</code> <code>&amp;quot;</code> <code>&amp;apos;</code> werden nicht unterstützt — ein<br /><code>&lt;malformed&gt;</code>-Block im Test-Fixture dokumentiert das Negativ-Verhalten.</p>\n<h2>Idempotenz</h2>\n<p>Jede Datei wird über ihren SHA-256-Hash identifiziert. Ein zweiter Import<br />derselben Datei ergibt 100 % Duplikate (Re-Import ist sicher). Innerhalb<br />eines Imports gilt: die Zeilen-Fingerprints (<code>started_at</code> UTC × <code>site</code> ×<br /><code>max_depth_m</code> × <code>duration_s</code>) werden aufsteigend geprüft, sodass ein<br />Duplikat innerhalb derselben Datei nicht zweimal geschrieben wird.</p>\n<h2>Limits</h2>\n<p>- Maximal 5 MB pro Upload<br />- Maximal 5 000 Zeilen pro Datei<br />- <code>max_depth_m</code> &lt; 0, <code>duration_s</code> &lt; 0 und out-of-range Koordinaten liefern<br />  einen Zeilen-Fehler, nicht einen 400 für die ganze Datei<br />- Map-Punkte: nur Dives mit endlichen Koordinaten in<br />  <code>lat ∈ [-90, 90]</code> / <code>lon ∈ [-180, 180]</code> werden projiziert</p>\n<h2>Negativ-Scope</h2>\n<p>- Keine Cross-Repo-Refactors (Repository bleibt aiopta-addons)<br />- Keine Änderung am Auth/Login-Layer<br />- Keine DB-Schema-Änderung (Stats lesen nur, keine zweite Aggregationstabelle)<br />- Keine neuen externen Dependencies<br />- Keine Map-Keys im Browserbundle ohne vorhandene Credential-Verwaltung<br />  (Inline-SVG ist der Default; ein Provider ist Operator-Opt-in via env)<br />- Keine medizinischen Deko-Berechnungen</p>",
          "byteSize": 6447
        }
      }
    },
    {
      "id": "docker-manager",
      "uuid": "6790723a-a456-4adf-b0a7-53f3f8f46c33",
      "name": "Docker Manager (Multi-Host: Inventory, Trust, Mutations, Image Updates, Compose Orchestration + Preview)",
      "version": "1.4.5",
      "description": "Aiopta Docker Manager — read-only multi-host inventory + logs (#126), secure multi-host trust contract (#39: single-use enrollment, mTLS-CA, SSRF gate), controlled start/stop/restart with drift protection (#157), digest-pinned image updates with health rollback (#159), and compose orchestration with apply + partial-failure reconcile + rollback (#158; slice #329 adds the declarative compose-schema + versioned repo loader, slice #330 adds declarative Compose-Deploy preview with policy gate — render + 4 standard policy rules + secret-redacted preview output). Cross-user lookups map to 404 (no enumeration leak).",
      "author": "Aiopta",
      "icon": "🐳",
      "capabilities": [
        "docker-inventory",
        "docker-logs",
        "docker-readonly",
        "docker-multihost",
        "log-redaction",
        "container-start",
        "container-stop",
        "container-restart",
        "preview-with-drift",
        "idempotent-apply",
        "audit-without-secrets",
        "digest-pinned-image-update",
        "image-pull-before-mutation",
        "auto-rollback-on-unhealthy",
        "registry-allowlist",
        "credential-redaction",
        "docker-trust",
        "docker-enrollment",
        "docker-mtls-ca",
        "docker-ssrf-guard",
        "docker-audit",
        "compose-schema",
        "compose-versioned-loader",
        "compose-project",
        "compose-preview",
        "compose-apply-idempotent",
        "compose-partial-failure-reconcile",
        "compose-rollback",
        "compose-render",
        "compose-policy-gate",
        "compose-secret-redaction",
        "compose-drift-detection"
      ],
      "tags": [],
      "download": "/artifacts/docker-manager-1.4.5.zip",
      "sha256": "67937ee9f427c2f1bf6c4bc998023a9d93e3858d85b2cfb29da50e1c17218d56",
      "size": 909876,
      "page": {
        "id": "docker-manager-page",
        "path": "/addons/docker-manager",
        "name": "Docker Manager",
        "icon": "🐳",
        "section": "Operations",
        "component": "@addons/docker-manager/src/components/DockerManagerPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/docker-manager/health",
        "/api/v1/addons/docker-manager/hosts",
        "/api/v1/addons/docker-manager/inventory",
        "/api/v1/addons/docker-manager/inventory/:host",
        "/api/v1/addons/docker-manager/logs/:host/:container",
        "/api/v1/addons/docker-manager/status/:host",
        "/api/v1/addons/docker-manager/redaction-patterns",
        "/api/v1/addons/docker-manager/preview",
        "/api/v1/addons/docker-manager/containers/:id/preview",
        "/api/v1/addons/docker-manager/containers/:id/start",
        "/api/v1/addons/docker-manager/containers/:id/stop",
        "/api/v1/addons/docker-manager/containers/:id/restart",
        "/api/v1/addons/docker-manager/containers/:id/update/preview",
        "/api/v1/addons/docker-manager/containers/:id/update/apply",
        "/api/v1/addons/docker-manager/containers/:id/update/rollback",
        "/api/v1/addons/docker-manager/audit",
        "/api/v1/addons/docker-manager/ca/status",
        "/api/v1/addons/docker-manager/enroll",
        "/api/v1/addons/docker-manager/enroll/:token",
        "/api/v1/addons/docker-manager/hosts/:id",
        "/api/v1/addons/docker-manager/hosts/:id/test",
        "/api/v1/addons/docker-manager/hosts/:id/revoke",
        "/api/v1/addons/docker-manager/hosts/:id/rotate-cert",
        "/api/v1/addons/docker-manager/snippet/:host_label",
        "/api/v1/addons/docker-manager/compose/preview",
        "/api/v1/addons/docker-manager/compose/apply",
        "/api/v1/addons/docker-manager/compose/rollback"
      ],
      "skills": [
        {
          "name": "docker-manager-view",
          "displayName": "Docker Manager: Multi-Host Inventar und Logs lesen",
          "description": "Listet Container, Images, Netzwerke und Volumes mehrerer Docker-Hosts read-only und liefert begrenzte, redacted Logs. Reiner Lesezugriff — keine Mutationen.",
          "permissions": [
            "addons/docker-manager/inventory",
            "addons/docker-manager/logs",
            "addons/docker-manager/status"
          ],
          "endpoint": "/api/v1/addons/docker-manager/inventory",
          "scripts": [
            {
              "id": "docker-manager-view-cli",
              "name": "docker-manager-view-cli",
              "path": "docker-manager-view/cli.sh",
              "sha256": "c50553b6ef2f34e341ea6ce1f9c9b32ab9549058f3d6e537a2264dd26bdc4a88",
              "interpreter": "bash",
              "timeoutMs": 10000,
              "permissions": [
                "addons/docker-manager/inventory",
                "addons/docker-manager/logs",
                "addons/docker-manager/status"
              ],
              "inputSchema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "limit": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 100,
                    "default": 20
                  },
                  "timeoutMs": {
                    "type": "integer",
                    "minimum": 100,
                    "maximum": 300000
                  }
                }
              },
              "outputSchema": {
                "type": "object",
                "required": [
                  "ok"
                ],
                "properties": {
                  "ok": {
                    "type": "boolean"
                  },
                  "status": {
                    "type": "integer"
                  },
                  "data": {},
                  "error": {
                    "type": "string"
                  },
                  "truncated": {
                    "type": "boolean"
                  }
                }
              },
              "size": 12130
            }
          ]
        },
        {
          "name": "docker-manager-manage",
          "displayName": "Docker Manager: Reaktions-Pattern verwalten",
          "description": "Verwaltet die pro User konfigurierbaren Secret-Reaktions-Pattern (Regex), die auf alle Log-Read-Pull-Aufrufe angewendet werden.",
          "permissions": [
            "addons/docker-manager/redaction-patterns"
          ],
          "endpoint": "/api/v1/addons/docker-manager/redaction-patterns",
          "scripts": []
        },
        {
          "name": "docker-manager-preview",
          "displayName": "Docker Manager: Container-Preview mit Drift-Schutz",
          "description": "Liest einen Container-Snapshot vom registrierten Docker-Host und liefert den snapshotHash, den Apply-Aufrufe zur Drift-Prüfung zitieren müssen. Keine Mutation; idempotent. Der Snapshot enthält keinen Secret-Material.",
          "permissions": [
            "addons/docker-manager/containers/preview"
          ],
          "endpoint": "/api/v1/addons/docker-manager/containers/:id/preview",
          "scripts": []
        },
        {
          "name": "docker-manager-apply",
          "displayName": "Docker Manager: Start/Stop/Restart mit Drift-Schutz",
          "description": "Wendet start | stop | restart auf einen Container an. Erfordert idempotencyKey + snapshotHash vom vorherigen Preview. Drift -> 409, Timeout -> 'unknown' (nie success), doppelte Submission -> 409 in_progress. Audit-Log ohne Secrets.",
          "permissions": [
            "addons/docker-manager/containers/start",
            "addons/docker-manager/containers/stop",
            "addons/docker-manager/containers/restart"
          ],
          "endpoint": "/api/v1/addons/docker-manager/containers/:id/start",
          "scripts": []
        },
        {
          "name": "docker-manager-update",
          "displayName": "Docker Manager: digest-gepinnte Image-Updates mit Health-Rollback",
          "description": "Pullt einen Image-Digest (kein Tag) von einer allowlisteten Registry, verifiziert Signatur/Policy, stoppt den Container und startet ihn mit dem neuen Digest. Health-Check entscheidet; bei unhealthy/timeout wird automatisch auf den alten Digest zurückgerollt. Kein prune/delete fremder Images, Credentials werden nicht im Audit-Log abgelegt (issue #159).",
          "permissions": [
            "addons/docker-manager/containers/update/preview",
            "addons/docker-manager/containers/update/apply",
            "addons/docker-manager/containers/update/rollback"
          ],
          "endpoint": "/api/v1/addons/docker-manager/containers/:id/update/preview",
          "scripts": []
        },
        {
          "name": "docker-manager-trust",
          "displayName": "Docker Manager: Trust-Vertrag verwalten (Enrollment, mTLS-CA, SSRF-Gate)",
          "description": "Verwaltet Remote-Docker-Host-Enrollment (Single-Use-Tokens), die Aiopta-interne mTLS-CA mit Rotation, Endpoint-Validierung (SSRF/DNS-Pinning) und den pro-User Audit-Log.",
          "permissions": [
            "addons/docker-manager/hosts",
            "addons/docker-manager/enroll",
            "addons/docker-manager/ca.status",
            "addons/docker-manager/audit"
          ],
          "endpoint": "/api/v1/addons/docker-manager/hosts",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Docker Manager (addons/docker-manager)</h1>\n<p>**Read-only Multi-Host-Inventar und Logs (#126) + controlled start/stop/restart (#157)<br />+ digest-pinned image updates with health-rollback (#159) + secure multi-host<br />trust contract (#39: single-use enrollment, mTLS-CA, SSRF gate).**<br />A self-contained Aiopta addon for the homelab operator who wants to see<br />container, image, network, and volume state across multiple Docker hosts,<br />perform bounded, audited start/stop/restart actions, roll out<br />digest-pinned image updates that auto-rollback on health failure, <strong>and</strong><br />enroll remote hosts via single-use tokens backed by an Aiopta-internal<br />mTLS-CA with SSRF/DNS-pinning endpoint validation — without<br />exposing any delete/prune/exec/compose surface. Host definitions are per-user<br />(ADR-054 <code>scopedStore</code>), and every log read + audit entry passes<br />through a configurable secret-redaction pipeline.</p>\n<h2>#39 — Trust contract (layered)</h2>\n<p>The trust anchor for talking to remote Docker hosts:</p>\n<p>- <strong>Single-use enrollment tokens</strong> (24h TTL), SHA-256-hashed at rest,<br />  plaintext returned ONCE in the <code>POST /hosts</code> response + the compose<br />  snippet. Audit row on every consume.<br />- <strong>Aiopta-internal mTLS-CA</strong> (Ed25519, AES-256-GCM-encrypted private key<br />  at rest). Operator CLI <code>node scripts/aiopta-docker.cjs {status,rotate,help}</code>.<br />- <strong>SSRF gate</strong>: scheme MUST be https, server-side DNS resolve, IP pinned<br />  for the session, static CIDR blocklist (private/loopback/link-local/cloud<br />  metadata incl. AWS <code>169.254.169.254</code> + <code>fd00:ec2::254</code>).<br />- <strong>Cross-user → 404</strong>, TLS hostname/CA failure → 404 + audit.<br />- Tables: <code>docker_trust_hosts</code>, <code>docker_enrollments</code>, <code>docker_audit</code>,<br />  <code>docker_ca</code>, <code>docker_ca_revocations</code> (separate from #126&#39;s<br />  <code>docker_hosts</code> — see the integration PR body §Documented compromise).<br />- Endpoints: <code>POST hosts</code>, <code>audit</code>, <code>ca/status</code>, <code>enroll</code>, <code>enroll/:token</code>,<br />  <code>hosts/:id</code> (DELETE), <code>hosts/:id/{test,revoke,rotate-cert}</code>,<br />  <code>snippet/:host_label</code>.</p>\n<blockquote><p><strong>Slice against the parent agent-trust model from #39.</strong></p><p>This addon ships standalone with a clean adapter seam. Parent #39 will drop</p><p>in the <code>RemoteAgentDockerAdapter</code> (mTLS-enrolled agent transport). Until then</p><p>the in-memory / fake adapters serve a seeded surface so the addon is usable</p><p>end-to-end and fully testable.</p></blockquote>\n<h2>Scope (this slice)</h2>\n<p>### #126 — Read-only inventory + logs<br />- <strong>Per-user host list and inventory</strong> — the <code>docker_hosts</code> and<br />  <code>docker_inventory_cache</code> scoped tables (ADR-054) hold host definitions +<br />  the most recent inventory snapshot for the caller only. Foreign lookups<br />  return 0 rows; the route layer maps that to a 404 with no existence leak.<br />- <strong>Paged multi-host inventory</strong> — <code>GET /inventory</code> returns one normalized<br />  envelope per host. Partial-success: a down host yields <code>reachable=false</code><br />  and does not poison the rest of the payload.<br />- <strong>Bounded, redacted log reads</strong> — <code>GET /logs/:host/:container</code> enforces byte<br />  + line limits and returns an opaque cursor. Every line is run through the<br />  resolved redaction-pattern set BEFORE the response leaves the server.<br />- <strong>Configurable redaction</strong> — <code>GET|PUT|DELETE|PATCH /redaction-patterns</code> is<br />  the per-user meta-config knob for the secret-redaction pipeline.<br />- <strong>Status</strong> — every status response carries <code>host</code> + <code>fetchedAt</code>.<br />- <strong>Polling</strong> — a singleton in-process poller refreshes the inventory cache<br />  for every enrolled user; when the addon is disabled the poller exits cleanly.</p>\n<p>### #157 — Controlled mutations<br />- ✅ Start / stop / restart of allowlisted containers<br />- ✅ Digest-pinned image updates with health-rollback (#159) — pull + verify an<br />  image BEFORE mutation; on unhealthy/timeout/start-failure the container rolls<br />  back to the old digest automatically (old image preserved; no prune/delete)<br />- ✅ Preview snapshot + drift detection between preview and apply<br />- ✅ Idempotency key replay (single mutation, repeated requests are no-ops)<br />- ✅ Audit log with actor + host alias + container id, secrets redacted<br />- ✅ Per-user registry allowlist + credential redaction (no plaintext in audit)<br />- ❌ <strong>No</strong> delete / prune / exec / compose / image-tag mutation<br />- ❌ <strong>No</strong> prune/delete of foreign images (the old digest stays for rollback)</p>\n<p>### #158 (slice 1/3, #329) — Declarative compose-schema + versioned repo loader<br />- ✅ JSON-Schema for Compose-project manifests at<br />  <code>addons/docker-manager/schema/compose-project.schema.json</code> — single source<br />  of truth for what a valid <code>project.json</code> looks like (<code>name</code>, <code>version</code>,<br />  <code>services[]</code>, <code>volumes[]</code>, <code>networks[]</code>, <code>secrets[]</code>).<br />- ✅ Versioned repo loader (<code>loadProject(repoUrl, commitSha)</code>) — clones the<br />  pinned commit, validates the manifest against the schema, and verifies<br />  the post-checkout HEAD matches the pinned SHA. Pure function for tests<br />  via injected <code>LoaderDeps</code>; production deps spawn <code>git</code> via<br />  <code>child_process.execFile</code> (no shell, argv-only).<br />- ✅ Typed errors (<code>DriftError</code> → 409, <code>ProjectSchemaError</code> → 422,<br />  <code>EmptyRepoError</code> / <code>MissingManifestError</code> → 404, <code>InvalidJsonError</code> →<br />  400, <code>IoError</code> → 502, <code>InvalidArgsError</code> → 400). The route layer maps<br />  each <code>ProjectErrorCode</code> to its HTTP status via <code>PROJECT_ERROR_TO_HTTP</code><br />  in <code>project-errors.ts</code>.<br />- ✅ No privileged / host-network / arbitrary bind mounts — the schema<br />  refuses them structurally (slice #330 will enforce them semantically).<br />- ✅ No silent overwrite of an existing project path — the loader refuses<br />  to clobber a leftover clone and surfaces <code>InvalidArgsError</code>.<br />- ⏭ Render / Policy / Preview → slice #330.<br />- ⏭ Apply + Rollback → slice #331.<br />- ❌ <strong>No</strong> direct <code>/var/run/docker.sock</code> from the addon — adapter seam only<br />- ❌ <strong>No</strong> cross-user enumeration — foreign container ids map to 404</p>\n<h2>Routes</h2>\n<p>| Method | Path | Description |<br />| --- | --- | --- |<br />| <code>GET</code> | <code>/api/v1/addons/docker-manager/health</code> | Addon enabled + hosts count |<br />| <code>GET</code> | <code>/api/v1/addons/docker-manager/hosts</code> | List caller&#39;s enrolled hosts |<br />| <code>GET</code> | <code>/api/v1/addons/docker-manager/inventory</code> | Paginated multi-host inventory |<br />| <code>GET</code> | <code>/api/v1/addons/docker-manager/inventory/:host</code> | Single-host drill-down |<br />| <code>GET</code> | <code>/api/v1/addons/docker-manager/logs/:host/:container</code> | Bounded, redacted logs |<br />| <code>GET</code> | <code>/api/v1/addons/docker-manager/status/:host</code> | Per-host status (+ fetchedAt) |<br />| <code>GET\\|PUT\\|DELETE\\|PATCH</code> | <code>/api/v1/addons/docker-manager/redaction-patterns</code> | Per-user redaction config |<br />| <code>POST</code> | <code>/api/v1/addons/docker-manager/containers/:id/preview</code> | Read-only snapshot → <code>snapshotHash</code> |<br />| <code>POST</code> | <code>/api/v1/addons/docker-manager/containers/:id/start</code> | Idempotent start |<br />| <code>POST</code> | <code>/api/v1/addons/docker-manager/containers/:id/stop</code> | Idempotent stop |<br />| <code>POST</code> | <code>/api/v1/addons/docker-manager/containers/:id/restart</code> | Idempotent restart |<br />| <code>POST</code> | <code>/api/v1/addons/docker-manager/containers/:id/update/preview</code> | Pull + verify a digest-pinned image; return update plan |<br />| <code>POST</code> | <code>/api/v1/addons/docker-manager/containers/:id/update/apply</code> | Stop → start-new → health-check; auto-rollback on unhealthy |<br />| <code>POST</code> | <code>/api/v1/addons/docker-manager/containers/:id/update/rollback</code> | Operator-initiated rollback to old digest |</p>\n<p>Every mutation route is wrapped in <code>userRoute</code> (issue #157 user-scope). The<br />user scope is enforced in <code>src/lib/allowlist.ts</code> via <code>assertContainerForUser</code>,<br />which maps a foreign id to <strong>404</strong> (no enumeration leak across users).</p>\n<h2>Workflow (mutations)</h2>\n<pre><code class=\"lang-text\">client                docker-manager           adapter         audit\n  │  POST /preview      │                       │              │\n  │ ───────────────────►│ assertContainerForUser│              │\n  │                     │ ────────────────────►│ snapshot     │\n  │                     │                       │ ───────────► │\n  │ {snapshotHash,K}    │ audit row (success)   │              │\n  │ ◄───────────────────│                       │              │\n  │  POST /start        │                       │              │\n  │  {snapshotHash,K}   │ reserve(K)            │              │\n  │                     │ ────────────────────►│ apply        │\n  │                     │ ◄────────────────────│ ok / drift   │\n  │ {status:success}    │ audit row             │              │\n  │ ◄───────────────────│                       │              │</code></pre>\n<p>Drift between preview and apply surfaces as <strong>HTTP 409</strong> with<br /><code>status: &quot;drift&quot;</code>. Timeout surfaces as <strong>HTTP 504</strong> with <code>status: &quot;timeout&quot;</code><br />— never <code>success</code> (issue #157 acceptance #2).</p>\n<h2>Trust contract (parent #39)</h2>\n<p>This addon does NOT ship the agent enrollment / mTLS surface — that lives in<br />parent #39 / PR #261. The adapter seam is the contract between this addon and<br />the trust layer. Implementations shipped today:</p>\n<p>- <code>FakeDockerAdapter</code> (#126) — pure in-memory, hermetic. Used by the #126<br />  inventory unit suite. NEVER opens a socket.<br />- <code>createInMemoryDockerAdapter</code> (#157) — fully deterministic mutation adapter<br />  for the #157 unit suite; no real network, no real Docker.<br />- <code>LocalDockerAdapter</code> / <code>RemoteAgentAdapter</code> — land with parent #39.</p>\n<h2>Out of scope (parent #39 + child stories)</h2>\n<ul><li>Compose / Image <strong>mutations</strong> — #158 / #159.</li><li>Host enrollment UI + mTLS key generation — parent #39 / PR #261.</li><li>Live Docker API integration — bundled with the agent contract.</li></ul>\n<h2>Security posture</h2>\n<p>- The only Docker-mutating routes are start/stop/restart (#157); the<br />  <code>redaction-patterns</code> non-GET routes are per-user meta-config, not Docker<br />  mutations.<br />- <code>credentialRef</code> (the pointer to the Core Credential Store) is masked before<br />  any host is sent to a client; the raw reference NEVER leaves the server.<br />- Every Docker-host operation is per-user via <code>scopedStore(userId)</code>; foreign<br />  users get 0 rows + 404.<br />- Per-host failure isolation — a down host does not poison the rest of the<br />  dashboard.<br />- Audit entries carry no secret material (opaque-token + secret-key patterns<br />  are redacted before logging).</p>\n<h2>Commands</h2>\n<pre><code class=\"lang-bash\"># Local validation\n./scripts/validate-addons.sh</code></pre>\n<p># Unit tests<br />npx vitest run addons/docker-manager/tests/unit<br />```</p>\n<h2>Parent / Blocker</h2>\n<ul><li>#39 (Docker Manager Multi-Host-Trust) — supplies the agent transport</li><li>#126 (Read-only Multi-Host-Inventar) — supplies the inventory + host CRUD</li><li>#157 (this slice) — the mutation surface against that trust model</li></ul>\n<p>The adapter seam lets #157 ship standalone; the agent transport drops in once<br />#39 lands without touching this addon&#39;s route handlers.</p>",
          "byteSize": 10807
        },
        "changelog": {
          "filename": "CHANGELOG.md",
          "sourcePath": "CHANGELOG.md",
          "rendered": "<h1>Changelog</h1>\n<h2>0.1.0 — Issue #39 (Multi-Host-Docker-Trust contract)</h2>\n<p><strong>Initial release</strong> — ships the trust anchor for multi-host Docker<br />management. Children #126–#159 build the read/mutate/compose/img<br />surfaces on top.</p>\n<h3>Added</h3>\n<p>- <code>addon.json</code> manifest + <code>src/manifest.ts</code> runtime extension (scope: <code>list</code>, <code>test</code>, <code>enroll</code>, <code>revoke</code>, <code>rotate-cert</code>, <code>audit</code>, <code>ca/status</code>, <code>snippet/{label}</code>).<br />- Per-user <code>scopedStore(&#39;docker_hosts&#39;)</code>, <code>scopedStore(&#39;docker_enrollments&#39;)</code>, <code>scopedStore(&#39;docker_audit&#39;)</code> (ADR-054).<br />- Instance-level <code>docker_ca</code> + <code>docker_ca_revocations</code> (CRL).<br />- Single-use enrollment tokens: SHA-256(token) stored; plaintext<br />  returned ONCE in <code>POST /hosts</code> response and embedded in the<br />  <code>docker-compose.yml</code> snippet.<br />- AES-256-GCM envelope encryption (<code>src/lib/crypto.ts</code>) for the CA<br />  private key + optional cached agent private key.<br />- mTLS CA primitive (<code>src/lib/mtls-ca.ts</code>) — generation, agent cert<br />  issuance, signature verify (with Ed25519-aware <code>crypto.sign</code> /<br />  <code>crypto.verify</code>), rotation, CRL pinning.<br />- Endpoint validator (<code>src/lib/endpoint-validator.ts</code>) — SSRF gate<br />  (private / loopback / link-local / cloud-metadata blocklist +<br />  DNS-pinning) + <code>assertSameUser</code> cross-user guard.<br />- Adapter seam (<code>src/lib/adapter.ts</code>) — typed <code>Success</code> / <code>Timeout</code> /<br />  <code>Refuse</code> / <code>Revoke</code> fakes + <code>liveAdapter</code> (TLS-validated) +<br />  <code>outcomeFromAdapter()</code> audit-log mapper.<br />- <code>docker-compose.yml</code> snippet renderer (<code>src/lib/compose-snippet.ts</code>)<br />  with security invariants (no <code>ports:</code>, no <code>privileged: true</code>, no<br />  <code>network_mode: host</code>).<br />- 8 API route handlers under <code>src/app/api/v1/addons/docker-manager/</code>.<br />- UI dashboard (<code>src/components/DockerManagerDashboard.tsx</code>) with<br />  enroll form, host list, compose snippet viewer, audit log, and<br />  per-host actions.<br />- Operator CLI (<code>scripts/aiopta-docker.cjs {status, rotate, help}</code>)<br />  for CA rotation.</p>\n<h3>Security</h3>\n<p>- No <code>/var/run/docker.sock</code> mount from Aiopta to remote hosts.<br />- Cross-user → 404 (no enumeration leak).<br />- TLS hostname / CA failure → 404 + audit row.<br />- DNS-pinning (server-side resolve, IP pinned for the session).<br />- Agent private key (when cached for re-rotate) encrypted at rest<br />  via AES-256-GCM.<br />- <code>redactEnvelopes()</code> strips <code>enc:v1:...</code> blobs from any error path.</p>\n<h3>Tests</h3>\n<p>- 6 test files, 77 passing tests.<br />- <code>compose-snippet.test.ts</code> (12) — render + security invariants + sha256.<br />- <code>endpoint-validator.test.ts</code> (19) — SSRF, DNS-pinning, cross-user,<br />  CIDR membership for v4/v6.<br />- <code>crypto.test.ts</code> (15) — round-trip + tamper detection + nonce<br />  uniqueness + weak-key guard + envelope redaction.<br />- <code>mtls-ca.test.ts</code> (9) — CA gen, agent cert, rotation, verify<br />  round-trip, cross-CA defense, expiry.<br />- <code>adapter.test.ts</code> (11) — fake outcomes + audit-log mapping.<br />- <code>store.test.ts</code> (10) — schema idempotency + per-user scoping +<br />  single-use tokens + CRL pinning + CA rotation.</p>\n<h3>Validation</h3>\n<ul><li><code>npm run validate:addons</code> ✅</li><li><code>node --check scripts/aiopta-docker.cjs</code> ✅</li></ul>\n<h2>1.3.0 — Issue #158 slice 2 (Render / Policy / Preview vor Apply)</h2>\n<p>Slice 2 of the Docker Manager declarative Compose-Deploy epic. Ships<br />the deterministic render pipeline + 4 standard policy rules + the<br />preview endpoint. Slice 1 (#329) owns the JSON-Schema + repo-loader;<br />slice 3 (#331) will wire the preview output into the apply path.</p>\n<h3>Added</h3>\n<p>- <strong><code>src/lib/compose-project.ts</code></strong> — <code>ComposeProject</code> types (services,<br />  volumes, networks, secrets, mounts) + <code>validateProjectShape()</code><br />  (pure schema validator that collects ALL issues in one pass) +<br />  <code>loadProject({project, expectedCommitSha})</code> (deep-frozen validator<br />  with drift detection via <code>DriftError</code>) + <code>projectContentHash()</code><br />  (canonical JSON SHA-256, deterministic across key-order changes).<br />  This file is the shared contract between slice 1 (loader) and slice<br />  2 (renderer).<br />- <strong><code>src/lib/render.ts</code></strong> — deterministic <code>renderProject(project, vars)</code><br />  → canonical docker-compose.yml + <code>configHash</code> + used placeholder<br />  list. Pure function: equal inputs always yield equal output.<br />  <code>PLACEHOLDER_RE = /\\$\\{([A-Z][A-Z0-9_]{0,63})\\}/g</code> is the only<br />  recognised placeholder shape; suspicious placeholders are passed<br />  verbatim so the policy engine can flag them. <code>redactRenderedYaml()</code><br />  + <code>previewRenderedConfig()</code> strip secret-shaped values<br />  (<code>password=...</code>, <code>token=...</code>, <code>api_key=...</code>, <code>secret=...</code>, URL with<br />  userinfo, opaque ≥32-char tokens) before returning to the caller.<br />- <strong><code>src/lib/policy.ts</code></strong> — <code>checkPolicy(project)</code> runs the four<br />  standard rules and returns typed <code>PolicyViolation[]</code>:<br />  - R1 — <code>privileged: true</code> → block<br />  - R2 — <code>network_mode: host</code> → block<br />  - R3 — dangerous bind mounts (<code>/</code>, <code>/etc</code>, <code>/proc</code>, <code>/sys</code>,<br />    <code>/dev</code>, <code>/boot</code>, <code>/root</code>, <code>/home</code>, <code>/var/run/docker.sock</code>,<br />    <code>/var/lib/docker</code>); catches <code>/etc/../var/run/docker.sock</code><br />    traversal evasion<br />  - R4 — inlined secrets in env / command / labels; suspicious<br />    placeholders (<code>${../etc/passwd}</code>, <code>${$(rm -rf /)}</code>) → block,<br />    non-standard placeholder shape → warn<br />- <strong><code>src/lib/preview-pipeline.ts</code></strong> — <code>runPreview(input)</code> glues<br />  load → render → policy → redact into a typed <code>PreviewResult</code>.<br />  The route layer uses it directly; slice 3 (#331) calls the same<br />  helper before apply.<br />- <strong><code>POST /api/v1/addons/docker-manager/preview</code></strong> — preview endpoint<br />  (200 on clean / 403 on block-severity violations / 409 on drift /<br />  400 on bad input / 401 on no-auth). Writes one audit entry per<br />  accepted request: <code>success</code> / <code>rejected</code> (with violation summary)<br />  / <code>drift</code> (with both SHAs). No secrets in the audit <code>reason</code><br />  field — redaction runs before the entry is appended.<br />- <strong>Tests</strong> — 4 new test files (52 new assertions total):<br />  <code>compose-project.test.ts</code> (19), <code>render.test.ts</code> (15),<br />  <code>policy.test.ts</code> (16), <code>preview-pipeline.test.ts</code> (13),<br />  <code>preview-route.test.ts</code> (15). All pass via vitest<br />  (<code>npx vitest run addons/docker-manager/tests/unit/</code>).</p>\n<h3>Out-of-scope (handled by sibling slices)</h3>\n<p>- Slice 1 (#329): Git-clone repo loader + JSON-Schema file. Slice 2<br />  consumes the typed <code>ComposeProject</code> slice 1 produces.<br />- Slice 3 (#331): <code>POST /api/v1/addons/docker-manager/apply</code> with<br />  partial-failure reconcile + rollback plan. Slice 3 reads the<br />  preview&#39;s <code>configHash</code> + <code>violations[]</code> and refuses apply when<br />  <code>blocked === true</code>.</p>\n<h3>Notes</h3>\n<p>- The ComposeProject type lives in slice 2 even though slice 1 owns<br />  the loader — the render + policy modules need a typed shape and<br />  putting it here means slice 1 imports this module. Slice 1&#39;s<br />  JSON-Schema becomes a hand-written mirror of the runtime shape,<br />  so a drift fails fast in <code>validateProjectShape</code>.</p>",
          "byteSize": 6639
        }
      }
    },
    {
      "id": "dropshipping",
      "uuid": "ee37a5f1-96bb-4aa8-aa90-d809df1cb2ed",
      "name": "Dropshipping & POD",
      "version": "2.0.1",
      "description": "DEPRECATED — zusammengeführt in das kanonische Commerce-Addon `commerce` als Dropshipping-Connector (ADR-008 Addon-Konsolidierung, Issue #590 / Epic #492). Der Dropshipping-Channel (Printful/Printify POD, Supplier-Abstraktion, Margins/Pricing, Woo-Bridge) ist als Feature-Modul unter `commerce/src/connectors/dropshipping/` migriert: BYOK-Credentials in der gemeinsamen `commerce_connector_config`-Tabelle, Sync-Client auf dem kanonischen `CommerceChannel`-Vertrag, Alt-ID-Redirect `/addons/dropshipping` → `/addons/commerce?connector=dropshipping`. dropshipping bleibt bis zum Sunset (2026-12-31) voll funktionsfähig und datenerhaltend — kein Endpoint entfällt, keine Zeile wird kopiert/gelöscht. Ursprüngliche Fähigkeit: Import print-on-demand products, set margins, place and fulfill orders via Printful / Printify. The suppliers are external SaaS APIs reached with the owner's API key from Aiopta-config (no container) — auto-fulfillment is always an explicit call (ADR-013).",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "commerce-dropshipping",
        "dropshipping-products",
        "dropshipping-orders",
        "dropshipping-fulfillment",
        "dropshipping-margins",
        "pod-printful",
        "pod-printify"
      ],
      "tags": [],
      "download": "/artifacts/dropshipping-2.0.1.zip",
      "sha256": "7c30abaa993865bbff19f4b2778fad2e9f36044519d86f4b0b8027e8541cb2b7",
      "size": 114975,
      "page": {
        "id": "dropshipping-page",
        "path": "/addons/dropshipping",
        "name": "Dropshipping & POD",
        "icon": "📦",
        "section": "Money",
        "component": "@addons/dropshipping/src/page"
      },
      "deprecated": true,
      "replacedBy": "commerce",
      "deprecation": {
        "deprecated": true,
        "replacedBy": "commerce",
        "sunset": "2026-12-31",
        "successorPath": "/addons/commerce",
        "successorLabel": "Commerce (Nachfolger)"
      },
      "endpoints": [
        "/api/v1/addons/dropshipping/status",
        "/api/v1/addons/dropshipping/products",
        "/api/v1/addons/dropshipping/orders",
        "/api/v1/addons/dropshipping/orders/:id",
        "/api/v1/addons/dropshipping/orders/:id/fulfill",
        "/api/v1/addons/dropshipping/fulfillments",
        "/api/v1/addons/dropshipping/margins"
      ],
      "skills": [
        {
          "name": "dropshipping-catalog",
          "displayName": "Dropshipping: Browse & Import Products",
          "description": "List Printful/Printify catalog products (with margin-applied retail prices) and import one into the shop.",
          "permissions": [
            "addons/dropshipping/products",
            "addons/dropshipping/status"
          ],
          "endpoint": "/api/v1/addons/dropshipping/products",
          "scripts": []
        },
        {
          "name": "dropshipping-orders",
          "displayName": "Dropshipping: Orders & Fulfillment",
          "description": "List/create draft orders and explicitly fulfill them (forward to the supplier for production). Fulfillment moves money and is never implicit.",
          "permissions": [
            "addons/dropshipping/orders",
            "addons/dropshipping/fulfillment"
          ],
          "endpoint": "/api/v1/addons/dropshipping/orders",
          "scripts": []
        },
        {
          "name": "dropshipping-margins",
          "displayName": "Dropshipping: Margin Rules",
          "description": "Read or set margin rules (percent + fixed, charm-rounding) that turn supplier base cost into retail price.",
          "permissions": [
            "addons/dropshipping/margins"
          ],
          "endpoint": "/api/v1/addons/dropshipping/margins",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Dropshipping &amp; POD Addon</h1>\n<p>Run a <strong>print-on-demand / dropshipping</strong> business from inside Aiopta — browse a supplier<br />catalog, apply <strong>margin rules</strong> to turn supplier cost into retail price, place <strong>draft orders</strong>, and<br /><strong>explicitly fulfill</strong> them. Usable by agents (via the addon API) and by humans (via the management<br />page). Suppliers: <strong>Printful</strong> and <strong>Printify</strong>.</p>\n<h2>Architecture (ADR-013)</h2>\n<p>- <strong>The suppliers are external SaaS APIs — there is NO container.</strong> Printful (<code>https://api.printful.com</code>)<br />  and Printify (<code>https://api.printify.com/v1</code>) are third-party SaaS, not self-hostable tools, so —<br />  unlike the WordPress addon (ADR-009/010) — this addon declares <strong>no <code>container</code></strong>. Each supplier<br />  client (<code>src/lib/printful-client.ts</code>, <code>src/lib/printify-client.ts</code>) is a thin HTTPS client behind a<br />  small common interface (<code>src/lib/supplier.ts</code>: <code>SupplierClient</code>). This is the same pattern as the<br />  LLM providers (a client to a third-party API using the owner&#39;s key).<br />- <strong>Owner&#39;s key from Aiopta-config, never hard-coded, never logged.</strong> Each client reads its API key via<br />  <code>getAddonConfiguration(&quot;dropshipping&quot;)</code> (<code>PRINTFUL_API_KEY</code> / <code>PRINTIFY_API_KEY</code>). The vendor base<br />  URL is a compile-time constant in the client — <strong>never a user-entered host</strong>. The key is sent as a<br />  <code>Bearer</code> token and is never written to logs. When a key is missing, the client returns a clear<br />  <strong>400</strong> <code>SupplierError</code> (it never throws uncaught and never calls a default host).<br />- <strong>No money moves without an explicit fulfill call.</strong> Creating an order produces a <strong>draft</strong> only.<br />  Fulfillment is a separate, explicit endpoint (<code>POST /orders/{id}/fulfill</code>) that forwards the order<br />  to the supplier (<code>/orders/{id}/confirm</code> for Printful, <code>/send-to-production.json</code> for Printify). Real<br />  fulfillment requires the owner&#39;s <strong>funded</strong> supplier account; until funded the supplier rejects<br />  confirmation and the upstream error is surfaced.<br />- <strong>Talks to other addons only via the Aiopta API.</strong> The optional WooCommerce bridge<br />  (<code>src/lib/woo-bridge.ts</code>) calls the <strong>wordpress addon&#39;s</strong> Woo endpoints<br />  (<code>/api/v1/addons/wordpress/products</code>, <code>.../orders/{id}</code>) through the Aiopta API — never WooCommerce or<br />  the supplier directly — and fails soft if the wordpress addon is absent.</p>\n<h2>Configuration (Aiopta addon-config — no secret committed)</h2>\n<p>| Key | Required | Purpose |<br />|-----|----------|---------|<br />| <code>PRINTFUL_API_KEY</code> | for Printful | Printful API token (Bearer). Stored <code>is_secret=1</code>. |<br />| <code>PRINTFUL_STORE_ID</code> | optional | Store id for account-level Printful tokens (<code>X-PF-Store-Id</code>). |<br />| <code>PRINTIFY_API_KEY</code> | for Printify | Printify personal access token (Bearer). Stored <code>is_secret=1</code>. |<br />| <code>PRINTIFY_SHOP_ID</code> | optional | Shop id; auto-resolved from <code>/shops.json</code> when absent. |<br />| <code>MARGIN_RULES</code> | optional | JSON margin rules; defaults to +40% charm-rounded to <code>.99</code>. |</p>\n<p>Set them with <code>setAddonConfiguration(&quot;dropshipping&quot;, { PRINTFUL_API_KEY: &quot;…&quot; })</code>. Until at least one<br />key is set, <code>/status</code> reports the supplier as not-configured and product/order calls return a clear<br /><strong>400</strong>.</p>\n<h2>API</h2>\n<p>All routes are namespaced under <code>/api/v1/addons/dropshipping/</code> and require auth.</p>\n<pre><code>GET  /status                          → configured suppliers + connection probe\nGET  /products?supplier=printful      → list catalog products (with margin-applied retailPrice)\nPOST /products    { productId, supplier?, syncWoo? }   → import a product (optional Woo publish)\nGET  /orders?supplier=&amp;status=        → list orders\nPOST /orders      { items[], recipient{…}, supplier? } → create a DRAFT order (no charge)\nGET  /orders/{id}?supplier=           → order status + tracking\nPOST /orders/{id}/fulfill { supplier? }→ EXPLICITLY fulfill (forward to supplier; moves money)\nGET  /fulfillments                    → open/in-progress orders across suppliers + revenue\nPOST /fulfillments { orderId, supplier? } → re-sync one order&#39;s status + tracking\nGET  /margins                         → active margin rules + worked example\nPUT  /margins     { default:{percent,fixed,minPrice?,roundTo?}, perSupplier?{…} } → set rules</code></pre>\n<p>Responses use the <code>{ &quot;success&quot;: true, &quot;data&quot;: … }</code> envelope. On failure read <code>error</code> + the HTTP<br />status (a <code>400</code> means the supplier key is not set; the upstream vendor status is preserved otherwise).</p>\n<h3>Margins / pricing</h3>\n<p><code>src/lib/pricing.ts</code> is pure and unit-tested. A <code>MarginRule</code> turns a supplier <strong>base cost</strong> into a<br /><strong>retail price</strong>: <code>retail = max(cost*(1+percent/100) + fixed, minPrice)</code>, then optional charm-rounding<br />(<code>roundTo</code>, e.g. <code>.99</code>). Per-supplier overrides are supported via <code>perSupplier</code>. Default rule: <code>+40%</code>,<br />charm-rounded to <code>.99</code> (so cost <code>20</code> → retail <code>28.99</code>).</p>\n<h2>Layout</h2>\n<pre><code>addons/dropshipping/\n├── addon.json                 # Manifest (page + widget + endpoints + skills; NO container)\n├── README.md\n├── SKILL.md                   # Agent skill\n├── src/\n│   ├── manifest.ts            # Extension (dual-registration: routes + page; string-literal handlers)\n│   ├── page.tsx               # Management UI (Products / Orders / Fulfillments / Margins)\n│   ├── widget.tsx             # Dashboard widget (open fulfillments + revenue, declarative)\n│   ├── lib/\n│   │   ├── supplier.ts        # Common SupplierClient interface + SupplierError\n│   │   ├── printful-client.ts # Printful SaaS client (key from Aiopta-config)\n│   │   ├── printify-client.ts # Printify SaaS client (key from Aiopta-config)\n│   │   ├── http.ts            # Shared Bearer fetch (constant base URL; key never logged)\n│   │   ├── suppliers.ts       # Registry + /status probe\n│   │   ├── pricing.ts         # Pure margin / retail-price math\n│   │   ├── margins-store.ts   # Margin rules persisted via Aiopta addon-config\n│   │   └── woo-bridge.ts      # OPTIONAL WooCommerce bridge via the wordpress addon&#39;s Aiopta-API\n│   └── app/api/v1/addons/dropshipping/{status,products,orders,orders/[id],orders/[id]/fulfill,fulfillments,margins}/route.ts\n└── tests/unit/                # manifest validity + pure pricing + supplier clients (fetch+config mocked)</code></pre>\n<h2>Owner-parked items (non-blocking)</h2>\n<p>The addon code + routes work the moment the owner stores a supplier key. Tracked in<br /><code>docs/OPEN-DECISIONS.md</code>:</p>\n<blockquote><p><strong>TODO(owner): <code>dropshipping-supplier-keys</code></strong> — create a Printful and/or Printify API key and store</p><p>it in Aiopta addon-config (<code>PRINTFUL_API_KEY</code> / <code>PRINTIFY_API_KEY</code>). No secret is committed. Until set,</p><p><code>/status</code> reports not-configured and calls return a clear 400. Real <strong>fulfillment</strong> additionally</p><p>requires a <strong>funded</strong> supplier account.</p></blockquote>\n<h2>License</h2>\n<p>MIT</p>",
          "byteSize": 6745
        }
      }
    },
    {
      "id": "email-marketing",
      "uuid": "c9f6a2c1-0716-4f47-91f4-dbc7c437c654",
      "name": "E-Mail-Marketing (Listen, Kampagnen, Templates, Sequenzen)",
      "version": "1.0.5",
      "description": "E-Mail-Marketing über die in Aiopta konfigurierte E-Mail (SMTP/ESP, wie lead-gen): Listen & Abonnenten mit Double-Opt-in, wiederverwendbare Templates, Kampagnen mit Segmentierung, Drip-Sequenzen sowie Öffnungs-/Klick-/Bounce-Tracking. Lokale SQLite ist die Quelle der Wahrheit; ohne konfigurierten Versand läuft das Addon graceful (Listen/Abonnenten/Templates anlegbar, Versand meldet 'zuerst E-Mail konfigurieren', nie ein 500). Jede Marketing-Mail trägt einen Abmeldelink; unbestätigte Adressen werden nie angeschrieben. Confirm/Unsubscribe/Open/Click sind öffentlich, aber tokengeschützt; der Klick-Redirect leitet nur auf die gespeicherte URL um (kein Open-Redirect). ADR-042.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "lists",
        "subscribers",
        "double-opt-in",
        "templates",
        "campaigns",
        "sequences",
        "segmentation",
        "open-tracking",
        "click-tracking",
        "bounce-tracking",
        "unsubscribe"
      ],
      "tags": [],
      "download": "/artifacts/email-marketing-1.0.5.zip",
      "sha256": "200c0613fc02935fc27b4e873159e74de2b5b56225491d4e3d690069390be7e1",
      "size": 267743,
      "page": {
        "id": "email-marketing-page",
        "path": "/addons/email-marketing",
        "name": "E-Mail-Marketing",
        "icon": "📣",
        "section": "Money",
        "component": "@addons/email-marketing/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/email-marketing/status",
        "/api/v1/addons/email-marketing/lists",
        "/api/v1/addons/email-marketing/lists/:id",
        "/api/v1/addons/email-marketing/lists/:id/subscribers",
        "/api/v1/addons/email-marketing/subscribers/:id",
        "/api/v1/addons/email-marketing/templates",
        "/api/v1/addons/email-marketing/templates/:id",
        "/api/v1/addons/email-marketing/campaigns",
        "/api/v1/addons/email-marketing/campaigns/:id",
        "/api/v1/addons/email-marketing/campaigns/:id/send",
        "/api/v1/addons/email-marketing/sequences",
        "/api/v1/addons/email-marketing/sequences/:id",
        "/api/v1/addons/email-marketing/sends/:id/bounce",
        "/api/v1/addons/email-marketing/public/confirm",
        "/api/v1/addons/email-marketing/public/unsubscribe",
        "/api/v1/addons/email-marketing/public/open",
        "/api/v1/addons/email-marketing/public/click",
        "/api/v1/addons/email-marketing/widget"
      ],
      "skills": [
        {
          "name": "email-marketing-overview",
          "displayName": "E-Mail-Marketing: Status & Übersicht",
          "description": "Liest den Versand-Status (konfiguriert? Provider/From-Domain, ohne Secrets) und die Marketing-Übersicht (Listen, Abonnenten nach Opt-in-Status, Templates/Kampagnen/Sequenzen, letzte Öffnungsrate). Reiner Lesezugriff — versendet nichts.",
          "permissions": [
            "addons/email-marketing/status"
          ],
          "endpoint": "/api/v1/addons/email-marketing/status",
          "scripts": []
        },
        {
          "name": "email-marketing-lists",
          "displayName": "E-Mail-Marketing: Listen & Abonnenten",
          "description": "Listet/erstellt Listen und fügt Abonnenten hinzu (mit Double-Opt-in: neue Adressen sind 'pending' und bekommen eine Bestätigungs-Mail). Unbestätigte Adressen werden nie angeschrieben. Versand der Bestätigung braucht konfigurierte E-Mail (sonst bleibt der Abonnent 'pending', kein 500).",
          "permissions": [
            "addons/email-marketing/lists",
            "addons/email-marketing/status"
          ],
          "endpoint": "/api/v1/addons/email-marketing/lists",
          "scripts": []
        },
        {
          "name": "email-marketing-templates",
          "displayName": "E-Mail-Marketing: Templates",
          "description": "Listet/erstellt/bearbeitet wiederverwendbare E-Mail-Templates (Betreff + HTML/Text-Body mit Merge-Feldern wie {{name}}).",
          "permissions": [
            "addons/email-marketing/templates"
          ],
          "endpoint": "/api/v1/addons/email-marketing/templates",
          "scripts": []
        },
        {
          "name": "email-marketing-campaigns",
          "displayName": "E-Mail-Marketing: Kampagnen",
          "description": "Erstellt Kampagnen (Liste/Segment + Template) und versendet sie. Der Versand iteriert NUR bestätigte, segment-passende Abonnenten, respektiert eine Sendegrenze, hängt an jede Mail einen Abmeldelink und fügt Öffnungs-/Klick-Tracking hinzu. Ohne konfigurierte E-Mail liefert der Versand ein klares 400 ('zuerst E-Mail konfigurieren'), nie ein 500.",
          "permissions": [
            "addons/email-marketing/campaigns",
            "addons/email-marketing/status"
          ],
          "endpoint": "/api/v1/addons/email-marketing/campaigns",
          "scripts": []
        },
        {
          "name": "email-marketing-sequences",
          "displayName": "E-Mail-Marketing: Sequenzen",
          "description": "Listet/erstellt Drip-Sequenzen (mehrere Mails zeitversetzt, Trigger: nach Bestätigung) und aktiviert/deaktiviert sie.",
          "permissions": [
            "addons/email-marketing/sequences"
          ],
          "endpoint": "/api/v1/addons/email-marketing/sequences",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>E-Mail-Marketing Addon</h1>\n<p>Listen &amp; Abonnenten (mit <strong>Double-Opt-in</strong>), wiederverwendbare <strong>Templates</strong>, <strong>Kampagnen</strong> mit<br /><strong>Segmentierung</strong>, Drip-<strong>Sequenzen</strong> sowie <strong>Öffnungs-/Klick-/Bounce-Tracking</strong> — versendet über die in<br />Aiopta konfigurierte E-Mail (SMTP/ESP, <strong>derselbe Mail-Pfad wie lead-gen</strong>).</p>\n<blockquote><p>Money-Stufe-1 · ADR-042 · Connector-Pattern (<strong>kein</strong> Container — nutzt Aiopta instances Mail) · #69 / INBOX-2.</p></blockquote>\n<h2>Was es tut</h2>\n<p>- <strong>Listen &amp; Abonnenten</strong> — Listen anlegen; Abonnenten hinzufügen/importieren (Name/E-Mail/Attribute/<br />  Tags). Opt-in-Status <code>pending → confirmed → unsubscribed | bounced</code>.<br />- <strong>Double-Opt-in</strong> — neue Abonnenten sind <code>pending</code> und erhalten eine <strong>Bestätigungs-Mail</strong> mit einem<br />  tokengeschützten Confirm-Link; eine <strong>öffentliche</strong> Confirm-Route setzt sie auf <code>confirmed</code>. Unbestätigte<br />  Adressen werden <strong>nie</strong> in Kampagnen angeschrieben.<br />- <strong>Templates</strong> — Betreff + HTML/Text-Body mit Merge-Feldern (<code>{{name}}</code>, <code>{{email}}</code>, beliebige<br />  Attribute).<br />- <strong>Kampagnen</strong> — Liste/Segment + Template wählen, anlegen, senden (oder planen). Der Versand iteriert<br />  <strong>nur bestätigte, segment-passende</strong> Abonnenten, respektiert eine Sendegrenze (<code>sendCap</code> + Tageslimit),<br />  hängt an <strong>jede</strong> Mail einen Abmeldelink und fügt Öffnungs-/Klick-Tracking hinzu.<br />- <strong>Sequenzen</strong> — geordnete Mail-Serie mit Zeitversatz; Trigger: nach Bestätigung (on-confirm).<br />- <strong>Tracking</strong> — <strong>Öffnung</strong> (1×1-Pixel über eine öffentliche Route, token = Send-ID), <strong>Klick</strong> (Links<br />  werden auf eine öffentliche Redirect-Route umgeschrieben, die den Klick erfasst und dann auf die<br />  <strong>gespeicherte</strong> Ziel-URL 302-weiterleitet — kein Open-Redirect), <strong>Bounce</strong> (Bounce-Zustand +<br />  Route/Hook zum Markieren).<br />- <strong>Segmentierung</strong> — filtere Abonnenten nach Tag/Attribut/Aktivität (geöffnet/geklickt) für die<br />  Kampagnen-Zielgruppe.</p>\n<h2>Sicherheit / Compliance (#78)</h2>\n<p>- <strong>Abmeldelink in jeder Marketing-Mail</strong> (tokengeschützte öffentliche Route, ein-Klick, ohne Login) —<br />  CAN-SPAM/GDPR, nicht verhandelbar.<br />- <strong>Double-Opt-in</strong> ist Standard; unbestätigte Adressen werden nie massenhaft angeschrieben.<br />- Tracking-Pixel / Klick-Redirect / Confirm / Unsubscribe sind <strong>öffentlich, aber tokengeschützt</strong><br />  (unrater Token → erfassen → handeln). Das Redirect-Ziel ist <strong>ausschließlich die gespeicherte URL</strong><br />  (kein Open-Redirect / SSRF über ein vom Empfänger geliefertes Ziel — es gibt keinen Ziel-Parameter im<br />  Request).<br />- Secrets (SMTP/ESP) kommen aus <strong>Aiopta-Config</strong>, nie geloggt. In Logs/URLs steht keine PII außer dem<br />  opaken Token.<br />- Ohne konfigurierte E-Mail liefert der Versand ein klares <strong>400</strong> (&quot;zuerst E-Mail konfigurieren&quot;); die<br />  Seite zeigt den <code>not-configured</code>-EmptyState (CTA → <code>/settings/connections</code>), nie ein 500.<br />- <strong>Pro-Benutzer-Isolation (#142 / INBOX-59, ADR-054):</strong> jede Liste, jeder Abonnent, jedes Template,<br />  jede Kampagne/Sequenz/Send/Tracking-Zeile trägt <code>user_id NOT NULL</code> und gehört dem **erstellenden<br />  Benutzer** (server-seitig aus <code>auth.id</code> gestempelt, ein Client-Owner wird ignoriert). Lesen/Ändern/<br />  Senden/Löschen ist auf die <strong>eigenen</strong> Daten gescoped; eine fremde id ist ein 404 (nie 403). Das<br />  Tageslimit ist pro Benutzer. Die <strong>öffentlichen, tokengeschützten</strong> Routen sind die einzige Ausnahme<br />  (login-frei, nur durch den unraten Token autorisiert).</p>\n<h2>Versand-Konfiguration (Aiopta addon-config, wie lead-gen)</h2>\n<p>SMTP: <code>SMTP_HOST</code>, <code>SMTP_PORT</code>, <code>SMTP_USER</code>, <code>SMTP_PASS</code>, <code>SMTP_SECURE</code>, <code>SMTP_FROM</code> — <strong>oder</strong> ein ESP:<br /><code>ESP_PROVIDER</code> + <code>ESP_API_KEY</code> + <code>ESP_FROM</code>. Optional: <code>SEND_DAILY_LIMIT</code> (Standard 2000). Secrets werden<br />mit <code>is_secret=1</code> gespeichert, maskiert, nie geloggt. (Siehe OPEN-DECISIONS <code>email-marketing-mail-config</code>.)</p>\n<h2>Architektur</h2>\n<p>- <strong>Quelle der Wahrheit:</strong> lokale SQLite (<code>email_marketing_*</code>), erstellt in <code>install()</code>, entfernt in<br />  <code>uninstall()</code> über Aiopta instances <code>getDb()</code>.<br />- <strong>Mail-Pfad:</strong> <code>src/lib/sender.ts</code> liest die Config wie lead-gen (<code>getAddonConfiguration</code>) und versendet<br />  per <strong>nodemailer</strong> (lazy import) — kein neuer SMTP-Layer.<br />- <strong>Pure Kerne (unit-getestet, ohne DB):</strong> Opt-in-Statusmaschine, Merge-Felder, Segment-Prädikat,<br />  Abmelde-Footer, Klick-Link-Rewrite (gespeicherte Ziel-URL).<br />- <strong>Öffentliche Routen:</strong> <code>src/proxy.ts</code> (<code>isEmailMarketingPublic</code>) lässt GETs unter <code>/public/</code> ohne<br />  Credential durch — derselbe Mechanismus wie der file-hosting-Serve-Read.</p>\n<h2>Owner-abhängig (OPEN-DECISIONS)</h2>\n<p>- <strong>Zustellbarkeit</strong> (Sending-Domain + SPF/DKIM/DMARC) — der Owner stellt die Mail-Infrastruktur; das<br />  Addon versendet über die konfigurierte SMTP/ESP.<br />- <strong>Bounce-Ingestion</strong> (IMAP-Postfach oder Provider-Webhook) — der Bounce-<strong>Zustand</strong> + die<br />  <code>POST /sends/:id/bounce</code>-Route/Hook shippen heute; der Ingestion-Transport ist Owner-Infra.</p>\n<p>Beides ist <strong>nicht blockierend</strong>: das Addon läuft graceful ohne sie.</p>\n<h2>Tests</h2>\n<p><code>addons/email-marketing/tests/unit</code> — Store (Opt-in-Statusmaschine + Double-Opt-in-Confirm-Flip,<br />confirmed-only-Audience, Tracking, Bounce-Flip), Sender (gemockte SMTP: confirmed-only, Abmeldelink in<br />jeder Mail, Cap, Klick-Rewrite gespeicherte URL, ohne SMTP → graceful 400), öffentliche Routen<br />(Confirm/Unsubscribe-Flip, Open-Pixel, Klick-Redirect nur auf gespeicherte URL + fremdes Ziel abgelehnt),<br /><strong>Evil-User-Isolation</strong> (<code>isolation.test.ts</code> — Benutzer A/B, B sieht/ändert/sendet/löscht A&#39;s Listen/<br />Abonnenten/Templates/Kampagnen/Sequenzen nicht; Owner aus <code>auth.id</code> gestempelt; Route-Layer 401 unauth +<br />404 für fremde id), Proxy-Allow-Liste, Manifest-Audit.</p>",
          "byteSize": 5556
        }
      }
    },
    {
      "id": "erpnext",
      "uuid": "7508d49e-1225-4341-8a89-034bd53b4afb",
      "name": "ERPNext",
      "version": "0.9.0",
      "description": "Quotation-ebene für die eigene ERPNext-Instanz (frappe v16, Basis-URL in den Addon-Einstellungen) in einem dual-mode connect-Addon. Sucht Quotations ueber die WooCommerce-Bestellnummer (custom_woocommerce_order_id) oder po_no, legt Entwuerfe an (mit Riegel gegen den zweiten Beleg zur selben Bestellung), liest sie inkl. Positionen, setzt Preise je Position (Treffer über die Unterzeile oder den item_code), kommentiert Belege (geht auch am gebuchten Beleg), markiert Angebote als Verloren (am gebuchten Beleg ueber declare_enquiry_lost, am Entwurf per PUT), submitted und versendet Quotations, streamt die PDF und meldet, ob ERPNext ueberhaupt ein Mailkonto mit Ausgang hat. Drei Rechtestufen: read (lesen) < write (Preise, Verloren) < submit (submit, send). Geteilt zwischen LayerY (Auto-Slicing #1116) und Buchhaltung (#1055). Findet ausserdem die gebuchte Sales Invoice zu einer WooCommerce-Bestellung und traegt den Angebotsverweis nach (verknuepfen, nicht buchen).",
      "author": "Aiopta",
      "icon": "📄",
      "capabilities": [
        "erpnext",
        "erpnext-quotations",
        "quotations",
        "quotation-prices",
        "quotation-pdf",
        "sales"
      ],
      "tags": [],
      "download": "/artifacts/erpnext-0.9.0.zip",
      "sha256": "bea09bf02ad81c43c169d23f895264e3464c9b16877774a46379ca115bad6ee5",
      "size": 244888,
      "page": {
        "id": "erpnext",
        "path": "/addons/erpnext",
        "name": "ERPNext",
        "icon": "📄",
        "section": "Tools",
        "component": "@addons/erpnext/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/erpnext/quotation",
        "/api/v1/addons/erpnext/quotation/{name}",
        "/api/v1/addons/erpnext/quotation/{name}/items",
        "/api/v1/addons/erpnext/quotation/{name}/comment",
        "/api/v1/addons/erpnext/quotation/{name}/lost",
        "/api/v1/addons/erpnext/quotation/{name}/submit",
        "/api/v1/addons/erpnext/quotation/{name}/send",
        "/api/v1/addons/erpnext/quotation/{name}/pdf",
        "/api/v1/addons/erpnext/sales-invoice",
        "/api/v1/addons/erpnext/sales-invoice/{name}/quotation-link"
      ],
      "skills": [
        {
          "name": "erpnext-read",
          "displayName": "ERPNext: Quotation lesen",
          "description": "Sucht eine ERPNext-Quotation ueber die WooCommerce-Bestellnummer (custom_woocommerce_order_id) oder po_no und liest sie (Kunde, Positionen, Status), die PDF-URL und den dreiwertigen Befund erpMailAccount (Konto mit Ausgang: ja / nein / nicht feststellbar). Kein Treffer ist ein benanntes Ergebnis (gefunden: false), kein leerer Erfolg. Reiner Lesezugriff ueber das erpnext-Addon.",
          "permissions": [
            "addons/erpnext/read"
          ],
          "endpoint": "/api/v1/addons/erpnext/quotation/{name}",
          "scripts": []
        },
        {
          "name": "erpnext-write",
          "displayName": "ERPNext: Preise setzen & Verloren",
          "description": "Legt einen Quotation-Entwurf zu einer WooCommerce-Bestellung an (docstatus 0; ein zweiter Entwurf zur selben Bestellung wird mit 409 abgelehnt statt angelegt), setzt Preise je Position (Position ueber die Unterzeile name oder den item_code, docstatus bleibt 0) kommentiert den Beleg (auch nach dem Buchen moeglich) und markiert eine Quotation als Verloren mit Grund (gebuchter Beleg ueber declare_enquiry_lost, Entwurf per PUT; der gegangene Weg steht benannt in der Antwort). Getrennt vom Submit.",
          "permissions": [
            "addons/erpnext/read",
            "addons/erpnext/write"
          ],
          "endpoint": "/api/v1/addons/erpnext/quotation/{name}/items",
          "scripts": []
        },
        {
          "name": "erpnext-submit",
          "displayName": "ERPNext: Submit & Senden",
          "description": "Submitted eine Quotation (docstatus 0→1) oder versendet sie per E-Mail an den Kunden. Der Versand reicht auf Wunsch vorher ein, prueft das ERPNext-Ausgangskonto und meldet einen nicht erfolgten Versand offen (sent:false mit Grund) statt laut zu scheitern. Hoechste Rechtestufe — Admin-Granulieren.",
          "permissions": [
            "addons/erpnext/read",
            "addons/erpnext/write",
            "addons/erpnext/submit"
          ],
          "endpoint": "/api/v1/addons/erpnext/quotation/{name}/submit",
          "scripts": []
        }
      ],
      "mcpServers": [
        {
          "id": "erpnext-tools",
          "displayName": "ERPNext tools",
          "description": "Read-only ERPNext-Tools für Agents: Quotation lesen (Positionen, Bestellnummer, pdf-URL). Authentifiziert über die Slots baseUrl/apiToken, nie als Tool-Argument.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "erpnext-tools/server.mjs",
          "permissions": [
            "addons/erpnext/read"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [
            "baseUrl"
          ],
          "credentialRefs": [
            "apiToken"
          ],
          "archiveEntry": "erpnext/mcp/servers/erpnext-tools/server.mjs",
          "sha256": "8dc33756575425a1fd9884272f87e7deef585d56786dbfba288343939c2763e7",
          "size": 12539,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>ERPNext (erpnext)</h1>\n<p>Kapselt die <strong>Quotation-Ebene deiner ERPNext-Instanz</strong> (Frappe v16) in Aiopta —<br />als dual-mode connect-Addon ohne Container. Geteilt zwischen LayerY<br />(Auto-Slicing, #1116) und Buchhaltung (#1055); ersetzt die n8n-Workflows<br />WF05/WF06/WF03.</p>\n<blockquote><p><strong>Ohne <code>baseUrl</code> tut dieses Addon nichts.</strong> Es gibt keinen Vorgabewirt und</p><p>keinen stillen Rückfall (#1220): trage die Basis-URL deiner eigenen</p><p>ERPNext-Instanz in den Addon-Einstellungen ein, sonst antworten die Routen</p><p>mit „nicht konfiguriert&quot; — und es geht kein einziger Netzruf hinaus.</p></blockquote>\n<h2>Architektur</h2>\n<pre><code>Browser/Agent ──HTTPS──▶ Aiopta (dieses Addon) ──HTTPS──▶ ERPNext (konfigurierte baseUrl)\n                         ├─ Dashboard (native DS-Seite)   /quotation/{name}\n                         ├─ API-Routen (userRoute)         /quotation/{name}…\n                         └─ MCP-Server (erpnext-tools)    read-only</code></pre>\n<p>- <strong>Natives Dashboard</strong> (<code>src/page.tsx</code>, <code>ui.mode: &quot;aiopta&quot;</code>): Quotation<br />  über Bestellnummer (<code>po_no</code>) oder Beleg-Name suchen, Kunde/Status/docstatus<br />  und Positionen anzeigen, Preise setzen, submit, senden, Verloren markieren,<br />  PDF öffnen. Ausschließlich <code>@aiopta/ui</code>-Komponenten. Der Browser<br />  kontaktiert ERPNext nie direkt.<br />- <strong>API-Routen</strong> (<code>src/app/api/v1/addons/erpnext/*</code>): zentrale Auth via<br />  <code>userRoute</code> (Session-Cookie oder Bearer/X-API-Key Agent-Key). Es gibt<br />  <strong>eine</strong> Pfadfamilie: <code>quotation/{name}/…</code>. Der Addon-Client<br />  (<code>src/lib/erpnext-client.ts</code>) spricht Frappe-REST/<code>api/method</code> mit<br />  <code>Authorization: token &lt;api_key:api_secret&gt;</code>.<br />- <strong>Rechtestufen</strong> (Ticket #1174, #1187): <code>read</code> (lesen, PDF-URL) <code>&lt;</code><br />  <code>write</code> (Preise setzen, Verloren) <code>&lt;</code> <code>submit</code> (submit 0→1, E-Mail-Senden).<br />  Das ist der <code>tier</code> in <code>capabilityDeclarations[]</code><br />  (<code>standard</code>/<code>elevated</code>/<code>highest</code>) samt zugehöriger Permission; Durchsetzung<br />  core-seitig. **Nicht zu verwechseln mit dem Addon-Trust-Level nach<br />  ADR-062** (<code>trusted</code>/<code>standard</code>/<code>restricted</code>) — der ist instanzweit, wird<br />  vom Admin gesetzt, steht <strong>nicht</strong> im Manifest und wird am Dispatch über<br />  <code>isMethodAllowedForTrust</code> erzwungen. Bis 0.2.0 nannte diese Zeile ADR-060;<br />  das ist die ADR zur Audit-Export-Aufbewahrung und war schlicht die falsche<br />  Nummer. Die Routen erzwingen die docstatus-Invariante **kein implizites<br />  Submit**: <code>prices</code>/<code>items</code> lässt docstatus unangetastet, Submitten läuft NUR<br />  über die <code>submit</code>-Route.<br />- <strong>MCP-Server</strong> (<code>mcp/servers/erpnext-tools/server.mjs</code>, Node-STDIO):<br />  strikt <strong>read-only</strong> nach #399 (<code>toolPolicy.mode: read-only</code>) — <code>quotation_get</code><br />  und <code>quotation_find_by_po</code>. Schreibende Tools kommen erst, wenn der<br />  Vorschau/Bestätigungs-Vertrag (addons#930) etabliert ist.</p>\n<h2>Settings (settingsSchema → WebUI-Formular)</h2>\n<p>| Key | Typ | Default | Beschreibung |<br />| --- | --- | --- | --- |<br />| <code>baseUrl</code> | text | — (<strong>Pflicht</strong>) | Basis-URL <strong>deiner</strong> ERPNext-Instanz, z. B. <code>https://erp.example.org</code>. Kein Vorgabewert, kein Rückfall — siehe unten. |<br />| <code>apiToken</code> | password (secret) | — | ERPNext-API-Token, Format <code>api_key:api_secret</code>. Wird als <code>Authorization: token &lt;wert&gt;</code> versendet und verlässt den Server nie. |<br />| <code>printFormat</code> | text | `<code> (leer) | ERPNext-Print-Format für die Quotation-PDF, z. B. </code>Quotation with Item Image`. Leer = Standard-Print-Format der Instanz. |</p>\n<h2>Warum <code>baseUrl</code> Pflicht ist und keinen Default hat</h2>\n<p>Bis einschliesslich Version 0.2.0 trug <code>baseUrl</code> die Adresse **einer<br />bestimmten Installation** als Vorgabewert — und fiel bei leerem Feld still<br />darauf zurück. Das waren zwei Fehler in einem:</p>\n<p>1. **Der Wirt eines Betreibers stand im Code eines Addons, das andere<br />   installieren.** Wer das Addon aus dem Marketplace zog und das Feld nicht<br />   füllte, schickte sein <code>apiToken</code> an einen Server, der ihm nicht gehört.<br />2. <strong>Der Rückfall war still.</strong> Eine leere Einstellung führte zu einem Netzruf<br />   statt zu einer Meldung; der Aufrufer bekam 401/404 von einer fremden<br />   Instanz und suchte den Fehler bei sich.</p>\n<p>Heute gilt: leer bleibt leer. <code>callErp</code> wirft <code>ErpConfigError</code> <strong>vor</strong> jedem<br /><code>fetch</code>, der MCP-Server fails CLOSED ohne <code>AIOPTA_CONFIG_BASEURL</code>. Das<br />Beispiel <code>https://erp.example.org</code> steht in der <code>description</code> des Settings,<br />nicht in seinem <code>default</code>. Fehlt das <code>http://</code>- bzw. <code>https://</code>-Schema, ist<br />das ebenfalls ein Konfigurationsfehler mit eigener Meldung — sonst käme der<br />häufigste Tippfehler als „ERPNext nicht erreichbar&quot; heraus und man suchte bei<br />einem Server, der läuft.</p>\n<blockquote><p><strong>Wo die Pflicht durchgesetzt wird:</strong> zur <strong>Laufzeit</strong>, nicht im Formular.</p><p><code>settingsSchema</code> trägt zwar <code>required: true</code>, aber das Core-Schema für</p><p>Settings-Felder kennt diesen Schlüssel nicht</p><p>(<code>aiopta-core/src/lib/addons/manifest.ts</code>, <code>addonSettingsFieldSchema</code>) und</p><p>verwirft ihn beim Parsen — genau wie beim <code>apiToken</code>, der es seit jeher</p><p>ebenso hält. Das Formular hindert also niemanden am Speichern eines leeren</p><p>Feldes; das Addon antwortet danach mit „nicht konfiguriert&quot;.</p></blockquote>\n<h2>Das Print-Format der PDF</h2>\n<p><code>/pdf</code> und <code>/send</code> ziehen die PDF über <strong>dasselbe</strong> Print-Format. Rangfolge:</p>\n<ol><li>der Aufruf (<code>?format=…</code> bzw. Body-Feld <code>format</code>),</li><li>das Addon-Setting <code>printFormat</code>,</li><li>sonst entscheidet ERPNext (Standard-Print-Format der Instanz).</li></ol>\n<p>Warum das ein Parameter ist und keine Konstante: die LayerY-Druckzentrale<br />zeigt dem Betreiber vor dem Versand genau die PDF, die der Kunde bekommt —<br />und die kommt dort aus <code>Quotation with Item Image</code> (n8n-Workflow 09). Ohne<br />den Parameter wären Vorschau und Kundenanhang zwei verschiedene Dokumente,<br />und der Unterschied fiele erst beim Kunden auf. Der Default bleibt leer:<br />für bestehende Installationen ändert sich nichts, solange niemand das<br />Setting füllt.</p>\n<h2>Welche Position der Preis trifft</h2>\n<p><code>POST /quotation/{name}/items</code> nimmt je Eintrag <code>{name?, item_code?, rate}</code>:</p>\n<p>| Schlüssel | Trifft | Wofür |<br />| --- | --- | --- |<br />| <code>name</code> | genau <strong>eine</strong> Unterzeile (<code>items[].name</code> der Quotation, im ERP ein Hash) | der genaue Weg |<br />| <code>item_code</code> | jede Position mit dieser Artikelnummer | Rückfall für bestehende Aufrufer (#1055) |</p>\n<p>Sind beide gesetzt, gilt <code>name</code> — der genauere Schlüssel schlägt den<br />gröberen. <code>rate</code> muss eine <strong>Zahl</strong> sein; <code>&quot;36.98&quot;</code> wird abgelehnt, statt das<br />Dezimaltrennzeichen zu raten.</p>\n<p><strong>Drei Fälle, in denen kein Preis gesetzt wird — und das gesagt wird:</strong></p>\n<p>| Fall | Antwort |<br />| --- | --- |<br />| Schlüssel gehört nicht zu diesem Beleg | <code>404</code> mit Nennung des Schlüssels |<br />| Schlüssel trifft mehrere Positionen | <code>400</code> „…ist mehrdeutig&quot; |<br />| zwei Einträge treffen dieselbe Position | <code>400</code> |</p>\n<p>Die Zuordnung steht <strong>vollständig</strong> fest, bevor die erste Zeile verändert<br />wird: entweder alle Preise oder keiner. Ein halb bepreister Beleg wäre<br />schlimmer als ein unbepreister, weil er von aussen fertig aussieht.</p>\n<p>Warum das ein eigener Schlüssel sein muss (addons#1211): bei LayerY<br />nummeriert der <code>item_code</code> nur durch (<code>LaY-3D-3440-01</code>, <code>LaY-3D-3440-02</code>) und<br />sagt nichts darüber, welche Datei die Zeile meint. Zwei Positionen mit<br />vertauschten Preisen fänden über den item_code nicht auseinander — am<br />06.08.2026 hat diese Fehlerklasse 141,83 EUR statt 36,98 EUR ergeben.</p>\n<p>Für bestehende item_code-Aufrufer ändert sich genau <strong>eine</strong> Sache: trifft<br />ein item_code mehrere Zeilen, ist das jetzt ein Fehler. Bis 0.2.0 bekamen<br />dann alle diese Zeilen stillschweigend denselben Preis.</p>\n<h2>Was <code>GET /quotation/{name}</code> liefert — und was bewusst nicht</h2>\n<p>Die Antwort trägt den ERPNext-Beleg (alle Felder inkl. <code>items</code>), dazu:</p>\n<p>| Feld | Bedeutung |<br />| --- | --- |<br />| <code>pdfUrl</code> | Adresse der <code>/pdf</code>-Route dieses Belegs |<br />| <code>erpMailAccount</code> | <code>{outgoing, grund, konto}</code> — gibt es ein <code>Email Account</code> mit <code>enable_outgoing = 1</code>? |</p>\n<p><code>erpMailAccount.outgoing</code> ist <strong>dreiwertig</strong>, und das ist der Punkt:</p>\n<p>| Wert | Heisst |<br />| --- | --- |<br />| <code>true</code> | Es gibt mindestens ein Konto mit Ausgang (<code>konto</code> nennt das erste). |<br />| <code>false</code> | Es gibt keines. Eine <strong>Aussage</strong>. |<br />| <code>null</code> | Die Frage wurde <strong>nicht beantwortet</strong> — <code>grund</code> sagt warum. |</p>\n<p><code>false</code> und <code>null</code> zusammenzuwerfen wäre der teuerste Kurzschluss dieses<br />Addons. Der häufigste Grund für <code>null</code> ist ein HTTP 403 auf <code>Email Account</code>,<br />weil der API-Nutzer dieses DocType nicht lesen darf. Als <code>false</code> gemeldet<br />sähe eine funktionierende Instanz aus wie eine ohne Postausgang — und<br />niemand käme je auf die Idee, ein Recht zu prüfen.</p>\n<p>Die Zusatzfrage lässt die Route <strong>nie</strong> scheitern: wer den Beleg sehen will,<br />bekommt ihn auch dann, wenn das Mailkonto gerade unbeantwortbar ist.</p>\n<h3>Der Block <code>bestellung</code> kommt NICHT von hier (addons#1213)</h3>\n<p>Der abgelöste n8n-Workflow 08 lieferte drei Blöcke: <code>quotation</code>,<br /><code>bestellung</code> (WooCommerce) und <code>erp_mail_konto</code>. Dieses Addon liefert die<br />erste und die dritte Frage. Den **WooCommerce-Stand holt der Aufrufer beim<br /><code>wordpress</code>-Addon**:</p>\n<pre><code>GET /api/v1/addons/wordpress/orders/{order_id}</code></pre>\n<p>Warum nicht hier — drei Gründe, jeder nachprüfbar:</p>\n<p>1. <strong>Die Route existiert dort schon.</strong> <code>addons/wordpress</code> v1.2.1 führt die<br />   Capability <code>woocommerce-orders</code> und genau diese Route. Hier wäre sie eine<br />   zweite Umsetzung derselben Frage.<br />2. <strong>Es wären Shop-Zugangsdaten an einem zweiten Ort.</strong> Dieses Addon kennt<br />   heute nur <code>baseUrl</code> + <code>apiToken</code> (ERPNext). WooCommerce-Consumer-Key und<br />   -Secret hier abzulegen hiesse: dieselben Geheimnisse zweimal, mit zwei<br />   Rotationszeitpunkten.<br />3. **Eine <code>addonDependencies</code>-Kante auf <code>wordpress</code> bräche die<br />   Buchhaltung.** Nach ADR-047 endet eine unerfüllte <code>required</code>-Kante in<br />   <code>addon_dependencies_unmet</code> (HTTP 409). Dieses Addon wird zwischen LayerY<br />   (#1116) und der Buchhaltung (#1055) geteilt — letztere hat kein<br />   WooCommerce und könnte es dann nicht mehr installieren.</p>\n<p>Beide Addons liegen hinter derselben Aiopta-Adresse und derselben Auth; der<br />zweite Ruf kostet keinen neuen Zugang und kein neues Geheimnis.</p>\n<h2>Senden: wann <code>sent: false</code> kommt — und was der Aufrufer dann tun soll</h2>\n<p><code>POST /quotation/{name}/send</code> nimmt `{subject?, message?, format?,<br />submitIfDraft?}` und antwortet mit<br /><code>{name, sent, grund, submitted, docstatus, detail}</code>.</p>\n<p><strong>Zwei Antwortklassen, und sie bedeuten Verschiedenes:</strong></p>\n<p>| Antwort | Heisst | Der Aufrufer soll |<br />| --- | --- | --- |<br />| <code>200</code>, <code>sent: true</code> | ERPNext hat die Mail angelegt und in den Ausgang gestellt | fertig |<br />| <code>200</code>, <code>sent: false</code> + <code>grund</code> | <strong>Auskunft</strong> — es wurde nichts versendet, und warum | <strong>umschwenken</strong> (anderer Versandweg) |<br />| <code>404</code> / <code>502</code> | <strong>Störung</strong> — Beleg unbekannt, ERPNext nicht erreichbar, PDF kaputt | später erneut versuchen |</p>\n<p>Vier Gründe führen zu <code>sent: false</code>:</p>\n<p>1. <strong>Kein ERPNext-Ausgangskonto</strong> — kein <code>Email Account</code> mit<br />   <code>enable_outgoing = 1</code>.<br />2. <strong>Nicht feststellbar, ob eines existiert</strong> — z. B. HTTP 403, weil der<br />   API-Nutzer <code>Email Account</code> nicht lesen darf. Wer nicht weiss, ob ERPNext<br />   senden kann, darf nicht so tun, als hätte er gesendet. Der Preis dieser<br />   Strenge steht hier, damit ihn niemand suchen muss: fehlt dieses Leserecht,<br />   sendet das Addon nie, obwohl es könnte — der Grundtext nennt dann genau<br />   den Status, an dem es liegt.<br />3. <strong>Kein Empfänger</strong> — weder <code>contact_email</code> noch <code>email_id</code> am Beleg.<br />4. <strong>ERPNext hat den Versand nicht bestätigt</strong> — 2xx, aber keine<br />   Communication in der Antwort.</p>\n<p><code>sent: true</code> heisst: **ERPNext hat die Communication angelegt und in die<br />Ausgangswarteschlange gestellt.** Es heisst nicht, dass der Kunde die Mail<br />hat — das kann diese Schnittstelle nicht wissen, und sie behauptet es<br />deshalb auch nicht.</p>\n<h3><code>submitIfDraft</code></h3>\n<p>Default <code>false</code> (für bestehende Aufrufer ändert sich nichts). Mit <code>true</code><br />wird ein Entwurf (docstatus 0) <strong>vor</strong> dem Versand eingereicht; <code>submitted</code><br />in der Antwort sagt, ob das in diesem Aufruf passiert ist. Nur ein echtes<br /><code>true</code> zählt — die Zeichenkette <code>&quot;true&quot;</code> wird nicht gedeutet, weil<br />Einreichen eine Mutation ist, die sich nicht rückgängig machen lässt.</p>\n<p>Eingereicht wird <strong>vor</strong> der Mailkonto-Prüfung. Das sieht nach einer<br />verschwendeten Mutation aus, wenn danach <code>sent: false</code> kommt — ist aber die<br />richtige Reihenfolge: der Rückfallweg (Versand über den Shop) liest den<br /><strong>gebuchten</strong> Beleg. Wer zuerst das Konto prüft und dann das Einreichen<br />verweigert, macht genau den Weg unmöglich, für den er die Auskunft gibt.</p>\n<h2>Endpoints</h2>\n<p>| Methode | Pfad | Zweck | Trust |<br />| --- | --- | --- | --- |<br />| GET | <code>/quotation</code> | Beleg <strong>suchen</strong> — genau ein Schlüssel: <code>?woocommerceOrderId=…</code> oder <code>?poNo=…</code>. Kein Treffer ist <code>200 {gefunden:false}</code>, <strong>kein</strong> 404 | <code>addons/erpnext/quotation/read</code> |<br />| POST | <code>/quotation</code> | Entwurf <strong>anlegen</strong> (<code>docstatus</code> 0), Body <code>{woocommerceOrderId, kunde, kontaktEmail?, kundenAnschrift?, positionen:[{item_code, qty, item_name?, description?, rate?}]}</code>. Gibt es den Beleg schon → <strong>409</strong> <code>quotation_existiert_bereits</code> mit dem vorhandenen Namen | <code>addons/erpnext/quotation/write</code> |<br />| GET | <code>/quotation/{name}</code> | Beleg lesen (Kunde, Positionen, <code>pdfUrl</code>, <code>erpMailAccount</code>) | <code>addons/erpnext/quotation/read</code> |<br />| POST | <code>/quotation/{name}/items</code> | Preise je Position, Body <code>{po_no, items:[{name?, item_code?, rate}]}</code> — <strong>kein</strong> implizites Submit | <code>addons/erpnext/quotation/write</code> |<br />| POST | <code>/quotation/{name}/comment</code> | Beleg kommentieren, Body <code>{inhalt, autorEmail?, autorName?}</code> — geht <strong>auch am gebuchten Beleg</strong> | <code>addons/erpnext/quotation/write</code> |<br />| POST | <code>/quotation/{name}/lost</code> | Verloren markieren mit Grund; Antwort trägt <code>weg</code> (<code>put</code> \\| <code>declare_enquiry_lost</code>) | <code>addons/erpnext/quotation/write</code> |<br />| GET | <code>/quotation/{name}/pdf</code> | PDF streamen (optional <code>?format=&lt;Print Format&gt;</code>) | <code>addons/erpnext/quotation/read</code> |<br />| POST | <code>/quotation/{name}/submit</code> | Submit (docstatus 0→1) | <code>addons/erpnext/quotation/submit</code> |<br />| GET | <code>/sales-invoice</code> | Gebuchte Rechnung zur Bestellung suchen, <code>?woocommerceOrderId=…</code> (Filter enthält <code>docstatus = 1</code>) | <code>addons/erpnext/quotation/read</code> |<br />| POST | <code>/sales-invoice/{name}/quotation-link</code> | Angebotsverweis <code>custom_quotation</code> nachtragen, Body <code>{angebot}</code>; Antwort trägt <code>geschrieben</code> | <code>addons/erpnext/quotation/write</code> |<br />| POST | <code>/quotation/{name}/send</code> | PDF per E-Mail senden, Body <code>{subject?, message?, format?, submitIfDraft?}</code>, Antwort <code>{sent, grund, submitted, docstatus}</code> | <code>addons/erpnext/quotation/submit</code> |</p>\n<h3>Zwei Suchschlüssel, weil es zwei Erzeuger gibt (addons#1316)</h3>\n<p><code>po_no</code> ist die Bestellnummer, über die die Buchhaltung sucht (#1055).<br /><code>custom_woocommerce_order_id</code> ist der Schlüssel der LayerY-Strecke: n8n-Workflow<br />01 legt den Entwurf damit an und schreibt <strong>kein</strong> <code>po_no</code> (gemessen am<br />12.08.2026 an der aktiven Fassung).</p>\n<p>Bis 0.6.1 kannte das Addon nur <code>po_no</code>. Eine Suche nach dem LayerY-Entwurf lief<br />damit ins Leere und meldete „nicht gefunden&quot; — und wer daraus „also noch keiner<br />da&quot; schliesst, legt einen <strong>zweiten</strong> Entwurf für denselben Kunden an. Beide<br />sehen richtig aus, beide tragen dieselbe Bestellung, und welcher dem Kunden<br />geschickt wird, entscheidet der Zufall.</p>\n<p>Der Riegel gegen den zweiten Beleg sitzt deshalb in <code>POST /quotation</code> selbst und<br />nicht im Aufrufer. <strong>Er ist ein Netz, keine Ausschlussgarantie:</strong> zwei Läufer,<br />die im selben Augenblick suchen, finden beide nichts. Dass nie zwei Läufer<br />gleichzeitig laufen, ist Sache der Zuteilung (addons#1319) — hier steht es,<br />damit niemand den Riegel für die Lösung hält.</p>\n<h3>Am gebuchten Beleg ist fast alles gesperrt (addons#1317)</h3>\n<p><code>status</code> trägt an der Quotation <code>allow_on_submit = 0</code>. Ein PUT darauf endet am<br />gebuchten Beleg (<code>docstatus == 1</code>) in <strong>417</strong>. Daraus folgen zwei Dinge:</p>\n<p>* <strong><code>lost</code> kennt zwei Wege.</strong> Am Entwurf bleibt es beim Voll-Doc-PUT (in<br />  Betrieb, füllt <code>lost_reasons</code>). Am gebuchten Beleg geht es über<br />  <code>run_doc_method</code>/<code>declare_enquiry_lost</code> — die Methode schreibt per <code>db_set</code><br />  und umgeht die Sperre. n8n-Workflow 03 macht es seit jeher so und begründet<br />  es im Knoten „Status abbilden&quot;. Welcher Weg gegangen wurde, steht im Feld<br />  <code>weg</code> der Antwort, damit niemand rekonstruieren muss, warum <code>lost_reasons</code><br />  einmal gefüllt ist und einmal nicht.<br />* <strong>Ein Kommentar ist der einzige verbleibende Vermerk.</strong> Deshalb gibt es<br />  <code>/comment</code>. Ein abgelehntes <code>declare_enquiry_lost</code> (z. B. Angebot bereits<br />  „Ordered&quot;) wird gemeldet und geht <strong>nie</strong> als Erfolg durch — sonst gälte eine<br />  Absage als verbucht, die nie ankam.</p>\n<h3>Sales Invoice: verknüpfen, nicht buchen (addons#1318)</h3>\n<p>ERPNext kennt die Kette Angebot → Auftrag → Rechnung. Bei LayerY entsteht die<br />Rechnung aber <strong>direkt aus der Shop-Bestellung</strong>, weil der bestehende<br />Bestell-Sync das seit jeher so macht — samt Zahlungseingang und<br />Anbietergebühr. Eine zusätzliche Rechnung aus dem Angebot heraus wäre eine<br /><strong>Doppelbuchung</strong>. Deshalb verbindet <code>quotation-link</code> nur, was ohnehin schon<br />da ist. (Die Begründung steht als Sticky Note auch in n8n-Workflow 04.)</p>\n<p>Drei Fälle, drei Antworten, keiner davon still:</p>\n<p>| Zustand an der Rechnung | Antwort |<br />| --- | --- |<br />| <code>custom_quotation</code> leer | schreiben, <code>geschrieben: true</code> |<br />| schon derselbe Verweis | nichts tun, <code>geschrieben: false</code> — ein Treffer, kein Fehler |<br />| ein <strong>anderer</strong> Verweis | <strong>409</strong> <code>verweis_zeigt_woandershin</code>, nichts wird überschrieben |</p>\n<p>Der zweite Fall ist kein Luxus: Workflow 04 läuft alle 15 Minuten über<br />dieselbe Bestellliste. Der dritte ist der teure — ein überschriebener Verweis<br />sähe hinterher aus wie ein richtiger, und welche Zuordnung die gewollte war,<br />liesse sich nicht mehr rekonstruieren.</p>\n<p>Geschrieben wird <strong>nur dieses eine Feld</strong>, nie das ganze Doc: die Rechnung ist<br />gebucht, ein Voll-Doc-PUT liefe in dieselbe <code>allow_on_submit</code>-Sperre wie bei<br /><code>lost</code> (addons#1317).</p>\n<p>Das sind <strong>alle</strong> Routen des Addons — je eine Zeile je <code>route.ts</code> unter<br /><code>src/app/api/v1/addons/erpnext/</code>. Gemessen an <code>origin/develop</code>: sechs<br /><code>route.ts</code>, alle unter <code>quotation/</code> (Einzahl).</p>\n<p>Es gibt <strong>keine</strong> zweite Pfadfamilie: <code>/quotations/…</code> (mit <code>s</code>) hat nie<br />existiert — <code>git ls-tree</code> findet dafuer null Dateien. Die Zeilen dafuer standen<br />bis addons#1221 hier, samt der Bezeichnung „Alias&quot; fuer die einzigen echten<br />Routen. Wer danach debuggt hat, suchte eine 404, die das Addon nie vorgesehen<br />hat.</p>\n<h2>MCP-Server (read-only)</h2>\n<ul><li><code>AIOPTA_CONFIG_BASEURL</code> → ERPNext-Basis-URL (kein Default)</li><li><code>AIOPTA_CREDENTIAL_APITOKEN</code> → <code>api_key:api_secret</code> (nie als Argument)</li></ul>\n<p>Slots werden vom Core injiziert; ohne aufgelösten <code>apiToken</code> <strong>oder</strong> ohne<br />aufgelöste <code>baseUrl</code> fails jedes Tool CLOSED mit „nicht konfiguriert&quot; (nie<br />erfundene Daten, und kein Netzruf an einen geratenen Wirt). Handshake und<br /><code>tools/list</code> funktionieren weiterhin ohne jede Env.</p>\n<h2>Permissions</h2>\n<p>Zwei Familien, und sie sind <strong>nicht</strong> dasselbe (addons#1187):</p>\n<p>Die <strong>Routen</strong> hängen an den quotation-eigenen Rechten aus<br /><code>capabilityDeclarations[]</code> — die Slash-Form der jeweiligen Capability:</p>\n<ul><li><code>addons/erpnext/quotation/read</code> — Quotations lesen, PDF, PDF-URL.</li><li><code>addons/erpnext/quotation/write</code> — Preise setzen (docstatus bleibt 0), Verloren mit Grund.</li><li><code>addons/erpnext/quotation/submit</code> — Submit (docstatus 0→1), E-Mail-Versand.</li></ul>\n<p>Die <strong>Skills</strong> und der <strong>MCP-Server</strong> führen die gröberen Addon-Rechte:</p>\n<ul><li><code>addons/erpnext/read</code> — Skill <code>erpnext-read</code>, MCP-Tools (read-only).</li><li><code>addons/erpnext/write</code> — Skill <code>erpnext-write</code>.</li><li><code>addons/erpnext/submit</code> — Skill <code>erpnext-submit</code>.</li></ul>\n<p>Die Durchsetzung für Agent-API-Keys erfolgt core-seitig (Agent-Scope-<br />Enforcement anhand der deklarierten Permissions).</p>",
          "byteSize": 19411
        }
      }
    },
    {
      "id": "error-monitoring",
      "uuid": "ebe1c125-189b-4270-9209-c6979d5c242a",
      "name": "Error Monitoring",
      "version": "1.0.3",
      "description": "Self-hosted, Sentry-like in-app error capture: unhandled client exceptions, API 500s and server errors are captured per-user, grouped by fingerprint with occurrence counts, and surfaced for acknowledgement/resolution. No external service.",
      "author": "Aiopta",
      "icon": "🐞",
      "capabilities": [
        "error-monitoring",
        "observability",
        "errors"
      ],
      "tags": [],
      "download": "/artifacts/error-monitoring-1.0.3.zip",
      "sha256": "344845d8cc4df3dbb3f0006594063c9a8384c8d2950d6705ce17b3e4bc9f0955",
      "size": 50834,
      "page": null,
      "deprecated": true,
      "replacedBy": "monitoring",
      "deprecation": {
        "deprecated": true,
        "replacedBy": "monitoring",
        "sunset": "2026-12-31",
        "successorPath": "/addons/monitoring",
        "successorLabel": "Observability (Nachfolger)"
      },
      "endpoints": [
        "/api/v1/addons/error-monitoring/errors",
        "/api/v1/addons/error-monitoring/resolve",
        "/api/v1/addons/error-monitoring/stats"
      ],
      "skills": [
        {
          "name": "error-monitoring-read",
          "displayName": "Error Monitoring: read captured errors",
          "description": "List recent captured errors and aggregated error stats for the user.",
          "permissions": [
            "addons/error-monitoring/errors",
            "addons/error-monitoring/stats"
          ],
          "endpoint": "/api/v1/addons/error-monitoring/errors",
          "scripts": []
        },
        {
          "name": "error-monitoring-manage",
          "displayName": "Error Monitoring: capture & resolve errors",
          "description": "Capture an in-app error and acknowledge or resolve captured errors for the user.",
          "permissions": [
            "addons/error-monitoring/errors",
            "addons/error-monitoring/resolve"
          ],
          "endpoint": "/api/v1/addons/error-monitoring/errors",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": null
    },
    {
      "id": "etsy",
      "uuid": "27b03d6b-6e37-4f5b-a06d-0c4f14f83967",
      "name": "Etsy",
      "version": "2.0.3",
      "description": "DEPRECATED — zusammengeführt in das kanonische Commerce-Addon `commerce` als Etsy-Connector (ADR-008 Addon-Konsolidierung, Issue #589 / Epic #492). Der Etsy-Channel ist als Feature-Modul unter `commerce/src/connectors/etsy/` migriert: BYOK-Credentials in der gemeinsamen `commerce_connector_config`-Tabelle, Sync-Client auf dem kanonischen `CommerceChannel`-Vertrag, Alt-ID-Redirect `/addons/etsy` → `/addons/commerce?connector=etsy`. etsy bleibt bis zum Sunset (2026-12-31) voll funktionsfähig und datenerhaltend — kein Endpoint entfällt, keine Zeile wird kopiert/gelöscht. Ursprüngliche Fähigkeit: Commerce channel: sync listings, stock & orders from your Etsy shop via the OpenAPI v3 REST API (external SaaS). Each user brings their own API key + OAuth2 access token (BYOK) stored per-user through the scoped data-access layer (ADR-054); no secret is hard-coded or logged and there is no container. Sync is always an explicit call.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "commerce-etsy",
        "etsy-products",
        "etsy-stock",
        "etsy-orders",
        "etsy-sync"
      ],
      "tags": [],
      "download": "/artifacts/etsy-2.0.3.zip",
      "sha256": "379c1591a69b7026a1efa8fef80d31d870566d41de4d4a44202efa851854829c",
      "size": 93641,
      "page": {
        "id": "etsy-page",
        "path": "/addons/etsy",
        "name": "Etsy",
        "icon": "🎨",
        "section": "Money",
        "component": "@addons/etsy/src/page"
      },
      "deprecated": true,
      "replacedBy": "commerce",
      "deprecation": {
        "deprecated": true,
        "replacedBy": "commerce",
        "sunset": "2026-12-31",
        "successorPath": "/addons/commerce",
        "successorLabel": "Commerce (Nachfolger)"
      },
      "endpoints": [
        "/api/v1/addons/etsy/status",
        "/api/v1/addons/etsy/config",
        "/api/v1/addons/etsy/products",
        "/api/v1/addons/etsy/stock",
        "/api/v1/addons/etsy/orders",
        "/api/v1/addons/etsy/sync"
      ],
      "skills": [
        {
          "name": "etsy-sync",
          "displayName": "Etsy: Sync listings / stock / orders",
          "description": "Trigger an explicit sync of the caller's Etsy shop (listings, stock and orders/receipts) via the OpenAPI v3 API using the user's BYOK credentials.",
          "permissions": [
            "addons/etsy/sync",
            "addons/etsy/products",
            "addons/etsy/orders"
          ],
          "endpoint": "/api/v1/addons/etsy/sync",
          "scripts": []
        },
        {
          "name": "etsy-products",
          "displayName": "Etsy: Listings & Stock",
          "description": "List the caller's cached Etsy listings and stock levels, or write back a stock level to Etsy.",
          "permissions": [
            "addons/etsy/products",
            "addons/etsy/stock"
          ],
          "endpoint": "/api/v1/addons/etsy/products",
          "scripts": []
        },
        {
          "name": "etsy-orders",
          "displayName": "Etsy: Orders",
          "description": "List the caller's cached Etsy orders/receipts.",
          "permissions": [
            "addons/etsy/orders"
          ],
          "endpoint": "/api/v1/addons/etsy/orders",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": null
    },
    {
      "id": "file-hosting",
      "uuid": "94c186f9-93e5-4e38-90c0-672fa0940142",
      "name": "Files",
      "version": "2.0.4",
      "description": "Files: library (documents) + hosting/upload/public-URL. Canonical Files addon (ADR-008) consolidating the former documents addon, with upload, download, and public URL generation using Aiopta's Next.js API routes.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "file-upload",
        "file-download",
        "url-generation",
        "token-based-access",
        "documents",
        "workspace",
        "agent-files"
      ],
      "tags": [],
      "download": "/artifacts/file-hosting-2.0.4.zip",
      "sha256": "d10cbb40f712705cabee74c65d45ea40f9024f513c54c0f18a22c91c20a01c27",
      "size": 154328,
      "page": {
        "path": "/addons/file-hosting",
        "name": "Files",
        "icon": "📁",
        "section": "Tools",
        "component": "@addons/file-hosting/src/components/FileHostingPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/file-hosting/upload",
        "/api/v1/addons/file-hosting/files",
        "/api/v1/addons/file-hosting/list",
        "/api/v1/addons/file-hosting/file/:id",
        "/api/v1/addons/file-hosting/serve/:id",
        "/api/v1/addons/file-hosting/config",
        "/api/v1/addons/file-hosting/agent-files"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>File Hosting Addon</h1>\n<p>A file hosting service addon for Aiopta that provides file upload, download, and public URL generation capabilities using Aiopta&#39;s Next.js API routes.</p>\n<h2>Features</h2>\n<ul><li>📤 <strong>File Upload</strong>: Upload files with automatic validation (size, type)</li><li>📥 <strong>File Download</strong>: Access files via unique IDs through Next.js API routes</li><li>🔗 <strong>Public URL Generation</strong>: Generate public URLs with optional token-based access</li><li>🔒 <strong>Token-based Access</strong>: Optional token protection for file downloads using Aiopta&#39;s API key system</li><li>📊 <strong>Metadata Tracking</strong>: Store file metadata (name, size, MIME type, checksum)</li><li>⚙️ <strong>Configurable</strong>: Customize upload directory, file size limits, and allowed types</li><li>🌐 <strong>Next.js Integration</strong>: Uses Aiopta&#39;s Next.js web server (no standalone server)</li><li>🔒 <strong>Type Safety</strong>: Full TypeScript support</li></ul>\n<h2>Configuration</h2>\n<h3>Default Configuration</h3>\n<pre><code class=\"lang-json\">{\n  &quot;publicUrl&quot;: &quot;http://localhost:3000&quot;,\n  &quot;requireToken&quot;: true,\n  &quot;uploadDir&quot;: &quot;data/uploads&quot;,\n  &quot;maxFileSize&quot;: 104857600,\n  &quot;allowedMimeTypes&quot;: [\n    &quot;image/jpeg&quot;,\n    &quot;image/png&quot;,\n    &quot;image/gif&quot;,\n    &quot;image/webp&quot;,\n    &quot;application/pdf&quot;,\n    &quot;text/plain&quot;,\n    &quot;text/markdown&quot;,\n    &quot;application/json&quot;,\n    &quot;application/zip&quot;,\n    &quot;application/x-zip-compressed&quot;\n  ]\n}</code></pre>\n<h3>Configuration Options</h3>\n<p>| Option | Type | Default | Description |<br />|--------|------|---------|-------------|<br />| <code>publicUrl</code> | string | <code>&quot;http://localhost:3000&quot;</code> | The public base URL for file access |<br />| <code>requireToken</code> | boolean | <code>true</code> | Whether authentication is required |<br />| <code>uploadDir</code> | string | <code>&quot;data/uploads&quot;</code> | Directory to store uploaded files |<br />| <code>maxFileSize</code> | number | <code>104857600</code> (100MB) | Maximum file size in bytes |<br />| <code>allowedMimeTypes</code> | string[] | See default | List of allowed MIME types |</p>\n<h2>API Endpoints</h2>\n<p>### Upload File<br /><strong>POST</strong> <code>/api/v1/addons/file-hosting/upload</code><br />Upload a file and get its ID and public URL.</p>\n<p>### Get File Info<br /><strong>GET</strong> <code>/api/v1/addons/file-hosting/file/:id</code><br />Get file metadata and download URL.</p>\n<p>### Download File<br /><strong>GET</strong> <code>/api/v1/addons/file-hosting/serve/:id?token={apiKey}</code><br />Download the file. Token can be provided via query param, Authorization header, or X-API-Key header.</p>\n<p>### Delete File<br /><strong>DELETE</strong> <code>/api/v1/addons/file-hosting/file/:id</code><br />Delete a file by ID.</p>\n<p>### Get Configuration<br /><strong>GET</strong> <code>/api/v1/addons/file-hosting/config</code><br />Get the current addon configuration.</p>\n<p>### Update Configuration<br /><strong>POST</strong> <code>/api/v1/addons/file-hosting/config</code><br />Update the addon configuration.</p>\n<h2>Examples</h2>\n<p><strong>Upload:</strong><br />```bash<br />curl -X POST http://localhost:3000/api/v1/addons/file-hosting/upload \\<br />  -F &quot;file=@document.pdf&quot;<br />```</p>\n<p><strong>Download with token:</strong><br />```bash<br />curl &quot;http://localhost:3000/api/v1/addons/file-hosting/serve/{id}?token={your_api_key}&quot; -o file.pdf<br />```</p>\n<h2>Architecture</h2>\n<p>This addon uses Aiopta&#39;s Next.js web server for all file serving operations. No standalone server is required.</p>\n<h3>Token-based Access</h3>\n<p>When <code>requireToken: true</code>, files can only be accessed with valid authentication:<br />1. Query Parameter: <code>?token={apiKey}</code><br />2. Authorization Header: <code>Authorization: Bearer {apiKey}</code><br />3. X-API-Key Header: <code>X-API-Key: {apiKey}</code></p>\n<p>The token must be a valid Aiopta API key.</p>",
          "byteSize": 3187
        }
      }
    },
    {
      "id": "forgejo",
      "uuid": "1e4f363b-6556-4e35-b5e4-9500c03082ea",
      "name": "Forgejo",
      "version": "1.2.4",
      "description": "Manage Forgejo / Gitea instances from Aiopta: list repositories, issues, pull requests, projects, milestones, releases, and labels through a single dashboard, plus a per-user instance picker for admins and end-users. Slice #227 ships the skeleton (manifest + page shell + SKILL.md placeholder). Slice #228 ships the typed HTTP client with token-redaction and retry semantics. Slice #229 ships the instance picker (admin + user, AES-GCM at-rest token encryption, per-user isolation, connectivity test, empty-state CTA — no defaults). Slice #230 ships the read-dashboard (filter engine, priority mapper, LRU cache with stampede protection, and the API routes + UI components for repos/issues/PRs tabs). Slice #231 ships the Issue/PR write API on top of the read dashboard (CRUD, comments, labels, reviews, merge, approval gate, idempotency, job pattern). Projects CRUD in #232, SKILL.md freeze in #233.",
      "author": "Aiopta",
      "icon": "🦊",
      "capabilities": [
        "forgejo",
        "gitea",
        "repositories",
        "issues",
        "pull-requests",
        "projects",
        "milestones",
        "releases",
        "labels"
      ],
      "tags": [],
      "download": "/artifacts/forgejo-1.2.4.zip",
      "sha256": "4742b3e634ea274f3630cba6a5c40d9af88aa15a7eea644c9ab10ddbd27f50c5",
      "size": 917022,
      "page": [
        {
          "id": "forgejo",
          "path": "/addons/forgejo",
          "name": "Forgejo",
          "icon": "🦊",
          "section": "Dev",
          "component": "@addons/forgejo/src/components/ForgejoDashboard"
        }
      ],
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/forgejo/repos",
        "/api/v1/addons/forgejo/issues",
        "/api/v1/addons/forgejo/pulls",
        "/api/v1/addons/forgejo/projects",
        "/api/v1/addons/forgejo/labels",
        "/api/v1/addons/forgejo/members",
        "/api/v1/addons/forgejo/cache/stats",
        "/api/v1/addons/forgejo/issues/{instance}/{repo}",
        "/api/v1/addons/forgejo/issues/{instance}/{repo}/{index}",
        "/api/v1/addons/forgejo/issues/{instance}/{repo}/{index}/state",
        "/api/v1/addons/forgejo/issues/{instance}/{repo}/{index}/comments",
        "/api/v1/addons/forgejo/issues/{instance}/{repo}/{index}/labels",
        "/api/v1/addons/forgejo/issues/{instance}/{repo}/{index}/assignees",
        "/api/v1/addons/forgejo/issues/bulk/label",
        "/api/v1/addons/forgejo/pulls/{instance}/{repo}/{index}",
        "/api/v1/addons/forgejo/pulls/{instance}/{repo}/{index}/files",
        "/api/v1/addons/forgejo/pulls/{instance}/{repo}/{index}/commits",
        "/api/v1/addons/forgejo/pulls/{instance}/{repo}/{index}/reviews",
        "/api/v1/addons/forgejo/pulls/{instance}/{repo}/{index}/merge",
        "/api/v1/addons/forgejo/pulls/{instance}/{repo}/{index}/state",
        "/api/v1/addons/forgejo/jobs/{id}",
        "/api/v1/addons/forgejo/instances",
        "/api/v1/addons/forgejo/instances/{id}",
        "/api/v1/addons/forgejo/instances/{id}/test",
        "/api/v1/addons/forgejo/admin/instances",
        "/api/v1/addons/forgejo/admin/instances/{id}"
      ],
      "skills": [],
      "mcpServers": [
        {
          "id": "forgejo-tools",
          "displayName": "Forgejo tools",
          "description": "Read-only Forgejo/Gitea companion tools for agents: decode/encode the dashboard issue/PR filter wire format, map priorities to label names, detect issue priority, redact credential patterns from text, and validate instance base URLs and labels - faithful ports of the addon's src/lib modules (issue #420, epic #399). Mutates nothing; live per-user Forgejo access is resolved core-side via the declared config/credential slots.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "forgejo-tools/server.mjs",
          "permissions": [
            "addons/forgejo/read"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [
            "baseUrl"
          ],
          "credentialRefs": [
            "token"
          ],
          "archiveEntry": "forgejo/mcp/servers/forgejo-tools/server.mjs",
          "sha256": "8f479e5a96d349cd12582a4f026721f1e5c27941808fe59971a1d3097500cbc4",
          "size": 29917,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Forgejo Addon</h1>\n<p>Aiopta integration for <strong>Forgejo</strong> and <strong>Gitea</strong>. Manage repositories,<br />issues, pull requests, projects, milestones, releases, and labels<br />across one or more Forgejo / Gitea instances — both from the dashboard<br />UI (<code>/addons/forgejo</code>) and through the Aiopta agent skill<br />(<code>SKILL.md</code>).</p>\n<blockquote><p><strong>Status: 1.0.0.</strong> Slices #227 (skeleton), #228 (typed client +</p><p>token-redaction + retry), and #230 (read-dashboard: filter engine,</p><p>priority mapper, LRU cache with stampede protection, API routes, and</p><p>UI components) are landed. Slice #233 (SKILL.md + agent discovery</p><p>smoke + README polish) is finalising the agent-surface + the</p><p>1.0.0 manifest. Instance picker (#229), write API (#231), and</p><p>projects / milestones / releases / labels admin (#232) are tracked</p><p>as separate issues — see the Roadmap below.</p></blockquote>\n<h2>Overview</h2>\n<p>- <strong>Addon ID:</strong> <code>forgejo</code><br />- <strong>Version:</strong> <code>1.0.0</code> (slice #228 client + slice #230 read-dashboard<br />  + slice #233 SKILL.md agent surface)<br />- <strong>Capabilities:</strong> <code>forgejo</code>, <code>gitea</code>, <code>repositories</code>, <code>issues</code>,<br />  <code>pull-requests</code>, <code>projects</code>, <code>milestones</code>, <code>releases</code>, <code>labels</code><br />- <strong>UI Page:</strong> <code>/addons/forgejo</code><br />- <strong>Author:</strong> Aiopta<br />- <strong>License:</strong> Per the surrounding Aiopta addon marketplace terms</p>\n<h2>Why Forgejo + Gitea share one client</h2>\n<p>Forgejo&#39;s HTTP API is API-compatible with Gitea&#39;s. A single client<br />serves both — no parallel code path. The addon assumes upstream<br />versions <code>&gt;= Forgejo 1.20 / Gitea 1.20</code>; older instances will receive<br />a <code>426 Upgrade Required</code> response from the concrete endpoints (see<br /><code>forgejo-client.ts</code>).</p>\n<h2>Roadmap</h2>\n<p>| Issue | Title | Status | Slice |<br />|---|---|---|---|<br />| #227 | Forgejo-Addon skeleton | ✅ | manifest, page shell, docs |<br />| #228 | TypeScript client + auth wrapper (token redaction, retry) | ✅ | core client |<br />| #229 | Instance management (admin + user, scoped storage, RBAC, encrypted tokens) | ⏳ | instance picker |<br />| #230 | Read dashboard with repos/issues/PRs + filter engine | ✅ | dashboard backend + UI |<br />| #231 | Issue/PR detail + write API (CRUD, comments, labels, reviews, merge, approval gate) | ⏳ | write surface |<br />| #232 | Projects + milestones + releases + labels admin | ⏳ | board surface |<br />| #233 | SKILL.md + agent discovery smoke + README polish | ✅ | agent surface (this slice) |</p>\n<h2>Setup</h2>\n<p>The Forgejo addon does <strong>not</strong> require any environment variables at<br />install time. The HTTP client is instantiated lazily when the user<br />configures a per-user instance (slice #229 lands the instance picker<br />and the encrypted-token storage). Until then, an env-var-backed<br />default instance store lets the dashboard run against a single<br />configured instance.</p>\n<p>Runtime data that the addon needs to operate:</p>\n<p>| Source | Where it lives | Notes |<br />|--------|----------------|-------|<br />| Per-user instance URL | caller&#39;s per-user data root (<code>&lt;DATA_DIR&gt;/addons/forgejo/&lt;userId&gt;/instances.json</code>) | Configured via the dashboard. No hardcoded default. |<br />| Per-user access token | same root, encrypted at rest | Slice #229. |<br />| Upstream Forgejo / Gitea version | n/a | Required <code>&gt;= 1.20</code>. Older versions emit <code>426 Upgrade Required</code>. |</p>\n<h3>Env-var fallback (slice #230 — interim)</h3>\n<p>For the env-var-backed default instance store, set:</p>\n<pre><code class=\"lang-bash\">export FORGEJO_INSTANCE_&lt;ID&gt;_URL=&quot;https://your-forgejo-host.example&quot;\nexport FORGEJO_INSTANCE_&lt;ID&gt;_TOKEN=&quot;ghp_…&quot;\n# Optional: override the priority label prefix (default: &quot;priority:&quot;)\nexport FORGEJO_PRIORITY_PREFIX=&quot;prio&quot;\n# Optional: override the read-cache TTL (default: 60s)\nexport FORGEJO_READ_CACHE_TTL_SECONDS=120</code></pre>\n<p>Slice #229 will replace the env-var backend with a DB-backed store<br />without changing the <code>instance-store.ts</code> interface.</p>\n<h2>Endpoint overview</h2>\n<p>The agent skill (<code>SKILL.md</code>) is the canonical endpoint cheat-sheet<br />for the agent. The manifest&#39;s <code>endpoints</code> array mirrors the slice<br />#228 baseline of the typed client — every endpoint listed there is<br />reachable from <code>@addons/forgejo/src/lib/forgejo-client.ts</code> today.<br />The slice #230 read-dashboard exposes an additional Aiopta-side<br />aggregation surface under <code>/api/v1/addons/forgejo/*</code>.</p>\n<h3>Upstream Forgejo REST API (slice #228 typed client)</h3>\n<p>| Resource | Paths |<br />|----------|-------|<br />| User | <code>/user</code>, <code>/user/repos</code>, <code>/user/projects</code> |<br />| Repos | <code>/repos/{owner}/{name}</code> |<br />| Issues | <code>/repos/{repo}/issues</code>, <code>/repos/{repo}/issues/{number}</code>, <code>/repos/{repo}/issues/{number}/comments</code> |<br />| Pulls | <code>/repos/{repo}/pulls</code>, <code>/repos/{repo}/pulls/{number}</code> |<br />| Projects | <code>/repos/{repo}/projects</code>, <code>/user/projects</code> |<br />| Milestones | <code>/repos/{repo}/milestones</code> |<br />| Releases | <code>/repos/{repo}/releases</code> |<br />| Labels | <code>/repos/{repo}/labels</code> |</p>\n<h3>Aiopta-side dashboard routes (slice #230 read-dashboard)</h3>\n<p>| Method | Path | Purpose |<br />| ------ | ---- | ------- |<br />| GET | <code>/api/v1/addons/forgejo/repos?instance=&amp;search=</code> | List repos for the active instance |<br />| GET | <code>/api/v1/addons/forgejo/issues?&lt;filter&gt;</code> | List issues for a repo |<br />| GET | <code>/api/v1/addons/forgejo/pulls?&lt;filter&gt;</code> | List PRs for a repo |<br />| GET | <code>/api/v1/addons/forgejo/projects?instance=&amp;repo=</code> | Project boards |<br />| GET | <code>/api/v1/addons/forgejo/labels?instance=&amp;repo=</code> | Repo labels (filter dropdown source) |<br />| GET | <code>/api/v1/addons/forgejo/members?instance=&amp;org=</code> | Org members (author / assignee source) |<br />| GET | <code>/api/v1/addons/forgejo/cache/stats</code> | Cache diagnostic counts |</p>\n<p>Future slices extend this surface:</p>\n<p>- <strong>#229</strong> — per-user instance picker (local-only routes, no new<br />  upstream endpoints)<br />- <strong>#231</strong> — write API + approval gate (consumes the slice #228<br />  <code>create</code> / <code>update</code> / <code>addComment</code> endpoints; adds<br />  <code>POST /repos/{repo}/pulls/{number}/merge</code>)<br />- <strong>#232</strong> — projects / milestones / releases / labels admin (adds<br />  <code>POST /user/projects</code>, <code>PATCH /projects/{id}</code>, and label CRUD<br />  against <code>/repos/{repo}/labels</code>)</p>\n<h2>Slice #230 — what ships here</h2>\n<h3>Library (<code>addons/forgejo/src/lib/</code>)</h3>\n<p>- <strong><code>issue-filters.ts</code></strong> — typed <code>IssueFilter</code> + URL codec.<br />  Multi-value groups use repeated keys (<code>?labels=bug&amp;labels=urgent</code>)<br />  so labels containing commas, colons, or Unicode roundtrip<br />  unambiguously. AND across groups, OR within each group.<br />  Defaults: <code>state=open</code>, <code>limit=50</code> (hard cap 200).<br />- <strong><code>priority-mapper.ts</code></strong> — maps priority values to label names with<br />  a configurable prefix (default <code>priority:</code>). Accepts compact<br />  <code>P0..P3</code> aliases. Detects the highest priority present on an<br />  issue&#39;s labels via <code>detect()</code>.<br />- <strong><code>read-cache.ts</code></strong> — per-user LRU cache with TTL (default 60s,<br />  overridable via <code>FORGEJO_READ_CACHE_TTL_SECONDS</code>) + stale-while-<br />  revalidate + stampede protection (100 concurrent identical<br />  requests coalesce into one upstream call). Cache keys NEVER<br />  contain the configured token; user-supplied free-text <code>q</code> is<br />  FNV-1a-hashed before key assembly.<br />- <strong><code>instance-store.ts</code></strong> — read-side contract for the per-user<br />  instance + token lookup. Ships an env-var-backed default so the<br />  dashboard runs before slice #229&#39;s DB schema lands; the<br />  implementation in #229 swaps the loader while keeping the<br />  interface intact.<br />- <strong><code>read-dashboard-api.ts</code></strong> — server-side helpers used by every<br />  read route: instance resolution, typed client construction, the<br />  shared read cache, and the <code>DashboardError</code> wire shape (<code>code</code> /<br />  <code>lastSuccessAt</code> / <code>retryAfterMs</code>).</p>\n<h3>API routes (<code>addons/forgejo/src/app/api/v1/addons/forgejo/</code>)</h3>\n<p>- <code>GET /repos?instance=&amp;search=&amp;page=&amp;limit=</code> — list repos for the<br />  active instance.<br />- <code>GET /issues?&lt;filter&gt;</code> — list issues for a repo, filtered.<br />- <code>GET /pulls?&lt;filter&gt;</code> — list PRs for a repo, filtered.<br />- <code>GET /projects?instance=&amp;repo=</code> — project boards.<br />- <code>GET /labels?instance=&amp;repo=</code> — repo labels (filter dropdown<br />  source).<br />- <code>GET /members?instance=&amp;org=</code> — org members (author / assignee<br />  typeahead).<br />- <code>GET /cache/stats</code> — diagnostic cache stats (size / hits / misses<br />  / stampedeHits / evictions / hit-ratio).</p>\n<p>All routes are <code>userRoute</code>-wrapped (auth required) and flow through<br />the shared cache + stampede helper.</p>\n<h3>UI (<code>addons/forgejo/src/components/</code> + <code>hooks/</code>)</h3>\n<p>- <strong><code>ForgejoDashboard.tsx</code></strong> — three-tab page (Repos / Issues / PRs)<br />  with instance + repo pickers. Filter state is mirrored to the URL<br />  via <code>useForgejoQuery</code> so refresh restores the exact view and the<br />  browser back/forward buttons navigate between filter histories.<br />- <strong><code>IssueFilterBar.tsx</code></strong> / <strong><code>PRFilterBar.tsx</code></strong> — combined filter<br />  bars (labels, priority, state, author, assignee, updated-range,<br />  full-text <code>q</code>).<br />- <strong><code>IssueTable.tsx</code></strong> / <strong><code>PRTable.tsx</code></strong> / <strong><code>ReposTable.tsx</code></strong> —<br />  tables with cursor-style pagination (50 default, 200 hard cap, no<br />  endless scroll in v1).<br />- <strong><code>LabelPill.tsx</code></strong> / <strong><code>PriorityPill.tsx</code></strong> — coloured chips with<br />  stable priority colour mapping (critical=red, high=orange,<br />  medium=yellow, low=gray).<br />- <strong><code>EmptyState.tsx</code></strong> — three distinguished copy variants:<br />  <code>no_matches</code>, <code>repo_empty</code>, <code>no_instance</code> — each with its own<br />  <code>data-i18n-key</code> for the v1 localisation wire.<br />- <strong><code>ErrorState.tsx</code></strong> — stable <code>code</code> / <code>lastSuccessAt</code> /<br />  <code>retryAfterMs</code> UX with a Retry button on transient errors and a<br />  &quot;Renew token&quot; CTA on <code>UNAUTHORIZED</code>.<br />- <strong><code>StaleDataBanner.tsx</code></strong> — &quot;Data from X — stale by Ys&quot; banner<br />  when the cache TTL has elapsed (stale-while-revalidate).<br />- <strong><code>useForgejoQuery</code></strong> — data-fetching hook with URL sync,<br />  background revalidation, and typed error surface.</p>\n<h3>Tests</h3>\n<p>- <code>tests/unit/issue-filters.test.ts</code> (52 tests) — Encode/Decode<br />  roundtrip + 20 random vectors + special-character handling +<br />  pagination caps.<br />- <code>tests/unit/priority-mapper.test.ts</code> (21 tests) — default prefix<br />  + custom prefix (<code>prio</code> → <code>prio:high</code>) + aliases + detection.<br />- <code>tests/unit/read-cache.test.ts</code> (20 tests) — TTL, stampede<br />  protection (100 calls → 1 upstream), token redaction invariant,<br />  LRU eviction, per-user isolation.<br />- <code>tests/unit/read-api.test.ts</code> (12 tests) — integration coverage<br />  for every read endpoint against the mock Forgejo server +<br />  <code>DashboardError</code> shape + stampede.<br />- <code>tests/e2e/dashboard.spec.ts</code> — Playwright spec covering tab<br />  navigation, three-filter combination + URL roundtrip + back-button<br />  restoration, empty-state rendering, and upstream error UX.</p>\n<h2>Architecture notes</h2>\n<p>- <strong>Typed HTTP client.</strong> <code>src/lib/forgejo-client.ts</code> wraps the<br />  upstream REST API in a small typed surface (<code>repos</code>, <code>issues</code>,<br />  <code>pulls</code>, <code>projects</code>, <code>milestones</code>, <code>releases</code>, <code>labels</code>,<br />  <code>users</code>). It uses Node 20+ built-in <code>fetch</code> + <code>AbortController</code><br />  — no <code>gitea-js</code> or <code>undici</code> dependency (see<br />  <code>docs/LIBRARY-EVALUATION.md</code> for the rationale).<br />- <strong>Token redaction.</strong> <code>src/lib/token-redaction.ts</code> rewrites every<br />  <code>token &lt;value&gt;</code> and <code>Bearer &lt;value&gt;</code> literal to <code>***REDACTED***</code><br />  in response bodies, error messages, and stack traces before they<br />  leave the client. The unit test<br />  <code>tests/unit/token-redaction.test.ts</code> pins this contract.<br />- <strong>Retry semantics.</strong> <code>src/lib/retry.ts</code> implements exponential<br />  backoff (<code>baseMs</code>, doubling per attempt), honours the upstream<br />  <code>Retry-After</code> header on <code>429</code>, and stops at <code>maxRetries</code> (default<br />  3). Retryable codes are <code>429</code> and any <code>5xx</code>; non-retryable <code>4xx</code><br />  propagate immediately.<br />- <strong>Per-user isolation.</strong> Every persisted artifact is registered via<br />  <code>registerScopedTable()</code> per ADR-054. The addon never reads or<br />  writes another user&#39;s instance configuration or cached state.<br />- <strong>No hardcoded default instance.</strong> No demo URL, no demo token, no<br />  silent default admin. When the user has not configured an<br />  instance, the dashboard shows an empty state with a CTA and every<br />  API call returns a clear <code>instance_not_configured</code> error (slice<br />  #229 will codify that error code).<br />- <strong>Approval gate.</strong> Write operations (issue close, PR merge, label<br />  changes) flow through the existing autonomy/approval pipeline —<br />  never silently executed. The read-dashboard does not perform any<br />  writes.</p>\n<h2>Slice #233 — what ships here</h2>\n<p>- <code>addons/forgejo/SKILL.md</code> — the agent skill, with the 5 mandatory<br />  sections (Purpose, When to use, Capability / security, Endpoint<br />  cheat-sheet, Example curl) modelled on<br />  <code>addons/task-board/SKILL.md</code>.<br />- <code>addons/forgejo/README.md</code> — this file (Setup, Env-Vars, Endpoint<br />  overview, Architecture notes).<br />- <code>addons/forgejo/addon.json</code> — finalised manifest with<br />  <code>version: 1.0.0</code>, the complete <code>endpoints</code> list (slice #228 typed<br />  client baseline + slice #230 read-dashboard routes),<br />  <code>capabilities</code>, and <code>pages</code> array.<br />- <code>addons/forgejo/tests/integration/skill-discovery.test.ts</code> —<br />  agent-discovery smoke test: stub loader finds <code>SKILL.md</code> and<br />  parses the 5 sections successfully.<br />- <code>addons/forgejo/tests/integration/marketplace-build.test.ts</code> —<br />  confirms <code>npm run build:artifacts</code> produces a ZIP and<br />  <code>npm run build:catalog</code> includes the entry.</p>\n<h2>Non-goals (slice #233)</h2>\n<p>- <strong>No new HTTP endpoints</strong> — this slice is docs + tests only.<br />- <strong>No new client methods</strong> — slice #228 owns the client surface.<br />- <strong>No instance picker</strong> — slice #229.<br />- <strong>No filter engine</strong> — slice #230.<br />- <strong>No write operations</strong> — slices #231 / #232.<br />- <strong>No agent-loader changes</strong> — the loader lives in<br />  <code>aiopta-core/src/lib/addons/addon-skills.ts</code> and is out of scope<br />  for this slice.</p>\n<h2>License</h2>\n<p>Per the Aiopta addon marketplace repository terms.</p>",
          "byteSize": 13228
        }
      }
    },
    {
      "id": "hermes-desktop",
      "uuid": "332ed156-aa4b-4dd4-92b7-fd173b48d71f",
      "name": "Hermes Desktop",
      "version": "0.6.3",
      "description": "Commit-pinned feature matrix for NousResearch hermes-agent desktop capabilities plus a secret-free capability adapter (issues #101 + #169), a per-user Hermes sessions UI (sessions list, SSE stream with chunk dedupe, Stop, Retry — issue #120), a per-user workspace file browser + sandboxed preview envelope (issue #167), a secret-free settings panel for model / personality / skills / memory / automation-status (issue #121), an optional voice input / output panel with explicit opt-in, retention hints and secret-free verdict (issue #168), and a pinned upstream + image-compatibility contract (issue #169). The shell surfaces only capabilities and settings backed by an Aiopta contract — no optimistic flags. Status: chat-streaming/tool-activity/sessions/provider/personality/skills/memory/automation/tool-settings are partial, file-browser/side-by-side-previews are partial (issue #167 — capability-gated, per-user workspace root, sandboxed preview, no core-origin rights for active content), voice-input + voice-output are partial (issue #168 — explicit opt-in, secret-free, retention hint, no audio bytes logged, permission revoke collapses to unavailable), image-pin is partial, upstream-update is unsupported-by-design, shell-cache-invalidation/shell-no-optimistic-flags/preview-sandbox/no-file-url/voice-secret-free are full. Pin JSON (hermes-desktop.pin.json) hard-binds the source commit + the OCI image + sha256 imageDigest + API schema version + license + compatibility issue per issue #169; any drift is a hard error in scripts/validate-addons.cjs. The browser talks only to Aiopta APIs — no Hermes-domain code is duplicated here.",
      "author": "Aiopta",
      "icon": "📜",
      "capabilities": [
        "hermes-desktop",
        "feature-matrix",
        "capability-adapter",
        "secret-free-status",
        "commit-pinned-contract",
        "image-digest-pinned",
        "api-schema-version-pinned",
        "supply-chain-checked",
        "hermes-sessions",
        "hermes-streaming",
        "hermes-stream-stop",
        "hermes-stream-retry",
        "hermes-workspace-files",
        "hermes-workspace-preview",
        "preview-sandboxed",
        "no-file-url",
        "settings-panel",
        "settings-shell-state",
        "voice-input",
        "voice-output",
        "voice-opt-in",
        "voice-secret-free",
        "voice-audio-size-limit"
      ],
      "tags": [],
      "download": "/artifacts/hermes-desktop-0.6.3.zip",
      "sha256": "e0cf77980569d5be82f2fdba6291601cbfd1c884a8c5bd92fff4b6b74a8ac87c",
      "size": 928997,
      "page": {
        "id": "hermes-desktop",
        "name": "Hermes Desktop",
        "path": "/addons/hermes-desktop",
        "icon": "📜",
        "section": "Tools",
        "component": "@addons/hermes-desktop/src/components/HermesDesktopPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/hermes-desktop/capabilities",
        "/api/v1/addons/hermes-desktop/sessions",
        "/api/v1/addons/hermes-desktop/sessions/{id}/stream",
        "/api/v1/addons/hermes-desktop/sessions/{id}/stop",
        "/api/v1/addons/hermes-desktop/sessions/{id}/retry",
        "/api/v1/addons/hermes-desktop/files",
        "/api/v1/addons/hermes-desktop/preview",
        "/api/v1/addons/hermes-desktop/settings",
        "/api/v1/addons/hermes-desktop/voice"
      ],
      "skills": [
        {
          "name": "hermes-desktop-capabilities",
          "displayName": "Hermes Desktop: Capability Matrix",
          "description": "Read the commit-pinned Hermes-desktop feature matrix and the live capability-adapter verdict for the current backend driver. Secret-free: the route returns status + a reference to the upstream row, never credentials. Use this skill to answer 'which Hermes features work in Aiopta right now?' — no live Hermes call required.",
          "permissions": [
            "addons/hermes-desktop/capabilities"
          ],
          "endpoint": "/api/v1/addons/hermes-desktop/capabilities",
          "scripts": [
            {
              "id": "hermes-desktop-capabilities-cli",
              "name": "hermes-desktop-capabilities-cli",
              "path": "hermes-desktop-capabilities/cli.sh",
              "sha256": "a377bf88125a933c465d28ef61d631c5c7bc8742a7466ea7af7dedfe3e4844fb",
              "interpreter": "bash",
              "timeoutMs": 10000,
              "permissions": [
                "addons/hermes-desktop/capabilities"
              ],
              "inputSchema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "limit": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 100,
                    "default": 20
                  },
                  "timeoutMs": {
                    "type": "integer",
                    "minimum": 100,
                    "maximum": 300000
                  }
                }
              },
              "outputSchema": {
                "type": "object",
                "required": [
                  "ok"
                ],
                "properties": {
                  "ok": {
                    "type": "boolean"
                  },
                  "status": {
                    "type": "integer"
                  },
                  "data": {},
                  "error": {
                    "type": "string"
                  },
                  "truncated": {
                    "type": "boolean"
                  }
                }
              },
              "size": 12176
            }
          ]
        },
        {
          "name": "hermes-desktop-sessions",
          "displayName": "Hermes Desktop: Per-User Sessions",
          "description": "Read the per-user Hermes sessions list, stream chunks, and Stop/Retry an in-flight session (issue #120). The route is per-user, capability-flagged, and secret-free. No live Hermes call is made — the page renders the cached chat-addon session list and surfaces the HermesDesktop capability verdict for the page to gate Stop/Retry buttons.",
          "permissions": [
            "addons/hermes-desktop/sessions",
            "addons/hermes-desktop/sessions/{id}/stream",
            "addons/hermes-desktop/sessions/{id}/stop",
            "addons/hermes-desktop/sessions/{id}/retry"
          ],
          "endpoint": "/api/v1/addons/hermes-desktop/sessions",
          "scripts": []
        },
        {
          "name": "hermes-desktop-workspace",
          "displayName": "Hermes Desktop: Workspace Files & Preview",
          "description": "Read a per-user workspace file listing and a sandboxed preview of a single file (issue #167). The endpoints are per-user (each user sees their own workspace root, never another user's), capability-gated, and secret-free. Previews are returned as envelopes (mime + body + sandbox + CSP) — never as `file://` URLs or raw paths. Active content in HTML / PDF previews is sandboxed (no `allow-same-origin`) so it cannot reach the core origin.",
          "permissions": [
            "addons/hermes-desktop/files",
            "addons/hermes-desktop/preview"
          ],
          "endpoint": "/api/v1/addons/hermes-desktop/files",
          "scripts": []
        },
        {
          "name": "hermes-desktop-settings",
          "displayName": "Hermes Desktop: Settings Panel",
          "description": "Read the per-row settings verdict for the matrix rows whose group is 'settings' (model / personality / skills / memory / automation-status). Secret-free: the route returns supported/unsupported/unreachable + the Aiopta route that owns the value + the recency of the last successful observation + a stable error code, never credentials or setting values. Use this skill to answer 'which Hermes settings can the user act on right now?' — no live Hermes call required.",
          "permissions": [
            "addons/hermes-desktop/settings"
          ],
          "endpoint": "/api/v1/addons/hermes-desktop/settings",
          "scripts": []
        },
        {
          "name": "hermes-desktop-voice",
          "displayName": "Hermes Desktop: Voice Input / Output",
          "description": "Read the per-row voice verdict (voice-input / voice-output) for the matrix rows whose id starts with 'feat-voice-'. Secret-free: the route returns the per-row shell state (ready | degraded | unavailable | unsupported) + the provider id (NEVER the URL or credential) + the stable error code + the retention hint (max audio bytes, transcript preview limit) — never audio bytes, transcripts, or backend credentials. The verdict is consent-aware: until the user explicitly opts in, every row reports CONSENT_REQUIRED. The agent must surface the verdict verbatim and never recommend a default opt-in.",
          "permissions": [
            "addons/hermes-desktop/voice"
          ],
          "endpoint": "/api/v1/addons/hermes-desktop/voice",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Hermes Desktop (issues #101 + #120 + #121 + #167 + #168 + #169)</h1>\n<p>This addon ships the <strong>pinned-upstream + image-compatibility contract</strong><br />for the <a href=\"https://github.com/NousResearch/hermes-agent\" rel=\"noopener noreferrer\">NousResearch <code>hermes-agent</code></a><br />desktop capabilities together with a <strong>secret-free capability adapter</strong><br />that tells the Aiopta shell which Hermes features are usable <em>right now</em><br />on the active backend driver, a <strong>per-user Hermes sessions UI</strong><br />(session list, SSE stream with chunk dedupe, Stop, Retry — issue #120),<br />a <strong>per-user workspace file browser + sandboxed preview envelope</strong><br />(issue #167), a <strong>secret-free settings panel</strong> that surfaces the<br />5 settings-group rows of the matrix (model / personality / skills /<br />memory / automation-status — issue #121) with a dedicated<br />shell-state vocabulary (<code>supported | unsupported | unreachable</code>) plus<br />the recency of the last successful observation and a stable error<br />code, and an <strong>optional voice input/output panel</strong> with explicit<br />opt-in, secret-free verdict, retention hints and a 10 MiB audio size<br />limit (issue #168). The browser talks only to Aiopta APIs — no<br />Hermes-domain code is duplicated here.</p>\n<p>| Property | Value |<br />|----------|-------|<br />| Addon id | <code>hermes-desktop</code> |<br />| Matrix source | <code>hermes-desktop-parity.json</code> (16 rows, pinned) |<br />| Pin file | <code>hermes-desktop.pin.json</code> |<br />| Pin commit | <code>ea8a8b4af8612b655a5bbfc74eba21e1e806758d</code> |<br />| Pin image | <code>nousresearch/hermes-agent</code> |<br />| Pin image digest | <code>sha256:dabcf559…a7cb</code> — <strong>PLACEHOLDER</strong> (sha256 sentinel, no <code>latest</code>); real immutable digest pending, see issue #169 caveat below |<br />| Pin API schema version | <code>1.0.0</code> (Semver) |<br />| Pin license | <code>MIT</code> |<br />| Spec issue | #101 |<br />| Compatibility issue | #169 (pinned upstream + image-compatibility contract) |<br />| Follow-ups | #120 (sessions UI, done in 0.2.0), #121 (settings panel, done in 0.2.0), #167, #168 |<br />| Endpoints | <code>GET /api/v1/addons/hermes-desktop/capabilities</code> (issue #101)&lt;br&gt;<code>GET /api/v1/addons/hermes-desktop/sessions</code> (issue #120, per-user slice)&lt;br&gt;<code>GET /api/v1/addons/hermes-desktop/sessions/{id}/stream</code> (SSE with chunk dedupe)&lt;br&gt;<code>POST /api/v1/addons/hermes-desktop/sessions/{id}/stop</code> (capability-gated)&lt;br&gt;<code>POST /api/v1/addons/hermes-desktop/sessions/{id}/retry</code> (capability-gated)&lt;br&gt;<code>GET /api/v1/addons/hermes-desktop/settings</code> (issue #121, secret-free per-row verdict) |<br />| Pages | <code>/addons/hermes-desktop</code> (matrix + settings panel, #101 + #121)&lt;br&gt;<code>/addons/hermes-desktop/sessions</code> (sessions list + stream pane, #120) |<br />| Secrets / tokens | <strong>none</strong> — adapter + sessions routes + settings route return status + references, never credentials or setting values |<br />| Hard-coded URLs | <strong>none</strong> — paths come from the matrix JSON + the settings source-bindings map |<br />| <code>unsupported</code> | upstream self-update is intentionally <code>unsupported</code> (by design) |</p>\n<h2>Pinned upstream + image-compatibility contract (issue #169)</h2>\n<p><code>hermes-desktop.pin.json</code> is the single source of truth for the contract<br />that binds Hermes-desktop to upstream. The contract is <strong>explicit</strong>,<br /><strong>rollback-capable</strong>, and <strong>supply-chain-checked</strong>:</p>\n<p>| Field | Purpose |<br />|-------|---------|<br />| <code>commit</code> | Pinned git source commit (issue #101) |<br />| <code>image</code> | OCI registry/repository reference (e.g. <code>nousresearch/hermes-agent</code>) |<br />| <code>imageDigest</code> | sha256-pinned image digest — <code>^sha256:[a-f0-9]{64}$</code>, <strong>no <code>latest</code></strong> |<br />| <code>apiSchemaVersion</code> | Pinned Semver of the upstream API contract |<br />| <code>license</code> | Upstream license string, stored alongside the rest of the contract |<br />| <code>compatibilityIssue</code> | Reference to the contract that hardens the pin (issue #169) |</p>\n<p>The matrix JSON (<code>hermes-desktop-parity.json</code>) mirrors every field<br />above in its <code>pin</code> block. Drift between the two files is a hard error<br />from <code>scripts/validate-addons.cjs</code> (issue #101 + #169) and from the<br />runtime matrix loader (<code>src/lib/matrix.ts:validateMatrix</code>).</p>\n<p>The adapter (<code>src/lib/capability-adapter.ts</code>) surfaces the full contract<br />in the verdict and degrades rows to <code>down</code> / <code>unsupported-version</code><br />when the running image&#39;s sha256 digest or the backend&#39;s API schema<br />version drifts from the pin — <strong>never</strong> an optimistic <code>full</code> flag.</p>\n<p><strong>Update procedure</strong> (explicit, rollbackable, supply-chain checked):<br />1. Operator decides to bump: change all four pinned fields in<br />   <code>hermes-desktop.pin.json</code> AND in <code>hermes-desktop-parity.json</code><br />   (pin block) in a single PR.<br />2. <code>scripts/validate-addons.sh</code> hard-fails on any drift between the<br />   two JSONs.<br />3. <code>node --test addons/hermes-desktop/tests/unit/pin.test.cjs</code><br />   covers the schema contract for new fields.<br />4. Rollback: <code>git revert</code> the PR — pin, matrix, and matrix rows move<br />   together, so a single revert restores the prior contract.<br />5. Supply-chain check: the imageDigest field is sha256-pinned and the<br />   adapter reports <code>imageDigestOk=false</code> if the running image&#39;s sha256<br />   does NOT match. No <code>latest</code> tag, no silent fallback.</p>\n<h3>Caveat — imageDigest is a placeholder (issue #169, partial)</h3>\n<p>The real immutable sha256 digest of <code>nousresearch/hermes-agent</code> at the<br />pinned commit is <strong>not yet available</strong> in issue #169. To keep the<br />contract <em>shape</em> enforced end-to-end (drift detection between<br /><code>hermes-desktop.pin.json</code>, the matrix pin block, and the<br /><code>HERMES_DESKTOP_PIN_IMAGE_DIGEST</code> constant in <code>scripts/validate-addons.cjs</code><br />all work) without faking a real digest, the three mirrors currently carry<br />a clearly-nonzero <strong>placeholder</strong> digest — the sha256 of a descriptive<br />sentinel string. It passes the <code>^sha256:[a-f0-9]{64}$</code> shape check but<br />is <strong>not</strong> a real image digest.</p>\n<p><strong>Before this addon is trusted for production supply-chain claims</strong>, an<br />operator must replace the placeholder in all three mirrors with the<br />actual <code>docker digest</code> (or registry manifest digest) of the pinned image.<br />Until then the <code>feat-image-pin</code> matrix row is <code>partial</code>, not <code>full</code>.</p>\n<h2>Why this addon exists</h2>\n<p><code>hermes-agent</code> ships a desktop client with a broad feature surface<br />(streaming, sessions, side-by-side previews, file browser, voice, settings,<br />updates). Aiopta already covers the chat / streaming / sessions / settings<br />paths through <em>other</em> addons (chat, development, memories, skills).<br />The risk is that the Aiopta browser ends up duplicating Hermes-domain code,<br />or worse, advertising features that are not actually wired up.</p>\n<p>This addon fixes that:</p>\n<p>1. The <strong>matrix</strong> (<code>hermes-desktop-parity.json</code>) is the <em>only</em> source of<br />   truth for &quot;which Hermes feature exists in Aiopta, where, and at what<br />   coverage level&quot;. Each row binds an upstream feature to an Aiopta<br />   contract (capability + route + skill + UI + test) and tags it<br />   <code>full | partial | missing | unsupported | different</code>. Rows tagged<br />   with <code>group: &quot;settings&quot;</code> (issue #121) are rendered together in the<br />   settings panel.<br />2. The <strong>capability adapter</strong> (<code>src/lib/capability-adapter.ts</code>) reads<br />   the matrix and returns the <em>shell-verified</em> state (`not-selected |<br />   down | old | unsupported | full`) for every row, keyed by the<br />   active backend driver. The cache invalidates on backend switch —<br />   there are no optimistic &quot;ready&quot; badges.<br />3. The <strong>settings adapter</strong> (<code>src/lib/settings-adapter.ts</code>) reads the<br />   matrix rows whose <code>group === &quot;settings&quot;</code> and returns a per-row<br />   settings verdict (<code>supported | unsupported | unreachable</code>) plus the<br />   last-refreshed timestamp and the stable error code. The verdict is<br />   keyed by <code>(backend, live-state-hash)</code> so a backend switch OR a<br />   change in the live Aiopta configuration source forces a fresh<br />   evaluation.<br />4. The <strong>shell</strong> (<code>src/components/HermesDesktopPage.tsx</code>) renders only<br />   what the capability adapter actually returned. The settings panel<br />   (<code>src/components/SettingsPanel.tsx</code>) renders only what the settings<br />   adapter returned. Missing/unsupported/down/unreachable rows are<br />   labelled honestly. The user never sees &quot;voice input: ready&quot; when<br />   the upstream contract is <code>missing</code>, nor &quot;memory: supported&quot; when<br />   the memories addon is unreachable.</p>\n<h2>Files</h2>\n<p>| File | Purpose |<br />|------|---------|<br />| <code>hermes-desktop.pin.json</code> | Frozen commit SHA + spec/parity issue refs |<br />| <code>hermes-desktop-parity.json</code> | 20-row feature matrix (validated by <code>validate-addons.cjs</code>) |<br />| <code>hermes-desktop-parity.schema.json</code> | JSON Schema for the matrix (draft-07) |<br />| <code>addon.json</code> | Aiopta manifest (capability list, endpoints, page) |<br />| <code>src/manifest.ts</code> | Runtime extension manifest (ADR-001 dual-registration) |<br />| <code>src/page.ts</code> | Page descriptor consumed by the registry generator |<br />| <code>src/components/HermesDesktopPage.tsx</code> | Shell — renders the capability matrix + the settings panel + the voice panel |<br />| <code>src/components/SettingsPanel.tsx</code> | Settings panel — model / personality / skills / memory / automation (issue #121) |<br />| <code>src/components/VoicePanel.tsx</code> | Voice input/output panel — explicit opt-in, secret-free, retention hint, audio size limit (issue #168) |<br />| <code>src/app/api/v1/addons/hermes-desktop/capabilities/route.ts</code> | Capability-adapter endpoint (secret-free, authenticated) |<br />| <code>src/app/api/v1/addons/hermes-desktop/settings/route.ts</code> | Settings-adapter endpoint (secret-free, authenticated, issue #121) |<br />| <code>src/app/api/v1/addons/hermes-desktop/voice/route.ts</code> | Voice-panel endpoint (secret-free, authenticated, issue #168): per-row voice verdict + retention hint |<br />| <code>src/lib/matrix.ts</code> | Matrix loader + validator (pure) — accepts the optional <code>group</code> field (issue #121) |<br />| <code>src/lib/capability-adapter.ts</code> | Capability-adapter logic — backend-aware, cache-aware, secret-free (pure) |<br />| <code>src/lib/settings-adapter.ts</code> | Settings-adapter logic — backend- + live-state-aware, cache-aware, secret-free (pure, issue #121) |<br />| <code>src/lib/voice-adapter.ts</code> | Voice-adapter logic — backend- + live-snapshot-aware, consent-aware, cache-aware, secret-free (pure, issue #168) |<br />| <code>src/lib/voice-policy.ts</code> | Voice policy — consent state machine, audio size limit, log redaction, provider status (pure, issue #168) |<br />| <code>src/lib/shell-state.ts</code> | Capability shell-state computation: backend state × matrix row → <code>not-selected|down|old|unsupported|full</code> |<br />| <code>src/lib/settings-shell-state.ts</code> | Settings shell-state computation: backend state × matrix row → <code>supported|unsupported|unreachable</code> (pure, issue #121) |<br />| <code>src/lib/session-source.ts</code> | Sessions + stream contract (issue #120): per-user row, SSE chunk shape, id validator, capability-driven <code>canStop</code> / <code>canShowRetry</code> |<br />| <code>src/lib/stream-buffer.ts</code> | SSE chunk dedupe + reconnect helper (issue #120): <code>StreamBuffer</code> (hard-floor dedupe at <code>highestSeq</code>), bounded exponential backoff with jitter, terminal-flag-aware reconnect |<br />| <code>src/lib/session-page-state.ts</code> | Page state machine (issue #120): <code>loading | down | ready | stream | closed</code>, capability-driven gates, backend-switch invalidation |<br />| <code>src/lib/sessions-fixture.ts</code> | Per-user sessions fixture + cursor helper (issue #120): shape validator + <code>selectUserSessions</code> / <code>findSessionForUser</code> / <code>nextChunk</code> |<br />| <code>src/app/api/v1/addons/hermes-desktop/sessions/route.ts</code> | <code>GET /sessions</code> (issue #120): per-user slice + capability-flagged <code>canStop</code> / <code>canRetry</code> |<br />| <code>src/app/api/v1/addons/hermes-desktop/sessions/[id]/stream/route.ts</code> | <code>GET /sessions/{id}/stream</code> (issue #120): SSE responder, advances cursor based on <code>Last-Event-ID</code> |<br />| <code>src/app/api/v1/addons/hermes-desktop/sessions/[id]/stop/route.ts</code> | <code>POST /sessions/{id}/stop</code> (issue #120): capability-gated, idempotent on already-stopped |<br />| <code>src/app/api/v1/addons/hermes-desktop/sessions/[id]/retry/route.ts</code> | <code>POST /sessions/{id}/retry</code> (issue #120): capability-gated, idempotent on already-streaming |<br />| <code>src/components/HermesSessionsPage.tsx</code> | Sessions UI (issue #120): per-user list + stream pane + capability-gated Stop / Retry + backend-switch cleanup |<br />| <code>src/page-sessions.ts</code> | Page descriptor for the sessions UI (ADR-001 dual-registration) |<br />| <code>src/lib/workspace-fs.ts</code> | Per-user workspace file browser + sandboxed preview envelope (issue #167): <code>FsAdapter</code>, <code>normalizeRelativePath</code>, <code>resolveSafePath</code>, <code>listDirectory</code>, <code>readPreview</code>, <code>sandboxFor</code>, <code>cspFor</code>. Pure — every fs call goes through the caller-supplied adapter so the unit tests pass an in-memory <code>Map</code> |<br />| <code>src/app/api/v1/addons/hermes-desktop/files/route.ts</code> | <code>GET /files</code> (issue #167): per-user workspace listing, capability-gated, secret-free, no <code>file://</code> URL |<br />| <code>src/app/api/v1/addons/hermes-desktop/preview/route.ts</code> | <code>GET /preview</code> (issue #167): per-user sandboxed preview envelope, capability-gated, secret-free, no <code>file://</code> URL, active content sandboxed (no <code>allow-same-origin</code>) |<br />| <code>src/components/HermesWorkspacePage.tsx</code> | Workspace UI (issue #167): file tree + side-by-side preview pane, capability-gated, sandboxed iframe for active content |<br />| <code>src/page-workspace.ts</code> | Page descriptor for the workspace UI (ADR-001 dual-registration) |<br />| <code>src/lib/auth-fixture.ts</code> | Auth test fixtures (4 scenarios: authenticated-anon, missing-token, bad-signature, backend-switch) |<br />| <code>src/lib/timeout-fixture.ts</code> | Timeout test fixtures (DB / network &gt; 30s → degraded) |<br />| <code>src/lib/version-fixture.ts</code> | Version test fixtures (down / old / pinned-commit match) |<br />| <code>tests/unit/*.test.cjs</code> | Legacy <code>node --test</code> suite covering the acceptance criteria |<br />| <code>tests/unit/*.test.ts</code> | Vitest suite covering the acceptance criteria (mirrors the <code>.test.cjs</code> files) |</p>\n<h2>Acceptance criteria → evidence</h2>\n<p>| AC | Test |<br />|----|------|<br />| Matrix pro Upstreamfeature: Commitpfad, Aiopta-Vertrag, <code>full|partial|missing|unsupported</code>, Test/Gap | <code>tests/unit/matrix.test.cjs</code> validates every row&#39;s shape; <code>tests/unit/manifest.test.cjs</code> asserts the matrix rows match the manifest contract |<br />| Adapter liefert authenticated, secret-free status und invalidiert bei Backendwechsel | <code>tests/unit/capability-adapter.test.cjs</code> — happy path + 4 auth-fixture scenarios + backend-switch invalidation |<br />| Shell zeigt not-selected/down/old/unsupported; keine optimistischen Flags | <code>tests/unit/shell-state.test.cjs</code> — every state transition is asserted against the matrix + backend state tuple; no test ever passes when a <code>down</code> row renders as <code>full</code> |<br />| Manifest-/Adapter-/Auth-/Timeout-/Versionfixtures und <code>validate:addons</code> grün | <code>scripts/validate-addons.sh</code> (exits 0) + the four fixture files plus their dedicated unit tests |<br />| <strong>Issue #120:</strong> Nur eigene Sessions sichtbar | <code>tests/unit/sessions-route.test.cjs</code> (Alice sees her sessions, Bob sees his, cross-user probe returns same shape as missing) + <code>tests/unit/sessions-fixture.test.cjs</code> (<code>selectUserSessions</code> + <code>findSessionForUser</code>) |<br />| <strong>Issue #120:</strong> Stop/Retry folgt Capability-Flags | <code>tests/unit/sessions-route.test.cjs</code> (capability flags threaded per row) + <code>tests/unit/stop-route.test.cjs</code> + <code>tests/unit/retry-route.test.cjs</code> (501 on unsupported/missing, 200 on full/partial, 409 on invalid state) + <code>tests/unit/session-page-state.test.cjs</code> (<code>canStop</code>/<code>canRetry</code> gated by verdict) |<br />| <strong>Issue #120:</strong> Streaming dupliziert keine Chunks | <code>tests/unit/stream-buffer.test.cjs</code> (hard-floor dedupe at <code>highestSeq</code> + recent-set dedupe + bounded eviction) + <code>tests/unit/stream-route.test.cjs</code> (<code>Last-Event-ID</code> cursor + SSE <code>id:</code> line) |<br />| <strong>Issue #120:</strong> Addon deaktivieren entfernt Navigation und beendet Polling/Streams | <code>src/components/HermesSessionsPage.tsx</code> <code>useEffect</code> cleanup closes the <code>EventSource</code> + clears the reconnect timer on unmount |<br />| <strong>Issue #120:</strong> Hermes down → retrybarer degraded state | <code>tests/unit/session-page-state.test.cjs</code> (<code>down</code> state + <code>RETRY</code> transition) + <code>tests/unit/sessions-route.test.cjs</code> (BACKEND_DOWN → 503 typed error) |<br />| <strong>Issue #120:</strong> Streamabbruch → reconnect oder klar beendet | <code>tests/unit/stream-buffer.test.cjs</code> (<code>shouldReconnect</code> honours terminal flag + attempt cap; <code>selectReconnectBackoff</code> is bounded at 8s with half-jitter) + <code>HermesSessionsPage</code> reconnects with <code>?resume=&lt;nextSeq&gt;</code> |<br />| <strong>Issue #120:</strong> Backendwechsel beendet Subscriptions und leert stale Sessions | <code>tests/unit/session-page-state.test.cjs</code> (<code>BACKEND_SWITCH</code> transitions from ready/stream/down to closed) + <code>HermesSessionsPage</code> backend-switch watcher |<br />| <strong>Issue #167:</strong> Capability-gated Files/Previews mit per-User Workspace-root | <code>tests/unit/workspace-fs.test.cjs</code> (per-user root, workspace-root cap, <code>scopedStore</code>-ready) + <code>tests/unit/files-route.test.cjs</code> (per-user <code>resolveWorkspaceRootForUser</code> + 401 on missing/bad signature) + <code>tests/unit/preview-route.test.cjs</code> (per-user envelope) |<br />| <strong>Issue #167:</strong> Path traversal, symlink escape, oversized/binary/unsupported sauber behandelt | <code>tests/unit/workspace-fs.test.cjs</code> (TRAVERSAL via <code>..</code>, <code>\\\\</code>, NUL, symlink escape via <code>resolveSafePath</code> realpath check, OVERSIZED via <code>maxBytes + 1</code> round-trip, BINARY via extension + NUL-byte sniff, UNSUPPORTED via non-whitelisted mime) + <code>tests/unit/files-route.test.cjs</code> (every code → status mapping) |<br />| <strong>Issue #167:</strong> Preview ist sandboxed; aktiver Inhalt erhält keine Core-Origin-Rechte | <code>tests/unit/workspace-fs.test.cjs</code> (<code>sandboxFor</code> returns <code>allow-popups</code> for text/html + application/pdf, NEVER <code>allow-same-origin</code>; <code>cspFor</code> returns <code>default-src &#39;none&#39;</code>, NEVER <code>script-src</code>) + <code>tests/unit/preview-route.test.cjs</code> (envelope carries <code>sandbox</code> + <code>csp</code> for every mime, response is secret-free) |<br />| <strong>Issue #167:</strong> Adapter verwendet ausschließlich Aiopta Workspace-/Artefakt-APIs; keine freien Hostpfade oder <code>file://</code>-URLs | <code>tests/unit/workspace-fs.test.cjs</code> (response shape is secret-free, listing entries are RELATIVE paths, envelope never carries <code>file://</code>) + <code>tests/unit/preview-route.test.cjs</code> (response body never contains <code>file://</code> or the absolute path) |<br />| <strong>Issue #167:</strong> FS/AuthZ/preview sandbox Matrix und zwei User | <code>tests/unit/two-user-isolation.test.cjs</code> (user-a + user-b see different content, path-traversal blocked, symlink to other user blocked as <code>SYMLINK_ESCAPE</code>, error body never carries another user&#39;s file content) |<br />| Issue #121: Jedes Matrixfeature hat UI-Zustand supported/unsupported/unreachable | <code>tests/unit/settings-shell-state.test.cjs</code> — full truth table + &quot;no optimistic flags&quot; guard |<br />| Issue #121: Modelloptionen stammen aus Aiopta-Konfiguration | <code>tests/unit/settings-adapter.test.cjs</code> — source reference binds to Aiopta routes (<code>SETTINGS_SOURCE_BINDINGS</code>) |<br />| Issue #121: Memory/Automation zeigen Aktualität und Fehlerzustand | <code>tests/unit/settings-adapter.test.cjs</code> — <code>lastRefreshedAt</code> + <code>errorCode</code> surfaces, plus <code>tests/unit/settings-route.test.cjs</code> |<br />| Issue #121: Keine internen Hosts/Secrets im Browserpayload | <code>tests/unit/settings-adapter.test.cjs</code> — JSON-stringified verdict scan forbids <code>token</code>, <code>password</code>, <code>secret</code>, <code>apikey</code>, <code>bearer</code>, <code>credential</code>, <code>http://</code>, <code>https://</code>, <code>localhost</code>, <code>127.0.0.1</code>, <code>0.0.0.0</code> |<br />| Issue #121: Capability-Matrix-Tests | <code>tests/unit/matrix.test.cjs</code> §&quot;issue #121 — settings-group rows&quot; pins every settings-group row |<br />| Issue #121: Komponententests für supported/unsupported/down | <code>tests/unit/settings-shell-state.test.cjs</code> (every state × every backend) |<br />| Issue #121: E2E Backendwechsel Hermes → anderer Provider | <code>tests/unit/settings-backend-switch.test.cjs</code> — hermes → openclaw with mixed live-state per row |</p>\n<h2>What is explicitly out of scope</h2>\n<p>- <strong>Upstream self-update</strong> is <code>unsupported</code> (by design). The addon never<br />  advertises &quot;update available&quot; — there is no Hermes-desktop self-update<br />  surface. Updates flow through the normal Aiopta Marketplace ZIP-artifact<br />  release pipeline.<br />- <strong>Hermes desktop app</strong> itself is not in this repo. The matrix binds<br />  upstream features to Aiopta contracts; it does not re-implement the<br />  Hermes client.<br />- <strong>DB schema</strong> — this addon ships no <code>migrations/</code> directory. All state<br />  lives in the matrix JSON + the request-scoped adapter cache + the<br />  per-user sessions fixture (issue #120). No <code>scopedStore</code>, no per-user<br />  table, no FK.<br />- <strong>Browser → Hermes direct</strong> (issue #120 neg-scope). The browser never<br />  talks to Hermes directly; every session / stream / stop / retry call<br />  flows through the Aiopta <code>userRoute</code>-wrapped handler in this addon.<br />- <strong>Cross-user session access</strong> (issue #120 neg-scope). The sessions<br />  list is filtered by <code>auth.id</code>. A cross-user probe returns the same<br />  answer as a missing id — no enumeration leak.<br />- <strong>Setting values</strong> (issue #121 neg-scope). The settings verdict<br />  surfaces the Aiopta route that owns the value + recency + error code.<br />  It never returns the value of a setting (model id, API key,<br />  personality content, memory content, automation payload). The user<br />  clicks &quot;Manage in Aiopta&quot; to reach the surface that owns the value.</p>\n<h2>Run it locally</h2>\n<pre><code class=\"lang-bash\">npm run validate:addons        # addon schema + matrix pin guard\nnode --test addons/hermes-desktop/tests/unit/*.test.cjs</code></pre>\n<p>The Jenkins PR-check pipeline runs both as part of <code>ci:build</code>.</p>",
          "byteSize": 20743
        },
        "changelog": {
          "filename": "CHANGELOG.md",
          "sourcePath": "CHANGELOG.md",
          "rendered": "<h1>Hermes Desktop Changelog (issues #101 + #120 + #169 + #121 + #167)</h1>\n<p>All notable changes to the <code>hermes-desktop</code> addon are documented here.<br />This addon follows <a href=\"https://semver.org/\" rel=\"noopener noreferrer\">Semantic Versioning</a>.</p>\n<h2>0.3.0 — 2026-06-30 (issue #167)</h2>\n<h3>Added</h3>\n<p>- <strong>Per-user workspace file browser</strong> — `GET<br />  /api/v1/addons/hermes-desktop/files?path=&lt;relative&gt;` returns a<br />  listing of the calling user&#39;s workspace root only. The endpoint<br />  is per-user (scopedStore(userId).workspaces), capability-gated<br />  (<code>addons/hermes-desktop/files</code>), and secret-free (the response<br />  never carries a token, bearer, or credential; the listing<br />  entries are RELATIVE paths only).<br />- <strong>Per-user sandboxed preview</strong> — `GET<br />  /api/v1/addons/hermes-desktop/preview?path=&lt;relative&gt;` returns<br />  a <code>PreviewEnvelope</code> (<code>mime</code>, <code>content</code>, <code>encoding</code>, <code>size</code>,<br />  <code>sandbox</code>, <code>csp</code>, <code>truncated</code>). HTML / PDF previews carry<br />  <code>sandbox=&quot;allow-popups&quot;</code> (NEVER <code>allow-same-origin</code>, NEVER<br />  <code>allow-scripts</code>) and a CSP that defaults to <code>default-src &#39;none&#39;</code><br />  — active content cannot reach the core origin. PDF bodies are<br />  base64-encoded; text / markdown / JSON / HTML bodies are<br />  UTF-8. The shell renders the envelope via `&lt;iframe sandbox<br />  srcdoc=...&gt;<code> or </code>&lt;pre&gt;<code> for text. There is no </code>file://` URL<br />  and no path the shell could follow.<br />- <strong>Path-traversal / symlink-escape / binary / oversize guards</strong><br />  — implemented in <code>src/lib/workspace-fs.ts</code>. Every filesystem<br />  call goes through a caller-supplied <code>FsAdapter</code> so the unit<br />  tests pass an in-memory <code>Map</code>-backed mock; the route handler<br />  passes <code>node:fs/promises</code>. The route handler maps the<br />  <code>WorkspaceFsError.code</code> to a stable HTTP status: 400 (TRAVERSAL<br />  / ABSOLUTE_PATH / NOT_A_DIRECTORY), 404 (NOT_FOUND /<br />  SYMLINK_ESCAPE), 413 (OVERSIZED), 415 (BINARY / UNSUPPORTED).<br />- <strong>Matrix rows</strong> — <code>feat-side-by-side-previews</code> and<br />  <code>feat-file-browser</code> move from <code>missing</code> to <code>partial</code> (issue<br />  #167 closes the upstream follow-up; the Aiopta side is<br />  capability-gated and secret-free). Two new contract rows are<br />  added: <code>feat-preview-sandbox</code> (full) and <code>feat-no-file-url</code><br />  (full).<br />- <strong>Workspace page + capability flags</strong> — <code>HermesWorkspacePage</code><br />  composes the file tree + a side-by-side preview pane. New<br />  capabilities: <code>hermes-workspace-files</code>, <code>hermes-workspace-preview</code>,<br />  <code>preview-sandboxed</code>, <code>no-file-url</code>. New skill:<br />  <code>hermes-desktop-workspace</code> (permissions for both routes; the<br />  agent must have both before calling <code>/preview</code>).</p>\n<h3>Tests</h3>\n<p>- <code>tests/unit/workspace-fs.test.cjs</code> — 69 tests covering the pure<br />  module: path normalisation, symlink escape, binary detection,<br />  mime classification, sandbox/CSP contract, secret-free response<br />  shape.<br />- <code>tests/unit/files-route.test.cjs</code> — 24 tests covering the route<br />  handler: auth gate, happy path, capability gating, error code<br />  → status mapping.<br />- <code>tests/unit/preview-route.test.cjs</code> — 22 tests covering the<br />  preview route: envelope shape, sandbox contract, no-file-url<br />  contract, per-user workspace, security guards.<br />- <code>tests/unit/two-user-isolation.test.cjs</code> — 8 tests covering<br />  the Pflicht-Test &quot;Cross-User-Zugriff: 404 statt 403&quot; for both<br />  routes (file browser + preview).</p>\n<h2>0.2.0 — 2026-06-30 (issues #120 + #121 + #169)</h2>\n<h3>Added</h3>\n<p>- <strong>Per-user Hermes sessions UI</strong> — a new page at<br />  <code>/addons/hermes-desktop/sessions</code> that lists the calling user&#39;s<br />  Hermes sessions, opens an SSE stream, and offers Stop / Retry<br />  only when the capability adapter reports <code>feat-chat-streaming</code><br />  as <code>full</code> or <code>partial</code>.<br />- <strong>Per-user sessions API</strong> — `GET<br />  /api/v1/addons/hermes-desktop/sessions` returns the user&#39;s slice<br />  only. Cross-user probes are merged with missing-id responses so a<br />  caller cannot enumerate session ids by HTTP status (issue #120<br />  AC #1: &quot;Nur eigene Sessions sichtbar&quot;).<br />- <strong>Capability-gated SSE stream</strong> — `GET<br />  /api/v1/addons/hermes-desktop/sessions/{id}/stream` emits one<br />  chunk per request. The browser dedupes by sequence id; the route<br />  advances the cursor based on <code>Last-Event-ID</code> so a reconnect<br />  resumes from the next chunk (issue #120 AC #3: &quot;Streaming<br />  dupliziert keine Chunks&quot;). The browser&#39;s <code>StreamBuffer</code> adds a<br />  hard floor at <code>highestSeq</code> so a buggy backend cannot trick the UI<br />  into rendering an old chunk.<br />- <strong>Capability-gated Stop / Retry</strong> — `POST<br />  /api/v1/addons/hermes-desktop/sessions/{id}/stop<code> and </code>POST<br />  /api/v1/addons/hermes-desktop/sessions/{id}/retry` are gated by<br />  the capability verdict for <code>feat-chat-streaming</code> (issue #120<br />  AC #2: &quot;Stop/Retry folgt Capability-Flags&quot;). <code>unsupported</code> /<br />  missing row → 501. Already-stopped → 200 (idempotent). Invalid<br />  state → 409.<br />- <strong>Pinned upstream + image-compatibility contract</strong> (issue #169):<br />  <code>hermes-desktop.pin.json</code> now stores, displays and validates<br />  together with the source commit:<br />  - <code>image</code>              — OCI registry/repository reference<br />  - <code>imageDigest</code>        — sha256-pinned image digest<br />                          (<code>^sha256:[a-f0-9]{64}$</code>, no <code>latest</code>)<br />  - <code>apiSchemaVersion</code>   — pinned Semver of the upstream API contract<br />  - <code>license</code>            — upstream license string (now mandatory +<br />                          hard-validated against the canonical value)<br />  - <code>compatibilityIssue</code> — reference to issue #169 (this entry)<br />- <code>hermes-desktop-parity.json</code> mirrors every contract field in its<br />  <code>pin</code> block; the matrix JSON Schema (<code>hermes-desktop-parity.schema.json</code>)<br />  requires the new fields.<br />- <code>src/lib/pin.ts</code> — pure pin loader/validator + <code>compareSemver</code> +<br />  <code>classifyApiSchemaCompatibility</code> for the new API-schema drift<br />  verdict (<code>compatible | older | newer | unknown</code>).<br />- Adapter verdict (<code>src/lib/capability-adapter.ts</code>) carries the<br />  full pinned contract: <code>pinImage</code>, <code>pinImageDigest</code>,<br />  <code>pinApiSchemaVersion</code>, <code>pinLicense</code>, <code>compatibilityIssue</code>,<br />  <code>apiSchemaCompatible</code>, <code>backendApiSchemaVersion</code>. Each<br />  <code>VerdictRow</code> carries the per-evaluation <code>imageDigestOk</code> and<br />  <code>apiSchemaVersionOk</code> columns so the shell can render a precise<br />  reason without re-running the adapter.<br />- New shell state <code>&#39;unsupported-version&#39;</code> (issue #169 AC #2): API<br />  schema drift on a <code>full</code> row surfaces <code>unsupported-version</code>,<br />  distinct from <code>down</code> (image-digest mismatch / backend unreach-<br />  able) and <code>old</code> (commit drift).<br />- <code>BackendState</code> carries new mandatory fields <code>imageDigestOk</code> and<br />  <code>apiSchemaVersionOk</code> (defaults to <code>false</code> — no optimistic<br />  default).<br />- <code>src/app/api/v1/addons/hermes-desktop/capabilities/route.ts</code><br />  reads the backend&#39;s reported API schema version from a<br />  deterministic file (<code>.backend-api-schema.json</code>) and surfaces the<br />  full pinned contract under <code>data.pinContract</code> in the response.<br />- Validator (<code>scripts/validate-addons.cjs</code>):<br />  <code>validateHermesDesktopPin</code> hard-validates every contract field<br />  (image + imageDigest + apiSchemaVersion + license +<br />  compatibilityIssue). <code>validateHermesMatrix</code> hard-fails on any<br />  drift between the matrix pin block and the pin JSON.<br />- New unit test <code>tests/unit/pin.test.cjs</code> (issue #169 contract):<br />  every required field is present + drift surfaces a typed<br />  <code>PinLoadError</code> with a precise <code>field</code> pointer.<br />- Existing tests extended:<br />  <code>tests/unit/capability-adapter.test.cjs</code> covers the new<br />  <code>imageDigestOk</code> / <code>apiSchemaVersionOk</code> semantics + the<br />  <code>apiSchemaCompatible</code> verdict.<br />  <code>scripts/validate-addons.test.cjs</code> covers every drift scenario<br />  for the new pin fields.<br />- <strong>Backend-switch cleanup</strong> — when the verdict&#39;s <code>backend</code> id<br />  changes between refreshes the page tears down every open<br />  EventSource + clears the session list (issue #120 edge-case:<br />  &quot;Backendwechsel beendet Subscriptions und leert stale<br />  Sessions&quot;).<br />- <strong>Page state machine</strong> — <code>src/lib/session-page-state.ts</code><br />  implements a typed reducer over `loading | down | ready | stream<br />  | closed<code> with transitions for </code>LOADED<code>, </code>LOAD_FAILED`,<br />  <code>SELECT_SESSION</code>, <code>CHUNK_RENDERED</code>, <code>STREAM_TERMINAL</code>, <code>STOP</code>,<br />  <code>RETRY</code>, <code>BACKEND_SWITCH</code>, <code>AUTH_LOST</code>. Pure, fully unit-tested.<br />- <strong>Sessions fixture</strong> — a git-ignored<br />  <code>addons/hermes-desktop/.sessions-fixture.json</code> (local test seam)<br />  holds the per-user session slices + stream chunks the route<br />  handlers consult. The shape is validated by <code>loadSessionsFixture</code><br />  so a malformed fixture fails the route handler with 503, never<br />  silently downgrades to an empty list.<br />- <strong>New skill</strong> — <code>hermes-desktop-sessions</code> exposes the per-user<br />  sessions endpoint to Aiopta&#39;s agent layer. Secret-free by<br />  construction (issue #101 invariant preserved).<br />- <strong>Settings panel</strong> (model / personality / skills / memory /<br />  automation-status) backed by a new endpoint and a new shell-state<br />  vocabulary (issue #121). The matrix is extended with an optional<br />  <code>group</code> field (currently the only allowed value is <code>&quot;settings&quot;</code>);<br />  the five settings-group rows are tagged accordingly.<br />- <code>src/lib/settings-shell-state.ts</code> — pure shell-state computation<br />  that maps a backend state × matrix row to a discrete state<br />  (<code>supported | unsupported | unreachable</code>). The verdict is <em>never</em><br />  optimistic: a <code>missing</code> matrix row stays <code>unsupported</code> regardless of<br />  live-source health, and a <code>full</code>/<code>partial</code>/<code>different</code> row on an<br />  unreachable / erroring / never-observed source renders as<br />  <code>unreachable</code>.<br />- <code>src/lib/settings-adapter.ts</code> — pure, secret-free adapter. Reads the<br />  matrix once at construction, filters rows whose `group ===<br />  &quot;settings&quot;`, and returns a per-row settings verdict keyed by<br />  <code>(backend, live-state-hash)</code>. The verdict is whitelist-serialized<br />  by <code>serializeSettingsSecretFree</code> so a future contributor adding a<br />  new field cannot leak a credential, an internal host, a base URL,<br />  or a setting value. Stable error code namespace:<br />  <code>SOURCE_UNREACHABLE</code>, <code>SOURCE_ERRORED</code>, <code>SOURCE_NEVER_OBSERVED</code>.<br />- <code>src/components/SettingsPanel.tsx</code> — shell page that renders the<br />  per-row settings verdict with the discrete shell state + last-<br />  refreshed timestamp + stable error code + Aiopta route reference.<br />- <code>src/app/api/v1/addons/hermes-desktop/settings/route.ts</code> —<br />  authenticated, secret-free settings endpoint. Reads the live per-row<br />  state from <code>addons/hermes-desktop/.settings-state.json</code> and falls<br />  back to <code>emptySettingsBackendState()</code> when the file is missing.<br />  Hard 30s timeout on the adapter evaluation.<br />- Updated <code>src/manifest.ts</code> to register the new <code>settings</code> route + the<br />  new skill (alongside the existing sessions routes from issue #120)<br />  and the bumped version (<code>0.2.0</code>).<br />- Updated <code>addon.json</code> to register the new endpoint + skill +<br />  capabilities (<code>settings-panel</code>, <code>settings-shell-state</code>) in addition<br />  to the existing sessions endpoints/capabilities (issue #120).<br />- Updated <code>hermes-desktop-parity.schema.json</code> to accept the optional<br />  <code>group</code> field.<br />- New unit tests (issue #121):<br />  <code>settings-shell-state.test.cjs</code> (full truth table + no-optimistic-<br />  flags guard), <code>settings-adapter.test.cjs</code> (happy path + secret-free<br />  invariant + cache invalidation on backend switch + cache<br />  invalidation on live-state change + cache TTL + error code mapping),<br />  <code>settings-route.test.cjs</code> (auth gate + happy path + secret-free<br />  response + per-row state mapping), <code>settings-backend-switch.test.cjs</code><br />  (E2E backend-switch test, hermes → openclaw).</p>\n<h3>Changed</h3>\n<p>- <code>hermes-desktop.pin.json</code> schema extended additively — every<br />  existing field is preserved; the four new fields are mandatory.<br />- <code>hermes-desktop-parity.json</code> <code>pin</code> block extended additively with<br />  the four contract fields + <code>compatibilityIssue</code>.<br />- <code>src/lib/shell-state.ts</code> truth-table now distinguishes image-<br />  digest mismatch (<code>down</code>) from API-schema drift on <code>full</code> rows<br />  (<code>unsupported-version</code>).<br />- <code>src/lib/matrix.ts</code> <code>validateMatrix</code> accepts the optional<br />  <code>expectedPin</code> argument and hard-fails on drift between matrix<br />  and pin JSON for every contract field.<br />- The 16-row matrix keeps all rows; the <code>image-pin</code> row status is<br />  unchanged (still <code>missing</code>) because the surface-level pin UI is<br />  out of scope for this issue. The data model + validator hardening<br />  IS the contract surface this issue ships.<br />- <code>addon.json</code> — version 0.2.0; capabilities extended with<br />  <code>image-digest-pinned</code>, <code>api-schema-version-pinned</code>,<br />  <code>supply-chain-checked</code> (issue #169) and<br />  <code>hermes-sessions</code>, <code>hermes-streaming</code>, <code>hermes-stream-stop</code>,<br />  <code>hermes-stream-retry</code> (issue #120); endpoint list extended with<br />  the four new routes; <code>pages[]</code> now also carries<br />  <code>hermes-desktop-sessions-page</code>.<br />- <code>src/manifest.ts</code> — registers the four new routes + the new<br />  page descriptor; <code>pages: [hermesDesktopPage, hermesSessionsPage]</code>.<br />- <code>hermes-desktop-parity.json</code> — <code>feat-chat-streaming</code> and<br />  <code>feat-sessions</code> rows now have <code>followUpIssue: null</code> (the UI<br />  surface shipped in #120). Status remains <code>partial</code> because the<br />  page is wired against a local fixture rather than a live chat<br />  backend driver — that live integration is a future follow-up.<br />- <code>feat-tool-activity</code> row still references #120 as the<br />  follow-up (the timeline is still routed through chat UI; this<br />  slice did not address it).</p>\n<h3>Internal</h3>\n<p>- New lib modules: <code>src/lib/session-source.ts</code> (typed contract),<br />  <code>src/lib/stream-buffer.ts</code> (dedupe + reconnect helper),<br />  <code>src/lib/session-page-state.ts</code> (page state reducer),<br />  <code>src/lib/sessions-fixture.ts</code> (fixture validator + helpers).<br />- New routes: <code>src/app/api/v1/addons/hermes-desktop/sessions/route.ts</code><br />  + <code>sessions/[id]/{stream,stop,retry}/route.ts</code>.<br />- New tests: <code>tests/unit/{stream-buffer,session-page-state,sessions-fixture,sessions-route,stream-route,stop-route,retry-route}.test.cjs</code><br />  + <code>tests/unit/_sessions-fixture-shared.cjs</code> (pure helper module<br />  shared by the route tests).<br />- <code>tests/unit/manifest.test.cjs</code> extended to assert the new<br />  capabilities, endpoints, skills, and pages[] entries.<br />- Path-param fix for <code>sessions/[id]/{stream,stop,retry}</code> route<br />  handlers: read <code>id</code> from <code>context.params.id</code> (awaited per Next.js<br />  15) instead of <code>req.nextUrl.searchParams</code> (the old code returned<br />  400 for every stream/stop/retry call).</p>\n<h3>Acceptance criteria (issue #121)</h3>\n<p>- ✅ Jedes Matrixfeature hat UI-Zustand supported/unsupported/unreachable —<br />  <code>settings-shell-state.test.cjs</code> pins every state × backend combination.<br />- ✅ Modelloptionen stammen aus Aiopta-Konfiguration — <code>SETTINGS_SOURCE_BINDINGS</code><br />  binds each matrix row to its Aiopta route; the verdict surfaces the<br />  reference, never the value.<br />- ✅ Memory/Automation zeigen Aktualität und Fehlerzustand — <code>lastRefreshedAt</code><br />  + <code>errorCode</code> are first-class verdict fields.<br />- ✅ Keine internen Hosts/Secrets im Browserpayload — <code>serializeSettingsSecretFree</code><br />  whitelist + JSON-stringified scan forbids <code>token</code>/<code>password</code>/<code>secret</code>/<br />  <code>apikey</code>/<code>bearer</code>/<code>credential</code>/<code>http://</code>/<code>https://</code>/<code>localhost</code>/<code>127.0.0.1</code>/<code>0.0.0.0</code>.<br />- ✅ Capability-Matrix-Tests — <code>matrix.test.cjs</code> §&quot;issue #121 — settings-group<br />  rows&quot; pins every settings-group row.<br />- ✅ Komponententests für supported/unsupported/down — <code>settings-shell-state.test.cjs</code>.<br />- ✅ E2E Backendwechsel Hermes → anderer Provider — <code>settings-backend-switch.test.cjs</code>.</p>\n<h2>0.1.0 — 2026-06-29 (issue #101)</h2>\n<h3>Added</h3>\n<p>- <code>hermes-desktop.pin.json</code> — commit-pinned reference to<br />  NousResearch <code>hermes-agent</code> <code>@ea8a8b4af8612b655a5bbfc74eba21e1e806758d</code>.<br />- <code>hermes-desktop-parity.json</code> — 16-row feature matrix binding every<br />  upstream Hermes desktop capability to an Aiopta contract (route +<br />  skill + UI + test) with a status of<br />  <code>full|partial|missing|unsupported|different</code>.<br />- <code>hermes-desktop-parity.schema.json</code> — JSON Schema (draft-07) for the<br />  matrix shape; the validator enforces the same shape at PR-check time.<br />- Capability adapter (<code>src/lib/capability-adapter.ts</code>) — pure, backend-<br />  aware, cache-aware, secret-free. Cache invalidates on backend switch.<br />- Shell state computation (<code>src/lib/shell-state.ts</code>) — maps a backend<br />  state × matrix row to a discrete shell state<br />  (<code>not-selected|down|old|unsupported|full</code>). No optimistic flags.<br />- Adapter endpoint (<code>src/app/api/v1/addons/hermes-desktop/capabilities</code>)<br />  — authenticated, secret-free. Returns status + references, never<br />  credentials.<br />- Shell page (<code>src/components/HermesDesktopPage.tsx</code>) — renders the<br />  matrix + the live verdict. Every cell carries the upstream commit<br />  ref + the follow-up issue (when status ≠ <code>full</code>).<br />- Auth / timeout / version fixtures (<code>src/lib/auth-fixture.ts</code>,<br />  <code>timeout-fixture.ts</code>, <code>version-fixture.ts</code>) — 4 + 3 + 3 scenarios<br />  each, driven by unit tests so a future change cannot silently weaken<br />  the contract.<br />- Validator hooks in <code>scripts/validate-addons.cjs</code>:<br />  <code>validateHermesDesktopPin</code> (commit pin equals canonical SHA) and<br />  <code>validateHermesMatrix</code> (16 unique rows, all upstream<br />  commits equal the pin, every <code>missing|different</code> row carries a<br />  numeric <code>followUpIssue</code>).<br />- Unit tests:<br />  <code>matrix.test.cjs</code>, <code>capability-adapter.test.cjs</code>,<br />  <code>shell-state.test.cjs</code>, <code>auth-fixture.test.cjs</code>,<br />  <code>timeout-fixture.test.cjs</code>, <code>version-fixture.test.cjs</code>,<br />  <code>capabilities-route.test.cjs</code>, <code>manifest.test.cjs</code>.</p>",
          "byteSize": 16961
        }
      }
    },
    {
      "id": "homeassistant",
      "uuid": "cba7bb26-7bbc-4718-b303-d7edfff5500d",
      "name": "Home Assistant",
      "version": "1.7.3",
      "description": "Monitor and control Home Assistant entities — lights, climate, sensors, switches, locks, automations, scenes and scripts via REST API. Curated automation & scene packs (formerly the standalone `smart-home` addon, consolidated per ADR-008 / #493) are a feature within Home Assistant. Includes an agent-skill-driven onboarding for the upstream `homeassistant-ai/ha-mcp` server (read-only by default).",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "entity-browser",
        "entity-control",
        "dashboard",
        "automation-trigger",
        "yaml-config-editor",
        "lovelace-dashboards",
        "helpers-management",
        "areas-management",
        "voice-assistant",
        "connection-config",
        "mcp-onboarding",
        "automation-scene-packs",
        "template-sensors",
        "service-call",
        "template-render",
        "entity-registry",
        "history",
        "lovelace-card-builder"
      ],
      "tags": [],
      "download": "/artifacts/homeassistant-1.7.3.zip",
      "sha256": "6b24ec4345d6efce5c90ca8d7c14429f91051a409854e2dbd625f2d9ee61a1a1",
      "size": 723507,
      "page": {
        "id": "homeassistant-page",
        "path": "/addons/homeassistant",
        "name": "Home Assistant",
        "icon": "🏠",
        "section": "Smart Home",
        "component": "@addons/homeassistant/src/components/HomeAssistantPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/homeassistant/status",
        "/api/v1/addons/homeassistant/entities",
        "/api/v1/addons/homeassistant/entities/:id",
        "/api/v1/addons/homeassistant/entities/:id/toggle",
        "/api/v1/addons/homeassistant/entities/:id/service",
        "/api/v1/addons/homeassistant/automations",
        "/api/v1/addons/homeassistant/automations/:id/trigger",
        "/api/v1/addons/homeassistant/automations/:id/yaml",
        "/api/v1/addons/homeassistant/automations/:id/update",
        "/api/v1/addons/homeassistant/scenes",
        "/api/v1/addons/homeassistant/scenes/:id",
        "/api/v1/addons/homeassistant/scenes/:id/yaml",
        "/api/v1/addons/homeassistant/scenes/:id/update",
        "/api/v1/addons/homeassistant/scenes/reload",
        "/api/v1/addons/homeassistant/scripts",
        "/api/v1/addons/homeassistant/scripts/:id",
        "/api/v1/addons/homeassistant/scripts/:id/yaml",
        "/api/v1/addons/homeassistant/scripts/:id/update",
        "/api/v1/addons/homeassistant/scripts/reload",
        "/api/v1/addons/homeassistant/lovelace/dashboards",
        "/api/v1/addons/homeassistant/lovelace/dashboards/:id",
        "/api/v1/addons/homeassistant/lovelace/dashboards/:id/config",
        "/api/v1/addons/homeassistant/helpers/:domain",
        "/api/v1/addons/homeassistant/helpers/:domain/:id",
        "/api/v1/addons/homeassistant/areas",
        "/api/v1/addons/homeassistant/areas/:id",
        "/api/v1/addons/homeassistant/assist",
        "/api/v1/addons/homeassistant/assist-config",
        "/api/v1/addons/homeassistant/config",
        "/api/v1/addons/homeassistant/test",
        "/api/v1/addons/homeassistant/services",
        "/api/v1/addons/homeassistant/services/:domain/:service",
        "/api/v1/addons/homeassistant/template",
        "/api/v1/addons/homeassistant/registry/entities",
        "/api/v1/addons/homeassistant/history",
        "/api/v1/addons/homeassistant/sensors",
        "/api/v1/addons/homeassistant/sensors/:id"
      ],
      "skills": [],
      "mcpServers": [
        {
          "id": "homeassistant-tools",
          "displayName": "Home Assistant tools",
          "description": "Capability-gegateter MCP-Server fuer Home Assistant: Zustaende lesen, Dienste aufrufen, Automationen lesen/anlegen/aendern/loeschen. Jedes mutierende Tool laeuft ueber Preview/Confirm (addons#930); destruktives Loeschen verlangt einen wortgleichen Grant. Zugangsdaten kommen ueber die Slots url/token, nie als Tool-Argument.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "homeassistant-tools/server.mjs",
          "permissions": [
            "addons/homeassistant/automation/delete",
            "addons/homeassistant/automation/read",
            "addons/homeassistant/automation/write",
            "addons/homeassistant/entity/call",
            "addons/homeassistant/entity/read"
          ],
          "toolPolicy": {
            "mode": "write",
            "allowDestructive": false
          },
          "configRefs": [
            "url"
          ],
          "credentialRefs": [
            "token"
          ],
          "archiveEntry": "homeassistant/mcp/servers/homeassistant-tools/server.mjs",
          "sha256": "d8d11bebbd0d22c123b9db7b7791e379f45168d7fadaf2968520188b0378d6f7",
          "size": 15448,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Home Assistant</h1>\n<p>Monitor and control Home Assistant entities — lights, climate, sensors, switches, locks,<br />automations, scenes and scripts — via the HA REST API. Edit automation/scene/script **code as<br />YAML<strong>, manage </strong>Lovelace dashboards (Views + Cards), Helpers (<code>input_*</code>) and Areas**, and expose a<br />Aiopta agent as a Home Assistant <strong>voice assistant</strong>. Onboard the upstream<br /><code>homeassistant-ai/ha-mcp</code> MCP server so Aiopta agents can read HA over MCP.</p>\n<h2>Überblick</h2>\n<p>- <strong>Addon-ID:</strong> <code>homeassistant</code><br />- <strong>Version:</strong> 1.0.0<br />- <strong>Fähigkeiten:</strong> entity-browser, entity-control, dashboard, automation-trigger,<br />  yaml-config-editor (automation/scene/script), lovelace-dashboards, helpers-management,<br />  areas-management, voice-assistant (Aiopta agent as HA conversation agent), connection-config,<br />  mcp-onboarding (ha-mcp setup flow, addons#179)</p>\n<h2>Einrichtung</h2>\n<p>1. <strong>HA-Verbindung</strong> (Settings-Zahnrad oben rechts): Home-Assistant-URL +<br />   Long-Lived Access Token. Wird unter <code>data/homeassistant-config.json</code> gespeichert; **kein<br />   hartkodierter Host**, kein Secret im Repo.<br />2. <strong>Voice Assistant</strong> (optional, Tab „Voice&quot;): Aiopta-Agent auswählen, der HA-Assist beantwortet.<br />3. <strong>HA-MCP für Agenten</strong> (Abschnitt oben auf der HA-Seite, ab addons#179): das Upstream-Add-on<br />   <code>homeassistant-ai/ha-mcp</code> einbinden, damit MCP-fähige Agenten Home Assistant lesen können. Wird<br />   unter dem Per-User-Slug <code>home-assistant</code> in der MCP-Addon-Datenbank gespeichert (kein zweites<br />   Secret in <code>homeassistant-config.json</code>). Feature-gated auf das <code>mcp</code>-Addon (ADR-047) — ist das<br />   MCP-Addon nicht installiert oder deaktiviert, erscheint eine klare Installieren-/Aktivieren-<br />   Handlung; die klassische REST/WS-Verbindung bleibt unabhängig nutzbar.</p>\n<h2>YAML-Editor (Automationen / Szenen / Skripte) — QA #31</h2>\n<p>Für Automationen, Szenen und Skripte lässt sich der <strong>tatsächliche Code (YAML)</strong> bearbeiten — nicht<br />nur der Name.</p>\n<p>- Tab <strong>Automations</strong> → Button „Edit YAML&quot; (oder Tabs <strong>Scenes</strong> / <strong>Scripts</strong> → „YAML&quot;).<br />- Der Editor lädt das HA-Config-Objekt als YAML (<code>GET …/&lt;domain&gt;s/[id]/yaml</code>), prüft die Eingabe<br />  <strong>live mit <code>js-yaml</code></strong> (gültig/ungültig-Status) und speichert über <code>PUT …/&lt;domain&gt;s/[id]/update</code><br />  mit <code>{ yaml }</code>.<br />- <strong>Server-seitig</strong> wird das YAML vor dem Schreiben geparst + validiert (<code>validateYaml</code> in<br />  <code>src/lib/yaml.ts</code>); nur ein gültiges <strong>Config-Objekt</strong> geht an HA<br />  (<code>POST /api/config/&lt;domain&gt;/config/&lt;object_id&gt;</code>) — nie roher YAML-Text, nie ein leeres/<code>null</code>/<br />  Listen-/Skalar-Dokument (das würde den Eintrag zerstören).<br />- Domains sind auf <code>automation</code> / <code>scene</code> / <code>script</code> whitelisted; alle drei sind voll editierbar.</p>\n<h2>Automationen/Routinen lesen+schreiben (Edit-Routine) — INBOX-60</h2>\n<p>Die HA-Config-API (<code>/api/config/automation/config/&lt;id&gt;</code>) ist <strong>nicht</strong> über den Entity-Slug<br />(<code>automation.wohnzimmer_indirekt_ein</code>) ansprechbar, sondern nur über die <strong>echte <code>attributes.id</code></strong><br />(numerische/uuid <code>unique_id</code>) — und sie bedient <strong>nur <code>.storage</code>/UI-definierte</strong> Automationen.</p>\n<p>- <strong>id-Auflösung:</strong> Beim Lesen/Schreiben einer Automation wird zuerst die echte <code>attributes.id</code> aus<br />  dem Entity-State (<code>GET /api/states/automation.&lt;slug&gt;</code> → <code>attributes.id</code>) aufgelöst und <strong>diese</strong> an<br />  die Config-API gegeben (<code>src/lib/client.ts → resolveAutomationConfigId</code>). Das war die Ursache des<br />  404 / der leeren Trigger/Action im „Edit Routine&quot;-Dialog.<br />- <strong>YAML-Automationen (kein Config-Eintrag):</strong> Schlägt die Config-API mit <strong>404</strong> fehl (Automation<br />  liegt in <code>automations.yaml</code>), wird <strong>best-effort</strong> die <strong>WebSocket-API</strong> versucht; bietet HA die<br />  Config dort nicht an, kommt eine <strong>klare, typisierte Meldung</strong> zurück (<code>HaYamlOnlyError</code>, HTTP<br />  <strong>422</strong>, <code>{ readOnly:true, reason:&quot;yaml_defined&quot; }</code>) — „YAML-definiert, hier nur lesbar; in HA<br />  bearbeiten&quot;. <strong>Nie ein nacktes 404 in der UI</strong>; der Edit-Dialog zeigt entweder echte Trigger/Action<br />  oder einen klaren Grund.<br />- Lesen/Schreiben/Löschen einer Routine laufen über <code>src/lib/automations.ts</code><br />  (<code>readAutomationConfig</code> / <code>writeAutomationConfig</code> / <code>deleteAutomationConfig</code>); Enable/Disable/Trigger<br />  über <code>automation.turn_on|turn_off|trigger</code>.</p>\n<h2>Voll-Kontrolle (Agent-Fähigkeiten: lesen + schreiben)</h2>\n<p>Das Addon (und ein Aiopta-Agent als HA-Voice-/Routinen-Schreiber) deckt über die Aiopta-API **lesend und<br />schreibend** ab:</p>\n<p>- <strong>Entitäten/Sensoren:</strong> lesen (<code>GET /entities</code>, <code>/entities/[id]</code>), schalten/setzen<br />  (<code>POST /entities/[id]/toggle</code>, <code>/entities/[id]/service</code> → <code>/api/services/&lt;domain&gt;/&lt;service&gt;</code>).<br />- <strong>Automationen/Routinen:</strong> lesen + anlegen + ändern + aktivieren/deaktivieren + auslösen + löschen<br />  (id-Auflösung + WS-Fallback wie oben).<br />- <strong>Szenen / Skripte:</strong> lesen/anlegen/löschen/reload + YAML-Edit.<br />- <strong>Helpers (<code>input_*</code>) / Areas:</strong> lesen/anlegen/ändern/löschen (WS).<br />- <strong>Lovelace-Dashboards:</strong> Views/Karten lesen + speichern (No-Wipe-Garantie).</p>\n<h2>Dashboards / Helpers / Areas / Szenen+Skripte-Verwaltung — ADR-028</h2>\n<p>Aufbauend auf der YAML/Entity/Config-Basis (ADR-018) lassen sich weitere HA-Konfigurationsflächen<br />direkt aus Aiopta verwalten. <strong>Aiopta-API only</strong>; HA über den Addon-Client (URL+Token aus der<br />Addon-Config, <strong>Token wird nie geloggt</strong>).</p>\n<p>WS-only-Flächen laufen über einen kleinen <strong>global-<code>WebSocket</code>-Helper</strong> (<code>src/lib/ha-ws.ts</code>) —<br /><strong>keine neue Dependency</strong> (Node-22-Built-in): eine authentifizierte WS wird geöffnet<br />(<code>auth_required</code>→<code>auth</code>→<code>auth_ok</code>), <strong>ein</strong> Kommando gesendet, das Ergebnis abgewartet, dann<br />geschlossen. Wo HA REST anbietet (Szenen/Skripte-Config) wird REST bevorzugt.</p>\n<p>- <strong>Tab Dashboards</strong> — Lovelace-Dashboards auflisten/anlegen/löschen und die <strong>Views + Karten</strong> eines<br />  Dashboards als YAML bearbeiten (<code>lovelace/config</code> get, <code>lovelace/config/save</code> set,<br />  <code>lovelace/dashboards/list|create|delete</code>). Vor dem Speichern wird die <strong>Form geprüft</strong> (Objekt mit<br />  <code>views[]</code>) — eine ungültige/leere Konfiguration wird <strong>abgelehnt</strong>, damit ein Dashboard nie<br />  überschrieben/geleert wird (<code>validateLovelaceConfig</code> in <code>src/lib/yaml.ts</code>, client <strong>und</strong> server).<br />- <strong>Tab Helpers</strong> — <code>input_boolean</code>, <code>input_number</code>, <code>input_text</code>, <code>input_select</code>, <code>input_datetime</code>,<br />  <code>input_button</code>: auflisten/anlegen/ändern/löschen über die WS-Collections<br />  (<code>&lt;domain&gt;/list|create|update|delete</code>); nur diese Domains sind erlaubt.<br />- <strong>Tab Areas</strong> — Bereiche auflisten/anlegen/umbenennen/löschen über die Area-Registry<br />  (<code>config/area_registry/list|create|update|delete</code>).<br />- <strong>Szenen &amp; Skripte</strong> — der YAML-Editor (oben) deckt die Config ab; zusätzlich gibt es<br />  list/create/delete/reload über die HA-Config-API (<code>/api/config/&lt;domain&gt;/config/...</code>,<br />  <code>&lt;domain&gt;.reload</code>).</p>\n<p><strong>Geparkt</strong> (Owner/Folge-Arbeit, <code>docs/OPEN-DECISIONS.md</code>): Entitäten/Geräte einem Bereich<br />zuweisen (<code>ha-area-entity-assignment</code>), HA-Version-abhängige WS-Edge-Cases<br />(<code>ha-ws-auth-edge-cases</code>).</p>\n<p><code>ha-lovelace-visual-designer</code> ist <strong>nicht mehr geparkt</strong> — die Kartenbibliothek im<br />Dashboard-Reiter ist mit addons#932 gebaut.</p>\n<h2>HA-MCP Onboarding (ha-mcp für Agenten einrichten) — addons#179</h2>\n<p>Ein zweiter, <strong>vom klassischen REST-Pfad unabhängiger</strong> Onboarding-Weg bindet das Upstream-Add-on<br /><code>homeassistant-ai/ha-mcp</code> ein, damit MCP-fähige Agenten die HA-Instanz über MCP lesen können.<br />Standardmäßig <strong>read-only</strong> — destruktive Tools (<code>ha_config_set_yaml</code>, <code>ha_write_file</code>,<br /><code>ha_delete_file</code>, <code>ha_rename_entity</code>, rohe YAML / Filesystem / Delete) bleiben blockiert und<br />brauchen eine separate explizite Freigabe (siehe #178, <code>mcp-tool-policy.ts</code>).</p>\n<p>- <strong>UI:</strong> Abschnitt „Home Assistant MCP for agents&quot; oben auf der HA-Seite (<code>HaMcpSetupSection.tsx</code>).<br />  URL + optionales Bearer-Token + Default-<code>read-only</code>-Toggle, plus eine sichtbare State-Machine<br />  (<code>not configured → saved/disabled → connecting → tools discovered → ready</code> | konkreter Fehler).<br />  Der Bereich erscheint auch wenn die klassische REST-Verbindung nicht konfiguriert ist, damit<br />  reine ha-mcp-Nutzer ohne REST-Token onboarden können.<br />- <strong>Chat:</strong> Agent-Skill <code>homeassistant-ha-mcp-setup</code> (siehe <code>addon.json</code>). Deterministischer Dialog<br />  (URL → Token → read-only → Setup → Resultat), identische Validierung und Idempotenz wie die UI.<br />- <strong>Feature-Gating:</strong> der gesamte Bereich ist in <code>&lt;FeatureGate addonId=&quot;mcp&quot;&gt;</code> eingebettet (ADR-047).<br />  Ohne / mit deaktiviertem MCP-Addon erscheint der Standard-„needs the MCP addon — enable it&quot;-<br />  Panel mit Link auf den Addon-Manager; die REST-Verbindung bleibt nutzbar.<br />- <strong>Server-Storage:</strong> Per-User-Slug <code>home-assistant</code> in der <code>mcp_servers</code>-Tabelle des MCP-Addons<br />  (<code>addons/mcp/src/lib/mcp-server-store.ts</code>). <strong>Kein</strong> zweites HA-MCP-Secret in<br />  <code>homeassistant-config.json</code> — die direkte HA-Config bleibt unverändert.<br />- <strong>Secret-Handling:</strong> die zufällige Secret-Path-Komponente der ha-mcp-URL ist ein Credential. Sie<br />  wird per User gespeichert, aber <code>maskMcpServer</code> maskiert sie auf jeder GET-Antwort, sobald das<br />  Feld <code>metadata.credentialInUrl: true</code> gesetzt ist (Antwort zeigt nur Origin +<br />  <code>••••••</code>). Der Bearer-Token folgt dem bestehenden <code>auth.token → &quot;••••••&quot;</code>-Vertrag. Beide werden<br />  in UI / Chat / Logs / Fehlern nie im Klartext ausgegeben — <code>sanitiseHaMcpError</code> entfernt Bearer-<br />  Header und <code>/private_&lt;token&gt;</code>-Pfade aus jeder Fehlermeldung, bevor sie die Komponente oder den<br />  Chat erreicht.<br />- <strong>Idempotenz:</strong> Mehrfaches Speichern mit unveränderter URL/Token ist ein No-op auf dem Wire; ein<br />  PATCH mit leerem Token löscht kein bestehendes Token; eine vorhandene strengere Policy<br />  (z. B. manuelle <code>deniedTools</code>-Liste unter <code>read-only</code>) wird nicht gelockert; eine vorhandene<br />  weitere Policy (<code>write</code>/<code>full</code>) wird auf <code>read-only</code> zurückgesetzt.<br />- <strong>Identity-Check:</strong> nach <code>discover</code> und <code>sync-tools</code> prüft der Flow, dass mindestens einer der<br />  ha-mcp-Identitätsmarker (<code>ha_get_overview</code> ODER <code>ha_get_skill_guide</code>) im entdeckten Toolset<br />  vorhanden ist. Ein beliebiger fremder MCP-Server wird <strong>nicht</strong> still als Home Assistant<br />  aktiviert; bei fehlendem Marker bleibt der Server deaktiviert und der Retry ist möglich.<br />- <strong>Kein Fake-Green:</strong> der Server wird nur aktiviert, wenn Handshake + Identity-Check grün sind.<br />  Bei jedem Fehlschlag bleibt der Datensatz deaktiviert und retry-fähig.<br />- <strong>OAuth:</strong> im ersten Schritt nicht unterstützt — statischer Bearer-Token (bzw. der vom Upstream<br />  auf HA OS / Supervised vererbte Supervisor-Token) ist der einzige aktuelle Auth-Flow, bis #177<br />  explizit einen OAuth-Flow nachliefert.<br />- <strong>Video + Upstream:</strong> siehe Video <a href=\"https://youtu.be/AL391nkWGIc\" rel=\"noopener noreferrer\">https://youtu.be/AL391nkWGIc</a> und Upstream-Repository<br />  <a href=\"https://github.com/homeassistant-ai/ha-mcp.\" rel=\"noopener noreferrer\">https://github.com/homeassistant-ai/ha-mcp.</a> <strong>Kein</strong> Forken / Vendoren / eigenes Container-<br />  Image; Aiopta greift ausschließlich über die HA-Add-on-Logs auf die vom Nutzer bereitgestellte<br />  URL zu.</p>\n<p>Installationsschritte HA-Seitig (Owner-Aktion, automatisierbar in einer Folge-Story):</p>\n<p>1. Repository <code>https://github.com/homeassistant-ai/ha-mcp</code> im HA Add-on Store hinzufügen.<br />2. <strong>Home Assistant MCP Server</strong> installieren und starten.<br />3. Vollständige Endpoint-URL (inkl. Secret-Path) aus dem Add-on-Log kopieren.<br />4. In Aiopta im HA-Add-on-Bereich „Home Assistant MCP for agents&quot; einfügen und Setup starten.<br />5. Auf HA OS / Supervised ist <strong>kein</strong> zusätzlicher Long-Lived Token nötig — das Upstream-Add-on<br />   erkennt die Supervisor-Verbindung selbst.</p>\n<h2>Voice Assistant (Aiopta-Agent als HA-Conversation-Agent) — QA #32</h2>\n<p>Ein Mission-Control-Agent kann HA-Assist beantworten. Aiopta stellt den Endpunkt; HA zeigt<br />seinen Conversation-Agent darauf.</p>\n<p>- Tab <strong>Voice</strong>: Aiopta-Agent auswählen (aus <code>/api/v1/agents/my-agents</code>), Endpunkt-URL kopieren,<br />  Setup-Schritte ansehen. Auswahl wird als <code>HA_ASSIST_AGENT_ID</code> in der Addon-Config gespeichert.<br />- <strong>Endpunkt:</strong> <code>POST /api/v1/addons/homeassistant/assist</code> mit<br />  <code>{ text, conversation_id?, agentId? }</code>. Der Text wird über <code>@/lib/gateway</code> an den gewählten Agenten<br />  geroutet (dessen Soul + Modell-Kette + Fallbacks, Backend-Binding) — <strong>kein</strong> direkter Provider-Call.<br />- <strong>Antwort:</strong> HA-<code>conversation.process</code>-Form<br />  <code>{ response: { speech: { plain: { speech } }, … }, conversation_id }</code>. Fehler werden als<br />  HA-Error-Response zurückgegeben (Assist bleibt am Leben), nicht als HTTP-Fehler.<br />- <strong>HA-Seite = Owner-Konfiguration</strong> (parked, <code>ha-assist-agent-setup</code> in<br />  <code>docs/OPEN-DECISIONS.md</code>): in HA einen REST/Conversation-Agent auf den Endpunkt zeigen, mit einem<br />  <strong>Aiopta-Agent-API-Key</strong> als Bearer-Token authentifizieren, und in eine <strong>Assist-Pipeline</strong> einhängen.<br />  Bis dahin funktioniert die Aiopta-Seite vollständig; HA muss den Endpunkt nur erreichen.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET | <code>/api/v1/addons/homeassistant/automations</code> |<br />| GET | <code>/api/v1/addons/homeassistant/automations/[id]</code> |<br />| POST | <code>/api/v1/addons/homeassistant/automations/[id]/toggle</code> |<br />| POST | <code>/api/v1/addons/homeassistant/automations/[id]/trigger</code> |<br />| PUT | <code>/api/v1/addons/homeassistant/automations/[id]/update</code> |<br />| GET | <code>/api/v1/addons/homeassistant/automations/[id]/yaml</code> |<br />| GET | <code>/api/v1/addons/homeassistant/scenes/[id]/yaml</code> |<br />| PUT | <code>/api/v1/addons/homeassistant/scenes/[id]/update</code> |<br />| GET | <code>/api/v1/addons/homeassistant/scripts/[id]/yaml</code> |<br />| PUT | <code>/api/v1/addons/homeassistant/scripts/[id]/update</code> |<br />| POST | <code>/api/v1/addons/homeassistant/assist</code> |<br />| GET, PUT | <code>/api/v1/addons/homeassistant/assist-config</code> |<br />| GET, PUT | <code>/api/v1/addons/homeassistant/config</code> |<br />| GET | <code>/api/v1/addons/homeassistant/entities</code> |<br />| GET | <code>/api/v1/addons/homeassistant/entities/[id]</code> |<br />| POST | <code>/api/v1/addons/homeassistant/entities/[id]/service</code> |<br />| POST | <code>/api/v1/addons/homeassistant/entities/[id]/toggle</code> |<br />| GET, POST | <code>/api/v1/addons/homeassistant/routines</code> |<br />| GET, PUT, DELETE | <code>/api/v1/addons/homeassistant/routines/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/homeassistant/lovelace/dashboards</code> |<br />| DELETE | <code>/api/v1/addons/homeassistant/lovelace/dashboards/[id]</code> |<br />| GET, PUT | <code>/api/v1/addons/homeassistant/lovelace/dashboards/[id]/config</code> |<br />| GET, POST | <code>/api/v1/addons/homeassistant/helpers/[domain]</code> |<br />| PUT, DELETE | <code>/api/v1/addons/homeassistant/helpers/[domain]/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/homeassistant/areas</code> |<br />| PUT, DELETE | <code>/api/v1/addons/homeassistant/areas/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/homeassistant/scenes</code> |<br />| DELETE | <code>/api/v1/addons/homeassistant/scenes/[id]</code> |<br />| POST | <code>/api/v1/addons/homeassistant/scenes/reload</code> |<br />| GET, POST | <code>/api/v1/addons/homeassistant/scripts</code> |<br />| DELETE | <code>/api/v1/addons/homeassistant/scripts/[id]</code> |<br />| POST | <code>/api/v1/addons/homeassistant/scripts/reload</code> |<br />| GET | <code>/api/v1/addons/homeassistant/status</code> |<br />| POST | <code>/api/v1/addons/homeassistant/test</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<p>- Alle Verwaltungsfunktionen (YAML-Editor, Dashboards, Helpers, Areas) verlangen eine konfigurierte<br />  HA-Verbindung (URL + Token); ist HA nicht erreichbar, antworten die Routen mit einem klaren<br />  Nicht-200-Status statt eines Absturzes.<br />- Dashboards: <strong>Kartenbibliothek</strong> (sechs Lovelace-Kartentypen: Kachel, Verlauf, Thermostat,<br />  Energie, Kamera, Sensor-Gruppe) <strong>und</strong> YAML-Editor für Views/Karten. Beide arbeiten auf<br />  derselben Konfiguration und speichern über denselben Weg — es gibt genau einen Speicherpfad<br />  (<code>saveDashboardConfig</code>), damit kein Builder ein Dashboard leeren kann.<br />- Areas: Bereiche-CRUD; <strong>Entitäten/Geräte einem Bereich zuweisen</strong> ist geparkt<br />  (<code>ha-area-entity-assignment</code>).<br />- Die <strong>HA-seitige</strong> Voice-Assistant-Verkabelung ist Owner-Konfiguration (siehe oben /<br />  <code>docs/OPEN-DECISIONS.md</code> → <code>ha-assist-agent-setup</code>).<br />- <strong>HA-MCP</strong>-Onboarding (addons#179): Standard-Policy ist <code>read-only</code>. Der explizite<br />  „Switch zu <code>write</code> / <code>full</code>&quot;-Flow ist <strong>eine separate Folge-Story</strong> (nicht in #179 enthalten);<br />  destruktive Tools bleiben so lange gesperrt, wie der Nutzer nicht ausdrücklich zustimmt.<br />  OAuth-Auth ist im aktuellen Schritt nicht unterstützt.</p>\n<p>&gt; Architektur-Entscheidungen: <code>docs/architecture/ADR-018-homeassistant-yaml-voice.md</code> (YAML+Voice),<br />&gt; <code>docs/architecture/ADR-028-ha-dashboards-helpers-areas.md</code> (Dashboards/Helpers/Areas/Szenen+Skripte),<br />&gt; <code>docs/architecture/ADR-047-addon-dependencies-feature-gating.md</code> (Feature-Gating über das<br />  MCP-Addon), <code>docs/architecture/ADR-054-user-scoped-data-access.md</code> (Per-User-Isolation für die<br />  <code>home-assistant</code>-MCP-Zeile). Voraussetzungen für die HA-MCP-Sicherheit: #177 (Streamable-HTTP-<br />  Transport im MCP-Addon) + #178 (Least-Privilege-Tool-Policy im MCP-Addon).</p>",
          "byteSize": 16419
        }
      }
    },
    {
      "id": "jarvis",
      "uuid": "610e4b31-2e5a-436a-8f83-723035f05fbf",
      "name": "Jarvis",
      "version": "1.0.5",
      "description": "Voice-first personal operations console with morning briefing, live weather, agenda, Tesla controls, and Home Assistant visibility.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "voice-assistant",
        "briefing",
        "calendar",
        "weather",
        "tesla",
        "home-assistant"
      ],
      "tags": [],
      "download": "/artifacts/jarvis-1.0.5.zip",
      "sha256": "1ab10fdd80f9d366a4e2799ab0d217d8b62fc94b5759495924fd1d49de672058",
      "size": 103993,
      "page": {
        "path": "/addons/jarvis",
        "name": "Jarvis",
        "icon": "🤖",
        "section": "Tools",
        "component": "@addons/jarvis/src/components/dashboard/JarvisDashboard"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/jarvis/config",
        "/api/v1/addons/jarvis",
        "/api/v1/addons/jarvis/briefing",
        "/api/v1/addons/jarvis/weather",
        "/api/v1/addons/jarvis/calendar",
        "/api/v1/addons/jarvis/tesla",
        "/api/v1/addons/jarvis/home-assistant"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Jarvis AI Assistant — Aiopta Addon</h1>\n<p>Iron Man-style AI assistant with weather, calendar, Tesla, and home automation.</p>\n<h2>Installation</h2>\n<ol><li>Copy this addon into <code>aiopta/addons/jarvis/</code></li><li>Copy <code>.env.example</code> to <code>.env.local</code> and configure</li><li>Install dependencies: <code>npm install</code></li><li>Rebuild Aiopta</li></ol>\n<h2>Features</h2>\n<p>| Feature | API | Auth Required |<br />|---------|-----|--------------|<br />| Weather | Open-Meteo | No |<br />| Calendar | Google Calendar | API Key |<br />| Tesla | Fleet API | OAuth Token |<br />| Home Assistant | REST API | Long-Lived Token |<br />| Morning Briefing | Aggregates all | Per-service |</p>\n<h2>API Endpoints</h2>\n<p>### Weather<br />```<br />GET /api/jarvis/weather?lat=52.52&amp;lon=13.40&amp;forecast=true<br />```</p>\n<p>### Calendar<br />```<br />GET /api/jarvis/calendar?today=true<br />GET /api/jarvis/calendar?start=2026-01-01&amp;end=2026-01-07<br />```</p>\n<p>### Tesla<br />```<br />GET  /api/jarvis/tesla                    # List vehicles<br />GET  /api/jarvis/tesla?vehicleId=123      # Vehicle status<br />POST /api/jarvis/tesla                    # Send command<br />     { &quot;vehicleId&quot;: &quot;123&quot;, &quot;action&quot;: &quot;wake&quot; }<br />```</p>\n<p>Actions: <code>wake</code>, <code>lock</code>, <code>unlock</code>, <code>honk</code>, <code>flash</code>, <code>climate_on</code>, <code>climate_off</code>, <code>charge_start</code>, <code>charge_stop</code></p>\n<p>### Home Assistant<br />```<br />GET  /api/jarvis/home-assistant                  # All entities<br />GET  /api/jarvis/home-assistant?entityId=light.living_room<br />POST /api/jarvis/home-assistant<br />     { &quot;entityId&quot;: &quot;light.living_room&quot;, &quot;service&quot;: &quot;toggle&quot; }<br />```</p>\n<p>### Morning Briefing<br />```<br />GET /api/jarvis/briefing<br />```</p>\n<h2>Environment Variables</h2>\n<p>| Variable | Default | Description |<br />|----------|---------|-------------|<br />| <code>WEATHER_DEFAULT_LAT</code> | 52.5200 | Default latitude (Berlin) |<br />| <code>WEATHER_DEFAULT_LON</code> | 13.4050 | Default longitude (Berlin) |<br />| <code>GOOGLE_CALENDAR_API_KEY</code> | — | Google API key |<br />| <code>GOOGLE_CALENDAR_ID</code> | — | Calendar ID |<br />| <code>TESLA_ACCESS_TOKEN</code> | — | Tesla OAuth token |<br />| <code>HOMEASSISTANT_URL</code> | — | HA instance URL |<br />| <code>HOMEASSISTANT_TOKEN</code> | — | HA long-lived token |</p>\n<h2>Architecture</h2>\n<p>This is a <strong>pure addon</strong> — zero changes to Aiopta core. Each service gracefully degrades when unconfigured (returns empty data).</p>\n<h2>Troubleshooting</h2>\n<ul><li><strong>Weather not loading</strong>: No config needed. Check network connectivity.</li><li><strong>Calendar empty</strong>: Set <code>GOOGLE_CALENDAR_API_KEY</code> and <code>GOOGLE_CALENDAR_ID</code>.</li><li><strong>Tesla not connected</strong>: Generate a token via Tesla developer portal.</li><li><strong>Home Assistant offline</strong>: Verify URL is reachable and token is valid.</li></ul>",
          "byteSize": 2396
        }
      }
    },
    {
      "id": "layery-druckzentrale",
      "uuid": "e5fbff65-9c1d-476f-aaa0-59655812094e",
      "name": "LayerY Druckzentrale",
      "version": "0.25.0",
      "description": "Die Bedienoberflaeche der LayerY-Druckzentrale, in Aiopta. Auftraege des eigenen Slice-Dienstes ansehen (Zustand samt Phase und Aktivitaet, Mengen, Summen, Platten mit serverseitig gerenderter Vorschau, Teile, Hinweise, Artefakte, und bei einem Fehlschlag der unterscheidbare Grund - Zeitlimit, Speichergrenze oder ein Riss der Strecke zum Ausfuehrungsort, jeder mit seinen Zahlen und dem Handgriff, der hilft) und einem laufenden Auftrag beim Schneiden zusehen (Live-Fortschritt, Takt am Zustand des Auftrags und nicht an der geoeffneten Seite), sehen, was ueberhaupt waehlbar ist (zentral aktivierte Drucker-/Prozesspaare, Filamentbestand, Druckerzustand, der Konfigurationsausschnitt dieses Zugangs und die Auftragsfelder - in EINER Antwort aus dem Dienst, nicht im Addon zusammengestellt), die Preisgrundlage lesen, einen Auftrag neu schneiden lassen — und das Angebot dazu lesen, bepreisen und einreichen, ohne dass der Slice-Dienst Aiopta von aussen ruft. Die Angebots- und ERP-Strecke laeuft INNERHALB von Aiopta ueber das erpnext-Addon; ERP-Adresse und ERP-Token bleiben dort und werden hier nicht ein zweites Mal gefuehrt. Bepreist wird ausschliesslich ueber die Unterzeile der Position, nie ueber den item_code — bei LayerY nummeriert der nur durch, und zwei vertauschte Preise faenden darueber nicht auseinander. Der WooCommerce-Bestellblock kommt aus dem wordpress-Addon und ist eine optionale Kante: ohne Shop bleibt das Angebot lesbar und die Oberflaeche sagt, dass der Bestellstand fehlt. Senden ist in dieser Fassung bewusst NICHT verdrahtet und wird als nicht verfuegbar samt Grund angezeigt — es ist der einzige Weg, dessen Fehler den Kunden erreicht, und bekommt deshalb eine eigene Scheibe mit eigener Berechtigung. Der Slice-Dienst bleibt ein eigener Dienst: OrcaSlicer-CLI, Profil-Repository und Auftragsablage passen nicht in einen Next.js-Container. Eigentuemer und Sichtbarkeit kommen aus dem Dienst und werden dargestellt, nicht neu erfunden. Angebunden ueber HMAC-Grants (Kopf X-Aiopta-Grant, Audience addon:layery-slicer, TTL 120 s) oder ersatzweise ueber das geteilte Dienst-Wort (Kopf X-Slicer-Token). Kein vorbelegter Wirt: ohne Basis-URL und ohne Zugang antwortet jede Route mit einem sprechenden Fehler und es geht nichts ans Netz.",
      "author": "Aiopta",
      "icon": "🖨️",
      "capabilities": [
        "layery",
        "3d-printing",
        "print-jobs",
        "slicing",
        "print-shop-operations"
      ],
      "tags": [],
      "download": "/artifacts/layery-druckzentrale-0.25.0.zip",
      "sha256": "74aed25120b5793599b8e653c1eea7e5b5c44eefc51457955b064e16f15e4ff1",
      "size": 1161241,
      "page": {
        "id": "layery-druckzentrale",
        "path": "/addons/layery-druckzentrale",
        "name": "LayerY Druckzentrale",
        "icon": "🖨️",
        "section": "Tools",
        "component": "@addons/layery-druckzentrale/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/layery-druckzentrale/capabilities",
        "/api/v1/addons/layery-druckzentrale/config/stufenprofile",
        "/api/v1/addons/layery-druckzentrale/jobs",
        "/api/v1/addons/layery-druckzentrale/jobs/{id}",
        "/api/v1/addons/layery-druckzentrale/jobs/{id}/ai-stages/{stage}/reset",
        "/api/v1/addons/layery-druckzentrale/jobs/{id}/calculation",
        "/api/v1/addons/layery-druckzentrale/jobs/{id}/live",
        "/api/v1/addons/layery-druckzentrale/jobs/{id}/mold",
        "/api/v1/addons/layery-druckzentrale/jobs/{id}/mold/approve",
        "/api/v1/addons/layery-druckzentrale/jobs/{id}/preview/{name}",
        "/api/v1/addons/layery-druckzentrale/jobs/{id}/projektbasis",
        "/api/v1/addons/layery-druckzentrale/jobs/{id}/reslice",
        "/api/v1/addons/layery-druckzentrale/jobs/{id}/source-files",
        "/api/v1/addons/layery-druckzentrale/quotes/{name}",
        "/api/v1/addons/layery-druckzentrale/quotes/{name}/pdf",
        "/api/v1/addons/layery-druckzentrale/quotes/{name}/prices",
        "/api/v1/addons/layery-druckzentrale/quotes/{name}/submit"
      ],
      "skills": [
        {
          "name": "druckzentrale-jobs-read",
          "displayName": "Druckzentrale: Auftraege ansehen",
          "description": "Listet die Auftraege des Slice-Dienstes mit Zustand, Platten, Dauer und Quelle und liest einen einzelnen Auftrag samt Zustand, Phase, Mengen, Summen, Plattenliste, Teilen, Hinweisen, Eigentuemer, Sichtbarkeit und erzeugten Dateien — dazu den Live-Fortschritt eines laufenden Laufes und die Auskunft, was ueberhaupt waehlbar ist. Reiner Lesezugriff.",
          "permissions": [
            "addons/layery-druckzentrale/jobs/read"
          ],
          "endpoint": "/api/v1/addons/layery-druckzentrale/jobs",
          "scripts": []
        },
        {
          "name": "druckzentrale-pricing-read",
          "displayName": "Druckzentrale: Preisgrundlage lesen",
          "description": "Liest die Preisgrundlage eines Auftrags: Mengen, die Aufstellung als Tabelle samt Herkunft je Zahl, den Preisspiegel, die Preisstreuung, die Maschinenherkunft mit den Werten, die gefehlt haben, und den Freigabestand. Rechnet nichts nach. Eigene Permission, getrennt vom Auftragslesen.",
          "permissions": [
            "addons/layery-druckzentrale/pricing/read"
          ],
          "endpoint": "/api/v1/addons/layery-druckzentrale/jobs/{id}/calculation",
          "scripts": []
        },
        {
          "name": "druckzentrale-job-reslice",
          "displayName": "Druckzentrale: Auftrag neu schneiden",
          "description": "Laesst genau einen Auftrag neu schneiden — mit Spule, Profil, Ausfuehrungsort, Stueckzahlen je Datei, Dateiauswahl, Prozesswerten, Profil-Overrides und Anweisung, oder ueber den zweiten Weg gegen eine bestaetigte Projektquelle. Verwirft auf Wunsch vorher die gemerkte Entscheidung einer KI-Stufe; scheitert das, wird NICHT geschnitten. Eigene, hoechste Trust-Stufe — getrennt vom Lesen. Kein Wiederholversuch: der Schnitt belegt Rechenzeit und schreibt Dateien.",
          "permissions": [
            "addons/layery-druckzentrale/jobs/read",
            "addons/layery-druckzentrale/jobs/reslice"
          ],
          "endpoint": "/api/v1/addons/layery-druckzentrale/jobs/{id}/reslice",
          "scripts": []
        },
        {
          "name": "druckzentrale-quote-read",
          "displayName": "Druckzentrale: Angebot lesen",
          "description": "Liest das Angebot zu einem Auftrag: Positionen samt Unterzeile, Summen, Belegstand, Kunde, Gueltigkeit, PDF-Adresse und den WooCommerce-Bestellstand, sofern das Shop-Addon da ist. Reiner Lesezugriff.",
          "permissions": [
            "addons/layery-druckzentrale/quotes/read"
          ],
          "endpoint": "/api/v1/addons/layery-druckzentrale/quotes/{name}",
          "scripts": []
        },
        {
          "name": "druckzentrale-quote-price",
          "displayName": "Druckzentrale: Angebot bepreisen",
          "description": "Setzt Preise an den Positionen eines Angebotsentwurfs. Getroffen wird ueber die Unterzeile oder ueber den Namen der Kundendatei — nie ueber den item_code. Kein implizites Einreichen, kein Versand. Lesen wird mitverlangt, weil vor dem Schreiben frisch gelesen wird.",
          "permissions": [
            "addons/layery-druckzentrale/quotes/read",
            "addons/layery-druckzentrale/quotes/price"
          ],
          "endpoint": "/api/v1/addons/layery-druckzentrale/quotes/{name}/prices",
          "scripts": []
        },
        {
          "name": "druckzentrale-quote-submit",
          "displayName": "Druckzentrale: Angebot einreichen",
          "description": "Reicht einen Angebotsentwurf ein (docstatus 0 -> 1). Eigene, hoechste Trust-Stufe, getrennt vom Bepreisen. Verschickt nichts an den Kunden.",
          "permissions": [
            "addons/layery-druckzentrale/quotes/read",
            "addons/layery-druckzentrale/quotes/submit"
          ],
          "endpoint": "/api/v1/addons/layery-druckzentrale/quotes/{name}/submit",
          "scripts": []
        }
      ],
      "mcpServers": [
        {
          "id": "druckzentrale-tools",
          "displayName": "LayerY Druckzentrale tools",
          "description": "Read-only Werkzeuge fuer Agents: Auftraege des Slice-Dienstes auflisten, einen Auftrag samt Eigentuemer und Sichtbarkeit lesen, die erzeugten Dateien auflisten. Der neu schneidende Pfad ist bewusst NICHT als Werkzeug exponiert — ein Schnitt belegt Rechenzeit und ist nicht wiederholbar; er laeuft ausschliesslich ueber die API-Route mit eigener Berechtigung. Die Preisgrundlage ist ebenfalls nicht exponiert: sie haengt an einer eigenen Berechtigung und traegt Betriebszahlen. Authentifiziert ueber die Slots baseUrl/grantSecret/apiToken, nie als Werkzeug-Argument.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "druckzentrale-tools/server.mjs",
          "permissions": [
            "addons/layery-druckzentrale/jobs/read"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [
            "baseUrl"
          ],
          "credentialRefs": [
            "grantSecret",
            "apiToken"
          ],
          "archiveEntry": "layery-druckzentrale/mcp/servers/druckzentrale-tools/server.mjs",
          "sha256": "0b8bde17dd011eb6ca4eaedd10869574d510764e8370790f590f5521d71cf95e",
          "size": 16797,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>LayerY Druckzentrale</h1>\n<p>Die Bedienoberflaeche der LayerY-Druckzentrale — <strong>in Aiopta</strong>. Auftraege des<br />eigenen Slice-Dienstes ansehen, die Preisgrundlage lesen und einen Auftrag neu<br />schneiden lassen, ohne zweite Oberflaeche und ohne zweite Anmeldung.</p>\n<p>Issue: <code>addons#1229</code>. Vorbedingung: <code>addons#1195</code> (Eigentuemer und<br />Sichtbarkeit, im Slice-Dienst umgesetzt am 10.08.2026).</p>\n<hr />\n<h2>Der Schnitt: was hier liegt und was nicht</h2>\n<p>Der Slice-Dienst bleibt ein <strong>eigener Dienst</strong>. Nicht aus Bequemlichkeit:<br />OrcaSlicer-CLI, das Profil-Repository (ein Git-Klon mit Nutzerprofilen) und<br />die Auftragsablage passen nicht in einen Next.js-Container.</p>\n<p>| | bleibt im Slice-Dienst | liegt in diesem Addon |<br />| --- | --- | --- |<br />| Schneiden, Anordnen, G-Code | X | |<br />| Profilregister, Filament, Ablage | X | |<br />| G-Code-Nachbearbeitung (M486) | X | |<br />| Bedienoberflaeche | | X |<br />| Angebot, ERP-Anbindung | | X <em>(noch nicht umgezogen, siehe unten)</em> |<br />| Shop-/WooCommerce-Anbindung | | X <em>(noch nicht umgezogen, siehe unten)</em> |<br />| Eigentuemer und Sichtbarkeit | Durchsetzung | Darstellung |</p>\n<p>Der Dienst hat heute 43 HTTP-Routen. Ein Addon, das sie weiterreicht, ist kein<br />Addon, sondern ein Reverse Proxy mit Aiopta-Auth — dann laege die Drucklogik<br />doch wieder in Aiopta, nur verteilt ueber Routenpfade statt ueber Module.<br />Dieses Addon ruft deshalb <strong>vier</strong> Dienstrouten.</p>\n<p>Bewusst <strong>draussen</strong>: Vorschaubilder, G-Code-Auslieferung, 3MF- und<br />Quelldateien, Live-Protokoll. Beim G-Code ist das keine Bequemlichkeit — die<br />Dienstroute streamt bereits, und eine puffernde Aiopta-Route machte aus einem<br />40-MB-Strom einen 40-MB-Puffer.</p>\n<hr />\n<h2>Einrichtung</h2>\n<p>Alles wird in der Oberflaeche gesetzt (Einstellungen → Addons → LayerY<br />Druckzentrale). Es gibt <strong>keine Datei</strong>, die von Hand zu pflegen waere, und<br /><strong>keine Vorgabe</strong> fuer irgendeinen Wirt.</p>\n<p>| Einstellung | Pflicht | Bedeutung |<br />| --- | --- | --- |<br />| <code>baseUrl</code> | ja | Wurzel des eigenen Slice-Dienstes, ohne Pfad |<br />| <code>grantSecret</code> | einer von beiden | geteiltes Geheimnis fuer kurzlebige Grants (im Dienst: <code>AIOPTA_GRANT_SECRET</code> bzw. die Datei aus <code>AIOPTA_GRANT_SECRET_DATEI</code>) |<br />| <code>apiToken</code> | einer von beiden | Ersatzweg: das Dienst-Wort (<code>SLICER_API_TOKEN</code> bzw. <code>SLICER_TOKEN_DATEI</code>) |</p>\n<p>Ist die Basis-URL oder jeder Zugang leer, antwortet <strong>jede</strong> Route mit<br /><code>503 druckzentrale_not_configured</code> — und es geht <strong>kein Netzruf</strong> raus. Das<br />ist gemessen, nicht behauptet: <code>tests/unit/route-contract.test.ts</code> prueft, dass<br /><code>fetch</code> in diesem Fall nicht einmal gerufen wird.</p>\n<h3>Warum es keine vorbelegte Adresse gibt</h3>\n<p>Der Fehler sieht harmlos aus und ist es nicht. <code>addons/layery-druckzentrale</code><br />geht an <em>jede</em> Aiopta-Installation. Stuende hier ein Wirt als Vorgabe, riefe<br />der Server eines fremden Betreibers, der das Feld leer laesst, still den<br />Produktivdienst eines fremden Kunden auf. Drei Addons dieses Repos sind ueber<br />genau diese Falle gestolpert (<code>spoolman</code> #1226, <code>erpnext</code> #1220,<br /><code>octoprint-proxy</code> #1215).</p>\n<h3>Grant oder Dienst-Wort — der Unterschied ist sichtbar</h3>\n<p>* <strong>Grant</strong> (empfohlen): das Addon stellt je Ruf einen kurzlebigen,<br />  HMAC-signierten Ausweis aus (<code>X-Aiopta-Grant</code>, Audience<br />  <code>addon:layery-slicer</code>, Laufzeit 120 s). Er traegt die Kennung des<br />  angemeldeten Aiopta-Nutzers, und **nur so greifen Eigentuemer und<br />  Sichtbarkeit des Dienstes je Aufrufer**.<br />* <strong>Dienst-Wort</strong>: das Addon schickt <code>X-Slicer-Token</code>. Der Dienst behandelt<br />  jeden Traeger als Betreiber — der angemeldete Nutzer sieht damit <strong>alle</strong><br />  Auftraege.</p>\n<p>Der Grant hat Vorrang, wenn beides gesetzt ist. Und die Oberflaeche schreibt<br />hin, welcher Weg gerade laeuft: eine Betreibersicht, die man fuer seine eigene<br />haelt, ist schlimmer als gar keine.</p>\n<p>Das Grant-Format ist byte-genau das aus <code>app/grant.py</code> des Dienstes; der<br />Vergleichswert steht als gemessener Testvektor in <code>tests/unit/grant.test.ts</code>.</p>\n<hr />\n<h2>Routen</h2>\n<p>| Aiopta-Route | Methode | Bildet ab auf | Permission |<br />| --- | --- | --- | --- |<br />| <code>…/jobs</code> | GET | <code>GET /jobs</code> | <code>addons/layery-druckzentrale/jobs/read</code> |<br />| <code>…/jobs/{id}</code> | GET | <code>GET /slice/{id}</code> <strong>+</strong> <code>GET /slice/{id}/dateien</code> | <code>addons/layery-druckzentrale/jobs/read</code> |<br />| <code>…/jobs/{id}/calculation</code> | GET | <code>GET /slice/{id}/kalkulation</code> | <code>addons/layery-druckzentrale/pricing/read</code> |<br />| <code>…/jobs/{id}/reslice</code> | POST | <code>POST /slice/{id}/neu</code> | <code>addons/layery-druckzentrale/jobs/reslice</code> |<br />| <code>…/quotes/{name}</code> | GET | <code>erpnext</code> in-Prozess <strong>+</strong> <code>wordpress</code> (optional) | <code>addons/layery-druckzentrale/quotes/read</code> |<br />| <code>…/quotes/{name}/pdf</code> | GET | <code>erpnext</code> in-Prozess | <code>addons/layery-druckzentrale/quotes/read</code> |<br />| <code>…/quotes/{name}/prices</code> | POST | <code>erpnext</code> in-Prozess | <code>addons/layery-druckzentrale/quotes/price</code> |<br />| <code>…/quotes/{name}/submit</code> | POST | <code>erpnext</code> in-Prozess | <code>addons/layery-druckzentrale/quotes/submit</code> |</p>\n<p>Die Preisgrundlage haengt an einer <strong>eigenen</strong> Berechtigung. Wer sehen darf,<br />was gerade druckt, darf deshalb noch nicht sehen, was es den Betrieb kostet:<br />die Aufstellung traegt Stundensaetze, Materialpreise und Aufschlaege, die<br />Auftragsliste nicht.</p>\n<h2>Angebot und ERP: der Ruf entsteht INNERHALB von Aiopta</h2>\n<p>Die Angebotsstrecke lag in <code>app/shop.py</code> des Slice-Dienstes und ging von dort<br />ueber n8n-Webhooks (Workflows 07–11) an ERPNext. Der Dienst brauchte dafuer<br />eine Aiopta-Adresse und einen Agent-Key — einen Netzruf von <strong>aussen</strong> nach<br />innen. Die vier <code>quotes</code>-Routen loesen das ab.</p>\n<h3>In-Prozess statt Rueckschleife — und was das kostet</h3>\n<p>Es gibt zwei uebliche Wege, ein fremdes Addon zu benutzen. Dieses Addon nimmt<br />den zweiten:</p>\n<p>1. <strong>Rueckschleife</strong> — ein echter HTTP-Ruf auf die eigene Aiopta-Instanz, wie<br />   <code>seo-content</code> → <code>wordpress</code>. Das haette den Netzruf nur nach innen<br />   verschoben und einen weiteren installationsgebundenen Wert gebraucht (die<br />   eigene Basis-Adresse).<br />2. <strong>In-Prozess</strong> — die Client-Bibliothek von <code>erpnext</code> wird direkt benutzt,<br />   wie <code>chat</code> die von <code>browser</code> benutzt. Kein zweiter Netzweg, kein zweites<br />   Envelope, ein Zugang.</p>\n<p><strong>Der Preis, offen benannt:</strong> die Routen von <code>erpnext</code> laufen dabei nicht mit,<br />und deren Permission <code>addons/erpnext/quotation/read</code> wird <strong>nicht</strong> geprueft.<br />Die Autorisierung liegt vollstaendig bei den Routen dieses Addons. Sie sind<br />deshalb nicht schwaecher gestuft als das ERP-Gegenstueck: <code>quotes/price</code> und<br /><code>quotes/submit</code> stehen auf <code>highest</code>. Wer <code>src/lib/quote-bridge.ts</code> aendert,<br />aendert eine Berechtigungsgrenze.</p>\n<h3>Bepreist wird ueber die Unterzeile, nie ueber den <code>item_code</code></h3>\n<p>Bei LayerY nummeriert der <code>item_code</code> nur durch (<code>LaY-3D-3440-01</code>,<br /><code>LaY-3D-3440-02</code>) und sagt nichts darueber, welche Datei die Zeile meint. Zwei<br />Positionen mit vertauschten Preisen faenden darueber nicht auseinander; am<br />06.08.2026 hat genau diese Fehlerklasse im Betrieb 141,83 EUR statt 36,98 EUR<br />ergeben. <code>addons/erpnext</code> nimmt seit 0.3.0 die Unterzeile (<code>items[].name</code>) an<br />und laesst sie den <code>item_code</code> schlagen — dieses Addon liefert ausschliesslich<br />sie, und der <code>item_code</code> wird als Schluessel <strong>gar nicht erst angenommen</strong>.</p>\n<p>Weil der Betreiber den Hash nicht kennt, nimmt die Route auch <code>source</code> (den<br />Namen der Kundendatei) und loest ihn gegen den frisch gelesenen Beleg auf<br />(<code>src/lib/position-match.ts</code>, Portierung von <code>shop.position_fuer_quelle</code>).<br />Null oder mehrere Treffer sind ein lauter Fehler — geraten wird nicht.</p>\n<h3>Kein zweites ERP-Geheimnis</h3>\n<p>Dieses Addon fuehrt <strong>keine</strong> ERP-Adresse und <strong>kein</strong> ERP-Token im eigenen<br /><code>settingsSchema</code>. Beides bleibt beim <code>erpnext</code>-Addon. Waeren sie hier noch<br />einmal gefuehrt, gaebe es dieselben Zugangsdaten an zwei Orten mit zwei<br />Rotationszeitpunkten.</p>\n<p>Dabei greift ein eigener Waechter. Als diese Strecke entstand, fiel<br /><code>addons/erpnext</code> bei leerer Einstellung still auf eine feste ERP-Adresse<br />zurueck (<code>addons#1220</code>); wer dessen <code>getErpSettings()</code> ungeprueft benutzte,<br />erbte die Falle. <strong>Am 11.08.2026 wurde das drueben behoben</strong> (PR #1223,<br /><code>erpnext</code> auf 0.6.0, Bruch).</p>\n<p><code>requireErpnextConfig()</code> bleibt trotzdem — aus zwei Gruenden, die von der<br />fremden Fassung unabhaengig sind:</p>\n<p>1. <strong>Fail-closed an der eigenen Grenze.</strong> Die Kante nennt <code>minVersion</code><br />   <code>0.3.0</code>; auf einer Instanz mit einer aelteren Fassung greift der alte<br />   Rueckfall weiterhin. Die Zusage „ohne ERP-Adresse geht nichts ans Netz&quot;<br />   darf nicht davon abhaengen, welche Fassung des Nachbarn installiert ist.<br />2. <strong>Die Antwort kommt ohne Modulladen zustande.</strong> 503<br />   <code>erpnext_not_configured</code> faellt, <strong>bevor</strong> das ERP-Modul importiert ist —<br />   und der Test pinnt, dass dabei kein einziger <code>fetch</code> laeuft.</p>\n<h3>Der Bestellblock kommt aus <code>wordpress</code>, und die Kante ist optional</h3>\n<p><code>bestellung</code> gehoert nicht ins ERP-Addon — das wurde in <code>addons#1213</code><br />begruendet entschieden (die Route existiert im <code>wordpress</code>-Addon bereits,<br />Shop-Zugangsdaten laegen sonst an einem zweiten Ort, und die Shop-Adresse<br />waere ein zweiter installationsgebundener Wert). Der <em>Aufrufer</em> soll den Block<br />selbst holen — dieses Addon ist dieser Aufrufer.</p>\n<p>Die Kante ist <code>optional: true</code>, weil Abnahmekriterium 1 einen Betrieb ohne<br />WooCommerce verlangt. Deshalb <strong>wirft dort nichts</strong>: ein fehlender Shop darf<br />das Lesen eines Angebots nicht scheitern lassen. Stattdessen kommt ein<br />benannter Zustand mit, und die Oberflaeche zeigt ihn:</p>\n<p>| Zustand | Heisst |<br />| --- | --- |<br />| <code>ok</code> | Bestellung gelesen |<br />| <code>no_reference</code> | das Angebot zeigt auf keine WooCommerce-Bestellung (<code>po_no</code> ohne <code>WC-&lt;id&gt;</code>) |<br />| <code>unavailable</code> | das <code>wordpress</code>-Addon ist nicht installiert — <strong>zulaessig</strong> |<br />| <code>not_configured</code> | installiert, aber leer eingerichtet; es ging nichts ans Netz |<br />| <code>failed</code> | der Shop hat nicht geantwortet — ob es die Bestellung gibt, ist <strong>unbekannt</strong>, nicht „keine&quot; |</p>\n<p>Diese fuenf zu einem <code>null</code> zusammenzufassen waere der teuerste Kurzschluss<br />dieser Strecke: „kein Shop installiert&quot; und „Shop antwortet gerade nicht&quot;<br />verlangen vom Betreiber Entgegengesetztes.</p>\n<h3>Senden ist NICHT verdrahtet — und die Oberflaeche sagt, warum</h3>\n<p>Der Sendeweg fehlt bewusst, und der Grund hat sich waehrend dieser Scheibe<br /><strong>geaendert</strong> — das ist wichtig genug, um es hinzuschreiben statt es zu<br />glaetten.</p>\n<p>Als die Scheibe begann, war der Sendeweg von <code>addons/erpnext</code> (v0.3.0)<br />nachweislich unehrlich: er reichte einen Entwurf nicht vorher ein, kannte die<br />Frage nach dem Postausgang nicht, und die Route antwortete <code>sent: true</code>,<br />sobald der Aufruf kein Nicht-2xx lieferte. **Am 11.08.2026 wurde genau das<br />behoben und gemerged** (<code>addons#1212</code> / PR #1219; <code>erpnext</code> steht seither auf<br />0.6.0). Der alte Grund ist damit weg.</p>\n<p>Der neue Grund ist die Reihenfolge selbst. Senden ist der einzige Weg, dessen<br />Fehler den <strong>Kunden</strong> erreicht; ein falsches „verschickt&quot; faellt erst auf,<br />wenn der Kunde nicht antwortet. Ein solcher Weg wird nicht am Ende einer<br />Scheibe nachgeschoben, weil die Gegenseite gerade gruen geworden ist. Er<br />bekommt eine eigene Scheibe mit</p>\n<p>* eigener Berechtigung (<code>quotes.send</code>, <code>highest</code>),<br />* eigenen Tests gegen die <strong>neue</strong> Antwortform<br />  (<code>{sent, grund, submitted, docstatus}</code>) statt gegen die alte,<br />* und einer eigenen Anzeige fuer <code>sent: false</code>, die dem Betreiber sagt, dass<br />  er <strong>umschwenken</strong> muss statt es spaeter nochmal zu versuchen.</p>\n<p>Genau so steht es in <code>PLAN-n8n-abloese.md</code>: Senden kommt zuletzt, und<br />einzeln. Bis dahin laeuft der Versand unveraendert ueber den bisherigen Weg —<br />es geht nichts verloren, es wird nur nichts Neues behauptet.</p>\n<p>In der Oberflaeche steht der Sende-Knopf deshalb da und ist <strong>aus</strong>, mit<br />Grund. Ihn wegzulassen saehe aus wie vergessen.</p>\n<h3>Zustaende, die nicht gleich aussehen duerfen</h3>\n<p>| Situation | Status | <code>code</code> |<br />| --- | --- | --- |<br />| nichts eingestellt (<strong>kein</strong> Netzruf) | 503 | <code>druckzentrale_not_configured</code> |<br />| Basis-URL unbrauchbar (<strong>kein</strong> Netzruf) | 503 | <code>druckzentrale_invalid_base_url</code> |<br />| Zugang abgelehnt | 502 | <code>druckzentrale_auth_failed</code> |<br />| Grant deckt die Route nicht ab | 502 | <code>druckzentrale_grant_scope</code> |<br />| Dienst nicht erreichbar | 502 | <code>druckzentrale_unreachable</code> |<br />| Auftrag unbekannt <strong>oder</strong> verborgen | 404 | <code>job_not_found_or_hidden</code> |<br />| Preisgrundlage noch nicht belastbar | 409 | <code>calculation_not_ready</code> |<br />| Schnitt begruendet abgelehnt (nichts gestartet) | 409 | <code>reslice_rejected</code> |<br />| Schnitt-Ausgang unklar | 409 | <code>reslice_unclear</code> |</p>\n<p>Drei davon verdienen einen eigenen Absatz:</p>\n<p><strong><code>job_not_found_or_hidden</code> heisst nicht „geloescht&quot;.</strong> Der Slice-Dienst tarnt<br />eine Sichtbarkeitsverweigerung absichtlich als 404 mit dem Text<br /><code>unbekannte job_id</code>, damit die Existenz eines fremden Auftrags nicht<br />durchscheint. Dieses Addon loest die Zweideutigkeit nicht auf — das waere<br />genau das Leck, das die Tarnung verhindern soll.</p>\n<p><strong><code>calculation_not_ready</code> ist keine leere Preisgrundlage.</strong> Ist ein Auftrag<br />noch nicht so weit, antwortet der Dienst mit <strong>200</strong> und einem Feld <code>fehler</code><br />statt einer Kalkulation. Ein Client ohne diese Pruefung zeigte eine leere<br />Aufstellung — und die liest sich wie „kostet nichts&quot;.</p>\n<p><strong><code>reslice_unclear</code> wird nicht wiederholt.</strong> Ein Schnitt ist nicht idempotent:<br />er belegt Rechenzeit, schreibt Dateien in die Auftragsablage und loest die<br />bisherigen Artefakte ab. Bleibt der Ausgang unklar, sagt die Route genau das —<br />samt Zeitpunkt als Ankerwert fuer die Nachschau — und schneidet <strong>nicht</strong> nach.</p>\n<h3>Der Koerper des Schnitts ist eine Positivliste</h3>\n<p><code>POST …/reslice</code> nimmt nur die Felder an, die der Dienst als Ueberlagerung<br />kennt: <code>anweisung</code>, <code>filament_spool_id</code>, <code>filament_id</code>, <code>prozess_profil</code>,<br /><code>prozess_werte</code>, <code>profil_overrides</code>, <code>material</code>, <code>color</code>, <code>quality</code>,<br /><code>ausfuehrungsort</code>, <code>nur_dateien</code>, <code>ohne_dateien</code>, <code>mengen_overrides</code>,<br /><code>projektquelle</code>, <code>modus</code>.</p>\n<p><code>source</code>, <code>order_id</code> und <code>kunde</code> fehlen dort <strong>absichtlich</strong> — sonst liesse<br />sich ein Auftrag im Nachhinein einem anderen Kunden zuschreiben. Eine<br />Sperrliste muesste jedes kuenftige gefaehrliche Feld kennen; die Positivliste<br />kennt nur die harmlosen.</p>\n<hr />\n<h2>Eigentuemer und Sichtbarkeit werden dargestellt, nicht erfunden</h2>\n<p>Beides kommt aus dem Dienst (<code>addons#1195</code>):</p>\n<ul><li><code>eigentuemer</code> = <code>{ mandant, nutzer, quelle }</code>, <code>quelle ∈ grant | token | lokal</code></li><li><code>sichtbarkeit ∈ privat | mandant | betrieb</code></li></ul>\n<p>Fuer Auftraege von vor dem 10.08.2026 gibt es <strong>keinen</strong> Eigentuemer. Dann<br />zeigt die Oberflaeche den <code>eigentuemer_vermerk</code> des Dienstes und raet keine<br />Zuordnung dazu.</p>\n<p>Und: <strong>die Auftragsliste ist gefiltert.</strong> Der Dienst laesst nicht sichtbare<br />Auftraege weg, ohne sie zu erwaehnen. Die Antwort traegt deshalb<br /><code>filteredByService: true</code>, und die Seite schreibt es hin — „das sind alle<br />Auftraege&quot; waere eine Behauptung, die der Dienst nie aufgestellt hat.</p>\n<hr />\n<h2>Was eine bestellte Festigkeit erzwingt (<code>addons#1294</code>)</h2>\n<p>Der Konfigurationsausschnitt zeigt jeden Regler als **eine Zeile mit einem<br />Wert**. Fuer eine Zahl ist das richtig. Der Regler<br /><code>qualitaet.stabilitaetsstufen</code> traegt aber eine <strong>Tabelle</strong> — je<br />Festigkeitsstufe die Prozesswerte, die sie erzwingt —, und als Zeile bliebe<br />davon <code>[{&quot;stufe&quot;:&quot;dekorativ&quot;,&quot;wall_loops&quot;:null,…}]</code> uebrig. Nicht falsch, nur<br />keine Auskunft: die Aussage dieser Tabelle ist eine <strong>Leiter</strong>.</p>\n<p><code>app/faehigkeiten.py</code> im Dienst sagt woertlich, warum ein Addon sie ueberhaupt<br />sehen darf: wer dem Kunden „Belastbar&quot; zur Wahl stellt, muss sagen koennen,<br />was Belastbar bedeutet — sonst zeigt er eine Zusage an, die er nicht kennt.</p>\n<p>Deshalb steht die Leiter <strong>zusaetzlich</strong> zur Reglerzeile: die Zeile sagt, dass<br />es den Regler gibt und ob dieser Zugang ihn setzen darf; die Leiter sagt, was<br />er bedeutet.</p>\n<p><strong>Stabilitaet ist eine Untergrenze.</strong> Die gewaehlte Stufe darf Waende und<br />Fuellung erhoehen, nie senken. Der Dienst lehnt eine absteigende Tabelle beim<br />Speichern ab und bleibt die Instanz; dieses Addon **entscheidet nichts und<br />rechnet nichts nach**. Findet es trotzdem einen Bruch (aeltere Fassung des<br />Dienstes, von Hand geschriebenes <code>betrieb.json</code>, Wert aus einer<br />Umgebungsvariablen), ist das ein <strong>Befund</strong> und steht als Fehler da — in einer<br />sortierten Tabelle saehe er aus wie vier normale Zeilen.</p>\n<p>Was hier <strong>nicht</strong> passiert:</p>\n<p>* <strong>Keine Zahlen im Addon.</strong> Nirgends steht „belastbar heisst 5 Waende&quot;. Das<br />  waere ein installationsgebundener Wert — und die zweite Wahrheit: der<br />  Betreiber darf die Tabelle drehen, und ein Addon mit eigener Fassung wuerde<br />  ihm die alte Zusage bestaetigen. Ein Testfall misst dafuer den Quelltext.<br />* <strong>Keine neue Berechtigung.</strong> Gelesen wird der Block, der ueber<br />  <code>addons/layery-druckzentrale/jobs/read</code> ohnehin schon kommt.<br />* <strong>Kein stiller Rueckfall.</strong> „Der Regler ist in dieser Sicht nicht<br />  freigegeben&quot;, „der Dienst hat nichts geschickt&quot; und „die Tabelle ist leer&quot;<br />  sind drei Auskuenfte und sehen verschieden aus. Eine leere Anzeige fuer alle<br />  drei hiesse „dieser Betrieb erzwingt nichts&quot; — und das ist die einzige der<br />  drei, die beruhigt.</p>\n<hr />\n<h2>Ein weggeworfener Lauf ist kein gescheiterter (<code>addons#1308</code>)</h2>\n<p>Am 12.08.2026 um 23:07:23 nahm ein <code>docker restart slicer-api</code> einen 42<br />Sekunden alten, <strong>lebenden</strong> Lauf mit. <code>RestartCount=0</code>, kein OOM — ein<br />gewollter Neustart von Hand. Die Neustartsperre des Slice-Dienstes haette<br />gehalten; sie wurde nur nicht gefragt, denn sie sitzt in <code>run.sh</code>, und<br /><code>docker restart</code>, <code>docker stop</code> und <code>docker compose</code> gehen daran vorbei.</p>\n<p>In dieser Oberflaeche stand danach dieselbe rote Ueberschrift wie bei einem<br />Lauf, der an der Geometrie gescheitert ist: *„Der Dienst meldet einen Fehler<br />zu diesem Lauf&quot;*. Beide verlangen aber verschiedene Handgriffe — der eine ein<br />anderes Modell oder Material, der andere nur einen neuen Anlauf **und die<br />Frage vor dem naechsten Neustart**. So sah der teure Fall aus wie ein<br />Modellproblem.</p>\n<p>Eingeordnet wird ueber das <strong>Feld</strong> <code>status.abbruch_art</code> des Dienstes<br />(<code>app/main.py</code>, <code>ABBRUCH_ART_NEUSTART</code>), nicht ueber den deutschen Satz<br />daneben. Ihn nach dem Wort „Neustart&quot; abzusuchen waere verlockend — er steht<br />ja da —, aber beim naechsten Umformulieren im Dienst zeigte diese Oberflaeche<br />etwas Falsches an, und das faellt niemandem auf: der Satz sieht weiter richtig<br />aus, nur die Einordnung stimmt nicht mehr. Dieselbe Regel wie bei<br /><code>schnittabbrueche[].art</code> (<code>addons#1289</code>, <code>abbruchgrund.ts</code>).</p>\n<p>Ein Lauf <strong>ohne</strong> das Feld — also einer von vor <code>addons#1308</code> — gilt<br />ausdruecklich als <em>nicht</em> durch einen Neustart abgebrochen. Eine geratene<br />Einordnung waere hier schlechter als gar keine: sie stuende mit demselben<br />Nachdruck da wie eine gemessene.</p>\n<p>Der Satz des Dienstes bleibt sichtbar — er ist der Beleg; die Einordnung<br />darueber ist die Auskunft. Der Handgriff nennt den einen Schritt, der dieselbe<br />Frage auf jedem Weg beantwortet:</p>\n<pre><code>docker exec slicer-api python3 /root/slicer/app/neustartsperre.py</code></pre>\n<hr />\n<h2>Abhaengigkeiten (ADR-047)</h2>\n<p><code>addonDependencies</code> ist ein <code>Record&lt;addonId, …&gt;</code>, kein Array. Eine<br /><code>required</code>-Kante auf ein Addon, das auf der Zielinstanz fehlt, ergibt<br /><code>addon_dependencies_unmet</code> und <strong>HTTP 409</strong> — das Addon waere dort nicht<br />installierbar.</p>\n<p>| Addon | Kante | Warum |<br />| --- | --- | --- |<br />| <code>erpnext</code> | optional <code>&gt;=0.3.0</code> | <strong>In 0.2.0 von <code>required</code> gelockert</strong> — Begruendung unten. |<br />| <code>wordpress</code> | optional <code>&gt;=1.2.0</code> | WooCommerce ist <strong>ein</strong> Bestellkanal, nicht der einzige. Ein Betrieb, der nur ueber ERP anbietet, muss installieren koennen (Abnahmekriterium 1). |<br />| <code>spoolman</code> | optional <code>&gt;=0.2.0</code> | Der Filament-Bestand bleibt laut Schnitt im Slice-Dienst; dieses Addon liest ihn von dort, nicht aus Spoolman. |<br />| <code>octoprint-proxy</code> | optional <code>&gt;=2.0.0</code> | Die Strecke zum Drucker haengt am Slice-Dienst, nicht an diesem Addon: der Dienst spricht den Sync-Proxy selbst an. |</p>\n<p>Alle vier Kanten degradieren nach ADR-047 transparent mit maschinenlesbarem<br />Grund (<code>optional-dependency-missing</code>) — das ist fuer eine Ansicht das<br />richtige Verhalten, eine harte Kante waere es nicht.</p>\n<h3>Warum <code>erpnext</code> von <code>required</code> auf <code>optional</code> gewechselt ist</h3>\n<p>In 0.1.0 war die Kante hart, mit der Begruendung „es gibt keinen zweiten<br />ERP-Weg&quot;. Der erste Halbsatz stimmt; der Schluss daraus war falsch. Zwei<br />Dinge wurden verwechselt:</p>\n<p>* Dass die <strong>Angebotsstrecke</strong> nur ueber <code>erpnext</code> laeuft — richtig.<br />* Dass dieses Addon <strong>ohne ERP nutzlos</strong> waere — falsch. Auftraege ansehen,<br />  die Preisgrundlage lesen und neu schneiden sprechen ausschliesslich mit dem<br />  Slice-Dienst. Das war der gesamte Inhalt von 0.1.0, und es funktioniert auf<br />  einer ERP-freien Instanz vollstaendig.</p>\n<p>Was die harte Kante tatsaechlich bewirkt hat: nach ADR-047 endet eine<br />unerfuellte <code>required</code>-Kante in <code>addon_dependencies_unmet</code> und <strong>HTTP 409</strong>.<br />Die Druckzentrale waere damit auf jeder Instanz ohne <code>erpnext</code><br /><strong>uninstallierbar</strong> gewesen — und <code>erpnext</code> ist derzeit <strong>nirgends</strong><br />installiert.</p>\n<p>Der zweite Grund ist ein Widerspruch im eigenen Code: waere die Kante hart,<br />koennte der Fall „<code>erpnext</code> fehlt&quot; gar nicht eintreten, und die Codes<br /><code>erpnext_unavailable</code> / <code>erpnext_not_configured</code> waeren <strong>toter Code</strong>. Genau<br />die Degradierung, die dieses Addon sorgfaeltig baut und testet, waere<br />unerreichbar. Mit <code>optional</code> gilt stattdessen:</p>\n<p>* ohne <code>erpnext</code> laufen die <code>jobs</code>-Routen und die Oberflaeche weiter;<br />* die vier <code>quotes</code>-Routen antworten <strong>503 <code>erpnext_unavailable</code></strong> mit<br />  sprechendem Grund, statt dass gar nichts installierbar ist.</p>\n<hr />\n<h2>MCP: <code>druckzentrale-tools</code> (read-only)</h2>\n<p><code>job_list</code>, <code>job_get</code>, <code>job_artifacts</code>.</p>\n<p>Bewusst <strong>kein</strong> schneidendes Werkzeug: ein Agent, der bei unklarer Antwort<br />noch einmal ruft, laesst denselben Auftrag ein zweites Mal rechnen. Der<br />schneidende Pfad laeuft ausschliesslich ueber die API-Route mit eigener,<br />hoechster Trust-Stufe. Die Preisgrundlage ist ebenfalls nicht exponiert: sie<br />haengt an einer eigenen Berechtigung.</p>\n<p>Der Server ist ohne Env handshake-faehig (Conformance C3) und faellt ohne<br /><code>AIOPTA_CONFIG_BASEURL</code> fail-closed aus.</p>\n<hr />\n<h2>Trust-Stufen</h2>\n<p>Drei getrennte Achsen, die gern verwechselt werden:</p>\n<p>1. <strong>ADR-062</strong> (<code>trusted | standard | restricted</code>) — instanzweit, admin-gesetzt,<br />   steht <strong>nicht</strong> im Manifest, durchgesetzt ueber <code>isMethodAllowedForTrust</code>.<br />2. <code>capabilityDeclarations[].tier</code> (<code>standard | elevated | highest</code>) — die<br />   deklarative Absicht dieses Addons.<br />3. Die Permission selbst.</p>\n<p>Deshalb liegt der Schnittpfad auf <strong>POST</strong>: nur ein mutierendes Verb wird auf<br /><code>restricted</code> abgewiesen. Die Versuchung war hier greifbar — eine „Neu<br />schneiden&quot;-Verknuepfung haette sich als GET viel bequemer in eine<br />Tabellenzeile setzen lassen und den Sandbox-Schutz still ausgehebelt.</p>\n<hr />\n<h2>Was dieses Addon (noch) NICHT tut</h2>\n<p>Ehrliche Liste, damit niemand danach sucht:</p>\n<p>* <strong>Senden (n8n-Workflow 11).</strong> Bewusst nicht verdrahtet — siehe oben. Die<br />  Vorbedingung (<code>addons#1212</code>) ist seit dem 11.08.2026 <strong>erledigt</strong>; es fehlt<br />  die eigene Scheibe, nicht die Gegenseite. Der Versand laeuft bis dahin<br />  unveraendert ueber den bisherigen Weg.<br />* <strong>Das Mailkonto-Feld <code>erpMailAccount</code> anzeigen.</strong> <code>GET /quotation/{name}</code><br />  von <code>erpnext</code> traegt es seit 0.4.0 (<code>addons#1213</code>, gemergt am 11.08.2026)<br />  als <code>{outgoing, grund, konto}</code> mit <strong>dreiwertigem</strong> <code>outgoing</code>. Diese<br />  Ansicht liest es noch nicht — sie wurde gegen 0.3.0 geschnitten. Nachzuholen<br />  ist genau eine Sache, und sie ist nicht kosmetisch: <code>false</code> („es gibt<br />  keines&quot;) und <code>null</code> („nicht feststellbar&quot;, meist HTTP 403 auf<br />  <code>Email Account</code>) duerfen in der Anzeige <strong>nicht</strong> gleich aussehen. Gehoert<br />  in dieselbe Scheibe wie Senden.<br />* <strong>Ein Angebot ANLEGEN und Angebote SUCHEN.</strong> <code>erpnext</code> kann heute lesen und<br />  aendern; <code>Quotation</code> anlegen (n8n-Workflow 01) und ueber Kriterien suchen<br />  (02, 04) fehlen dort. Das gehoert zur Ablose der Orchestrierer.<br />* <strong><code>lost</code> (Kundenentscheidung).</strong> Die Route existiert im <code>erpnext</code>-Addon,<br />  aber der Entscheidungsweg haengt an den WooCommerce-Bestaetigungen<br />  (<code>decision-ack</code>, <code>lost-ack</code>) aus dem Plugin <code>Layery-FileIntake</code>, die im<br />  <code>wordpress</code>-Addon noch nicht abgebildet sind.<br /><em> </em>*Die Regler im Slice-Dienst abbauen (<code>integration.aiopta_url</code>,<br />  <code>aiopta_agent_key</code>).** Abnahmekriterium 3. Sie liegen im Dienst-Repo, nicht<br />  hier. Sie fallen erst, wenn die Angebotsstrecke ueber dieses Addon im<br />  Betrieb bestaetigt ist — sie vorher zu ziehen hiesse, den bisherigen Weg<br />  abzuschneiden, bevor der neue gemessen ist.<br />* <strong>Einen neuen Auftrag anlegen.</strong> <code>POST /slice</code> ist beim Dienst gemessen<br />  <strong>nicht</strong> grant-schreibbar (die Pruefung verlangt <code>path.startswith(&quot;/slice/&quot;)</code>,<br />  und <code>/slice</code> faellt durch). Ein Anlegen ueber das Addon braucht also erst<br />  eine Aenderung im Dienst.<br />* <strong>Konfiguration und Faehigkeiten des Dienstes</strong> (<code>/dashboard-api/konfiguration</code>,<br />  Profilregister, Filament, Druckerstatus). Das ist die geplante Route<br />  <code>…/capabilities</code> und fasst vier Dienstrouten zusammen; sie gehoert in<br />  denselben Schritt wie das Angebot.<br />* <strong>Die sechs n8n-Orchestrierer (00–05) abloesen.</strong> Reihenfolge laut Issue:<br />  erst der Umzug der Angebots- und ERP-Logik, dann die Orchestrierer.<br />  Workflow 00 kommt zuletzt — er gibt Kundendateien auf dem Webserver zur<br />  Loeschung frei.<br />* <strong>Eine signierte <code>attestation.json</code>.</strong> Signiert wird ausschliesslich im<br />  Auftrag <code>ci/Jenkinsfile.addonsign</code> mit der Credential<br />  <code>aiopta-addon-signing-key</code>; der Schluessel liegt weder im Repo noch bei<br />  einem Entwickler. Bis dahin traegt das Addon einen ticketgebundenen Eintrag<br />  in <code>BEKANNTE_LUECKEN</code> (<code>addons#1002</code>).</p>",
          "byteSize": 25126
        }
      }
    },
    {
      "id": "lead-gen",
      "uuid": "7a769d72-3697-43e2-93b5-7a9a947712d4",
      "name": "Lead-Gen & Outreach (CRM-lite)",
      "version": "2.0.4",
      "description": "DEPRECATED — zusammengeführt in das kanonische CRM-Addon `crm` (ADR-008 Addon-Konsolidierung, Issue #491 / Epic #490). `crm` ist das durchgängige CRM, das laut ADR-015 direkt auf dieser lead-gen-CRM-lite aufbaut. lead-gen bleibt bis zum Sunset (2026-12-31) voll funktionsfähig und datenerhaltend — kein Endpoint entfällt, keine Zeile wird kopiert/gelöscht. Der Redirect läuft über die Addon-Metadaten (`replacedBy: crm`, `settingsPagePath: /addons/crm`) und den Nachfolger-Link im Marketplace/Settings-Hub. Ursprüngliche Fähigkeit: CRM-lite (contacts, pipeline, suppression, sequences) + Cold Outreach; Scrape/Enrich via Aiopta-managed Container (ADR-009, nur interne URL), Sequenz-Entwürfe via Aiopta-Gateway, Versand unter zwingenden Anti-Spam-Guardrails (Suppression + per-run/daily Rate-Limit + explizites confirm). SMTP/ESP- und Enrichment-Keys kommen aus Aiopta addon-config, nie aus dem Manifest. Money-Stufe-1 (ADR-015).",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "crm-contacts",
        "crm-pipeline",
        "lead-scraping",
        "lead-enrichment",
        "outreach-sequences",
        "outreach-sending",
        "outreach-suppression"
      ],
      "tags": [],
      "download": "/artifacts/lead-gen-2.0.4.zip",
      "sha256": "c56f5d40523070b78268f4212a1ac241a5056baac267e32707125bc482b470de",
      "size": 113670,
      "page": {
        "id": "lead-gen-page",
        "path": "/addons/lead-gen",
        "name": "Lead-Gen & Outreach",
        "icon": "🎯",
        "section": "Money",
        "component": "@addons/lead-gen/src/page"
      },
      "deprecated": true,
      "replacedBy": "crm",
      "deprecation": {
        "deprecated": true,
        "replacedBy": "crm",
        "sunset": "2026-12-31",
        "successorPath": "/addons/crm",
        "successorLabel": "CRM (Nachfolger)"
      },
      "endpoints": [
        "/api/v1/addons/lead-gen/status",
        "/api/v1/addons/lead-gen/contacts",
        "/api/v1/addons/lead-gen/contacts/:id",
        "/api/v1/addons/lead-gen/pipeline",
        "/api/v1/addons/lead-gen/sequences",
        "/api/v1/addons/lead-gen/sequences/:id/generate",
        "/api/v1/addons/lead-gen/sequences/:id/send",
        "/api/v1/addons/lead-gen/suppression",
        "/api/v1/addons/lead-gen/enrich",
        "/api/v1/addons/lead-gen/widget"
      ],
      "skills": [
        {
          "name": "lead-crm",
          "displayName": "Lead-Gen: CRM contacts & pipeline",
          "description": "List/create/edit contacts and move them through the pipeline (new→contacted→replied→qualified→won/lost). Pure stage-machine enforced; no sending.",
          "permissions": [
            "addons/lead-gen/contacts",
            "addons/lead-gen/pipeline",
            "addons/lead-gen/status"
          ],
          "endpoint": "/api/v1/addons/lead-gen/contacts",
          "scripts": [
            {
              "id": "lead-crm-cli",
              "name": "lead-crm-cli",
              "path": "lead-crm/cli.sh",
              "sha256": "a1bcdfec934ada02e9669497c1ad1b1f94f76b5a505788b1ca9f793266834761",
              "interpreter": "bash",
              "timeoutMs": 10000,
              "permissions": [
                "addons/lead-gen/contacts",
                "addons/lead-gen/pipeline",
                "addons/lead-gen/status"
              ],
              "inputSchema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "limit": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 100,
                    "default": 20
                  },
                  "timeoutMs": {
                    "type": "integer",
                    "minimum": 100,
                    "maximum": 300000
                  },
                  "input": {
                    "type": "object",
                    "description": "Request-Body als JSON — niemals Secrets"
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": false,
                    "description": "Request validieren ohne Mutation — kein API-Call"
                  }
                }
              },
              "outputSchema": {
                "type": "object",
                "required": [
                  "ok"
                ],
                "properties": {
                  "ok": {
                    "type": "boolean"
                  },
                  "status": {
                    "type": "integer"
                  },
                  "data": {},
                  "error": {
                    "type": "string"
                  },
                  "truncated": {
                    "type": "boolean"
                  }
                }
              },
              "size": 12062
            }
          ]
        },
        {
          "name": "lead-enrich",
          "displayName": "Lead-Gen: Scrape & enrich",
          "description": "Scrape lead candidates and enrich contacts via the MC-managed scraper container. If the container is not provisioned the call returns a clear 503; never a hard-coded host.",
          "permissions": [
            "addons/lead-gen/enrich",
            "addons/lead-gen/contacts",
            "addons/lead-gen/status"
          ],
          "endpoint": "/api/v1/addons/lead-gen/enrich",
          "scripts": []
        },
        {
          "name": "lead-sequences",
          "displayName": "Lead-Gen: Draft outreach sequences",
          "description": "Create sequences and generate personalized message DRAFTS per contact via the MC gateway (never a direct LLM call). Drafting only — this skill cannot send.",
          "permissions": [
            "addons/lead-gen/sequences",
            "addons/lead-gen/contacts"
          ],
          "endpoint": "/api/v1/addons/lead-gen/sequences",
          "scripts": []
        },
        {
          "name": "lead-suppression",
          "displayName": "Lead-Gen: Suppression / opt-out",
          "description": "List and add opt-out / suppressed addresses. Suppressed contacts are NEVER sent to — this is the anti-spam source of truth.",
          "permissions": [
            "addons/lead-gen/suppression",
            "addons/lead-gen/status"
          ],
          "endpoint": "/api/v1/addons/lead-gen/suppression",
          "scripts": []
        },
        {
          "name": "lead-send",
          "displayName": "Lead-Gen: Send outreach (guarded)",
          "description": "Send drafted messages. REQUIRES confirm:true, respects the suppression list and the per-run/daily rate limits, and needs SMTP/ESP config in MC addon-config (missing → 400). This is the only skill that can send; grant it deliberately.",
          "permissions": [
            "addons/lead-gen/sequences",
            "addons/lead-gen/suppression",
            "addons/lead-gen/status"
          ],
          "endpoint": "/api/v1/addons/lead-gen/sequences/:id/send",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Lead-Gen &amp; Outreach (CRM-lite)</h1>\n<blockquote><p>⚠️ **DEPRECATED — merged into the canonical <code>crm</code> addon (ADR-008 addon-consolidation,</p><p>issue #491 / epic #490).** <code>crm</code> is the durable, full CRM that ADR-015 already builds on top of this</p><p>CRM-lite. lead-gen remains functional and data-preserving until its <strong>sunset on 2026-12-31</strong> — no</p><p>endpoint is removed and no row is copied or deleted. The Alt-ID redirect is expressed through the</p><p>addon metadata (<code>replacedBy: crm</code>, <code>sunset</code>, <code>settingsPagePath: /addons/crm</code>) so the Marketplace and</p><p>Settings-Hub point at the successor, and — at the record level — through <code>crm</code>&#39;s</p><p><code>getContactByLeadEmail</code>, which resolves an old lead-gen lead identity (by email) to the migrated CRM</p><p>contact. The lossless consolidation path is <code>crm</code>&#39;s per-user <code>importLeads</code> reconcile</p><p>(<code>POST /api/v1/addons/crm/import/leads</code>), which reads this addon&#39;s own scoped store and never mutates</p><p>the <code>lead_gen_*</code> tables. See the PR for #491 for the license/pricing/permission review.</p></blockquote>\n<p>A <strong>Money-Stufe-1</strong> addon: a lightweight CRM plus cold-outreach engine. Capture and qualify leads,<br />scrape/enrich them via an Aiopta-managed container, draft personalized messages through the Aiopta<br />AI gateway, and send them under <strong>mandatory anti-spam guardrails</strong>.</p>\n<p>This addon <strong>orchestrates</strong> existing Aiopta building blocks the same way <code>seo-content</code> and<br /><code>dropshipping</code> do (ADR-015):</p>\n<p>- <strong>AI message generation → the Aiopta gateway</strong> (<code>@/lib/gateway</code>, <code>chatCompletionWithFallbacks</code>). There is<br />  <strong>no direct LLM/provider call</strong> anywhere in the addon.<br />- <strong>Scraping / enrichment → an Aiopta-managed container</strong> (ADR-009), reached <strong>only</strong> via<br />  <code>getContainerAddonInternalUrl(&quot;lead-gen&quot;)</code> — never a hard-coded host.<br />- <strong>Sending → an SMTP/ESP interface</strong> that reads config from Aiopta addon-config — **no secret in the repo<br />  or logs**.</p>\n<h2>⚠️ Anti-spam guardrails (mandatory — read this first)</h2>\n<p>Cold outreach is dangerous if it can blast. This addon makes a careless mass-send **structurally<br />impossible<strong>. Three guardrails are enforced in <code>src/lib/sender.ts</code> and are </strong>not optional**:</p>\n<p>1. <strong>Explicit confirmation.</strong> <code>POST /sequences/:id/send</code> refuses to send unless the request body<br />   contains <code>confirm: true</code>. Without it the call returns <strong>400 before any transport is opened</strong> — no<br />   accidental blast.<br />2. <strong>Suppression / opt-out check.</strong> Every recipient is checked against the suppression list<br />   (<code>/suppression</code>). A suppressed / opted-out address is <strong>never</strong> sent to — it is skipped and reported.<br />   This is the source of truth for honoring unsubscribes; add an address there and it is dead to the<br />   sender forever.<br />3. <strong>Rate limits.</strong> A small <strong>per-run</strong> cap and a <strong>daily</strong> cap (both configurable in addon-config,<br />   with conservative defaults — <code>25/run</code>, <code>200/day</code>) bound how many messages can go out. Anything over<br />   the remaining quota is deferred (<code>rate-limited</code>), not sent.</p>\n<p>The ordering is strict and <strong>pure</strong> (<code>planSend</code>): drop invalid emails → drop suppressed → apply the<br />remaining-quota cap. It is unit-tested without any SMTP/DB so the guarantee can&#39;t silently regress<br />(<code>tests/unit/sender.test.ts</code>).</p>\n<blockquote><p><strong>Mass-send / bulk outreach without explicit owner approval is not supported.</strong> These guardrails are</p><p>a guardrail, not a setting to switch off. Sending also requires SMTP/ESP config to be present</p><p>(missing → 400), so the addon can never email through an unconfigured transport.</p></blockquote>\n<h2>Per-user isolation (#141 / ADR-054)</h2>\n<p>The CRM is <strong>per-user</strong>, not a shared catalog. Contacts, sequences (+ their steps), the<br />suppression / opt-out list and the daily send-log all carry <code>user_id</code> and are read/written only<br />through <code>scopedStore(auth.id)</code> / <code>WHERE user_id = ?</code>. The server stamps the owner from the session<br />(a client-supplied <code>user_id</code> is ignored), and a foreign contact/sequence id returns <strong>404</strong> (not the<br />row, not 403). Two users may track the <strong>same email</strong> independently (<code>UNIQUE(user_id, email)</code>).<br />Critically, <strong>suppression and the rate-limit are per user</strong>: one user&#39;s opt-out list cannot gag<br />another user&#39;s outreach, and one user&#39;s sent volume cannot consume another&#39;s daily quota.</p>\n<h2>Configuration (Aiopta addon-config — never the manifest, never logged)</h2>\n<p>Stored via Aiopta addon-config for the <code>lead-gen</code> addon; secret-looking keys (<code>*_PASS</code>, <code>*_API_KEY</code>) are<br />persisted with <code>is_secret = 1</code>. Two owner decisions are parked in <code>docs/OPEN-DECISIONS.md</code><br />(<code>lead-scraper-image</code>, <code>lead-gen-email-config</code>).</p>\n<h3>Email sending (<code>lead-gen-email-config</code>)</h3>\n<p>| Key | Meaning | Default |<br />|-----|---------|---------|<br />| <code>ESP_PROVIDER</code> | <code>smtp</code> (wired) or <code>sendgrid</code>/<code>mailgun</code>/<code>postmark</code>/<code>resend</code> (declared) | <code>smtp</code> |<br />| <code>SMTP_HOST</code> / <code>SMTP_PORT</code> / <code>SMTP_USER</code> / <code>SMTP_PASS</code> | SMTP connection (provider = <code>smtp</code>) | — / <code>587</code> / — / — |<br />| <code>SMTP_SECURE</code> | <code>&quot;true&quot;</code> for implicit TLS | <code>false</code> |<br />| <code>SMTP_FROM</code> (or <code>ESP_FROM</code> / <code>FROM</code>) | From address (<strong>required for any send</strong>) | — |<br />| <code>ESP_API_KEY</code> | ESP API key (provider ≠ <code>smtp</code>) | — |<br />| <code>SEND_PER_RUN_LIMIT</code> | per-run rate cap | <code>25</code> |<br />| <code>SEND_DAILY_LIMIT</code> | rolling-24h rate cap | <code>200</code> |</p>\n<p>Until email is configured, <code>/status</code> reports <code>email.configured: false</code> and any send returns a clear<br /><strong>400</strong>. No secret is ever written to the manifest or surfaced by <code>/status</code> (only booleans, the provider<br />id, and the from-<strong>domain</strong>).</p>\n<h3>Scraper / enrichment container (<code>lead-scraper-image</code>)</h3>\n<p>Declared as a <code>container</code> in <code>addon.json</code> / <code>src/manifest.ts</code>:</p>\n<pre><code>image:        registry.langkau.info/aiopta/lead-scraper:latest\ninternalPort: 8080\nhealthPath:   /health\nenvSchema:    ENRICHMENT_PROVIDER, ENRICHMENT_API_KEY, SCRAPER_USER_AGENT, SCRAPER_RATE_LIMIT</code></pre>\n<p><code>envSchema</code> declares only the <strong>names</strong> of the keys the <strong>container</strong> needs (enrichment-provider<br />credentials); the values come from Aiopta addon-config, never the manifest. The image is <strong>owner-built</strong> —<br />until it is published, <code>/status</code> reports <code>scraper.provisioned: false</code> and <code>/enrich</code> (scrape/enrich)<br />returns a clear <strong>503</strong>. The addon never falls back to a default host.</p>\n<h2>API</h2>\n<p>Base: <code>/api/v1/addons/lead-gen</code> (all routes auth-gated; agent capability <code>lead-gen/*</code>).</p>\n<p>| Goal | Method + path | Body |<br />|------|---------------|------|<br />| Status (scraper + email + gateway + pipeline + limits) | <code>GET /status</code> | — |<br />| List contacts | <code>GET /contacts?stage=new</code> | — |<br />| Create / upsert a contact | <code>POST /contacts</code> | <code>{ &quot;email&quot;: &quot;…&quot;, &quot;name&quot;: &quot;…&quot;, &quot;company&quot;: &quot;…&quot; }</code> |<br />| Read / edit / delete a contact | <code>GET|PUT|DELETE /contacts/:id</code> | edit: <code>{ &quot;stage&quot;: &quot;…&quot;, &quot;tags&quot;: […] }</code> |<br />| Pipeline board | <code>GET /pipeline</code> | — |<br />| Move a contact&#39;s stage | <code>PUT /pipeline</code> | <code>{ &quot;contactId&quot;: 1, &quot;stage&quot;: &quot;contacted&quot; }</code> |<br />| List sequences | <code>GET /sequences</code> | — |<br />| Create a sequence | <code>POST /sequences</code> | <code>{ &quot;name&quot;: &quot;…&quot;, &quot;goal&quot;: &quot;book a demo&quot; }</code> |<br />| Draft messages (via gateway) | <code>POST /sequences/:id/generate</code> | <code>{ &quot;stage&quot;: &quot;new&quot; }</code> or <code>{ &quot;contactId&quot;: 1 }</code> |<br />| <strong>Send drafts (guarded)</strong> | <code>POST /sequences/:id/send</code> | <code>{ &quot;confirm&quot;: true, &quot;messages&quot;: [{ &quot;contactId&quot;: 1, &quot;subject&quot;: &quot;…&quot;, &quot;body&quot;: &quot;…&quot; }] }</code> |<br />| Suppression list | <code>GET /suppression</code> | — |<br />| Add an opt-out | <code>POST /suppression</code> | <code>{ &quot;email&quot;: &quot;…&quot;, &quot;reason&quot;: &quot;unsubscribe&quot; }</code> |<br />| Scrape / enrich (container) | <code>POST /enrich</code> | <code>{ &quot;query&quot;: &quot;…&quot; }</code> · <code>{ &quot;contactId&quot;: 1 }</code> · <code>{ &quot;email&quot;: &quot;…&quot; }</code> |<br />| Dashboard widget (flat) | <code>GET /widget</code> | — |</p>\n<h3>Pipeline stages</h3>\n<p><code>new → contacted → replied → qualified → won/lost</code>. <code>won</code>/<code>lost</code> are terminal (a closed deal can&#39;t be<br />re-opened by a move — delete + recreate instead). Moving to <code>replied</code> stamps the reply timestamp, which<br />drives the <strong>reply-rate</strong> shown on the page and widget. The stage-machine (<code>canTransition</code>) is pure and<br />unit-tested.</p>\n<h2>Page &amp; widget</h2>\n<p>- <strong>Page</strong> (<code>Money</code> section, <code>/addons/lead-gen</code>): tabs <strong>Leads</strong> (table + add/scrape), <strong>Pipeline</strong><br />  (per-stage columns with move buttons), <strong>Sequences</strong> (create → generate drafts → edit → **send with a<br />  required confirm checkbox<strong>), </strong>Suppression** (opt-out list). A status banner shows gateway/scraper/<br />  email readiness and the active rate limits.<br />- <strong>Widget</strong> (<code>lead-gen.pipeline</code>): open leads, reply-rate, won, suppression size, scraper + email<br />  readiness.</p>\n<h2>Architecture notes (ADR-015)</h2>\n<p>- <strong>AI via gateway only.</strong> <code>src/lib/sequencer.ts</code> builds the prompt and calls<br />  <code>chatCompletionWithFallbacks</code> — no provider <code>fetch</code>. A test stubs global <code>fetch</code> and asserts it is<br />  never called during generation.<br />- <strong>Scraper via the container-service.</strong> <code>src/lib/scraper.ts</code> resolves the base URL with<br />  <code>getContainerAddonInternalUrl(&quot;lead-gen&quot;)</code>; absent → 503 (tested), present → calls the internal<br />  <code>/scrape</code> and <code>/enrich</code> endpoints.<br />- <strong>Sender is an interface with guardrails.</strong> <code>src/lib/sender.ts</code> reads SMTP/ESP from addon-config,<br />  enforces confirm + suppression + rate-limit, and records each send for the daily cap.<br />- <strong>CRM owns its tables.</strong> <code>src/lib/crm-store.ts</code> creates <code>lead_gen_contacts</code>, <code>lead_gen_suppression</code>,<br />  <code>lead_gen_sequences</code>, <code>lead_gen_sequence_steps</code> (and the sender&#39;s <code>lead_gen_send_log</code>) in <code>install()</code><br />  and drops them in <code>uninstall()</code> via Aiopta core&#39;s <code>getDb()</code>.</p>\n<h2>Tests</h2>\n<p><code>addons/lead-gen/tests/unit/</code> (run with the manifest-route-sync test):</p>\n<p>- <code>manifest.test.ts</code> — manifest validity, namespaced endpoints, page + widget, <strong>container present</strong>,<br />  least-privilege skills (only <code>lead-send</code> carries <code>/send</code>), no secret/host in the manifest.<br />- <code>crm-store.test.ts</code> — the pure stage-machine + email/contact validation.<br />- <code>sender.test.ts</code> — <strong>the anti-spam guarantees</strong>: <code>planSend</code> never includes a suppressed contact and<br />  enforces per-run/daily caps; <code>sendBatch</code> rejects without <code>confirm</code>, rejects when unconfigured, and<br />  with confirm never delivers to a suppressed contact and caps the batch.<br />- <code>sequencer.test.ts</code> — generation routes through <code>@/lib/gateway</code>, never a direct provider <code>fetch</code>.<br />- <code>scraper.test.ts</code> — reached only via <code>getContainerAddonInternalUrl</code>; container-absent → 503.</p>\n<pre><code class=\"lang-bash\">wsl -e bash -lc &#39;cd /mnt/c/Users/Patrick/IdeaProjects/Antigravity/aiopta &amp;&amp; \\\n  docker run --rm -v &quot;$PWD&quot;:/app -w /app node:22-slim \\\n  node node_modules/vitest/vitest.mjs run \\\n  tests/unit/extensions/manifest-route-sync.test.ts addons/lead-gen/tests/unit&#39;</code></pre>",
          "byteSize": 10290
        }
      }
    },
    {
      "id": "manifest-routing",
      "uuid": "170ef3fc-b8d2-4760-a92e-98ffce3fc31e",
      "name": "Manifest Routing",
      "version": "1.1.2",
      "description": "Routing over the Core provider catalog, subscriptions and monitoring as an installable addon.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "llm-routing",
        "subscription-management",
        "usage-monitoring",
        "zai"
      ],
      "tags": [],
      "download": "/artifacts/manifest-routing-1.1.2.zip",
      "sha256": "e56d4a171b7f70248263a5cde740ba91f789f6e25316689dd6e695c7ec30e1d4",
      "size": 195494,
      "page": {
        "path": "/addons/manifest-routing",
        "name": "Manifest Routing",
        "icon": "🧭",
        "section": "Admin",
        "component": "@addons/manifest-routing/src/components/ManifestRoutingSettings"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/manifest-routing",
        "/api/v1/addons/manifest-routing/subscriptions",
        "/api/v1/addons/manifest-routing/zai",
        "/api/v1/addons/manifest-routing/notifications",
        "/api/v1/addons/manifest-routing/failed-requests",
        "/api/v1/addons/manifest-routing/health-check",
        "/api/v1/addons/manifest-routing/stats",
        "/api/v1/addons/manifest-routing/rule/{id}"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Manifest Routing</h1>\n<p>Routing over the Core provider catalog, subscriptions and monitoring as an installable addon.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>manifest-routing</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> llm-routing, subscription-management, usage-monitoring, zai</li><li><strong>UI-Seite:</strong> <code>/addons/manifest-routing</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Dieses Addon benötigt folgende Secrets/Provider-Konfiguration (Settings → Credentials bzw. Provider-Store, <strong>nie</strong> in addon.json):</p>\n<ul><li><code>OPENAI_CLIENT_SECRET</code></li></ul>\n<p>Provider, Modelle und API-Keys werden ausschließlich vom Core unter<br /><code>/settings#llm-providers</code> beziehungsweise <code>/api/v1/llm-providers</code> verwaltet.<br />Manifest Routing liest den Core-Provider-Katalog und referenziert dessen Provider- und Modell-IDs<br />in Routing-Regeln; das Addon besitzt keine eigene Provider-CRUD-Oberfläche.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET, POST | <code>/api/v1/addons/manifest-routing</code> |<br />| GET, POST, DELETE | <code>/api/v1/addons/manifest-routing/failed-requests</code> |<br />| GET | <code>/api/v1/addons/manifest-routing/health-check</code> |<br />| GET, POST, DELETE | <code>/api/v1/addons/manifest-routing/notifications</code> |<br />| POST | <code>/api/v1/addons/manifest-routing/oauth</code> |<br />| DELETE | <code>/api/v1/addons/manifest-routing/rule/[id]</code> |<br />| GET | <code>/api/v1/addons/manifest-routing/stats</code> |<br />| GET | <code>/api/v1/addons/manifest-routing/status</code> |<br />| GET, POST, PUT, DELETE | <code>/api/v1/addons/manifest-routing/subscriptions</code> |<br />| GET, POST | <code>/api/v1/addons/manifest-routing/zai</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>Keine bekannten Einschränkungen.</li></ul>\n<blockquote><p>README aus <code>addon.json</code> + vorhandenen Routen generiert (<code>scripts/generate-addon-readmes.js</code>). Bei manuellen Ergänzungen den Generator-Hinweis entfernen.</p></blockquote>",
          "byteSize": 1667
        }
      }
    },
    {
      "id": "marketing-ads",
      "uuid": "f1030d66-e21a-4546-8085-1ea9840bdf6d",
      "name": "Marketing & Ads (Affiliate-Tracking + Google/Meta Kampagnen + KI-Creatives)",
      "version": "1.0.3",
      "description": "Per-User Marketing: Ad-Kampagnen (Google/Meta) mit Budget & ROAS, Affiliate-Tracking mit Klick-/Conversion-Zählern und KI-Creative-Prompts. Plattform-Spend/Revenue fließen über einen injizierbaren, per-User BYOK Fetch-Seam (nie hartcodiert); KI-Creatives hinter einem LLM-Gateway-Seam mit graceful Degradation. Lokale SQLite ist die Quelle der Wahrheit (ADR-054).",
      "author": "Aiopta",
      "icon": "📣",
      "capabilities": [
        "marketing",
        "ads",
        "campaigns",
        "google",
        "meta",
        "affiliates",
        "creatives",
        "budget",
        "roas"
      ],
      "tags": [],
      "download": "/artifacts/marketing-ads-1.0.3.zip",
      "sha256": "19474dbd3ade5e29c824bb38d2e9cdabd4a9d67f3e25301ab7f8fba70580840d",
      "size": 121571,
      "page": {
        "id": "marketing-ads-page",
        "path": "/addons/marketing-ads",
        "name": "Marketing & Ads",
        "icon": "📣",
        "section": "Money",
        "component": "@addons/marketing-ads/src/components/MarketingAdsPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/marketing-ads/campaigns",
        "/api/v1/addons/marketing-ads/campaigns/:id",
        "/api/v1/addons/marketing-ads/affiliates",
        "/api/v1/addons/marketing-ads/affiliates/:id",
        "/api/v1/addons/marketing-ads/affiliates/:id/click",
        "/api/v1/addons/marketing-ads/affiliates/:id/convert",
        "/api/v1/addons/marketing-ads/creatives",
        "/api/v1/addons/marketing-ads/creatives/:id",
        "/api/v1/addons/marketing-ads/stats"
      ],
      "skills": [
        {
          "name": "marketing-ads-read",
          "displayName": "Marketing & Ads: Kampagnen, ROAS & Affiliates lesen",
          "description": "Listet Ad-Kampagnen (Google/Meta) mit Budget/Spend/Revenue/ROAS, Affiliate-Links mit Klick-/Conversion-Zählern und KI-Creatives für den Nutzer. Reiner Lesezugriff — legt nichts an.",
          "permissions": [
            "addons/marketing-ads/campaigns",
            "addons/marketing-ads/affiliates",
            "addons/marketing-ads/creatives",
            "addons/marketing-ads/stats"
          ],
          "endpoint": "/api/v1/addons/marketing-ads/stats",
          "scripts": []
        },
        {
          "name": "marketing-ads-manage",
          "displayName": "Marketing & Ads: Kampagnen & Budget verwalten",
          "description": "Legt Ad-Kampagnen (Google/Meta) an oder entfernt diese und setzt Status/Budget/Spend/Revenue (per-User gebucht). ROAS wird aus lokaler Spend/Revenue berechnet.",
          "permissions": [
            "addons/marketing-ads/campaigns"
          ],
          "endpoint": "/api/v1/addons/marketing-ads/campaigns",
          "scripts": []
        },
        {
          "name": "marketing-ads-affiliates",
          "displayName": "Marketing & Ads: Affiliate-Links verwalten",
          "description": "Legt Affiliate-/Partner-Links an, zeichnet Klicks/Conversions auf (per-User gebucht) und entfernt diese.",
          "permissions": [
            "addons/marketing-ads/affiliates"
          ],
          "endpoint": "/api/v1/addons/marketing-ads/affiliates",
          "scripts": []
        },
        {
          "name": "marketing-ads-creatives",
          "displayName": "Marketing & Ads: KI-Creatives generieren",
          "description": "Erstellt KI-Creative-Prompts (Google/Meta) und generiert Copy über das LLM-Gateway (graceful Degradation auf einen Stub, falls das Gateway nicht konfiguriert ist).",
          "permissions": [
            "addons/marketing-ads/creatives"
          ],
          "endpoint": "/api/v1/addons/marketing-ads/creatives",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>📣 Marketing &amp; Ads (Affiliate-Tracking + Google/Meta Kampagnen + KI-Creatives)</h1>\n<p>Per-User Marketing: Ad-Kampagnen (Google/Meta) mit Budget &amp; ROAS, Affiliate-Tracking mit Klick-/Conversion-Zählern und KI-Creative-Prompts. Plattform-Spend/Revenue fließen über einen injizierbaren, per-User BYOK Fetch-Seam (nie hartcodiert); KI-Creatives hinter einem LLM-Gateway-Seam mit graceful Degradation. Lokale SQLite ist die Quelle der Wahrheit (ADR-054).</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>marketing-ads</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> marketing, ads, campaigns, google, meta, affiliates, creatives, budget, roas</li><li><strong>UI-Seite:</strong> <code>/addons/marketing-ads</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials. Die<br />Google/Meta-Plattform-Integration (Spend/Revenue) ist OPTIONAL und per-User<br />BYOK (Bring-Your-Own-Key): Tokens liegen im Nutzer-Secret-Store und fließen über<br />den injizierbaren Fetch-Seam (<code>src/lib/marketing-ads-fetch.ts</code>). Bis eine echte<br />Plattform-Anbindung konfiguriert ist, läuft das Addon auf lokalen Zahlen<br />(graceful Degradation) — ROAS bleibt deterministisch offline. Die KI-Creative-<br />Generierung (<code>src/lib/marketing-ads-creative.ts</code>) verhält sich analog: fehlt das<br />LLM-Gateway, wird ein deterministischer Stub gespeichert.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />| --- | --- |<br />| GET, POST | <code>/api/v1/addons/marketing-ads/campaigns</code> |<br />| PATCH, DELETE | <code>/api/v1/addons/marketing-ads/campaigns/:id</code> |<br />| GET, POST | <code>/api/v1/addons/marketing-ads/affiliates</code> |<br />| DELETE | <code>/api/v1/addons/marketing-ads/affiliates/:id</code> |<br />| POST | <code>/api/v1/addons/marketing-ads/affiliates/:id/click</code> |<br />| POST | <code>/api/v1/addons/marketing-ads/affiliates/:id/convert</code> |<br />| GET, POST | <code>/api/v1/addons/marketing-ads/creatives</code> |<br />| DELETE | <code>/api/v1/addons/marketing-ads/creatives/:id</code> |<br />| GET | <code>/api/v1/addons/marketing-ads/stats</code> |</p>\n<p>Alle Routen sind <code>userRoute</code> + <code>auth.id</code>-scoped (ADR-054). Eine fremde ID ergibt<br />404 ohne Existenz-Leak.</p>\n<h2>Architektur</h2>\n<p>- <strong>Per-User Store</strong> (<code>src/lib/marketing-ads-store.ts</code>): <code>scopedStore(userId)</code> +<br />  <code>registerScopedTable</code> für <code>marketing_ads_campaigns</code>, <code>marketing_ads_affiliates</code>,<br />  <code>marketing_ads_creatives</code>. ROAS = revenue / spend (0 bei Spend 0, nie Infinity).<br />- <strong>Affiliate-Resolver</strong> (<code>src/lib/affiliate-link.ts</code>): reine URL-Builder +<br />  Resolver für <code>/go/&lt;code&gt;</code> → Ziel-URL.<br />- <strong>Fetch-Seam</strong> (<code>src/lib/marketing-ads-fetch.ts</code>): injizierbarer Google/Meta-<br />  Adapter, BYOK-Tokens per User, graceful Degradation.<br />- <strong>Creative-Seam</strong> (<code>src/lib/marketing-ads-creative.ts</code>): injizierbares LLM-<br />  Gateway, deterministischer Stub-Fallback.</p>",
          "byteSize": 2618
        }
      }
    },
    {
      "id": "marketplace",
      "uuid": "685a4b98-3401-4207-bf8c-4259c6d5d32b",
      "name": "Agent-/Addon-Marketplace",
      "version": "1.5.2",
      "description": "An in-instance marketplace: a browsable catalog to discover, install and publish agents (as shared templates) and addons. A thin curation surface over existing primitives — agent listings are shared agent-templates (instantiate to install, Phase A), addon listings are installable addons from the registry (enable to install, Phase K). Listings may be PAID (one-time and/or subscription price + currency); a configurable platform revenue-share % splits each paid transaction (seller payout vs platform cut). Purchases create a payment via the existing payments addon — when payments is unconfigured the paid flow is gracefully inactive while free listings + browsing keep working. It never re-implements sharing/install/Stripe-PayPal, never copies a secret, is visibility-aware (a private template is never exposed), and never calls external services (ADR-009, ADR-026). Cross-instance/remote-registry marketplace is out of scope.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "marketplace-catalog",
        "marketplace-agent-listings",
        "marketplace-addon-listings",
        "marketplace-install",
        "marketplace-publish",
        "marketplace-paid-listings",
        "marketplace-revenue-share",
        "marketplace-seeds",
        "marketplace-subscriptions"
      ],
      "tags": [],
      "download": "/artifacts/marketplace-1.5.2.zip",
      "sha256": "5ba5f17be47f0d43ea8ecd68712e96d1122d45a0d5ef1d12b8e56440b32c2f5c",
      "size": 509097,
      "page": {
        "id": "marketplace-page",
        "path": "/addons/marketplace",
        "name": "Marketplace",
        "icon": "🛍️",
        "section": "Addons",
        "component": "@addons/marketplace/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/marketplace/status",
        "/api/v1/addons/marketplace/listings",
        "/api/v1/addons/marketplace/listings/:id",
        "/api/v1/addons/marketplace/listings/:id/install",
        "/api/v1/addons/marketplace/listings/:id/purchase",
        "/api/v1/addons/marketplace/listings/:id/pricing",
        "/api/v1/addons/marketplace/listings/:id/tiers",
        "/api/v1/addons/marketplace/listings/:id/subscribe",
        "/api/v1/addons/marketplace/listings/:id/cancel",
        "/api/v1/addons/marketplace/revenue-share",
        "/api/v1/addons/marketplace/publish",
        "/api/v1/addons/marketplace/widget",
        "/api/v1/addons/marketplace/seeds",
        "/api/v1/addons/marketplace/seeds/:id/install"
      ],
      "skills": [
        {
          "name": "marketplace-browse",
          "displayName": "Marketplace: Katalog durchsuchen",
          "description": "List/search the in-instance catalog of agents (shared templates) and addons. Visibility-aware: never returns another user's private template. Read-only; supports kind/category/q filters and a detail view per listing.",
          "permissions": [
            "addons/marketplace/listings",
            "addons/marketplace/status"
          ],
          "endpoint": "/api/v1/addons/marketplace/listings",
          "scripts": [
            {
              "id": "marketplace-browse-cli",
              "name": "marketplace-browse-cli",
              "path": "marketplace-browse/cli.sh",
              "sha256": "80a5e529eeafe322234457b593d1b8265cd27c3426ab9577deedf4584f5ba19b",
              "interpreter": "bash",
              "timeoutMs": 10000,
              "permissions": [
                "addons/marketplace/listings",
                "addons/marketplace/status"
              ],
              "inputSchema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "limit": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 100,
                    "default": 20
                  },
                  "timeoutMs": {
                    "type": "integer",
                    "minimum": 100,
                    "maximum": 300000
                  }
                }
              },
              "outputSchema": {
                "type": "object",
                "required": [
                  "ok"
                ],
                "properties": {
                  "ok": {
                    "type": "boolean"
                  },
                  "status": {
                    "type": "integer"
                  },
                  "data": {},
                  "error": {
                    "type": "string"
                  },
                  "truncated": {
                    "type": "boolean"
                  }
                }
              },
              "size": 12111
            }
          ]
        },
        {
          "name": "marketplace-install",
          "displayName": "Marketplace: Eintrag installieren/verwenden",
          "description": "Install a listing: kind=agent instantiates the shared template for the caller via the Phase-A path (own key, no secrets); kind=addon enables the available addon (Phase K). Increments the install counter. Reuses existing primitives — never re-implements provisioning or ZIP install.",
          "permissions": [
            "addons/marketplace/listings",
            "addons/marketplace/status"
          ],
          "endpoint": "/api/v1/addons/marketplace/listings/:id/install",
          "scripts": []
        },
        {
          "name": "marketplace-publish",
          "displayName": "Marketplace: Agenten veröffentlichen",
          "description": "Publish an agent the caller owns as a SHARED template + curated listing (owner/admin-gated, same capability as Phase-A shared-publish). Captures only declarative config (model/soul/skills/permission-preset) — never secrets. Addons are listed from the registry, not published here.",
          "permissions": [
            "addons/marketplace/publish",
            "addons/marketplace/status"
          ],
          "endpoint": "/api/v1/addons/marketplace/publish",
          "scripts": []
        },
        {
          "name": "marketplace-seeds",
          "displayName": "Marketplace: Seed-Katalog (kuratiert)",
          "description": "Read the curated marketplace seed catalog (issue #321, Slice C): agents / addons / workflows shipped as versioned fixtures, with slice-B import state overlaid (installable / installed / upgrade-available / missing-dependency). Install a seed by id; the slice-B idempotent importer is the canonical writer.",
          "permissions": [
            "addons/marketplace/listings",
            "addons/marketplace/status"
          ],
          "endpoint": "/api/v1/addons/marketplace/seeds",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Agent-/Addon-Marketplace</h1>\n<p>An <strong>in-instance</strong> marketplace — one browsable catalog to <strong>discover, install, and publish</strong> agents (as<br />shared templates) and addons inside this Aiopta instance.</p>\n<p>It is a thin <strong>curation layer over primitives that already exist</strong> and re-implements <strong>nothing</strong><br />(ADR-026):</p>\n<p>- <strong>Agent listings</strong> ARE shared <code>agent_templates</code> (Phase A / ADR-021). Install = instantiate the template<br />  for the caller (a new agent with its own key, <strong>no secrets copied</strong>). Publish = share an agent you own.<br />- <strong>Addon listings</strong> are installable addons from the registry (Phase K / ADR-022). Install = enable the<br />  available addon. A ZIP upload is a separate flow (<code>POST /api/v1/addons/install</code>).</p>\n<h2>Model</h2>\n<p>- <strong>Listing</strong> = `{ id, kind:&#39;agent&#39;|&#39;addon&#39;, refId, title, description, category, authorUserId,<br />  visibility:&#39;shared&#39;, installs, featured, installed, pricing, purchased, createdAt, source }`.<br />  - <code>refId</code> is an <code>agent_templates.id</code> (agent) or an addon id (addon). <code>id</code> is synthetic<br />    (<code>agent:&lt;id&gt;</code> / <code>addon:&lt;id&gt;</code>), and a curated <code>mkt-&lt;uuid&gt;</code> id also resolves.<br />  - <code>installed</code> is computed <strong>live per caller</strong> (own shared template / enabled addon).<br />  - <code>pricing</code> = `{ pricingModel:&#39;free&#39;|&#39;one_time&#39;|&#39;subscription&#39;|&#39;both&#39;, priceMinor, subscriptionPriceMinor,<br />    subscriptionInterval, currency }<code> (money in **integer minor units**; </code>free` by default — no regression).<br />  - <code>purchased</code> = whether the <strong>caller</strong> holds a completed entitlement (buyer-scoped; always false for free).<br />- The addon owns ONE thin table <code>marketplace_listings</code> (curation metadata + install counter +<br />  monetization columns, <code>UNIQUE(kind, ref_id)</code>) and a per-buyer <code>marketplace_purchases</code> ledger<br />  (<code>user_id NOT NULL</code>, <code>UNIQUE(user_id, listing_id)</code>). It never copies the template payload or addon code —<br />  those stay in their Phase-A / Phase-K homes and are read live by <code>src/lib/catalog.ts</code>.</p>\n<h2>Monetization (#122, ADR-026 addendum)</h2>\n<p>- A listing can be <strong>paid</strong>: a one-time and/or subscription <strong>price</strong> + currency (<code>monetization.ts</code>,<br />  pure). Free stays the default. Money mirrors the <strong>payments</strong> addon convention (minor units + ISO-4217).<br />- A configurable <strong>platform revenue-share %</strong> (instance-wide admin setting, Aiopta addon-config) splits each<br />  paid transaction into a platform cut + seller payout (<code>computeRevenueSplit</code>, rounding-safe: cut + payout<br />  == gross exactly). The split is recorded per transaction on the purchase row.<br />- A <strong>purchase</strong> reuses the <strong>payments</strong> addon read-only (provider registry + payment-link store) — no<br />  Stripe/PayPal reimplemented. <strong>Owner creds are owner-secret (Block H):</strong> when payments is unconfigured<br />  the paid flow is <strong>gracefully inactive</strong> (<code>409</code> &quot;payments not configured&quot;), free listings + browsing<br />  always work. On success the buyer gets a (pending) entitlement; a paid listing must be purchased before<br />  it can be installed (<code>402</code> otherwise).<br />- <strong>Policy:</strong> publishing (free or paid) uses the existing <code>agent.create</code> gate; editing a listing&#39;s price is<br />  <strong>owner-scoped</strong> (seller or admin); changing the revenue-share % is <strong><code>mc.manage</code>-admin-gated</strong>.</p>\n<h2>Aggregation</h2>\n<p><code>listMarketplace(user, query)</code> merges <code>listVisibleTemplates(user)</code> (filtered to <code>visibility===&#39;shared&#39;</code>)<br />with <code>readInstalledAddonCatalog()</code> (excluding the marketplace itself), applies <code>q</code>/<code>category</code>/<code>kind</code><br />filters and <code>sort</code> (<code>featured|installs|recent|title</code>), and lets curated rows win over derived rows.</p>\n<h2>Safety</h2>\n<p>- <strong>Visibility-aware</strong>: only <code>shared</code> agent-templates are surfaced. **Another user&#39;s PRIVATE template is<br />  never exposed** — not in browse, and not by a guessed <code>agent:&lt;id&gt;</code> (detail/install resolve through the<br />  same function → 404).<br />- <strong>No secrets</strong>: publish reuses the Phase-A allow-list projection (<code>buildTemplateFromAgent</code>); install<br />  mints a new own key via the Phase-A instantiate route.<br />- <strong>No external calls</strong>: install dispatches to the existing primitive over the <strong>same-origin</strong> Aiopta API<br />  (<code>assertSameOrigin</code>); there is no container and no outbound request (ADR-009).<br />- <strong>RBAC</strong>: browsing is for any authed user; publishing a shared listing is <strong>owner/admin-gated</strong> (the<br />  same <code>agent.create</code> capability Phase-A uses for instance-wide sharing). Editing a listing&#39;s price is<br />  owner-scoped; the revenue-share % is <code>mc.manage</code>-admin-gated.<br />- <strong>Isolation</strong>: catalog <strong>reads are instance-wide by design</strong> (public catalog, not a leak); **listing<br />  writes<strong> (incl. pricing) are </strong>owner-scoped** (seller <code>user_id</code> stamped from <code>auth.id</code>);<br />  <strong>purchases/entitlements</strong> are <strong>buyer-scoped</strong> (<code>WHERE user_id</code>). All new raw-<code>getDb</code> access lives in the<br />  already-allowlisted <code>src/lib/store.ts</code>; no allowlist addition was needed.</p>\n<h2>Routes</h2>\n<p>Dual-registered in <code>addon.json</code> + <code>src/manifest.ts</code>:</p>\n<ul><li><code>GET /api/v1/addons/marketplace/status</code></li><li><code>GET /api/v1/addons/marketplace/listings?kind=&amp;category=&amp;q=&amp;sort=</code></li><li><code>GET /api/v1/addons/marketplace/listings/[id]</code></li><li><code>POST /api/v1/addons/marketplace/listings/[id]/install</code></li><li><code>POST /api/v1/addons/marketplace/listings/[id]/purchase</code> — buy a paid listing (#122)</li><li><code>PUT /api/v1/addons/marketplace/listings/[id]/pricing</code> — set a listing&#39;s price (owner/admin, #122)</li><li><code>GET</code> + <code>PUT /api/v1/addons/marketplace/revenue-share</code> — read / set the platform % (PUT admin-only, #122)</li><li><code>POST /api/v1/addons/marketplace/publish</code></li><li><code>GET /api/v1/addons/marketplace/widget</code></li></ul>\n<p>See <code>SKILL.md</code> for the agent-facing endpoint cheat-sheet, and <code>docs/architecture/ADR-026-marketplace.md</code><br />(incl. the monetization addendum) for the full decision. Paid listings are now supported (OPEN-DECISIONS<br />#33 resolved); cross-instance/remote-registry marketplace remains parked (OPEN-DECISIONS #32).</p>",
          "byteSize": 5640
        }
      }
    },
    {
      "id": "marketplace-automation",
      "uuid": "777c8b5f-044c-4a2a-a55c-1e256cb91f1a",
      "name": "Marketplace Automation",
      "version": "2.0.3",
      "description": "DEPRECATED — zusammengeführt in das kanonische Commerce-Addon `commerce` als Marketplace-Connector (ADR-008 Addon-Konsolidierung, Issue #591 / Epic #492). Die Publish-Pipeline + der Store sind als Feature-Modul unter `commerce/src/connectors/marketplace/` migriert: BYOK-Credentials in der gemeinsamen scopedStore-Basis, Alt-ID-Redirect `/addons/marketplace-automation` → `/addons/commerce?connector=marketplace`. Die Section-Kollision (Operations → Money) ist final aufgelöst: marketplace-automation ist als Connector in der einheitlichen commerce-Section. marketplace-automation bleibt bis zum Sunset (2026-12-31) voll funktionsfähig und datenerhaltend — kein Endpoint entfällt, keine Zeile wird kopiert/gelöscht. Ursprüngliche Fähigkeit: Manage marketplace accounts, create listing drafts, publish to supported portals, and track publish jobs from inside Aiopta.",
      "author": "Aiopta",
      "icon": "🛍️",
      "capabilities": [
        "commerce-marketplace",
        "marketplace-accounts",
        "listing-drafts",
        "listing-publish",
        "listing-tracking"
      ],
      "tags": [],
      "download": "/artifacts/marketplace-automation-2.0.3.zip",
      "sha256": "04b55cfa0ac3eaa6f047a84f7855b6fe021a7a3dadd531257c095c634887eab6",
      "size": 79941,
      "page": {
        "id": "marketplace-automation",
        "name": "Marketplace Automation",
        "path": "/addons/marketplace-automation",
        "icon": "🛍️",
        "section": "Operations",
        "component": "@addons/marketplace-automation/src/page"
      },
      "deprecated": true,
      "replacedBy": "commerce",
      "deprecation": {
        "deprecated": true,
        "replacedBy": "commerce",
        "sunset": "2026-12-31",
        "successorPath": "/addons/commerce",
        "successorLabel": "Commerce (Nachfolger)"
      },
      "endpoints": [
        "/api/v1/addons/marketplace-automation/status",
        "/api/v1/addons/marketplace-automation/accounts",
        "/api/v1/addons/marketplace-automation/accounts/:id",
        "/api/v1/addons/marketplace-automation/listings",
        "/api/v1/addons/marketplace-automation/listings/:id",
        "/api/v1/addons/marketplace-automation/listings/:id/publish",
        "/api/v1/addons/marketplace-automation/jobs"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>🛍️ Marketplace Automation</h1>\n<p>Manage marketplace accounts, create listing drafts, publish to supported portals, and track publish jobs from inside Aiopta.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>marketplace-automation</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> marketplace-accounts, listing-drafts, listing-publish, listing-tracking</li><li><strong>UI-Seite:</strong> <code>/addons/marketplace-automation</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET, POST, DELETE | <code>/api/v1/addons/marketplace-automation/accounts</code> |<br />| GET, PUT | <code>/api/v1/addons/marketplace-automation/accounts/[id]</code> |<br />| GET | <code>/api/v1/addons/marketplace-automation/jobs</code> |<br />| GET, POST | <code>/api/v1/addons/marketplace-automation/listings</code> |<br />| GET, PUT | <code>/api/v1/addons/marketplace-automation/listings/[id]</code> |<br />| POST | <code>/api/v1/addons/marketplace-automation/listings/[id]/publish</code> |<br />| GET | <code>/api/v1/addons/marketplace-automation/status</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>Keine bekannten Einschränkungen.</li></ul>\n<blockquote><p>README aus <code>addon.json</code> + vorhandenen Routen generiert (<code>scripts/generate-addon-readmes.js</code>). Bei manuellen Ergänzungen den Generator-Hinweis entfernen.</p></blockquote>",
          "byteSize": 1197
        }
      }
    },
    {
      "id": "mcp",
      "uuid": "c0f71398-0eeb-4cf5-b840-a6b83c5879ac",
      "name": "MCP Servers",
      "version": "1.3.3",
      "description": "Model Context Protocol server management, runtime installation, tool discovery, agent tool execution, and per-user least-privilege tool policies (addons#178).",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "mcp",
        "tools",
        "servers",
        "agent-tools"
      ],
      "tags": [],
      "download": "/artifacts/mcp-1.3.3.zip",
      "sha256": "61689e3aa3408ac8d7c3d494887d4da0e7d6268b22596937c8e626997ec1f60d",
      "size": 408987,
      "page": {
        "id": "mcp",
        "path": "/addons/mcp",
        "name": "MCP Servers",
        "icon": "🔌",
        "section": "Admin",
        "component": "@addons/mcp/src/components/MCPPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/mcp/agent",
        "/api/v1/addons/mcp/deploy",
        "/api/v1/addons/mcp/servers",
        "/api/v1/addons/mcp/servers/{id}",
        "/api/v1/addons/mcp/servers/{id}/discover",
        "/api/v1/addons/mcp/servers/{id}/test-auth",
        "/api/v1/addons/mcp/servers/{id}/tool-policy",
        "/api/v1/addons/mcp/servers/{id}/tools",
        "/api/v1/addons/mcp/tools"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>MCP Servers</h1>\n<p>Model Context Protocol server management, runtime installation, tool discovery, and agent tool execution.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>mcp</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> mcp, tools, servers, agent-tools</li><li><strong>UI-Seite:</strong> <code>/mcp</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Optionale BYOK-Credentials pro MCP-Server (<code>auth.token</code>). Diese sind <strong>pro Benutzer</strong> privat.</p>\n<h2>Remote-Transporte (#177)</h2>\n<p><code>type: &quot;http&quot;</code> bedeutet standardkonformes <strong>MCP Streamable HTTP</strong> (offizielles<br /><code>@modelcontextprotocol/sdk</code>). <code>type: &quot;sse&quot;</code> ist Legacy-SSE und nutzt<br /><code>SSEClientTransport</code> aus demselben SDK.</p>\n<p><code>source.url</code> ist <strong>der vollständige MCP-Endpunkt</strong> — inklusive einer etwaigen<br />Secret-Path-Komponente wie <code>http://HA-IP:9583/private_&lt;secret&gt;</code>. Es wird **kein<br />Pfadsegment** angehängt (<code>/health</code>, <code>/tools</code>, <code>/mcp</code> o.ä.). Der Endpunkt wird<br />nur getrimmt und als URL validiert.</p>\n<p>Unterstützte Auth-Modi:</p>\n<p>| <code>auth.type</code> | Verhalten |<br />|---|---|<br />| <code>none</code> | Kein <code>Authorization</code>-Header. |<br />| <code>token</code> (statischer Bearer) | <code>requestInit.headers.Authorization: Bearer &lt;token&gt;</code> — Token passiert ausschließlich diesen Pfad. |<br />| <code>oauth</code> (Authorization-Code-Flow) | <strong>Nicht unterstützt</strong> — saubere <code>not supported</code>-Fehlermeldung ohne Fallback. |</p>\n<p>Health = erfolgreicher MCP-<code>initialize</code>-Handshake am konfigurierten Endpunkt<br />(optional gefolgt von <code>tools/list</code>). Es gibt <strong>keinen</strong> proprietären <code>/health</code><br />Endpunkt mehr.</p>\n<p>Sicherheits-Disziplin:</p>\n<p>- Das Bearer-Token und der Secret-Path-Anteil der URL werden <strong>nie</strong> geloggt,<br />  <strong>nie</strong> in Fehlermeldungen gespiegelt und <strong>nie</strong> über die API zurückgegeben.<br />- Per <code>userId::serverId</code> existiert <strong>genau ein</strong> verbundener SDK-Client +<br />  Transport; zwei Nutzer mit demselben Server-Slug bleiben vollständig<br />  isoliert (ADR-054).</p>\n<h2>Daten-Isolation (#144 / ADR-054)</h2>\n<p>MCP-Server sind <strong>pro Benutzer</strong> isoliert (BYOK). Jeder registrierte Server — inklusive seines<br /><code>auth.token</code> — gehört ausschließlich dem anlegenden Benutzer und liegt in der user-gescopten<br />Tabelle <code>mcp_servers</code> (über <code>scopedStore(auth.id)</code>), <strong>nicht</strong> mehr in einer geteilten Datei.</p>\n<p>- Reads/Updates/Deletes sehen nur die <strong>eigenen</strong> Server; eine fremde Server-ID liefert <strong>404</strong>.<br />- Der rohe <code>auth.token</code> wird über die API <strong>nie</strong> zurückgegeben (maskiert als <code>••••••</code>) — auch nicht<br />  an den Besitzer.<br />- Die Agent-Tool-Ausführung (<code>POST /agent</code>) löst nur die Server des <strong>Agent-Besitzers</strong> auf; ein<br />  fremder Agent kann die BYOK-Server eines Benutzers nicht betreiben.<br />- Der mitgelieferte <code>config/mcp-servers.json</code> ist nur noch ein <strong>read-only Instanz-Katalog</strong> von<br />  Default-Server-Definitionen (ohne Secrets), aus dem die privaten Zeilen eines Benutzers beim<br />  ersten Zugriff geseedet werden (alle deaktiviert, ohne Token).</p>\n<h2>Least-Privilege-Tool-Profile (addons#178)</h2>\n<p><code>homeassistant-ai/ha-mcp</code> veröffentlicht 84 Werkzeuge; ohne Profile könnte ein Agent nach Eingabe<br />einer Secret-URL unmittelbar den gesamten schreibenden und teils destruktiven HA-Katalog<br />bedienen. Die MCP-Integration filtert daher jede Tool-Sicht und jeden <code>tools/call</code> durch die<br /><strong>Least-Privilege-Policy</strong> des Server-Owners, <strong>bevor</strong> der Aufruf an den MCP-Transport geht<br />(zweistufig: Discovery-Filter + Execution-Gate).</p>\n<h3>Policy-Modell</h3>\n<p>Pro <code>mcp_servers</code>-Zeile existiert eine Policy in der Spalte <code>tool_policy_json</code><br />(Default: <code>custom</code> + leere Listen = <strong>Default-Deny</strong>). Optional überschreibt eine zweite,<br />per-User Override in <code>mcp_tool_policies</code> (<code>scopedStore(userId)</code>) die Server-Konfig — der<br />Override <strong>ersetzt</strong> die Server-Policy (kein Merge, ADR-054).</p>\n<p>| Feld | Mögliche Werte | Bedeutung |<br />|---|---|---|<br />| <code>mode</code> | <code>custom</code> \\| <code>read-only</code> \\| <code>write</code> \\| <code>full</code> | Steuert die annotations-getriebene Auswahl. <code>custom</code> = nur Listen zählen. |<br />| <code>allowDestructive</code> | <code>boolean</code> | Nur unter <code>mode: &quot;full&quot;</code> wirksam. Öffnet destructive Tools. |<br />| <code>allowedTools</code> | <code>string[]</code> | Optionale Allowlist (zusätzlich zur Mode-Grenze; kann sie <strong>nicht</strong> erweitern). |<br />| <code>deniedTools</code> | <code>string[]</code> | Denylist — gewinnt immer gegen die Allowlist. |</p>\n<h3>Semantik pro Mode</h3>\n<p>| Mode | Sichtbar sind … | <code>destructiveHint: true</code> … |<br />|---|---|---|<br />| <code>custom</code> (Default) | nur <code>allowedTools</code> (ohne <code>deniedTools</code>) | wie der Mode bzw. die Liste es hergibt |<br />| <code>read-only</code> | Tools mit <code>annotations.readOnlyHint === true</code> | blockiert |<br />| <code>write</code> | Read-only + nicht-destruktive Write-Tools | blockiert |<br />| <code>full</code> | alle nicht-destruktiven Tools | blockiert — <code>allowDestructive: true</code> hebt es auf |<br />| _kein Mode / fehlende Annotation_ | _nichts_ (Default-Deny) | blockiert |</p>\n<h3>Vertrag (Edge-Cases)</h3>\n<p>- <strong>Default-Deny</strong>: leere / fehlende Policy → kein Tool ist sichtbar. Es gibt <strong>keinen</strong> stillen<br />  Default-Allow.<br />- <strong>Fehlende oder widersprüchliche Annotationen</strong> bleiben in jedem annotation-getriebenen Mode<br />  unsichtbar — ein Tool ohne <code>annotations</code> ist im <code>read-only</code>-, <code>write</code>- und <code>full</code>-Mode blockiert.<br />- <strong>Cache-Stale-State</strong>: ein bereits entdeckter Tool-Aufruf wird nach Policy-Verschärfung<br />  sofort blockiert — der Execution-Gate liest die Policy unmittelbar vor <code>tools/call</code> neu.<br />- <strong>Direkter Execute-Request</strong> auf ein blockiertes Tool ergibt 403 mit <code>MCP_TOOL_DENIED</code>,<br />  <strong>ohne</strong> Argumente, Token oder Secret-Path-URL in der Fehlermeldung.<br />- <strong>Per-User-Isolation</strong>: <code>mcp_tool_policies</code> ist <code>UNIQUE(user_id, server_id)</code> — zwei Nutzer<br />  mit demselben Slug haben distinkte Overrides. Eine Policy-Änderung von A berührt B nie.<br />- <strong>Legacy-Fallback</strong> (bestehende Server ohne <code>tool_policy_json</code>): die Spalte wird mit der<br />  &quot;legacy full&quot; Policy (<code>mode: &quot;full&quot;</code>, <code>allowDestructive: false</code>) back-gefüllt. Das<br />  HA-MCP-Onboarding MUSS dagegen explizit <code>read-only</code> schreiben.<br />- <strong>Race-Safety</strong>: das Lesen der effektiven Policy läuft in einer Transaktion; ein gleichzeitiger<br />  PATCH + callTool reißt den Zustand nicht.</p>\n<h3>HA-spezifische Sicherheitsregeln (Vorgabe für Onboarding)</h3>\n<p>- <code>ha_config_set_yaml</code>, <code>ha_write_file</code>, <code>ha_delete_file</code> sind <strong>immer</strong> Default-Deny.<br />  Sie tauchen ohne explizite Allowlist in keiner Mode-Sicht auf und werden am Gate<br />  durch <code>McpToolDeniedError</code> (kein Token, keine Argumente, kein Secret-Path) abgewiesen.<br />- <code>full + allowDestructive: true</code> erfordert in der UI eine zweite ausdrückliche Bestätigung<br />  (Tippen der Server-ID); die UI schaltet <code>allowDestructive</code> niemals stillschweigend frei.<br />- Der Status zeigt, ob <code>ha_manage_backup</code> bzw. die Upstream-Safety-Tools entdeckt wurden.</p>\n<h3>Per-User-Override (API)</h3>\n<p>| Methode | Pfad | Effekt |<br />|---|---|---|<br />| <code>GET</code> | <code>/api/v1/addons/mcp/servers/{id}/tool-policy</code> | Effective Policy + <code>source</code> (<code>user-override</code> \\| <code>server-config</code> \\| <code>default</code>) |<br />| <code>PUT</code> | <code>/api/v1/addons/mcp/servers/{id}/tool-policy</code> | Installiert / ersetzt den per-User Override (validiert). |<br />| <code>DELETE</code> | <code>/api/v1/addons/mcp/servers/{id}/tool-policy</code> | Entfernt den Override; Server-Konfig greift wieder. |</p>\n<p>Fremde Server-IDs liefern 404 (kein Existenz-Leak). <code>PUT</code> validiert <code>mode</code>, <code>allowDestructive</code><br />und die Listen — ein leerer Body ist 400, niemals Default-Deny-Fallback.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET, POST | <code>/api/v1/addons/mcp/agent</code> |<br />| POST | <code>/api/v1/addons/mcp/deploy</code> |<br />| GET, POST, PATCH | <code>/api/v1/addons/mcp/servers</code> |<br />| GET, DELETE, PATCH | <code>/api/v1/addons/mcp/servers/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/mcp/servers/[id]/discover</code> |<br />| POST | <code>/api/v1/addons/mcp/servers/[id]/test-auth</code> |<br />| GET, PUT, DELETE | <code>/api/v1/addons/mcp/servers/[id]/tool-policy</code> |<br />| GET | <code>/api/v1/addons/mcp/servers/[id]/tools</code> |<br />| GET, POST | <code>/api/v1/addons/mcp/tools</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<p>- Der MCP-Tool-Schema-Cache (<code>config/mcp-tools-registry.json</code>) und der npm-Runtime-Installer sind<br />  instanzweit (geteilt) — sie enthalten keine Secrets; der Per-User-Zugriff wird in den Routen<br />  erzwungen (jeder Read/Sync ist auf die eigenen Server beschränkt).<br />- OAuth Authorization-Code-Flow ist nicht Bestandteil dieses Addons — ein entsprechender Server<br />  erhält eine <code>MCP_UNSUPPORTED_AUTH</code>-Fehlermeldung ohne Fallback.<br />- Das <code>config/mcp-servers.json</code> ist <strong>read-only</strong>; eine Änderung am dort hinterlegten<br />  <code>toolPolicy</code> wird beim nächsten Seeden in die per-User-Zeile übernommen. Das HA-MCP-<br />  Onboarding wird in einem separaten Issue ausgeliefert und schreibt <code>read-only</code> explizit.</p>",
          "byteSize": 8262
        }
      }
    },
    {
      "id": "media",
      "uuid": "5d497847-829e-4ae6-afb6-502c11699879",
      "name": "Media Server",
      "version": "1.0.3",
      "description": "Home media server management — Plex, Jellyfin, Sonarr, Radarr, qBittorrent, and more. Monitor what's playing, manage libraries, request new content, and control downloads.",
      "author": "Aiopta",
      "icon": "🎬",
      "capabilities": [
        "media",
        "streaming",
        "downloads",
        "plex",
        "jellyfin"
      ],
      "tags": [],
      "download": "/artifacts/media-1.0.3.zip",
      "sha256": "256bbee2f393139ad5dcda3336a41c1b30329f4ea83c7bbbb775ed14fb7dbf85",
      "size": 339645,
      "page": {
        "path": "/addons/media",
        "name": "Media Server",
        "icon": "🎬",
        "section": "Media",
        "component": "@addons/media/src/components/MediaDashboard"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/media/status",
        "/api/v1/addons/media/sessions",
        "/api/v1/addons/media/libraries",
        "/api/v1/addons/media/search",
        "/api/v1/addons/media/request",
        "/api/v1/addons/media/blacklist",
        "/api/v1/addons/media/releases",
        "/api/v1/addons/media/download-queue",
        "/api/v1/addons/media/config",
        "/api/v1/addons/media/health",
        "/api/v1/addons/media/history"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>🎬 Media Server</h1>\n<p>Home media server management — Plex, Jellyfin, Sonarr, Radarr, qBittorrent, and more. Monitor what&#39;s playing, manage libraries, request new content, and control downloads.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>media</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> media, streaming, downloads, plex, jellyfin</li><li><strong>UI-Seite:</strong> <code>/addons/media</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Dieses Addon benötigt folgende Secrets/Provider-Konfiguration (Settings → Credentials bzw. Provider-Store, <strong>nie</strong> in addon.json):</p>\n<ul><li><code>MEDIA_JELLYFIN_KEY</code></li><li><code>MEDIA_LIDARR_KEY</code></li><li><code>MEDIA_OVERSEERR_KEY</code></li><li><code>MEDIA_PLEX_TOKEN</code></li><li><code>MEDIA_PROWLARR_KEY</code></li><li><code>MEDIA_QBIT_PASSWORD</code></li><li><code>MEDIA_RADARR_KEY</code></li><li><code>MEDIA_READARR_KEY</code></li><li><code>MEDIA_SABNZBD_KEY</code></li><li><code>MEDIA_SONARR_KEY</code></li><li><code>MEDIA_TAUTULLI_KEY</code></li></ul>\n<p>Die ENV-Werte dienen nur als gemeinsame Fallback-Baseline. Die eigentliche Konfiguration (Service-URLs + BYOK-Secrets) wird <strong>pro Nutzer</strong> über <code>GET/POST /config</code> gepflegt.</p>\n<h2>Sicherheit / Per-User-Isolation (#136 / ADR-054)</h2>\n<p>Die Media-Konfiguration ist <strong>pro authentifiziertem Aiopta-Nutzer isoliert</strong>: Sie liegt in einer<br />user-gescopten Tabelle (<code>media_config</code>, <code>UNIQUE(user_id)</code>) über <code>scopedStore(auth.id)</code>, und jede<br /><code>MediaRegistry</code> (samt der daraus gebauten Service-Clients) wird <strong>pro Nutzer</strong> aufgebaut — kein<br />prozessweiter Singleton mehr. Konsequenzen:</p>\n<p>- <code>GET /config</code> maskiert <code>apiKey</code>/<code>token</code>/<code>password</code> standardmäßig. <code>GET /config?edit=true</code> liefert<br />  die Klartext-Secrets <strong>ausschließlich für die eigene Konfiguration</strong> des Aufrufers — niemals die<br />  eines anderen Nutzers.<br />- <code>POST /config</code> schreibt nur die eigene Zeile; ein Nutzer kann die Credentials eines anderen weder<br />  lesen noch überschreiben.<br />- Der frühere globale <code>data/media-config.json</code>-Blob wird beim ersten Zugriff einmalig in den<br />  Namespace des Seeding-Admins migriert und anschließend gelöscht.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| POST | <code>/api/v1/addons/media/auto-confirm-import</code> |<br />| GET, POST, DELETE | <code>/api/v1/addons/media/blacklist</code> |<br />| GET, POST | <code>/api/v1/addons/media/config</code> |<br />| PUT | <code>/api/v1/addons/media/config/auto-request</code> |<br />| GET | <code>/api/v1/addons/media/diagnose</code> |<br />| GET, DELETE | <code>/api/v1/addons/media/download-queue</code> |<br />| POST | <code>/api/v1/addons/media/download-queue/bulk-delete</code> |<br />| POST | <code>/api/v1/addons/media/fix</code> |<br />| GET | <code>/api/v1/addons/media/health</code> |<br />| GET | <code>/api/v1/addons/media/history</code> |<br />| GET | <code>/api/v1/addons/media/libraries</code> |<br />| POST | <code>/api/v1/addons/media/libraries/bulk-delete</code> |<br />| DELETE | <code>/api/v1/addons/media/libraries/items</code> |<br />| POST | <code>/api/v1/addons/media/libraries/refresh</code> |<br />| POST | <code>/api/v1/addons/media/libraries/scan</code> |<br />| PUT | <code>/api/v1/addons/media/monitor</code> |<br />| GET | <code>/api/v1/addons/media/popular</code> |<br />| GET | <code>/api/v1/addons/media/propose-import</code> |<br />| GET | <code>/api/v1/addons/media/releases</code> |<br />| POST | <code>/api/v1/addons/media/releases/add-language-priority</code> |<br />| GET | <code>/api/v1/addons/media/releases/detail</code> |<br />| POST | <code>/api/v1/addons/media/releases/grab</code> |<br />| GET, POST, DELETE | <code>/api/v1/addons/media/releases/language-profile</code> |<br />| POST | <code>/api/v1/addons/media/releases/search</code> |<br />| GET, POST | <code>/api/v1/addons/media/releases/search-custom</code> |<br />| POST | <code>/api/v1/addons/media/releases/upgrade-language</code> |<br />| POST | <code>/api/v1/addons/media/request</code> |<br />| GET, PUT, DELETE | <code>/api/v1/addons/media/requests</code> |<br />| POST | <code>/api/v1/addons/media/retry-import</code> |<br />| GET | <code>/api/v1/addons/media/search</code> |<br />| GET | <code>/api/v1/addons/media/search/advanced</code> |<br />| GET | <code>/api/v1/addons/media/sessions</code> |<br />| POST | <code>/api/v1/addons/media/sessions/terminate</code> |<br />| GET | <code>/api/v1/addons/media/stats/downloads</code> |<br />| GET | <code>/api/v1/addons/media/stats/requests-stats</code> |<br />| GET | <code>/api/v1/addons/media/stats/storage</code> |<br />| GET | <code>/api/v1/addons/media/stats/watch</code> |<br />| GET | <code>/api/v1/addons/media/status</code> |<br />| GET | <code>/api/v1/addons/media/trending</code> |<br />| GET | <code>/api/v1/addons/media/upcoming</code> |<br />| POST, DELETE | <code>/api/v1/addons/media/users/ban</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>Keine bekannten Einschränkungen.</li></ul>\n<blockquote><p>README aus <code>addon.json</code> + vorhandenen Routen generiert (<code>scripts/generate-addon-readmes.js</code>). Bei manuellen Ergänzungen den Generator-Hinweis entfernen.</p></blockquote>",
          "byteSize": 4122
        }
      }
    },
    {
      "id": "membership",
      "uuid": "56d99555-98da-45fe-be6c-7040dd69466c",
      "name": "Membership (Mitglieder/Abos/Paywall/Kurse/E-Books)",
      "version": "1.0.3",
      "description": "Per-User Mitgliedschaften: Mitglieder, Abo-Stufen mit Entitlement-Bündeln, Subscriptions (Paywall-Autorität), Kurse und E-Books. Integriert lesend mit dem payments-Addon (ein Abo darf die billing-Subscription referenzieren), ohne payments zu verändern. Lokale SQLite ist die Quelle der Wahrheit (ADR-054).",
      "author": "Aiopta",
      "icon": "🎟️",
      "capabilities": [
        "membership",
        "members",
        "plans",
        "subscriptions",
        "paywall",
        "entitlements",
        "courses",
        "ebooks"
      ],
      "tags": [],
      "download": "/artifacts/membership-1.0.3.zip",
      "sha256": "e2ccced777fab1bfa17ce2bee47263480024ed3d72b77e35ca30be051ec81424",
      "size": 130887,
      "page": {
        "id": "membership-page",
        "path": "/addons/membership",
        "name": "Membership",
        "icon": "🎟️",
        "section": "Money",
        "component": "@addons/membership/src/components/MembershipPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/membership/members",
        "/api/v1/addons/membership/members/:id",
        "/api/v1/addons/membership/plans",
        "/api/v1/addons/membership/plans/:id",
        "/api/v1/addons/membership/subscriptions",
        "/api/v1/addons/membership/subscriptions/:id",
        "/api/v1/addons/membership/courses",
        "/api/v1/addons/membership/courses/:id",
        "/api/v1/addons/membership/ebooks",
        "/api/v1/addons/membership/ebooks/:id",
        "/api/v1/addons/membership/entitlements"
      ],
      "skills": [
        {
          "name": "membership-read",
          "displayName": "Membership: Mitglieder, Abos & Inhalte lesen",
          "description": "Listet Mitglieder, Abo-Stufen, Subscriptions, Kurse und E-Books für den Nutzer und prüft den Entitlement-/Paywall-Status. Reiner Lesezugriff — legt nichts an.",
          "permissions": [
            "addons/membership/members",
            "addons/membership/plans",
            "addons/membership/subscriptions",
            "addons/membership/courses",
            "addons/membership/ebooks",
            "addons/membership/entitlements"
          ],
          "endpoint": "/api/v1/addons/membership/entitlements",
          "scripts": []
        },
        {
          "name": "membership-manage",
          "displayName": "Membership: Mitglieder & Abos verwalten",
          "description": "Legt Mitglieder, Abo-Stufen und Subscriptions an oder entfernt diese (per-User gebucht). Setzt den Subscription-Status, auf dem die Paywall entscheidet.",
          "permissions": [
            "addons/membership/members",
            "addons/membership/plans",
            "addons/membership/subscriptions"
          ],
          "endpoint": "/api/v1/addons/membership/subscriptions",
          "scripts": []
        },
        {
          "name": "membership-content",
          "displayName": "Membership: Kurse & E-Books verwalten",
          "description": "Legt Kurse und E-Books im geschützten Inhaltskatalog des Nutzers an oder entfernt diese (mit erforderlichem Entitlement).",
          "permissions": [
            "addons/membership/courses",
            "addons/membership/ebooks"
          ],
          "endpoint": "/api/v1/addons/membership/courses",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>🎟️ Membership (Mitglieder/Abos/Paywall/Kurse/E-Books)</h1>\n<p>Per-User Mitgliedschaften: Mitglieder, Abo-Stufen mit Entitlement-Bündeln, Subscriptions (Paywall-Autorität), Kurse und E-Books. Integriert lesend mit dem payments-Addon (ein Abo darf die billing-Subscription referenzieren, ohne payments zu verändern). Lokale SQLite ist die Quelle der Wahrheit (ADR-054).</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>membership</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> membership, members, plans, subscriptions, paywall, entitlements, courses, ebooks</li><li><strong>UI-Seite:</strong> <code>/addons/membership</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials. Die<br />payments-Integration ist optional und degradiert graceful, falls payments nicht<br />installiert/verfügbar ist.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET, POST | <code>/api/v1/addons/membership/members</code> |<br />| DELETE | <code>/api/v1/addons/membership/members/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/membership/plans</code> |<br />| DELETE | <code>/api/v1/addons/membership/plans/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/membership/subscriptions</code> |<br />| PATCH, DELETE | <code>/api/v1/addons/membership/subscriptions/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/membership/courses</code> |<br />| DELETE | <code>/api/v1/addons/membership/courses/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/membership/ebooks</code> |<br />| DELETE | <code>/api/v1/addons/membership/ebooks/[id]</code> |<br />| GET | <code>/api/v1/addons/membership/entitlements</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>Der Paywall-Entscheid basiert rein auf dem lokalen Subscription-Status; payments wird nur zur Billing-Anreicherung gelesen.</li></ul>\n<blockquote><p>README aus <code>addon.json</code> + vorhandenen Routen generiert (<code>scripts/generate-addon-readmes.js</code>). Bei manuellen Ergänzungen den Generator-Hinweis entfernen.</p></blockquote>",
          "byteSize": 1730
        }
      }
    },
    {
      "id": "memories",
      "uuid": "477f94c5-8620-46fc-be34-c9ce4de3d8ba",
      "name": "Memories",
      "version": "1.5.2",
      "description": "Long-term memories, memory search, memory group sharing, and a per-user knowledge-map graph.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "memories",
        "memory-groups",
        "search",
        "sharing",
        "knowledge-map",
        "memory-loop"
      ],
      "tags": [],
      "download": "/artifacts/memories-1.5.2.zip",
      "sha256": "df694028a0db5bb71abf8e6b0df635143d5a836ba453e16d47b9b8ed05fcbcb0",
      "size": 380252,
      "page": [
        {
          "id": "memories",
          "path": "/addons/memories",
          "name": "Memories",
          "icon": "🧠",
          "section": "Data",
          "component": "@addons/memories/src/components/memories/MemoryList"
        },
        {
          "id": "memories-graph",
          "path": "/addons/memories/graph",
          "name": "Knowledge Map",
          "icon": "🕸️",
          "section": "Data",
          "component": "@addons/memories/src/components/memories/MemoryGraph"
        },
        {
          "id": "memories-loop",
          "path": "/addons/memories/agent-loop",
          "name": "Memory-Loop",
          "icon": "♻️",
          "section": "Data",
          "component": "@addons/memories/src/components/memories/MemoryLoopSettings"
        }
      ],
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/memories",
        "/api/v1/addons/memories/{id}",
        "/api/v1/addons/memories/search",
        "/api/v1/addons/memories/graph",
        "/api/v1/addons/memories/memory-groups",
        "/api/v1/addons/memories/memory-groups/{id}",
        "/api/v1/addons/memories/memory-groups/{id}/members",
        "/api/v1/addons/memories/memory-groups/{id}/memories",
        "/api/v1/addons/memories/memory-groups/share",
        "/api/v1/addons/memories/memory-groups/visible",
        "/api/v1/addons/memories/agent-loop/settings",
        "/api/v1/addons/memories/agent-loop/sync"
      ],
      "skills": [],
      "mcpServers": [
        {
          "id": "memories-tools",
          "displayName": "Memories tools",
          "description": "Read-only knowledge-map math for agents: extract [[name]] cross-links and assemble the memory/group knowledge graph (nodes, dedup edges, membership) — the same pure builder the memories knowledge-map uses (issue #429, epic #399). Reads nothing external.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "memories-tools/server.mjs",
          "permissions": [
            "addons/memories/read"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [],
          "credentialRefs": [],
          "archiveEntry": "memories/mcp/servers/memories-tools/server.mjs",
          "sha256": "a2eaa5b2d66fba6274d77e1fdcb8cf3167f7750308c2ce6a0d508d8540812c9b",
          "size": 16130,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Memories</h1>\n<p>Long-term memories, memory search, memory group sharing, and a per-user knowledge-map graph.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>memories</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> memories, memory-groups, search, sharing, knowledge-map, memory-loop</li><li><strong>UI-Seiten:</strong> <code>/memories</code> (Liste), <code>/memories/graph</code> (Wissenskarte), <code>/memories/agent-loop</code> (Memory-Loop-Einstellungen)</li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET, POST | <code>/api/v1/addons/memories</code> |<br />| GET, PUT, DELETE | <code>/api/v1/addons/memories/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/memories/memory-groups</code> |<br />| GET, PUT, DELETE | <code>/api/v1/addons/memories/memory-groups/[id]</code> |<br />| GET, POST, DELETE | <code>/api/v1/addons/memories/memory-groups/[id]/members</code> |<br />| GET, POST, DELETE | <code>/api/v1/addons/memories/memory-groups/[id]/memories</code> |<br />| POST | <code>/api/v1/addons/memories/memory-groups/share</code> |<br />| GET | <code>/api/v1/addons/memories/memory-groups/visible</code> |<br />| GET | <code>/api/v1/addons/memories/search</code> |<br />| GET | <code>/api/v1/addons/memories/graph</code> |<br />| GET, POST | <code>/api/v1/addons/memories/agent-loop/settings</code> |<br />| POST | <code>/api/v1/addons/memories/agent-loop/sync</code> |</p>\n<h2>Wissenskarte (Knowledge-Map, #146 / INBOX-69)</h2>\n<p>Die UI-Seite <code>/memories/graph</code> rendert die eigenen Erinnerungen + Memory-Groups als interaktiven<br />Graphen (SVG, keine zusätzliche Abhängigkeit — force-directed Layout):</p>\n<p>- <strong>Knoten</strong> = Erinnerungen (violett, Größe ∝ <code>importance</code>) und Memory-Groups (bernstein).<br />- <strong>Kanten</strong> = <code>[[name]]</code>-Querverweise zwischen Erinnerungen (durchgezogen, titel-aufgelöst,<br />  case-insensitiv, ungerichtet entdoppelt) sowie Memory↔Group-Zugehörigkeit aus<br />  <code>memory_group_memories</code> (gestrichelt).<br />- <strong>Interaktion</strong> = Zoom (Scrollen), Pan (Hintergrund ziehen), Klick auf einen Knoten öffnet die<br />  Erinnerung (Detail-Panel via scoped <code>[id]</code>-Route) bzw. fokussiert auf eine Gruppe; Filter nach Gruppe.</p>\n<p>Die Graph-Daten kommen ausschließlich aus den <strong>bereits pro-Nutzer-isolierten</strong> Stores<br />(<code>listMemories</code>/<code>listGroups</code>/<code>getGroupMemories</code>, alle auf <code>auth.id</code> gescoped) — <code>GET …/graph</code> ist<br /><code>userRoute</code>-gewrappt. Ein <code>[[name]]</code> kann daher nur auf eine <strong>eigene</strong> Erinnerung auflösen, nie auf die<br />eines anderen Tenants.</p>\n<h2>Memory-Loop (#149 / INBOX-72, ADR-058)</h2>\n<p>Agenten schreiben ihre Outputs/Insights <strong>automatisch</strong> als Erinnerungen zurück (Contributor-Pattern)<br />— <strong>opt-in, standardmäßig AUS</strong>, mit optionalem Obsidian-Export. Die UI-Seite <code>/memories/agent-loop</code><br />steuert die per-Nutzer-Einstellung.</p>\n<p>- <strong>Event-getrieben, zweiter Subscriber:</strong> Das Addon abonniert das kanonische Event<br />  <code>agent.output.produced</code> (ADR-053, eingeführt in #148) ein <strong>zweites Mal, unabhängig</strong> vom<br />  artefakt-workspace — derselbe Event erzeugt sowohl ein Artefakt (#148) als auch eine Erinnerung,<br />  ohne Kopplung. Abo lazy+idempotent im <code>install()</code> (globalThis-Singleton, kein Boot-Hook).<br />- <strong>Ownership (ADR-054):</strong> Der Besitzer kommt IMMER aus dem Event-Payload (<code>userId</code>/<code>user_id</code>/<code>ownerId</code>),<br />  NIE aus einem Client-Wert; ein Event ohne erkennbaren Nutzer wird <strong>übersprungen</strong> (geloggt), nicht<br />  geraten. Der Write läuft über den per-Nutzer-<code>createMemory</code>-Store ⇒ <code>user_id</code> server-seitig gestempelt.<br />- <strong>Contributor-Provenance:</strong> Jede agent-erzeugte Erinnerung ist von menschlichen unterscheidbar —<br />  Kategorie <code>agent-contributed</code>, Tags <code>agent-contributed</code> + <code>agent:&lt;id&gt;</code> + <code>event:&lt;id&gt;</code>, plus<br />  Provenienz-Footer im Body (Quell-Agent + Source-Event).<br />- <strong>Opt-in (default OFF):</strong> per-Nutzer-Einstellung in <code>addon_settings</code> (<code>addon_id=&#39;memories-loop&#39;</code>,<br />  über <code>scopedStore</code>); ohne explizites <code>enabled: true</code> wird <strong>nichts</strong> geschrieben. Optionaler<br />  per-Agent-Allowlist (leer = alle Agenten).<br />- <strong>Optionaler Obsidian-Sync (default OFF):</strong> exportiert jede Erinnerung als eine Markdown-Datei<br />  (Frontmatter + Body + native <code>[[links]]</code>) unter <code>resolveUserPath(user,&#39;memories&#39;,&#39;obsidian&#39;,...)</code><br />  ⇒ <code>&lt;DATA_DIR&gt;/addons/memories/&lt;userId&gt;/obsidian</code> (containment-checked; ein <code>../</code>-Escape wird mit<br />  403 abgewiesen). Manueller Re-Export via <code>POST …/agent-loop/sync</code> (nur wenn aktiviert).</p>\n<h2>Sicherheit / Isolation (#137, ADR-054)</h2>\n<p>Alle Daten sind <strong>pro Nutzer isoliert</strong> (default-deny über die <code>scopedStore</code>-Foundation):</p>\n<p>- <code>agent_memories</code>, <code>memory_groups</code>, <code>memory_group_members</code> und <code>memory_group_memories</code> tragen je eine<br />  <code>user_id</code>-Spalte (<code>NOT NULL</code>). Der Server stempelt <code>user_id = auth.id</code> bei jedem Insert — ein<br />  client-gelieferter <code>agent_id</code>/<code>created_by</code>/<code>shared_by</code> ist <strong>nur ein gruppierender Schlüssel</strong>, nie die<br />  Sicherheitsgrenze.<br />- Jeder Read/Update/Delete ist auf den aufrufenden Nutzer gescoped. Eine fremde oder unbekannte <code>{id}</code><br />  liefert <strong>404</strong> (nie die Zeile eines anderen Nutzers, nie 403). Die <code>[id]</code>-Routen laufen über<br />  <code>userRoute</code>/<code>ownedResourceRoute</code> — eine unauthentifizierte Anfrage wird mit 401 abgewiesen (der<br />  frühere no-auth-IDOR ist geschlossen).<br />- Teilen funktioniert nur innerhalb des eigenen Besitzes: man kann ausschließlich eine <strong>eigene</strong> Memory<br />  in eine <strong>eigene</strong> Gruppe teilen.</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>Memory-Groups sind pro Nutzer; ein nutzerübergreifendes Teilen existiert bewusst nicht.</li></ul>",
          "byteSize": 5199
        }
      }
    },
    {
      "id": "meshcast",
      "uuid": "fedec288-4834-45d2-a96f-891068e41021",
      "name": "Meshcast",
      "version": "0.1.0",
      "description": "Erzeugt eine Formdatei zu einem Modell, indem es die Meshcast-Weboberflaeche fernsteuert — Zweiteilform mit Trennebene, Passstiften und Angusskanal, adaptive und wiederverwendbare Silikonform, Gipsform fuer Schlickerguss, Press-Form fuer Ton und Seife, Vasenform, Eis- und Schokoladenschale, dazu Makerwerkzeuge (Ausstecher, Lithophanie, Texturrolle, Verpackungseinlage). Eingang STL, OBJ oder 3MF, Ausgang STL oder 3MF. Das Addon liefert die Formdatei und sonst nichts: kein Preis, kein Schnitt, kein Auftrag. Es ist der Sonderfall und der Vergleichsmassstab neben der eigenen Formgeometrie (Vorhaben addons#1243), die ohne Handgriff durch die Kette laeuft. Die Fernsteuerung bricht bei jeder Aenderung der fremden Oberflaeche — und zwar laut: jeder Schritt prueft, ob er sein Bedienelement wirklich gefunden hat, und bricht mit benannter Ursache ab, statt weiterzuklicken. Die heruntergeladene Datei gilt erst als Ergebnis, wenn sie als STL oder 3MF lesbar ist und Volumen und Bounding-Box plausibel zur Eingabe passen; eine Fehlerseite mit der Endung .stl ist der Normalfall des Scheiterns und wird als solcher erkannt. Neun der 22 gemessenen Generatoren rechnen serverseitig — bei ihnen verlaesst das Kundenmodell das Geraet, und das steht vor dem Lauf in der Oberflaeche. Zeitgrenze und Wiederholungszahl sind Einstellungen, keine Konstanten; wiederholt wird ausschliesslich vor dem ausloesenden Griff. Kein vorbelegter Wirt: ohne Basis-URL antwortet jede Route mit einem sprechenden Fehler und es startet kein Browser. Die Lizenzstufe wird angezeigt und mitgezaehlt, sperrt aber nichts — die Lizenzverantwortung liegt beim Betreiber. Drei Trust-Stufen: generators.read und status.read (standard) < form.create (highest).",
      "author": "Aiopta",
      "icon": "🧊",
      "capabilities": [
        "3d-printing",
        "mold-making",
        "mesh-processing",
        "browser-automation"
      ],
      "tags": [],
      "download": "/artifacts/meshcast-0.1.0.zip",
      "sha256": "b7d923a3a804d27094af858ff9612e4b6dc5161da206036e94906d1976ea4623",
      "size": 344246,
      "page": {
        "id": "meshcast",
        "path": "/addons/meshcast",
        "name": "Meshcast",
        "icon": "🧊",
        "section": "Tools",
        "component": "@addons/meshcast/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/meshcast/generatoren",
        "/api/v1/addons/meshcast/status",
        "/api/v1/addons/meshcast/form"
      ],
      "skills": [
        {
          "name": "meshcast-generatoren-lesen",
          "displayName": "Meshcast: Generatoren und Rechenort ansehen",
          "description": "Listet die gemessenen Generatoren mit Namen, Gruppe und Rechenort — also mit der Antwort auf die Frage, ob das Kundenmodell bei diesem Generator das Geraet verlaesst. Reiner Lesezugriff, ohne Netzruf.",
          "permissions": [
            "addons/meshcast/generators/read"
          ],
          "endpoint": "/api/v1/addons/meshcast/generatoren",
          "scripts": []
        },
        {
          "name": "meshcast-status-lesen",
          "displayName": "Meshcast: Zustand, Lizenzstufe und Wochenzaehler lesen",
          "description": "Liest Einrichtungszustand, hinterlegte Lizenzstufe und die Zahl der Laeufe im rollenden Sieben-Tage-Fenster. Eine Auskunft, keine Schranke — das Addon sperrt nichts.",
          "permissions": [
            "addons/meshcast/status/read"
          ],
          "endpoint": "/api/v1/addons/meshcast/status",
          "scripts": []
        },
        {
          "name": "meshcast-form-erzeugen",
          "displayName": "Meshcast: Formdatei erzeugen lassen",
          "description": "Steuert die Oberflaeche fern und liefert eine geprueft brauchbare Formdatei. Eigene, hoechste Trust-Stufe: der Lauf sendet das Kundenmodell unter Umstaenden an einen Dritten und verbraucht ein Kontingent. Kein Wiederholversuch nach dem Ausloesen.",
          "permissions": [
            "addons/meshcast/generators/read",
            "addons/meshcast/form/create"
          ],
          "endpoint": "/api/v1/addons/meshcast/form",
          "scripts": []
        }
      ],
      "mcpServers": [
        {
          "id": "meshcast-tools",
          "displayName": "Meshcast tools",
          "description": "Read-only Werkzeuge fuer Agents: den gemessenen Generatorkatalog samt Rechenort lesen und den Einrichtungs-, Lizenz- und Zaehlerstand abfragen. Der formerzeugende Pfad ist bewusst NICHT als Werkzeug exponiert — ein Lauf startet einen Browser, sendet bei neun von 22 Generatoren das Kundenmodell an einen Dritten und verbraucht bei der freien Stufe einen von fuenf Wochendownloads; ein Agent, der bei unklarer Antwort noch einmal ruft, verbraucht den naechsten. Er laeuft ausschliesslich ueber die API-Route mit eigener, hoechster Berechtigung. Dieser Server geht ueberhaupt nicht ans Netz: beide Werkzeuge beantworten sich aus der mitgelieferten Messung und den injizierten Slots. Authentifiziert ueber die Slots basisUrl/lizenzstufe/anmeldename/anmeldewort, nie als Werkzeug-Argument; Name und Kennwort erscheinen in keinem Ergebnis.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "meshcast-tools/server.mjs",
          "permissions": [
            "addons/meshcast/generators/read",
            "addons/meshcast/status/read"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [
            "basisUrl",
            "lizenzstufe"
          ],
          "credentialRefs": [
            "anmeldename",
            "anmeldewort"
          ],
          "archiveEntry": "meshcast/mcp/servers/meshcast-tools/server.mjs",
          "sha256": "423fecf8cb603093b7c22a3c38068e256b0b6c9a5739948846e1c4df9aeb3e73",
          "size": 14483,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>meshcast</h1>\n<p>Erzeugt eine <strong>Formdatei zu einem Modell</strong>, indem es die Meshcast-Weboberfläche<br />fernsteuert — und prüft das Ergebnis, bevor es als Form gilt.</p>\n<p>Mehr macht dieses Addon nicht. Kein Preis, kein Schnitt, kein Auftrag: die Kette<br />(Analyse → Preis → Schnitt → Druck) liegt im Slice-Dienst und im Addon<br /><code>layery-druckzentrale</code>.</p>\n<blockquote><p><strong>Ticket:</strong> <a href=\"https://git.langkau.info/Aiopta/aiopta-addons/issues/1259\" rel=\"noopener noreferrer\">addons#1259</a></p><p><strong>Nachbarvorhaben:</strong> <a href=\"https://git.langkau.info/Aiopta/aiopta-addons/issues/1243\" rel=\"noopener noreferrer\">addons#1243</a> — die <em>eigene</em> Formgeometrie im Slice-Dienst</p></blockquote>\n<hr />\n<h2>Warum es dieses Addon überhaupt gibt</h2>\n<p>Der Betreiber hat am 11.08.2026 entschieden, <strong>beides</strong> zu haben:</p>\n<p>* Die <strong>eigene Geometrie</strong> (#1244–#1249) läuft ohne Handgriff durch die Kette.<br />  Sie ist der Regelweg.<br />* <strong>Meshcast ist der Sonderfall und der Vergleichsmaßstab.</strong> Es ist ein fremdes<br />  Werkzeug, das ein Mensch im Browser bedient; es kann nicht in einer Strecke<br />  stehen, die vom Bestelleingang bis zum Angebot ohne Zutun läuft. Was es kann,<br />  ist die Messlatte, an der sich die eigene Form messen lassen muss.</p>\n<p>Von vier vorgelegten Wegen hat der Betreiber <strong>„Browser fernsteuern&quot;</strong> gewählt,<br />ausdrücklich im Wissen darum, dass das bei jeder Änderung der fremden Seite<br />bricht. Auflage: <strong>der Bruch muss laut sein.</strong> Wie das gebaut ist, steht unter<br />Wenn es bricht.</p>\n<hr />\n<h2>Was an <a href=\"https://meshcast.app/\" rel=\"noopener noreferrer\">https://meshcast.app/</a> gemessen wurde (11.08.2026)</h2>\n<p>Alles Folgende ist an der Seite selbst nachgemessen, nicht aus einer<br />Beschreibung übernommen.</p>\n<h3>Ein- und Ausgang</h3>\n<p>Eingang <strong>STL, OBJ oder 3MF</strong> (bestätigt über die dynamisch geladenen Bündel<br /><code>STLLoader</code>, <code>OBJLoader</code>, <code>3MFLoader</code>) oder eine mitgelieferte Grundform;<br />Ausgang „watertight, print-ready STL&quot; bzw. 3MF. Das Netz muss **wasserdicht<br />(manifold)** sein, sonst kann der Generator nicht sauber abziehen.</p>\n<h3>„Läuft komplett im Browser&quot; stimmt <strong>nicht</strong></h3>\n<p>Die Datenschutzerklärung der Seite sagt es selbst, wörtlich:</p>\n<blockquote><p>„Most generators run entirely in your browser via WebAssembly, so your file</p><p>never leaves your device at all.&quot;</p></blockquote>\n<blockquote><p>„A few of the more advanced mold generators need heavier computation and send</p><p>your model&#39;s geometry to our generation server purely to build the mold.&quot;</p></blockquote>\n<p><strong>Welche „few&quot; das sind, sagt die Seite nicht.</strong> Für einen Druckdienst, der<br />fremde Kundenmodelle verarbeitet, ist das keine Fußnote, sondern eine<br />Datenschutzfrage. Deshalb wurde es gemessen: für jede der 28 Werkzeugseiten<br />wurde der Bündelgraph geladen und geprüft, ob er den Aufruf <code>run-pipeline</code><br />enthält — den einzigen Weg, auf dem die Oberfläche Geometrie an den Rechenserver<br />gibt.</p>\n<p><strong>Neun Generatoren rechnen serverseitig</strong> (das Kundenmodell verlässt das Gerät):</p>\n<p>| Pfad | Generator | Pipeline |<br />| --- | --- | --- |<br />| <code>/adaptive-silicone</code> | Adaptive Silikonform | <code>adaptive-silicone</code> |<br />| <code>/candle-mold</code> | Kerzenform (Zweiteilform) | <code>boxmold</code> |<br />| <code>/photo-candle-mold</code> | Fotokerzenform | <code>boxmold</code> |<br />| <code>/silicone-mold</code> | Wiederverwendbare Silikonform | — |<br />| <code>/plaster-mold</code> | Gipsform für Schlickerguss | — |<br />| <code>/press-mold</code> | Press-Form für Ton und Seife | <code>bathbomb</code> |<br />| <code>/vase-mold</code> | Vasen- und Pflanzgefäßform | — |<br />| <code>/texture-roller</code> | Texturrolle | <code>texture-roller-handle</code> |<br />| <code>/packaging-insert</code> | Verpackungseinlage | <code>shipping-cradle</code> |</p>\n<p><strong>Alle übrigen rechnen im Browser</strong> — darunter ausdrücklich die Hauptform<br /><code>/mold</code> (Zweiteilform mit Trennebene, Passstiften und Angusskanal): ihr Bündel<br /><code>main-*.js</code> enthält kein <code>run-pipeline</code>. Ebenso <code>/cutter</code>, <code>/lithophane</code>,<br /><code>/nameplate</code>, <code>/embosser</code>, <code>/keychain</code>, <code>/golf-ball-stencil</code>, <code>/planter</code>,<br /><code>/pottery-mold</code>, <code>/carbon-fiber-mold</code>, <code>/ice-cube-tray-mold</code>,<br /><code>/print-splitter</code>, <code>/svg-to-stl</code>.</p>\n<p>Der Katalog steht in <code>generatoren.json</code> und ist die <strong>eine</strong> Quelle für die<br />Addon-Laufzeit <em>und</em> den MCP-Server. Die Oberfläche zeigt den Rechenort <strong>vor</strong><br />dem Lauf; ein Generator außerhalb des Katalogs gilt vorsorglich als<br />serverseitig und lässt sich gar nicht erst starten.</p>\n<h3>Die Lizenz</h3>\n<p>Gemessen auf der Preisseite, wörtlich:</p>\n<p>| Stufe | Preis | Umfang |<br />| --- | --- | --- |<br />| <strong>Free</strong> | 0 | „5 free STL downloads a week, across every tool, with a free account&quot; + „3 bonus downloads&quot; für die Anmeldung · <strong>„Personal use only - not for resale&quot;</strong> · „Small &#39;Meshcast&#39; engraving on each STL&quot; |<br />| <strong>Pro</strong> | „$20 / mo&quot; | „Sell everything you make as many copies as you like&quot;, unbegrenzt, eigenes Logo |<br />| <strong>Founder</strong> | „$349 / once&quot; | „Everything in Pro for life&quot; |</p>\n<p><strong>Ein Druckdienst ist gewerblich.</strong></p>\n<h3>Es gibt eine undokumentierte API — sie wird bewusst <strong>nicht</strong> benutzt</h3>\n<p>Die Seite bewirbt keine API. Gesucht wurde trotzdem, und gefunden:</p>\n<p>| Aufruf | Befund |<br />| --- | --- |<br />| <code>GET https://api.meshcast.app/queue-status</code> | 200, öffentlich, JSON (<code>max_concurrent</code>, <code>running</code>, <code>waiting_priority</code>, <code>waiting_normal</code>) |<br />| <code>GET https://api.meshcast.app/health</code> | 200, öffentlich: <code>{&quot;ok&quot;:true,&quot;checks&quot;:{&quot;run_build123d_multi.py&quot;:true,&quot;run_manifold_multi.mjs&quot;:true,&quot;pipelines&quot;:true}}</code> |<br />| <code>POST https://api.meshcast.app/run-pipeline/&lt;slug&gt;</code> | die Erzeugungsschnittstelle; Rumpf <code>{ params, upload_stl_base64 }</code>, Kopf <code>X-User-Plan: free\\|paid</code> |<br />| <code>GET /openapi.json</code>, <code>GET /docs</code> | 404 (<code>{&quot;detail&quot;:&quot;Not Found&quot;}</code> — FastAPI mit abgeschalteter Doku) |</p>\n<p>Sie wäre der bequemere Weg. Zwei Gründe sprechen dagegen, und der zweite ist der<br />ernstere:</p>\n<p>1. <strong>Sie ist undokumentiert und ohne Zusage.</strong> Jede Änderung bricht sie ohne<br />   Ankündigung, und anders als bei der Oberfläche gäbe es keine Fehlerseite, an<br />   der man es merkt.<br />2. <strong>Der Downloadzähler und die Lizenzstufe sitzen im Browser</strong><br />   (<code>downloadGate-*.js</code>, <code>quotaWindow-*.js</code>). An der API vorbei zu rechnen hieße,<br />   die Lizenzbedingung des Anbieters zu umgehen — unabhängig davon, welche Stufe<br />   der Betreiber gebucht hat.</p>\n<p>Der Befund steht hier, damit ihn niemand ein zweites Mal suchen muss.</p>\n<hr />\n<h2>Einrichtung</h2>\n<p>Alles über die Addon-Einstellungen. <strong>Kein Feld hat einen Vorgabewert.</strong></p>\n<p>| Feld | Pflicht | Bedeutung |<br />| --- | --- | --- |<br />| <code>basisUrl</code> | ja | Wurzel der fernzusteuernden Oberfläche, ohne Pfad |<br />| <code>zeitgrenzeSekunden</code> | ja | 10–1800; wie lange ein Schritt höchstens wartet |<br />| <code>wiederholungen</code> | ja | 0–5; Wiederholungen <strong>vor</strong> dem auslösenden Griff |<br />| <code>lizenzstufe</code> | nein | <code>frei</code>, <code>pro</code> oder <code>founder</code> — reine Anzeige |<br />| <code>anmeldename</code> | nein | Konto beim Anbieter (Secret) |<br />| <code>anmeldewort</code> | nein | Kennwort (Secret) |</p>\n<p><strong>Warum auch die Basis-URL Pflicht ist</strong>, obwohl der Wirt hier kein eigener<br />Dienst ist: ein vorbelegter Wirt hieße, dass eine fremde Aiopta-Installation ein<br />Kundenmodell an einen Dritten schickt, den ihr Betreiber nie eingetragen hat.<br />Wer fernsteuert, trägt ein, wen er fernsteuert. Ist das Feld leer, antwortet<br />jede Route mit <code>503 meshcast_not_configured</code> — <strong>bevor</strong> ein Browser startet.</p>\n<p>Ein Erfahrungswert für <code>zeitgrenzeSekunden</code> steht bewusst nicht im<br /><code>settingsSchema</code> (er wäre eine Vorgabe): die neun serverseitigen Generatoren<br />rechnen spürbar länger als die im Browser und stehen zusätzlich in einer<br />Warteschlange (<code>/queue-status</code>), die browserseitigen sind meist in Sekunden<br />fertig.</p>\n<h3>Browser-Laufzeit</h3>\n<p>Dieses Addon bringt <strong>keinen eigenen Browser</strong> mit. Es hängt hart an<br /><code>browser &gt;= 1.4.0</code>, das Puppeteer und Chromium einmal containerweit<br />bereitstellt. Fehlt die Laufzeit, sagt das Addon das mit Verweis auf<br /><code>POST /api/v1/addons/browser/install-runtime</code> — es installiert nichts nach.</p>\n<h3>Anmeldung</h3>\n<p>Der Zugang ist <strong>hinterlegbar</strong> und wird als Secret behandelt (er erscheint in<br />keiner Antwort; die Oberfläche zeigt nur, <em>ob</em> er hinterlegt ist). Die Anmeldung<br />selbst wird <strong>nicht</strong> automatisch durchgeklickt: sie läuft gemessen über einen<br />fremden Anmeldedienst (Supabase) mit Magic-Link bzw. OAuth, und so etwas blind<br />nachzuklicken hieße raten. Stattdessen läuft der Browser auf einem **bleibenden<br />Profilverzeichnis** unter <code>DATA_DIR/meshcast/profil</code> — einmal von Hand anmelden,<br />die Sitzung überlebt. Erkennt der Ablauf einen abgemeldeten Zustand, bricht er<br />mit <code>409 meshcast_not_signed_in</code> ab, statt eine Anmeldeseite als Form abzulegen.</p>\n<hr />\n<h2>Die Lizenz: sichtbar, aber keine Schranke</h2>\n<p>Betreiber, 11.08.2026:</p>\n<blockquote><p>„Free zum Ausprobieren, aber nicht bei gewerblichen Aufträgen weigern. Der</p><p>User ist verantwortlich für die Lizenz (Login muss natürlich angegeben werden</p><p>können). Ich entscheide mich später für eine Lizenz.&quot;</p></blockquote>\n<p>Also: <strong>das Addon verweigert nichts</strong> — egal welche Stufe hinterlegt ist oder ob<br />gar keine hinterlegt ist. Was es tut, ist den Zustand zeigen:</p>\n<p>* die konfigurierte Stufe (und ausdrücklich „nicht hinterlegt&quot;, wenn keine da<br />  ist — das ist <strong>nicht</strong> dasselbe wie „frei&quot;),<br />* die Läufe im rollenden Sieben-Tage-Fenster,<br />* und den gemessenen Satz der Gegenseite <strong>wörtlich in der Oberfläche</strong>:<br />  <code>Personal use only - not for resale</code>.</p>\n<p>Der Zähler zählt nur, was <strong>dieses Addon</strong> ausgelöst hat. Was der Betreiber<br />daneben von Hand im Browser herunterlädt, zählt die Gegenseite mit, dieser Stand<br />nicht — er ist eine Untergrenze, kein Kontostand. Das steht so auch in der<br />Oberfläche.</p>\n<p>Bricht Meshcast selbst wegen erschöpftem Kontingent ab, ist das ein **benannter<br />Fehler mit dem echten Grund** (<code>409 meshcast_quota_exhausted</code>, „Wochenkontingent<br />erschöpft&quot;). Bei der freien Stufe tritt das nach fünf Formen ein — der Fall ist<br />als Normalfall behandelt, nicht als Störung.</p>\n<hr />\n<h2>Wenn es bricht</h2>\n<p>Jeder Schritt der Fernsteuerung besteht aus drei Teilen, und der dritte ist der,<br />den man weglässt, wenn man es eilig hat: **die Prüfung, dass der erste getroffen<br />hat.** Es gibt in <code>fernsteuerung.ts</code> keinen Zweig, der weiterklickt, weil ein<br />Element nicht da war.</p>\n<p>| Schritt | Ursache bei Fehlschlag |<br />| --- | --- |<br />| Seite öffnen | „Oberfläche nicht erreichbar&quot; |<br />| Generatorauswahl | „Generatorauswahl nicht gefunden&quot; |<br />| Modell hochladen | „Hochladefeld nicht gefunden&quot; |<br />| Erzeugen auslösen | „Erzeugen-Schaltfläche nicht gefunden&quot; |<br />| Herunterladen auslösen | „Herunterladen-Schaltfläche nicht gefunden&quot; |<br />| Download abwarten | „Kein Download innerhalb der Zeitgrenze&quot; |</p>\n<p>Die Antwort trägt Schritt <strong>und</strong> Ursache (<code>502 meshcast_step_failed</code>). Genau<br />daran erkennt der Betreiber, ob die Gegenseite ihre Oberfläche geändert hat.</p>\n<p><strong>Alles, was brechen kann, liegt an einer Stelle:</strong> die Auswahlketten in<br /><code>generatoren.json</code>. Wie brüchig das ist, zeigt die Messung selbst — die<br />Erzeugen-Schaltfläche heißt auf der Hauptform <code>#generateBtn</code>, auf der adaptiven<br />Silikonform <code>#generate-btn</code>, auf der Verpackungseinlage <code>#sc-generate</code> und beim<br />Pflanzgefäß <code>#planter-generate-btn</code>. Es gibt keine Systematik, nur die Messung.</p>\n<h3>Wiederholt wird nur <strong>vor</strong> dem Auslösen</h3>\n<p>Eine Wiederholung, die es bis zum auslösenden Griff schafft, verbraucht bei der<br />freien Stufe einen der fünf Wochendownloads — auch dann, wenn der erste Versuch<br />erfolgreich war und nur die Antwort verloren ging. Deshalb: ab dem auslösenden<br />Griff genau <strong>ein</strong> Versuch. Bleibt der Ausgang unklar, endet der Lauf mit<br /><code>409 meshcast_outcome_unclear</code> samt Zeitpunkt als Ankerwert für die Nachschau im<br />Konto der Gegenseite — er entscheidet nicht und startet nicht nach.</p>\n<hr />\n<h2>Die Prüfung der Datei</h2>\n<p>Eine heruntergeladene Datei ist erst ein Ergebnis, wenn sie sich als Netz lesen<br />lässt <strong>und</strong> sich gegenüber der Eingabe plausibel verhält. Der Normalfall des<br />Scheiterns ist nicht die leere Datei, sondern die **Fehlerseite mit der Endung<br /><code>.stl</code>**: sie existiert, sie hat eine plausible Größe, und wer nur auf Existenz<br />prüft, liefert sie als Form aus.</p>\n<p>| Befund | Was passiert ist |<br />| --- | --- |<br />| <code>form_da</code> | lesbares Netz, umschließt die Eingabe |<br />| <code>datei_fehlt</code> / <code>datei_leer</code> | kein Download bzw. 0 Byte |<br />| <code>html_statt_netz</code> | Fehler- oder Anmeldeseite |<br />| <code>format_unbekannt</code> / <code>netz_unlesbar</code> | u. a. abgeschnittener Download (die STL-Längenformel <code>84 + 50·n</code> fängt ihn) |<br />| <code>keine_dreiecke</code> / <code>volumen_null</code> | lesbar, aber ohne Geometrie |<br />| <code>gleich_der_eingabe</code> | die Eingabe kam Byte für Byte zurück |<br />| <code>kleiner_als_eingabe</code> | keine Form, sondern das Modell oder ein Ausschnitt |<br />| <code>unverhaeltnismaessig_gross</code> | Maßstabsfehler |</p>\n<p>Gemessen werden Dreieckszahl, Volumen (vorzeichenbehaftete Tetraedersumme) und<br />Bounding-Box, für STL (binär und ASCII), OBJ und 3MF (ZIP + <code>3D/3dmodel.model</code>).<br />Eine Datei, die nicht besteht, wird <strong>nicht</strong> mitgeliefert — auch nicht „mit<br />Warnung&quot;: eine Form mit Warnung sieht in einer Auftragsliste aus wie eine Form.<br />Sie kommt als <code>422 meshcast_result_implausible</code> mit dem vollständigen Befund.</p>\n<p>Lag <strong>keine</strong> messbare Eingabe vor (mitgelieferte Grundform), steht<br /><code>ohneEingabevergleich: true</code> im Ergebnis. Das ist kein Freispruch, sondern eine<br />fehlende Messung — dieselbe Unterscheidung, die #1243 als <code>ohne_abformdaten</code><br />führt.</p>\n<p><strong>Nicht geprüft wird Wasserdichtigkeit.</strong> Das wäre eine zweite<br />Geometriewahrheit neben <code>app/meshinfo.py</code> im Slice-Dienst.</p>\n<hr />\n<h2>Dieselbe Eingabe durch beide Wege — der Vergleichsmaßstab</h2>\n<p>Das ist der Zweck aus #1243/#1245: **derselbe Eingang, zwei Wege, der<br />Unterschied wird benannt.**</p>\n<pre><code class=\"lang-bash\">MODELL=ente.stl\nB64=$(base64 -w0 &quot;$MODELL&quot;)</code></pre>\n<p># Weg A — Meshcast (dieses Addon)<br />curl -sS -X POST <a href=\"https://&amp;lt;aiopta&amp;gt;/api/v1/addons/meshcast/form\" rel=\"noopener noreferrer\">https://&amp;lt;aiopta&amp;gt;/api/v1/addons/meshcast/form</a> \\<br />  -H &#39;content-type: application/json&#39; -H &quot;X-API-Key: $AIOPTA_KEY&quot; \\<br />  -d &quot;{\\&quot;generator\\&quot;:\\&quot;mold\\&quot;,\\&quot;dateiname\\&quot;:\\&quot;$MODELL\\&quot;,\\&quot;inhaltBase64\\&quot;:\\&quot;$B64\\&quot;}&quot; \\<br />  &gt; meshcast.json</p>\n<p># Weg B — eigene Formgeometrie im Slice-Dienst (Vorhaben #1243, Betriebsart<br /># &quot;negativform&quot;). Über die Druckzentrale, nicht über dieses Addon:<br />curl -sS -X POST <a href=\"https://&amp;lt;aiopta&amp;gt;/api/v1/addons/layery-druckzentrale/jobs/&amp;lt;id&amp;gt;/reslice\" rel=\"noopener noreferrer\">https://&amp;lt;aiopta&amp;gt;/api/v1/addons/layery-druckzentrale/jobs/&amp;lt;id&amp;gt;/reslice</a> \\<br />  -H &#39;content-type: application/json&#39; -H &quot;X-API-Key: $AIOPTA_KEY&quot; \\<br />  -d &#39;{&quot;anweisung&quot;:&quot;negativform&quot;}&#39; \\<br />  &gt; eigen.json<br />```</p>\n<p>Verglichen werden die Größen, die <strong>beide</strong> Wege liefern und die dieses Addon<br />ohnehin misst:</p>\n<p>| Größe | Wo sie in <code>meshcast.json</code> steht |<br />| --- | --- |<br />| Volumen der Form | <code>data.pruefung.mass.volumenMm3</code> |<br />| Bounding-Box | <code>data.pruefung.mass.groesse</code> |<br />| Verhältnis Form zu Modell, je Achse | <code>data.pruefung.vergleich.faktor</code> |<br />| Dreieckszahl | <code>data.pruefung.mass.dreieckszahl</code> |<br />| Ausgabeformat | <code>data.pruefung.format</code> |</p>\n<p>Der Faktor ist die aussagekräftigste Zahl: er sagt, wie viel Material die Form<br />gegenüber dem Urmodell kostet — und genau das entscheidet später den Preis.</p>\n<p><strong>Zwei Dinge, die beim Vergleichen zu beachten sind:</strong></p>\n<p>1. <strong>Bei der freien Stufe sind fünf Vergleiche je Woche möglich.</strong> Der Zähler<br />   unter <code>/api/v1/addons/meshcast/status</code> sagt, wie viele davon schon gelaufen<br />   sind.<br />2. **Bei den neun serverseitigen Generatoren verlässt das Vergleichsmodell das<br />   Gerät.** Für einen Vergleichslauf mit einem echten Kundenmodell ist das eine<br />   Datenschutzentscheidung. <code>/mold</code> rechnet browserseitig und ist deshalb der<br />   unproblematischere Vergleichspartner zur eigenen Kastenform.</p>\n<p>Begriffe werden aus #1243 übernommen und nicht neu erfunden: <strong>Negativform</strong>,<br /><strong>Urmodell</strong>, <strong>Trennebene</strong>, <strong>Passstifte</strong>, <strong>Angusskanal</strong>.</p>\n<hr />\n<h2>API</h2>\n<p>| Methode | Pfad | Permission | Trust |<br />| --- | --- | --- | --- |<br />| GET | <code>/api/v1/addons/meshcast/generatoren</code> | <code>addons/meshcast/generators/read</code> | standard |<br />| GET | <code>/api/v1/addons/meshcast/status</code> | <code>addons/meshcast/status/read</code> | standard |<br />| POST | <code>/api/v1/addons/meshcast/form</code> | <code>addons/meshcast/form/create</code> | <strong>highest</strong> |</p>\n<p><code>form.create</code> ist <code>highest</code>, und jeder der drei Gründe reicht für sich: der Lauf<br />startet einen Browser, er sendet bei neun von 22 Generatoren das Kundenmodell an<br />einen fremden Rechenserver, und er verbraucht bei der freien Stufe einen von<br />fünf Wochendownloads. Der Pfad liegt auf POST, damit er im Sandbox-Betrieb<br />(ADR-062 <code>restricted</code>) abgewiesen wird — ein GET, das all das tut, wäre dort<br />unsichtbar erlaubt.</p>\n<h3>MCP-Server <code>meshcast-tools</code></h3>\n<p>Read-only, zwei Werkzeuge: <code>generator_list</code> (Katalog samt Rechenort) und<br /><code>status_get</code> (Einrichtung, Lizenz, Wochenzähler). **Er geht überhaupt nicht ans<br />Netz** — beide Werkzeuge beantworten sich aus der mitgelieferten Messung und den<br />injizierten Slots.</p>\n<p>Ein formerzeugendes Werkzeug gibt es bewusst <strong>nicht</strong>: ein Agent, der bei<br />unklarer Antwort noch einmal ruft, verbraucht den nächsten Wochendownload.</p>\n<hr />\n<h2>Was bewusst weggelassen wurde</h2>\n<p>* <strong>Die undokumentierte API</strong> (Begründung oben).<br />* <strong>Automatisches Anmelden.</strong> Der Anmeldeweg der Gegenseite läuft über einen<br />  fremden Dienst mit Magic-Link/OAuth; ihn blind nachzuklicken wäre Raten — und<br />  Raten ist in diesem Addon die eine Sache, die nicht vorkommen darf.<br />* <strong>Ein Konto anlegen oder eine Lizenz kaufen.</strong><br />* <strong>Parameter der Generatoren</strong> (Trennebenenwinkel, Passstiftmaß, Anguss-<br />  durchmesser, Wandstärke …). Der Lauf benutzt die Voreinstellungen der Seite.<br />  Jeder Regler wäre eine weitere Auswahlkette, die bei der nächsten Änderung<br />  bricht — und die eigenen Regler gehören laut #1243 in <code>betrieb.REGLER</code> des<br />  Slice-Dienstes, nicht hierher.<br />* <strong>Das Urteil über Wasserdichtigkeit.</strong> Meshcast verlangt ein manifoldes<br />  Eingangsnetz und sagt von seiner Ausgabe, sie sei wasserdicht. Beides hier<br />  nachzuprüfen hieße, eine zweite Geometriewahrheit neben <code>app/meshinfo.py</code> im<br />  Slice-Dienst zu führen — genau die Fehlerklasse, die #1243 benennt. Gemessen<br />  wird nur, was sich aus den Dreiecken allein ergibt.<br />* <strong>Attestierung.</strong> <code>attestation.json</code> wird ausschließlich in<br />  <code>ci/Jenkinsfile.addonsign</code> signiert; der Schlüssel liegt weder im Repo noch<br />  bei einem Entwickler. Bis dahin ein ticketgebundener Eintrag in<br />  <code>BEKANNTE_LUECKEN</code> (addons#1259).</p>\n<hr />\n<h2>Schnitt zum Rest</h2>\n<pre><code>Kundenmodell ──┬─► meshcast (dieses Addon) ─────────────► Formdatei   [Sonderfall, Vergleichsmaßstab]\n               │\n               └─► Slice-Dienst: Abformanalyse ─► Formwächter ─► Negativform / Urmodell\n                                                                  │\n                                                                  ▼\n                                          layery-druckzentrale ─► Preis ─► Schnitt ─► Druck</code></pre>\n<p>Dieses Addon kennt keinen Auftrag, keinen Preis und keine Platte. Es liefert<br />eine Datei und ein Urteil darüber, ob sie eine ist.</p>",
          "byteSize": 18318
        }
      }
    },
    {
      "id": "meshy",
      "uuid": "816fc58c-d034-4337-b9a4-dc8767ce54ff",
      "name": "Meshy 3D-Erzeugung",
      "version": "0.1.0",
      "description": "Aus einem Satz oder einem Foto ein 3D-Modell erzeugen und als STL oder 3MF herausgeben — fuer den Kunden, der gar kein Modell hat. Angebunden an die Meshy-REST-API (api.meshy.ai, Praefix /openapi) mit asynchronen Auftraegen: Text zu 3D, Bild zu 3D, Druckbarkeitspruefung und Netzreparatur. Erzeugend und nachbearbeitend, NICHT parametrisch: Negativform, Boolean-Subtraktion und CSG kann Meshy nicht, und dieses Addon tut auch nicht so. Die Pruefung misst die Netztopologie (wasserdicht, Volumen, non-manifold-Kanten, degenerierte Flaechen, Loecher) und NICHT Wandstaerke, Ueberhaenge oder Massstab — ein repariertes Netz ist deshalb kein druckbares Teil, das entscheidet erst der Schnitt. Kosten sind sichtbar (verbrauchte Credits je Auftrag, Guthaben) und zwei optionale Tagesgrenzen greifen VOR dem Netzruf; ist keine gesetzt, sagt die Antwort ausdruecklich, dass nichts begrenzt ist. Ein fehlgeschlagener Auftrag sieht nie aus wie ein leeres Ergebnis. Kein vorbelegter Schluessel, kein vorbelegtes Modell, keine vorbelegte Polygonzahl. Zwei Trust-Stufen: Lesen und Pruefen (elevated) < Erzeugen und Reparieren (highest, weil sie Geld kosten).",
      "author": "Aiopta",
      "icon": "🗿",
      "capabilities": [
        "3d-generation",
        "text-to-3d",
        "image-to-3d",
        "3d-printing",
        "mesh-quality"
      ],
      "tags": [],
      "download": "/artifacts/meshy-0.1.0.zip",
      "sha256": "0ce123e4cb331db06317203df60c5271dc520bb0dae4dd48dbe86bd2e4913aa0",
      "size": 266865,
      "page": {
        "id": "meshy",
        "path": "/addons/meshy",
        "name": "Meshy 3D-Erzeugung",
        "icon": "🗿",
        "section": "Tools",
        "component": "@addons/meshy/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/meshy/auftraege",
        "/api/v1/addons/meshy/auftraege/{id}",
        "/api/v1/addons/meshy/auftraege/{id}/druckpruefung",
        "/api/v1/addons/meshy/auftraege/{id}/reparatur",
        "/api/v1/addons/meshy/verbrauch"
      ],
      "skills": [
        {
          "name": "meshy-auftraege-lesen",
          "displayName": "Meshy: Auftraege und Verbrauch ansehen",
          "description": "Listet die eigenen Meshy-Auftraege mit Gattung, Zustand, Fortschritt, verbrauchten Credits und Ablauffrist, liest einen einzelnen Auftrag samt Modell-Adressen und zeigt Guthaben, Tagesverbrauch und den Stand der Obergrenzen. Reiner Lesezugriff, gibt kein Geld aus.",
          "permissions": [
            "addons/meshy/auftraege/lesen"
          ],
          "endpoint": "/api/v1/addons/meshy/auftraege",
          "scripts": []
        },
        {
          "name": "meshy-auftrag-erzeugen",
          "displayName": "Meshy: Modell aus Text oder Bild erzeugen",
          "description": "Legt genau einen Erzeugungsauftrag an, aus einem Satz oder aus einem Bild, und fordert die Formate stl und 3mf mit an. Kostet Credits. Eigene, hoechste Trust-Stufe und die Tagesgrenzen greifen vor dem Netzruf.",
          "permissions": [
            "addons/meshy/auftraege/lesen",
            "addons/meshy/auftraege/erzeugen"
          ],
          "endpoint": "/api/v1/addons/meshy/auftraege",
          "scripts": []
        },
        {
          "name": "meshy-druckpruefung",
          "displayName": "Meshy: Netztopologie pruefen",
          "description": "Laesst ein fertiges Netz auf Topologiefehler pruefen und zeigt Fehler und Warnungen getrennt: nicht wasserdicht, nicht-positives Volumen und non-manifold-Kanten gegen degenerierte Flaechen und Loecher. Wandstaerke, Ueberhaenge und Massstab prueft sie nicht — das entscheidet erst der Schnitt. Kostet laut Doku nichts.",
          "permissions": [
            "addons/meshy/auftraege/lesen",
            "addons/meshy/pruefung/starten"
          ],
          "endpoint": "/api/v1/addons/meshy/auftraege/{id}/druckpruefung",
          "scripts": []
        },
        {
          "name": "meshy-reparatur",
          "displayName": "Meshy: Netztopologie reparieren",
          "description": "Laesst Topologiefehler eines fertigen Netzes reparieren. Kostet 10 Credits je erfolgreicher Aufgabe und entfernt vorhandene Texturen; das Ergebnis ist ein wasserdichtes Netz, kein geprueft druckbares Teil. Eigene, hoechste Berechtigung, getrennt vom Pruefen.",
          "permissions": [
            "addons/meshy/auftraege/lesen",
            "addons/meshy/reparatur/starten"
          ],
          "endpoint": "/api/v1/addons/meshy/auftraege/{id}/reparatur",
          "scripts": []
        }
      ],
      "mcpServers": [
        {
          "id": "meshy-tools",
          "displayName": "Meshy tools",
          "description": "Read-only Werkzeuge fuer Agents: die eigenen Auftraege auflisten, einen Auftrag samt Zustand, Modell-Adressen und verbrauchten Credits lesen, Guthaben und Stand der Obergrenzen abfragen. Es gibt BEWUSST kein erzeugendes und kein reparierendes Werkzeug — beide geben Geld aus, und ein Agent, der bei unklarer Antwort noch einmal ruft, bezahlt zweimal. Auch die kostenlose Druckbarkeitspruefung ist kein Werkzeug: sie legt einen Auftrag an, und die Grenze zwischen kostenlos und kostenpflichtig ist eine Zusage des Anbieters, keine Eigenschaft des Protokolls. Alle drei Pfade laufen ausschliesslich ueber die API-Routen mit eigener Berechtigung. Authentifiziert ueber den Slot apiKey, nie als Werkzeug-Argument.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "meshy-tools/server.mjs",
          "permissions": [
            "addons/meshy/auftraege/lesen"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [
            "tagesgrenzeCredits",
            "tagesgrenzeAuftraege"
          ],
          "credentialRefs": [
            "apiKey"
          ],
          "archiveEntry": "meshy/mcp/servers/meshy-tools/server.mjs",
          "sha256": "8f68b530778c84d6ac781f7d1bde153c3d44fd72e1ef3fd07d8d1ff38fc21e89",
          "size": 18227,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>meshy — aus einem Satz oder einem Foto ein druckbares Modell</h1>\n<p>Für den Kunden, der <strong>gar kein Modell hat</strong>. Er hat einen Satz oder ein Foto.<br />Dieses Addon macht daraus ein 3D-Modell und gibt es als <strong>STL</strong> und <strong>3MF</strong><br />heraus — sichtbar, was es kostet, und ohne dass ein fehlgeschlagener Auftrag<br />wie ein leeres Ergebnis aussieht.</p>\n<p>Angebunden ist die REST-API von Meshy (<code>api.meshy.ai</code>, Präfix <code>/openapi</code>).<br />Alles in diesem Dokument ist am <strong>11.08.2026</strong> an <code>docs.meshy.ai</code> und an den<br />Nutzungsbedingungen nachgeschlagen; wo eine Aussage aus der <em>Abwesenheit</em><br />einer Doku-Stelle folgt, steht es dabei.</p>\n<hr />\n<h2>Lies zuerst das hier: die vier Sätze, die dieses Addon von einer Enttäuschung trennen</h2>\n<p>1. <strong>Meshy kann keine Form.</strong> Keine Negativform, keine Boolean-Subtraktion,<br />   kein CSG. Es ist erzeugend und nachbearbeitend, <strong>nicht parametrisch</strong>.<br />2. <strong>„Geprüft&quot; heißt nicht „druckbar&quot;.</strong> Die Druckprüfung misst Netztopologie.<br />   Wandstärke, Überhänge und Maßstab misst sie <strong>nicht</strong>.<br />3. <strong>Die Dateien verfallen nach drei Tagen.</strong> Wer sie behalten will, lädt sie<br />   vorher herunter.<br />4. <strong>Jeder Auftrag kostet Geld.</strong> Es gibt zwei Obergrenzen, beide optional —<br />   und wenn keine gesetzt ist, steht das ausdrücklich in der Oberfläche.</p>\n<hr />\n<h2>Was es <strong>nicht</strong> tut — und warum das so bleibt</h2>\n<h3>Keine Verbindung zum Addon <code>layery-druckzentrale</code></h3>\n<p>**Betreiberentscheid vom 11.08.2026: vorerst KEINE Verbindung zur<br />Druckzentrale.** Das Addon steht für sich, ist für sich bedienbar und hat<br />keine Kante dorthin:</p>\n<ul><li>keine <code>addonDependencies</code> — das Manifest hat den Schlüssel überhaupt nicht;</li><li>kein Auftragsbezug, keine Brücke, kein gemeinsamer Zustand.</li></ul>\n<p><strong>Das ist kein Versehen und keine Unfertigkeit.</strong> Wer hier eine Kante ergänzt,<br />muss zuerst den Test <code>manifest.test.ts → &quot;KEINE Abhängigkeitskante&quot;</code> löschen<br />— und liest dabei diese Begründung. Bild → 3D → STL ist ein **eigenes<br />Produkt**; es zuerst an einen Druckauftrag zu binden, hätte es zu einem<br />Zubehör gemacht, das ohne die Druckzentrale nicht installierbar ist.</p>\n<h3>Keine Form, kein Boolean, kein CSG</h3>\n<p>Die API-Referenz führt Erzeugung (Text, Bild, Mehrbild), Nachbearbeitung<br />(Retexture, Remesh, Convert, Resize, UV Unwrap), Rigging/Animation und die<br />Druckstrecke — und <strong>an keiner Stelle</strong> etwas Parametrisches. Wer eine<br />Gussform oder eine Silikonform braucht, ist bei den Formen-Tickets richtig,<br />nicht hier. Diese Erwartung liegt nahe („es macht doch 3D&quot;) und ist falsch;<br />deshalb steht sie hier und im Manifest.</p>\n<hr />\n<h2>Die vier Gefahren, und was dagegen gebaut ist</h2>\n<h3>1. Kosten je Aufruf</h3>\n<p>Der Betreiber zahlt pro Auftrag. **Eine Schleife, die unbemerkt Geld ausgibt,<br />ist der schlimmste Fehler, den dieses Addon machen kann.**</p>\n<p>Dagegen stehen <strong>zwei</strong> Obergrenzen — und dass es zwei sind, hat einen<br />gemessenen Grund:</p>\n<p>| Einstellung | zählt | Wirkung |<br />| --- | --- | --- |<br />| <code>tagesgrenzeCredits</code> | verbrauchte Credits <strong>fertiger</strong> Aufträge | die Geld-Decke, <strong>hinkt nach</strong> |<br />| <code>tagesgrenzeAuftraege</code> | <strong>angelegte</strong> kostenpflichtige Aufträge | die Schleifenbremse, <strong>greift sofort</strong> |</p>\n<p>Der Grund: <code>consumed_credits</code> steht laut Doku erst am <strong>fertigen</strong> Auftrag.<br />Ein Auftrag in <code>PENDING</code>/<code>IN_PROGRESS</code> meldet noch nichts. Eine durchgedrehte<br />Schleife kann fünfzig Aufträge abschicken, bevor der erste einen Verbrauch<br />berichtet — die Credit-Grenze hätte bis dahin brav „0 von 500&quot; angezeigt.</p>\n<p><strong>Beide sind optional und haben keinen Vorgabewert.</strong> Betreiberentscheid vom<br />11.08.2026: <em>„Definierbare Obergrenze als Setting im Addon.&quot;</em> Also:</p>\n<p><em> <strong>gesetzt → durchgesetzt:</strong> der Auftrag unterbleibt mit Begründung </em>und dem<br />  erreichten Stand* („40 von 40&quot;), bevor irgendetwas Kostenpflichtiges<br />  rausgeht;<br />* <strong>nicht gesetzt → nichts begrenzt</strong>, aber der Zustand steht als Klartext in<br />  der Oberfläche und in <code>GET …/verbrauch</code> (<code>keineGrenzeGesetzt: true</code>). Ein<br />  unbegrenztes Konto, das aussieht wie ein begrenztes, ist genau der stille<br />  Zustand, den es hier nirgends geben darf;<br />* <strong>unlesbar (Tippfehler) → Fehler</strong>, nicht „unbegrenzt&quot;. Sonst schaltete ein<br />  vertipptes <code>5oo</code> die Bremse still ab;<br />* <strong>der Zähler läuft in jedem Fall</strong> und ist ablesbar.</p>\n<p>Der Zähler wird <strong>an der Quelle gemessen</strong> — aus Meshys eigenen<br />Auftragslisten, nicht aus einer lokalen Buchhaltung. Eine zweite Buchhaltung<br />könnte auseinanderlaufen (Neustart, ein Auftrag über die Meshy-Weboberfläche,<br />ein verlorener Schreibvorgang), und eine Kostenanzeige, die von der<br />Wirklichkeit abweicht, ist schlimmer als keine.</p>\n<blockquote><p><strong>Offen gesagt, was das kostet:</strong> um die Grenze zu prüfen, muss vorher</p><p>gelesen werden. „Kein Netzruf vor der Grenze&quot; ist also <em>nicht</em> wörtlich</p><p>wahr. Wahr ist: <strong>kein kostenpflichtiger Ruf</strong>, bevor die Grenze geprüft</p><p>ist. Genau so ist es getestet (<code>route-contract.test.ts</code>: bei erreichter</p><p>Grenze geht <strong>kein einziges POST</strong> raus).</p></blockquote>\n<h3>2. Rechte am erzeugten Modell</h3>\n<p>Nachgesehen statt vermutet, in den Nutzungsbedingungen<br />(&lt;<a href=\"https://www.meshy.ai/terms-of-use&amp;gt;):\" rel=\"noopener noreferrer\">https://www.meshy.ai/terms-of-use&amp;gt;):</a></p>\n<p>| Tarif | Wem gehört die Ausgabe | Gewerbliche Nutzung |<br />| --- | --- | --- |<br />| <strong>frei</strong> | <em>„Provider owns all right, title, and interest … in and to the AI Customer Output&quot;</em> | erlaubt, aber <strong>unter CC BY 4.0</strong> — also <strong>mit Namensnennung</strong> |<br />| <strong>bezahlt</strong> | der Kunde behält das Eigentum und räumt Meshy nur eine Nutzungslizenz ein | ohne Namensnennung |</p>\n<p>Für einen Druckdienstleister ist das der Unterschied zwischen „darf ich<br />verkaufen&quot; und „darf ich verkaufen, wenn ich Meshy nenne&quot;. Der Hinweis steht<br />deshalb auch in der Oberfläche und nicht nur hier.</p>\n<p>Die Rechte hängen am Tarif <strong>zum Zeitpunkt der Erzeugung</strong>. Ein späterer<br />Tarifwechsel ändert sie für bereits erzeugte Modelle nicht.</p>\n<h3>3. Netzqualität — und die Grenze der Prüfung</h3>\n<p>Ein erzeugtes Netz ist nicht automatisch druckbar. Es gibt zwei Stufen, und<br />sie versprechen sehr Unterschiedliches:</p>\n<p><strong><code>POST …/druckpruefung</code></strong> (<code>/openapi/v1/print/analyze</code>) misst genau <strong>fünf</strong><br />Größen und <strong>kostet laut Doku nichts</strong> („Always 0. This endpoint is free.&quot;):</p>\n<p>| Metrik | Stufe |<br />| --- | --- |<br />| <code>is_watertight</code> = false | <strong>Fehler</strong> |<br />| <code>volume</code> nicht positiv | <strong>Fehler</strong> |<br />| <code>non_manifold_edges</code> | <strong>Fehler</strong> |<br />| <code>degenerate_faces</code> | Warnung |<br />| <code>holes</code> | Warnung |</p>\n<p><strong><code>POST …/reparatur</code></strong> (<code>/openapi/v1/print/repair</code>) repariert **nur die<br />Topologie** hin zu einem wasserdichten Netz. Zwei Folgen, die nicht in einer<br />Fußnote stehen dürfen:</p>\n<p>* <strong>10 Credits</strong> je erfolgreicher Aufgabe;<br /><em> <strong>vorhandene Texturen gehen verloren</strong> — wörtlich: </em>„Existing textures are<br />  removed during repair due to geometry changes.&quot;*</p>\n<blockquote><p>**Und die wichtigste Einschränkung: ein repariertes Netz ist kein</p><p>druckbares Teil.** Wandstärke, Überhänge und Maßstab bleiben ungeprüft. Das</p><p>ist ein Befund <strong>durch Abwesenheit</strong>: die API-Referenz nennt diese drei</p><p>Größen an keiner Stelle — sie behauptet nicht, sie nicht zu prüfen, sie</p><p>kennt sie nicht. Wer „repariert&quot; mit „druckbar&quot; verwechselt, verkauft ein</p><p>Modell, das erst im Schnitt auffällt. Deshalb trägt <strong>jede</strong> Antwort und</p><p>jede Ansicht das Feld <code>ungeprueft</code>.</p></blockquote>\n<h3>4. Asynchron — und die Frist, die keiner erwartet</h3>\n<p>Aufträge laufen lange. Der Zustand überlebt einen Seitenwechsel, weil er<br /><strong>bei Meshy</strong> geführt wird und nicht hier: das Addon hat bewusst <strong>keine</strong><br />zweite Auftragstabelle. <code>GET …/auftraege</code> liest die vier Gattungslisten und<br />führt sie zusammen.</p>\n<p><strong>Die Frist, die in der API-Doku nicht steht:</strong> die Nutzungsbedingungen sagen<br />*„Customer Output generated by Customers using the APIs, other than<br />Enterprise Customers, will be deleted three (3) days after it is generated.&quot;*<br />Die Modell-Adressen sind also binnen <strong>drei Tagen</strong> weg; das Antwortobjekt<br />trägt dafür <code>expires_at</code>. Ein Addon, das nur die Adresse speichert, zeigt<br />nach drei Tagen einen toten Link. Deshalb führt jede Antwort <code>ablaufAm</code> und<br /><code>abgelaufen</code> mit, und ein fertiger Auftrag ohne Datei bekommt einen eigenen<br />Fehler (<code>meshy_ohne_modell</code>) statt einer leeren Dateiliste.</p>\n<hr />\n<h2>Kein stiller Rückfall: fünf Zustände, fünf Bilder</h2>\n<p>Der Kern dieses Addons. Diese fünf könnten sich alle als „keine Ergebnisse&quot;<br />tarnen — keiner darf:</p>\n<p>| Zustand | HTTP | <code>code</code> | Ging etwas raus? |<br />| --- | --- | --- | --- |<br />| nicht eingerichtet | 503 | <code>meshy_not_configured</code> | <strong>nein</strong>, gar nichts |<br />| Einstellung unlesbar | 503 | <code>meshy_einstellung_ungueltig</code> | <strong>nein</strong>, gar nichts |<br />| Tagesgrenze erreicht | 429 | <code>meshy_grenze_erreicht</code> | gelesen ja, <strong>angelegt nein</strong> |<br />| Meshy nicht erreichbar | 502 | <code>meshy_unreachable</code> | ja, ohne Antwort |<br />| Ausgang unklar | 409 | <code>meshy_ausgang_unklar</code> | ja — <strong>nicht wiederholen</strong> |<br />| fertig, aber ohne Datei | 409 | <code>meshy_ohne_modell</code> | ja |<br />| erreichbar und leer | 200 | — | ja, es ist wirklich leer |</p>\n<p>Dazu: <code>meshy_auth_fehlgeschlagen</code> (502), <code>meshy_kontingent</code> (402),<br /><code>meshy_abgelehnt</code> (400), <code>meshy_auftrag_unbekannt</code> (404),<br /><code>meshy_gattung_fehlt</code> (400), <code>meshy_wunsch_ungueltig</code> (400),<br /><code>meshy_upstream_error</code> (502).</p>\n<p><strong>Kein Wiederholversuch</strong> auf einem kostenpflichtigen Pfad. Bleibt der<br />Ausgang unklar, sagt die Antwort genau das — mit dem Zeitpunkt als Ankerwert<br />für die Nachschau in der Auftragsliste. Das Addon misst und berichtet, es<br />entscheidet nicht.</p>\n<hr />\n<h2>Routen</h2>\n<p>| Methode | Pfad | Berechtigung | Kostet |<br />| --- | --- | --- | --- |<br />| <code>GET</code> | <code>…/auftraege</code> | <code>auftraege/lesen</code> | nein |<br />| <code>POST</code> | <code>…/auftraege</code> | <code>auftraege/erzeugen</code> | <strong>ja</strong> |<br />| <code>GET</code> | <code>…/auftraege/{id}?art=…</code> | <code>auftraege/lesen</code> | nein |<br />| <code>POST</code> | <code>…/auftraege/{id}/druckpruefung</code> | <code>pruefung/starten</code> | nein (Doku: „Always 0&quot;) |<br />| <code>POST</code> | <code>…/auftraege/{id}/reparatur</code> | <code>reparatur/starten</code> | <strong>ja, 10 Credits</strong> |<br />| <code>GET</code> | <code>…/verbrauch</code> | <code>auftraege/lesen</code> | nein |</p>\n<p><code>art</code> ist beim Einzelabruf <strong>Pflicht</strong> (<code>text</code>, <code>bild</code>, <code>pruefung</code>,<br /><code>reparatur</code>). Meshy hat vier getrennte Endpunktfamilien und keine<br />gattungsübergreifende Nachschlagestelle; wer die Gattung nicht kennt, müsste<br />vier Rufe raten. Die Gattung steht in jeder Zeile der Auftragsliste.</p>\n<h3>Vier Berechtigungen, und warum es vier sind</h3>\n<p>| Capability | Tier | Begründung |<br />| --- | --- | --- |<br />| <code>addons.meshy.auftraege.lesen</code> | <code>elevated</code> | Bounded Egress mit hinterlegtem Schlüssel |<br />| <code>addons.meshy.auftraege.erzeugen</code> | <code>highest</code> | gibt Geld aus, nicht zurücknehmbar |<br />| <code>addons.meshy.pruefung.starten</code> | <code>elevated</code> | kostet laut Doku nichts, legt aber einen Auftrag an |<br />| <code>addons.meshy.reparatur.starten</code> | <code>highest</code> | 10 Credits <strong>und</strong> Texturverlust |</p>\n<p>Wer messen darf, darf deshalb noch nicht bezahlen und Texturen verlieren —<br />deshalb sind Prüfen und Reparieren getrennt.</p>\n<p>Alle drei anlegenden Routen liegen auf <strong>POST</strong>, auch die kostenlose Prüfung.<br />Nur ein mutierendes Verb wird auf ADR-062-Stufe <code>restricted</code> abgewiesen<br />(<code>isMethodAllowedForTrust</code>); ein Pfad, der bei einem Fremddienst etwas anlegt<br />und an einem GET hinge, wäre im Sandbox-Betrieb still erlaubt.</p>\n<hr />\n<h2>Einstellungen</h2>\n<p>Alles über die Aiopta-Oberfläche, nichts von Hand in einer Datei. **Kein Feld<br />hat einen Vorgabewert** — die Addons gehen an fremde Betreiber (#1220, #1226,<br />#1215 sind genau daran gescheitert).</p>\n<p>| Schlüssel | Typ | Pflicht | Leer bedeutet |<br />| --- | --- | --- | --- |<br />| <code>apiKey</code> | password | <strong>ja</strong> | jede Route antwortet 503, <strong>ohne Netzruf</strong> |<br />| <code>modell</code> | text | nein | <code>ai_model</code> wird <strong>nicht gesendet</strong>, Meshy entscheidet |<br />| <code>polygonzahl</code> | text | nein | <code>target_polycount</code> wird <strong>nicht gesendet</strong> |<br />| <code>tagesgrenzeCredits</code> | text | nein | <strong>unbegrenzt</strong> — und wird als unbegrenzt angezeigt |<br />| <code>tagesgrenzeAuftraege</code> | text | nein | <strong>unbegrenzt</strong> — und wird als unbegrenzt angezeigt |</p>\n<p>„Nicht gesendet&quot; ist nicht dasselbe wie „auf einen Vorgabewert gesetzt&quot;. Der<br />Unterschied ist messbar: <code>meshy-client.test.ts</code> prüft, dass das Feld im<br />Anfragekörper <strong>fehlt</strong>.</p>\n<p><code>modell</code> nimmt <code>meshy-5</code>, <code>meshy-6</code>, <code>meshy-7</code> oder <code>latest</code>. **<code>meshy-4</code> ist<br />in der aktuellen Referenz nicht mehr wählbar** und wird hier benannt<br />abgelehnt, statt es an Meshy durchzureichen und sich nach dem Netzruf ablehnen<br />zu lassen.</p>\n<h3>Warum <code>api.meshy.ai</code> fest im Code steht</h3>\n<p>Das sieht aus wie das <code>DEFAULT_BASE_URL</code>, an dem <code>spoolman</code> (#1226) und<br /><code>octoprint-proxy</code> (#1215) hängengeblieben sind, ist aber der umgekehrte Fall:</p>\n<p>* Dort war die Adresse der <strong>Wirt des Betreibers</strong>. Ließ ein fremder Betreiber<br />  das Feld leer, rief sein Server still die Produktivinstanz eines anderen<br />  Kunden auf.<br />* Hier ist die Adresse der <strong>Anbieter selbst</strong>. Es gibt genau einen<br />  Meshy-Endpunkt; ihn einstellbar zu machen hieße, ein Feld anzubieten, in das<br />  nur ein Umleitungsangriff etwas anderes schreiben könnte.</p>\n<p>Getrennt wird über den <strong>Schlüssel</strong> — und der ist Pflicht und hat keine<br />Vorgabe. Dasselbe Muster fährt <code>revenue-cat</code>. <code>config.test.ts</code> pinnt beide<br />Hälften: die feste Anbieteradresse <strong>und</strong> die Abwesenheit jedes<br />Vorgabewerts.</p>\n<hr />\n<h2>MCP-Server <code>meshy-tools</code></h2>\n<p>Drei <strong>read-only</strong> Werkzeuge: <code>meshy_auftrag_liste</code>, <code>meshy_auftrag_lesen</code>,<br /><code>meshy_verbrauch</code>.</p>\n<p><strong>Kein erzeugendes, kein prüfendes und kein reparierendes Werkzeug.</strong> Erzeugen<br />und Reparieren geben Geld aus, und die Meshy-API kennt keinen<br />Idempotenzschlüssel — ein Agent, der bei unklarer Antwort noch einmal ruft,<br />bezahlt zweimal. Auch die kostenlose Prüfung ist kein Werkzeug: sie legt einen<br />Auftrag bei einem fremden Dienst an, und „kostenlos&quot; ist eine Zusage des<br />Anbieters, keine Eigenschaft des Protokolls. Alle drei Pfade laufen<br />ausschließlich über die API-Routen mit eigener Berechtigung.</p>\n<p>Der Test <code>meshy-tools-mcp.test.ts</code> prüft das nicht als Aufzählung, sondern<br />über <code>tools/list</code>: <strong>kein Ruf irgendeines Werkzeugs ist ein POST.</strong> Ein später<br />ergänztes Werkzeug fällt sofort auf.</p>\n<p>Ohne <code>AIOPTA_CREDENTIAL_APIKEY</code> fällt jedes Werkzeug fail-closed aus — und<br />zwar <strong>ohne <code>fetch</code></strong>, nicht nur mit einer Fehlermeldung. Handshake und<br /><code>tools/list</code> laufen ohne jede Env (Conformance C3).</p>\n<hr />\n<h2>Tests</h2>\n<p><strong>Kein Test läuft gegen die echte API.</strong> Sie kostet Geld, und ein Testlauf,<br />der ein Konto belastet, ist einer, den niemand zweimal startet. Der globale<br /><code>fetch</code> ist überall gestubbt; ein durchgerutschter echter Ruf fällt als<br />„fetch not stubbed&quot; auf.</p>\n<p>| Datei | Was sie festhält |<br />| --- | --- |<br />| <code>config.test.ts</code> | keine Vorgabe, kein <code>fetch</code> ohne Schlüssel, die Adresse am Quelltext gemessen |<br />| <code>kostengrenze.test.ts</code> | der Zähler, beide Grenzen, „leer ≠ null ≠ kaputt&quot;, und der Nachweis, dass die Credit-Grenze nachhinkt |<br />| <code>meshy-client.test.ts</code> | v2 vs. v1, fehlende Felder im Körper, Millisekunden, ein Versuch je Kostenpfad |<br />| <code>errors.test.ts</code> | die fünf Tarnungen tragen fünf verschiedene Codes |<br />| <code>route-contract.test.ts</code> | anon401, getrennte Rechte, 429 <strong>ohne einziges POST</strong>, kein Schlüssel in einer Antwort |<br />| <code>manifest.test.ts</code> | Adoptions-Standard, kein <code>default</code>, <strong>keine Kante zur Druckzentrale</strong> |<br />| <code>trust.test.ts</code> | ADR-062: auf <code>restricted</code> läuft kein anlegender Pfad |<br />| <code>page.test.tsx</code> | sechs unterscheidbare Bilder, „keine Obergrenze gesetzt&quot; als Klartext |<br />| <code>meshy-tools-mcp.test.ts</code> | fail-closed, und kein Werkzeug setzt ein POST ab |</p>\n<hr />\n<h2>Bekannte Lücke</h2>\n<p><code>attestation.json</code> fehlt. Signiert wird ausschließlich im Auftrag<br /><code>ci/Jenkinsfile.addonsign</code> mit der Credential <code>aiopta-addon-signing-key</code>; der<br />Schlüssel liegt weder im Repo noch bei einem Entwickler. Bis dahin steht ein<br />ticketgebundener Eintrag in <code>scripts/check-addon-adoption.cjs</code><br />(<code>BEKANNTE_LUECKEN</code>), dessen Muster ausschließlich auf<br /><code>^attestation\\.json missing</code> passt. **Nach dem Signieren muss dieser Eintrag<br />weg** — <code>veralteteAusnahmen</code> macht sonst rot.</p>\n<hr />\n<h2>Quellen</h2>\n<p>Alles am 11.08.2026 abgerufen:</p>\n<ul><li>&lt;<a href=\"https://docs.meshy.ai/en&amp;gt;\" rel=\"noopener noreferrer\">https://docs.meshy.ai/en&amp;gt;</a> — Überblick, Modellstände</li><li>&lt;<a href=\"https://docs.meshy.ai/en/api&amp;gt;\" rel=\"noopener noreferrer\">https://docs.meshy.ai/en/api&amp;gt;</a> — Endpunktliste</li><li>&lt;<a href=\"https://docs.meshy.ai/en/api/authentication&amp;gt;\" rel=\"noopener noreferrer\">https://docs.meshy.ai/en/api/authentication&amp;gt;</a> — <code>Authorization: Bearer msy_…</code></li><li>&lt;<a href=\"https://docs.meshy.ai/en/api/text-to-3d&amp;gt;\" rel=\"noopener noreferrer\">https://docs.meshy.ai/en/api/text-to-3d&amp;gt;</a> — v2, <code>mode: preview</code></li><li>&lt;<a href=\"https://docs.meshy.ai/en/api/image-to-3d&amp;gt;\" rel=\"noopener noreferrer\">https://docs.meshy.ai/en/api/image-to-3d&amp;gt;</a> — Parameter, Zustände, <code>consumed_credits</code>, <code>expires_at</code></li><li>&lt;<a href=\"https://docs.meshy.ai/en/api/remesh&amp;gt;\" rel=\"noopener noreferrer\">https://docs.meshy.ai/en/api/remesh&amp;gt;</a> — Zielformate inkl. <code>stl</code>/<code>3mf</code>, <code>target_polycount</code> 100–300000</li><li>&lt;<a href=\"https://docs.meshy.ai/en/api/analyze-printability&amp;gt;\" rel=\"noopener noreferrer\">https://docs.meshy.ai/en/api/analyze-printability&amp;gt;</a> — fünf Metriken, „Always 0. This endpoint is free.&quot;</li><li>&lt;<a href=\"https://docs.meshy.ai/en/api/repair-printability&amp;gt;\" rel=\"noopener noreferrer\">https://docs.meshy.ai/en/api/repair-printability&amp;gt;</a> — 10 Credits, Texturverlust</li><li>&lt;<a href=\"https://docs.meshy.ai/en/api/balance&amp;gt;\" rel=\"noopener noreferrer\">https://docs.meshy.ai/en/api/balance&amp;gt;</a> — <code>{ &quot;balance&quot;: &lt;int&gt; }</code></li><li>&lt;<a href=\"https://www.meshy.ai/terms-of-use&amp;gt;\" rel=\"noopener noreferrer\">https://www.meshy.ai/terms-of-use&amp;gt;</a> — Eigentum je Tarif, Drei-Tage-Frist für API-Ausgaben</li></ul>",
          "byteSize": 16490
        }
      }
    },
    {
      "id": "monitoring",
      "uuid": "008f1300-57c6-495e-9b1a-c6d79ac14c2c",
      "name": "Observability (Host-Metriken Glances + Uptime-Kuma + Alerts + Error-Tracking)",
      "version": "1.0.7",
      "description": "Per-User Observability: verbindet sich BYOK mit Glances (Host-Metriken CPU/RAM/Disk/Net) und Uptime-Kuma (Uptime-Checks up/down/Latenz). Dashboard für Host-Metriken, Uptime-Status und konfigurierbare Alert-Regeln (Schwellwert → Vorfall/Notification). Jeder Nutzer verwaltet seine eigenen Connections, Alert-Regeln und Incident-Historie (ADR-054 scopedStore). Die Clients sind graziös, wenn nichts konfiguriert ist. Kanonisches Observability-Addon (ADR-008 / #495, Epic #490): Client-/Server-Error-Tracking (vormals `error-monitoring`) ist als Errors-Domänen-Connector desselben geteilten Observability-Datenmodells hier konsolidiert.",
      "author": "Aiopta",
      "icon": "📡",
      "capabilities": [
        "monitoring",
        "metrics",
        "uptime",
        "alerts",
        "glances",
        "uptime-kuma",
        "incident-log",
        "observability",
        "observability-hub",
        "error-connector"
      ],
      "tags": [],
      "download": "/artifacts/monitoring-1.0.7.zip",
      "sha256": "7628eca15a831c886c1f81491b0a2e5712ef86a55ff5af491a033acec6f84e65",
      "size": 144081,
      "page": {
        "id": "monitoring-page",
        "path": "/addons/monitoring",
        "name": "Monitoring",
        "icon": "📡",
        "section": "Operations",
        "component": "@addons/monitoring/src/components/MonitoringPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/monitoring/config",
        "/api/v1/addons/monitoring/metrics",
        "/api/v1/addons/monitoring/uptime",
        "/api/v1/addons/monitoring/alerts",
        "/api/v1/addons/monitoring/alerts/:id",
        "/api/v1/addons/monitoring/uptime-checks",
        "/api/v1/addons/monitoring/uptime-checks/:id"
      ],
      "skills": [
        {
          "name": "monitoring-view",
          "displayName": "Monitoring: Metriken & Uptime-Status lesen",
          "description": "Zeigt Host-Metriken (CPU/RAM/Disk/Netz aus Glances) und den Uptime-Status (up/down/Latenz aus Uptime-Kuma) für die konfigurierten Connections des Nutzers. Reiner Lesezugriff — legt nichts an.",
          "permissions": [
            "addons/monitoring/metrics",
            "addons/monitoring/uptime",
            "addons/monitoring/incident-log"
          ],
          "endpoint": "/api/v1/addons/monitoring/metrics",
          "scripts": []
        },
        {
          "name": "monitoring-manage",
          "displayName": "Monitoring: Connections & Alert-Regeln verwalten",
          "description": "Legt BYOK-Connections (Glances/Uptime-Kuma) und Alert-Regeln (Schwellwert → Vorfall) für den Nutzer an oder entfernt diese (per-User gebucht). Konfiguration wird im scopedStore abgelegt.",
          "permissions": [
            "addons/monitoring/config",
            "addons/monitoring/alerts",
            "addons/monitoring/uptime-checks"
          ],
          "endpoint": "/api/v1/addons/monitoring/config",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": null
    },
    {
      "id": "moto-routeplanner",
      "uuid": "4de4b514-2723-455c-9b53-c37b2953f16e",
      "name": "Moto Route Planner (GPX)",
      "version": "1.1.3",
      "description": "Plan motorcycle tours — round-trip or one-way, with waypoints, by target distance or duration, with a curviness level, avoid-highway (or highway-until-X then curvy/passes), prefer-passes — and export a valid GPX 1.1 for your navi (Garmin/TomTom/Calimoto). v1.1 adds POIs along the route (fuel/food/viewpoint via OpenStreetMap/Overpass), multi-day tours split into day stages with one GPX per stage, and an elevation profile (Höhenprofil). Routing runs on an MC-managed BRouter container (ADR-009, reached only via the internal URL — no hard-coded host) with an owner-keyed GraphHopper/ORS Directions API fallback; when neither is configured, plan calls return a clear 503. The fallback + Overpass API keys + BRouter rd5 region/disk are owner-config (OPEN-DECISIONS), never the manifest. ADR-037.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "route-planning",
        "gpx-export",
        "roundtrip-generation",
        "curvy-routing",
        "pois-along-route",
        "multi-day-stages",
        "elevation-profile"
      ],
      "tags": [],
      "download": "/artifacts/moto-routeplanner-1.1.3.zip",
      "sha256": "d76d72f5fa7c8abb7f227e0372aa4aa030c546c64d7bf1f693b426a7e27d520e",
      "size": 261000,
      "page": {
        "id": "moto-routeplanner-page",
        "path": "/addons/moto-routeplanner",
        "name": "Moto Route Planner",
        "icon": "🏍️",
        "section": "Tools",
        "component": "@addons/moto-routeplanner/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/moto-routeplanner/status",
        "/api/v1/addons/moto-routeplanner/plan",
        "/api/v1/addons/moto-routeplanner/tours",
        "/api/v1/addons/moto-routeplanner/tours/:id",
        "/api/v1/addons/moto-routeplanner/tours/:id/gpx",
        "/api/v1/addons/moto-routeplanner/tours/:id/stages",
        "/api/v1/addons/moto-routeplanner/tours/:id/stages/:stage/gpx",
        "/api/v1/addons/moto-routeplanner/widget"
      ],
      "skills": [
        {
          "name": "moto-plan-tour",
          "displayName": "Moto: Plan a tour → GPX",
          "description": "Plan a motorcycle tour from parameters (start/destination/waypoints, round-trip vs one-way, curviness, avoid-highway or highway-until-X, target distance/duration, prefer-passes) and get the routed geometry + a valid GPX 1.1. Routing uses the MC-managed BRouter container with an owner-keyed fallback; if neither is configured the call returns a clear 503 (never a hard-coded host).",
          "permissions": [
            "addons/moto-routeplanner/plan",
            "addons/moto-routeplanner/status"
          ],
          "endpoint": "/api/v1/addons/moto-routeplanner/plan",
          "scripts": []
        },
        {
          "name": "moto-tours",
          "displayName": "Moto: Save & list tours",
          "description": "Plan-and-save tours, list saved tours, fetch one (params + GPX), and download the GPX. Saved tours live in an addon-local table; the GPX download is a pure read (no re-routing).",
          "permissions": [
            "addons/moto-routeplanner/tours",
            "addons/moto-routeplanner/status"
          ],
          "endpoint": "/api/v1/addons/moto-routeplanner/tours",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Moto Route Planner (GPX) — Aiopta addon</h1>\n<p>Plan motorcycle tours and export a <strong>GPX 1.1</strong> file for your navi (Garmin /<br />TomTom / Calimoto). Round-trip or one-way, with waypoints, sized by target<br />distance or duration, with a <strong>curviness</strong> level, <strong>avoid-highway</strong> (or<br />&quot;highway-until-X, then curvy/passes&quot;), and <strong>prefer-passes</strong>. Built on ADR-037.</p>\n<p><strong>v1.1</strong> adds: <strong>POIs along the route</strong> (fuel/food/viewpoint… from<br />OpenStreetMap/Overpass, kept only inside a corridor around the planned line and<br />emitted as GPX waypoints with navi symbols), <strong>multi-day tours</strong> split into day<br /><strong>stages</strong> with <strong>one GPX per stage</strong> (download a single day), and an **elevation<br />profile** (Höhenprofil — ascent/descent/min/max + a distance↔elevation series).</p>\n<h2>How it works</h2>\n<p>- <strong>Routing engine = BRouter container (ADR-009).</strong> BRouter is an open-source<br />  router ideal for curvy moto routes (it avoids motorways and biases toward<br />  winding roads / passes via <em>profiles</em>). The addon declares it as an Aiopta-managed<br />  container, booted at install, and reaches it <strong>only</strong> via the Aiopta-internal URL —<br />  never a hard-coded host (<code>src/lib/brouter.ts</code> →<br />  <code>getContainerAddonInternalUrl(&quot;moto-routeplanner&quot;)</code>).<br />- <strong>Fallback.</strong> When the container is absent, an optional owner-keyed<br />  <strong>GraphHopper / OpenRouteService</strong> Directions API serves the route<br />  (<code>ROUTING_FALLBACK_API_KEY</code> in Aiopta addon-config — sent server-side, never<br />  logged). If <strong>neither</strong> is available, plan calls return a clear <strong>503</strong> and<br />  <code>/status</code> reports it; the addon never crashes and never calls a default host.<br />- <strong>Planning is pure + tested.</strong> Parameters → a BRouter profile (<code>profile.ts</code>),<br />  round-trip loops generated by a documented waypoint-ring heuristic<br />  (<code>roundtrip.ts</code>), and a hand-rolled GPX 1.1 serializer (<code>gpx.ts</code>). No routing or<br />  GPX npm dependency is added.<br />- <strong>v1.1 POIs along the route.</strong> A keyless <strong>Overpass</strong> (OpenStreetMap) query<br />  (<code>overpass.ts</code>, the only POI I/O — server-side, owner-overridable endpoint, any<br />  private-mirror key sent as a header and never logged) fetches candidates in the<br />  track&#39;s bounding box; a <strong>pure corridor filter</strong> (<code>poi.ts</code>) keeps only those<br />  within <code>poiCorridorM</code> of the line, annotates offset + along-route distance, and<br />  emits them as GPX <code>&lt;wpt&gt;</code>s with category symbols. A POI-source outage is<br />  best-effort: the route + GPX still come back (with a <code>poiError</code> note).<br />- <strong>v1.1 multi-day stages + elevation.</strong> A <strong>pure</strong> splitter (<code>stages.ts</code>) cuts<br />  the routed track into day stages near a <code>perDayKm</code> target (or a fixed<br />  <code>stageCount</code>) — contiguous slices sharing each boundary point — and each stage is<br />  serialized to <strong>its own GPX</strong>. A <strong>pure</strong> profiler (<code>elevation.ts</code>) turns the<br />  track&#39;s elevations into a Höhenprofil (jitter-suppressed ascent/descent, min/max,<br />  max grade, a down-sampled distance↔elevation series). Saved tours re-derive<br />  stages/elevation from the stored GPX (a tolerant <code>parseGpxTrackPoints</code>) with no<br />  re-routing.</p>\n<h2>Parameters</h2>\n<p><code>start</code> (required), optional <code>destination</code> (required for one-way; defaults to the<br />start for a round-trip) and <code>waypoints</code>; <code>tourType</code> (<code>round-trip</code> / <code>one-way</code>);<br /><code>curviness</code> (<code>straight</code> / <code>balanced</code> / <code>curvy</code> / <code>very-curvy</code>); <code>highwayMode</code><br />(<code>avoid</code> / <code>allow</code> / <code>until-waypoint</code> + <code>highwayUntilIndex</code>); <code>targetDistanceKm</code><br /><strong>or</strong> <code>targetDurationMin</code> (round-trip sizing); <code>preferPasses</code>; <code>name</code>, <code>save</code>.</p>\n<p><strong>v1.1:</strong> <code>includePois</code> + <code>poiCategories</code><br />(<code>fuel</code>/<code>food</code>/<code>cafe</code>/<code>viewpoint</code>/<code>attraction</code>/<code>hotel</code>/<code>repair</code>) + <code>poiCorridorM</code>;<br /><code>multiDay</code> + <code>perDayKm</code> <strong>or</strong> <code>stageCount</code>. <code>POST /plan</code> then also returns <code>pois</code>,<br /><code>poiError</code>, <code>elevation</code> (<code>samples</code> + <code>stats</code>) and, when <code>multiDay</code>, <code>stages</code> (with<br />a per-stage <code>gpx</code>).</p>\n<p>The acceptance case — <strong>a curvy round-trip by target distance, no highway</strong> —<br />produces a valid, downloadable GPX. Curviness and highway mode demonstrably change<br />the BRouter profile that reaches the engine.</p>\n<h2>API (agent-capable)</h2>\n<p>Base: <code>/api/v1/addons/moto-routeplanner</code>. All routes are <code>userRoute</code>-auth-gated;<br />the per-agent capability is <code>addons/moto-routeplanner/*</code>.</p>\n<p>| Method + path | Purpose |<br />|---------------|---------|<br />| <code>GET /status</code> | Engine/container availability + saved-tour count |<br />| <code>POST /plan</code> | Plan a tour → route + GPX (optional <code>save:true</code>) |<br />| <code>GET /tours</code> | List saved tours |<br />| <code>POST /tours</code> | Plan <strong>and save</strong> a tour |<br />| <code>GET /tours/{id}</code> | Fetch one saved tour (params + GPX + elevation profile) |<br />| <code>GET /tours/{id}/gpx</code> | Download the GPX (<code>application/gpx+xml</code>) |<br />| <code>GET /tours/{id}/stages</code> | <strong>v1.1</strong> List the saved tour&#39;s day stages |<br />| <code>GET /tours/{id}/stages/{n}/gpx</code> | <strong>v1.1</strong> Download one stage&#39;s GPX (<code>application/gpx+xml</code>) |<br />| <code>DELETE /tours/{id}</code> | Delete a saved tour |</p>\n<p>Saved tours live in <strong>one addon-local table</strong> (<code>moto_routeplanner_tours</code>), created<br />in <code>install()</code> and dropped in <code>uninstall()</code> — no core-schema impact.</p>\n<p><strong>Per-user isolation (#147 / ADR-054):</strong> <code>moto_routeplanner_tours</code> carries <code>user_id</code>;<br />the server stamps the owner from the authenticated caller on save (a client-supplied<br />owner is ignored), and every list/read/GPX-download/delete is scoped to the caller via<br /><code>scopedStore</code>. A tour id owned by another user returns <strong>404</strong> (no existence leak) — you<br />only ever see and act on your own saved tours, and the widget&#39;s &quot;latest&quot; is your own.</p>\n<h2>Page + widget</h2>\n<p>A management page (<code>Tools</code> section) sets start/destination/waypoints + curviness,<br />highway and target-distance controls + a round-trip toggle and prefer-passes,<br />computes a route, shows a summary (distance / duration / ascent / waypoint list)<br />and downloads the GPX, plus a saved-tours list. A dashboard widget shows the<br />saved-tour count, the latest tour and engine readiness. Light + Dark, mobile,<br /><code>var(--color-*)</code>.</p>\n<blockquote><p><strong>No interactive map yet.</strong> No map library is currently an Aiopta</p><p>dependency, and the project&#39;s no-new-dep rule forbids adding one, so the page</p><p>ships a functional coordinate-input + route-summary UI. An interactive map is</p><p>parked in <code>docs/OPEN-DECISIONS.md</code> as a follow-up.</p></blockquote>\n<h2>Owner setup (OPEN-DECISIONS)</h2>\n<p>1. <strong>BRouter image + <code>rd5</code> region data</strong> (<code>brouter-routing-image</code> /<br />   <code>brouter-rd5-region</code>): build/publish the BRouter image and provision the<br />   region-selectable OSM segment data (disk).<br />2. <strong>Fallback Directions key</strong> (<code>routing-fallback-api-key</code>): optionally set<br />   <code>ROUTING_FALLBACK_API_KEY</code> (+ <code>ROUTING_FALLBACK_PROVIDER</code>) for the<br />   container-absent fallback.<br />3. <strong>Overpass endpoint (v1.1, optional)</strong>: POIs use the public Overpass API by<br />   default (keyless). To point at a self-hosted/rate-limited mirror, set<br />   <code>OVERPASS_API_URL</code> (+ optional <code>OVERPASS_API_KEY</code>, sent server-side as a header,<br />   never logged) in Aiopta addon-config.<br />4. <strong>Map library</strong> (<code>moto-routeplanner-map-lib</code>): parked until/unless a map lib is<br />   a dependency.</p>\n<p>Until (1) or (2) is provisioned, <code>/status</code> reports the engine not-configured and<br />plan calls return a clear 503.</p>\n<h2>Tests</h2>\n<p><code>tests/unit/</code> (WSL node22): GPX 1.1 validity (<code>gpx.test.ts</code>), param→profile<br />mapping (<code>profile.test.ts</code>), round-trip distance bounds + geo helpers<br />(<code>roundtrip.test.ts</code>), the engine client incl. the no-engine 503 + container URL +<br />fallback (<code>brouter.test.ts</code>), the planner incl. the acceptance GPX + highway-until<br />two-leg routing (<code>planner.test.ts</code>), manifest/dual-registration (<code>manifest.test.ts</code>),<br />and the saved-tour per-user isolation (<code>isolation.test.ts</code>).</p>\n<p><strong>v1.1 tests:</strong> the elevation profile (<code>elevation.test.ts</code>), multi-day staging<br />(<code>stages.test.ts</code>), the POI corridor filter + category model (<code>poi.test.ts</code>), the<br />Overpass transport incl. key-never-leaked + 502 paths (<code>overpass.test.ts</code>), the<br />GPX POI waypoints + trackpoint parser (<code>gpx-v11.test.ts</code>), the planner<br />orchestration of POIs/elevation/stages with the engine + Overpass mocked<br />(<code>planner-v11.test.ts</code>), and per-user isolation of the new stage routes<br />(<code>isolation-v11.test.ts</code>).</p>",
          "byteSize": 7876
        }
      }
    },
    {
      "id": "n8n",
      "uuid": "600e5906-dcc7-469f-962b-43df98cd828a",
      "name": "n8n",
      "version": "0.1.0",
      "description": "Eine vorhandene, externe n8n-Instanz aus Aiopta heraus einsehen und bedienen: Workflows auflisten samt Zustand (aktiv/inaktiv), Ausfuehrungen einsehen (was laeuft dort noch), einen Workflow starten. Lesen steht im Vordergrund. Angebunden ueber die n8n Public API v1 (Kopf X-N8N-API-KEY, Basis /api/v1); der Start laeuft ueber den Produktions-Webhook des Workflows, weil die Public API nachweislich keinen Ausfuehren-Endpunkt hat (geprueft an der OpenAPI-Beschreibung am 2026-08-10). Abgegrenzt gegen workflow-builder: das ist eine EIGENE, Aiopta-interne Engine, die externe Hosts ausdruecklich verbietet (assertSameOrigin) — dieses Addon ist die Anbindung an die externe Instanz. Kein vorbelegter Server: ohne Basis-URL und Schluessel antwortet es mit einem sprechenden Fehler und ruft nichts ab. Drei Trust-Stufen: workflows.read und executions.read (elevated) < workflows.run (highest).",
      "author": "Aiopta",
      "icon": "🔗",
      "capabilities": [
        "n8n",
        "workflow-automation",
        "external-automation",
        "workflow-monitoring"
      ],
      "tags": [],
      "download": "/artifacts/n8n-0.1.0.zip",
      "sha256": "9dcb53977de55557f70044315d93b6c8b91a8e464f00a05e09f5940ba2716b79",
      "size": 174774,
      "page": {
        "id": "n8n",
        "path": "/addons/n8n",
        "name": "n8n",
        "icon": "🔗",
        "section": "Automation",
        "component": "@addons/n8n/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/n8n/workflows",
        "/api/v1/addons/n8n/workflows/{id}",
        "/api/v1/addons/n8n/workflows/{id}/run",
        "/api/v1/addons/n8n/executions",
        "/api/v1/addons/n8n/executions/{id}"
      ],
      "skills": [
        {
          "name": "n8n-workflows-read",
          "displayName": "n8n: Workflows und Zustand lesen",
          "description": "Listet die Workflows der externen n8n-Instanz mit ihrem Zustand (aktiv/inaktiv) und sagt je Workflow, ob er ueber die API startbar ist und warum nicht. Reiner Lesezugriff.",
          "permissions": [
            "addons/n8n/workflows/read"
          ],
          "endpoint": "/api/v1/addons/n8n/workflows",
          "scripts": []
        },
        {
          "name": "n8n-executions-read",
          "displayName": "n8n: Ausfuehrungen einsehen",
          "description": "Liest die Ausfuehrungen der Instanz (Status, Ausloeser, Start-/Stoppzeit) — die Antwort auf die Frage, was in n8n noch laeuft. Ohne Ausfuehrungsdaten. Eigene Permission, getrennt vom Workflow-Lesen.",
          "permissions": [
            "addons/n8n/executions/read"
          ],
          "endpoint": "/api/v1/addons/n8n/executions",
          "scripts": []
        },
        {
          "name": "n8n-workflow-run",
          "displayName": "n8n: Workflow starten",
          "description": "Startet genau einen Workflow ueber seinen Produktions-Webhook. Eigene, hoechste Trust-Stufe — getrennt vom Lesen. Kein Wiederholversuch: der Aufruf ist nicht idempotent.",
          "permissions": [
            "addons/n8n/workflows/read",
            "addons/n8n/workflows/run"
          ],
          "endpoint": "/api/v1/addons/n8n/workflows/{id}/run",
          "scripts": []
        }
      ],
      "mcpServers": [
        {
          "id": "n8n-tools",
          "displayName": "n8n tools",
          "description": "Read-only n8n-Tools fuer Agents: Workflows mit Zustand und Startweg, ein einzelner Workflow, Ausfuehrungen und eine einzelne Ausfuehrung. Der startende Pfad ist bewusst NICHT als Tool exponiert — ein Webhook-Aufruf ist nicht idempotent, ein zweiter Versuch laesst den Workflow ein zweites Mal laufen; er laeuft ausschliesslich ueber die API-Route mit eigener Berechtigung. Ausfuehrungsdaten (includeData) werden nicht geliefert. Authentifiziert ueber die Slots baseUrl/apiKey, nie als Tool-Argument.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "n8n-tools/server.mjs",
          "permissions": [
            "addons/n8n/workflows/read",
            "addons/n8n/executions/read"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [
            "baseUrl"
          ],
          "credentialRefs": [
            "apiKey"
          ],
          "archiveEntry": "n8n/mcp/servers/n8n-tools/server.mjs",
          "sha256": "23ef5bd8920e3ef9feea572f22c94baccabb2f214f8deeafcce89938ddd43cb1",
          "size": 17210,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>n8n (n8n)</h1>\n<p>Bindet eine <strong>vorhandene, externe n8n-Instanz</strong> als <code>connect</code>-Addon in Aiopta<br />ein: Workflows <strong>auflisten</strong> samt Zustand (aktiv/inaktiv), **Ausführungen<br />einsehen<strong> (was läuft dort noch?) und einen Workflow </strong>starten**.</p>\n<p><strong>Lesen steht im Vordergrund.</strong> Starten ist genau eine Schaltfläche je Zeile —<br />und nur dort, wo die n8n-API es hergibt.</p>\n<p>Ticket: <code>Aiopta/aiopta-addons#1222</code>.</p>\n<h2>Abgrenzung gegen <code>workflow-builder</code> — gemessen, nicht argumentiert</h2>\n<p><code>addons/workflow-builder</code> erwähnt n8n, ist aber <strong>keine</strong> n8n-Anbindung. Der<br />Befund am Quelltext (Stand <code>origin/develop</code>, 2026-08-10):</p>\n<p>| Frage | <code>workflow-builder</code> | dieses Addon |<br />|---|---|---|<br />| Was ist es? | eine <strong>eigene</strong>, Aiopta-interne Ausführungs-Engine | eine <strong>Anbindung</strong> an eine externe Instanz |<br />| Externe Hosts | <strong>verboten</strong> — <code>assertSameOrigin()</code> weist jeden nicht-Aiopta-Origin ab | genau das ist der Zweck |<br />| Kennt <code>X-N8N-API-KEY</code>? | nein, kein Vorkommen | ja, das ist der Auth-Kopf |<br />| Kennt einen n8n-Endpunkt? | nein, kein Vorkommen | <code>/api/v1/workflows</code>, <code>/api/v1/executions</code> |<br />| Führt selbst aus? | ja (Schritte: http/agent/condition/delay) | <strong>nein</strong> — n8n führt aus |</p>\n<p>Der Kommentar in <code>addons/workflow-builder/src/lib/engine.ts</code> sagt es<br />wörtlich: *&quot;the engine NEVER makes an arbitrary outbound request (ADR-009:<br />addons orchestrate via the Aiopta API/gateway, never external hosts)&quot;*. Das<br />Wort „n8n&quot; steht dort ausschließlich als <strong>Vergleich</strong> („n8n-lite scoped to<br />this platform&quot;).</p>\n<p>Damit ist ein Addon <code>n8n</code> eine Ergänzung, keine Dublette: das eine baut<br />Automatisierung <strong>in</strong> Aiopta, das andere macht sichtbar und bedienbar, was<br /><strong>außerhalb</strong> in n8n läuft.</p>\n<blockquote><p>Die Namensfalle aus der Addon-Vorlagen-Doku („n8n heißt <code>workflow-builder</code>&quot;)</p><p>beschreibt die <em>Analogie</em>, nicht die <em>Anbindung</em>. Sie hat in diesem Projekt</p><p>schon einmal zu einem falschen Plan geführt.</p></blockquote>\n<h2>Architektur</h2>\n<pre><code>Browser/Agent ──HTTPS──▶ Aiopta (dieses Addon) ──HTTPS──▶ externe n8n-Instanz\n                         ├─ Dashboard (native DS-Seite)   /addons/n8n\n                         ├─ API-Routen (userRoute)        /api/v1/addons/n8n/…\n                         └─ MCP-Server (n8n-tools)        read-only</code></pre>\n<p>- <strong>Natives Dashboard</strong> (<code>src/page.tsx</code>, <code>ui.mode: &quot;aiopta&quot;</code>): Workflows mit<br />  Zustand und Startweg, Ausführungen mit Statusfilter. Ausschließlich<br />  <code>@aiopta/ui</code>-Komponenten; der Browser kontaktiert n8n nie direkt.<br />- <strong>API-Routen</strong> (<code>src/app/api/v1/addons/n8n/*</code>): zentrale Auth über<br />  <code>userRoute</code> (Session-Cookie oder Bearer/X-API-Key Agent-Key).<br />- <strong>Client</strong> (<code>src/lib/n8n-client.ts</code>): injizierbarer fetch-Seam (Muster<br />  <code>spoolman-client.ts</code>), damit kein Unit-Test je das Netz anfasst.<br />- <strong>Konfiguration</strong> (<code>src/lib/config.ts</code>): <code>baseUrl</code> + <code>apiKey</code> (BYOK,<br />  <code>password</code>-Slot). Der Schlüssel verlässt den Server nie — auch nicht<br />  maskiert in einem Fehlertext.</p>\n<h2>Keine Vorgabe für die Umgebung</h2>\n<blockquote><p>„Addons in Aiopta: keine Defaults für Umgebungen, Namen und so - das wird</p><p>später vertrieben. Es soll als Addon auch für andere Nutzer gehen, ohne</p><p>meine Domain mitzugeben.&quot; — Betreiber, 10.08.2026</p></blockquote>\n<p>Deshalb gilt hier hart:</p>\n<p>- <strong>kein <code>default</code></strong> im <code>settingsSchema</code> (gepinnt in <code>manifest.test.ts</code>),<br />- <strong>kein Rückfall</strong> im Code auf irgendeine URL (gepinnt in <code>config.test.ts</code>,<br />  inklusive einer Prüfung <em>am Quelltext</em>, dass dort kein Hostname steht),<br />- <strong>leere Einstellung → sprechender Fehler, kein Netzruf</strong> (gepinnt in<br />  <code>route-contract.test.ts</code>: <code>fetch</code> wurde nachweislich nie gerufen),<br />- Beispiel-URLs stehen in der <code>description</code>, nie in <code>default</code>.</p>\n<h2>Die gemessene Randbedingung: n8n kann nicht „ausführen&quot;</h2>\n<p>Erhoben am <strong>2026-08-10</strong> an der OpenAPI-Beschreibung der n8n Public API<br />(<code>packages/cli/src/public-api/v1/openapi.yml</code>, <code>info.version 1.1.1</code>):</p>\n<p>- Workflow-Pfade sind <code>/workflows</code>, <code>/workflows/{id}</code>,<br />  <code>/workflows/{id}/activate</code>, <code>/deactivate</code>, <code>/archive</code>, <code>/transfer</code>,<br />  <code>/tags</code>.<br />- <strong>Es gibt keinen Pfad <code>/workflows/{id}/execute</code> und keinen <code>/run</code>.</strong><br />- Auth: Kopf <code>X-N8N-API-KEY</code>, Basis <code>/api/v1</code>.<br />- Blättern: <code>?limit&amp;cursor</code> rein, <code>{ data, nextCursor }</code> raus; <code>limit</code> ist<br />  bei 250 gedeckelt.</p>\n<p><strong>Folge:</strong> Ein Workflow lässt sich von außen nur über seinen<br /><strong>Webhook-Trigger</strong> starten. Dieses Addon liest dafür <code>path</code> und <code>httpMethod</code><br />aus dem <code>n8n-nodes-base.webhook</code>-Knoten der Workflow-Definition und ruft<br /><code>&lt;basis&gt;/webhook/&lt;pfad&gt;</code> — den <strong>Produktions</strong>-Pfad, nicht <code>webhook-test</code><br />(der existiert nur, solange jemand den Editor offen hat).</p>\n<p>Was das für den Betrieb heißt, steht <strong>in der Liste</strong>, nicht erst nach einem<br />Klick: jede Zeile trägt <code>startable</code> und, wenn nicht, den Grund.</p>\n<p>| Lage | <code>startable</code> | Grund |<br />|---|---|---|<br />| aktiver Workflow mit Webhook-Knoten | <code>true</code> | — |<br />| Workflow ohne Webhook-Knoten | <code>false</code> | kein Startweg; die Public API kennt kein Ausführen |<br />| inaktiver Workflow | <code>false</code> | der Produktions-Webhook ist nicht registriert |</p>\n<h2>Zwei Regeln, die im Quelltext festgeschrieben sind</h2>\n<h3>1. Kein Wiederholversuch beim Start</h3>\n<p>Ein Webhook-Aufruf ist <strong>nicht idempotent</strong> — n8n kennt dafür keinen<br />Idempotenzschlüssel. Bleibt der Ausgang unklar (Zeitgrenze,<br />Verbindungsabbruch), antwortet die Route mit <strong>409 <code>run_unclear</code></strong> und liefert<br /><code>attemptedAt</code> als Ankerwert mit. Der Abgleich läuft über<br /><code>GET /executions?workflowId=…</code>. Das Addon **misst und berichtet, es<br />entscheidet nicht** — dieselbe Regel wie in <code>spoolman</code>.</p>\n<p>Bei den elf LayerY-Workflows ist das kein akademischer Punkt: ein zweiter<br />Versuch kann eine zweite Bestellung, eine zweite Mail, eine zweite Buchung<br />auslösen.</p>\n<h3>2. Der API-Schlüssel geht nie an den Webhook</h3>\n<p>Der Webhook-Körper landet als <strong>Eingabe im Workflow</strong> und im<br />Ausführungsprotokoll jedes Laufs. Ein <code>X-N8N-API-KEY</code> dort hieße: der<br />Instanz-Schlüssel liegt im Klartext in der Lauf-Historie. Er geht<br />ausschließlich an <code>/api/v1/…</code> (gepinnt in <code>n8n-client.test.ts</code> und<br /><code>route-contract.test.ts</code>).</p>\n<h2>Endpunkte</h2>\n<p>Basis: <code>https://&lt;instanz&gt;/api/v1/addons/n8n</code></p>\n<p>| Ziel | Methode + Pfad | Permission |<br />|---|---|---|<br />| Workflows mit Zustand + Startweg | <code>GET /workflows</code> | <code>addons/n8n/workflows/read</code> |<br />| Ein Workflow mit Startbarkeit | <code>GET /workflows/{id}</code> | <code>addons/n8n/workflows/read</code> |<br />| Workflow starten | <code>POST /workflows/{id}/run</code> | <code>addons/n8n/workflows/run</code> |<br />| Ausführungen einsehen | <code>GET /executions</code> | <code>addons/n8n/executions/read</code> |<br />| Eine Ausführung | <code>GET /executions/{id}</code> | <code>addons/n8n/executions/read</code> |</p>\n<pre><code class=\"lang-bash\"># was gibt es, und was ist aktiv?\ncurl -s -H &quot;Authorization: Bearer $AIOPTA_API_KEY&quot; \\\n  &quot;https://&lt;instanz&gt;/api/v1/addons/n8n/workflows?active=true&quot;</code></pre>\n<p># was läuft dort gerade noch?<br />curl -s -H &quot;Authorization: Bearer $AIOPTA_API_KEY&quot; \\<br />  &quot;<a href=\"https://&amp;lt;instanz&amp;gt;/api/v1/addons/n8n/executions?status=running&amp;quot;\" rel=\"noopener noreferrer\">https://&amp;lt;instanz&amp;gt;/api/v1/addons/n8n/executions?status=running&amp;quot;</a></p>\n<p># einen Workflow starten<br />curl -s -X POST -H &quot;Authorization: Bearer $AIOPTA_API_KEY&quot; \\<br />  -H &#39;content-type: application/json&#39; -d &#39;{&quot;payload&quot;: {&quot;quelle&quot;: &quot;aiopta&quot;}}&#39; \\<br />  <a href=\"https://&amp;lt;instanz&amp;gt;/api/v1/addons/n8n/workflows/wf-1/run\" rel=\"noopener noreferrer\">https://&amp;lt;instanz&amp;gt;/api/v1/addons/n8n/workflows/wf-1/run</a><br />```</p>\n<h3>Fehlerbilder (maschinenlesbar über <code>code</code>)</h3>\n<p>| Situation | Status | <code>code</code> | Netzruf? |<br />|---|---|---|---|<br />| nichts eingestellt | 503 | <code>n8n_not_configured</code> | <strong>nein</strong> |<br />| Basis-URL unbrauchbar | 503 | <code>n8n_invalid_base_url</code> | <strong>nein</strong> |<br />| Schlüssel abgelehnt | 502 | <code>n8n_auth_failed</code> | ja |<br />| Instanz nicht erreichbar | 502 | <code>n8n_unreachable</code> | ja |<br />| Ressource unbekannt | 404 | <code>n8n_not_found</code> | ja |<br />| fremder Upstream-Status | 502 | <code>n8n_upstream_error</code> | ja |<br />| Workflow inaktiv | 409 | <code>workflow_inactive</code> | nur Lesen |<br />| kein Webhook-Trigger | 409 | <code>no_webhook_trigger</code> | nur Lesen |<br />| Start unklar | 409 | <code>run_unclear</code> | ein Startruf |</p>\n<p>Ein <strong>leeres</strong> Ergebnis (<code>200 { workflows: [] }</code>) sieht nachweislich anders<br />aus als ein Ausfall — und ein <em>nicht eingerichtetes</em> Addon sieht anders aus<br />als beides.</p>\n<h2>Trust-Stufen (ADR-062) und Manifest-<code>tier</code></h2>\n<p>Das sind <strong>zwei verschiedene Achsen</strong>, und ADR-062 ist <strong>nicht</strong> ADR-060:</p>\n<p>1. <strong>ADR-062</strong> <code>trusted | standard | restricted</code> — instanzweit, vom Admin<br />   gesetzt, steht <strong>nicht</strong> im Manifest, durchgesetzt über<br />   <code>isMethodAllowedForTrust</code>.<br />2. <code>capabilityDeclarations[].tier</code> — die deklarative Absicht dieses Addons.</p>\n<p>Auf <code>restricted</code> läuft das <strong>Lesen weiter</strong> und das **Starten wird<br />abgewiesen** — deshalb liegt der Startpfad auf <code>POST</code>. Der n8n-Webhook-Knoten<br />selbst darf auf <code>GET</code> stehen; die Aiopta-Route bleibt trotzdem <code>POST</code>, sonst<br />wäre das Addon nicht sandboxbar. Gepinnt in <code>tests/unit/trust.test.ts</code> gegen<br />die echte Core-Funktion.</p>\n<h2>MCP-Server (<code>n8n-tools</code>, read-only)</h2>\n<p><code>workflow_list</code>, <code>workflow_get</code>, <code>execution_list</code>, <code>execution_get</code>.</p>\n<p>Es gibt <strong>kein startendes Tool</strong> — und das ist eine Entscheidung, keine<br />Lücke: ein Agent, der bei unklarer Antwort noch einmal ruft, lässt den<br />Workflow ein zweites Mal laufen. Der startende Pfad läuft ausschließlich über<br />die API-Route mit eigener, höchster Trust-Stufe.</p>\n<p><code>includeData</code> der Ausführungen wird ebenfalls nicht geliefert: diese Daten<br />tragen die Nutzlast jedes durchlaufenen Knotens.</p>\n<p>Authentifizierung ausschließlich über die core-injizierten Slots<br /><code>AIOPTA_CONFIG_BASEURL</code> / <code>AIOPTA_CREDENTIAL_APIKEY</code> — nie als<br />Tool-Argument. Der Server bringt <strong>keine</strong> vorbelegte Instanz mit; fehlt ein<br />Slot, sagt das Tool das und ruft nichts ab.</p>\n<h2>Einrichtung</h2>\n<p>1. In n8n unter <strong>Settings → API</strong> einen Public-API-Schlüssel erzeugen.<br />2. In Aiopta unter <strong>Einstellungen → Addons → n8n</strong> eintragen:<br />   - <strong>n8n-Basis-URL</strong> — die Wurzel der Instanz, z. B.<br />     <code>https://n8n.example.com</code> (ohne <code>/api/v1</code>, ohne Pfad),<br />   - <strong>n8n-Public-API-Schlüssel</strong>.<br />3. <code>/addons/n8n</code> öffnen.</p>\n<p>Ohne diese beiden Werte antwortet jede Route mit <code>503 n8n_not_configured</code> —<br />und ruft nichts ab.</p>\n<h2>Bekannte Lücke</h2>\n<p><code>attestation.json</code> fehlt. Signiert wird ausschließlich im Jenkins-Auftrag<br /><code>ci/Jenkinsfile.addonsign</code> mit der Credential <code>aiopta-addon-signing-key</code>; der<br />Schlüssel liegt weder im Repo noch bei einem Entwickler. Bis dahin deckt ein<br />ticketgebundener Eintrag in <code>BEKANNTE_LUECKEN</code><br />(<code>scripts/check-addon-adoption.cjs</code>, <code>addons#1222</code>) <strong>eng</strong> nur den Befund<br /><code>attestation.json missing</code>.</p>\n<p>Danach muss der Eintrag <code>n8n</code> aus <code>BEKANNTE_LUECKEN</code> wieder <strong>entfernt</strong><br />werden — eine Ausnahme, die nichts mehr deckt, macht den Lauf rot<br />(<code>veralteteAusnahmen</code>).</p>",
          "byteSize": 10298
        }
      }
    },
    {
      "id": "network",
      "uuid": "4079e196-7af4-47b8-9665-5ec933516698",
      "name": "Network (Pi-hole / AdGuard Home DNS & Adblock)",
      "version": "1.0.5",
      "description": "Per-User BYOK DNS/Adblock-Integration: verbindet Pi-hole oder AdGuard Home mit eigenen API-Token/Passwörtern (ein Connection-Set pro Nutzer). DNS-Query-Statistiken, Block-Raten/Top-Blocked-Domains, Allow/Deny-Listen-Verwaltung und optionaler UniFi/Router-Client-Status. Alles per-user über scopedStore isoliert (ADR-054). Kanonisches Homelab-Netzwerk-Addon (ADR-008 / #494, Epic #490): VPN (WireGuard/Tailscale) und Reverse-Proxy (NPM/Traefik/Caddy) sind als Domänen-Connectoren desselben geteilten BYOK-Host-Modells hier konsolidiert.",
      "author": "Aiopta",
      "icon": "🛡️",
      "capabilities": [
        "dns-stats",
        "blocked-metrics",
        "allow-deny-lists",
        "client-status",
        "connection-config",
        "homelab-hub",
        "vpn-connector",
        "reverse-proxy-connector"
      ],
      "tags": [],
      "download": "/artifacts/network-1.0.5.zip",
      "sha256": "4069c349b5e18dc3d00c78bdca815baf748eda7bb3761d7426541b92db02dd5b",
      "size": 141481,
      "page": {
        "id": "network-page",
        "path": "/addons/network",
        "name": "Network",
        "icon": "🛡️",
        "section": "Tools",
        "component": "@addons/network/src/components/NetworkPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/network/connections",
        "/api/v1/addons/network/connections/:id",
        "/api/v1/addons/network/config",
        "/api/v1/addons/network/stats",
        "/api/v1/addons/network/lists",
        "/api/v1/addons/network/lists/:id",
        "/api/v1/addons/network/clients"
      ],
      "skills": [
        {
          "name": "network-read",
          "displayName": "Network: DNS-Stats & Block-Metriken lesen",
          "description": "Liest DNS-Query-Statistiken, Block-Raten und Top-Blocked-Domains aus der per-User BYOK-Verbindung (Pi-hole/AdGuard). Reiner Lesezugriff — ändert nichts.",
          "permissions": [
            "addons/network/stats",
            "addons/network/connections",
            "addons/network/clients"
          ],
          "endpoint": "/api/v1/addons/network/stats",
          "scripts": []
        },
        {
          "name": "network-lists",
          "displayName": "Network: Allow/Deny-Listen verwalten",
          "description": "Legt Allow- oder Deny-Listen-Einträge für den Nutzer an oder entfernt diese (per-User gebucht). Diese werden gegen die verbundene Pi-hole/AdGuard-Instanz synchronisiert.",
          "permissions": [
            "addons/network/lists",
            "addons/network/connections"
          ],
          "endpoint": "/api/v1/addons/network/lists",
          "scripts": []
        },
        {
          "name": "network-manage",
          "displayName": "Network: Verbindungen (BYOK) verwalten",
          "description": "Legt Pi-hole/AdGuard-Verbindungen mit Host + API-Token/Passwort für den Nutzer an oder entfernt diese. Die Secrets werden niemals zurückgegeben (maskiert).",
          "permissions": [
            "addons/network/connections",
            "addons/network/config"
          ],
          "endpoint": "/api/v1/addons/network/connections",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": null
    },
    {
      "id": "notifications",
      "uuid": "2f00f42e-406c-41c2-90cc-d126c3a2a92f",
      "name": "Notifications",
      "version": "1.0.6",
      "description": "Universal notifications: agent-backend dispatch (OpenClaw, Pi Agent, Hermes) + external channels (Discord, Telegram, Slack, WhatsApp, Signal, Email). Reads from Hermes gateway config when available.",
      "author": "Aiopta",
      "icon": "🔔",
      "capabilities": [
        "notifications",
        "discord",
        "telegram",
        "slack",
        "whatsapp",
        "signal",
        "email",
        "push",
        "webhook"
      ],
      "tags": [],
      "download": "/artifacts/notifications-1.0.6.zip",
      "sha256": "888e9a8e14de83bfd0285b92ecad9d37803f83c830ad8ecb14c0b32937b75867",
      "size": 360659,
      "page": {
        "path": "/addons/notifications",
        "name": "Notifications",
        "icon": "🔔",
        "section": "Settings",
        "component": "@addons/notifications/src/components/ExternalNotificationsSettings"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/notifications/send",
        "/api/v1/addons/notifications/config",
        "/api/v1/addons/notifications/test",
        "/api/v1/addons/notifications/channels",
        "/api/v1/addons/notifications/history",
        "/api/v1/addons/notifications/status",
        "/api/v1/addons/notifications/platforms",
        "/api/v1/addons/notifications/platforms/{id}",
        "/api/v1/addons/notifications/platforms/{id}/test",
        "/api/v1/addons/notifications/external-config",
        "/api/v1/addons/notifications/external-channels",
        "/api/v1/addons/notifications/external-test",
        "/api/v1/addons/notifications/ntfy/settings",
        "/api/v1/addons/notifications/ntfy/test",
        "/api/v1/addons/notifications/push/health",
        "/api/v1/addons/notifications/push/vapid"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>🔔 Notification Dispatch</h1>\n<p>Universal notification dispatch to AI agent backends</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>notifications</code></li><li><strong>Version:</strong> 1.0.0</li></ul>\n<h2>Einrichtung</h2>\n<p>Dieses Addon benötigt folgende Secrets/Provider-Konfiguration (Settings → Credentials bzw. Provider-Store, <strong>nie</strong> in addon.json):</p>\n<ul><li><code>HERMES_API_KEY</code></li><li><code>OPENCLAW_API_KEY</code></li><li><code>PI_AGENT_API_KEY</code></li></ul>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET | <code>/api/v1/addons/notifications/channels</code> |<br />| GET, POST | <code>/api/v1/addons/notifications/config</code> |<br />| GET | <code>/api/v1/addons/notifications/history</code> |<br />| POST | <code>/api/v1/addons/notifications/send</code> |<br />| POST | <code>/api/v1/addons/notifications/test</code> |<br />| GET, PUT | <code>/api/v1/addons/notifications/ntfy/settings</code> |<br />| POST | <code>/api/v1/addons/notifications/ntfy/test</code> |</p>\n<h2>Push via self-hosted ntfy / UnifiedPush (#129 / INBOX-52)</h2>\n<p>Der <strong>bevorzugte self-hosted Push-Pfad</strong> (statt FCM). Push geht per HTTP-POST an ein <strong>pro-User-Topic</strong>, das <strong>serverseitig aus der <code>userId</code></strong> abgeleitet wird (<code>&lt;prefix&gt;-sha256(userId)[:24]</code>) — <strong>nie ein Client-Wert</strong>. Ein Push für User A kann niemals an B&#39;s Topic adressiert werden. Der Mobile-Wrapper abonniert via UnifiedPush dasselbe Topic — ein POST erreicht Web <strong>und</strong> Android ohne Firebase. FCM bleibt optional/deprecated, aber funktionsfähig.</p>\n<p><strong>Konfiguration (instance-global, Aiopta addon-config):</strong></p>\n<p>| Key | Zweck | Default |<br />|---|---|---|<br />| <code>NTFY_SERVER_URL</code> | self-hosted ntfy-Server (oder ntfy.sh). <strong>Owner-Entscheidung</strong> (OPEN-DECISIONS Block H). | <code>https://ntfy.sh</code> |<br />| <code>NTFY_AUTH_TOKEN</code> | optionaler Bearer-Token für geschützten Server (Secret). | — |<br />| <code>NTFY_TOPIC_PREFIX</code> | Namespace-Prefix für pro-User-Topics. | <code>mc</code> |</p>\n<p>Solange kein Server gesetzt ist, <strong>degradiert</strong> der Kanal graziös (<code>ntfy not configured</code>, kein Crash).</p>\n<p><strong>Per-User:</strong> Jeder User aktiviert ntfy in seinen Notification-Settings (<code>GET/PUT …/ntfy/settings</code>, opt-in + Min-Priorität); der State liegt user-scoped (<code>scopedStore</code>, ADR-054). <code>POST …/ntfy/test</code> sendet einen Test-Push <strong>nur</strong> an das eigene Topic des Callers.</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>ntfy: Server-URL + ob FCM ganz fallengelassen wird = <strong>Owner-Entscheidungen</strong> (OPEN-DECISIONS Block H, #2); Mechanismus gebaut, Aktivierung/Server-Wahl wartet auf Owner.</li></ul>",
          "byteSize": 2257
        }
      }
    },
    {
      "id": "octoprint",
      "uuid": "2f1044e4-5bd6-44f5-9eaf-98f6c91186dc",
      "name": "OctoPrint (3D-Druck: Status/Aufträge/Temp/Webcam/G-Code-Queue/Multi)",
      "version": "1.0.4",
      "description": "Per-User OctoPrint-Anbindung: BYOK URL+API-Key pro Drucker, Live-Status/Jobs/Temperatur/Webcam, G-Code-Queue und Multi-Drucker. Brückt lesend zum openscad-generator-Addon (generierte Modelle in die Queue), ohne openscad-generator zu verändern. Lokale SQLite ist die Quelle der Wahrheit (ADR-054).",
      "author": "Aiopta",
      "icon": "🖨️",
      "capabilities": [
        "octoprint",
        "printers",
        "jobs",
        "temperature",
        "webcam",
        "queue",
        "gcode",
        "3d-printing"
      ],
      "tags": [],
      "download": "/artifacts/octoprint-1.0.4.zip",
      "sha256": "94dc42d67c2bc33d111ff2e8f64a55d5d4c313e5f0dde66ee86e1d2d21ecc5be",
      "size": 120226,
      "page": {
        "id": "octoprint-page",
        "path": "/addons/octoprint",
        "name": "OctoPrint",
        "icon": "🖨️",
        "section": "Tools",
        "component": "@addons/octoprint/src/components/OctoprintPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/octoprint/printers",
        "/api/v1/addons/octoprint/printers/:id",
        "/api/v1/addons/octoprint/jobs",
        "/api/v1/addons/octoprint/temperature",
        "/api/v1/addons/octoprint/webcam",
        "/api/v1/addons/octoprint/queue",
        "/api/v1/addons/octoprint/queue/:id",
        "/api/v1/addons/octoprint/bridge/openscad"
      ],
      "skills": [
        {
          "name": "octoprint-read",
          "displayName": "OctoPrint: Status, Jobs, Temperatur & Webcam lesen",
          "description": "Listet die konfigurierten Drucker des Nutzers und liest Live-Status, Druckaufträge, Temperaturen und Webcam-URLs von seiner OctoPrint-Instanz (BYOK URL+API-Key). Reiner Lesezugriff — legt nichts an.",
          "permissions": [
            "addons/octoprint/printers",
            "addons/octoprint/jobs",
            "addons/octoprint/temperature",
            "addons/octoprint/webcam",
            "addons/octoprint/queue"
          ],
          "endpoint": "/api/v1/addons/octoprint/printers",
          "scripts": []
        },
        {
          "name": "octoprint-manage",
          "displayName": "OctoPrint: Drucker & G-Code-Queue verwalten",
          "description": "Legt BYOK Drucker-Verbindungen an oder entfernt diese und verwaltet die G-Code-Queue (stagen, hochladen, Status setzen) per-User gebucht.",
          "permissions": [
            "addons/octoprint/printers",
            "addons/octoprint/queue"
          ],
          "endpoint": "/api/v1/addons/octoprint/queue",
          "scripts": []
        },
        {
          "name": "octoprint-bridge-openscad",
          "displayName": "OctoPrint: openscad-Modell in die Queue schreiben",
          "description": "Schreibt ein generiertes Modell aus dem openscad-generator-Addon lesend in die G-Code-Queue eines Druckers (ohne openscad-generator zu verändern).",
          "permissions": [
            "addons/octoprint/queue",
            "addons/octoprint/bridge/openscad"
          ],
          "endpoint": "/api/v1/addons/octoprint/bridge/openscad",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>🖨️ OctoPrint (3D-Druck: Status/Aufträge/Temp/Webcam/G-Code-Queue/Multi)</h1>\n<p>Per-User OctoPrint-Anbindung: BYOK URL+API-Key pro Drucker, Live-Status/Jobs/<br />Temperatur/Webcam, G-Code-Queue und Multi-Drucker. Brückt lesend zum<br />openscad-generator-Addon (generierte Modelle in die Queue), ohne<br />openscad-generator zu verändern. Lokale SQLite ist die Quelle der Wahrheit<br />(ADR-054).</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>octoprint</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> octoprint, printers, jobs, temperature, webcam, queue, gcode, 3d-printing</li><li><strong>UI-Seite:</strong> <code>/addons/octoprint</code></li></ul>\n<h2>Einrichtung</h2>\n<p>BYOK: pro Drucker werden eine OctoPrint-Basis-URL und ein Application API-Key<br /> benötigt (im Frontend oder via POST <code>/printers</code>). Die Credentials liegen<br /> per-User scoped in der lokalen SQLite (ADR-054) — sie werden nie hardcodiert<br /> und nur für den jeweiligen Caller gelesen. Live-Daten (Status/Jobs/Temp/Webcam)<br /> werden on-demand von der Instanz des Nutzers geholt und nicht persistiert.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET, POST | <code>/api/v1/addons/octoprint/printers</code> |<br />| GET, PATCH, DELETE | <code>/api/v1/addons/octoprint/printers/[id]</code> |<br />| GET | <code>/api/v1/addons/octoprint/jobs?printerId=</code> |<br />| GET | <code>/api/v1/addons/octoprint/temperature?printerId=</code> |<br />| GET | <code>/api/v1/addons/octoprint/webcam?printerId=</code> |<br />| GET, POST | <code>/api/v1/addons/octoprint/queue</code> |<br />| PATCH, DELETE | <code>/api/v1/addons/octoprint/queue/[id]</code> |<br />| POST | <code>/api/v1/addons/octoprint/bridge/openscad</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>OctoPrint-Status/Jobs/Temperatur sind Live-Daten der Nutzer-Instanz; nur die Drucker-Konfiguration und die G-Code-Queue sind lokal gespeichert.</li><li>Die openscad-Brücke schreibt generierte Modelle lesend in die Queue; openscad-generator wird nie modifiziert.</li></ul>\n<blockquote><p>README manuell gepflegt (addon.json + Routen als Quelle).</p></blockquote>",
          "byteSize": 1824
        }
      }
    },
    {
      "id": "octoprint-proxy",
      "uuid": "25f29dbd-f4f1-4b2b-82d6-907de0ccb930",
      "name": "OctoPrint Sync Proxy",
      "version": "2.0.1",
      "description": "Kapselt den extern laufenden OctoPrint Sync Proxy (Python-FastAPI, LAN-intern) in Aiopta: natives Dashboard (Status-Karten OctoPrint/Git/USB/G-Code-Kette, Job-Liste mit Fortschritt, Retry- und USB-Sync-Aktionen), API-Routen mit zentraler Auth und HMAC-Grant-Minting, Versionshistorie und Datei-Download je Job, OctoPrint-kompatible API für OrcaSlicer (Upload/Listing/Download via Aiopta Agent-API-Key) sowie ein MCP-Server (proxy-tools). Jeder Job trägt zusätzlich den Stand der G-Code-Nachbearbeitungskette (unverändert/geändert/aus) samt Kurzbericht.",
      "author": "Aiopta",
      "icon": "🖨️",
      "capabilities": [
        "octoprint",
        "sync-proxy",
        "jobs",
        "usb-sync",
        "orcaslicer",
        "3d-printing",
        "gcode-postprocessing",
        "git-history",
        "file-download"
      ],
      "tags": [],
      "download": "/artifacts/octoprint-proxy-2.0.1.zip",
      "sha256": "73e3de8524132e6632eb1d4fe9db319f93fc1db6d3cf5bd628adf5aaacd6678d",
      "size": 205519,
      "page": {
        "id": "octoprint-proxy",
        "path": "/addons/octoprint-proxy",
        "name": "OctoPrint Sync",
        "icon": "🖨️",
        "section": "Tools",
        "component": "@addons/octoprint-proxy/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/octoprint-proxy/status",
        "/api/v1/addons/octoprint-proxy/jobs",
        "/api/v1/addons/octoprint-proxy/jobs/{id}",
        "/api/v1/addons/octoprint-proxy/jobs/{id}/versions",
        "/api/v1/addons/octoprint-proxy/jobs/{id}/download",
        "/api/v1/addons/octoprint-proxy/jobs/{id}/retry",
        "/api/v1/addons/octoprint-proxy/kette/abgelehnt/quittieren",
        "/api/v1/addons/octoprint-proxy/usb/sync",
        "/api/v1/addons/octoprint-proxy/api/version",
        "/api/v1/addons/octoprint-proxy/api/files/local",
        "/api/v1/addons/octoprint-proxy/api/files/local/{name}",
        "/api/v1/addons/octoprint-proxy/downloads/files/local/{name}"
      ],
      "skills": [
        {
          "name": "octoprint-proxy-read",
          "displayName": "OctoPrint Sync Proxy: Status & Jobs lesen",
          "description": "Liest Service-Status (OctoPrint/Git/USB/G-Code-Kette), die Sync-Job-Liste inklusive Kettenstand, das Job-Detail mit vollem Kettenbericht und die Git-Versionshistorie einer Datei über die Addon-API. Reiner Lesezugriff.",
          "permissions": [
            "addons/octoprint-proxy/read"
          ],
          "endpoint": "/api/v1/addons/octoprint-proxy/status",
          "scripts": []
        },
        {
          "name": "octoprint-proxy-write",
          "displayName": "OctoPrint Sync Proxy: Retry, USB-Sync & Ablehnungen quittieren",
          "description": "Setzt fehlgeschlagene Sync-Targets eines Jobs zurück (Retry), startet manuelle USB-Syncs und quittiert Uploads, die die G-Code-Kette abgelehnt hat, über die Addon-API.",
          "permissions": [
            "addons/octoprint-proxy/read",
            "addons/octoprint-proxy/write"
          ],
          "endpoint": "/api/v1/addons/octoprint-proxy/usb/sync",
          "scripts": []
        }
      ],
      "mcpServers": [
        {
          "id": "proxy-tools",
          "displayName": "OctoPrint Sync Proxy tools",
          "description": "MCP-Tools für den externen OctoPrint Sync Proxy: Status (OctoPrint/Git/USB/G-Code-Kette) und Job-Liste lesen, Job-Details inklusive vollem Kettenbericht abrufen und als einziges schreibendes Tool einen Job-Retry auslösen. Authentifiziert sich per HMAC-Grant (grantSecret) oder Fallback per proxyApiKey; beides wird core-seitig aus den deklarierten Settings-Slots aufgelöst.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "proxy-tools/server.mjs",
          "permissions": [
            "addons/octoprint-proxy/job/retry",
            "addons/octoprint-proxy/read"
          ],
          "toolPolicy": {
            "mode": "write",
            "allowDestructive": false
          },
          "configRefs": [
            "baseUrl"
          ],
          "credentialRefs": [
            "grantSecret",
            "proxyApiKey"
          ],
          "archiveEntry": "octoprint-proxy/mcp/servers/proxy-tools/server.mjs",
          "sha256": "ee6141dccac933d74fb29fc8db970c78ea60bffbb3db848c749a849a217589c2",
          "size": 14628,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>OctoPrint Sync Proxy (octoprint-proxy)</h1>\n<p>Kapselt den <strong>extern laufenden OctoPrint Sync Proxy</strong> (Python/FastAPI,<br />LAN-intern) in Aiopta — ohne den Dienst selbst zu verändern und ohne ihn<br />nach außen zu exponieren.</p>\n<blockquote><p><strong>Keine Vorgabewerte für fremde Wirte.</strong> Die Basis-URL des Proxy ist eine</p><p>Pflichteinstellung <strong>ohne</strong> <code>default</code>. Solange sie leer ist, ruft das</p><p>Addon nichts auf, sondern antwortet mit „baseUrl ist nicht konfiguriert&quot;</p><p>(Dashboard-Routen 500, OrcaSlicer-Endpunkte 503, MCP-Tools</p><p>„nicht konfiguriert&quot;). Adressen in dieser Datei sind <strong>Beispiele</strong>.</p></blockquote>\n<h2>Architektur</h2>\n<pre><code>Browser/OrcaSlicer ──HTTPS──▶ Aiopta (dieses Addon) ──LAN──▶ Sync Proxy (baseUrl, z.B. proxy.lan:8000)\n                              ├─ Dashboard (native DS-Seite)      /status, /api/proxy/*\n                              ├─ API-Routen (userRoute + Grant)   /api/proxy/*\n                              ├─ Orca-API (X-Api-Key, statisch)   /api/version, /api/files/local*,\n                              │                                   /downloads/files/local/*\n                              └─ MCP-Server (proxy-tools)         /status, /api/proxy/jobs*</code></pre>\n<p>- <strong>Natives Dashboard</strong> (<code>src/page.tsx</code>, <code>ui.mode: &quot;aiopta&quot;</code>): Status-Karten<br />  (OctoPrint/Git/USB <strong>und G-Code-Kette</strong>), Job-Liste (neueste oben) mit<br />  Fortschrittsbalken, Retry- und USB-Sync-Aktionen. Ausschließlich<br />  <code>@aiopta/ui</code>-Komponenten. Der Browser kontaktiert den Proxy nie direkt.<br />- <strong>G-Code-Nachbearbeitungskette</strong>: der Proxy lässt seit dem Ausbau der<br />  Kette jeden Upload durch eine Reihe von Schritten laufen (u. a. Kürzen zu<br />  langer <code>M486</code>-Objektnamen). <code>/status</code> führt sie als <strong>vierte Station</strong><br />  neben OctoPrint/Git/USB (aktiv, Schritte, Konfigurationsfehler, Zähler<br />  geändert/unverändert, abgelehnte Uploads); jeder Job trägt seinen<br />  Kettenstand, das Job-Detail zusätzlich den vollen Bericht. Das Addon<br />  reicht beides unverändert durch — es filtert die Felder nicht.<br />- <strong>API-Routen</strong> (<code>src/app/api/v1/addons/octoprint-proxy/*</code>): zentrale Auth<br />  via <code>userRoute</code> (Session-Cookie oder Bearer/X-API-Key Agent-Key). Danach<br />  mintet das Addon einen <strong>HMAC-Grant</strong> (`X-Aiopta-Grant:<br />  v1.&lt;base64url(payload)&gt;.&lt;base64url(hmac)&gt;<code>; Payload </code>{aud, caps, exp,<br />  iat, sub}<code>; HMAC-SHA256 mit </code>grantSecret`; TTL 110 s) — Implementierung in<br />  <code>src/lib/grant.ts</code>, byte-kompatibel zur Proxy-Referenz <code>app/grant.py</code>.<br />  Fallback: statischer <code>X-Api-Key</code> (<code>proxyApiKey</code>).<br />- <strong>OrcaSlicer</strong> nutzt den Proxy nicht mehr direkt: als „OctoPrint&quot;-Host<br />  wird <code>https://&lt;aiopta&gt;/api/v1/addons/octoprint-proxy</code> eingetragen (Orca<br />  hängt <code>/api/...</code> an). Die Endpunkte (<code>api/version</code>, <code>api/files/local</code>,<br />  <code>api/files/local/[name]</code>, <code>downloads/files/local/[name]</code> — bewusst<br />  <strong>statische Routen</strong>, siehe<br />  Runtime-Kompatibilität) validieren den<br />  Orca-<code>X-Api-Key</code> als <strong>Aiopta Agent-/User-API-Key</strong> (<code>mc_…</code>, via<br />  Core-<code>getAuthUser</code>) und reichen den Request mit <strong>HMAC-Grant</strong> weiter —<br />  der Grant ist DER Auth-Weg auch auf den OctoPrint-Endpunkten<br />  (<code>/api/version</code>, <code>/api/files/*</code>; write-caps bei POST). <code>proxyApiKey</code><br />  (<code>X-Api-Key</code>) ist nur noch optionaler Legacy-Fallback für Installationen<br />  ohne grantSecret. Uploads gestreamt (<code>duplex: &quot;half&quot;</code>, kein Vollpuffer),<br />  Responses 1:1 im OctoPrint-JSON-Format. Die gemeinsame<br />  Auth/Mapping/Forwarding-Logik liegt zentral in <code>src/lib/orca-api.ts</code>.<br />- <strong>MCP-Server</strong> (<code>mcp/servers/proxy-tools/server.mjs</code>, Node-STDIO): vier<br />  Tools, davon drei read-only und eines mutierend.</p>\n<h2>Settings (settingsSchema → WebUI-Formular)</h2>\n<p>| Key | Typ | Default | Beschreibung |<br />| --- | --- | --- | --- |<br />| <code>baseUrl</code> | text | <strong>keiner (Pflicht)</strong> | Basis-URL der eigenen Proxy-Instanz im LAN, Form <code>http://&lt;host&gt;:&lt;port&gt;</code> (Beispiel: <code>http://proxy.lan:8000</code>). Leer ⇒ kein Netzruf, sondern „nicht konfiguriert&quot;. |<br />| <code>grantSecret</code> | password (secret) | — | Geteiltes HMAC-Secret; muss mit <code>AIOPTA_GRANT_SECRET</code> des Proxy übereinstimmen. |<br />| <code>proxyApiKey</code> | password (secret) | — | Statischer Proxy-Key — <strong>optional</strong>, nur Legacy-Fallback für Installationen ohne <code>grantSecret</code>. |<br />| <code>pollIntervalSeconds</code> | number | <code>10</code> (2–300) | Aktualisierungs-Intervall des Dashboards. |<br />| <code>allowUsbMirrorDelete</code> | boolean (adminOnly) | <code>false</code> | Erlaubt USB-Sync mit <code>delete=true</code> (zusätzlich <code>confirm=DELETE</code> nötig). |</p>\n<h2>Permissions und Capabilities</h2>\n<p><code>capabilityDeclarations</code> im Manifest ist der Selbstausweis des Addons nach<br />dem Addon-Adoption-Standard: je<br />Eintrag die <strong>dotted</strong> Capability, ihre <strong>Slash-Form</strong> (<code>permission</code>) und<br />die <strong>Tier-Stufe</strong>. Die Slash-Form ist immer exakt<br /><code>capability.split(&#39;.&#39;).join(&#39;/&#39;)</code> — <code>scripts/check-addon-adoption.cjs</code><br />rechnet das nach.</p>\n<p>| Capability (dotted) | Permission (slash) | Tier | Deckt |<br />| --- | --- | --- | --- |<br />| <code>addons.octoprint-proxy.read</code> | <code>addons/octoprint-proxy/read</code> | elevated | Status (inkl. Kette), Job-Liste, Job-Detail, Versionshistorie, Orca-Listing |<br />| <code>addons.octoprint-proxy.write</code> | <code>addons/octoprint-proxy/write</code> | elevated | Sammel-Permission der Schreib-Skill (Retry + USB-Sync) |<br />| <code>addons.octoprint-proxy.job.retry</code> | <code>addons/octoprint-proxy/job/retry</code> | elevated | <code>POST jobs/{id}/retry</code>, auch das einzige schreibende MCP-Tool |<br />| <code>addons.octoprint-proxy.kette.quittieren</code> | <code>addons/octoprint-proxy/kette/quittieren</code> | elevated | <code>POST kette/abgelehnt/quittieren</code> (räumt abgelehnte Uploads aus der Proxy-Liste) |<br />| <code>addons.octoprint-proxy.usb.sync</code> | <code>addons/octoprint-proxy/usb/sync</code> | elevated | <code>POST usb/sync</code> (mit <code>delete=true</code> nur über Admin-Setting + <code>confirm=DELETE</code>) |<br />| <code>addons.octoprint-proxy.file.download</code> | <code>addons/octoprint-proxy/file/download</code> | elevated | Job-Download (Queue oder Git-Fassung), Orca-Download |<br />| <code>addons.octoprint-proxy.file.upload</code> | <code>addons/octoprint-proxy/file/upload</code> | elevated | <code>POST api/files/local</code> (OrcaSlicer-Upload in die Druck-Queue) |</p>\n<p>Warum durchgehend <code>elevated</code> und nicht <code>standard</code>/<code>highest</code>: jeder Aufruf<br />steuert das <strong>vom Betreiber konfigurierte</strong> Backend (den LAN-Proxy) — das<br />ist nach der Tier-Rubrik gebundener Egress, also <code>elevated</code>. Kein Aufruf<br />führt fremden Code aus, gibt Credentials heraus oder löscht instanzweit;<br />das USB-Mirror-Delete räumt ausschließlich den <strong>addon-eigenen</strong> Spiegel<br />unter <code>/usb/OctoPrint</code> auf. Damit ist <code>highest</code> nicht gerechtfertigt.</p>\n<p>Die Durchsetzung für Agent-API-Keys erfolgt core-seitig (Agent-Scope-<br />Enforcement anhand der deklarierten Permissions); der Grant trägt die<br />Achse zusätzlich Richtung Proxy (<code>caps</code>: lesend <code>[&quot;read&quot;]</code>, mutierend<br /><code>[&quot;read&quot;,&quot;write&quot;]</code>).</p>\n<h3>Trust-Stufe (ADR-062)</h3>\n<p>Die Trust-/Sandbox-Stufe eines Addons ist <strong>kein Manifest-Feld</strong>. Sie wird<br />vom Betreiber pro Addon gesetzt (<code>PATCH /api/v1/admin/extensions</code> mit<br /><code>{ id, trust }</code>), im <code>extensions.settings</code>-Schlüssel <code>trust</code> abgelegt und<br />in <code>route-dispatch</code> durchgesetzt (<code>src/lib/access/addon-trust.ts</code> im Core).<br />Der Default ist <code>standard</code>, das Addon braucht dafür keine Zeile Code.</p>\n<p>Praktische Folge für dieses Addon: auf <code>restricted</code> werden alle<br />mutierenden Verben (<code>POST</code>) am Dispatch mit <strong>403</strong> abgewiesen. Retry,<br />USB-Sync <strong>und der OrcaSlicer-Upload</strong> fallen damit aus; lesend bleibt<br />alles erreichbar. Wer OrcaSlicer über dieses Addon drucken lassen will,<br />braucht also mindestens <code>standard</code>.</p>\n<h3>Selbstausweis (<code>attestation.json</code>) — offene Lücke</h3>\n<p>Der Adoption-Standard verlangt zusätzlich eine <strong>signierte</strong><br /><code>attestation.json</code>, deren <code>declares.capabilities</code> die<br /><code>capabilityDeclarations</code> spiegelt. Dieses Addon hat sie <strong>nicht</strong>: signiert<br />wird ausschließlich im Jenkins-Auftrag <code>ci/Jenkinsfile.addonsign</code> mit der<br />Credential <code>aiopta-addon-signing-key</code>, die weder im Repo noch bei einem<br />Entwickler liegt (fail-closed, kein Ersatzweg). Die Lücke ist in<br /><code>scripts/check-addon-adoption.cjs</code> unter <strong>addons#1002</strong> ticketgebunden<br />eingetragen und deckt ausschließlich den Befund<br />„<code>attestation.json missing</code>&quot; — jeder andere Vertragsverstoß macht den<br />Flottenlauf weiterhin rot.</p>\n<h2>API-Routen</h2>\n<p>| Route | Methode | Proxy-Ziel |<br />| --- | --- | --- |<br />| <code>.../status</code> | GET | <code>GET /status</code> (+ <code>pollIntervalSeconds</code> im Envelope) |<br />| <code>.../jobs</code> | GET | <code>GET /api/proxy/jobs</code> |<br />| <code>.../jobs/{id}</code> | GET | <code>GET /api/proxy/jobs/{id}</code> (404 passthrough) |<br />| <code>.../jobs/{id}/versions</code> | GET | <code>GET /api/proxy/jobs/{id}/versions</code> (Git-Historie, 404 passthrough) |<br />| <code>.../jobs/{id}/download</code> | GET | <code>GET /api/proxy/jobs/{id}/download[?commit=&lt;sha&gt;]</code> (binär gestreamt; 400/404/409 passthrough) |<br />| <code>.../jobs/{id}/retry</code> | POST | <code>POST /api/proxy/jobs/{id}/retry</code> |<br />| <code>.../kette/abgelehnt/quittieren</code> | POST | <code>POST /api/proxy/kette/abgelehnt/quittieren</code> (400/409/422 passthrough) |<br />| <code>.../usb/sync</code> | POST | <code>POST /api/proxy/usb/sync</code> (Delete-Guard s. Settings) |<br />| <code>.../api/version</code> | GET | <code>GET /api/version</code> (OrcaSlicer) |<br />| <code>.../api/files/local</code> | GET | <code>GET /api/files/local</code> (Query durchgereicht) |<br />| <code>.../api/files/local/{name}</code> | GET | <code>GET /api/files/local/&lt;name&gt;</code> (einstufige Namen) |<br />| <code>.../api/files/local</code> | POST | <code>POST /api/files/local</code> (Multipart-Upload, gestreamt) |<br />| <code>.../downloads/files/local/{name}</code> | GET | <code>GET /downloads/files/local/&lt;name&gt;</code> (Ziel von <code>refs.download</code>) |</p>\n<p><code>jobs/{id}/download</code> und <code>downloads/files/local/{name}</code> reichen den Body<br />als Stream durch (kein Vollpuffer, kein <code>apiSuccess</code>-Envelope) und nehmen<br /><code>content-disposition</code> mit — sonst speicherte der Browser eine Datei mit<br />Unicode-Namen unter dem letzten Pfadsegment.</p>\n<h3>Was das Addon bewusst NICHT bedient</h3>\n<p>Nicht jede Route des Proxy gehört unter die Addon-Basis-URL. Gemessen am<br /><strong>2026-08-10</strong> gegen Proxy-Commit <strong>e82e861</strong>: der Proxy führt <strong>19</strong><br />Methode-Pfad-Paare (16 im OpenAPI-Schema, drei ohne — <code>/</code>, <code>/dashboard</code>,<br /><code>/static/*</code>). Die zwölf Manifest-Endpunkte oben decken <strong>13</strong> davon ab<br />(<code>api/files/local</code> trägt GET und POST); die restlichen sechs bleiben<br />absichtlich draußen — damit das Manifest nichts verspricht, was es nicht<br />hält, und nichts verschweigt, was es könnte:</p>\n<p>| Proxy-Route | Warum nicht |<br />| --- | --- |<br />| <code>GET /healthz</code>, <code>GET /readyz</code>, <code>GET /metrics</code> | Betriebs-Endpunkte des Dienstes. Aiopta hat dafür sein eigenes Modell (<code>container.healthPath</code>, Monitoring-Addon); eine zweite, ungeschützte Kopie hinter der Addon-Auth wäre eine Fehlanzeige statt einer Messung. |<br />| <code>GET /</code>, <code>GET /dashboard</code>, <code>/static/*</code> | Die eigene Oberfläche des Proxy. In Aiopta ist <code>src/page.tsx</code> die Seite; zwei Oberflächen für dieselben Daten würden auseinanderlaufen. |</p>\n<h2>Runtime-Kompatibilität (runtime-installiert vs. gebündelt)</h2>\n<p>Die OrcaSlicer-Endpunkte sind bewusst <strong>statische Routen</strong> (plus einstufige<br /><code>[name]</code>-Dynamik), keine <code>[[...path]]</code>-Catch-all: der Core-Runtime-Router<br />(jiti, runtime-installierte ZIPs) resolvt dynamische Verzeichnisse nur mit<br />einem Segment pro Ebene — eine mehrstufige Catch-all liefert dort 404<br />(„No handler found&quot;). Das ist ein bekannter Core-Bug, verfolgt als<br /><a href=\"https://git.langkau.info/Aiopta/aiopta-core/issues/1355\" rel=\"noopener noreferrer\">Aiopta/aiopta-core#1355</a>;<br />das Addon umgeht ihn konventionsnah statt auf den Fix zu warten.</p>\n<p>Konsequenzen:</p>\n<p>- <code>api/version</code>, <code>api/files/local</code> (GET+POST) und<br />  <code>api/files/local/[name]</code> (GET) funktionieren in BEIDEN Welten<br />  (runtime-installiertes ZIP und gebündelter Next.js-Weg). Params kommen<br />  runtime-seitig als Record mit Bracket-Key — <code>routeParam</code> in<br />  <code>src/lib/orca-api.ts</code> akzeptiert beide Formen.<br />- <strong>Verschachtelte Datei-Detail-Pfade</strong> (<code>files/local/&lt;ordner&gt;/&lt;datei&gt;</code>)<br />  sind runtime-seitig bis zum Core-Fix #1355 nicht erreichbar (404);<br />  einstufige Namen decken den OrcaSlicer-Hauptweg ab. Der gebündelte Weg<br />  (Zielweg nach Core-Repair<br />  <a href=\"https://git.langkau.info/Aiopta/aiopta-core/issues/1356\" rel=\"noopener noreferrer\">Aiopta/aiopta-core#1356</a>)<br />  kann tiefe Pfade nachziehen, sobald der Router sie in beiden Welten<br />  kann.</p>\n<h2>MCP-Tools (<code>proxy-tools</code>)</h2>\n<p>| Tool | Wirkung |<br />| --- | --- |<br />| <code>proxy_status</code> | <code>GET /status</code> — vier Teilstati inkl. G-Code-Kette (read-only) |<br />| <code>proxy_list_jobs</code> | <code>GET /api/proxy/jobs</code> — inkl. Kettenstand je Job (read-only) |<br />| <code>proxy_job_detail</code> | <code>GET /api/proxy/jobs/{id}</code> — inkl. vollem Kettenbericht (read-only) |<br />| <code>proxy_retry_job</code> | <strong>MUTIEREND</strong>: <code>POST /api/proxy/jobs/{id}/retry</code> — einziges schreibendes Tool |</p>\n<p>Der Server minted den Grant selbst (gleiche Implementierung wie<br /><code>src/lib/grant.ts</code>). Die deklarierten Slots (<code>configRefs: [&quot;baseUrl&quot;]</code>,<br /><code>credentialRefs: [&quot;grantSecret&quot;, &quot;proxyApiKey&quot;]</code>) werden core-seitig<br />aufgelöst und als <code>AIOPTA_CONFIG_BASEURL</code> /<br /><code>AIOPTA_CREDENTIAL_GRANTSECRET</code> / <code>AIOPTA_CREDENTIAL_PROXYAPIKEY</code><br />injiziert. Ohne aufgelöste Credentials schlägt jeder Tool-Call sauber<br />fehl („nicht konfiguriert&quot;) — Handshake und <code>tools/list</code> funktionieren<br />immer.</p>\n<h2>Betrieb</h2>\n<p>- Der Sync Proxy läuft im eigenen LAN; seine Adresse trägt der Betreiber<br />  in <code>baseUrl</code> ein (kein öffentlicher Zugriff nötig/möglich). Es gibt<br />  <strong>keinen Vorgabewert</strong> — ohne Eintrag arbeitet das Addon nicht und sagt<br />  das auch, statt eine Adresse zu raten.<br />- <code>AIOPTA_GRANT_SECRET</code> auf der Proxy-Seite == <code>grantSecret</code> hier — der<br />  Grant wird auf <code>/api/proxy/*</code> UND den OctoPrint-Endpunkten (<code>/api/*</code>)<br />  akzeptiert und ist der empfohlene Weg.<br />- <code>PROXY_API_KEY</code> / <code>proxyApiKey</code>: optionaler Legacy-Fallback, wenn kein<br />  <code>grantSecret</code> gesetzt ist. Ohne beides antwortet das Addon mit<br />  „grantSecret nicht konfiguriert&quot;.<br />- OrcaSlicer: Host <code>https://&lt;aiopta&gt;/api/v1/addons/octoprint-proxy</code>,<br />  API-Key = Aiopta Agent-API-Key (<code>mc_…</code>) mit Scope<br />  <code>addons/octoprint-proxy/*</code>.</p>\n<h2>Addon-Abhängigkeiten: keine (geprüft 2026-08-10)</h2>\n<p>Das Manifest deklariert <strong>weder <code>addonDependencies</code> noch <code>requires</code></strong> — und<br />das ist eine Entscheidung, keine Auslassung. Eine <code>required</code>-Kante auf ein<br />Addon, das auf der Instanz nicht installiert oder deaktiviert ist, lässt<br />Installation und Aktivieren nach ADR-047 mit <strong>409</strong><br />(<code>addon_dependencies_unmet</code>) scheitern<br />(<code>src/lib/addons/dependency-check.ts</code> im Core).</p>\n<p>Geprüft und bewusst NICHT verdrahtet:</p>\n<p>| Kandidat | Befund |<br />| --- | --- |<br />| <code>octoprint</code> | Existiert als eigenes Addon, spricht aber direkt mit einer OctoPrint-Instanz. Dieses Addon spricht mit dem Sync Proxy. Keine Aufrufbeziehung, also keine Kante. |<br />| <code>spoolman</code> | <strong>Gibt es zum Prüfzeitpunkt (2026-08-10) nicht</strong> — <code>addons/spoolman</code> entsteht gerade unter addons#1126. Eine Kante darauf wäre heute ein garantierter 409. |<br />| <code>mount-manager</code> | Existiert nicht; Epic #881 (siehe unten). |</p>\n<p>Namensfallen für die nächste Prüfung: „Git&quot; heißt hier <code>forgejo</code>,<br />„WooCommerce&quot; steckt in <code>wordpress</code>, „n8n&quot; heißt <code>workflow-builder</code>. Wer<br />eine Kante ergänzt, prüft den <strong>Verzeichnisnamen</strong> unter <code>addons/</code>, nicht<br />den Produktnamen.</p>\n<h2>Ausblick: mount-manager-Dependency (Epic #881)</h2>\n<p>Die USB-Mount-Verwaltung liegt aktuell außerhalb von Aiopta (fstab/udev auf<br />dem Proxy-Host, manuell eingerichtet). Sobald das Epic<br /><a href=\"https://git.langkau.info/Aiopta/aiopta-addons/issues/881\" rel=\"noopener noreferrer\">#881</a><br />(<code>mount-manager</code> — zentrale Verwaltung von Block-Devices, fstab/udev,<br />UI mit Permissions, Freigabe von Mounts an Addons/Agenten) umgesetzt ist,<br />wird dieses Addon <code>mount-manager</code> als <code>addonDependency</code> deklarieren und den<br />USB-Pfad aus dessen Freigabe-Modell beziehen. Bis dahin bleibt die<br />host-seitige Einrichtung wie im Proxy-Repo dokumentiert<br />(<code>docs/PROXMOX-USB-PASSTHROUGH.md</code>, <code>docs/USB-SETUP.md</code>).</p>",
          "byteSize": 15355
        }
      }
    },
    {
      "id": "openscad-generator",
      "uuid": "e7f613c4-7492-443e-9661-b8902f8f7103",
      "name": "OpenSCAD Generator",
      "version": "1.1.2",
      "description": "Generate OpenSCAD 3D models from text prompts (via the LLM gateway) and images, with containerized OpenSCAD rendering and STL export",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "text-to-openscad",
        "image-to-openscad",
        "stl-export",
        "preview-rendering"
      ],
      "tags": [],
      "download": "/artifacts/openscad-generator-1.1.2.zip",
      "sha256": "8f1a6b7bf58ab39da772785241e14e75b4cf4da565c2b411c239d56b978ae6b4",
      "size": 67008,
      "page": {
        "path": "/addons/openscad-generator",
        "name": "OpenSCAD Generator",
        "icon": "🧱",
        "section": "Tools",
        "component": "@addons/openscad-generator/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/openscad-generator/generate",
        "/api/v1/addons/openscad-generator/render",
        "/api/v1/addons/openscad-generator/status"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>OpenSCAD Generator Addon</h1>\n<p>Generate 3D-printable OpenSCAD models from text prompts and images, render previews, and export<br />STL — all from inside Aiopta.</p>\n<h2>Features</h2>\n<p>- <strong>Text to OpenSCAD</strong>: Describe your model in natural language. Generation is an LLM task and<br />  runs through the Aiopta LLM gateway (<code>@/lib/gateway</code>) — never a hard-coded model host<br />  (ADR-009). A deterministic keyword-based generator is used as an offline fallback when no model<br />  is reachable.<br />- <strong>Image to OpenSCAD</strong>: Convert 2D images to extruded 3D models (via Trace2SCAD or OpenCV<br />  contours, server-side).<br />- <strong>Preview Rendering</strong> &amp; <strong>STL Export</strong>: The <code>openscad</code> binary is an external tool, so it runs in<br />  the addon&#39;s <strong>render container</strong> (declared in the manifest <code>container</code> block). The render route<br />  reaches it via Aiopta&#39;s internal container URL; a local <code>openscad</code> CLI is used as a dev<br />  fallback.</p>\n<h2>Architecture (ADR-009)</h2>\n<p>- <strong>LLM via gateway only.</strong> <code>text-to-openscad.ts</code> calls <code>chatCompletionWithFallbacks</code> from<br />  <code>@/lib/gateway</code>; it does <strong>not</strong> talk to Ollama or any model endpoint directly.<br />- <strong>External tool as a container.</strong> The OpenSCAD renderer is declared as a <code>container</code> in<br />  <code>addon.json</code> / <code>manifest.ts</code> (<code>registry.langkau.info/aiopta/openscad-render</code>). It is<br />  booted at install by the container-service, started/stopped by enable/disable (G6 lifecycle),<br />  and reached only via <code>getContainerAddonInternalUrl(&quot;openscad-generator&quot;)</code>.<br />- <strong>No secrets in the manifest.</strong> The render image is stateless (<code>envSchema: []</code>); any future<br />  secret must go through <code>envSchema</code> + Aiopta config.</p>\n<h2>API</h2>\n<p>All routes are namespaced under <code>/api/v1/addons/openscad-generator/</code> and require auth.</p>\n<p>### Generate (text or image → OpenSCAD)<br />```<br />POST /api/v1/addons/openscad-generator/generate<br />{ &quot;type&quot;: &quot;text&quot;,  &quot;input&quot;: &quot;I need a watering can&quot; }<br />{ &quot;type&quot;: &quot;image&quot;, &quot;input&quot;: &quot;/tmp/source.png&quot;, &quot;options&quot;: { &quot;height&quot;: 5, &quot;layers&quot;: 10 } }<br />```</p>\n<p>### Render / export (OpenSCAD code → PNG preview or STL)<br />```<br />POST /api/v1/addons/openscad-generator/render<br />{ &quot;format&quot;: &quot;png&quot;, &quot;code&quot;: &quot;&lt;openscad code&gt;&quot;, &quot;options&quot;: { &quot;width&quot;: 800, &quot;height&quot;: 600 } }<br />{ &quot;format&quot;: &quot;stl&quot;, &quot;code&quot;: &quot;&lt;openscad code&gt;&quot; }<br />```<br />The response includes <code>source</code> (<code>&quot;container&quot;</code> or <code>&quot;cli&quot;</code>) and, for the container path, a base64<br /><code>dataBase64</code> payload.</p>\n<p>### Status<br />```<br />GET /api/v1/addons/openscad-generator/status<br />→ { ok, capabilities, textGeneration: &quot;gateway&quot;, renderContainerAvailable }<br />```</p>\n<h2>Render container contract</h2>\n<p>The render image is expected to expose:</p>\n<p>- <code>POST /render</code> — body <code>{ code, format: &quot;png&quot;|&quot;stl&quot;, width?, height? }</code>, returns<br />  <code>{ dataBase64 }</code> (base64 PNG or STL).<br />- <code>GET /api/v1/health</code> — health probe used by the container-service.</p>\n<blockquote><p><strong>TODO(owner): <code>openscad-render-image</code></strong> — building and publishing the</p><p><code>registry.langkau.info/aiopta/openscad-render</code> image is owner-run (registry currently</p><p>in outage). Until it is published, render falls back to a local <code>openscad</code> CLI in dev. See</p><p><code>docs/OPEN-DECISIONS.md</code>.</p></blockquote>\n<h2>Layout</h2>\n<pre><code>addons/openscad-generator/\n├── addon.json                 # Manifest (page + container + endpoints)\n├── README.md\n├── SKILL.md                   # Agent skill\n├── src/\n│   ├── manifest.ts            # Extension (dual-registration: routes + page + container)\n│   ├── page.tsx               # Addon UI (registered page)\n│   ├── text-to-openscad.ts    # Text→SCAD via @/lib/gateway (+ offline fallback)\n│   ├── image-to-openscad.ts   # Image→SCAD via Trace2SCAD/contours\n│   ├── render-openscad.ts     # Render PNG + export STL (container-first, CLI fallback)\n│   └── app/api/v1/addons/openscad-generator/{generate,render,status}/route.ts\n└── tests/unit/                # manifest validity + gateway-backed generation (gateway mocked)</code></pre>\n<h2>License</h2>\n<p>MIT</p>",
          "byteSize": 3839
        }
      }
    },
    {
      "id": "paid-sample",
      "uuid": "7cc803ea-f53f-4dd4-b12a-b77b576c6961",
      "name": "Pro Pack (Paid-Addon Sample)",
      "version": "1.0.2",
      "description": "Beispiel-Addon für kostenpflichtige Marketplace-Eintraege (issue #89). Demonstriert das neue `pricing`-Feld in addon.json + Catalog: nur nach Registrierung + Login + 'Kaufen' + Mock-Payment downloadbar. Hat selbst keinen UI-Surface — der Wert liegt im演示ieren der Bezahl-Flow.",
      "author": "Aiopta",
      "icon": "💎",
      "capabilities": [
        "paid-listing-sample"
      ],
      "tags": [],
      "download": "/api/v1/store/download?addon=paid-sample",
      "sha256": "9eab35d0fc74aad56dbc14562ee5739510abc4b57610878ec2bb46515489cbb4",
      "size": 2784,
      "page": null,
      "deprecated": false,
      "endpoints": [],
      "skills": [
        {
          "name": "paid-sample-info",
          "displayName": "Pro Pack (Paid Sample): Info",
          "description": "Liest die Pricing-Metadaten dieses Addons und gibt sie zurück. Kostet nichts aufzurufen — der Kauf ist eine演示 des Paywalls, nicht ein API-Gate für die Info selbst.",
          "permissions": [
            "addons/paid-sample/status"
          ],
          "endpoint": "/api/v1/addons/paid-sample/status",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": {
        "amount": 499,
        "currency": "EUR",
        "kind": "one-time"
      },
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": "EUR",
        "tiers": [
          {
            "id": "standard",
            "name": "Standard",
            "priceMinor": 499,
            "interval": "one_time",
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Pro Pack — Paid-Addon Sample (issue #89)</h1>\n<p>Demonstrations-Addon für kostenpflichtige Marketplace-Eintraege. Der<br />eigentliche Wert liegt nicht in irgendwelchen Features, sondern darin,<br />den kompletten Auth + Paywall + Download-Flow einmal real durch den<br />Store zu schicken:</p>\n<p>1. Im Store-Browse als 💎 Pro Pack sichtbar mit Preis-Tag <strong>4,99 €</strong>.<br />2. Beim Klick auf &quot;Kaufen&quot; oeffnet sich der Login/Register-Modal<br />   (User-Story: Store-Betreiber moechte, dass kostenpflichtige Addons<br />   nur nach Registrierung + Login + Kauf heruntergeladen werden<br />   koennen).<br />3. Nach Registrierung + Klick auf &quot;Kaufen&quot; erstellt das Backend einen<br />   Checkout (Mock-Stripe-Signatur, status=pending) und das SPA ruft<br />   <code>/api/v1/store/payment/simulate</code> mit status=success auf.<br />4. Sobald die Purchase auf <code>paid</code> steht, schaltet der Download-Button<br />   scharf. Das Artifact wird vom Backend mit<br />   <code>Content-Disposition: attachment</code> gestreamt.</p>\n<p>Dieses Addon hat bewusst keine <code>src/</code>-UI — der UI-Surface waere nur<br />Rauschen. Das Manifest selbst ist die Demonstration.</p>\n<h2>Felder</h2>\n<ul><li><code>pricing.amount</code>: 499 (Cents, nicht Euro — Integer, ADR-konform)</li><li><code>pricing.currency</code>: EUR (ISO 4217, Whitelist: EUR/USD/GBP/CHF)</li><li><code>pricing.kind</code>: <code>one-time</code> (vs. <code>subscription</code>)</li></ul>\n<p>Aenderungen an <code>pricing.amount</code> &lt;= 0 oder fehlendes <code>pricing</code>-Feld<br />bedeuten automatisch &quot;free&quot; — dann erscheint der Eintrag im Store<br />ohne Preis-Tag und der Download ist weiterhin oeffentlich via nginx.</p>",
          "byteSize": 1447
        }
      }
    },
    {
      "id": "payments",
      "uuid": "5eea08c0-a898-4ba7-b55d-5d356bde7d09",
      "name": "Payments (Rechnungen, Abos, Payment-Links, Mahnwesen)",
      "version": "1.4.2",
      "description": "Rechnungen, Abos, Payment-Links und Mahnwesen über Stripe + PayPal — providerseitig konfigurierbar (Keys/Webhook-Secret/Mode via Aiopta addon-config, nie im Manifest) und auch für Aioptas eigene Abrechnung (Phase Q, ADR-023) nutzbar. Lokale SQLite ist die Quelle der Wahrheit; ohne konfigurierten Provider läuft das Addon graceful im Lesemodus — KEIN Geld ohne Owner-Keys. Webhooks sind signaturverifiziert. Money-Stufe-1 (ADR-039).",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "invoices",
        "subscriptions",
        "payment-links",
        "dunning",
        "payment-providers",
        "webhooks",
        "aiopta-billing"
      ],
      "tags": [],
      "download": "/artifacts/payments-1.4.2.zip",
      "sha256": "2446dc9bcc9ada3f83cb8e97dd7216b4e3c7d3349e9f41b8434288664bae70bc",
      "size": 344505,
      "page": {
        "id": "payments-page",
        "path": "/addons/payments",
        "name": "Payments",
        "icon": "💳",
        "section": "Money",
        "component": "@addons/payments/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/payments/status",
        "/api/v1/addons/payments/invoices",
        "/api/v1/addons/payments/invoices/:id",
        "/api/v1/addons/payments/subscriptions",
        "/api/v1/addons/payments/subscriptions/:id",
        "/api/v1/addons/payments/payment-links",
        "/api/v1/addons/payments/payment-links/:id",
        "/api/v1/addons/payments/dunning",
        "/api/v1/addons/payments/webhooks/:provider",
        "/api/v1/addons/payments/widget"
      ],
      "skills": [
        {
          "name": "payments-overview",
          "displayName": "Payments: Status & Übersicht",
          "description": "Liest Provider-Status (konfiguriert/erreichbar, ohne Secrets) und die Payments-Übersicht (offene Rechnungen, Umsatz/Monat, überfällig, aktive Abos). Reiner Lesezugriff — bewegt kein Geld.",
          "permissions": [
            "addons/payments/status"
          ],
          "endpoint": "/api/v1/addons/payments/status",
          "scripts": [
            {
              "id": "payments-overview-cli",
              "name": "payments-overview-cli",
              "path": "payments-overview/cli.sh",
              "sha256": "1a648111d619787c2d55d52043ce9283dad0e6dc5355bc402226d63cfe5b64d8",
              "interpreter": "bash",
              "timeoutMs": 10000,
              "permissions": [
                "addons/payments/status"
              ],
              "inputSchema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "limit": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 100,
                    "default": 20
                  },
                  "timeoutMs": {
                    "type": "integer",
                    "minimum": 100,
                    "maximum": 300000
                  }
                }
              },
              "outputSchema": {
                "type": "object",
                "required": [
                  "ok"
                ],
                "properties": {
                  "ok": {
                    "type": "boolean"
                  },
                  "status": {
                    "type": "integer"
                  },
                  "data": {},
                  "error": {
                    "type": "string"
                  },
                  "truncated": {
                    "type": "boolean"
                  }
                }
              },
              "size": 12090
            }
          ]
        },
        {
          "name": "payments-invoices",
          "displayName": "Payments: Rechnungen",
          "description": "Listet/erstellt Rechnungs-Entwürfe und führt Aktionen aus (senden=beim Provider hosten+öffnen, als bezahlt markieren, stornieren). Senden braucht einen konfigurierten Provider (sonst 400 — kein stilles No-Op); Statusmaschine wird erzwungen.",
          "permissions": [
            "addons/payments/invoices",
            "addons/payments/status"
          ],
          "endpoint": "/api/v1/addons/payments/invoices",
          "scripts": []
        },
        {
          "name": "payments-subscriptions",
          "displayName": "Payments: Abos",
          "description": "Listet/erstellt/kündigt Abos gegen eine vom Owner bereitgestellte Price-/Plan-ID. Erstellen braucht einen konfigurierten Provider (sonst 400).",
          "permissions": [
            "addons/payments/subscriptions",
            "addons/payments/status"
          ],
          "endpoint": "/api/v1/addons/payments/subscriptions",
          "scripts": []
        },
        {
          "name": "payments-links",
          "displayName": "Payments: Payment-Links",
          "description": "Erstellt teilbare Zahl-URLs für einen Einmalbetrag und deaktiviert sie. Erstellen braucht einen konfigurierten Provider (sonst 400); Betrag>0 und Währungs-Whitelist werden geprüft.",
          "permissions": [
            "addons/payments/payment-links",
            "addons/payments/status"
          ],
          "endpoint": "/api/v1/addons/payments/payment-links",
          "scripts": []
        },
        {
          "name": "payments-dunning",
          "displayName": "Payments: Mahnwesen",
          "description": "Startet Mahnläufe für überfällige Rechnungen (Stufen 1–3 + zuletzt-versendet) und versendet Erinnerungen über die Notifications, falls aktiv — sonst wird der Zustand nur erfasst. Versendet keine Rohdaten/PII in Logs.",
          "permissions": [
            "addons/payments/dunning",
            "addons/payments/status"
          ],
          "endpoint": "/api/v1/addons/payments/dunning",
          "scripts": []
        }
      ],
      "mcpServers": [
        {
          "id": "payments-tools",
          "displayName": "Payments tools",
          "description": "Read-only payments math + projections for agents: invoice status machine, invoice totals (subtotal/tax/total), payments summary (invoice/subscription/payment-link counts, revenue-this-month, overdue), dunning projection and money formatting — the same pure functions the payments addon uses for its status, summary and widget (issue #436, epic #399). Moves no money.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "payments-tools/server.mjs",
          "permissions": [
            "addons/payments/read"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [],
          "credentialRefs": [],
          "archiveEntry": "payments/mcp/servers/payments-tools/server.mjs",
          "sha256": "6c7279e9dbecdabcc5fcca04a92ccaac3aac5d9cc23777cd97abffe46fa7ca21",
          "size": 39541,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Payments Addon</h1>\n<p>Rechnungen (Invoices), Abos (Subscriptions), Payment-Links und Mahnwesen (Dunning) über <strong>Stripe + PayPal</strong><br />— providerseitig konfigurierbar und auch für Aioptas <strong>eigene Abrechnung (Phase Q)</strong> nutzbar.<br />Foundation-Addon für spätere <code>accounting</code> / <code>membership</code> / <code>marketing-ads</code>.</p>\n<blockquote><p>Money-Stufe-1 · ADR-039 · Connector-Pattern (Stripe/PayPal sind SaaS — <strong>kein</strong> Container).</p></blockquote>\n<h2>Was es tut</h2>\n<p>- <strong>Rechnungen</strong> — anlegen/listen/lesen/senden/als-bezahlt-markieren/stornieren. Positionen, Währung,<br />  USt; Statusmaschine <code>draft → open → paid | void | uncollectible</code>. Lokale SQLite ist die Quelle der<br />  Wahrheit; der Provider hostet die Rechnung / erzeugt die Zahl-URL.<br />- <strong>Abos</strong> — anlegen/listen/kündigen gegen eine <strong>vom Owner bereitgestellte</strong> Price-/Plan-ID; Status<br />  <code>active | past_due | canceled</code>.<br />- <strong>Payment-Links</strong> — teilbare Zahl-URL für einen Einmalbetrag; listen/deaktivieren.<br />- <strong>Mahnwesen</strong> — Eskalationsstufen (1–3) + zuletzt-versendet für überfällige Rechnungen; Versand über die<br />  <strong>Notifications</strong> (falls installiert), sonst wird der Zustand nur erfasst und angezeigt.<br />- <strong>Webhooks</strong> — <code>POST /webhooks/{provider}</code> empfängt Stripe/PayPal-Events, <strong>verifiziert die Signatur</strong><br />  und aktualisiert lokalen Status <strong>idempotent</strong>.<br />- <strong>Aiopta-Billing-Tie-in</strong> — ein dünner interner Helper (<code>src/lib/aiopta-billing.ts</code>), damit Aiopta instances Phase-Q-Metering<br />  eine Rechnung/ein Abo über dieses Addon ausstellen kann — <strong>ohne</strong> Metering zu duplizieren (liest<br />  <code>@/lib/quotas</code>), und einen bezahlten Tarif über dieselbe Naht wie der Invite-Pfad<br />  (<code>applyInviteAssignment</code>) setzen kann.</p>\n<h2>Provider-Konfiguration (Aiopta-Config, nie im Manifest)</h2>\n<p>Beide Provider sind <strong>optional</strong> — ohne Keys läuft das Addon graceful im Lesemodus (kein Geld ohne<br />Owner-Keys). Secrets liegen ausschließlich in Aiopta addon-config (<code>addon.config.payments.*</code>, <code>is_secret=1</code>);<br />der einzige Echo ist ein maskierter <code>set(len=…)</code>-Hinweis.</p>\n<p>| Schlüssel | Provider | Zweck |<br />|-----------|----------|-------|<br />| <code>STRIPE_SECRET_KEY</code> | Stripe | API-Key (Bearer) |<br />| <code>STRIPE_WEBHOOK_SECRET</code> | Stripe | Webhook-HMAC-Secret (<code>Stripe-Signature</code>) |<br />| <code>STRIPE_MODE</code> | Stripe | <code>test</code> \\| <code>live</code> |<br />| <code>PAYPAL_CLIENT_ID</code> / <code>PAYPAL_CLIENT_SECRET</code> | PayPal | OAuth2 Client-Credentials |<br />| <code>PAYPAL_WEBHOOK_ID</code> | PayPal | Webhook-ID für die verify-API |<br />| <code>PAYPAL_MODE</code> | PayPal | <code>sandbox</code> (Default) \\| <code>live</code> |</p>\n<p>Konkrete Keys/Webhook-Secrets, live-vs-test und die Price-/Plan-IDs sind <strong>Owner-Entscheidungen</strong> — siehe<br /><code>docs/OPEN-DECISIONS.md</code> (<code>billing-payment-provider</code> / <code>payments-stripe</code>, <code>payments-paypal</code>,<br /><code>payments-price-ids</code>).</p>\n<h2>Sicherheit (Review-relevant, #72/INBOX-11)</h2>\n<p>- <strong>Secrets nur via Aiopta-Config</strong>, nie geloggt, nur maskiert echo&#39;d (<code>maskSecret</code>).<br />- <strong>Webhook-Signaturprüfung ist Pflicht</strong>: Stripe HMAC-SHA256 über den Rohbody (<code>Stripe-Signature</code>,<br />  Replay-Toleranz 5 min, konstantzeitiger Vergleich); PayPal über die verify-webhook-signature-API gegen<br />  die konfigurierte <code>PAYPAL_WEBHOOK_ID</code>. Fehlende/ungültige Signatur → <strong>400</strong>, <strong>keine</strong> State-Mutation.<br />  Webhook-Events sind über <code>markWebhookSeen</code> <strong>idempotent</strong>.<br />- <strong>Geldbewegende Routen</strong> sind POST/PUT/DELETE mit mc-session-Auth und <strong>erfordern</strong> einen konfigurierten<br />  Provider (sonst 400 — kein stilles No-Op). GET/Daten-Routen degradieren graceful (200, leere/<code>configured:false</code>).<br />- <strong>Idempotency</strong> bei jedem Provider-Create (Stripe <code>Idempotency-Key</code>, PayPal <code>PayPal-Request-Id</code>).<br />- <strong>Validierung</strong>: Betrag &gt; 0 (Minor-Units), Währungs-Whitelist (EUR/USD/GBP/CHF/CAD/AUD), USt 0–100.<br />  Kein PII in Logs/URLs (Kunden-E-Mail wird in der Mahnliste maskiert).</p>\n<h2>Architektur</h2>\n<pre><code>addons/payments/\n  addon.json                       # Manifest (Endpoints, Page, Widget, Skills; kein Container)\n  src/manifest.ts                  # Dual-Registration (ADR-001), String-Literal route()-Handler\n  src/page.tsx                     # PaymentsPage (Tabs: Rechnungen/Abos/Links/Mahnwesen + Provider-Panel)\n  src/widget.tsx                   # Dashboard-Widget (key-values, graceful)\n  src/lib/\n    money.ts                       # pure: Währungs-Whitelist, Beträge, Summen, Formatierung\n    errors.ts                      # PaymentsError (+ HTTP-Status)\n    provider-config.ts             # liest Provider-Secrets aus Aiopta-Config, maskierte Hints\n    providers/types.ts             # PaymentProvider-Interface (wie BackendDriver) + Money/LineItem\n    providers/stripe.ts            # Stripe (raw REST, HMAC-Webhook-Verify) — vollständig\n    providers/paypal.ts            # PayPal (OAuth2 REST, verify-API) — gleiche Schnittstelle\n    providers/index.ts             # Registry/Factory: resolveWriteProvider, defaultProvider\n    payments-store.ts              # SQLite-Quelle-der-Wahrheit + Statusmaschinen (pure + DB)\n    dunning.ts                     # Mahnlauf, Reminder-Versand (Notifications-Reuse, graceful)\n    status.ts                      # /status-Aggregat (Provider + Summary), graceful\n    aiopta-billing.ts              # Phase-Q-Tie-in (Rechnung/Abo ausstellen, Tarif-Naht)\n  src/app/api/v1/addons/payments/**/route.ts   # thin Handler (apiSuccess/apiError)\n  tests/unit/                      # money, store (Statusmaschine+DB), providers (Signatur), manifest, routes-graceful\n  SKILL.md, README.md</code></pre>\n<h2>Tests</h2>\n<pre><code class=\"lang-bash\">npx vitest run addons/payments</code></pre>\n<p>- <code>money.test.ts</code> — pure Validierung/Math.<br />- <code>payments-store.test.ts</code> — Statusmaschine (pure) + DB (In-Memory SQLite): Transitions, Abos,<br />  Payment-Links, Mahn-Cap, Webhook-Idempotenz + <code>applyWebhook</code>.<br />- <code>providers.test.ts</code> — Stripe-Signaturprüfung (gültig/manipuliert/abgelaufen), Klassifizierer,<br />  Happy-Path (mock fetch) + not-configured-Refusal, Registry.<br />- <code>manifest.test.ts</code> — Manifest gültig, namespaced, kein Container, keine Secrets/Hosts.<br />- <code>routes-graceful.test.ts</code> — Daten-Routen 200/leer ohne Provider; Webhook lehnt unkonfiguriert/unbekannt ab.</p>",
          "byteSize": 5962
        }
      }
    },
    {
      "id": "polymarket",
      "uuid": "b5b72cdb-8f3b-4815-910e-2e3c544c091d",
      "name": "Polymarket",
      "version": "1.0.2",
      "description": "Prediction market addon with market discovery, positions, open orders, and direct order placement via the official Polymarket CLOB client.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "prediction-markets",
        "market-discovery",
        "positions",
        "orders",
        "trading"
      ],
      "tags": [],
      "download": "/artifacts/polymarket-1.0.2.zip",
      "sha256": "eb26fba45c76df29f27b52f4a89d36bdf298e8419e72e6388b3a31cc7248119d",
      "size": 101702,
      "page": {
        "path": "/addons/polymarket",
        "name": "Polymarket",
        "icon": "🎯",
        "section": "Finance",
        "component": "@addons/polymarket/src/components/PolymarketPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/polymarket",
        "/api/v1/addons/polymarket/status",
        "/api/v1/addons/polymarket/config",
        "/api/v1/addons/polymarket/markets",
        "/api/v1/addons/polymarket/book",
        "/api/v1/addons/polymarket/positions",
        "/api/v1/addons/polymarket/orders"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Polymarket</h1>\n<p>Prediction market addon with market discovery, positions, open orders, and direct order placement via the official Polymarket CLOB client.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>polymarket</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> prediction-markets, market-discovery, positions, orders, trading</li><li><strong>UI-Seite:</strong> <code>/addons/polymarket</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials.</p>\n<p>npm-Abhängigkeiten (werden bei Installation aufgelöst): <code>@polymarket/clob-client-v2</code>, <code>viem</code>.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET | <code>/api/v1/addons/polymarket</code> |<br />| GET | <code>/api/v1/addons/polymarket/book</code> |<br />| GET, POST, DELETE | <code>/api/v1/addons/polymarket/config</code> |<br />| GET | <code>/api/v1/addons/polymarket/markets</code> |<br />| GET, POST, DELETE | <code>/api/v1/addons/polymarket/orders</code> |<br />| GET | <code>/api/v1/addons/polymarket/positions</code> |<br />| GET | <code>/api/v1/addons/polymarket/status</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>Keine bekannten Einschränkungen.</li></ul>\n<blockquote><p>README aus <code>addon.json</code> + vorhandenen Routen generiert (<code>scripts/generate-addon-readmes.js</code>). Bei manuellen Ergänzungen den Generator-Hinweis entfernen.</p></blockquote>",
          "byteSize": 1134
        }
      }
    },
    {
      "id": "proxmox",
      "uuid": "13e9fe70-e0df-4254-9de4-295e89b75d1c",
      "name": "Proxmox VE",
      "version": "1.0.5",
      "description": "Control Proxmox VE — start/stop/shutdown/reboot VMs and LXC containers, node overview and cluster resources via the Proxmox API.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "node-overview",
        "vm-control",
        "lxc-control",
        "cluster-resources",
        "connection-config"
      ],
      "tags": [],
      "download": "/artifacts/proxmox-1.0.5.zip",
      "sha256": "ba1324d0094fffcb7a7c769435fb2bb2b54d1baac3a20878f98eae5163e2c097",
      "size": 110029,
      "page": {
        "id": "proxmox-page",
        "path": "/addons/proxmox",
        "name": "Proxmox VE",
        "icon": "🖥️",
        "section": "Tools",
        "component": "@addons/proxmox/src/components/ProxmoxPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/proxmox/status",
        "/api/v1/addons/proxmox/nodes",
        "/api/v1/addons/proxmox/resources",
        "/api/v1/addons/proxmox/nodes/:node/qemu",
        "/api/v1/addons/proxmox/nodes/:node/qemu/:vmid/status",
        "/api/v1/addons/proxmox/nodes/:node/qemu/:vmid/start",
        "/api/v1/addons/proxmox/nodes/:node/qemu/:vmid/stop",
        "/api/v1/addons/proxmox/nodes/:node/qemu/:vmid/shutdown",
        "/api/v1/addons/proxmox/nodes/:node/qemu/:vmid/reboot",
        "/api/v1/addons/proxmox/nodes/:node/lxc",
        "/api/v1/addons/proxmox/nodes/:node/lxc/:vmid/status",
        "/api/v1/addons/proxmox/nodes/:node/lxc/:vmid/start",
        "/api/v1/addons/proxmox/nodes/:node/lxc/:vmid/stop",
        "/api/v1/addons/proxmox/nodes/:node/lxc/:vmid/shutdown",
        "/api/v1/addons/proxmox/nodes/:node/lxc/:vmid/reboot",
        "/api/v1/addons/proxmox/config",
        "/api/v1/addons/proxmox/test"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Proxmox VE Addon</h1>\n<p>Control a <a href=\"https://www.proxmox.com/\" rel=\"noopener noreferrer\">Proxmox VE</a> cluster from Aiopta:<br />node overview, cluster-wide VM/LXC list, per-guest status, and power actions<br />(start / shutdown / stop / reboot) — via the Proxmox API token.</p>\n<h2>Features</h2>\n<p>- <strong>Node overview</strong> — list cluster nodes and their state.<br />- <strong>Cluster resources</strong> — all QEMU VMs and LXC containers in one call (<code>/resources</code>).<br />- <strong>Guest control</strong> — start, clean-shutdown, hard-stop, reboot for VMs and containers.<br />- <strong>Connection config</strong> — host/port + API token stored on the server; the token<br />  secret is never returned to the client.<br />- <strong>Dashboard widget</strong> <code>proxmox.status</code> — running vs. total guests.<br />- <strong>Agent skill</strong> (<code>SKILL.md</code>) — token-sparing single-REST-call control for agents.</p>\n<h2>Setup</h2>\n<p>1. <strong>Create an API token in Proxmox</strong> (Datacenter → Permissions → API Tokens):<br />   - Pick a user/realm (e.g. <code>root@pam</code>) and a token id (e.g. <code>mc</code>).<br />   - Give the token the permissions it needs (e.g. <code>PVEVMAdmin</code> on <code>/</code> or per-VM).<br />   - Copy the secret — Proxmox shows it <strong>once</strong>.<br />   - The full token id is <code>USER@REALM!TOKENID</code> (e.g. <code>root@pam!mc</code>).<br />2. <strong>Configure in Aiopta</strong> (Proxmox addon → Settings, or <code>PUT /config</code>):<br />   - <code>host</code> (e.g. <code>pve.example.com</code>), <code>port</code> (default <code>8006</code>),<br />   - <code>tokenId</code> (<code>root@pam!mc</code>), <code>tokenSecret</code>,<br />   - <code>verifyTls</code> — <strong>uncheck</strong> if your Proxmox uses the default self-signed cert.<br />3. <strong>Test</strong> the connection (Test button, or <code>POST /test</code>).</p>\n<h2>TLS</h2>\n<p>Proxmox ships a <strong>self-signed</strong> certificate by default. Set <code>verifyTls: false</code><br />to connect anyway (the client then disables certificate verification for the<br />Proxmox host only). Use a proper certificate + <code>verifyTls: true</code> in production.</p>\n<h2>Endpoints</h2>\n<p>Base: <code>/api/v1/addons/proxmox</code></p>\n<p>| Method | Path | Description |<br />|--------|------|-------------|<br />| GET | <code>/status</code> | Reachability + Proxmox version |<br />| GET | <code>/nodes</code> | Cluster node list |<br />| GET | <code>/resources</code> | All VMs + LXC (vmid, node, type, status, name) |<br />| GET | <code>/nodes/{node}/qemu</code> | VMs on a node |<br />| GET | <code>/nodes/{node}/qemu/{vmid}/status</code> | VM status |<br />| POST | <code>/nodes/{node}/qemu/{vmid}/start</code> | Start VM |<br />| POST | <code>/nodes/{node}/qemu/{vmid}/shutdown</code> | Clean shutdown |<br />| POST | <code>/nodes/{node}/qemu/{vmid}/stop</code> | Hard power-off |<br />| POST | <code>/nodes/{node}/qemu/{vmid}/reboot</code> | Reboot |<br />| GET | <code>/nodes/{node}/lxc</code> | Containers on a node |<br />| GET | <code>/nodes/{node}/lxc/{vmid}/status</code> | Container status |<br />| POST | <code>/nodes/{node}/lxc/{vmid}/start\\|shutdown\\|stop\\|reboot</code> | Container power actions |<br />| GET | <code>/config</code> | Current config (token secret masked) |<br />| PUT | <code>/config</code> | Set connection + token (auth-gated) |<br />| POST | <code>/test</code> | Test the configured connection (auth-gated) |</p>\n<p><code>shutdown</code> is a clean shutdown (guest agent / ACPI); <code>stop</code> is a hard power-off.<br />Control routes (POST) and <code>PUT /config</code> run under the per-agent key scoping from<br />Phase S — <code>proxmox/*</code> is its own capability.</p>\n<h2>Architecture</h2>\n<pre><code>src/\n├── lib/\n│   ├── config.ts     # data/proxmox-config.json (load/save/mask)\n│   ├── client.ts     # Proxmox API client (PVEAPIToken auth, TLS opt-out, timeouts)\n│   ├── types.ts      # ProxmoxConfig, PveNode, PveResource, PveGuestStatus, …\n│   └── response.ts    # { success, data } envelope helpers\n├── manifest.ts       # Extension + route registration\n├── page.ts           # Sidebar page (section &quot;Tools&quot;)\n├── components/ProxmoxPage.tsx\n└── app/api/v1/addons/proxmox/...  # route handlers</code></pre>",
          "byteSize": 3509
        }
      }
    },
    {
      "id": "rag",
      "uuid": "c4d66679-decf-4ac0-85ea-76473c76a572",
      "name": "RAG",
      "version": "1.7.0",
      "description": "Retrieval-Augmented Generation: chunk → embed → store → retrieve over a self-hosted Qdrant sidecar, with HYBRID search (semantic dense-vector + lexical BM25/SQLite-FTS5 fused via Reciprocal Rank Fusion, #727), an optional CROSS-ENCODER RERANK stage as the final quality gate (coarse hybrid pool → cross-encoder → top-K; off by default, disableable per call for low-latency voice paths, #728), FILE INGESTION via an OCR-to-markdown pipeline (PDF/Office/scans → normalised markdown → the same chunk+embed+store path; local converter by default, optional Mistral-OCR cloud provider, #729), and CONFIGURABLE CHUNK STRATEGIES with an ENFORCED INDEX-INVARIANT (recursive char-splitter default vs semantic 0-overlap structure-boundary splitter; per-collection {embeddingModelId,dimensions,chunkStrategy} metadata; a model/dimension mismatch on ingest or retrieval is a clear code:\"index_model_mismatch\" error instead of silently broken search; document-wise reindex from the persisted markdown source for model/strategy switches, #730). Standalone search surface; other addons (memories, knowledge-base) detect this capability at runtime via isAddonAvailable(\"rag\").",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "rag",
        "knowledge-management"
      ],
      "tags": [],
      "download": "/artifacts/rag-1.7.0.zip",
      "sha256": "37524af03d7753f9dcca2f9712200a839d94e048c90df0a4f7f46d4a7f3c62ce",
      "size": 411491,
      "page": [
        {
          "id": "rag",
          "path": "/addons/rag",
          "name": "RAG",
          "icon": "🔎",
          "section": "Workspace",
          "component": "@addons/rag/src/components/rag/RagConsole"
        }
      ],
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/rag",
        "/api/v1/addons/rag/status",
        "/api/v1/addons/rag/ingest",
        "/api/v1/addons/rag/ingest/file",
        "/api/v1/addons/rag/reindex",
        "/api/v1/addons/rag/search",
        "/api/v1/addons/rag/documents",
        "/api/v1/addons/rag/documents/{id}"
      ],
      "skills": [],
      "mcpServers": [
        {
          "id": "rag-tools",
          "displayName": "RAG tools",
          "description": "Read-only semantic search over the RAG vector index for agents: rag_search (scoped to the calling user + agent, Qdrant payload filter enforces isolation) and rag_status (pipeline health). Read-only Policy — no ingest/delete/write tools (issue #384 contract, #584).",
          "transport": "stdio",
          "runtime": "node",
          "entry": "rag-tools/server.mjs",
          "permissions": [
            "addons/rag/read"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [],
          "credentialRefs": [],
          "archiveEntry": "rag/mcp/servers/rag-tools/server.mjs",
          "sha256": "8701beb71660cf7151614067f8a1cd9b70cf3d81c49e23603771735b96ff5c30",
          "size": 14952,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>RAG</h1>\n<p>Retrieval-Augmented Generation: a standalone semantic-search addon that chunks,<br />embeds, stores and retrieves documents over a self-hosted Qdrant sidecar. Other<br />addons (memories, knowledge-base) detect this capability at runtime via<br /><code>isAddonAvailable(&quot;rag&quot;)</code> and use it <strong>optionally</strong> — falling back to their own<br />(SQL <code>LIKE</code>) search when RAG is not installed or the Qdrant sidecar is absent.</p>\n<p>This README covers the addon surface (Issue #587, <strong>Slice 1</strong>). The pipeline<br />itself lives in the core library <code>src/lib/rag/</code> (see<br /><code>docs/rag.md</code> in aiopta-core); this addon only <em>consumes</em><br />that library.</p>\n<h2>Überblick</h2>\n<p>- <strong>Addon-ID:</strong> <code>rag</code><br />- <strong>Version:</strong> 1.7.0<br />- <strong>Fähigkeiten (capabilities):</strong> <code>rag</code>, <code>knowledge-management</code><br />- <strong>UI-Seite:</strong> <code>/addons/rag</code> (RAG-Konsole: Status, Ingest, Search)<br />- <strong>Abhängigkeiten:</strong> keine (standalone). Optionaler Qdrant-Sidecar<br />  (<code>docker compose up -d qdrant</code>).</p>\n<h2>Knowledge-Anbieter 1 „vector&quot; (core#816/#818)</h2>\n<p>Seit dem Knowledge-Epic (core#816, Slice 2 core#818) ist RAG <strong>Anbieter 1</strong><br />(<code>kind: &quot;vector&quot;</code>) der generischen knowledge-management-Fähigkeit: Der<br /><code>RagService</code> wird über <code>src/lib/knowledge-adapter.ts</code> auf den Vertrag<br /><code>KnowledgeManagementCapability</code> aus <code>@aiopta/sdk</code> abgebildet und in<br /><code>src/lib/capability-register.ts</code> unter <code>core.knowledge.management</code><br />registriert (Provider-Liste der Core-Registry, <code>registerCapabilityProvider</code>).<br />Verbraucher codieren gegen die Fähigkeit, nie gegen dieses Addon; welcher<br />Anbieter für wen aktiv ist, entscheiden Resolver + Zustimmung (core#820/#821).<br />Wichtig: Wissen ≠ Gedächtnis (Owner-Entscheidung 2026-08-03) — was der Agent<br />über den Nutzer behält, bleibt allein im <code>memories</code>-Addon.</p>\n<h2>Architektur (Owner-Entscheidung D5, 2026-07-08)</h2>\n<p>RAG ist ein <strong>eigenständiges Addon</strong> — kein direkter Einbau in <code>memories</code>. Das<br />Pattern ist <strong>Capability-Detection</strong>:</p>\n<pre><code class=\"lang-ts\">import { isAddonAvailable } from &quot;@/lib/addons/availability&quot;;</code></pre>\n<p>if (await isAddonAvailable(&quot;rag&quot;)) {<br />  // use src/lib/rag vector search (chunk + embed + retrieve, Qdrant)<br />} else {<br />  // existing SQL LIKE search (fallback, no regression)<br />  // + store-link in the UI: &quot;Install the RAG addon for semantic search&quot;<br />}<br />```</p>\n<p>- <strong>NICHT</strong> <code>addonDependencies</code> (hard deps) — RAG ist optional, kein 409-Block.<br />- <strong><code>isAddonAvailable(&quot;rag&quot;)</code></strong> (<code>availability.ts:154</code>) ist die kanonische<br />  Prüfung. Keine neue <code>hasCapability</code> erfinden.<br />- <strong>Registry:</strong> <code>addonRegistry.getByCapability(&quot;rag&quot;)</code> findet dieses Addon.</p>\n<h2>Pipeline (konsumiert <code>src/lib/rag/</code>)</h2>\n<pre><code>document text ──► chunk (rag/chunk.ts)\n              ──► embed (rag/embedding.ts)   ← injectable provider\n              ──► store (rag/vector-store.ts) ← Qdrant sidecar, degrades gracefully\nquery text    ──► embed ──► search (store) ──► rank (rag/retrieve.ts)</code></pre>\n<p>| Stage | Core module | Notes |<br />|---|---|---|<br />| Chunking | <code>rag/chunk.ts</code> | Greedy word packing with overlap, exact char offsets. |<br />| Embedding | <code>rag/embedding.ts</code> | Injectable <code>EmbeddingProvider</code>; deterministic hash fallback (network-free) for tests/CI. |<br />| Store | <code>rag/vector-store.ts</code> | <code>QdrantVectorStore</code> over the REST API. Never throws on connectivity. |<br />| Retrieve | <code>rag/retrieve.ts</code> | Pure cosine ranking + merge/dedupe. |</p>\n<h2>Graceful degradation</h2>\n<p>Das Addon <strong>crasht nie</strong>, weil der optionale Qdrant-Sidecar fehlt:</p>\n<p>- <code>createVectorStore()</code> liefert <code>null</code>, wenn <code>RAG_VECTOR_STORE_ENABLED=false</code>.<br />- Jede <code>QdrantVectorStore</code>-Methode fängt Connectivity-Fehler ab, loggt eine<br />  Warnung und resolve zu einem No-op / leeren Ergebnis. <code>store.available</code><br />  reflektiert den letzten Kontakt.<br />- Der deterministische Embedding-Fallback braucht kein Netzwerk/keinen API-Key,<br />  sodass die volle Pipeline (chunk → embed → rank) in CI ohne Container läuft.<br />- <code>GET /status</code> meldet <code>storeAvailable:false</code> — die UI rendert einen klaren<br />  &quot;nicht bereit&quot;-Zustand, kein Crash.</p>\n<h2>User-Isolation</h2>\n<p>Jeder Vektor-Punkt trägt ein <code>user_id</code>-Payload-Feld (vom Server aus der Session<br />gestanzt, niemals ein Client-Wert). Die Suche filtert standardmäßig auf<br /><code>user_id = &lt;caller&gt;</code> — User A sieht <strong>niemals</strong> User B&#39;s Dokumente. Der<br /><code>includeOthers</code>-Parameter (Admin-Affordanz) lockert den Filter bewusst.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode | Pfad | Beschreibung |<br />|---|---|---|<br />| GET | <code>/api/v1/addons/rag</code> | Addon-Deskriptor (id, capabilities, endpoints). |<br />| GET | <code>/api/v1/addons/rag/status</code> | Pipeline-Health + Konfiguration (Provider, Dimensions, Qdrant-Verfügbarkeit). Wirft nie. |<br />| POST | <code>/api/v1/addons/rag/ingest</code> | Dokument chunken + embedden + speichern. <code>user_id</code> vom Server gestanzt. Degradiert zu <code>stored:false</code>, wenn Qdrant nicht erreichbar. |<br />| POST | <code>/api/v1/addons/rag/ingest/file</code> | Multipart-Datei-Ingestion (#729): PDF/Office/Scan/Text → OCR-zu-Markdown-Konvertierung → dieselbe chunk+embed+store-Pipeline. 400 bei unsupported/oversize, 409 <code>code:&quot;unconfigured&quot;</code> wenn der benötigte Konverter fehlt (nie 5xx). |<br />| POST | <code>/api/v1/addons/rag/search</code> | Semantische Suche, gescoped auf den Caller (<code>user_id</code>-Payload-Filter). Leeres Resultat (kein Fehler), wenn der Store nicht verfügbar ist. |<br />| GET | <code>/api/v1/addons/rag/documents</code> | Distinct ingested document ids des Callers (read-only). |<br />| DELETE | <code>/api/v1/addons/rag/documents/{id}</code> | Löscht ein Dokument + alle seine Chunks (idempotent, gescoped). |</p>\n<h3>Beispiel</h3>\n<pre><code class=\"lang-bash\"># Ingest a document\ncurl -s -X POST -H &quot;Authorization: Bearer $AIOPTA_API_KEY&quot; -H &quot;Content-Type: application/json&quot; \\\n  -d &#39;{&quot;id&quot;:&quot;readme-v1&quot;,&quot;text&quot;:&quot;The quick brown fox jumps over the lazy dog.&quot;}&#39; \\\n  https://&lt;instance&gt;/api/v1/addons/rag/ingest</code></pre>\n<p># Semantic search<br />curl -s -X POST -H &quot;Authorization: Bearer $AIOPTA_API_KEY&quot; -H &quot;Content-Type: application/json&quot; \\<br />  -d &#39;{&quot;query&quot;:&quot;quick fox&quot;,&quot;topK&quot;:5}&#39; \\<br />  <a href=\"https://&amp;lt;instance&amp;gt;/api/v1/addons/rag/search\" rel=\"noopener noreferrer\">https://&amp;lt;instance&amp;gt;/api/v1/addons/rag/search</a><br />```</p>\n<p>Responses use <code>{ success, data | error }</code>.</p>\n<h2>Datei-Ingestion: OCR-zu-Markdown-Pipeline (Issue #729)</h2>\n<p>Praxis-Dokumente liegen selten als sauberer Text vor. Am Anfang der<br />Indexierungs-Pipeline steht deshalb ein Konvertierungs-Schritt, der **jede<br />Datei in normalisiertes Markdown<strong> umwandelt — es wird </strong>immer bereinigtes<br />Markdown<strong> in den Wissensspeicher geladen, nie rohe PDFs. Erst </strong>nach** der<br />Markdown-Normalisierung wird gechunkt und embedded (dieselbe Pipeline wie<br /><code>POST /ingest</code> — keine Parallel-Pipeline).</p>\n<p><strong>Konverter-Provider</strong> (<code>src/lib/markdown-converter.ts</code>):</p>\n<p>| Provider | Pfad | Kann | Kann NICHT |<br />|---|---|---|---|<br />| <code>local-text</code> (<strong>Default</strong>) | lokal, kein Datenabfluss | Text/Markdown-Passthrough; Text-PDFs via <code>pdftotext</code> (poppler, falls installiert) bzw. eingebauter Extraktor für einfache/unkomprimierte PDFs | <strong>Scans/Bild-PDFs, Bilder, Office</strong> (dokumentierte Grenze — dafür ist OCR nötig) |<br />| <code>mistral-ocr</code> (optional) | <strong>Cloud</strong> — Inhalt verlässt die Instanz | Scans, Bilder, Layout-PDFs, Office-Dokumente → strukturiertes Markdown | läuft nur mit konfiguriertem API-Key |</p>\n<p><strong>DSGVO / Datenabfluss (Betriebs-Hinweis):</strong> Der lokale Pfad ist der Default<br />und verlässt die Instanz nie. Cloud-OCR (<code>mistral-ocr</code>) wird <strong>nur</strong> genutzt,<br />wenn (a) ein Key konfiguriert ist <strong>und</strong> (b) der lokale Konverter den Inhalt<br />nicht extrahieren kann (Scan/Bild/Office). Jede Nutzung ist gekennzeichnet:<br /><code>GET /status</code> meldet pro Provider <code>cloud: true/false</code>, die Antwort von<br /><code>POST /ingest/file</code> trägt ein <code>cloud</code>-Flag und die RAG-Konsole zeigt den<br />Egress-Marker (&quot;content leaves this instance&quot;) an. Betreiber, die keinen<br />Datenabfluss wollen, konfigurieren schlicht keinen <code>RAG_MISTRAL_OCR_API_KEY</code><br />— Scan-Uploads antworten dann sauber mit <code>409 code:&quot;unconfigured&quot;</code> (#515),<br />nie mit einem 5xx.</p>\n<p><strong>Persistierte Markdown-Quelle / Reindex ohne OCR:</strong> Das normalisierte<br />Markdown wird pro <code>(user_id, document_id)</code> in der Tabelle<br /><code>rag_markdown_sources</code> gespeichert (user-scoped, ADR-054). Re-Chunking/<br />Reindex (<code>reindexDocument()</code> in <code>src/lib/file-ingest.ts</code>) liest diese Quelle<br />und läuft <strong>ohne erneutes OCR</strong>. Re-Ingest derselben Dokument-Id ersetzt<br />Chunks <strong>und</strong> Markdown-Quelle vollständig (id-stabil, kein<br />Duplikat-Wachstum).</p>\n<p><strong>Konfiguration:</strong></p>\n<pre><code class=\"lang-bash\">RAG_INGEST_FILE_MAX_BYTES=20971520          # Upload-Limit (Default 20 MiB)\nRAG_MISTRAL_OCR_API_KEY=&lt;key&gt;               # aktiviert mistral-ocr (Fallback: MISTRAL_API_KEY)\nRAG_MISTRAL_OCR_BASE_URL=https://api.mistral.ai\nRAG_MISTRAL_OCR_MODEL=mistral-ocr-latest\n# Optional fuer bessere lokale Text-PDF-Extraktion: poppler-utils installieren\n# (apt-get install poppler-utils) — ohne poppler greift der eingebaute\n# Extraktor fuer einfache PDFs; komprimierte Text-PDFs brauchen dann OCR.</code></pre>\n<h2>Qdrant-Sidecar (Operator)</h2>\n<pre><code class=\"lang-bash\"># Start the sidecar\ndocker compose up -d qdrant</code></pre>\n<p># Smoke-check<br />curl -s http://localhost:6333/readyz</p>\n<p># Defaults already match; override only if needed<br /># RAG_VECTOR_STORE_URL=http://qdrant:6333   # inside the compose network<br /># RAG_VECTOR_STORE_ENABLED=false            # disable RAG entirely<br />```</p>\n<p>Env vars: <code>RAG_VECTOR_STORE_URL</code>, <code>RAG_VECTOR_STORE_API_KEY</code>,<br /><code>RAG_VECTOR_STORE_ENABLED</code>, <code>RAG_EMBEDDING_PROVIDER</code>,<br /><code>RAG_EMBEDDING_DIMENSIONS</code>. Siehe aiopta-core <code>.env.example</code> + <code>docs/rag.md</code>.</p>\n<h2>Slice-Plan (Issue #587)</h2>\n<p>- <strong>Slice 1 (dieser PR):</strong> Standalone Addon-Surface + Manifest. Konsumiert<br />  <code>src/lib/rag/</code> + Qdrant-Sidecar. <strong>Keine</strong> Consumer-Integration.<br />- <strong>Slice 2 (Folge-Issue):</strong> memories-Integration (<code>searchMemories()</code><br />  Vektor-Search-Fallback + Isolation + Store-Link).<br />- <strong>Slice 3 (Folge-Issue):</strong> MCP-Server für RAG (<code>rag-tools</code>, read-only search)<br />  + realer Embedding-Provider (OpenAI/Ollama).</p>",
          "byteSize": 9546
        }
      }
    },
    {
      "id": "real-estate-investor",
      "uuid": "d5cb732a-73d8-478a-8f77-519df4ca5086",
      "name": "Real Estate Investor",
      "version": "1.0.4",
      "description": "Tracks investment property leads from IMAP and external market sources, calculates deal metrics, and ranks opportunities by configurable scoring.",
      "author": "Aiopta",
      "icon": "🏘️",
      "capabilities": [
        "real-estate-tracking",
        "imap-ingest",
        "market-comparison",
        "deal-scoring",
        "investment-analysis"
      ],
      "tags": [],
      "download": "/artifacts/real-estate-investor-1.0.4.zip",
      "sha256": "65a8e996dec34cc7840b3172860207b78d31dd47b76ecf1b042dbb23f6693839",
      "size": 148572,
      "page": {
        "id": "real-estate-investor",
        "name": "Real Estate Investor",
        "path": "/addons/real-estate-investor",
        "icon": "🏘️",
        "section": "Finance",
        "component": "@addons/real-estate-investor/src/components/RealEstateInvestorPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/real-estate-investor/status",
        "/api/v1/addons/real-estate-investor/config",
        "/api/v1/addons/real-estate-investor/regions",
        "/api/v1/addons/real-estate-investor/sources",
        "/api/v1/addons/real-estate-investor/sync/mail",
        "/api/v1/addons/real-estate-investor/sync/market",
        "/api/v1/addons/real-estate-investor/candidates",
        "/api/v1/addons/real-estate-investor/candidates/:id/promote",
        "/api/v1/addons/real-estate-investor/objects",
        "/api/v1/addons/real-estate-investor/objects/:id"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>🏘️ Real Estate Investor</h1>\n<p>Tracks investment property leads from IMAP and external market sources, calculates deal metrics, and ranks opportunities by configurable scoring.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>real-estate-investor</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> real-estate-tracking, imap-ingest, market-comparison, deal-scoring, investment-analysis</li><li><strong>UI-Seite:</strong> <code>/addons/real-estate-investor</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET, POST | <code>/api/v1/addons/real-estate-investor/candidates</code> |<br />| POST | <code>/api/v1/addons/real-estate-investor/candidates/[id]/promote</code> |<br />| GET, POST | <code>/api/v1/addons/real-estate-investor/config</code> |<br />| GET, POST | <code>/api/v1/addons/real-estate-investor/objects</code> |<br />| GET, PUT | <code>/api/v1/addons/real-estate-investor/objects/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/real-estate-investor/regions</code> |<br />| GET, POST | <code>/api/v1/addons/real-estate-investor/sources</code> |<br />| GET | <code>/api/v1/addons/real-estate-investor/status</code> |<br />| POST | <code>/api/v1/addons/real-estate-investor/sync/mail</code> |<br />| POST | <code>/api/v1/addons/real-estate-investor/sync/market</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>Im Manifest <strong>deaktiviert</strong> (<code>enabled: false</code>).</li></ul>\n<blockquote><p>README aus <code>addon.json</code> + vorhandenen Routen generiert (<code>scripts/generate-addon-readmes.js</code>). Bei manuellen Ergänzungen den Generator-Hinweis entfernen.</p></blockquote>",
          "byteSize": 1407
        }
      }
    },
    {
      "id": "revenue-cat",
      "uuid": "41e76065-d882-4591-b1c6-51d51cf47553",
      "name": "Revenue Cat (Subscription-Management + Entitlements)",
      "version": "1.0.2",
      "description": "Subscription-Management über Revenue Cat — per-user BYOK (eigenes API-Projekt + Secret-API-Key via scopedStore(userId), ADR-054). Holt aktive Subscriptions, Entitlements, Customer-Info und Offerings von Revenue Cats REST API (fetch, kein SDK) und brückt Revenue-Cat-Entitlements ins Core-Entitlement-System (hasEntitlement, core#583). Ein read-only MCP-Server (revenue-cat-tools) exponiert die Daten für Agenten (#384). Ohne konfigurierten API-Key läuft das Addon graceful im Lesemodus — KEIN Default/Anonymous-Zugriff (INBOX-86-Analogie). Keys werden NIE geloggt. Money-Stufe-1 (ADR-039).",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "subscriptions",
        "entitlements",
        "customers",
        "offerings"
      ],
      "tags": [],
      "download": "/artifacts/revenue-cat-1.0.2.zip",
      "sha256": "fedfcb462ad452a128babc88a77435bbaedd50647a81568aa6563068e40b03e7",
      "size": 99685,
      "page": {
        "id": "revenue-cat-page",
        "path": "/addons/revenue-cat",
        "name": "Revenue Cat",
        "icon": "🐱",
        "section": "Money",
        "component": "@addons/revenue-cat/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/revenue-cat/status",
        "/api/v1/addons/revenue-cat/config",
        "/api/v1/addons/revenue-cat/subscriptions",
        "/api/v1/addons/revenue-cat/entitlements",
        "/api/v1/addons/revenue-cat/customer",
        "/api/v1/addons/revenue-cat/offerings",
        "/api/v1/addons/revenue-cat/widget"
      ],
      "skills": [],
      "mcpServers": [
        {
          "id": "revenue-cat-tools",
          "displayName": "Revenue Cat tools",
          "description": "Read-only Revenue Cat companion tools for agents: normalize/filter active subscriptions, project a customer's entitlements, summarize the customer info (active subs + non-subscription purchases) and project an offerings/package tree — faithful ports of the revenue-cat addon's src/lib projection helpers (issue #545, epic #399). Mutates nothing; live per-user Revenue Cat access is resolved core-side via the declared config/credential slots.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "revenue-cat-tools/server.mjs",
          "permissions": [
            "addons/revenue-cat/read"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [
            "projectId"
          ],
          "credentialRefs": [
            "apiKey"
          ],
          "archiveEntry": "revenue-cat/mcp/servers/revenue-cat-tools/server.mjs",
          "sha256": "f5f50508a918f474995661bab9f6f54c9cd5ee1f8397445d59ebe554133f3eea",
          "size": 19924,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Revenue Cat Addon — Subscription-Management + Entitlements + MCP (#545)</h1>\n<p>Subscription-Management über <a href=\"https://www.revenuecat.com/\" rel=\"noopener noreferrer\">Revenue Cat</a> — per-user<br />BYOK (Bring-Your-Own-Key), an Aiopta angebunden. Holt aktive Subscriptions,<br />Entitlements, Customer-Info und Offerings von Revenue Cats REST API und brückt<br />Revenue-Cat-Entitlements ins Core-Entitlement-System (<code>hasEntitlement</code>, core#583).<br />Ein read-only MCP-Server exponiert die Daten für Agenten.</p>\n<blockquote><p>Owner-Entscheidung D5 (2026-07-08): neue Commerce/Payment-Addons. Revenue Cat</p><p>ist <strong>neu</strong> — kein bestehendes Aiopta-Addon deckt es ab (<code>payments</code> → Stripe/</p><p>PayPal, <code>membership</code> → Abo-Modellierung, aber keines spricht Revenue Cat an).</p></blockquote>\n<h2>Eigenschaften</h2>\n<p>- <strong>BYOK per-user (ADR-054):</strong> jeder User hinterlegt seinen eigenen Revenue Cat<br />  <code>projectId</code> + Secret-API-Key über <code>scopedStore(userId)</code>. Der Key wird <strong>nie</strong><br />  geloggt und <strong>nie</strong> über die API zurückgegeben (nur ein boolean <code>hasApiKey</code>).<br />- <strong>Graceful Read-Mode:</strong> ohne konfigurierten Key läuft das Addon im Lesemodus —<br />  <strong>kein</strong> Default/Anonymous-Zugriff (INBOX-86-Analogie, wie das <code>payments</code>-Addon).<br />- <strong>User-Isolation:</strong> User A sieht <strong>nur</strong> seine eigenen Revenue-Cat-Daten<br />  (<code>user_id</code>-Filter via <code>scopedStore</code>).<br />- <strong>Entitlement-Bridge:</strong> Revenue-Cat-Entitlements → Aiopta-Entitlement-Keys<br />  (<code>addon.&lt;id&gt;</code> / <code>rc.&lt;id&gt;</code>), die mit dem Core-Gate (<code>hasEntitlement</code>,<br />  <code>hasAddonEntitlement</code>, core#583) komponieren.<br />- <strong>MCP-Server (read-only):</strong> <code>revenue-cat-tools</code> exponiert Projektions-Tools für<br />  Agenten (<code>list_subscriptions</code>, <code>get_entitlements</code>, <code>get_customer</code>,<br />  <code>list_offerings</code>) — validiert gegen den <code>#384</code>-Contract.</p>\n<h2>API-Endpunkte</h2>\n<p>Alle Endpunkte sind namespaced unter <code>/api/v1/addons/revenue-cat</code>. Daten-Endpunkte<br />benötigen <code>customer_id</code> (den Revenue Cat app user id) und einen konfigurierten Key.</p>\n<p>| Methode | Pfad | Beschreibung |<br />|---|---|---|<br />| GET | <code>/status</code> | Konfiguriert? (boolean, kein Secret-Leak) |<br />| GET/POST | <code>/config</code> | BYOK-Config lesen/schreiben (Key nie zurückgegeben) |<br />| GET | <code>/subscriptions?customer_id=&lt;id&gt;&amp;active=true</code> | Subscriptions eines Kunden |<br />| GET | <code>/entitlements?customer_id=&lt;id&gt;</code> | Entitlements + Aiopta-Keys |<br />| GET | <code>/customer?customer_id=&lt;id&gt;</code> | Customer-Info + Summary |<br />| GET | <code>/offerings</code> | Project-Offerings |<br />| GET | <code>/widget</code> | Dashboard-Key-Values (wirft nie) |</p>\n<h2>BYOK konfigurieren</h2>\n<pre><code class=\"lang-bash\">curl -X POST /api/v1/addons/revenue-cat/config \\\n  -H &#39;Content-Type: application/json&#39; \\\n  -d &#39;{&quot;projectId&quot;:&quot;abc123def&quot;,&quot;apiKey&quot;:&quot;sk_xxx_…&quot;}&#39;</code></pre>\n<p>Der <code>apiKey</code> (Secret-API-Key aus dem Revenue Cat Dashboard) wird per-user gespeichert<br />und nie zurückgegeben. <code>projectId</code> ist die Projekt-ID aus der Dashboard-URL.</p>\n<h2>MCP-Server <code>revenue-cat-tools</code></h2>\n<p>Ein Node-STDIO-Server (Contract <code>#384</code>), deklariert in <code>addon.json</code> → <code>mcpServers[]</code>.<br />Permissions: <code>[&quot;addons/revenue-cat/read&quot;]</code>, <code>toolPolicy: read-only</code>. BYOK-Slots:<br /><code>configRefs: [&quot;projectId&quot;]</code>, <code>credentialRefs: [&quot;apiKey&quot;]</code> (per Namen, nie Werte —<br />die Core-Runtime löst sie pro aufrufendem User auf, core#454).</p>\n<p>Tools (alle read-only, annotiert <code>readOnlyHint:true</code>/<code>destructiveHint:false</code>):</p>\n<ul><li><code>list_subscriptions</code> — aktive Subscriptions filtern</li><li><code>get_entitlements</code> — aktive Entitlements → Aiopta-Keys (core#583)</li><li><code>get_customer</code> — Customer-Summary (aktive Abos, Entitlements, Käufe)</li><li><code>list_offerings</code> — Offerings/Packages projizieren</li></ul>\n<p>Validierung: <code>npm run test:mcp-servers</code> (Konformanz-Suite entdeckt das Addon<br />automatisch) + <code>scripts/addon-mcp-pilot-revenue-cat.test.cjs</code> (Golden Values).</p>\n<h2>Architektur</h2>\n<pre><code>addons/revenue-cat/\n  addon.json                         # Manifest + mcpServers[]-Deskriptor\n  README.md\n  src/\n    lib/\n      credential-store.ts            # BYOK per-user via scopedStore (ADR-054)\n      revenue-cat-client.ts          # REST-API-Client (fetch, kein SDK), BYOK\n      projections.ts                 # Pure Projektions-Helper (aktiv-Filter etc.)\n      entitlement-bridge.ts          # RC-Entitlements → Core hasEntitlement (core#583)\n    app/api/v1/addons/revenue-cat/   # Routen (status, config, subscriptions, …)\n    page.tsx                         # Read-only Management-Seite (Money-Section)\n  mcp/servers/revenue-cat-tools/\n    server.mjs                       # Read-only MCP-Server (faithful ports)\n  tests/unit/\n    manifest.test.ts\n    revenue-cat-isolation.test.ts\n    revenue-cat-graceful.test.ts\n    revenue-cat-projections.test.ts</code></pre>\n<p><strong>NICHT anfassen:</strong> Core <code>src/lib/entitlements.ts</code> (nur konsumieren),<br /><code>src/lib/license/*</code>, <code>payments</code>/<code>membership</code>-Addon-Internals, Core-Routen.</p>\n<h2>Money-Stufe &amp; Sicherheit</h2>\n<p>- Money-Stufe-1 (ADR-039). Webhook-Signature-Verifizierung folgt dem<br />  <code>payments</code>-Muster (falls Webhooks implementiert werden).<br />- Rate-Limit von Revenue Cat → Backoff/Retry (client-seitig via Timeout).<br />- Ungültiger API-Key → HTTP 401, Fehleranzeige, keine stille Recovery.</p>\n<h2>Tests</h2>\n<pre><code class=\"lang-bash\">npx vitest run addons/revenue-cat/tests/unit          # Addon-Unit (BYOK, Isolation, Graceful, Projektionen)\nnpm run test:mcp-servers                              # MCP-Konformanz + Golden Values</code></pre>",
          "byteSize": 5120
        }
      }
    },
    {
      "id": "reverse-proxy",
      "uuid": "3d3c832b-4b84-440c-8b9e-21ec883809f5",
      "name": "Reverse Proxy (NPM/Traefik/Caddy — Domains, Routen, SSL + Ablauf-Warnungen)",
      "version": "1.0.4",
      "description": "Per-User Reverse-Proxy-Monitoring: Verbindet sich BYOK mit Nginx Proxy Manager, Traefik oder Caddy. Listet Domains, Proxy-Routen und SSL-Zertifikate; warnt vor bald auslaufenden Zertifikaten (warn/critical) und prüft die Health der Routen. Read-only gegenüber dem Proxy, vollständig per-User über scopedStore isoliert (ADR-054).",
      "author": "Aiopta",
      "icon": "🛡️",
      "capabilities": [
        "reverse-proxy",
        "domains",
        "routes",
        "certs",
        "ssl",
        "health",
        "cert-expiry"
      ],
      "tags": [],
      "download": "/artifacts/reverse-proxy-1.0.4.zip",
      "sha256": "cd9e68c70cba83a298b11eb135338bf2a1ff9931d056c24cb201867e827976f2",
      "size": 115460,
      "page": {
        "id": "reverse-proxy-page",
        "path": "/addons/reverse-proxy",
        "name": "Reverse Proxy",
        "icon": "🛡️",
        "section": "Infra",
        "component": "@addons/reverse-proxy/src/components/ReverseProxyPage"
      },
      "deprecated": true,
      "replacedBy": "network",
      "deprecation": {
        "deprecated": true,
        "replacedBy": "network",
        "sunset": "2026-12-31",
        "successorPath": "/addons/network",
        "successorLabel": "Network/Homelab (Nachfolger)"
      },
      "endpoints": [
        "/api/v1/addons/reverse-proxy/config",
        "/api/v1/addons/reverse-proxy/routes",
        "/api/v1/addons/reverse-proxy/certs",
        "/api/v1/addons/reverse-proxy/health"
      ],
      "skills": [
        {
          "name": "reverse-proxy-read",
          "displayName": "Reverse Proxy: Domains, Routen & SSL-Zertifikate lesen",
          "description": "Listet Domains, Proxy-Routen und SSL-Zertifikate des konfigurierten Reverse Proxies (NPM/Traefik/Caddy) für den Nutzer und zeigt Zertifikats-Ablauf-Warnungen. Reiner Lesezugriff — legt nichts an.",
          "permissions": [
            "addons/reverse-proxy/config",
            "addons/reverse-proxy/routes",
            "addons/reverse-proxy/certs",
            "addons/reverse-proxy/health"
          ],
          "endpoint": "/api/v1/addons/reverse-proxy/routes",
          "scripts": []
        },
        {
          "name": "reverse-proxy-config",
          "displayName": "Reverse Proxy: Verbindung verwalten",
          "description": "Legt die BYOK-Verbindung zum Reverse Proxy an oder entfernt diese (per-User gebucht: provider, base URL, Token — niemals das Passwort im Klartext zurückgegeben).",
          "permissions": [
            "addons/reverse-proxy/config"
          ],
          "endpoint": "/api/v1/addons/reverse-proxy/config",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Reverse Proxy (NPM / Traefik / Caddy)</h1>\n<p>Per-User Reverse-Proxy-Monitoring (#515). BYOK-Verbindung zu Nginx Proxy<br />Manager, Traefik oder Caddy; listet Domains, Proxy-Routen und SSL-Zertifikate;<br />warnt vor bald auslaufenden Zertifikaten (<code>warn</code> / <code>critical</code> / <code>expired</code>) und<br />prüft die Health überwachter Routen.</p>\n<h2>Eigenschaften</h2>\n<p>- <strong>BYOK per User</strong> — jeder Nutzer hinterlegt seine eigene Proxy-Verbindung<br />  (provider, base URL, token). Token wird gespeichert, aber niemals im<br />  Klartext zurückgegeben (maskiert).<br />- <strong>Read-only gegenüber dem Proxy</strong> — das Addon mutiert nie eine Upstream-Route<br />  oder ein Zertifikat. Es spiegelt und klassifiziert nur.<br />- <strong>Per-User isoliert</strong> — alle Daten via <code>scopedStore</code> + <code>registerScopedTable</code><br />  (ADR-054). Eine fremde id → 404 ohne Existenz-Leak.<br />- <strong>Cert-Expiry-Evaluator (pure)</strong> — `notAfter → days-remaining →<br />  ok/warn/critical/expired/unknown`. Schwellen default 30/7 Tage.<br />- <strong>Gracious Degradation</strong> — ein nicht erreichbarer Proxy skippt die Verbindung,<br />  statt die gesamte Anzeige scheitern zu lassen.</p>\n<h2>Struktur</h2>\n<pre><code>src/\n  manifest.ts            # Extension-Manifest (Routes + Page + install/uninstall)\n  page.ts                # Page-Deskriptor\n  components/\n    ReverseProxyPage.tsx # Dashboard (Domains / SSL / Health / Verbindungen)\n  lib/\n    rp-store.ts               # scopedStore: connections, routes, certs\n    cert-expiry-evaluator.ts  # PURE: notAfter → days-remaining → level\n    proxy-types.ts            # neutrale Shapes + safeFetch + injectable fetch\n    proxy-clients.ts          # provider dispatch\n    npm-client.ts             # Nginx Proxy Manager (read-only)\n    traefik-client.ts         # Traefik (read-only)\n    caddy-client.ts           # Caddy admin API (read-only)\n  app/api/v1/addons/reverse-proxy/\n    config/route.ts + [id]/route.ts\n    routes/route.ts + [id]/route.ts\n    certs/route.ts\n    health/route.ts\ntests/unit/\n    cert-expiry.test.ts       # reiner Evaluator (kein DB)\n    proxy-clients.test.ts     # fetch seam gemockt, provider-mapping\n    reverse-proxy-isolation.test.ts  # store-level per-user isolation</code></pre>\n<h2>Routen</h2>\n<p>| Method | Path | Beschreibung |<br />|--------|------|--------------|<br />| GET    | <code>/api/v1/addons/reverse-proxy/config</code> | Verbindungen (maskiert) |<br />| POST   | <code>/api/v1/addons/reverse-proxy/config</code> | Verbindung anlegen |<br />| DELETE | <code>/api/v1/addons/reverse-proxy/config/:id</code> | Verbindung entfernen |<br />| GET    | <code>/api/v1/addons/reverse-proxy/routes</code> | Monitored domains (<code>?sync=1</code> spiegelt Zertifikate) |<br />| POST   | <code>/api/v1/addons/reverse-proxy/routes</code> | Domain überwachen |<br />| PATCH  | <code>/api/v1/addons/reverse-proxy/routes/:id</code> | aktiv/inaktiv |<br />| DELETE | <code>/api/v1/addons/reverse-proxy/routes/:id</code> | entfernen |<br />| GET    | <code>/api/v1/addons/reverse-proxy/certs</code> | Zertifikate + Expiry-Level + worst-Level |<br />| GET    | <code>/api/v1/addons/reverse-proxy/health</code> | Probed aktiver Routen (up/down/unknown) |</p>",
          "byteSize": 2929
        }
      }
    },
    {
      "id": "seo-content",
      "uuid": "f877087a-5162-4d62-97d3-861752855653",
      "name": "SEO Content Engine",
      "version": "1.0.3",
      "description": "Keyword → AI draft → on-page optimize → publish pipeline plus rank tracking. Research goes through the websearch addon, AI drafting through the MC gateway, publishing through the wordpress addon (all via MC-API); the rank-tracker SEO-data provider is an external SaaS reached with the owner's API key from MC-config (no container, ADR-014).",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "seo-keyword-research",
        "seo-article-generation",
        "seo-on-page-analysis",
        "seo-publishing",
        "seo-rank-tracking"
      ],
      "tags": [],
      "download": "/artifacts/seo-content-1.0.3.zip",
      "sha256": "15887a1b3bc0d39c7c19932e4d1c2cce78a4dd6704f8e3a9c79093b5ec885d42",
      "size": 160402,
      "page": {
        "id": "seo-content-page",
        "path": "/addons/seo-content",
        "name": "SEO Content Engine",
        "icon": "📈",
        "section": "Money",
        "component": "@addons/seo-content/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/seo-content/status",
        "/api/v1/addons/seo-content/keywords",
        "/api/v1/addons/seo-content/articles",
        "/api/v1/addons/seo-content/articles/:id",
        "/api/v1/addons/seo-content/articles/:id/optimize",
        "/api/v1/addons/seo-content/articles/:id/publish",
        "/api/v1/addons/seo-content/rankings",
        "/api/v1/addons/seo-content/widget"
      ],
      "skills": [
        {
          "name": "seo-keyword-research",
          "displayName": "SEO: Keyword Research",
          "description": "Research a query (via the websearch addon) into scored keyword suggestions, and save target keywords into the content pipeline.",
          "permissions": [
            "addons/seo-content/keywords",
            "addons/seo-content/status"
          ],
          "endpoint": "/api/v1/addons/seo-content/keywords",
          "scripts": []
        },
        {
          "name": "seo-article-pipeline",
          "displayName": "SEO: Draft & Optimize Articles",
          "description": "Generate an AI article draft (via the MC gateway), edit it, and run pure on-page analysis (title/meta/density/links). Drafting is never a direct LLM call.",
          "permissions": [
            "addons/seo-content/articles"
          ],
          "endpoint": "/api/v1/addons/seo-content/articles",
          "scripts": []
        },
        {
          "name": "seo-publish",
          "displayName": "SEO: Publish to WordPress",
          "description": "Publish a finished draft to WordPress via the wordpress addon's MC-API. Fails soft if WordPress is not installed; never talks to WordPress directly.",
          "permissions": [
            "addons/seo-content/articles"
          ],
          "endpoint": "/api/v1/addons/seo-content/articles/:id/publish",
          "scripts": []
        },
        {
          "name": "seo-rank-tracking",
          "displayName": "SEO: Rank Tracking",
          "description": "Read live SERP positions for tracked keywords via the owner-keyed SEO-data SaaS. Missing key returns a clear 400; no provider call without a key.",
          "permissions": [
            "addons/seo-content/rankings",
            "addons/seo-content/status"
          ],
          "endpoint": "/api/v1/addons/seo-content/rankings",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>SEO Content Engine Addon</h1>\n<p>Run an <strong>SEO content pipeline</strong> from inside Aiopta: research keywords, generate an AI article<br /><strong>draft</strong>, run <strong>on-page</strong> optimization, <strong>publish</strong> to WordPress, and track <strong>rankings</strong>. Usable by<br />agents (via the addon API) and by humans (via the management page). This is the second money tool in<br />the series (after dropshipping, ADR-013), covering REQUIREMENTS-AUDIT row 26 &quot;SEO/Content&quot;.</p>\n<pre><code>keyword research → AI draft → on-page optimize → publish → rank-track\n   (websearch)      (gateway)   (pure analyzer)   (wordpress)  (owner SaaS)</code></pre>\n<h2>Architecture (ADR-014)</h2>\n<p>The engine is deliberately a <strong>thin orchestrator</strong> over capabilities Aiopta already owns — it<br />adds no new direct integration except the owner-keyed rank tracker:</p>\n<p>- <strong>AI generation goes through <code>@/lib/gateway</code> — never a direct LLM/provider call.</strong><br />  <code>src/lib/article-generator.ts</code> builds the prompt and calls <code>chatCompletionWithFallbacks(...)</code>; the<br />  gateway decides provider/backend routing. There is <strong>no provider <code>fetch</code></strong> in this addon (a unit test<br />  stubs global <code>fetch</code> and asserts it is never called during generation). The model returns a fenced<br />  ``<code>json block which we strip + parse into </code>{ title, slug, markdown, metaDescription }`.<br />- <strong>Research builds on the consolidated research suite (websearch addon) via the Aiopta API.</strong><br />  <code>src/lib/keyword-research.ts</code> calls <code>POST /api/v1/addons/websearch/search</code> through the Aiopta API,<br />  <strong>forwarding the caller&#39;s auth</strong> (RBAC preserved). It never re-implements search and never imports<br />  the websearch addon directly. The raw SERP hits feed a <strong>pure</strong> scoring helper<br />  (difficulty/opportunity) that is fully unit-tested.<br />- <strong>Publishing goes through the wordpress addon&#39;s Aiopta API — never WordPress directly.</strong><br />  <code>src/lib/wordpress-bridge.ts</code> calls <code>POST /api/v1/addons/wordpress/posts</code> (or <code>/pages</code>), forwarding<br />  auth, and <strong>fails soft</strong> with a clear error if the wordpress addon is absent / not configured.<br />- <strong>The rank-tracker is an external SEO-data SaaS reached with the owner&#39;s key — NO container.</strong><br />  <code>src/lib/rank-tracker.ts</code> reads <code>SEO_API_KEY</code> (+ <code>SEO_API_PROVIDER</code>) from<br />  <code>getAddonConfiguration(&quot;seo-content&quot;)</code> — the same owner-key pattern as the LLM providers and the<br />  dropshipping suppliers. Provider base URLs (DataForSEO / SerpApi) are <strong>compile-time constants</strong>,<br />  never a user-entered host; the key is sent as a Bearer token and <strong>never logged</strong>. A **missing key<br />  returns a clean 400 before any fetch** (no unauthenticated call ever goes out).<br />- <strong>The pipeline owns a table.</strong> Unlike the dropshipping margins (a single config blob), the pipeline<br />  has real state, so the addon owns <code>seo_content_items</code>, created in the extension <code>install()</code> and<br />  dropped in <code>uninstall()</code> via Aiopta core&#39;s <code>getDb()</code>. On-page analysis (<code>src/lib/on-page.ts</code>) is <strong>pure</strong><br />  (no I/O) and unit-tested.<br />- <strong>Per-user isolation (#146 / ADR-054).</strong> <code>seo_content_items</code> carries <code>user_id NOT NULL</code>; all CRUD<br />  goes through <code>scopedStore(auth.id)</code>, the server stamps the owner from <code>auth.id</code>, and a foreign id<br />  returns 404. A user only ever sees/edits/publishes/ranks their own articles, and on-page<br />  optimization draws internal-link candidates only from the caller&#39;s own pipeline (never another<br />  user&#39;s slugs). DDL runs via <code>db.prepare(sql).run()</code> (never <code>db.exec</code>); a legacy table is backfilled<br />  to the first admin (logged) or its un-attributable rows dropped.</p>\n<h2>Configuration (Aiopta addon-config — no secret committed)</h2>\n<p>| Key | Required | Purpose |<br />|-----|----------|---------|<br />| <code>SEO_API_KEY</code> | for rank tracking | SEO-data SaaS API token (Bearer). Stored <code>is_secret=1</code>. |<br />| <code>SEO_API_PROVIDER</code> | optional | <code>dataforseo</code> (default) or <code>serpapi</code>. |<br />| <code>SEO_API_LOCATION</code> | optional | Default location/country code passed to the provider. |</p>\n<p>Set them with <code>setAddonConfiguration(&quot;seo-content&quot;, { SEO_API_KEY: &quot;…&quot; })</code>. Keyword research and AI<br />drafting work <strong>without</strong> this key (they use the websearch addon + the gateway). Only <strong>rank tracking</strong><br />needs it; until it is set, <code>/status</code> reports <code>rankTracker.configured: false</code> and <code>/rankings</code> returns a<br />clear <strong>400</strong>.</p>\n<h2>API</h2>\n<p>All routes are namespaced under <code>/api/v1/addons/seo-content/</code> and require auth.</p>\n<pre><code>GET  /status                              → rank-tracker config + AI-gateway reachability + pipeline summary\nGET  /keywords?q=…                        → research a query (via websearch) → scored suggestions\nGET  /keywords                            → saved target keywords (stage = keyword)\nPOST /keywords    { keyword }             → save a target keyword into the pipeline\nGET  /articles?stage=                     → list the pipeline (optionally by stage)\nPOST /articles    { keyword, itemId?, … } → generate an AI DRAFT (via the gateway), persisted\nGET  /articles/{id}                       → one pipeline item\nPUT  /articles/{id} { title?, markdown?, metaDescription?, slug?, keyword? } → edit the draft\nDELETE /articles/{id}                     → remove a pipeline item\nPOST /articles/{id}/optimize              → pure on-page analysis → score + recommendations (stage→optimized)\nPOST /articles/{id}/publish { type?, status? } → publish to WordPress via the wordpress addon (stage→published)\nGET  /rankings?keywords=a,b&amp;domain=&amp;location= → live SERP positions (owner SEO key)\nGET  /widget                              → flat key-values for the dashboard widget</code></pre>\n<p>Responses use the <code>{ &quot;success&quot;: true, &quot;data&quot;: … }</code> envelope. On failure read <code>error</code> + the HTTP status<br />(a <code>400</code> on <code>/rankings</code> means <code>SEO_API_KEY</code> is not set; a publish/research failure forwards the<br />dependent addon&#39;s status).</p>\n<h3>On-page scoring (pure)</h3>\n<p><code>src/lib/on-page.ts</code> is pure + unit-tested. It scores a draft 0–100 from title/meta length, keyword<br />presence + density (healthy window 0.5–2.5%), word count, heading usage, and suggests internal links to<br />sibling pipeline items that share a meaningful term. No AI is involved — the model <em>writes</em> the draft;<br />this module <em>measures</em> it deterministically.</p>\n<h2>Layout</h2>\n<pre><code>addons/seo-content/\n├── addon.json                  # Manifest (page + widget + endpoints + skills; NO container)\n├── README.md\n├── SKILL.md                    # Agent skill\n├── src/\n│   ├── manifest.ts             # Extension (dual-registration; string-literal handlers; install/uninstall table)\n│   ├── page.tsx                # Pipeline UI (Pipeline / Keyword Research / Rankings)\n│   ├── widget.tsx              # Dashboard widget (pipeline counts + rank-tracker availability, declarative)\n│   ├── lib/\n│   │   ├── errors.ts           # SeoContentError (HTTP-status-carrying)\n│   │   ├── keyword-research.ts # websearch via Aiopta-API + pure scoring (difficulty/opportunity)\n│   │   ├── article-generator.ts# prompt → @/lib/gateway → draft {title,slug,markdown,meta}; fence-strip\n│   │   ├── on-page.ts          # PURE on-page analyzer (score + recommendations + internal links)\n│   │   ├── rank-tracker.ts     # SEO-data SaaS client (SEO_API_KEY from Aiopta-config; missing key → 400)\n│   │   ├── wordpress-bridge.ts # publish via the wordpress addon&#39;s Aiopta-API (fail-soft)\n│   │   ├── content-store.ts    # pipeline persistence (seo_content_items table via getDb)\n│   │   └── status.ts           # /status payload (providers + gateway reachability + summary)\n│   └── app/api/v1/addons/seo-content/{status,keywords,articles,articles/[id],articles/[id]/optimize,articles/[id]/publish,rankings,widget}/route.ts\n└── tests/unit/                 # manifest validity + pure on-page + pure scoring + gateway-routing + rank-tracker 400</code></pre>\n<h2>Owner-parked items (non-blocking)</h2>\n<p>The addon code + routes work today: research uses the websearch addon, drafting uses the gateway,<br />publishing uses the wordpress addon. Only <strong>rank tracking</strong> needs an owner SEO key. Tracked in<br /><code>docs/OPEN-DECISIONS.md</code>:</p>\n<blockquote><p><strong>TODO(owner): <code>seo-data-provider-keys</code></strong> — create a DataForSEO or SerpApi key and store it in Aiopta</p><p>addon-config (<code>SEO_API_KEY</code>, optional <code>SEO_API_PROVIDER</code>). No secret is committed. Until set,</p><p><code>/status</code> reports rank tracking not-configured and <code>/rankings</code> returns a clear 400.</p></blockquote>\n<h2>License</h2>\n<p>MIT</p>",
          "byteSize": 8332
        }
      }
    },
    {
      "id": "shopify",
      "uuid": "093be941-2064-484f-aba4-51b9e232ea08",
      "name": "Shopify",
      "version": "2.0.3",
      "description": "DEPRECATED — zusammengeführt in das kanonische Commerce-Addon `commerce` als Shopify-Connector (ADR-008 Addon-Konsolidierung, Issue #588 / Epic #492). Der Shopify-Channel ist als Feature-Modul unter `commerce/src/connectors/shopify/` migriert: BYOK-Credentials in der gemeinsamen `commerce_connector_config`-Tabelle, Sync-Client auf dem kanonischen `CommerceChannel`-Vertrag, Alt-ID-Redirect `/addons/shopify` → `/addons/commerce?connector=shopify`. shopify bleibt bis zum Sunset (2026-12-31) voll funktionsfähig und datenerhaltend — kein Endpoint entfällt, keine Zeile wird kopiert/gelöscht. Ursprüngliche Fähigkeit: Commerce channel: sync products, stock & orders from your Shopify shop via the Admin API (external SaaS). Each user brings their own access token (BYOK) stored per-user through the scoped data-access layer (ADR-054); no secret is hard-coded or logged and there is no container. Sync is always an explicit call.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "commerce-shopify",
        "shopify-products",
        "shopify-stock",
        "shopify-orders",
        "shopify-sync"
      ],
      "tags": [],
      "download": "/artifacts/shopify-2.0.3.zip",
      "sha256": "e8d3f950f9b3336dcc52bb7ba0d8567dcd5d740a9f1cf6796c2541d8905bd5d2",
      "size": 126361,
      "page": {
        "id": "shopify-page",
        "path": "/addons/shopify",
        "name": "Shopify",
        "icon": "🛍️",
        "section": "Money",
        "component": "@addons/shopify/src/page"
      },
      "deprecated": true,
      "replacedBy": "commerce",
      "deprecation": {
        "deprecated": true,
        "replacedBy": "commerce",
        "sunset": "2026-12-31",
        "successorPath": "/addons/commerce",
        "successorLabel": "Commerce (Nachfolger)"
      },
      "endpoints": [
        "/api/v1/addons/shopify/status",
        "/api/v1/addons/shopify/config",
        "/api/v1/addons/shopify/products",
        "/api/v1/addons/shopify/stock",
        "/api/v1/addons/shopify/orders",
        "/api/v1/addons/shopify/sync"
      ],
      "skills": [
        {
          "name": "shopify-sync",
          "displayName": "Shopify: Sync products / stock / orders",
          "description": "Trigger an explicit sync of the caller's Shopify shop (products, stock and orders) via the Admin API using the user's BYOK access token.",
          "permissions": [
            "addons/shopify/sync",
            "addons/shopify/products",
            "addons/shopify/orders"
          ],
          "endpoint": "/api/v1/addons/shopify/sync",
          "scripts": []
        },
        {
          "name": "shopify-products",
          "displayName": "Shopify: Products & Stock",
          "description": "List the caller's cached Shopify products and stock levels, or write back a stock level to Shopify.",
          "permissions": [
            "addons/shopify/products",
            "addons/shopify/stock"
          ],
          "endpoint": "/api/v1/addons/shopify/products",
          "scripts": []
        },
        {
          "name": "shopify-orders",
          "displayName": "Shopify: Orders",
          "description": "List the caller's cached Shopify orders.",
          "permissions": [
            "addons/shopify/orders"
          ],
          "endpoint": "/api/v1/addons/shopify/orders",
          "scripts": []
        }
      ],
      "mcpServers": [
        {
          "id": "shopify-tools",
          "displayName": "Shopify tools",
          "description": "Read-only Shopify commerce-channel companion tools for agents: build the Admin API base URL from a shop domain, normalise product/variant payloads into the channel's CommerceProduct shape, roll up variant inventory, normalise order payloads, and summarise a sync result - faithful ports of the addon's src/lib modules (issue #442, epic #399). Mutates nothing; live per-user Shopify access is resolved core-side via the declared config/credential slots.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "shopify-tools/server.mjs",
          "permissions": [
            "addons/shopify/read"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [
            "shopDomain",
            "apiVersion",
            "locationId"
          ],
          "credentialRefs": [
            "accessToken"
          ],
          "archiveEntry": "shopify/mcp/servers/shopify-tools/server.mjs",
          "sha256": "cce740612ed7dd55be79f90bacf452f6170849e79c4fcb860ece95bef9676c18",
          "size": 20421,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": null
    },
    {
      "id": "skills",
      "uuid": "24b7741d-1b5c-4885-8fbc-9a2a90f0f4a4",
      "name": "Skills",
      "version": "1.0.12",
      "description": "Skill catalog, local skill management, ClawHub integration, and dynamic skill generation helpers.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "skills",
        "clawhub",
        "skill-repository",
        "dynamic-skills"
      ],
      "tags": [],
      "download": "/artifacts/skills-1.0.12.zip",
      "sha256": "5ec7f830daf4ef04c826fe5f7b93ba7309f0c98738eac32667348870b1bf1a2b",
      "size": 277438,
      "page": {
        "id": "skills",
        "path": "/addons/skills",
        "name": "Skills",
        "icon": "🧩",
        "section": "Agents",
        "component": "@addons/skills/src/components/skills/SkillsPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/skills",
        "/api/v1/addons/skills/clawhub",
        "/api/v1/addons/skills/repository"
      ],
      "skills": [
        {
          "name": "skills-list",
          "displayName": "Skills: lokal auflisten",
          "description": "Listet die lokal installierten Skills aus dem Skills-Catalog. Reine Lese-Operation, kein Netzwerk-Call.",
          "permissions": [
            "addons/skills/list"
          ],
          "endpoint": "/api/v1/addons/skills",
          "scripts": [
            {
              "id": "skills-list-local",
              "name": "skills-list-local",
              "path": "skills-list/list.sh",
              "sha256": "fe3d0b5e133713889db65772d238994030924ce556ed1de662fd11ad90d8287e",
              "interpreter": "bash",
              "permissions": [
                "addons/skills/list"
              ],
              "timeoutMs": 5000,
              "size": 864
            }
          ]
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Skills</h1>\n<p>Skill catalog, local skill management, ClawHub integration, and dynamic skill generation helpers.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>skills</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> skills, clawhub, skill-repository, dynamic-skills</li><li><strong>UI-Seite:</strong> <code>/skills</code></li></ul>\n<h2>Lokale vs. Remote-Skills</h2>\n<p>Das Addon hat <strong>zwei voneinander unabhängige Pfade</strong>:</p>\n<h3>Lokale Skills (immer verfügbar, ohne Secrets) — <strong>per User isoliert</strong></h3>\n<p>Lokale Skills liegen im Dateisystem. Seit der per-User-Isolation (#145 / INBOX-59 / ADR-054) liegen<br />die <strong>eigenen erstellten/installierten</strong> Skills eines Users unter dessen <strong>per-User-FS-Root</strong><br />(<code>resolveUserPath(user, &quot;skills&quot;, &quot;local&quot;, …)</code>, containment-checked) statt im früheren<br />prozessweiten <code>…/skills/local</code>. Diese funktionieren <strong>vollständig ohne externen Dienst</strong>:<br />Durchsuchen, <code>SKILL.md</code> ansehen/bearbeiten, neuen Skill anlegen, in einen Agent-Workspace deployen<br />(<code>installSkillToWorkspace</code>), aktivieren/deaktivieren — alles auf <code>auth.id</code> gescoped, sodass ein User<br />nie fremde Local-Skills sieht/ändert/löscht. Der lokale Browse-Pfad (<code>GET /api/v1/skills</code>) macht<br /><strong>keinen</strong> Netzwerk-Aufruf — eine fehlende/falsch konfigurierte Remote-Registry blockiert die lokalen<br />Skills nie.</p>\n<p>Der <strong>geteilte Katalog</strong> (builtin-/Projekt-Skill-Verzeichnisse + die remote ClawHub-Registry) bleibt<br /><strong>instanzweit, read-only</strong>: er wird jedem User gelistet, aber nie per-User geschrieben, und ein<br />Katalog-Skill ist nicht als eigener Local-Skill löschbar. Nur die EIGENEN Local-Instanzen sind<br />gescoped.</p>\n<h3>Remote-Registry (ClawHub — optional, Owner-Infra)</h3>\n<p>Browse/Install/Sync <strong>entfernter</strong> Skills läuft entweder über das <code>clawhub</code>-CLI<br />(falls installiert) <strong>oder</strong> über einen HTTP-<code>skill-repo</code>-Dienst. Die Basis-URL<br />dieses Dienstes kommt aus der <strong>Aiopta-Addon-Konfiguration</strong> (<code>SKILL_REPO_URL</code>) bzw. dem<br />Env-Fallback — <strong>kein</strong> Host ist fest verdrahtet. Ist nichts konfiguriert, ist der<br />Remote-Pfad in einem klaren <em>not-configured</em>-Zustand (keine stillen Fehler): die UI<br />zeigt einen informativen Leerzustand mit Konfigurationshinweis statt einer leeren<br />Liste oder eines Dauer-Spinners.</p>\n<blockquote><p>Solange weder das CLI installiert noch eine Registry konfiguriert ist, sind die</p><p>Remote-Funktionen erwartungsgemäß deaktiviert. Das ist Owner-Infrastruktur und in</p><p><code>docs/OPEN-DECISIONS.md</code> (<code>clawhub-skill-registry</code>) geparkt. **Lokale Skills sind</p><p>davon unberührt.**</p></blockquote>\n<h2>Einrichtung (Remote-Registry)</h2>\n<p>Lokale Skills brauchen <strong>keine</strong> Konfiguration. Um die Remote-Registry zu aktivieren:</p>\n<p>1. Entweder das <code>clawhub</code>-CLI im Aiopta-Container installieren (<code>which clawhub</code> muss<br />   erfolgreich sein), <strong>oder</strong><br />2. einen <code>skill-repo</code>-Dienst betreiben und dessen Basis-URL setzen — in der<br />   Aiopta-Addon-Konfiguration des <code>skills</code>-Addons unter dem Schlüssel<br />   <strong><code>SKILL_REPO_URL</code></strong> (z. B. <code>https://skill-repo.example.com</code>), oder ersatzweise<br />   über die Umgebungsvariable <code>SKILL_REPO_URL</code>. Die Addon-Konfiguration hat Vorrang<br />   vor der Env-Variable.</p>\n<p>Erwartete HTTP-API des <code>skill-repo</code>-Dienstes (siehe <code>SkillRepositoryClient</code> in<br /><code>src/lib/skills-repo.ts</code>): <code>GET /health</code>, <code>GET /api/stats</code>, <code>GET /api/skills</code>,<br /><code>GET /api/skills/:name</code>, <code>…/versions</code>, <code>…/dependencies</code>, <code>…/dependents</code>,<br /><code>GET /api/search</code>, <code>GET /api/categories</code>, <code>POST /api/sync</code>,<br /><code>POST /api/skills/:name/{install,uninstall,update}</code>. Ein leichtgewichtiger<br />Mock-Server für die Entwicklung liegt in <code>src/lib/mock-skill-repo.ts</code>.</p>\n<h2>Status / Diagnose</h2>\n<p><code>GET /api/v1/skills/clawhub?action=status</code> (oder <code>…/repository?action=status</code>)<br />liefert den echten Zustand:</p>\n<pre><code class=\"lang-json\">{ &quot;cliAvailable&quot;: false, &quot;repoConfigured&quot;: false, &quot;repoReachable&quot;: false, &quot;repoUrl&quot;: &quot;&quot;, &quot;remoteAvailable&quot;: false }</code></pre>\n<p>So weiß die UI, ob die Remote-Registry <strong>nicht konfiguriert</strong>, **konfiguriert aber<br />nicht erreichbar**, oder voll funktionsfähig ist.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad | Zweck |<br />|---|---|---|<br />| GET, POST, PUT, DELETE | <code>/api/v1/addons/skills</code> | Lokale Skills (Filesystem) — browse, <code>SKILL.md</code>, create, deploy, toggle, delete |<br />| GET, POST | <code>/api/v1/addons/skills/clawhub</code> | ClawHub (CLI <strong>oder</strong> skill-repo); <code>?action=status</code> |<br />| GET, POST | <code>/api/v1/addons/skills/repository</code> | Remote-Registry-Browse/Install/Sync; <code>?action=status</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<p>- <strong>Remote-Registry</strong> (Browse/Install/Sync entfernter Skills) erfordert entweder das<br />  <code>clawhub</code>-CLI oder einen konfigurierten <code>skill-repo</code>-Dienst (<code>SKILL_REPO_URL</code>).<br />  Ohne beides ist der Remote-Pfad deaktiviert und zeigt einen klaren Hinweis —<br />  lokale Skills funktionieren unabhängig davon. Geparkt in <code>docs/OPEN-DECISIONS.md</code><br />  (<code>clawhub-skill-registry</code>).</p>",
          "byteSize": 4613
        }
      }
    },
    {
      "id": "smart-home",
      "uuid": "0dcae4ae-196e-4c95-aec8-512901ec4129",
      "name": "Smart Home (Home Assistant Automation & Scene Packs)",
      "version": "1.0.6",
      "description": "Per-User Smart-Home-Packs: kuratierte Home Assistant Automations- und Szenen-Vorlagen (BYOK HA URL + Token pro Nutzer), Pack-Browser mit Apply-Flow, Live-Gerätestatus (device overview) und per-User Szenen-State. Lokale SQLite ist die Quelle der Wahrheit für angewandte Packs + Szenen-State (ADR-054).",
      "author": "Aiopta",
      "icon": "🏡",
      "capabilities": [
        "smart-home",
        "automation-packs",
        "scene-packs",
        "pack-apply",
        "device-overview",
        "ha-connection-config"
      ],
      "tags": [],
      "download": "/artifacts/smart-home-1.0.6.zip",
      "sha256": "876267e44e3c5b37efc7c96d042e3a02bc79a1e68097f07495ef68cbb10a690f",
      "size": 122818,
      "page": null,
      "deprecated": true,
      "replacedBy": "homeassistant",
      "deprecation": {
        "deprecated": true,
        "replacedBy": "homeassistant",
        "sunset": "2026-12-31",
        "successorPath": "/addons/homeassistant",
        "successorLabel": "Home Assistant (Nachfolger)"
      },
      "endpoints": [
        "/api/v1/addons/smart-home/packs",
        "/api/v1/addons/smart-home/packs/:id",
        "/api/v1/addons/smart-home/scenes",
        "/api/v1/addons/smart-home/devices",
        "/api/v1/addons/smart-home/config",
        "/api/v1/addons/smart-home/config/:id",
        "/api/v1/addons/smart-home/config/test",
        "/api/v1/addons/smart-home/apply"
      ],
      "skills": [
        {
          "name": "smart-home-read",
          "displayName": "Smart Home: Packs & Gerätestatus lesen",
          "description": "Listet die kuratierten Home Assistant Automations- und Szenen-Vorlagen, den Live-Gerätestatus (device overview) und die gespeicherte HA-Verbindung des Nutzers. Reiner Lesezugriff — wendet nichts an.",
          "permissions": [
            "addons/smart-home/packs",
            "addons/smart-home/scenes",
            "addons/smart-home/devices",
            "addons/smart-home/config"
          ],
          "endpoint": "/api/v1/addons/smart-home/packs",
          "scripts": []
        },
        {
          "name": "smart-home-apply",
          "displayName": "Smart Home: Packs anwenden & Szenen steuern",
          "description": "Wendet eine kuratierte Automations- oder Szenen-Vorlage auf die Home Assistant Instanz des Nutzers an (BYOK URL+Token) und steuert den per-User Szenen-State.",
          "permissions": [
            "addons/smart-home/packs",
            "addons/smart-home/scenes",
            "addons/smart-home/apply"
          ],
          "endpoint": "/api/v1/addons/smart-home/apply",
          "scripts": []
        },
        {
          "name": "smart-home-config",
          "displayName": "Smart Home: HA-Verbindung verwalten",
          "description": "Speichert, testet oder entfernt die per-User Home Assistant Verbindung (BYOK URL + Long-Lived-Token). Das Token wird serverseitig gespeichert und nie an Clients zurückgegeben.",
          "permissions": [
            "addons/smart-home/config"
          ],
          "endpoint": "/api/v1/addons/smart-home/config",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>🏡 Smart Home (Home Assistant Automation &amp; Scene Packs)</h1>\n<p>Per-User Smart-Home-Packs: kuratierte Home Assistant Automations- und Szenen-Vorlagen<br />(BYOK HA URL + Long-Lived-Token pro Nutzer), ein Pack-Browser mit Apply-Flow, ein<br />Live-Gerätestatus (device overview) sowie ein per-User Szenen-State. Lokale SQLite ist<br />die Quelle der Wahrheit für angewandte Packs + Szenen-State (ADR-054).</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>smart-home</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> smart-home, automation-packs, scene-packs, pack-apply, device-overview, ha-connection-config</li><li><strong>UI-Seite:</strong> <code>/addons/smart-home</code></li></ul>\n<h2>Konzept</h2>\n<p>Jeder Nutzer bringt sein eigenes Home Assistant mit (BYOK): URL + Long-Lived-Token<br />werden per-User gespeichert (das Token verlässt niemals den Server — Clients sehen nur<br /><code>hasToken: boolean</code>). Aus dem kuratierten Katalog wählt der Nutzer eine Automation- oder<br />Szenen-Vorlage, füllt die Platzhalter (z. B. <code>motion_entity</code>, <code>light_entity</code>) und wendet<br />sie an. Der reine <code>pack-applier</code> baut daraus das HA-Config-Payload, der Server POSTet es<br />an <code>/api/config/&lt;domain&gt;/config/&lt;id&gt;</code> der Nutzer-Instanz und lädt die Domain neu.<br />Angewandte Packs + Szenen-State werden per-User in SQLite gespeichert.</p>\n<p>Der <code>device-overview</code> ist ein Best-Effort-Lesezugriff auf <code>GET /api/states</code> der<br />Nutzer-Instanz und degradiert graceful (<code>connected: false</code>) — ohne Verbindung oder bei<br />Netz-/Auth-Fehlern rendert die UI den &quot;Verbinde dein HA&quot;-Prompt statt zu crashen.</p>\n<h2>Setup</h2>\n<p>Keine Secrets im Addon selbst. Der Nutzer trägt seine eigenen HA-Credentials über den<br />&quot;HA Connection&quot;-Tab (oder <code>POST /api/v1/addons/smart-home/config</code>) ein. Ohne Verbindung<br />sind Katalog + Szenen nutzbar; das <code>apply</code> und der <code>device-overview</code> benötigen eine<br />aktive Verbindung.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET | <code>/api/v1/addons/smart-home/packs</code> |<br />| GET | <code>/api/v1/addons/smart-home/packs/:id</code> |<br />| GET, PATCH | <code>/api/v1/addons/smart-home/scenes</code> |<br />| GET | <code>/api/v1/addons/smart-home/devices</code> |<br />| GET, POST | <code>/api/v1/addons/smart-home/config</code> |<br />| DELETE | <code>/api/v1/addons/smart-home/config/:id</code> |<br />| POST | <code>/api/v1/addons/smart-home/config/test</code> |<br />| POST | <code>/api/v1/addons/smart-home/apply</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<p>- Das HA-Config-Payload wird bei jedem Apply neu aus der Vorlage + den gespeicherten<br />  Platzhalter-Werten generiert (nie roh persistiert) — Vorlagen-Updates wirken beim<br />  erneuten Anwenden.<br />- Der Reload der Domain nach dem Apply ist Best-Effort (<code>reloaded: false</code> möglich, wenn<br />  HA den <code>reload</code>-Service einschränkt); der Config-Eintrag wird dennoch geschrieben.<br />- Szenen-State ist eine reine UI/Memory-Funktion; das tatsächliche HA-Szenen-Activation<br />  passiert über das <code>apply</code>.</p>",
          "byteSize": 2705
        }
      }
    },
    {
      "id": "social-media",
      "uuid": "ed389eea-f473-42a6-8a49-0911455c2fc9",
      "name": "Social Media (Planung & Posting)",
      "version": "1.0.2",
      "description": "Multi-Plattform-Planung und -Posting (Mastodon voll implementiert; X/Instagram/Facebook/LinkedIn über die gleiche Connector-Schnittstelle, sobald die Owner-OAuth-Apps konfiguriert sind), KI-gestützte Captions über den MC-Gateway, Redaktionskalender und Basis-Analytics. Lokale SQLite ist die Quelle der Wahrheit; ohne verbundene Plattform meldet ein Post graceful ein 400 ('zuerst eine Plattform verbinden'), nie ein 500 und nie ein stilles No-op. Plattform-Tokens nur via MC-Config, maskiert, nie geloggt. KI-Vorschläge werden dem Nutzer angezeigt, nie automatisch gepostet. ADR-043.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "multi-platform-posting",
        "scheduling",
        "editorial-calendar",
        "ai-captions",
        "basic-analytics",
        "mastodon"
      ],
      "tags": [],
      "download": "/artifacts/social-media-1.0.2.zip",
      "sha256": "bf0b8a8783820b00a073327e7f87e77421b6a0625817ff210a136f78de82e727",
      "size": 90908,
      "page": {
        "id": "social-media-page",
        "path": "/addons/social-media",
        "name": "Social Media",
        "icon": "📱",
        "section": "Money",
        "component": "@addons/social-media/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/social-media/status",
        "/api/v1/addons/social-media/accounts",
        "/api/v1/addons/social-media/accounts/:id",
        "/api/v1/addons/social-media/posts",
        "/api/v1/addons/social-media/posts/:id",
        "/api/v1/addons/social-media/posts/:id/publish",
        "/api/v1/addons/social-media/scheduled/run-due",
        "/api/v1/addons/social-media/ai/caption",
        "/api/v1/addons/social-media/widget"
      ],
      "skills": [
        {
          "name": "social-media-overview",
          "displayName": "Social Media: Status & Übersicht",
          "description": "Liest, welche Plattformen verbunden sind (nur Booleans, keine Tokens) und die Inhalts-Übersicht (Konten, Posts, geplant, veröffentlicht). Reiner Lesezugriff — postet nichts.",
          "permissions": [
            "addons/social-media/status"
          ],
          "endpoint": "/api/v1/addons/social-media/status",
          "scripts": []
        },
        {
          "name": "social-media-compose",
          "displayName": "Social Media: Beitrag erstellen & planen",
          "description": "Erstellt einen Beitrag (Inhalt + Zielplattformen, optional Medien-Referenz und Planzeitpunkt). Veröffentlichen ist eine separate, bestätigte Aktion; ohne verbundene Plattform liefert das Veröffentlichen ein klares 400 ('zuerst verbinden'), nie ein 500.",
          "permissions": [
            "addons/social-media/posts",
            "addons/social-media/status"
          ],
          "endpoint": "/api/v1/addons/social-media/posts",
          "scripts": []
        },
        {
          "name": "social-media-ai-caption",
          "displayName": "Social Media: KI-Caption",
          "description": "Erzeugt einen Caption-Vorschlag über den MC-Gateway (Thema/Plattform/Tonalität). Der Vorschlag wird dem Nutzer angezeigt, nie automatisch gepostet. Ohne erreichbares Modell graceful (klare Meldung, kein 500).",
          "permissions": [
            "addons/social-media/ai"
          ],
          "endpoint": "/api/v1/addons/social-media/ai/caption",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>social-media</h1>\n<p>Multi-Plattform-Planung &amp; -Posting für Aiopta (ADR-043).</p>\n<p>## Was es kann<br />- <strong>Verfassen</strong> eines Beitrags für mehrere Plattformen, <strong>sofort posten</strong> oder<br />  <strong>planen</strong> (<code>scheduled_at</code>).<br />- <strong>Redaktionskalender</strong> der geplanten Beiträge.<br />- <strong>KI-Caption</strong> über den Aiopta-Gateway (Thema → Vorschlag; wird nie automatisch gepostet).<br />- <strong>Basis-Analytics</strong> je Beitrag/Plattform (was die Plattform-API liefert).</p>\n<p>## Plattformen<br />- <strong>Mastodon — voll implementiert.</strong> Konfiguration in Aiopta-Config:<br />  <code>MASTODON_INSTANCE</code> (z. B. <code>https://mastodon.social</code>) + <code>MASTODON_ACCESS_TOKEN</code>.<br />- <strong>X / Instagram / Facebook / LinkedIn</strong> — an dieselbe Connector-Schnittstelle<br />  modelliert; aktiv, sobald die Owner-OAuth-Apps konfiguriert sind<br />  (siehe <code>docs/OPEN-DECISIONS.md</code> #75). Bis dahin meldet ein Post für diese<br />  Plattformen graceful „nicht verbunden&quot; (kein Fake-Erfolg).</p>\n<p>## Sicherheit<br />- Posten ist eine <strong>authentifizierte</strong> Aktion; ohne verbundene Plattform → klares<br />  <strong>400</strong> („zuerst verbinden&quot;), nie ein 500, <strong>nie ein stilles No-op</strong>.<br />- Tokens nur via Aiopta-Config, maskiert, nie geloggt.<br />- KI-Captions werden dem Nutzer zur Prüfung angezeigt, nicht automatisch versendet.<br />- <strong>Per-User-isoliert (#141 / ADR-054):</strong> Accounts, Beiträge und deren Plattform-<br />  Targets tragen <code>user_id</code> und werden ausschließlich über <code>scopedStore(auth.id)</code><br />  gelesen/geschrieben. Der Server stempelt den Owner aus der Session (ein vom<br />  Client geliefertes <code>user_id</code> wird ignoriert); eine fremde Beitrags-/Account-id<br />  liefert <strong>404</strong> (nicht die Zeile). <code>POST /scheduled/run-due</code> ist ein<br />  <strong>Per-User-Scheduler</strong> — jeder Aufruf veröffentlicht nur die EIGENEN fälligen<br />  Beiträge.</p>\n<p>## Endpoints<br /><code>/status</code>, <code>/accounts(+/:id)</code>, <code>/posts(+/:id, /:id/publish)</code>, <code>/scheduled/run-due</code>,<br /><code>/ai/caption</code>, <code>/widget</code> — alle unter <code>/api/v1/addons/social-media/</code>.</p>\n<p>## Planung<br /><code>POST /scheduled/run-due</code> veröffentlicht fällige geplante Beiträge — von einem<br />Cron/Scheduler (owner-konfiguriert) getriggert.</p>",
          "byteSize": 2002
        }
      }
    },
    {
      "id": "spoolman",
      "uuid": "d2e61a95-0bf8-4c5a-8627-59e02fd9b129",
      "name": "Spoolman",
      "version": "0.2.0",
      "description": "Filament-Bestand aus einer vorhandenen Spoolman-Instanz (getestet gegen 0.26.1) lesen und die Restmenge fortschreiben. Dual-mode connect: Basis-URL aus der Nutzerkonfiguration (Pflichtfeld, ohne Vorgabe), Zugang als BYOK, injizierbarer fetch-Seam. Lesend: Hersteller (vendor), Filamente und Spulen mit Restmenge inklusive der extra-Felder. Schreibend: genau ein relativer Abzug (PUT /spool/{id}/use, use_length in mm) — nie absolut, nie wiederholt. Zwei Trust-Stufen: inventory.read (elevated) < inventory.update (highest).",
      "author": "Aiopta",
      "icon": "🧵",
      "capabilities": [
        "spoolman",
        "filament-inventory",
        "3d-printing",
        "material-tracking"
      ],
      "tags": [],
      "download": "/artifacts/spoolman-0.2.0.zip",
      "sha256": "9b613ac876591a24debac02ed22b346d1d03bf2640acdf186519e7fd9def6b89",
      "size": 139973,
      "page": {
        "id": "spoolman",
        "path": "/addons/spoolman",
        "name": "Spoolman",
        "icon": "🧵",
        "section": "Tools",
        "component": "@addons/spoolman/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/spoolman/inventory",
        "/api/v1/addons/spoolman/spool/{id}",
        "/api/v1/addons/spoolman/spool/{id}/use"
      ],
      "skills": [
        {
          "name": "spoolman-inventory-read",
          "displayName": "Spoolman: Filament-Bestand lesen",
          "description": "Liest Hersteller, Filamente und Spulen mit Restmenge aus Spoolman, inklusive der extra-Felder. Reiner Lesezugriff.",
          "permissions": [
            "addons/spoolman/inventory/read"
          ],
          "endpoint": "/api/v1/addons/spoolman/inventory",
          "scripts": []
        },
        {
          "name": "spoolman-inventory-update",
          "displayName": "Spoolman: Restmenge fortschreiben",
          "description": "Schreibt genau einen relativen Abzug (use_length in mm) auf eine Spule fort. Eigene, hoechste Trust-Stufe — getrennt vom Lesen.",
          "permissions": [
            "addons/spoolman/inventory/read",
            "addons/spoolman/inventory/update"
          ],
          "endpoint": "/api/v1/addons/spoolman/spool/{id}/use",
          "scripts": []
        }
      ],
      "mcpServers": [
        {
          "id": "spoolman-tools",
          "displayName": "Spoolman tools",
          "description": "Read-only Spoolman-Tools fuer Agents: Spulen mit Restmenge, eine einzelne Spule (used_length als Abgleichswert), Filamente und Hersteller. Der schreibende Pfad ist bewusst NICHT als Tool exponiert — ein Abzug ist relativ und ohne Idempotenzschluessel nicht wiederholbar; er laeuft ausschliesslich ueber die API-Route mit eigener Berechtigung. Authentifiziert ueber die Slots baseUrl/apiKey, nie als Tool-Argument.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "spoolman-tools/server.mjs",
          "permissions": [
            "addons/spoolman/inventory/read"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [
            "baseUrl"
          ],
          "credentialRefs": [
            "apiKey"
          ],
          "archiveEntry": "spoolman/mcp/servers/spoolman-tools/server.mjs",
          "sha256": "585c8dc0fd717295bb5f5017523126ccb972d967f7dc080a42252667597b8243",
          "size": 14285,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Spoolman (spoolman)</h1>\n<p>Bindet <strong>deine eigene Spoolman-Instanz</strong> als dual-mode <code>connect</code>-Addon in<br />Aiopta ein: Filament-Bestand <strong>lesen</strong> und die Restmenge <strong>fortschreiben</strong>.</p>\n<p>Die Basis-URL ist ein <strong>Pflichtfeld ohne Vorgabe</strong> (<code>baseUrl</code> in den<br />Addon-Einstellungen). Es steht bewusst kein Hostname im Code: dieses Addon<br />liegt im gemeinsamen Bestand und wird an jede Aiopta-Installation<br />ausgeliefert — ein voreingestellter Wirt wäre der Produktivserver <em>eines</em><br />Betreibers in der Installation <em>aller anderen</em>. Ist das Feld leer,<br />antworten alle Routen mit <code>409 spoolman_not_configured</code>, und es geht kein<br />Netzruf raus.</p>\n<p>Gemessen wurde gegen Spoolman <strong>0.26.1</strong> (<code>git_commit f387203</code>, Build<br />2026-08-07) auf der Instanz des Betreibers; die Befunde unten beziehen sich<br />auf diese Version.</p>\n<p>Ticket: <code>Aiopta/aiopta-addons#1126</code> (Kind von #1116).</p>\n<h2>Architektur</h2>\n<pre><code>Browser/Agent ──HTTPS──▶ Aiopta (dieses Addon) ──HTTPS──▶ Spoolman 0.26.1\n                         ├─ Dashboard (native DS-Seite)   /addons/spoolman\n                         ├─ API-Routen (userRoute)        /api/v1/addons/spoolman/…\n                         └─ MCP-Server (spoolman-tools)   read-only</code></pre>\n<p>- <strong>Natives Dashboard</strong> (<code>src/page.tsx</code>, <code>ui.mode: &quot;aiopta&quot;</code>): Spulen mit<br />  Restmenge, <code>used_length</code>, <code>extra</code>-Feldern — und je Spule ein Feld für den<br />  relativen Abzug in Millimetern. Ausschließlich <code>@aiopta/ui</code>-Komponenten;<br />  der Browser kontaktiert Spoolman nie direkt.<br />- <strong>API-Routen</strong> (<code>src/app/api/v1/addons/spoolman/*</code>): zentrale Auth über<br />  <code>userRoute</code> (Session-Cookie oder Bearer/X-API-Key Agent-Key).<br />- <strong>Client</strong> (<code>src/lib/spoolman-client.ts</code>): injizierbarer fetch-Seam<br />  (Muster <code>shopify-client.ts</code>), damit kein Unit-Test je das Netz anfasst.<br />- <strong>Konfiguration</strong> (<code>src/lib/config.ts</code>): <code>baseUrl</code> (**Pflicht, kein<br />  Default**) + <code>apiKey</code> (BYOK, <code>password</code>-Slot, optional). Fehlt <code>baseUrl</code>,<br />  wirft <code>getSpoolmanSettings()</code> einen <code>SpoolmanNotConfiguredError</code>, bevor<br />  irgendein <code>fetch</code> entsteht. Der Schlüssel verlässt den Server nie — auch<br />  nicht maskiert in einem Fehlertext.</p>\n<h2>Die drei gemessenen Randbedingungen</h2>\n<p>Alles hier ist am <strong>2026-08-10</strong> an der Anlage erhoben (Quellen:<br /><code>spoolman-offline-proxy/README.md</code> §1, <code>app/oben.py</code>, <code>app/kern.py</code>;<br />Plugin-Quelltext auf dem OctoPi; <code>GET /api/v1/openapi.json</code> der Instanz).</p>\n<h3>1. <code>PUT /spool/{id}/use</code> ist relativ, in Millimetern</h3>\n<p>Der Körper ist <code>{&quot;use_length&quot;: &lt;mm&gt;}</code> — eine <strong>Länge</strong>, kein Gewicht. Das<br />OctoPrint-Spoolman-Plugin puffert mm (<code>handleCommitSpoolUsage()</code>). Gramm<br />entstehen nur für die Anzeige, aus <code>filament.density</code> und<br /><code>filament.diameter</code>.</p>\n<p>Die absoluten Wege existieren (<code>PUT /spool/{id}/measure</code>,<br /><code>PATCH /spool/{id}</code>) und werden hier <strong>nie</strong> benutzt: ein Absolutwert würde<br />eine zwischenzeitliche zentrale Korrektur überschreiben.</p>\n<h3>2. Spoolman 0.26.1 hat keinen Idempotenzschlüssel</h3>\n<p>Geprüft an der OpenAPI-Beschreibung (30 Endpunkte):<br /><code>PUT /spool/{spool_id}/use</code> nimmt <strong>genau</strong> <code>spool_id</code> im Pfad und<br /><code>use_length</code>/<code>use_weight</code> im Körper. Kein Idempotenzschlüssel, kein<br /><code>If-Match</code>, keine Vorgangs-ID, kein Buchungsbeleg.</p>\n<p><strong>Folge:</strong> doppelte Abbuchung lässt sich nur <em>außerhalb</em> von Spoolman<br />verhindern.</p>\n<h3>3. Der Doppelabzugsschutz lebt im <code>spoolman-offline-proxy</code> — nicht hier</h3>\n<p>Der Proxy (eigenes Repo, <code>systemd</code>-Dienst auf dem OctoPi) hält<br /><code>pending → sending → confirmed</code> und gleicht über <code>used_length</code> ab<br />(<code>app/kern.py::_erwartet_used</code>, <code>::_schon_gebucht</code>). **Dieses Addon baut<br />diese Logik nicht ein zweites Mal** — zwei Rechenstellen für dieselbe Frage<br />laufen auseinander.</p>\n<h2>Warum das Addon direkt mit Spoolman spricht (und nicht mit dem Proxy)</h2>\n<p>Zwei Befunde, beide belegbar:</p>\n<p>1. <strong>Der Proxy ist vom Aiopta-Server nicht erreichbar.</strong> Er bindet auf<br />   <code>127.0.0.1:7913</code> (<code>spoolman-offline-proxy/beispiel.env</code>:<br />   <code>PROXY_ADRESSE=127.0.0.1</code>; README §5). Er sitzt <em>hinter</em> OctoPrint auf<br />   dem OctoPi und soll dort bleiben — sein Zweck ist der Druckpfad bei<br />   ausgefallener Powerline, nicht eine zweite Fernbedienung.<br />2. <strong>Die beiden Schreibpfade sind fachlich verschieden.</strong> Der Proxy bucht<br />   den <em>Druckverbrauch</em>, ausgelöst vom Plugin, unbeaufsichtigt, über eine<br />   unzuverlässige Leitung. Dieses Addon bucht eine *ausdrückliche<br />   Korrektur*, ausgelöst von Mensch oder Agent, über eine normale Leitung.</p>\n<p>Damit daraus trotzdem keine zweite Rechenstelle wird, gilt hier eine harte<br />Regel: <strong>das Addon misst und berichtet, es entscheidet nicht.</strong></p>\n<p>| | Proxy | dieses Addon |<br />|---|---|---|<br />| Warteschlange (<code>pending/sending/confirmed</code>) | ja | <strong>nein</strong> |<br />| Wiederholversuch | ja, abgesichert über <code>used_length</code> | <strong>nie</strong> |<br />| Schattenbestand / lokaler Cache | ja | <strong>nein</strong> |<br />| Entscheidung „schon gebucht?&quot; | ja | <strong>nein</strong> — nur Messwerte im Bericht |</p>\n<p>Bleibt der Ausgang eines Abzugs unklar (Zeitgrenze, Verbindungsabbruch),<br />antwortet die Route mit <strong>409 <code>booking_unclear</code></strong> und liefert<br /><code>usedLengthBefore</code> + <code>usedLengthProbe</code> mit. Kein stiller Rückfall, kein<br />stiller zweiter Versuch — die Entscheidung liegt beim Menschen.</p>\n<h2>Endpunkte</h2>\n<p>Basis: <code>https://&lt;instanz&gt;/api/v1/addons/spoolman</code></p>\n<p>| Ziel | Methode + Pfad | Permission |<br />|---|---|---|<br />| Bestand (Spulen, Filamente, Hersteller, <code>extra</code>) | <code>GET /inventory</code> | <code>addons/spoolman/inventory/read</code> |<br />| Eine Spule inkl. <code>used_length</code> | <code>GET /spool/{id}</code> | <code>addons/spoolman/inventory/read</code> |<br />| Relativen Abzug buchen | <code>POST /spool/{id}/use</code> | <code>addons/spoolman/inventory/update</code> |</p>\n<pre><code class=\"lang-bash\"># lesen\ncurl -s -H &quot;Authorization: Bearer $AIOPTA_API_KEY&quot; \\\n  https://&lt;instanz&gt;/api/v1/addons/spoolman/inventory</code></pre>\n<p># einen Abzug von 1250 mm buchen (relativ!)<br />curl -s -X POST -H &quot;Authorization: Bearer $AIOPTA_API_KEY&quot; \\<br />  -H &#39;content-type: application/json&#39; \\<br />  -d &#39;{&quot;use_length_mm&quot;: 1250}&#39; \\<br />  <a href=\"https://&amp;lt;instanz&amp;gt;/api/v1/addons/spoolman/spool/7/use\" rel=\"noopener noreferrer\">https://&amp;lt;instanz&amp;gt;/api/v1/addons/spoolman/spool/7/use</a><br />```</p>\n<h3>Fehlerbilder (maschinenlesbar über <code>code</code>)</h3>\n<p>| Situation | Status | <code>code</code> |<br />|---|---|---|<br />| <code>baseUrl</code> nicht konfiguriert (kein Netzruf) | 409 | <code>spoolman_not_configured</code> |<br />| Spoolman nicht erreichbar | 502 | <code>spoolman_unreachable</code> |<br />| Spoolman antwortet mit unerwartetem Status | 502 | <code>spoolman_upstream_error</code> |<br />| Spule existiert nicht | 404 | <code>spool_not_found</code> |<br />| <code>use_length_mm</code> fehlt / ≤ 0 | 400 | <code>invalid_use_length</code> |<br />| <strong>Buchung unklar — nicht wiederholen</strong> | 409 | <code>booking_unclear</code> |</p>\n<p><strong>Erreichbar-und-leer ist etwas anderes als ausgefallen.</strong> Ein leerer<br />Bestand ist <code>200 { reachable: true, spools: [] }</code>; ein Ausfall ist<br /><code>502 spoolman_unreachable</code>. Die Oberfläche zeigt beides getrennt.</p>\n<h2>Berechtigungen (dotted) und Trust-Stufen</h2>\n<h3>Dotted Capabilities (<code>capabilityDeclarations</code>, core#1078)</h3>\n<p>| Capability | Tier | Permission |<br />|---|---|---|<br />| <code>addons.spoolman.inventory.read</code> | <code>elevated</code> | <code>addons/spoolman/inventory/read</code> |<br />| <code>addons.spoolman.inventory.update</code> | <code>highest</code> | <code>addons/spoolman/inventory/update</code> |</p>\n<p><code>read</code> ist <code>elevated</code>, nicht <code>standard</code>: die Rubrik in<br /><code>docs/ADDON-ADOPTION-STANDARD.md</code> stuft <strong>bounded Egress</strong> (fixes,<br />BYOK-konfiguriertes Ziel) als <code>elevated</code> ein — Lesen ohne Egress wäre<br /><code>standard</code>, dies hier verlässt aber die Instanz.</p>\n<p><code>update</code> ist <code>highest</code>, weil **ein falscher Abzug den Bestand still<br />verfälscht**: es fällt erst auf, wenn eine Rolle mitten im Druck leer ist.</p>\n<h3>Addon-Trust-Stufe (ADR-062)</h3>\n<p>ADR-062 (<code>aiopta-core/src/lib/access/addon-trust.ts</code>) ist eine<br /><strong>instanzweite, admin-gesetzte</strong> Eigenschaft je Addon<br />(<code>trusted | standard | restricted</code>, Default <code>standard</code>) — sie steht <strong>nicht</strong><br />im Manifest, sondern in <code>extensions.settings</code> und wird in<br /><code>dispatchExtensionRoute</code> durchgesetzt. <code>restricted</code> verbietet<br /><code>POST/PUT/PATCH/DELETE</code> (<code>isMethodAllowedForTrust</code>).</p>\n<p>Dieses Addon ist darauf ausgelegt:</p>\n<p>| Trust-Stufe | Bestand lesen | Abzug buchen |<br />|---|---|---|<br />| <code>trusted</code> | ✅ | ✅ (zusätzlich Permission nötig) |<br />| <code>standard</code> (Default) | ✅ | ✅ (zusätzlich Permission nötig) |<br />| <code>restricted</code> | ✅ | ⛔ 403 vor dem Handler |</p>\n<p>Deshalb liegt der Schreibpfad auf <strong>POST</strong>, nicht auf GET: nur ein<br />mutierendes Verb ist überhaupt sandboxbar. <code>addons/spoolman/tests/unit/trust.test.ts</code><br />pinnt das gegen die echte Core-Funktion.</p>\n<p><code>restricted</code> ist die empfohlene Einstellung, solange nur gelesen werden<br />soll — dann ist das Addon nachweislich schreibunfähig, ohne es abzuschalten.</p>\n<h2>Addon-Abhängigkeiten (ADR-047)</h2>\n<p><code>addonDependencies</code> ist bewusst <strong>leer</strong>. Geprüft am 2026-08-10 gegen den<br />Bestand von 72 Addons: die naheliegenden Kandidaten heißen <code>octoprint</code><br />und <code>octoprint-proxy</code> (beide existieren), aber es gibt <strong>keine</strong><br />Laufzeitkopplung — dieses Addon liest und schreibt allein gegen Spoolman.</p>\n<p>Eine <code>required</code>-Kante auf eine nicht existierende Addon-Id macht das Addon<br />nach ADR-047 uninstallierbar (<code>addon_dependencies_unmet</code>, <strong>HTTP 409</strong>,<br /><code>aiopta-core/src/lib/addons/dependency-check.ts:176/186</code>). Eine Kante ohne<br />Kopplung wäre genau dieses Risiko ohne jeden Nutzen.</p>\n<h2>Selbstausweis (<code>attestation.json</code>) — Stand</h2>\n<p>Der Selbstausweis eines Addons ist die <strong>signierte <code>attestation.json</code></strong><br />(<code>docs/ADDON-ADOPTION-STANDARD.md</code> §2): <code>declares.capabilities</code> spiegelt<br /><code>capabilityDeclarations</code>, <code>declares.touches</code> trägt sha256 über jede<br />ausgelieferte Laufzeitdatei, signiert mit Ed25519 über kanonisches JSON.<br />Ohne sie zählt die Selbstauskunft nicht — „der Selbstausweis zählt **nur<br />kryptografisch verifiziert**&quot;.</p>\n<p><strong>Dieses Addon liefert sie noch nicht mit.</strong> Der private Schlüssel ist<br />absichtlich nicht im Repo: er lebt als Jenkins-Credential<br /><code>aiopta-addon-signing-key</code> und wird ausschließlich vom Job<br /><code>ci/Jenkinsfile.addonsign</code> gebunden. Das Signieren ist damit ein<br />CI-Schritt, kein Entwickler-Schritt.</p>\n<p>Nach dem Merge:</p>\n<pre><code class=\"lang-bash\">node scripts/addon-adoption/attest.mjs generate spoolman   # im Signier-Job\nnode scripts/addon-adoption/attest.mjs verify   spoolman</code></pre>\n<p>Danach muss der Eintrag <code>spoolman</code> aus <code>BEKANNTE_LUECKEN</code> in<br /><code>scripts/check-addon-adoption.cjs</code> <strong>entfernt</strong> werden — der Validator macht<br />den Lauf sonst rot (<code>veralteteAusnahmen</code>), und das ist Absicht.</p>\n<h2>MCP-Server (<code>spoolman-tools</code>)</h2>\n<p>Read-only, <code>stdio</code>/<code>node</code>, Permission <code>addons/spoolman/inventory/read</code>,<br /><code>toolPolicy: { mode: &quot;read-only&quot;, allowDestructive: false }</code>.</p>\n<p>| Tool | Zweck |<br />|---|---|<br />| <code>spool_list</code> | Spulen mit Restmenge (inkl. <code>extra</code>) |<br />| <code>spool_get</code> | eine Spule inkl. <code>used_length</code> |<br />| <code>filament_list</code> | Filamente (Material, Farbe, Dichte, Preis) |<br />| <code>vendor_list</code> | Hersteller |</p>\n<p><strong>Es gibt bewusst kein schreibendes Tool.</strong> Ein Agent, der bei unklarer<br />Antwort noch einmal ruft, bucht doppelt — und genau das kann Spoolman 0.26.1<br />nicht abwehren. Der schreibende Pfad läuft ausschließlich über die<br />API-Route mit eigener, höchster Trust-Stufe.</p>\n<p>Slots (nie als Tool-Argument): <code>AIOPTA_CONFIG_BASEURL</code>,<br /><code>AIOPTA_CREDENTIAL_APIKEY</code>.</p>\n<h2>Tests</h2>\n<pre><code class=\"lang-bash\">npm run test:unit -- addons/spoolman            # vitest (Manifest, Client, Routen, Trust)\nnode --test scripts/addon-mcp-conformance.test.cjs   # MCP-Konformanz (deckt spoolman automatisch)</code></pre>\n<p>Die Negativ-Fälle des Tickets sind einzeln gepinnt:</p>\n<p>- Fortschreiben ohne Berechtigung → typisierte Ablehnung, **und der Bestand<br />  ist danach nachweislich unverändert** (nicht nur „ein Fehler kam&quot;).<br />- Spoolman nicht erreichbar → erklärter Zustand, unterscheidbar von einem<br />  leeren Bestand.<br />- Der Zugang taucht in <strong>keiner</strong> Antwort auf, auch nicht maskiert in einem<br />  Fehlertext.</p>\n<h2>Betriebliche Randbedingung</h2>\n<p>Owner, 2026-08-04: <em>„erst muss der Kundenauftrag durchgehen.&quot;</em> Der<br />Slice-Dienst auf CT 123 läuft produktiv. **Lesen und messen: ja. Ändern<br />oder neu starten: nein.** Dieses Addon wurde ausschließlich gegen Stubs<br />entwickelt; kein Test und kein Build-Schritt spricht mit der echten<br />Spoolman-Instanz.</p>",
          "byteSize": 11691
        }
      }
    },
    {
      "id": "task-board",
      "uuid": "66755468-1c64-4061-a1c0-2e542d134e15",
      "name": "Projects & Tasks",
      "version": "2.2.4",
      "description": "Canonical Projects & Tasks addon (ADR-008): task board + kanban plus the project-management surface (projects, members, tags, templates, stats) absorbed under task-board/project-management/*. Old /api/v1/addons/project-management/* paths keep working via re-export shims.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "tasks",
        "kanban",
        "projects",
        "project-management"
      ],
      "tags": [],
      "download": "/artifacts/task-board-2.2.4.zip",
      "sha256": "9e50f80cc634810d2c50e3018895fe49b142cb900a85571ed408a282bea06bb7",
      "size": 458054,
      "page": [
        {
          "id": "tasks",
          "path": "/addons/task-board",
          "name": "Tasks",
          "icon": "📋",
          "section": "Work",
          "component": "@addons/task-board/src/components/tasks/TasksPage"
        },
        {
          "id": "projects",
          "path": "/addons/task-board/projects",
          "name": "Projects",
          "icon": "📁",
          "section": "Work",
          "component": "@addons/task-board/src/components/project-management/projects/CoreProjectsPage"
        }
      ],
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/task-board",
        "/api/v1/addons/task-board/search",
        "/api/v1/addons/task-board/templates",
        "/api/v1/addons/task-board/{id}",
        "/api/v1/addons/task-board/{id}/attachments",
        "/api/v1/addons/task-board/{id}/comments",
        "/api/v1/addons/task-board/{id}/dependencies",
        "/api/v1/addons/task-board/{id}/subtasks",
        "/api/v1/addons/task-board/{id}/time",
        "/api/v1/addons/task-board/tasks",
        "/api/v1/addons/task-board/tasks/{id}",
        "/api/v1/addons/task-board/tasks/search",
        "/api/v1/addons/task-board/kanban/boards",
        "/api/v1/addons/task-board/kanban/{boardId}/analytics",
        "/api/v1/addons/task-board/kanban/{boardId}/columns",
        "/api/v1/addons/task-board/kanban/{boardId}/export",
        "/api/v1/addons/task-board/kanban/{boardId}/swimlanes",
        "/api/v1/addons/task-board/comments/destinations",
        "/api/v1/addons/task-board/project-management",
        "/api/v1/addons/task-board/project-management/categories",
        "/api/v1/addons/task-board/project-management/create",
        "/api/v1/addons/task-board/project-management/import",
        "/api/v1/addons/task-board/project-management/sync",
        "/api/v1/addons/task-board/project-management/tags",
        "/api/v1/addons/task-board/project-management/templates",
        "/api/v1/addons/task-board/project-management/{id}",
        "/api/v1/addons/task-board/project-management/{id}/activity",
        "/api/v1/addons/task-board/project-management/{id}/archive",
        "/api/v1/addons/task-board/project-management/{id}/export",
        "/api/v1/addons/task-board/project-management/{id}/members",
        "/api/v1/addons/task-board/project-management/{id}/settings",
        "/api/v1/addons/task-board/project-management/{id}/stats",
        "/api/v1/addons/task-board/project-management/{id}/tags",
        "/api/v1/addons/task-board/project-management/{id}/tasks",
        "/api/v1/addons/task-board/project-management/projects",
        "/api/v1/addons/task-board/project-management/projects/{id}"
      ],
      "skills": [],
      "mcpServers": [
        {
          "id": "task-board-tools",
          "displayName": "Projects & Tasks tools",
          "description": "Read-only kanban board projection for agents: group tasks into the canonical board columns, count work-in-progress and surface approval-needed items — the same board model the Projects & Tasks addon renders (issue #445, epic #399). Mutates nothing.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "task-board-tools/server.mjs",
          "permissions": [
            "addons/task-board/read"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [],
          "credentialRefs": [],
          "archiveEntry": "task-board/mcp/servers/task-board-tools/server.mjs",
          "sha256": "7b3fc5ba601d8739c96cdd9cdf9a1b0c64d597449ab7ab509790a2fc5b106b50",
          "size": 13124,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Task Board</h1>\n<p>Task board UI and task APIs as an installable addon.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>task-board</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> tasks, kanban</li><li><strong>UI-Seite:</strong> <code>/tasks</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET, POST | <code>/api/v1/addons/task-board</code> |<br />| GET, PUT, DELETE | <code>/api/v1/addons/task-board/[id]</code> |<br />| GET, POST, DELETE | <code>/api/v1/addons/task-board/[id]/attachments</code> |<br />| GET, POST | <code>/api/v1/addons/task-board/[id]/comments</code> |<br />| GET, POST, DELETE | <code>/api/v1/addons/task-board/[id]/dependencies</code> |<br />| GET, POST | <code>/api/v1/addons/task-board/[id]/subtasks</code> |<br />| GET, POST, DELETE | <code>/api/v1/addons/task-board/[id]/time</code> |<br />| GET, POST | <code>/api/v1/addons/task-board/comments/destinations</code> |<br />| GET | <code>/api/v1/addons/task-board/kanban/[boardId]/analytics</code> |<br />| GET, POST, PUT, DELETE | <code>/api/v1/addons/task-board/kanban/[boardId]/columns</code> |<br />| GET | <code>/api/v1/addons/task-board/kanban/[boardId]/export</code> |<br />| GET, POST | <code>/api/v1/addons/task-board/kanban/[boardId]/swimlanes</code> |<br />| GET, POST | <code>/api/v1/addons/task-board/kanban/boards</code> |<br />| GET | <code>/api/v1/addons/task-board/search</code> |<br />| GET | <code>/api/v1/addons/task-board/status</code> |<br />| GET, POST | <code>/api/v1/addons/task-board/tasks</code> |<br />| GET, PUT, DELETE | <code>/api/v1/addons/task-board/tasks/[id]</code> |<br />| GET | <code>/api/v1/addons/task-board/tasks/search</code> |<br />| GET, POST | <code>/api/v1/addons/task-board/templates</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>Keine bekannten Einschränkungen.</li></ul>\n<blockquote><p>README aus <code>addon.json</code> + vorhandenen Routen generiert (<code>scripts/generate-addon-readmes.js</code>). Bei manuellen Ergänzungen den Generator-Hinweis entfernen.</p></blockquote>",
          "byteSize": 1678
        }
      }
    },
    {
      "id": "trading",
      "uuid": "97c0b487-8e82-46ee-abb1-1a335c48ea5e",
      "name": "trading",
      "version": "1.56.10",
      "description": "Hyperliquid trading extension with real-time orders, positions, journal, workflow automation, self-contained market research tooling, DST-safe pre-market/weekly-review scheduler (issue #153), market-open/midday-scan safe-execute dispatcher (issue #154), and a deterministic, per-user lesson tracker (issue #116)",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "trading",
        "orders",
        "positions",
        "journal",
        "autotrading",
        "strategy-automation",
        "risk-management",
        "research",
        "scheduler",
        "planner",
        "executor",
        "workflow",
        "gov-scanner"
      ],
      "tags": [],
      "download": "/artifacts/trading-1.56.10.zip",
      "sha256": "4f6f597f5f6c513508dc6597dabddc62b3d0664bfe80f5fb1fe389d298341929",
      "size": 2601164,
      "page": {
        "path": "/addons/trading",
        "name": "Trading",
        "icon": "📊",
        "section": "Finance",
        "component": "@addons/trading/src/components/TradingPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/trading/account",
        "/api/v1/addons/trading/analyze",
        "/api/v1/addons/trading/autotrading",
        "/api/v1/addons/trading/buy",
        "/api/v1/addons/trading/charts",
        "/api/v1/addons/trading/close",
        "/api/v1/addons/trading/config",
        "/api/v1/addons/trading/gov-config",
        "/api/v1/addons/trading/gov-config/test-webhook",
        "/api/v1/addons/trading/gov-proposals",
        "/api/v1/addons/trading/gov-proposals/[id]/decide",
        "/api/v1/addons/trading/gov-prices",
        "/api/v1/addons/trading/gov-scan",
        "/api/v1/addons/trading/gov-signals",
        "/api/v1/addons/trading/gov-signals/[id]",
        "/api/v1/addons/trading/gov-sources",
        "/api/v1/addons/trading/gov-issuers/refresh",
        "/api/v1/addons/trading/history",
        "/api/v1/addons/trading/hyperliquid",
        "/api/v1/addons/trading/journal",
        "/api/v1/addons/trading/lessons",
        "/api/v1/addons/trading/market/{category}",
        "/api/v1/addons/trading/orders",
        "/api/v1/addons/trading/positions",
        "/api/v1/addons/trading/report",
        "/api/v1/addons/trading/research/fetch",
        "/api/v1/addons/trading/research/search",
        "/api/v1/addons/trading/risk",
        "/api/v1/addons/trading/sell",
        "/api/v1/addons/trading/settings",
        "/api/v1/addons/trading/sl",
        "/api/v1/addons/trading/stats",
        "/api/v1/addons/trading/status",
        "/api/v1/addons/trading/stop-loss",
        "/api/v1/addons/trading/strategies",
        "/api/v1/addons/trading/strategies/ladder",
        "/api/v1/addons/trading/strategies/preview",
        "/api/v1/addons/trading/workflow",
        "/api/v1/addons/trading/workflow-config",
        "/api/v1/addons/trading/workflow/run"
      ],
      "skills": [
        {
          "name": "trading-status",
          "displayName": "Trading: Account & Positions",
          "description": "Read Hyperliquid account value, open positions and live status",
          "permissions": [
            "addons/trading/status",
            "addons/trading/positions",
            "addons/trading/account"
          ],
          "endpoint": "/api/v1/addons/trading/status",
          "scripts": [
            {
              "id": "trading-status-cli",
              "name": "trading-status-cli",
              "path": "trading-status/cli.sh",
              "sha256": "6da8c8486514f36922ec398642274a0c57528fbdd70b4f58716ebd91b977d145",
              "interpreter": "bash",
              "timeoutMs": 10000,
              "permissions": [
                "addons/trading/account",
                "addons/trading/positions",
                "addons/trading/status"
              ],
              "inputSchema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "limit": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 100,
                    "default": 20
                  },
                  "timeoutMs": {
                    "type": "integer",
                    "minimum": 100,
                    "maximum": 300000
                  }
                }
              },
              "outputSchema": {
                "type": "object",
                "required": [
                  "ok"
                ],
                "properties": {
                  "ok": {
                    "type": "boolean"
                  },
                  "status": {
                    "type": "integer"
                  },
                  "data": {},
                  "error": {
                    "type": "string"
                  },
                  "truncated": {
                    "type": "boolean"
                  }
                }
              },
              "size": 12083
            }
          ]
        },
        {
          "name": "trading-orders",
          "displayName": "Trading: Place & Manage Orders",
          "description": "Open/close positions and place limit orders on Hyperliquid perps",
          "permissions": [
            "addons/trading/buy",
            "addons/trading/sell",
            "addons/trading/close",
            "addons/trading/orders"
          ],
          "endpoint": "/api/v1/addons/trading/orders",
          "scripts": []
        },
        {
          "name": "trading-risk",
          "displayName": "Trading: Risk & Stop-Loss",
          "description": "Set stop-loss orders and read risk metrics for open positions",
          "permissions": [
            "addons/trading/stop-loss",
            "addons/trading/sl",
            "addons/trading/risk"
          ],
          "endpoint": "/api/v1/addons/trading/risk",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Trading Extension</h1>\n<p>Crypto trading integration for Aiopta, powered by Hyperliquid.</p>\n<h2>Features</h2>\n<ul><li><strong>Portfolio Status</strong> — Real-time account value, margin, positions across core and builder-deployed perp DEXs</li><li><strong>Market &amp; Limit Orders</strong> — Buy/sell with instant or price-targeted execution</li><li><strong>Risk Management</strong> — Auto stop-loss on buy, manual stop-loss/take-profit</li><li><strong>Position Tracking</strong> — Open positions with unrealized P&amp;L</li><li><strong>Trade History</strong> — Paginated history with performance stats (Sharpe, drawdown, win rate)</li><li><strong>Daily Snapshots</strong> — Automatic capital snapshots for trend analysis</li><li><strong>Journal &amp; Lessons</strong> — Trading journal with AI-generated lessons</li><li><strong>Auto-trading</strong> — Configurable automated trading strategies</li><li><strong>Mr Krabs Strategy Automation</strong> — Percentage trailing stops, DCA ladders, options wheel planning, and copy-trading policy</li><li><strong>Workflow Automation</strong> — Pre-market, market-open, midday, EOD and weekly workflow runs with stored reports</li><li><strong>Multi-channel Delivery</strong> — Aiopta chat, Discord, Slack, email, webhook, and in-app storage</li><li><strong>Research Delegation</strong> — Trading research is delegated to the <code>research-tools</code> addon instead of duplicating search logic</li><li><strong>KRABCLI Parity Matrix</strong> — Owner-verified, evidence-backed parity view of the eleven atomic KRABCLI commands and five workflow phase bundles (issue #104). Pin-enforced sub-module reference (<code>krabcli.pin.json</code> at commit <code>487b7895a65d53bfa698a17583fbf6d61342e325</code>); matrix loader at <code>src/lib/krabcli/parity.ts</code> and sub-process wrapper at <code>src/lib/krabcli/runner.ts</code>. See <code>docs/KRABCLI-PARITY.md</code> for the 17-row evidence document.</li></ul>\n<h2>Automated Strategies</h2>\n<p>Rule-based strategy engines (no LLM needed) under <code>src/lib/</code>. Each is a pure,<br />unit-tested planner; execution goes through the normal order path with the<br />strategy guard-rails (<code>dryRun</code>, daily-trade and exposure caps).</p>\n<p>| Strategy | Module | Execution on Hyperliquid | Notes |<br />|----------|--------|--------------------------|-------|<br />| <strong>Percentage trailing stop</strong> | <code>stop-manager.ts</code> + <code>strategy-config.ts</code> | ✅ full | Price-based floor that only ratchets up (longs) / down (shorts); places the SL via <code>engine.setStopLoss</code>. |<br />| <strong>Ladder buys / DCA</strong> | <code>ladder-executor.ts</code> | ✅ full | Staged limit buys at dip thresholds, double-execution-guarded in DB. |<br />| <strong>Wheel strategy (#236)</strong> | <code>wheel-strategy.ts</code> | ⚠️ advisory only | The cash-secured-put → assignment → covered-call <strong>cycle state machine</strong> and per-cycle premium accounting are implemented and tested. <strong>Limitation:</strong> Hyperliquid has no options, so there is no execution backend — <code>planWheelCycle</code> returns <code>advisoryOnly</code> plans (paper). Strike selection uses a delta-as-moneyness heuristic; wiring an options-capable provider (Tradier/Alpaca/IBKR) through a <code>WheelExecutionAdapter</code> + a live options chain would make it executable. |<br />| <strong>Copy trading / smart money (#237)</strong> | <code>copy-trading.ts</code> | ✅ executable, ⚠️ needs data source | The <strong>mirroring engine</strong> (conviction sizing, USD cap, confidence/lag filters, allow/deny lists, manual-approval gate) is implemented and tested; mirrored orders are normal perp orders. <strong>Limitation:</strong> the <em>data source</em> — which trades to copy (CapitolTrades/Quiver politician filings, whale wallets) — is external and not wired. Feed trades via the <code>ObservedTrade</code> shape; a future provider implements the <code>CopyTradeProvider</code> interface. |</p>\n<p>Strategy config lives in <code>strategy-config.ts</code> (<code>StrategyAutomationConfig</code>) and is<br />editable through the Trading settings UI.</p>\n<h2>API Endpoints</h2>\n<p>All endpoints require API key authentication (<code>Authorization: Bearer &lt;key&gt;</code>).<br />Addon runtime routes are exposed under <code>/api/v1/addons/trading/...</code>.</p>\n<p>| Method | Endpoint | Description |<br />|--------|----------|-------------|<br />| GET | <code>/api/v1/trading/status</code> | Full portfolio status with positions |<br />| GET | <code>/api/v1/trading/account</code> | Account balance, margin, capital summary |<br />| GET | <code>/api/v1/trading/positions</code> | Open positions only |<br />| GET | <code>/api/v1/trading/orders</code> | Open orders |<br />| POST | <code>/api/v1/trading/orders</code> | Place limit/stop/TP order |<br />| POST | <code>/api/v1/trading/buy</code> | Open long position |<br />| POST | <code>/api/v1/trading/sell</code> | Open short position |<br />| POST | <code>/api/v1/trading/close</code> | Close position by coin |<br />| POST | <code>/api/v1/trading/stop-loss</code> | Set stop-loss for position |<br />| GET | <code>/api/v1/trading/history</code> | Closed-trade history (canonical, KRABCLI <code>history</code>, max 30, per-user) |<br />| GET | <code>/api/v1/trading/stats</code> | Performance statistics (canonical, KRABCLI <code>stats</code>, per-user) |<br />| GET | <code>/api/v1/trading/report</code> | Daily report (template-versioned, idempotent, KRABCLI <code>report</code>) |<br />| GET | <code>/api/v1/trading/charts</code> | Chart data |<br />| GET | <code>/api/v1/trading/risk</code> | Risk metrics |<br />| GET | <code>/api/v1/trading/journal</code> | Trading journal entries |<br />| GET | <code>/api/v1/trading/lessons</code> | Learned lessons |<br />| GET | <code>/api/v1/trading/analyze</code> | AI trade analysis |<br />| GET | <code>/api/v1/trading/research/search</code> | Search web research sources |<br />| GET | <code>/api/v1/trading/research/fetch</code> | Extract readable source content |<br />| GET | <code>/api/v1/trading/autotrading</code> | Auto-trading config |<br />| GET | <code>/api/v1/addons/trading/strategies</code> | Mr Krabs strategy automation settings |<br />| PATCH | <code>/api/v1/addons/trading/strategies</code> | Update percentage trailing, DCA, wheel, copy-trading and guard-rail settings |<br />| POST | <code>/api/v1/addons/trading/strategies/preview</code> | Preview strategy effects for a supplied market snapshot |<br />| GET | <code>/api/v1/trading/workflow</code> | Recent workflow runs |<br />| POST | <code>/api/v1/trading/workflow/run</code> | Run a workflow phase manually |<br />| POST | <code>/api/v1/trading/hyperliquid</code> | Webhook for trade fills/cancels/liquidations |</p>\n<h2>Canonical history &amp; stats (issue #115 — KRABCLI parity)</h2>\n<p><code>GET /api/v1/addons/trading/history</code>, <code>GET /api/v1/addons/trading/stats</code><br />and <code>GET /api/v1/addons/trading/report</code> are the <strong>canonical</strong> surfaces<br />that mirror the KRABCLI commands <code>history</code>, <code>stats</code>, <code>report</code>. They<br />read from the per-user <code>trades</code> table (<code>scopedStore(userId)</code>, ADR-054)<br />so a user can never see another user&#39;s positions or PnL — the row-level<br />isolation is enforced in the SQL layer, not in JS.</p>\n<h3>History (<code>/history</code>)</h3>\n<p>- Closed trades only (<code>status = &#39;closed&#39;</code>). Open / cancelled rows never<br />  leak through.<br />- Sort key: <code>closed_at DESC, id DESC</code> (deterministic tie-break).<br />- Hard cap: <strong>30 rows</strong> (the KRABCLI parity contract). Bumping this<br />  constant is a contract change — update both this file and<br />  <code>addons/trading/src/lib/history.ts: CANONICAL_HISTORY_LIMIT</code>.<br />- Each row carries its <strong>own</strong> <code>fxRate</code>, <code>fxFetchedAt</code>, <code>fxStale</code><br />  triple so a downstream consumer can render `USD (1.10 EUR, fetched<br />  2 min ago)`-style labels without re-resolving the FX snapshot.</p>\n<h3>Stats formulas (<code>/stats</code>)</h3>\n<p>All formulas are pinned by the golden-fixture tests in<br /><code>tests/unit/performance-canonical.test.ts</code>. Sample size, all rates and<br />the Sharpe are computed from the <strong>per-user closed-trade ledger only</strong><br />(ADR-054). When <code>sampleSize = 0</code> the response returns neutral zeros<br />plus <code>fx</code>/<code>degraded</code> metadata.</p>\n<p>| Metric | Formula |<br />|---|---|<br />| <code>winRate</code> | <code>wins / sampleSize</code> (rounded to 4 dp) |<br />| <code>lossRate</code> | <code>losses / sampleSize</code> (rounded to 4 dp) |<br />| <code>sharpe</code> | <code>(mean(pnlUsd) / sampleStd(pnlUsd)) * sqrt(252)</code> (annualised; <code>sqrt(252)</code> is the documented trading-day convention for the KRABCLI parity matrix). Undefined for <code>sampleSize ≤ 1</code> → returns <code>0</code>. |<br />| <code>maxDrawdownUsd</code> | <code>max(peak_cumulative - cumPnL_i)</code> over the chronological pnl series (USD, rounded to cents) |<br />| <code>avgDurationHours</code> | <code>mean(closed_at - opened_at)</code> across rows where both timestamps parse |<br />| <code>medianDurationHours</code> | <code>median</code> of the same duration array (rounded to 4 dp) |<br />| <code>totalPnlUsd</code> / <code>totalPnlEur</code> | <code>sum(pnl_usd)</code> / <code>sum(pnl_eur)</code> (USD rounded to cents, EUR to cents) |<br />| <code>totalFeesUsd</code> | <code>sum(fees_usd)</code> (column added by issue #115 migration) |<br />| <code>bestTradeUsd</code> / <code>worstTradeUsd</code> / <code>avgPnlUsd</code> | <code>max</code> / <code>min</code> / <code>mean</code> of per-trade <code>pnl_usd</code> |</p>\n<p>Changing any of these is a contract change — update the README, the<br />golden-fixture test, and <code>performance.ts</code> together.</p>\n<h3>FX service (<code>fx.ts</code>)</h3>\n<p>- TTL: <strong>1 hour</strong> (<code>FX_TTL_MS = 3 600 000</code> ms). A cache entry older<br />  than the TTL is served as <code>stale: true</code>, never as fresh.<br />- Provider: Frankfurter API (<code>https://api.frankfurter.app/latest</code>),<br />  with a 5 s per-request timeout (<code>FX_REQUEST_TIMEOUT_MS</code>).<br />- <strong>No invented rates.</strong> When the provider fails AND no cache entry<br />  exists, the snapshot returns <code>rate: 1</code> with <code>source: &#39;provider-failed&#39;</code><br />  and <code>stale: true</code>. The route layer surfaces <code>degraded: true</code> so the<br />  UI tells the user &quot;we don&#39;t know the rate right now&quot; instead of<br />  silently picking a number.<br />- Per-trade FX pricing is the canonical source — each closed-trade<br />  row stores its own <code>eur_usd_rate</code> (USD → EUR) plus <code>fx_rate_at</code><br />  timestamp (ISO), written at close time. Legacy rows that pre-date<br />  the column fall back to the live snapshot and get tagged<br />  <code>fxStale: true</code>. The route payload exposes both the per-row FX and<br />  the batch-level FX snapshot so the consumer can choose which to<br />  surface.</p>\n<h3><code>report</code> (<code>/report</code>)</h3>\n<p>The <code>report</code> command is the <strong>MISSING-CONTRACT</strong> from the KRABCLI<br />parity matrix (issue #156, row <code>cmd-report</code>). The Aiopta route is:</p>\n<p>- Read-only, template-versioned (<code>templateVersion: &quot;1.0.0&quot;</code>), no order<br />  mutation.<br />- Idempotent per <code>(userId, date)</code> — the response carries an<br />  <code>idempotencyKey</code> (FNV-1a over <code>(userId, date, stats)</code>) so a cron<br />  retry on the same day produces the same key and the same body.<br />- Date defaults to today (UTC); <code>?date=YYYY-MM-DD</code> for back-fills.</p>\n<h2>Workflow Notes</h2>\n<ul><li>Broad scan covers crypto, equities, and macro research.</li><li>Live execution remains limited to connected backends, currently Hyperliquid.</li><li>Trading research inside this addon calls into the <code>research-tools</code> addon.</li><li>Delivery targets are configurable in the trading settings panel.</li></ul>\n<h2>Mr. Crabs Usage Examples</h2>\n<p>### Check Account Balance<br />```bash<br />curl -H &quot;Authorization: Bearer $API_KEY&quot; \\<br />  <a href=\"https://aiopta.example.com/api/v1/trading/account\" rel=\"noopener noreferrer\">https://aiopta.example.com/api/v1/trading/account</a><br />```</p>\n<p>### Open a Long Position (BTC, $100 margin, 20x leverage, 5% stop-loss)<br />```bash<br />curl -X POST -H &quot;Authorization: Bearer $API_KEY&quot; \\<br />  -H &quot;Content-Type: application/json&quot; \\<br />  -d &#39;{&quot;coin&quot;:&quot;BTC&quot;,&quot;size&quot;:100,&quot;leverage&quot;:20}&#39; \\<br />  <a href=\"https://aiopta.example.com/api/v1/trading/buy\" rel=\"noopener noreferrer\">https://aiopta.example.com/api/v1/trading/buy</a><br />```</p>\n<p>### Open a Short Position<br />```bash<br />curl -X POST -H &quot;Authorization: Bearer $API_KEY&quot; \\<br />  -H &quot;Content-Type: application/json&quot; \\<br />  -d &#39;{&quot;coin&quot;:&quot;ETH&quot;,&quot;size&quot;:200,&quot;leverage&quot;:10}&#39; \\<br />  <a href=\"https://aiopta.example.com/api/v1/trading/sell\" rel=\"noopener noreferrer\">https://aiopta.example.com/api/v1/trading/sell</a><br />```</p>\n<p>### Place a Limit Order<br />```bash<br />curl -X POST -H &quot;Authorization: Bearer $API_KEY&quot; \\<br />  -H &quot;Content-Type: application/json&quot; \\<br />  -d &#39;{&quot;coin&quot;:&quot;BTC&quot;,&quot;side&quot;:&quot;BUY&quot;,&quot;type&quot;:&quot;LIMIT&quot;,&quot;size&quot;:0.1,&quot;price&quot;:80000}&#39; \\<br />  <a href=\"https://aiopta.example.com/api/v1/trading/orders\" rel=\"noopener noreferrer\">https://aiopta.example.com/api/v1/trading/orders</a><br />```</p>\n<p>### Trade a Builder-Deployed Perp<br />```bash<br />curl -X POST -H &quot;Authorization: Bearer $API_KEY&quot; \\<br />  -H &quot;Content-Type: application/json&quot; \\<br />  -d &#39;{&quot;coin&quot;:&quot;xyz:NVDA&quot;,&quot;side&quot;:&quot;BUY&quot;,&quot;type&quot;:&quot;MARKET&quot;,&quot;size&quot;:1}&#39; \\<br />  <a href=\"https://aiopta.example.com/api/v1/trading/orders\" rel=\"noopener noreferrer\">https://aiopta.example.com/api/v1/trading/orders</a><br />```</p>\n<p>### Set Stop-Loss<br />```bash<br />curl -X POST -H &quot;Authorization: Bearer $API_KEY&quot; \\<br />  -H &quot;Content-Type: application/json&quot; \\<br />  -d &#39;{&quot;coin&quot;:&quot;BTC&quot;,&quot;price&quot;:79000}&#39; \\<br />  <a href=\"https://aiopta.example.com/api/v1/trading/stop-loss\" rel=\"noopener noreferrer\">https://aiopta.example.com/api/v1/trading/stop-loss</a><br />```</p>\n<p>### Close Position<br />```bash<br />curl -X POST -H &quot;Authorization: Bearer $API_KEY&quot; \\<br />  -H &quot;Content-Type: application/json&quot; \\<br />  -d &#39;{&quot;coin&quot;:&quot;BTC&quot;}&#39; \\<br />  <a href=\"https://aiopta.example.com/api/v1/trading/close\" rel=\"noopener noreferrer\">https://aiopta.example.com/api/v1/trading/close</a><br />```</p>\n<p>### Get Daily P&amp;L Summary<br />```bash<br />curl -H &quot;Authorization: Bearer $API_KEY&quot; \\<br />  <a href=\"https://aiopta.example.com/api/v1/trading/stats\" rel=\"noopener noreferrer\">https://aiopta.example.com/api/v1/trading/stats</a><br />```</p>\n<p>### Run A Pre-Market Workflow<br />```bash<br />curl -X POST -H &quot;Authorization: Bearer $API_KEY&quot; \\<br />  -H &quot;Content-Type: application/json&quot; \\<br />  -d &#39;{&quot;phase&quot;:&quot;pre_market&quot;}&#39; \\<br />  <a href=\"https://aiopta.example.com/api/v1/trading/workflow/run\" rel=\"noopener noreferrer\">https://aiopta.example.com/api/v1/trading/workflow/run</a><br />```</p>\n<h2>Configuration</h2>\n<p>Set these environment variables:</p>\n<pre><code class=\"lang-env\">HYPERLIQUID_ADDRESS=0x...\nHYPERLIQUID_PRIVATE_KEY=0x...</code></pre>\n<h2>Extension Management</h2>\n<p>Enable/disable via Admin API:<br />```bash<br /># Check status<br />curl -H &quot;Authorization: Bearer $API_KEY&quot; \\<br />  <a href=\"https://aiopta.example.com/api/v1/admin/extensions\" rel=\"noopener noreferrer\">https://aiopta.example.com/api/v1/admin/extensions</a></p>\n<p># Disable trading<br />curl -X POST -H &quot;Authorization: Bearer $API_KEY&quot; \\<br />  -H &quot;Content-Type: application/json&quot; \\<br />  -d &#39;{&quot;id&quot;:&quot;trading&quot;,&quot;enabled&quot;:false}&#39; \\<br />  <a href=\"https://aiopta.example.com/api/v1/admin/extensions\" rel=\"noopener noreferrer\">https://aiopta.example.com/api/v1/admin/extensions</a><br />```</p>\n<h2>Database Tables</h2>\n<p>Created on install:<br />- <code>trades</code> — All trade records (open/closed/cancelled)<br />- <code>orders</code> — Limit/stop/TP orders<br />- <code>lessons</code> — Trading lessons learned<br />- <code>daily_snapshots</code> — Capital snapshots per day</p>\n<h2>Gov-Scanner (addons#933)</h2>\n<p>Wertet öffentliche Pflichtmeldungen und Vergabebekanntmachungen aus. **Keine<br />Insider-Tipps** — ausschliesslich Quellen, deren Veröffentlichung gesetzlich<br />vorgeschrieben ist.</p>\n<h3>Die sechs Quellen</h3>\n<p>| Kennung | Art | liefert | Zugangsdaten |<br />|---|---|---|---|<br />| <code>usaspending</code> | Vergabe | US-Bundesvergaben | keine |<br />| <code>sec_form4</code> | Meldung | Insidergeschäfte (SEC Form 4) | <code>gov.sec_user_agent</code> |<br />| <code>stock_act</code> | Meldung | Offenlegungen des US-Kongresses | <code>gov.stock_act_user_agent</code> |<br />| <code>ted</code> | Vergabe | EU-Vergabebekanntmachungen | <code>gov.ted_user_agent</code> |<br />| <code>eu_transparency</code> | Meldung | Stimmrechtsmitteilungen (EU) | <code>gov.eu_transparency_user_agent</code> |<br />| <code>bundesanzeiger</code> | Meldung | Directors&#39; Dealings (Art. 19 MAR) | <code>gov.bundesanzeiger_api_key</code> |</p>\n<p>Alle sechs stehen nach <code>install()</code> in <code>gov_sources</code> — auch die ohne<br />Zugangsdaten. Eine leere Liste wäre von „sechs Quellen, alle unkonfiguriert&quot;<br />nicht zu unterscheiden, obwohl das eine ein Zustand und das andere eine<br />Aufgabenliste ist.</p>\n<p><strong>Vergabe- und Meldequellen unterscheiden sich in der Richtung.</strong> Eine Vergabe<br />ist ein Zufluss: sie kann <code>BUY</code> oder <code>NEUTRAL</code> begründen, nie <code>SELL</code>. Nur<br />Meldequellen nennen den Geschäftsvorfall und damit auch einen Verkauf.</p>\n<h3>Zustand je Quelle</h3>\n<p><code>gov_sources.last_status</code> trägt einen von vier Werten:</p>\n<p>| Wert | heisst |<br />|---|---|<br />| <code>NULL</code> | noch nie gelaufen |<br />| <code>ok</code> | die Quelle hat geantwortet |<br />| <code>not_configured</code> | es fehlt eine Angabe, die nur der Betreiber liefern kann |<br />| <code>unreachable</code> | die Angabe ist da, die Quelle antwortet nicht |</p>\n<p>Die letzten beiden sehen an der Oberfläche gleich aus („keine Daten&quot;), sind<br />aber verschiedene Dinge: das eine ist eine Aufgabe für den Betreiber, das<br />andere eine Störung, die von selbst vergeht. Eine gestörte Quelle bricht den<br />Gesamtscan <strong>nicht</strong> ab — sonst sähen die übrigen aus wie „nichts Neues<br />gefunden&quot;, obwohl sie nie gefragt wurden.</p>\n<h3>Rechte</h3>\n<p>| Recht | erlaubt |<br />|---|---|<br />| <code>addons.trading.gov.read</code> | Quellenliste, Treffer, Beleg, Backtest, Konfiguration lesen |<br />| <code>addons.trading.gov.configure</code> | Quellen an/aus, Schwellen, Webhook, Scan auslösen |</p>\n<p>Die Gov-Daten sind <strong>instanzweit</strong> (ADR-054), nicht nutzergebunden — es gibt<br />kein Besitzerprädikat. Die Vorschlagsliste (<code>/gov-proposals</code>) ist die Ausnahme:<br />sie gehört dem Nutzer und wird über <code>user_id</code> gebunden.</p>\n<h3>Emittenten-Auflösung</h3>\n<p><code>resolveIssuer</code> löst einen gemeldeten Namen gegen <code>gov_issuers</code> auf und liefert<br />einen von drei Zuständen:</p>\n<p>| Zustand | heisst |<br />|---|---|<br />| <code>resolved</code> | genau ein Treffer, <code>ticker</code> ist gesetzt |<br />| <code>ambiguous</code> | mehrere Treffer, <code>candidates</code> nennt sie |<br />| <code>unresolved</code> | kein Treffer |</p>\n<p><strong><code>unresolved</code> lässt die Meldung nicht verschwinden.</strong> Der Entwurf trägt dann<br /><code>ticker: null</code> und bleibt sichtbar; nur zum Handelssignal wird er nicht. Eine<br />frühere Fassung liess solche Meldungen fallen — damit war die Zahl der<br />gefundenen Vorgänge systematisch zu klein, ohne dass es jemand sah.</p>\n<p>Die Auflösung vergleicht <strong>exakt</strong> auf der normalisierten Form. Eine frühere<br />beidseitige Teilstring-Heuristik erzeugte belegbare Falschtreffer<br />(<code>&quot;AMAZONIA CONSTRUCTORA LTDA&quot;</code> → <code>AMZN</code>, <code>&quot;CO&quot;</code> → <code>AIR</code>) — und aus einem<br />Falschtreffer wird kein Anzeigefehler, sondern ein Handelssignal auf das<br />falsche Papier.</p>\n<h3>Der Leerzustand des Backtests</h3>\n<p>Ohne konfigurierte Kursquelle gibt es <strong>keine Trefferquote</strong> — und <code>0 %</code> wäre<br />die schlechteste aller Antworten: sie sieht aus wie eine Messung und heisst<br />„alles verloren&quot;. Jedes Ergebnis trägt deshalb entweder Zahlen <strong>oder</strong> einen<br />Grund:</p>\n<p>| Grund | wann |<br />|---|---|<br />| <code>no_price_source</code> | keine Kursreihe vorhanden |<br />| <code>insufficient_sample</code> | weniger als 8 auswertbare Signale |<br />| <code>no_price_at_signal</code> | dieses Signal hat keinen Kurs am Signaltag |<br />| <code>horizon_incomplete</code> | der Horizont reicht über das Ende der Reihe hinaus |</p>\n<p>Die letzten beiden gelten je Signal und schliessen es aus der Stichprobe aus,<br />ohne die ganze Rechnung zu verwerfen. Fenster: 24 Monate Rückblick, 20<br />Handelstage Horizont.</p>\n<h2>Architecture</h2>\n<pre><code>lib/trading/\n├── client.ts       # Hyperliquid SDK wrapper\n├── engine.ts       # Core trading: buy, sell, close, stop-loss\n├── orders.ts       # Order types: market, limit, stop, take-profit\n├── performance.ts  # Stats, history, snapshots\n├── fx.ts           # EUR/USD rate\n├── charts.ts       # Chart data\n├── risk.ts         # Risk calculations\n├── journal.ts      # Trading journal\n├── lessons.ts      # AI lessons\n├── autotrading.ts  # Auto-trading config\n└── websocket.ts    # Real-time feeds</code></pre>\n<p>app/api/v1/trading/  # API routes (16 endpoints)<br />extensions/trading/   # Extension manifest &amp; registration<br />```</p>",
          "byteSize": 17348
        }
      }
    },
    {
      "id": "travel-booking",
      "uuid": "8ad3de7d-5aff-441f-9498-9073b3d16a9a",
      "name": "Travel-Booking (read-only Flug- + Hotelsuche)",
      "version": "0.2.1",
      "description": "Read-only Flug- + Hotelsuche: sucht und vergleicht normalisierte Flug- und Hotelangebote aus serverkonfigurierten Aggregatoren (Fake + echter Adaptervertrag). Keine Buchung, keine Zahlung, keine Browserautomation. User-scoped Suchprofil/-historie (ADR-054).",
      "author": "Aiopta",
      "icon": "✈️",
      "capabilities": [
        "travel-booking",
        "flights",
        "flight-search",
        "hotels",
        "hotel-search"
      ],
      "tags": [],
      "download": "/artifacts/travel-booking-0.2.1.zip",
      "sha256": "ffefe9013a27a004574b8535c10a34e695278941bdd419ad05ed6ccaffa20d02",
      "size": 192608,
      "page": null,
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/travel-booking/flights",
        "/api/v1/addons/travel-booking/hotels"
      ],
      "skills": [
        {
          "name": "travel-booking-search-flights",
          "displayName": "Travel-Booking: Flüge suchen (read-only)",
          "description": "Sucht normalisierte Flugangebote (Preis minor + Währung, Zeiten, Segmente, Quote-Ablauf) aus konfigurierten Aggregatoren. Streng read-only — löst keine Buchung aus und exponiert keine Credentials.",
          "permissions": [
            "addons/travel-booking/flights"
          ],
          "endpoint": "/api/v1/addons/travel-booking/flights",
          "scripts": []
        },
        {
          "name": "travel-booking-search-hotels",
          "displayName": "Travel-Booking: Hotels suchen (read-only)",
          "description": "Sucht normalisierte Hotelangebote (Preis minor + Währung, Steuern/Gebühren, Meal-Plan, Storno/Refundability, Quote-Ablauf) aus konfigurierten Aggregatoren. Streng read-only — löst keine Buchung aus, synthetisiert keine Bewertungen und exponiert keine Credentials.",
          "permissions": [
            "addons/travel-booking/hotels"
          ],
          "endpoint": "/api/v1/addons/travel-booking/hotels",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": null
    },
    {
      "id": "trip-planner",
      "uuid": "821a1fcc-69ff-4b9d-a9e5-a235d66dd260",
      "name": "Trip-Planner (Reisen: Etappen/Unterkünfte/POIs/Packliste/Budget/Karte)",
      "version": "1.0.2",
      "description": "Per-User Reisen: Trips mit Etappen (Timeline), Unterkünften, POIs, Packliste, pro-Trip Budget (Rollup aus Etappen + Unterkünften) und einer Karte. Lokale SQLite ist die Quelle der Wahrheit (ADR-054).",
      "author": "Aiopta",
      "icon": "🧳",
      "capabilities": [
        "trip-planner",
        "trips",
        "stages",
        "accommodations",
        "pois",
        "packing",
        "budget",
        "map"
      ],
      "tags": [],
      "download": "/artifacts/trip-planner-1.0.2.zip",
      "sha256": "5898ac1f7d40d1450a9fd6f6983ff16ed98108269d555df82c559e4c4dc6842a",
      "size": 124005,
      "page": {
        "id": "trip-planner-page",
        "path": "/addons/trip-planner",
        "name": "Trip-Planner",
        "icon": "🧳",
        "section": "Tools",
        "component": "@addons/trip-planner/src/components/TripPlannerPage"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/trip-planner/trips",
        "/api/v1/addons/trip-planner/trips/:id",
        "/api/v1/addons/trip-planner/stages",
        "/api/v1/addons/trip-planner/stages/:id",
        "/api/v1/addons/trip-planner/accommodations",
        "/api/v1/addons/trip-planner/accommodations/:id",
        "/api/v1/addons/trip-planner/pois",
        "/api/v1/addons/trip-planner/pois/:id",
        "/api/v1/addons/trip-planner/packing",
        "/api/v1/addons/trip-planner/packing/:id",
        "/api/v1/addons/trip-planner/budget"
      ],
      "skills": [
        {
          "name": "trip-planner-read",
          "displayName": "Trip-Planner: Reisen, Etappen & Orte lesen",
          "description": "Listet Reisen, Etappen, Unterkünfte, POIs und die Packliste für den Nutzer und zeigt das pro-Trip Budget (Rollup). Reiner Lesezugriff — legt nichts an.",
          "permissions": [
            "addons/trip-planner/trips",
            "addons/trip-planner/stages",
            "addons/trip-planner/accommodations",
            "addons/trip-planner/pois",
            "addons/trip-planner/packing",
            "addons/trip-planner/budget"
          ],
          "endpoint": "/api/v1/addons/trip-planner/budget",
          "scripts": []
        },
        {
          "name": "trip-planner-manage",
          "displayName": "Trip-Planner: Reisen & Etappen verwalten",
          "description": "Legt Reisen und Etappen an oder entfernt diese, setzt Etappen-Reihenfolge und Budget (per-User gebucht).",
          "permissions": [
            "addons/trip-planner/trips",
            "addons/trip-planner/stages"
          ],
          "endpoint": "/api/v1/addons/trip-planner/trips",
          "scripts": []
        },
        {
          "name": "trip-planner-details",
          "displayName": "Trip-Planner: Unterkünfte, POIs & Packliste verwalten",
          "description": "Legt Unterkünfte, POIs und Packlisten-Einträge an oder entfernt diese und setzt den Pack-Status (per-User gebucht).",
          "permissions": [
            "addons/trip-planner/accommodations",
            "addons/trip-planner/pois",
            "addons/trip-planner/packing"
          ],
          "endpoint": "/api/v1/addons/trip-planner/packing",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>🧳 Trip-Planner (Reisen: Etappen/Unterkünfte/POIs/Packliste/Budget/Karte)</h1>\n<p>Per-User Reisen: Trips mit Etappen (Timeline), Unterkünften, POIs, Packliste, pro-Trip Budget (Rollup aus Etappen + Unterkünften) und einer Karte. Lokale SQLite ist die Quelle der Wahrheit (ADR-054).</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>trip-planner</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> trip-planner, trips, stages, accommodations, pois, packing, budget, map</li><li><strong>UI-Seite:</strong> <code>/addons/trip-planner</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Keine Secrets erforderlich — funktioniert ohne zusätzliche Credentials. Die Karte<br />ist eingebettet (Equirectangular-Projektion über Lat/Lng der Etappen/Unterkünfte/POIs)<br />und benötigt keine externen Tile-Provider.</p>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET, POST | <code>/api/v1/addons/trip-planner/trips</code> |<br />| PATCH, DELETE | <code>/api/v1/addons/trip-planner/trips/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/trip-planner/stages</code> |<br />| PATCH, DELETE | <code>/api/v1/addons/trip-planner/stages/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/trip-planner/accommodations</code> |<br />| DELETE | <code>/api/v1/addons/trip-planner/accommodations/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/trip-planner/pois</code> |<br />| DELETE | <code>/api/v1/addons/trip-planner/pois/[id]</code> |<br />| GET, POST | <code>/api/v1/addons/trip-planner/packing</code> |<br />| PATCH, DELETE | <code>/api/v1/addons/trip-planner/packing/[id]</code> |<br />| GET | <code>/api/v1/addons/trip-planner/budget</code> |</p>\n<p>Alle Endpunkte sind per <code>userRoute</code> authentifiziert und per <code>scopedStore(auth.id)</code><br />per-User gebucht (ADR-054). Ein fremdes/fehlendes <code>id</code> -&gt; 404 ohne Existenz-Leak.<br />Das Löschen einer Reise kaskadiert auf deren Etappen/Unterkünfte/POIs/Packliste.</p>\n<h2>Budget</h2>\n<p><code>GET /budget?tripId=</code> liefert den Rollup: geplante Ausgaben (Summe aus Etappen-<br />+ Unterkünfte-Kosten) gegen das Reise-Budget, mit <code>remaining</code>, <code>percentUsed</code> und<br /><code>overBudget</code>. Zusätzlich wird der Pack-Fortschritt (<code>packed/total</code>, <code>percent</code>)<br />zurückgegeben.</p>\n<blockquote><p>README aus <code>addon.json</code> + vorhandenen Routen generiert (<code>scripts/generate-addon-readmes.js</code>). Bei manuellen Ergänzungen den Generator-Hinweis entfernen.</p></blockquote>",
          "byteSize": 2063
        }
      }
    },
    {
      "id": "voice",
      "uuid": "9777b5eb-54ad-488b-b622-1f7724eb6007",
      "name": "Voice",
      "version": "1.1.3",
      "description": "Unified voice addon combining Text-to-Speech and Speech-to-Text with multiple backend support.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "tts",
        "stt",
        "voice",
        "audio"
      ],
      "tags": [],
      "download": "/artifacts/voice-1.1.3.zip",
      "sha256": "a26b4e69a457e29e2b6a8f07f7601d643ecdf273a4b80ba0411b2bbba92a877b",
      "size": 148337,
      "page": {
        "path": "/addons/voice",
        "name": "Voice",
        "icon": "🎤",
        "section": "Settings",
        "component": "@addons/voice/src/ui/VoiceSettings"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/voice/health",
        "/api/v1/addons/voice/status",
        "/api/v1/addons/voice/start",
        "/api/v1/addons/voice/stop",
        "/api/v1/addons/voice/synthesize",
        "/api/v1/addons/voice/transcribe",
        "/api/v1/addons/voice/voices",
        "/api/v1/addons/voice/backends",
        "/api/v1/addons/voice/backend/switch",
        "/api/v1/addons/voice/settings",
        "/api/v1/addons/voice/tools/synthesize",
        "/api/v1/addons/voice/tools/transcribe",
        "/api/v1/addons/voice/tools/voice-proxy",
        "/api/v1/addons/voice/tools/voice-reply",
        "/api/v1/addons/voice/tools/voice-reply-external"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Voice</h1>\n<p>Unified voice addon combining Text-to-Speech and Speech-to-Text with multiple backend support.</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>voice</code></li><li><strong>Version:</strong> 1.0.0</li><li><strong>Fähigkeiten:</strong> tts, stt, voice, audio</li><li><strong>UI-Seite:</strong> <code>/addons/voice</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Dieses Addon benötigt folgende Secrets/Provider-Konfiguration (Settings → Credentials bzw. Provider-Store, <strong>nie</strong> in addon.json):</p>\n<ul><li><code>AWS_ACCESS_KEY_ID</code></li><li><code>AWS_SECRET_ACCESS_KEY</code></li><li><code>AWS_STT_ACCESS_KEY_ID</code></li><li><code>AWS_STT_SECRET_ACCESS_KEY</code></li><li><code>AZURE_STT_SUBSCRIPTION_KEY</code></li><li><code>ELEVENLABS_API_KEY</code></li><li><code>GOOGLE_STT_API_KEY</code></li><li><code>GOOGLE_TTS_API_KEY</code></li></ul>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| POST | <code>/api/v1/addons/voice/backend/switch</code> |<br />| GET | <code>/api/v1/addons/voice/backends</code> |<br />| GET | <code>/api/v1/addons/voice/health</code> |<br />| GET, POST | <code>/api/v1/addons/voice/settings</code> |<br />| POST | <code>/api/v1/addons/voice/start</code> |<br />| GET | <code>/api/v1/addons/voice/status</code> |<br />| POST | <code>/api/v1/addons/voice/stop</code> |<br />| POST | <code>/api/v1/addons/voice/synthesize</code> |<br />| GET, POST | <code>/api/v1/addons/voice/tools/synthesize</code> |<br />| GET, POST | <code>/api/v1/addons/voice/tools/transcribe</code> |<br />| GET, POST | <code>/api/v1/addons/voice/tools/voice-proxy</code> |<br />| GET, POST | <code>/api/v1/addons/voice/tools/voice-reply</code> |<br />| GET, POST | <code>/api/v1/addons/voice/tools/voice-reply-external</code> |<br />| POST | <code>/api/v1/addons/voice/transcribe</code> |<br />| GET | <code>/api/v1/addons/voice/voices</code> |</p>\n<h2>Bekannte Grenzen</h2>\n<ul><li>Keine bekannten Einschränkungen.</li></ul>\n<blockquote><p>README aus <code>addon.json</code> + vorhandenen Routen generiert (<code>scripts/generate-addon-readmes.js</code>). Bei manuellen Ergänzungen den Generator-Hinweis entfernen.</p></blockquote>",
          "byteSize": 1582
        }
      }
    },
    {
      "id": "vpn",
      "uuid": "9cdda8a9-9ac1-4548-b49d-fb20573ec8f0",
      "name": "VPN (WireGuard / Tailscale — Peers, Status & Konfig-QR)",
      "version": "1.0.4",
      "description": "Per-User VPN-Verwaltung: BYOK-Verbindung zu WireGuard (wg-easy API) oder Tailscale (API). Listet Peers (connected/disconnected), zeigt Peer-Status (Handshake/Transfer), generiert Konfig-QR (WireGuard-Peer-Config → QR-Data-URI) und legt Peers an/entfernt. Read/Write gegenüber dem eigenen VPN-Server, vollständig per-User über scopedStore isoliert (ADR-054).",
      "author": "Aiopta",
      "icon": "🔒",
      "capabilities": [
        "vpn",
        "wireguard",
        "tailscale",
        "peers",
        "peer-status",
        "config-qr",
        "connections"
      ],
      "tags": [],
      "download": "/artifacts/vpn-1.0.4.zip",
      "sha256": "045b522f03c97981480f6bf0eb4f9d3d95aac4dcc813d684d84aeb76fd5b318c",
      "size": 120933,
      "page": {
        "id": "vpn-page",
        "path": "/addons/vpn",
        "name": "VPN",
        "icon": "🔒",
        "section": "Infra",
        "component": "@addons/vpn/src/components/VpnPage"
      },
      "deprecated": true,
      "replacedBy": "network",
      "deprecation": {
        "deprecated": true,
        "replacedBy": "network",
        "sunset": "2026-12-31",
        "successorPath": "/addons/network",
        "successorLabel": "Network/Homelab (Nachfolger)"
      },
      "endpoints": [
        "/api/v1/addons/vpn/config",
        "/api/v1/addons/vpn/config/:id",
        "/api/v1/addons/vpn/peers",
        "/api/v1/addons/vpn/peers/:id",
        "/api/v1/addons/vpn/status",
        "/api/v1/addons/vpn/config-string",
        "/api/v1/addons/vpn/qr"
      ],
      "skills": [
        {
          "name": "vpn-read",
          "displayName": "VPN: Peers & Status lesen",
          "description": "Listet VPN-Peers (WireGuard/Tailscale) für den Nutzer mit Verbindungsstatus (connected/disconnected) und Peer-Status (Handshake/Transfer). Reiner Lesezugriff — legt nichts an.",
          "permissions": [
            "addons/vpn/peers",
            "addons/vpn/status",
            "addons/vpn/config"
          ],
          "endpoint": "/api/v1/addons/vpn/peers",
          "scripts": []
        },
        {
          "name": "vpn-manage",
          "displayName": "VPN: Peers & Verbindung verwalten",
          "description": "Legt die BYOK-Verbindung zum VPN-Server an/entfernt diese und fügt Peers hinzu oder entfernt sie (per-User gebucht: provider, base URL, Token — niemals das Passwort im Klartext zurückgegeben). Stellt die WireGuard-Peer-Konfiguration und den Konfig-QR bereit.",
          "permissions": [
            "addons/vpn/peers",
            "addons/vpn/config",
            "addons/vpn/config-string",
            "addons/vpn/qr"
          ],
          "endpoint": "/api/v1/addons/vpn/peers",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": null
    },
    {
      "id": "websearch",
      "uuid": "c39538aa-2123-46d4-b973-b1ffc6b3b2e4",
      "name": "Research",
      "version": "2.1.3",
      "description": "Unified research capability: web search (Brave, ZAI, Google, SearXNG) for chat agents, plus trend radar, briefing studio, YouTube intelligence, web fetch (URL extraction), image analysis, and PDF analysis. Consolidates websearch + trend-radar + briefing-studio + youtube-intelligence + research-tools (ADR-008, issue #374).",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "search",
        "web-search",
        "multi-provider",
        "brave-search",
        "zai-search",
        "google-search",
        "searxng",
        "trend-scanning",
        "watchlists",
        "news-monitoring",
        "briefing-generation",
        "script-framing",
        "youtube-analysis",
        "transcript-analysis",
        "content-ideation",
        "title-patterns",
        "research",
        "web-fetch",
        "image-analysis",
        "pdf-analysis"
      ],
      "tags": [],
      "download": "/artifacts/websearch-2.1.3.zip",
      "sha256": "e6ef4bab2cfbaedc98cc5a6ac5d1b6babcd55b9d9ffcade422d8f5582636ce3b",
      "size": 187399,
      "page": {
        "path": "/addons/websearch",
        "name": "Research",
        "icon": "🔍",
        "section": "Tools",
        "component": "@addons/websearch/src/components/WebSearchSettings"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/websearch/search",
        "/api/v1/addons/websearch/zread",
        "/api/v1/addons/websearch/settings",
        "/api/v1/addons/websearch/status",
        "/api/v1/addons/websearch/providers",
        "/api/v1/addons/websearch/web-fetch",
        "/api/v1/addons/websearch/image-analysis",
        "/api/v1/addons/websearch/pdf-analysis",
        "/api/v1/addons/websearch/trend-radar/status",
        "/api/v1/addons/websearch/trend-radar/news",
        "/api/v1/addons/websearch/trend-radar/scan",
        "/api/v1/addons/websearch/trend-radar/watchlists",
        "/api/v1/addons/websearch/trend-radar/watchlists/:id/run",
        "/api/v1/addons/websearch/briefing-studio/status",
        "/api/v1/addons/websearch/briefing-studio/research",
        "/api/v1/addons/websearch/briefing-studio/script-frame",
        "/api/v1/addons/websearch/youtube-intelligence/status",
        "/api/v1/addons/websearch/youtube-intelligence/channel/analyze",
        "/api/v1/addons/websearch/youtube-intelligence/video/transcript",
        "/api/v1/addons/websearch/youtube-intelligence/patterns",
        "/api/v1/addons/websearch/youtube-intelligence/ideas"
      ],
      "skills": [],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Web Search</h1>\n<p>Enable web search capabilities for chat agents with multi-provider support (Brave, ZAI, Google, SearXNG). Allows agents to search the web and read URLs for current information. Since issue #374 the addon also carries the consolidated research-tools capabilities: web fetch (URL content extraction), image analysis (ZAI Vision) and PDF analysis (ZAI PDF).</p>\n<h2>Überblick</h2>\n<ul><li><strong>Addon-ID:</strong> <code>websearch</code></li><li><strong>Version:</strong> 2.1.0</li><li><strong>Fähigkeiten:</strong> search, web-search, multi-provider, brave-search, zai-search, google-search, searxng, research, web-fetch, image-analysis, pdf-analysis (+ trend-radar / briefing-studio / youtube-intelligence Namespaces)</li><li><strong>UI-Seite:</strong> <code>/addons/websearch</code></li></ul>\n<h2>Einrichtung</h2>\n<p>Dieses Addon benötigt folgende Secrets/Provider-Konfiguration (Settings → Credentials bzw. Provider-Store, <strong>nie</strong> in addon.json):</p>\n<ul><li><code>BRAVE_API_KEY</code></li><li><code>GOOGLE_SEARCH_API_KEY</code></li><li><code>SEARXNG_API_KEY</code></li><li><code>ZAI_API_KEY</code></li></ul>\n<h2>API-Endpunkte</h2>\n<p>| Methode(n) | Pfad |<br />|---|---|<br />| GET, POST | <code>/api/v1/addons/websearch/config</code> |<br />| GET | <code>/api/v1/addons/websearch/providers</code> |<br />| POST | <code>/api/v1/addons/websearch/search</code> |<br />| GET, POST | <code>/api/v1/addons/websearch/settings</code> |<br />| GET | <code>/api/v1/addons/websearch/status</code> |<br />| POST | <code>/api/v1/addons/websearch/zread</code> |<br />| GET | <code>/api/v1/addons/websearch/web-fetch</code> |<br />| POST | <code>/api/v1/addons/websearch/image-analysis</code> |<br />| POST | <code>/api/v1/addons/websearch/pdf-analysis</code> |</p>\n<h3>Web Fetch (issue #374, ehemals research-tools)</h3>\n<pre><code>GET /api/v1/addons/websearch/web-fetch?url=&lt;url&gt;</code></pre>\n<p>Authentifiziert. Liefert <code>{ success, url, title, content, extractedAt }</code>. Der Abruf läuft<br />hinter einem SSRF-Guard (nur http/https, DNS-geprüfte öffentliche Adressen, Redirect-Hops<br />werden einzeln re-validiert, Größen-/Zeitlimit) — private/loopback/link-local/metadata-Ziele<br />werden mit <code>403</code> abgelehnt, ohne dass ein ausgehender Request stattfindet. Siehe<br /><code>src/lib/research-tools/web-fetch.ts</code> und <code>addons/research-tools/SUNSET.md</code>.</p>\n<h3>Image / PDF Analysis (issue #374, ehemals research-tools)</h3>\n<pre><code>POST /api/v1/addons/websearch/image-analysis\nBody: { &quot;imagePath&quot;: &quot;uploads/image.jpg&quot;, &quot;prompt&quot;: &quot;Describe this image&quot; }</code></pre>\n<p>POST /api/v1/addons/websearch/pdf-analysis<br />Body: { &quot;pdfPath&quot;: &quot;docs/file.pdf&quot;, &quot;prompt&quot;: &quot;Summarize this document&quot; }<br />```</p>\n<blockquote><p><strong>Per-User-Isolation (#147 / ADR-054, 1:1 aus research-tools übernommen):</strong> beide</p><p>Endpoints sind authentifiziert und lesen ausschließlich Dateien unter dem per-User-Datenroot</p><p>(<code>&lt;DATA_DIR&gt;/addons/websearch/&lt;userId&gt;</code>). <code>imagePath</code> / <code>pdfPath</code> sind <strong>relativ</strong> zu diesem</p><p>Root — absolute Pfade und <code>../</code>-Traversal werden mit <code>403</code> abgelehnt.</p></blockquote>\n<h2>Bekannte Grenzen</h2>\n<ul><li>Keine bekannten Einschränkungen.</li></ul>",
          "byteSize": 2679
        }
      }
    },
    {
      "id": "white-label",
      "uuid": "d480f9bd-1afa-463a-aa87-5cac2726bef0",
      "name": "White-Label & Business-Packs",
      "version": "1.0.4",
      "description": "Rebrand the instance (app name, logo, accent color, login tagline, powered-by toggle) with SAFE DEFAULTS so an unconfigured instance is visually unchanged, and apply business-packs — named bundles of addons + agent templates that set up a business vertical in a few clicks by reusing the existing enable/instantiate primitives (Phase K / Phase A). FULL white-label (OD #34/#35/#36): upload a brand LOGO (magic-byte validated, size-capped, registry-free storage under DATA_DIR, served via the addon and wired into the core logo URL); the custom-branding capability is TIER-GATED via the #119 licensing scaffold (graceful fallback to default MC branding + upgrade hint on a non-entitled tier); and a per-customer CUSTOM-DOMAIN config + status is stored (actual TLS cert issuance + DNS remain owner/infra at the reverse-proxy). Branding lives in core app_settings (a sibling of provisioning, ADR-020); the shell + login read it via the public GET /api/v1/branding and the accent threads through var(--color-accent*). Admin-gated; writes entitlement-gated; pack apply safe + idempotent. No container, no external calls (ADR-009, ADR-027).",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "white-label-branding",
        "white-label-business-packs"
      ],
      "tags": [],
      "download": "/artifacts/white-label-1.0.4.zip",
      "sha256": "93951639667666a095ccb1ce92032545cfef0df48711d0e8cd61fe79c2ca2fe7",
      "size": 221513,
      "page": {
        "id": "white-label-page",
        "path": "/addons/white-label",
        "name": "White-Label",
        "icon": "🏷️",
        "section": "Admin",
        "component": "@addons/white-label/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/white-label/status",
        "/api/v1/addons/white-label/branding",
        "/api/v1/addons/white-label/packs",
        "/api/v1/addons/white-label/packs/:id/apply",
        "/api/v1/addons/white-label/widget",
        "/api/v1/addons/white-label/logo",
        "/api/v1/addons/white-label/config"
      ],
      "skills": [
        {
          "name": "white-label-branding",
          "displayName": "White-Label: Branding lesen/setzen",
          "description": "Read the effective instance branding (app name, logo URL, accent color, login tagline, powered-by) and update it (admin). Allow-listed keys only; color/url shape-validated; safe MC defaults when unset. No secrets — branding is public-by-nature.",
          "permissions": [
            "addons/white-label/branding",
            "addons/white-label/status"
          ],
          "endpoint": "/api/v1/addons/white-label/branding",
          "scripts": []
        },
        {
          "name": "white-label-packs-apply",
          "displayName": "White-Label: Business-Pack anwenden",
          "description": "List the built-in business-packs and apply one (admin): it enables the pack's addons (Phase K) and instantiates its agent templates for the caller (Phase A, own key, no secrets). Safe + idempotent — re-apply never duplicates. Returns a summary of what was applied/skipped.",
          "permissions": [
            "addons/white-label/packs",
            "addons/white-label/status"
          ],
          "endpoint": "/api/v1/addons/white-label/packs",
          "scripts": []
        },
        {
          "name": "white-label-full",
          "displayName": "White-Label: Logo-Upload, Entitlement, Custom-Domain",
          "description": "Upload/replace/remove the brand logo (admin; magic-byte validated image, size-capped, served via the addon and wired into the core logo URL), read the tier-gating entitlement (is full white-label unlocked on the active tier, + upgrade hint — builds on the #119 tier scaffold), and configure the per-customer custom domain + status (TLS/DNS handled at the reverse-proxy — owner/infra). Admin-gated; writes are entitlement-gated and degrade gracefully on a non-entitled tier. No secret — branding + hostname are public-by-nature.",
          "permissions": [
            "addons/white-label/logo",
            "addons/white-label/config",
            "addons/white-label/status"
          ],
          "endpoint": "/api/v1/addons/white-label/config",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>White-Label &amp; Business-Packs</h1>\n<p>Two admin capabilities for resellers/operators (ADR-027):</p>\n<p>- <strong>White-label branding</strong> — rebrand this Aiopta instance (app name, logo URL, accent color,<br />  login tagline, &quot;powered by Aiopta&quot; toggle), applied across the shell + login. **SAFE<br />  DEFAULTS**: an unconfigured instance is visually identical to stock Aiopta.<br />- <strong>Business-packs</strong> — named bundles <code>{ addons[], agentTemplates[], settings }</code> that set up a business<br />  vertical in a few clicks by <strong>reusing existing primitives</strong> — enable + instantiate. Apply is **safe +<br />  idempotent** (re-apply never duplicates).<br />- <strong>Full white-label (#121 / INBOX-52)</strong> — logo <strong>upload</strong> + asset-hosting, a <strong>tier-gated</strong> branding<br />  entitlement (built on the #119 licensing scaffold), and per-customer <strong>custom-domain</strong> config. See<br />  &quot;Full white-label&quot; below.</p>\n<p>It <strong>extends + orchestrates</strong> — it never forks a store or reimplements install/provisioning.</p>\n<h2>Branding</h2>\n<p>- <code>Branding = { appName, logoUrl, accentColor, loginTagline, poweredBy }</code> lives in core <code>app_settings</code><br />  via <code>src/lib/branding.ts</code> — a <strong>sibling of provisioning.ts</strong> (ADR-020) that SHARES <code>INSTANCE_NAME</code> +<br />  <code>INSTANCE_BRAND_COLOR</code> and adds <code>BRANDING_LOGO_URL</code>/<code>_LOGIN_TAGLINE</code>/<code>_POWERED_BY</code>. <strong>No new table.</strong><br />- <code>getBranding()</code> returns the exact current MC values when unset (<code>AIOPTA_DEFAULT_BRANDING</code>, accent<br />  <code>#7c3aed</code>). <code>setBranding()</code> is an <strong>allow-list</strong> + <strong>shape-validates</strong> color (hex) and logo (empty /<br />  <code>/…</code> / <code>http(s)</code>; <code>javascript:</code>/<code>data:</code>/protocol-relative rejected).<br />- The shell + login read the <strong>public</strong> <code>GET /api/v1/branding</code>; a core <code>BrandingProvider</code><br />  (<code>src/lib/branding-client.tsx</code>, in <code>app/layout.tsx</code>) renders defaults immediately then threads a custom<br />  accent through <code>var(--color-accent*)</code> via a CSS variable on <code>&lt;html&gt;</code> — <strong>no per-component edits</strong>.</p>\n<h2>Business-packs</h2>\n<p>- <code>BusinessPack = { id, name, description, vertical, addons[], agentTemplates[], settings{} }</code>.<br />- 3 built-ins (repo-bundled addons + seeded templates): <code>ecommerce</code>, <code>agency-leadgen</code>, <code>content-saas</code>.<br />- <strong>Apply</strong> reuses primitives and is <strong>idempotent</strong>: addons → enabled via the shared <code>extensions</code> table<br />  (Addon-Manager, ADR-022; already-enabled → skipped); templates → instantiated for the caller via the<br />  Phase-A route (own key, no secrets; owned template / 409 → skipped); settings → allow-listed keys only.<br />- Apply-state is <strong>derived</strong> from <code>extensions</code> + <code>user_agents</code> (the addon owns no table).</p>\n<h2>Full white-label (OD #34/#35/#36, RESOLVED → built)</h2>\n<p>- <strong>Logo upload + asset-hosting (OD #35)</strong> — <code>src/lib/logo-storage.ts</code> stores an uploaded image<br />  registry-free under <code>DATA_DIR/white-label/</code> (a JSON sidecar — <strong>no table</strong>). The format is determined<br />  by <strong>magic-byte sniffing</strong> (PNG/JPEG/GIF/WEBP/SVG); the <strong>client MIME is never trusted</strong>, and a 2 MiB<br />  cap applies. <code>PUT …/logo</code> accepts a multipart <code>file</code> or JSON <code>{ dataUrl | base64 }</code>; the <strong>public</strong><br />  <code>GET …/logo</code> serves the bytes (with <code>X-Content-Type-Options: nosniff</code> + a strict CSP so an uploaded SVG<br />  stays inert), and the root-relative serve path is written into the core <code>Branding.logoUrl</code> (which the<br />  existing <code>isValidLogoUrl</code> already accepts — <strong>no core change</strong>). <code>DELETE …/logo</code> resets to the default.<br />- <strong>Branding entitlement, tier-gated (OD #36, builds on #119/ADR-050)</strong> — <code>src/lib/branding-entitlement.ts</code><br />  is a <strong>read-only</strong> consumer of <code>src/lib/entitlements.ts</code> (<code>hasEntitlement</code>) + <code>src/lib/tier-config.ts</code><br />  (<code>TIER_REGISTRY</code>); <strong>neither is modified</strong>. The shipped <code>Feature</code> union has no dedicated <code>whiteLabel</code><br />  flag yet, so it maps to the closest existing one, <code>addons.private-registry</code> (enterprise-only) — one line<br />  in <code>BRANDING_FEATURE</code> to change later. A non-entitled tier gets a <strong>graceful 402</strong> on the logo/domain<br />  writes (branding falls back to the Aiopta default, with an &quot;upgrade&quot; hint) — never a crash.<br />- <strong>Custom-domain config (OD #34)</strong> — <code>src/lib/custom-domain.ts</code> stores a hostname + status<br />  (<code>unconfigured</code>/<code>pending</code>/<code>active</code>) + a note as a JSON blob in <code>app_settings</code> (registry-free, `getDb()<br />  .prepare().run()`). <strong>Actual TLS certs + DNS are owner/infra at the reverse-proxy — no cert code ships.</strong><br />  An unconfigured instance uses the default domain (the public URL stays owned by the existing ingress).</p>\n<h2>Safety</h2>\n<p>- <strong>Admin-gated</strong>: branding by <code>user.manage</code>, pack apply by <code>addon.manage</code> (<code>requireCapability</code>).<br />- <strong>No secrets</strong>: branding is public-by-nature; pack apply mints a new own key via the Phase-A route and<br />  copies no secret.<br />- <strong>No external calls</strong>: pack apply dispatches over the <strong>same-origin</strong> Aiopta API (<code>assertSameOrigin</code>); no<br />  container, no outbound request (ADR-009).<br />- <strong>Safe defaults</strong>: zero visual change when unconfigured.</p>\n<h2>Routes</h2>\n<p>Dual-registered in <code>addon.json</code> + <code>src/manifest.ts</code> (plus the core public read):</p>\n<ul><li><code>GET /api/v1/branding</code> (core, <strong>public</strong> — shell/login read this)</li><li><code>GET /api/v1/addons/white-label/branding</code> · <code>PUT /api/v1/addons/white-label/branding</code> (<code>user.manage</code>)</li><li><code>GET /api/v1/addons/white-label/packs</code> (<code>addon.manage</code>)</li><li><code>POST /api/v1/addons/white-label/packs/[id]/apply</code> (<code>addon.manage</code>)</li><li><code>GET /api/v1/addons/white-label/status</code> · <code>GET /api/v1/addons/white-label/widget</code> (<code>user.manage</code>)</li><li><code>GET /api/v1/addons/white-label/logo</code> (<strong>public</strong> serve) · <code>PUT</code> · <code>DELETE</code> (<code>user.manage</code>, entitlement-gated write)</li><li><code>GET /api/v1/addons/white-label/config</code> (<code>user.manage</code>) · <code>PUT</code> (<code>user.manage</code>, entitlement-gated)</li></ul>\n<p>See <code>SKILL.md</code> for the agent-facing cheat-sheet and ADR-027 (white-label business<br />packs, in the core repo under <code>docs/architecture/</code>) for the full decision. **Logo upload, tier-gated branding entitlement, and custom-domain config are now<br />built (OD #34/#35/#36).** Still owner/infra: actual TLS cert issuance + DNS (handled at the reverse-proxy,<br />OD #34), and a dedicated <code>whiteLabel</code> tier-feature flag (additive in #119 — today mapped to<br /><code>addons.private-registry</code>).</p>",
          "byteSize": 5938
        }
      }
    },
    {
      "id": "wordpress",
      "uuid": "9a02657b-a931-43fa-977f-2c91562caca7",
      "name": "WordPress & WooCommerce",
      "version": "1.3.1",
      "description": "Create and manage WordPress sites and WooCommerce shops — pages, posts, themes, menus, products, orders, coupons and customers — agent- and web-usable. Dual-mode (INBOX-63): connect an EXISTING WordPress+WooCommerce site, or run the Aiopta-managed container (ADR-009/ADR-010).",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "wordpress-sites",
        "wordpress-pages",
        "wordpress-posts",
        "wordpress-themes",
        "wordpress-menus",
        "woocommerce-products",
        "woocommerce-orders",
        "woocommerce-coupons",
        "woocommerce-customers"
      ],
      "tags": [],
      "download": "/artifacts/wordpress-1.3.1.zip",
      "sha256": "80f8f62f31232205c80831c749aa98d200ad9a68940d14d3464116ea86802c45",
      "size": 172802,
      "page": {
        "id": "wordpress-page",
        "path": "/addons/wordpress",
        "name": "WordPress & WooCommerce",
        "icon": "🛍️",
        "section": "Tools",
        "component": "@addons/wordpress/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/wordpress/status",
        "/api/v1/addons/wordpress/config",
        "/api/v1/addons/wordpress/test",
        "/api/v1/addons/wordpress/sites",
        "/api/v1/addons/wordpress/pages",
        "/api/v1/addons/wordpress/posts",
        "/api/v1/addons/wordpress/themes",
        "/api/v1/addons/wordpress/menus",
        "/api/v1/addons/wordpress/products",
        "/api/v1/addons/wordpress/products/:id",
        "/api/v1/addons/wordpress/orders",
        "/api/v1/addons/wordpress/orders/:id",
        "/api/v1/addons/wordpress/coupons",
        "/api/v1/addons/wordpress/customers"
      ],
      "skills": [],
      "mcpServers": [
        {
          "id": "wordpress-tools",
          "displayName": "WordPress & WooCommerce tools",
          "description": "Read-only WordPress + WooCommerce companion tools for agents: normalize a site URL, resolve a WP core (/wp-json/wp/v2) or WooCommerce (/wp-json/wc/v3) REST URL, normalize WooCommerce product/order/customer payloads into the addon's stable shapes, and summarize a connection status - faithful ports of the addon's src/lib/wp-client.ts modules (issue #451, epic #399). Mutates nothing; all 12 mutating route handlers (create/update/delete product, update/delete order, create coupon, create page/post/menu, activate theme) stay in the addon's authenticated write API with its approval/audit pipeline. Live per-user WordPress + WooCommerce access is resolved core-side via the declared config/credential slots.",
          "transport": "stdio",
          "runtime": "node",
          "entry": "wordpress-tools/server.mjs",
          "permissions": [
            "addons/wordpress/read"
          ],
          "toolPolicy": {
            "mode": "read-only",
            "allowDestructive": false
          },
          "configRefs": [
            "WORDPRESS_MODE",
            "WORDPRESS_SITE_URL"
          ],
          "credentialRefs": [
            "AIOPTA_WP_APP_PASSWORD",
            "AIOPTA_WC_CONSUMER_SECRET"
          ],
          "archiveEntry": "wordpress/mcp/servers/wordpress-tools/server.mjs",
          "sha256": "698842925328ba80afead09b60a74133159d2b03dd0c97f64484925fa120c61f",
          "size": 25291,
          "descriptorVersion": 1
        }
      ],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>WordPress &amp; WooCommerce Addon</h1>\n<p>Create and manage <strong>WordPress sites</strong> and <strong>WooCommerce shops</strong> from inside Aiopta —<br />pages, posts, themes, menus (WordPress) and products, orders, coupons, customers (WooCommerce) —<br />usable both by agents (via the addon API) and by humans (via the management page).</p>\n<h2>Dual mode (INBOX-63): connect an existing site OR run a container</h2>\n<p>The addon works in <strong>two modes</strong>, chosen on the management page and persisted in Aiopta addon-config<br />(<code>WORDPRESS_MODE</code>, default <code>container</code>):</p>\n<p>- <strong>Aiopta-Container hochfahren (<code>container</code>, default)</strong> — the Aiopta-managed WordPress + WooCommerce<br />  container (ADR-010). Full container self-provisioning is still pending (<strong>TODO #133</strong>); until then<br />  the status reports &quot;Container nicht provisioniert&quot;.<br />- <strong>Bestehende Seite verbinden (<code>external</code>)</strong> — the owner connects an <strong>existing</strong> WordPress +<br />  WooCommerce site. They enter the <strong>site URL</strong>, a WordPress <strong>application password</strong> (user +<br />  app-password) and the <strong>WooCommerce consumer key + secret</strong>, hit <strong>&quot;Verbindung testen&quot;</strong>, and<br />  then every WP/Woo call runs against that real site. This mode is fully functional today.</p>\n<p><code>src/lib/wp-client.ts</code> resolves the base URL via <code>resolveBaseUrl()</code>: in <code>external</code> mode it is the<br />owner&#39;s <code>WORDPRESS_SITE_URL</code>; in <code>container</code> mode it is <code>getContainerAddonInternalUrl(&quot;wordpress&quot;)</code>.<br />Either way there is <strong>no hard-coded <code>localhost</code>/host</strong> anywhere — when no base URL is resolvable the<br />client throws a clear, mode-aware &quot;not configured / not provisioned&quot; error.</p>\n<h2>Architecture (ADR-009 / ADR-010)</h2>\n<p>- <strong>The container path is a container, not a host.</strong> WordPress with the WooCommerce plugin is an<br />  external tool, so it is declared as a <code>container</code> in <code>addon.json</code> / <code>manifest.ts</code><br />  (<code>registry.langkau.info/aiopta/wordpress-woo:latest</code>, <code>internalPort 80</code>,<br />  <code>healthPath /wp-json/</code>). It is booted at install by the container-service, started/stopped by the<br />  enable/disable lifecycle (G6), and removed on uninstall. The addon writes no lifecycle code.<br />- <strong>Reached only via the resolved base URL.</strong> <code>src/lib/wp-client.ts</code> talks to the<br />  <strong>WordPress REST API</strong> (<code>/wp-json/wp/v2/...</code>) and <strong>WooCommerce REST API</strong> (<code>/wp-json/wc/v3/...</code>)<br />  using the mode-aware base URL above.<br />- <strong>Secrets via Aiopta-config, never the manifest.</strong> The manifest declares only the <em>names</em> of the<br />  config keys (<code>envSchema</code>). Values — the WordPress <strong>application password</strong> and the WooCommerce<br />  <strong>consumer key + secret</strong> — are stored in Aiopta addon-config (<code>setAddonConfiguration</code>, secret-looking<br />  keys flagged <code>is_secret = 1</code>). The masked <code>GET /config</code> response only reports <em>whether</em> each<br />  secret is set, never its value. No secret value is ever written into the manifest or logged.</p>\n<h2>Authentication</h2>\n<p>| Surface | Mechanism | Config keys (Aiopta addon-config / <code>envSchema</code>) |<br />|---------|-----------|---------------------------------------------|<br />| Mode + site URL | non-secret connection config | <code>WORDPRESS_MODE</code> (<code>container</code>\\|<code>external</code>), <code>WORDPRESS_SITE_URL</code> |<br />| WordPress core (<code>/wp-json/wp/v2</code>) | Application password (Basic auth) | <code>AIOPTA_WP_USER</code>, <code>AIOPTA_WP_APP_PASSWORD</code> |<br />| WooCommerce (<code>/wp-json/wc/v3</code>) | Consumer key + secret (Basic auth) | <code>AIOPTA_WC_CONSUMER_KEY</code>, <code>AIOPTA_WC_CONSUMER_SECRET</code> |<br />| Container / DB | non-secret connection config | <code>WORDPRESS_DB_HOST</code>, <code>WORDPRESS_DB_NAME</code>, <code>WORDPRESS_DB_USER</code>, <code>WORDPRESS_TABLE_PREFIX</code> |</p>\n<h2>API</h2>\n<p>All routes are namespaced under <code>/api/v1/addons/wordpress/</code> and require auth.</p>\n<h3>WordPress</h3>\n<pre><code>GET  /status                      → mode + reachability + connection + config status\nGET  /config       PUT /config    → read (masked) / save mode + connect-existing creds\nPOST /test                        → &quot;Verbindung testen&quot;: probe WP + Woo on the configured site\nGET  /sites        POST /sites    → list / create managed site(s)\nGET  /pages        POST /pages    → list / create pages       { &quot;title&quot;, &quot;content?&quot;, &quot;status?&quot; }\nGET  /posts        POST /posts    → list / create posts        { &quot;title&quot;, &quot;content?&quot;, &quot;status?&quot; }\nGET  /themes       POST /themes   → list / activate theme      { &quot;stylesheet&quot; }\nGET  /menus        POST /menus    → list / create menu         { &quot;name&quot; }</code></pre>\n<h3>WooCommerce</h3>\n<pre><code>GET  /products       POST /products          → list / create product  { &quot;name&quot;, &quot;regular_price?&quot;, ... }\nGET  /products/[id]  PUT /products/[id]  DELETE /products/[id]\nGET  /orders                                 → list orders   (?status=, ?per_page=)\nGET  /orders/[id]    PUT /orders/[id]         → fetch / update an order (e.g. status)\nGET  /coupons        POST /coupons           → list / create coupon   { &quot;code&quot;, &quot;amount?&quot;, &quot;discount_type?&quot; }\nGET  /customers                              → list customers</code></pre>\n<p>Responses use the <code>{ &quot;success&quot;: true, &quot;data&quot;: … }</code> envelope. On failure read <code>error</code> and the HTTP<br />status (a <code>503</code> means the container is not provisioned; a <code>400</code> means credentials are not set).</p>\n<h2>Layout</h2>\n<pre><code>addons/wordpress/\n├── addon.json                 # Manifest (page + widget + container + endpoints)\n├── README.md\n├── SKILL.md                   # Agent skill\n├── src/\n│   ├── manifest.ts            # Extension (dual-registration: routes + page + container)\n│   ├── page.tsx               # Management UI (Sites/Pages/Posts + Products/Orders/Coupons)\n│   ├── widget.tsx             # Dashboard status widget (declarative key-values)\n│   ├── lib/wp-client.ts       # WP + Woo REST client — mode-aware base URL (external site OR container) + Aiopta-config\n│   └── app/api/v1/addons/wordpress/{status,config,test,sites,pages,posts,themes,menus,products,products/[id],orders,orders/[id],coupons,customers}/route.ts\n└── tests/unit/                # manifest validity + wp-client logic (dual-mode + testConnection) + config/test routes (mocked)</code></pre>\n<h2>Owner-parked items (non-blocking)</h2>\n<p><strong>External mode works today</strong> — the owner connects an existing site (URL + app-password + Woo<br />keys), tests it, and uses the full CRUD surface immediately.</p>\n<p>The <strong>container</strong> path needs its image + self-provisioning before it is usable:</p>\n<blockquote><p><strong>TODO(#133): container self-provisioning</strong> — the <code>container</code> mode declares the manifest</p><p><code>container</code> block but cannot yet actually provision/boot the WP+Woo stack from inside the addon.</p><p>Tracked as #133 (<code>addons/wordpress</code> is a concrete instance of #123/#133). Until then <code>container</code></p><p>mode reports &quot;Container nicht provisioniert&quot;; use <code>external</code> mode to connect an existing site.</p><p><strong>TODO(owner): <code>wordpress-woo-image</code></strong> — build + publish the</p><p><code>registry.langkau.info/aiopta/wordpress-woo</code> image (WordPress + WooCommerce behind its</p><p>HTTP server, exposing <code>/wp-json/</code>).</p><p><strong>TODO(owner): credentials</strong> — for either mode, the WordPress <strong>application password</strong></p><p>(<code>AIOPTA_WP_USER</code> / <code>AIOPTA_WP_APP_PASSWORD</code>) and the WooCommerce <strong>consumer key + secret</strong></p><p>(<code>AIOPTA_WC_CONSUMER_KEY</code> / <code>AIOPTA_WC_CONSUMER_SECRET</code>) are stored in Aiopta addon-config (via the addon page</p><p>or <code>PUT /config</code>). No secret is committed. The VALUES are owner-secret; the addon builds the</p><p>mechanism/UI.</p></blockquote>\n<h2>License</h2>\n<p>MIT</p>",
          "byteSize": 7176
        }
      }
    },
    {
      "id": "workflow-builder",
      "uuid": "83b7fda8-ea5c-4a92-a98f-60887f0936ed",
      "name": "Workflow / Automation Builder",
      "version": "1.1.11",
      "description": "Define automations (a trigger + ordered steps) that Aiopta runs server-side — an n8n-lite scoped to this platform. Triggers: manual, schedule (interval), webhook (tokened inbound URL), event. Step types: http (same-origin Aiopta API call), agent (run an Aiopta agent via the gateway), condition, delay. Steps thread prior-step output via {{stepN.field}}; the engine enforces a same-origin allow-list (never external services, never eval) and persists run history. No container (ADR-024). Canonical home of the former integrations addon's trigger surface (issue #375): the per-user webhook-trigger registry (/webhooks) and the admin-gated schedule-trigger view (/cron) live here.",
      "author": "Aiopta",
      "icon": "🧩",
      "capabilities": [
        "workflow-definitions",
        "workflow-execution",
        "workflow-triggers",
        "workflow-run-history",
        "workflow-webhooks",
        "webhooks",
        "cron",
        "integrations"
      ],
      "tags": [
        "onboarding"
      ],
      "download": "/artifacts/workflow-builder-1.1.11.zip",
      "sha256": "dd16d83a70942e5b5fdd2d0e2cc51bc47cc1d2ead0359ab82a0fb26a783c0518",
      "size": 292447,
      "page": {
        "id": "workflow-builder-page",
        "path": "/addons/workflow-builder",
        "name": "Workflow Builder",
        "icon": "🔀",
        "section": "Automation",
        "component": "@addons/workflow-builder/src/page"
      },
      "deprecated": false,
      "endpoints": [
        "/api/v1/addons/workflow-builder/status",
        "/api/v1/addons/workflow-builder/webhooks",
        "/api/v1/addons/workflow-builder/cron",
        "/api/v1/addons/workflow-builder/workflows",
        "/api/v1/addons/workflow-builder/workflows/:id",
        "/api/v1/addons/workflow-builder/workflows/:id/run",
        "/api/v1/addons/workflow-builder/workflows/:id/runs",
        "/api/v1/addons/workflow-builder/workflows/:id/webhook",
        "/api/v1/addons/workflow-builder/widget",
        "/api/v1/addons/workflow-builder/widget-runs",
        "/api/v1/addons/workflow-builder/widget-link"
      ],
      "skills": [
        {
          "name": "workflow-manage",
          "displayName": "Workflow: Define & manage automations",
          "description": "List/create/edit/delete workflows (trigger + ordered steps). Validates the trigger and every step shape; a webhook trigger gets a server-minted token. Pure CRUD — does not execute anything.",
          "permissions": [
            "addons/workflow-builder/workflows",
            "addons/workflow-builder/status"
          ],
          "endpoint": "/api/v1/addons/workflow-builder/workflows",
          "scripts": []
        },
        {
          "name": "workflow-run",
          "displayName": "Workflow: Run & inspect history",
          "description": "Execute a workflow now (manual trigger) and read its run history. The engine orchestrates ONLY the Aiopta API (same-origin allow-list) and Aiopta agents via the gateway — never external services, never eval. The caller's auth is forwarded to internal steps so RBAC still applies.",
          "permissions": [
            "addons/workflow-builder/workflows",
            "addons/workflow-builder/status"
          ],
          "endpoint": "/api/v1/addons/workflow-builder/workflows/:id/run",
          "scripts": []
        }
      ],
      "mcpServers": [],
      "pricing": null,
      "pricingTiers": {
        "schemaVersion": 1,
        "currency": null,
        "tiers": [
          {
            "id": "free",
            "name": "Free",
            "priceMinor": 0,
            "interval": null,
            "features": [],
            "contactOnly": false
          }
        ]
      },
      "meta": {
        "readme": {
          "filename": "README.md",
          "sourcePath": "README.md",
          "rendered": "<h1>Workflow / Automation Builder</h1>\n<p>An in-Aiopta automation builder — a Zapier/n8n-lite <strong>scoped to this platform</strong>. A workflow is<br />a <strong>trigger</strong> plus an <strong>ordered list of steps</strong> that Aiopta runs server-side, threading each<br />step&#39;s output into the next via <code>{{stepN.field}}</code> interpolation.</p>\n<p>It orchestrates <em>existing</em> Aiopta capabilities and <strong>never calls external services directly</strong> (ADR-009):<br /><code>http</code> steps hit only the Aiopta API (same-origin allow-list), <code>agent</code> steps go through the Aiopta gateway.</p>\n<h2>Model</h2>\n<p>- <strong>Workflow</strong> = <code>{ id, name, enabled, trigger, steps[] }</code>.<br />- <strong>Trigger</strong> ∈ <code>manual</code> · <code>schedule</code> (interval seconds) · <code>webhook</code> (per-workflow token) · <code>event</code> (Aiopta<br />  event name).<br />- <strong>Step</strong> = <code>{ id, type, name?, config, onError }</code>, type ∈:<br />  - <code>http</code> — an Aiopta-internal API call (<code>method</code> + same-origin <code>/api/...</code> <code>path</code> + optional <code>body</code>).<br />  - <code>agent</code> — run an Aiopta agent via the gateway with a <code>prompt</code> (built from prior step output).<br />  - <code>condition</code> — <code>left &lt;op&gt; right</code>; on <strong>false</strong> the run stops cleanly (skip-rest). No <code>eval</code>.<br />  - <code>delay</code> — wait <code>ms</code> (capped at 60s).</p>\n<h2>Trigger surface absorbed from <code>integrations</code> (issue #375)</h2>\n<p>Workflow-builder is the canonical home of the former integrations addon&#39;s two<br />endpoints (parent #312, cluster <code>automation-orchestration</code>,<br /><code>docs/ADDON-CLUSTERS.md</code>):</p>\n<p>- <strong><code>/webhooks</code></strong> (GET/POST/DELETE) — the per-user <strong>webhook-trigger registry</strong><br />  (register/list/delete webhook entries; <code>user_id</code>-scoped, secrets masked).<br />  Auth: <code>userRoute</code> — 401 without a session; every read/write is isolated to<br />  the caller (#139).<br />- <strong><code>/cron</code></strong> (GET) — the read-only <strong>schedule-trigger view</strong> over the<br />  instance scheduler config (<code>&lt;DATA_DIR&gt;/cron/jobs.json</code>). Auth: <code>adminRoute</code><br />  — 401 unauthenticated / 403 non-admin (#586, ADR-054); only schedule<br />  metadata is exposed, never a job payload/secret.</p>\n<p>The legacy <code>/api/v1/addons/integrations/*</code> routes stay alive as alias shims<br />during the sunset window (until <strong>2026-12-31</strong>, RFC 8594 headers) and answer<br /><strong>410 Gone</strong> afterwards — see <code>addons/integrations/README.md</code> for the mapping<br />and the rollback recipe. The data migration is zero-copy: the <code>webhooks</code> table<br />and <code>jobs.json</code> are unchanged; only the serving endpoints moved.</p>\n<h2>Safety</h2>\n<p>- <strong>Same-origin allow-list</strong>: an <code>http</code> step&#39;s path must begin with <code>/api/</code> and resolve to the Aiopta<br />  origin; absolute/protocol-relative/traversal paths are refused both at validation time and at request<br />  time in the engine. A workflow can never make an arbitrary outbound request.<br />- <strong>No eval / no code execution</strong>: conditions are a fixed operator switch; interpolation is plain string<br />  substitution.<br />- <strong>Auth forwarding</strong>: manual runs forward the caller&#39;s session/agent auth to internal steps, so Aiopta<br />  RBAC still applies — a workflow can do no more than its initiator.<br />- <strong>Webhook</strong>: token-gated (constant-time compare), no Aiopta session; the token is never logged or echoed.</p>\n<h2>Persistence</h2>\n<p>Two addon-owned tables (created in <code>install()</code>, dropped in <code>uninstall()</code> via Aiopta core&#39;s <code>getDb()</code>):<br /><code>workflow_builder_workflows</code> and <code>workflow_builder_runs</code> (run history). The addon never touches the core<br />schema. The webhook-trigger registry (<code>/webhooks</code>, #375) reads/writes the <strong>core-owned</strong> <code>webhooks</code><br />table through the user-scope layer (<code>scopedStore</code>), exactly as its integrations predecessor did.</p>\n<h2>UI</h2>\n<p>A workflow <strong>list</strong> + a structured <strong>step editor</strong>: pick a trigger, add/reorder/remove steps with<br />type-specific config, <strong>Run now</strong>, and a run-history timeline. A full visual drag-drop canvas is a<br />future enhancement (see <code>docs/OPEN-DECISIONS.md</code>).</p>\n<p>See <code>SKILL.md</code> for the agent-facing endpoint cheat-sheet and <code>docs/architecture/ADR-024-workflow-builder.md</code><br />for the design.</p>",
          "byteSize": 3755
        }
      }
    }
  ]
}
