From 4824c5297f605a2bcba81aa69142467830835637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sch=C3=A4tzl?= Date: Tue, 14 Jul 2026 13:46:28 +0200 Subject: [PATCH 01/15] add plan --- PLAN.md | 960 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 960 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000000..dc11ed6070c --- /dev/null +++ b/PLAN.md @@ -0,0 +1,960 @@ +# LibreChat Artifact Apps – Implementierungsplan + +## 1. Ziel + +Dieser Plan führt die Anforderungen aus folgenden LibreChat-Themen zusammen: + +- Discussion #12976: **LibreChat AppServer / Instant Apps** +- Issue #13374: **Standalone Artifact Publishing and Sharing** + +Ein in einer Unterhaltung erzeugtes Artifact soll als eigenständige **Artifact App** veröffentlicht werden können. Die App soll unabhängig von Conversation und Message bestehen, eine stabile URL besitzen, versioniert, geteilt, eingebettet und in einem tenantweiten Marketplace angeboten werden können. Optional darf die App explizit freigegebene MCP-Tools verwenden. Authentifizierung, Autorisierung und Tool-Aufrufe bleiben vollständig unter Kontrolle von LibreChat. + +## 2. Definition of Done + +Die Implementierung ist abgeschlossen, wenn: + +- ein vorhandenes Artifact als eigenständige Artifact App veröffentlicht werden kann; +- die veröffentlichte App nach Löschen oder Ändern der Quell-Conversation weiterhin unverändert funktioniert; +- jede App eine stabile Standalone-URL besitzt; +- private, eingeschränkte, tenantweite und optional öffentliche Lesesichtbarkeit unterstützt werden; +- Owner-, Editor- und Viewer-Rechte über das bestehende LibreChat-ACL-System funktionieren; +- mehrere Owner möglich sind; +- unveränderliche Versionen erstellt, veröffentlicht, aktiviert und zurückgerollt werden können; +- eine read-only Artifact-only-Ansicht ohne Chat existiert; +- eine sandboxed Embed-Ansicht existiert; +- Apps geforkt werden können; +- Apps in einer Marketplace-Kategorie sichtbar sind; +- erlaubte MCP-Tools über LibreChat und im Kontext des angemeldeten Benutzers aufgerufen werden können; +- Artifact-Code niemals OAuth-Tokens, Session-Tokens, API-Keys oder MCP-Credentials erhält; +- Schreibaktionen eine explizite Bestätigung verlangen; +- alle relevanten Aktionen auditiert werden; +- Unit-, Integrations-, E2E-, ACL- und Security-Tests vorhanden und erfolgreich sind; +- Typecheck, Linter und Build erfolgreich laufen. + +## 3. Scope + +### 3.1 In Scope + +- eigenständige `ArtifactApp`-Ressource; +- unveränderliche `ArtifactVersion`-Snapshots; +- Publish aus einem vorhandenen Artifact; +- stabile Standalone-URL; +- Artifact-only Viewer; +- read-only Embed; +- App-Lifecycle und Status; +- ACL mit Owner, Editor und Viewer; +- Freigabe an Benutzer, Gruppen und Rollen; +- tenantweite Sichtbarkeit; +- optionale öffentliche statische Ansicht; +- Versionierung und Rollback; +- Fork; +- Marketplace-Kategorie „Apps“; +- MCP-Tool-Allowlist pro App; +- MCP-Aufrufe mit serverseitigem Benutzerkontext; +- HITL für schreibende/destruktive Tools; +- Audit und Nutzungsereignisse; +- statischer Export als späteres Arbeitspaket innerhalb des Projekts. + +### 3.2 Nicht in Scope + +- Scheduler; +- Webhooks und externe Event-Trigger; +- automatische GitHub-/GitLab-/Azure-DevOps-Synchronisierung; +- automatische Code-Reparatur durch Coding-Agenten; +- anonyme öffentliche MCP-Ausführung; +- beliebige Netzwerkzugriffe aus Artifact-Code; +- beliebige npm-Paketinstallation; +- eigene persistente App-Datenbanken; +- eigener visueller Flow-Builder; +- Umbau der bestehenden Agent-Runtime; +- neue OAuth- oder MCP-Implementierung neben LibreChats bestehendem MCPManager. + +## 4. Architekturregeln + +LibreChats bestehende Workspace-Grenzen sind einzuhalten: + +- `packages/data-schemas`: MongoDB-Schemas, Models, DB-Methoden, Migrationen und ACL-nahe Logik; +- `packages/data-provider`: gemeinsame Typen, Zod-Schemas, Query Keys und API-Verträge; +- `packages/api`: neue Backend-Services, Publishing, Versionierung, MCP-Bridge und Audit; +- `api`: ausschließlich dünne Express-Routen/Controller zur Integration; +- `client`: React-UI, Viewer, Publish-Dialog, Marketplace und Berechtigungsverwaltung; +- vorhandene Komponenten für ACL, Sharing, People Picker, Audit, MCP, OAuth, HITL und Marketplace sind wiederzuverwenden; +- es darf kein paralleles Berechtigungssystem entstehen; +- alle DB-Abfragen müssen Tenant-Isolation erzwingen; +- veröffentlichte Versionen sind immutable; +- Conversation- und Message-IDs sind nur Herkunftsmetadaten, keine Laufzeitabhängigkeit. + +## 5. Zielarchitektur + +```text +Conversation / Message + │ + └── Artifact + │ + └── Als App veröffentlichen + │ + ▼ + ArtifactApp + ├── stabile ID und URL + ├── ACL + ├── Marketplace-Metadaten + ├── Tool-Policy + ├── aktive Version + └── ArtifactVersion[] + │ + ▼ + Standalone / Embed Viewer + │ + ▼ + Artifact Tool Bridge + │ + ▼ + bestehender MCPManager + ├── aktueller User-Kontext + ├── OAuth/OBO pro User + ├── Tool-Allowlist + ├── HITL + └── Audit +``` + +## 6. Datenmodell + +### 6.1 ArtifactApp + +```ts +interface ArtifactApp { + artifactAppId: string; + tenantId: string; + + title: string; + description?: string; + icon?: string; + category?: string; + tags?: string[]; + + createdBy: string; + activeVersionId?: string; + latestVersionNumber: number; + + status: + | 'draft' + | 'pending_review' + | 'published' + | 'suspended' + | 'archived'; + + visibility: + | 'private' + | 'restricted' + | 'tenant' + | 'public'; + + allowEmbed: boolean; + allowFork: boolean; + allowAnonymousView: boolean; + + toolPolicy: { + enabled: boolean; + allowedServers: string[]; + allowedTools: string[]; + requireConfirmationForWrites: boolean; + }; + + marketplace: { + listed: boolean; + featured: boolean; + summary?: string; + riskClass: 'none' | 'read' | 'write' | 'external'; + costClass: 'free' | 'low' | 'medium' | 'high'; + }; + + sourceMetadata?: { + conversationId?: string; + messageId?: string; + originalArtifactId?: string; + }; + + review?: { + submittedAt?: Date; + submittedBy?: string; + reviewedAt?: Date; + reviewedBy?: string; + result?: 'approved' | 'rejected'; + comment?: string; + }; + + createdAt: Date; + updatedAt: Date; + archivedAt?: Date; +} +``` + +### 6.2 ArtifactVersion + +```ts +interface ArtifactVersion { + artifactVersionId: string; + artifactAppId: string; + tenantId: string; + + versionNumber: number; + versionLabel?: string; + changelog?: string; + + artifactType: 'react' | 'html' | 'mermaid'; + sourceSnapshot: string; + + runtimeConfig: { + dependencies?: Record; + entryPoint?: string; + renderMode?: string; + }; + + integrity: { + sourceHash: string; + schemaVersion: number; + }; + + createdBy: string; + createdAt: Date; + + publication: { + state: 'draft' | 'released' | 'withdrawn'; + releasedBy?: string; + releasedAt?: Date; + }; +} +``` + +### 6.3 ACL + +Neuer ResourceType: + +```ts +ResourceType.ARTIFACT_APP +``` + +Versionen erben die Rechte der App; kein eigener `ARTIFACT_VERSION`-ACL-Typ, solange kein fachlicher Bedarf entsteht. + +Rollen: + +- Owner: vollständige Verwaltung, ACL, Tool-Policy, Veröffentlichung und Archivierung; +- Editor: neue Draft-Versionen erstellen und bearbeiten; +- Viewer: App anzeigen und freigegebene Funktionen verwenden; +- Admin-Capability: tenantweite Verwaltung, Suspendierung und Review. + +### 6.4 Indizes + +Mindestens: + +- eindeutiger Index auf `tenantId + artifactAppId`; +- eindeutiger Index auf `tenantId + artifactAppId + versionNumber`; +- Index auf `tenantId + status + visibility`; +- Index auf `tenantId + marketplace.listed + marketplace.featured`; +- Index auf `tenantId + createdBy`; +- Index auf `tenantId + activeVersionId`. + +## 7. Lifecycle + +```text +draft + → pending_review + → published + → rejected → draft + +published + → suspended + → archived + +suspended + → published + → archived +``` + +Regeln: + +- nur Owner können Freigaben und Tool-Policy ändern; +- nur Owner oder entsprechend berechtigte Reviewer können veröffentlichen; +- veröffentlichte Versionen werden nie überschrieben; +- Änderungen erzeugen eine neue Version; +- Rollback setzt `activeVersionId` auf eine ältere veröffentlichte Version; +- suspendierte oder archivierte Apps dürfen keine MCP-Tools ausführen; +- gelöschte Quell-Conversations verändern Artifact Apps nicht. + +## 8. API + +### 8.1 App-Lifecycle + +```http +POST /api/artifact-apps +GET /api/artifact-apps +GET /api/artifact-apps/:artifactAppId +PATCH /api/artifact-apps/:artifactAppId +DELETE /api/artifact-apps/:artifactAppId +``` + +`POST /api/artifact-apps`: + +1. authentifizierten Benutzer aus Server-Session bestimmen; +2. Zugriff auf Quell-Conversation/Message prüfen; +3. Artifact anhand bestehender Artifact-Struktur extrahieren; +4. Source normalisieren; +5. vollständigen Snapshot erzeugen; +6. SHA-256 oder projektüblichen Hash erzeugen; +7. `ArtifactApp` und `ArtifactVersion` atomar anlegen; +8. Owner-ACL vergeben; +9. Audit-Event schreiben; +10. App und Version zurückgeben. + +### 8.2 Versionen + +```http +POST /api/artifact-apps/:id/versions +GET /api/artifact-apps/:id/versions +GET /api/artifact-apps/:id/versions/:versionId +POST /api/artifact-apps/:id/versions/:versionId/release +POST /api/artifact-apps/:id/versions/:versionId/activate +POST /api/artifact-apps/:id/versions/:versionId/withdraw +``` + +### 8.3 Sharing + +```http +GET /api/artifact-apps/:id/permissions +POST /api/artifact-apps/:id/permissions +PATCH /api/artifact-apps/:id/permissions/:permissionId +DELETE /api/artifact-apps/:id/permissions/:permissionId +``` + +Bestehenden People Picker und vorhandene Principal-Typen nutzen. + +### 8.4 Review und Publishing + +```http +POST /api/artifact-apps/:id/submit +POST /api/artifact-apps/:id/approve +POST /api/artifact-apps/:id/reject +POST /api/artifact-apps/:id/suspend +POST /api/artifact-apps/:id/unpublish +``` + +### 8.5 Fork + +```http +POST /api/artifact-apps/:id/fork +``` + +Fork kopiert: + +- aktive Version als neue Version 1; +- Source und freigegebene Runtime-Konfiguration; +- Titel/Beschreibung als bearbeitbare Ausgangswerte. + +Fork kopiert nicht: + +- ACLs; +- Owner; +- Review-/Marketplace-Status; +- Audit-Historie; +- Nutzungsdaten; +- OAuth-/MCP-Credentials; +- Installationen anderer Benutzer. + +### 8.6 Marketplace + +```http +GET /api/marketplace/apps +GET /api/marketplace/apps/:id +POST /api/marketplace/apps/:id/install +DELETE /api/marketplace/apps/:id/install +``` + +Filter: + +- Suchtext; +- Kategorie; +- Tags; +- Owner/Team; +- Risikoklasse; +- Kostenklasse; +- featured; +- installiert; +- zuletzt aktualisiert. + +### 8.7 Tool Bridge + +```http +POST /api/artifact-apps/:id/tools/invoke +``` + +Request: + +```json +{ + "versionId": "...", + "server": "m365", + "tool": "search_sharepoint", + "arguments": {}, + "invocationId": "..." +} +``` + +Der Server ignoriert alle vom Artifact gelieferten User-, Tenant-, Rollen- oder Tokenangaben. + +## 9. Frontend + +### 9.1 Publish-Dialog + +Am Artifact-Menü neue Aktion: + +```text +Als App veröffentlichen +``` + +Schritte: + +1. Allgemein: Titel, Beschreibung, Icon, Kategorie, Tags; +2. Freigabe: Sichtbarkeit, Owner, Editor, Viewer; +3. Tools: MCP-Server und Tools auswählen; +4. Sicherheit: Risiko, Fork, Embed, Public View; +5. Veröffentlichung: Changelog, Review, Zusammenfassung. + +### 9.2 Standalone Viewer + +Route: + +```text +/apps/:artifactAppId +/apps/:artifactAppId/version/:versionId +``` + +Anforderungen: + +- kein Chatverlauf; +- App-Metadaten und aktive Version; +- isolierter Artifact Renderer; +- Versionsauswahl entsprechend Berechtigung; +- Fork-Aktion; +- klare Zustände für 403, 404, suspended und archived; +- keine Editierfunktionen für Viewer. + +### 9.3 Embed Viewer + +Route: + +```text +/embed/apps/:artifactAppId +``` + +Anforderungen: + +- minimales Layout; +- restriktive iframe-Sandbox; +- keine LibreChat-Navigation; +- kein Chat; +- kein Zugriff auf Parent-DOM; +- Kommunikation nur per validiertem `postMessage`; +- keine Tokens oder Secrets in URL, DOM oder Client-State; +- Embed nur, wenn App und Tenant-Konfiguration dies erlauben. + +### 9.4 Marketplace + +```text +Marketplace +├── Agents +├── Skills +├── Tools +└── Apps +``` + +App-Karte: + +- Icon; +- Titel; +- Kurzbeschreibung; +- Eigentümer/Team; +- Kategorie und Tags; +- Risiko- und Kostenklasse; +- aktive Version; +- aktualisiert am; +- Öffnen; +- Installieren/Entfernen; +- Fork, falls erlaubt. + +## 10. Sandbox und CSP + +Den vorhandenen Artifact-/Sandpack-Renderer wiederverwenden, aber in einen standalonefähigen Wrapper kapseln. + +Grundregeln: + +- keine direkte Übergabe von Tokens/Credentials; +- keine freien Netzwerkziele; +- keine dynamische Paketinstallation; +- zentral erlaubte Dependencies; +- kein Zugriff auf Parent-DOM; +- keine Top-Navigation; +- keine Popups, sofern nicht explizit benötigt; +- Größen-, Laufzeit- und Output-Limits; +- restriktive CSP; +- erlaubte `connect-src` ausschließlich zur LibreChat Artifact Bridge; +- `frame-ancestors` abhängig von Embed-Konfiguration; +- `postMessage` nur mit exakter Origin-Prüfung. + +## 11. MCP Bridge + +### 11.1 Ablauf + +```text +Artifact iframe +→ window.libreChatApp.invokeTool(...) +→ Host validiert postMessage +→ Backend Tool-Endpoint +→ Session/User bestimmen +→ Tenant prüfen +→ App ACL prüfen +→ Version prüfen +→ App Tool-Allowlist prüfen +→ User-Zugriff auf MCP-Server/Tool prüfen +→ bestehender MCPManager mit User-Kontext +→ per-user OAuth/OBO +→ Tool ausführen +→ Ergebnis begrenzen und sanitizen +→ Audit +→ Antwort an Artifact +``` + +### 11.2 Artifact API + +```ts +window.libreChatApp.invokeTool({ + server: 'm365', + tool: 'search_sharepoint', + arguments: { query: '...' } +}); +``` + +### 11.3 Auth-Regeln + +Jeder Aufruf prüft: + +1. gültige serverseitige Session; +2. Tenant-Zuordnung; +3. App-Status und Sichtbarkeit; +4. Viewer-Recht; +5. aktive oder angeforderte Version; +6. Tool in App-Allowlist; +7. MCP-Server für Benutzer verfügbar; +8. vorhandene User-OAuth/OBO-Verbindung; +9. Risikoklasse des Tools; +10. Rate-/Budget-Limits. + +Artifact-Code erhält niemals: + +- Access Token; +- Refresh Token; +- ID Token; +- Session Token; +- Client Secret; +- MCP Credential; +- Provider-Key; +- LiteLLM-Key. + +### 11.4 HITL + +Tool-Risikoklassen: + +```text +read → direkt, wenn erlaubt +write → Bestätigung mit Parametervorschau +destructive → hervorgehobene Bestätigung mit genauer Auswirkung +``` + +Ablehnung darf den Tool-Call nicht ausführen. Bestätigung und Ablehnung werden auditiert. + +## 12. Public und Embed + +- `public-view` und `public-execute` getrennt behandeln; +- v1 erlaubt optional `public-view` für statische Darstellung; +- v1 erlaubt kein anonymes `public-execute`; +- nicht angemeldete Besucher erhalten bei Tool-Aufruf eine Login-Aufforderung; +- öffentliche Freigabe muss tenantweit deaktivierbar sein; +- öffentliche App darf keine internen Daten serverseitig vorladen. + +## 13. Audit Events + +```text +artifact_app.created +artifact_app.updated +artifact_app.submitted +artifact_app.approved +artifact_app.rejected +artifact_app.published +artifact_app.suspended +artifact_app.archived +artifact_app.forked +artifact_app.embedded + +artifact_version.created +artifact_version.released +artifact_version.activated +artifact_version.withdrawn + +artifact_acl.granted +artifact_acl.updated +artifact_acl.revoked + +artifact_tool.requested +artifact_tool.confirmed +artifact_tool.rejected +artifact_tool.completed +artifact_tool.failed +artifact_tool.denied +``` + +Audit-Einträge dürfen keine Tokens, Secrets oder unnötigen Tool-Outputs enthalten. + +## 14. Export + +Späteres Arbeitspaket innerhalb dieses Plans: + +```text +artifact-app.zip +├── manifest.json +├── source/ +├── README.md +└── checksums.json +``` + +Nicht exportieren: + +- ACLs; +- User-Informationen; +- Tokens; +- Secrets; +- MCP-Credentials; +- Session-Daten; +- interne Audit-Historie. + +SCM-Synchronisierung bleibt außerhalb von v1. + +## 15. Threat Model + +Mindestens berücksichtigen: + +- Stored und Reflected XSS; +- Sandbox Escape; +- Parent-DOM-Zugriff; +- Token-/Credential-Exfiltration; +- beliebige MCP-Server oder Header; +- SSRF; +- Cross-Tenant-IDOR; +- Manipulation von App-/Version-IDs; +- Rechteentzug während einer offenen Session; +- Tool Confusion; +- Prompt Injection über Tool-Outputs; +- Replay von Tool-Aufrufen; +- anonyme Tool-Ausführung; +- Editor erweitert eigene Rechte; +- Fork übernimmt fremde Credentials; +- Dependency Supply Chain; +- DoS durch Endlosschleifen, große Payloads oder Outputs; +- Clickjacking; +- gefälschte `postMessage`-Origins; +- Leakage über Logs, Fehler und Telemetrie. + +## 16. Arbeitspakete + +### WP0 – Repository-Erkundung und ADR + +- tatsächliche Artifact-Datenstruktur lokalisieren; +- Renderer und Sandpack-Integration lokalisieren; +- Shared-Link-, Agent-Marketplace-, ACL-, Audit-, MCP- und HITL-Muster dokumentieren; +- konkrete Dateiliste in diesem Plan ergänzen; +- ADR für Snapshot-Speicherung; +- ADR für MCP Bridge; +- kein Produktivcode. + +### WP1 – Persistenz + +- `ArtifactApp`- und `ArtifactVersion`-Schema; +- Models und DB-Methoden; +- Indizes; +- Tenant-Isolation; +- atomare Erstellung; +- Migration; +- Unit-Tests. + +### WP2 – ACL und Capabilities + +- `ARTIFACT_APP` ResourceType; +- Capability-Mapping; +- Owner/Editor/Viewer; +- Benutzer-/Gruppen-/Rollenfreigabe; +- Admin-Capabilities; +- ACL- und Cross-Tenant-Tests. + +### WP3 – Publishing API + +- Quell-Artifact prüfen und extrahieren; +- vollständiger Snapshot; +- Hashing; +- App + Version 1; +- Owner-ACL; +- Audit; +- Integrationstests. + +### WP4 – Versionierung + +- Draft-Version; +- Release; +- Activate; +- Rollback; +- Withdraw; +- Versionshistorie; +- Immutability-Tests. + +### WP5 – Standalone Viewer + +- Route und Layout; +- Berechtigungszustände; +- aktive und explizite Version; +- Renderer-Wiederverwendung; +- Viewer-Tests. + +### WP6 – Publish- und Sharing-UI + +- Publish-Dialog; +- People Picker; +- ACL-Rollen; +- Sichtbarkeit; +- Tool-Policy; +- Review-Zusammenfassung; +- UI-Tests. + +### WP7 – Embed und Sandbox + +- Embed-Route; +- Sandbox; +- CSP; +- Origin-Regeln; +- Public View; +- Security-Tests. + +### WP8 – Marketplace + +- Apps-Tab; +- Suche und Filter; +- App-Details; +- Installieren/Entfernen; +- Öffnen/Fork; +- Risiko-/Kostenanzeige; +- E2E-Tests. + +### WP9 – MCP Bridge Read-only + +- Browser Bridge; +- Backend Invoke Endpoint; +- User Context; +- ACL und Tool-Allowlist; +- bestehender MCPManager; +- Output-Limits/Sanitizing; +- Rate Limits; +- per-user-OAuth-Tests. + +### WP10 – HITL für Write/Destructive + +- Tool-Risikometadaten; +- Bestätigungsdialog; +- Resume/Reject; +- Audit; +- E2E-Tests. + +### WP11 – Fork + +- unabhängige App und Version 1; +- eigener Owner; +- keine ACL/Credential-Übernahme; +- Fork-Tests. + +### WP12 – Export + +- Manifest; +- ZIP; +- Hashes; +- keine Secrets; +- Export-Tests. + +### WP13 – Hardening + +- Threat-Model-Review; +- Penetration-/Security-Tests; +- Performance und Limits; +- Migrationstests; +- Dokumentation; +- Admin-Kill-Switch; +- finaler Audit-Review. + +## 17. Testmatrix + +### Persistenz und Lifecycle + +- App und Version werden atomar erstellt; +- Conversation-Löschung verändert App nicht; +- Message-/Artifact-Änderung verändert Release nicht; +- Source-Hash ist reproduzierbar; +- Release ist immutable; +- Rollback aktiviert ältere Version; +- suspended/archived verhindert Ausführung. + +### ACL + +- Owner verwaltet App, ACL und Tool-Policy; +- Editor erstellt Draft-Version; +- Viewer kann nur anzeigen/ausführen; +- fremder Benutzer erhält 403/404; +- Gruppenfreigabe funktioniert; +- Rechteentzug wirkt beim nächsten Request; +- Cross-Tenant-Zugriff wird verhindert; +- Viewer/Editor können eigene Rolle nicht erhöhen. + +### Viewer und Embed + +- Standalone Viewer zeigt keinen Chat; +- Embed zeigt keine Navigation; +- deaktiviertes Embed wird blockiert; +- private App ist nicht öffentlich erreichbar; +- CSP blockiert unerlaubte Verbindungen; +- iframe kann Parent-DOM nicht lesen; +- gefälschte postMessage-Origin wird ignoriert. + +### MCP + +- erlaubtes Read-Tool funktioniert; +- nicht erlaubtes Tool wird blockiert; +- unbekannter Server wird blockiert; +- User A verwendet User-A-OAuth; +- User B verwendet User-B-OAuth; +- kein Token erreicht Artifact-Code; +- gefälschte User-/Tenant-ID wird ignoriert; +- öffentliche anonyme App kann kein Tool ausführen; +- Schreibaktion benötigt Bestätigung; +- Reject verhindert Tool-Ausführung; +- App-Suspendierung verhindert weitere Tool-Calls. + +### Fork + +- Fork besitzt eigene App-ID und Versionen; +- Fork übernimmt keine ACLs; +- Fork übernimmt keine Credentials; +- spätere Änderungen am Original verändern Fork nicht. + +### Security + +- Stored XSS; +- Reflected XSS; +- IDOR App/Version; +- Cross-Tenant-Abfragen; +- SSRF; +- Header Injection; +- Token Leakage in Logs; +- Replay; +- große Payloads; +- Endlosschleifen; +- Dependency-Manipulation; +- Tool-Output-Injection. + +## 18. Commit-Plan + +```text +01 docs: document artifact app architecture and threat model +02 feat(data): add artifact app and version schemas +03 feat(acl): add artifact app permissions and capabilities +04 test(acl): cover artifact app tenant isolation +05 feat(api): publish detached artifact snapshots +06 test(api): cover artifact snapshot independence +07 feat(api): add artifact version lifecycle and rollback +08 feat(web): add standalone artifact app viewer +09 feat(web): add artifact publishing and sharing UI +10 feat(embed): add sandboxed artifact embed viewer +11 feat(marketplace): add artifact apps category +12 feat(mcp): add authenticated artifact tool bridge +13 feat(mcp): enforce app tool allowlists and limits +14 feat(hitl): require approval for write tools +15 test(mcp): cover per-user oauth and tool isolation +16 feat(apps): add artifact app forking +17 feat(export): add static artifact app export +18 security: harden artifact runtime and CSP +19 docs: add administration and user documentation +``` + +Jeder Commit muss möglichst eigenständig build- und testbar bleiben. + +## 19. Reihenfolge der Coding-Sessions + +Jede Session erhält nur ein Arbeitspaket und diesen Plan als Referenz. + +```text +Session 1 → WP0 +Session 2 → WP1 +Session 3 → WP2 +Session 4 → WP3 +Session 5 → WP4 +Session 6 → WP5 +Session 7 → WP6 +Session 8 → WP7 +Session 9 → WP8 +Session 10 → WP9 +Session 11 → WP10 +Session 12 → WP11 +Session 13 → WP12 +Session 14 → WP13 +``` + +Regeln für jede Session: + +- bestehende Planung nicht neu schreiben; +- nur das zugewiesene Arbeitspaket bearbeiten; +- sofort nach gezielter Codepfadprüfung implementieren; +- nicht nach Analyse stoppen; +- Tests vor Abschluss ausführen; +- geänderte Dateien und Befehle dokumentieren; +- keine Aufgabe als fertig markieren, wenn nur Dokumentation oder minimale Stubs entstanden sind. + +## 20. Prompt-Vorlage pro Arbeitspaket + +```text +Implementiere jetzt ausschließlich Arbeitspaket WP aus PLAN.md. + +Regeln: +- PLAN.md ist verbindlich und wird nicht neu geplant. +- Analysiere nur die für WP relevanten Codepfade. +- Beginne anschließend sofort mit der Implementierung. +- Ändere keine fachfremden Bereiche. +- Verwende bestehende LibreChat-Muster und Workspace-Grenzen. +- Ergänze vollständige Tests. +- Führe Typecheck, relevante Tests und Linter aus. +- Beende die Aufgabe nicht nach Analyse oder mit reinen Stubs. + +Fertig bedeutet: +1. alle Punkte von WP implementiert; +2. Akzeptanztests erfüllt; +3. Tests erfolgreich; +4. geänderte Dateien aufgelistet; +5. ausgeführte Befehle aufgelistet; +6. verbleibende Risiken dokumentiert. +``` + +## 21. Abnahme + +Vor Merge müssen mindestens folgende Nachweise vorliegen: + +- vollständiger Build; +- Typecheck und Linter; +- Unit-, Integrations- und E2E-Tests; +- Cross-Tenant- und ACL-Testlauf; +- per-user-MCP-OAuth-Test mit zwei Benutzern; +- Test, dass keine Credentials im Browser oder in Logs erscheinen; +- Sandbox-/CSP-Test; +- Migration vorwärts und Rollback geprüft; +- dokumentierter Admin-Kill-Switch; +- dokumentierte bekannte Einschränkungen. From 9a2e9bcd573845975915f2c572bd008966e718a6 Mon Sep 17 00:00:00 2001 From: dsheine <304794417+dsheine@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:59:32 +0000 Subject: [PATCH 02/15] docs: document artifact app architecture and threat model --- docs/artifact-apps/WP0-exploration.md | 138 ++++++++++++++++++ .../adr-0001-snapshot-storage.md | 67 +++++++++ docs/artifact-apps/adr-0002-mcp-bridge.md | 61 ++++++++ 3 files changed, 266 insertions(+) create mode 100644 docs/artifact-apps/WP0-exploration.md create mode 100644 docs/artifact-apps/adr-0001-snapshot-storage.md create mode 100644 docs/artifact-apps/adr-0002-mcp-bridge.md diff --git a/docs/artifact-apps/WP0-exploration.md b/docs/artifact-apps/WP0-exploration.md new file mode 100644 index 00000000000..602ae571f78 --- /dev/null +++ b/docs/artifact-apps/WP0-exploration.md @@ -0,0 +1,138 @@ +# WP0 — Repository Exploration for Artifact Apps + +This document records the concrete code paths that the Artifact Apps feature +(PLAN.md, WP0–WP6) builds on. It is the reference "file list" required by WP0. +No production code is introduced by WP0 itself; the findings drive WP1–WP6. + +## 1. Artifact data structure + +- `client/src/common/artifacts.ts` — `Artifact` interface (`id`, `identifier`, + `type`, `content`, `title`, `language`, `messageId`, `lastUpdateTime`, + `download`). This is the canonical shape of an artifact in the client. +- `client/src/store/artifacts.ts` — Recoil atoms holding artifact state keyed by + a composite `artifactKey` (`${identifier}_${type}_${title}_${messageId}`). +- `client/src/components/Artifacts/Artifact.tsx` — `artifactPlugin` and the + construction of the composite key; associates an artifact with its `messageId`. +- Artifact MIME types: `application/vnd.react`, `text/html`, + `application/vnd.mermaid`, `text/markdown`, `application/vnd.code`. + +The Artifact App snapshot normalizes these to the three renderable runtime types +required by PLAN §6.2: `react | html | mermaid`. + +## 2. Artifact extraction / parsing + +- `client/src/components/Chat/Messages/Content/splitMarkdown.ts` — micromark / + mdast directive parsing of `:::artifact:::` blocks. +- `client/src/utils/artifacts.ts` — `TOOL_ARTIFACT_TYPES`, template selection + (`getArtifactType`, Sandpack template mapping), file-type detection. + +For publishing, the source artifact is passed from the client to +`POST /api/artifact-apps` (already-extracted `content` + `type` + `title`), +together with `conversationId`/`messageId` for access verification. The backend +re-normalizes and snapshots the content — it never trusts client-supplied +identity/tenant fields. + +## 3. Renderer / Sandpack integration + +- `client/src/components/Artifacts/ArtifactPreview.tsx` — `SandpackProvider` + + `SandpackPreview` (`@codesandbox/sandpack-react/unstyled`). Props: `files`, + `fileKey`, `template`, `sharedProps`, `currentCode`, `startupConfig`. +- `client/src/components/Artifacts/ArtifactTabs.tsx` — code/preview tab switch. +- `client/src/components/Artifacts/Artifacts.tsx` — header actions (copy, + download, refresh, version nav) — the insertion point for "Publish as App". +- `client/src/utils/artifacts.ts` — `getArtifactFiles` / template helpers used to + build Sandpack `files` from an artifact. + +WP5 reuses these by feeding a snapshot (`sourceSnapshot`, `artifactType`) into a +standalone wrapper (`ArtifactAppViewer`) that constructs the same Sandpack files +without any chat context. + +## 4. Artifact menu / "Publish as App" action + +- `client/src/components/Artifacts/Artifacts.tsx` header button group is where + the new "Publish as App" trigger is added (WP6), opening the publish dialog. + +## 5. Artifact ↔ conversation/message relationship + +- An artifact is addressed by `conversationId` + `messageId` + `identifier`. +- These are stored on the App only as **origin metadata** (`sourceMetadata`), + never as a runtime dependency (PLAN §4). Deleting the source conversation must + not change the App (enforced by full snapshotting in WP3). + +## 6. ACL / permissions system (reused, not reinvented) + +- `packages/data-provider/src/accessPermissions.ts` — `ResourceType`, + `PermissionBits` (VIEW=1, EDIT=2, DELETE=4, SHARE=8), `AccessRoleIds`, + `PrincipalType`, `accessRoleToPermBits`, zod DTOs for sharing. +- `packages/data-schemas/src/schema/aclEntry.ts` + `.../methods/aclEntry.ts` — + `grantPermission`, `revokePermission`, `hasPermission`, `findAccessibleResources`, + `getEffectivePermissions`, `findPublicResourceIds`. +- `packages/data-schemas/src/methods/accessRole.ts` — `seedDefaultRoles` + (viewer/editor/owner per resource type; `RoleBits`). +- `packages/data-schemas/src/common/permissions.ts` — `RoleBits` + (VIEWER=1, EDITOR=3, MANAGER=7, OWNER=15), `MAX_PERM_BITS`. +- `packages/api/src/acl/accessControlService.ts` — `AccessControlService` + (`grantPermission`, `checkPermission`, `findAccessibleResources`, + `hasPublicAccess`, `getUserPrincipals`). Validates against `ResourceType`. +- `packages/data-schemas/src/admin/capabilities.ts` — `SystemCapabilities`, + `ResourceCapabilityMap` (`Record` — adding a + new ResourceType requires a capability here) and `CapabilityImplications`. +- `api/server/middleware/accessResources/canAccessResource.js` — generic ACL + middleware with capability bypass + custom id resolver. +- `api/server/middleware/accessResources/canAccessAgentResource.js` — the + per-resource wrapper pattern mirrored by `canAccessArtifactAppResource`. +- `api/server/services/PermissionService` — `checkPermission` used by middleware. +- `api/server/controllers/PermissionsController.js` + + `api/server/routes/accessPermissions.js` — generic resource sharing endpoints + (People Picker principals). Artifact Apps register `ResourceType.ARTIFACT_APP` + and reuse these directly. + +## 7. Persistence patterns (mirrored for WP1) + +- Schema: `packages/data-schemas/src/schema/agent.ts` (+ `schema/index.ts`). +- Model factory: `packages/data-schemas/src/models/agent.ts` applies + `applyTenantIsolation`; registered in `models/index.ts` `createModels`. +- Types: `packages/data-schemas/src/types/agent.ts` (+ `types/index.ts`). +- Methods: `packages/data-schemas/src/methods/agent.ts` (factory returning + CRUD); aggregated in `methods/index.ts` `createMethods` and re-exported as + `AllMethods`. Consumed by `/api` via `api/models/index.js`. +- Tenant isolation: `packages/data-schemas/src/models/plugins/tenantIsolation.ts` + + `config/tenantContext.ts` (AsyncLocalStorage). All models get automatic + tenant filtering; DB methods additionally pass `tenantId` explicitly for + defense-in-depth. +- Migrations: `packages/data-schemas/src/migrations/*` (e.g. `tenantIndexes.ts`) + exported from `migrations/index.ts`. +- Transactions: `packages/data-schemas/src/utils/transactions.ts` + (`supportsTransactions`) — used for atomic App+Version creation with graceful + fallback when the deployment is a standalone (non-replica-set) MongoDB. +- Unit test pattern: `mongodb-memory-server` + `createModels`/`createMethods` + in `beforeAll` (see `methods/agent.spec.ts`). + +## 8. Audit + +- `packages/data-schemas/src/types/admin.ts` — `AUDIT_CATEGORIES`, + `AUDIT_ACTIONS`, `AUDIT_ACTION_CATEGORY`, outcomes/severities/actor types. +- `packages/data-schemas/src/methods/auditLog.ts` — `recordAuditEntry` + (append-only, hash-chained, tenant-scoped). + +WP3/WP4 extend the taxonomy with `artifact_app.*` / `artifact_version.*` / +`artifact_acl.*` actions and write via `recordAuditEntry`. Audit entries never +contain tokens/secrets (PLAN §13). + +## 9. MCP / HITL (out of scope for WP0–WP6, referenced for ADR-2) + +- `@librechat/agents` MCPManager (external dependency) is the only tool executor. +- Existing MCP server ACL: `ResourceType.MCPSERVER` with viewer/editor/owner. + +MCP bridge design is captured in ADR-2; no MCP code is written in WP0–WP6. + +## 10. Frontend routing / data-provider (WP5/WP6) + +- `packages/data-provider/src/api-endpoints.ts` — endpoint builders. +- `packages/data-provider/src/data-service.ts` — service functions. +- `packages/data-provider/src/keys.ts` — `QueryKeys` / `MutationKeys`. +- `client/src/routes` — SPA routes; `/apps/:artifactAppId[/version/:versionId]` + is added for the standalone viewer. +- `client/src/locales/en/translation.json` — English localization keys + (`com_ui_*`, `com_artifact_apps_*`). + diff --git a/docs/artifact-apps/adr-0001-snapshot-storage.md b/docs/artifact-apps/adr-0001-snapshot-storage.md new file mode 100644 index 00000000000..4c0eac33c9f --- /dev/null +++ b/docs/artifact-apps/adr-0001-snapshot-storage.md @@ -0,0 +1,67 @@ +# ADR-0001: Artifact Version Snapshot Storage + +- Status: Accepted +- Scope: WP1–WP4 (persistence, publishing, versioning) +- Related: PLAN.md §4, §6.2, §7, §17 + +## Context + +An Artifact App must keep working unchanged after its source conversation or +message is deleted or edited (PLAN §2, §4). Conversation/message/artifact IDs are +"origin metadata only, never a runtime dependency" (PLAN §4). Published versions +must be immutable (PLAN §4, §7). We must decide how the artifact source is stored +per version. + +Options considered: + +1. **Reference the source message/artifact at render time.** Rejected — violates + the independence requirement; deleting the conversation breaks the app. +2. **Store a full, self-contained snapshot inline on each `ArtifactVersion`.** +3. **Store snapshots in external object storage (S3/GridFS), referenced by hash.** + +## Decision + +Store a **full inline snapshot** on each `ArtifactVersion` document: + +- `sourceSnapshot: string` — the complete, normalized artifact source (the code / + markup / mermaid text). No reference back to the message is required to render. +- `artifactType: 'react' | 'html' | 'mermaid'` — the normalized runtime type. +- `runtimeConfig` — `{ dependencies?, entryPoint?, renderMode? }` captured at + publish time so the renderer is reproducible from the version alone. +- `integrity.sourceHash` — **SHA-256** hex digest computed over a canonical + serialization of `{ artifactType, sourceSnapshot, runtimeConfig }`. This makes + the snapshot reproducible/verifiable (PLAN §17 "source hash is reproducible") + and lets us detect tampering. +- `integrity.schemaVersion` — snapshot format version for forward migration. + +Immutability is enforced at the schema level: once a version's +`publication.state` is `released`, the content fields (`sourceSnapshot`, +`artifactType`, `runtimeConfig`, `integrity`, `versionNumber`) can never be +mutated (pre-update/save guards + method-level checks). New content always +creates a **new version**; rollback only flips the App's `activeVersionId`. + +### Why inline (option 2) over external storage (option 3) + +- Artifact sources are small text blobs (react/html/mermaid), comfortably within + Mongo document limits; a dedicated object store adds operational complexity and + a second failure/consistency domain for no benefit at v1 sizes. +- Atomic App + Version-1 creation (PLAN §8.1 step 7) is a single transaction in + one database; splitting content into object storage would break atomicity or + require a saga. +- The integrity hash already provides tamper-evidence; external storage would + still need the same hash. + +If artifact sizes grow (e.g. bundled assets in a later WP), the `runtimeConfig` ++ `integrity` split leaves room to move large blobs behind a content-addressed +pointer without changing the App/Version contract. + +## Consequences + +- `ArtifactApp` and `ArtifactVersion` are separate collections; the App holds + lifecycle/ACL/marketplace metadata and points to an `activeVersionId`. +- Creation of App + Version 1 is atomic (transaction when the MongoDB deployment + supports it; sequential-with-compensation fallback otherwise). +- Deleting/editing the source conversation has zero effect on any version + (verified by WP3 "snapshot independence" tests). +- Tenant isolation applies to both collections; every query is tenant-scoped. + diff --git a/docs/artifact-apps/adr-0002-mcp-bridge.md b/docs/artifact-apps/adr-0002-mcp-bridge.md new file mode 100644 index 00000000000..d26354df54d --- /dev/null +++ b/docs/artifact-apps/adr-0002-mcp-bridge.md @@ -0,0 +1,61 @@ +# ADR-0002: Artifact Tool (MCP) Bridge Design + +- Status: Accepted (design only — implementation is WP9/WP10, **out of scope for + WP0–WP6**) +- Scope: forward-looking; recorded now per WP0 so later sessions do not re-plan +- Related: PLAN.md §11, §12, §15 + +## Context + +Artifact Apps may optionally call explicitly allow-listed MCP tools. The artifact +runs in a sandboxed iframe and must **never** receive OAuth/session tokens, API +keys, or MCP credentials (PLAN §11.3). Authentication, authorization, and tool +execution must stay fully server-side under LibreChat's existing `MCPManager` +(PLAN §3.2 — no new MCP/OAuth implementation). + +## Decision + +Design the bridge as a **thin, server-mediated proxy**, decided now so WP1's +`toolPolicy` schema and WP3's publish flow store the right fields: + +1. **Browser bridge:** the iframe exposes `window.libreChatApp.invokeTool({ server, + tool, arguments })`. The host page listens for `postMessage` and forwards to + the backend. The artifact receives **only** tool results, never credentials. +2. **Origin discipline:** `postMessage` is accepted only from the exact expected + origin; the host validates message shape before forwarding. Replay is + mitigated with a per-call `invocationId`. +3. **Backend endpoint:** `POST /api/artifact-apps/:id/tools/invoke` is the single + trust boundary. It **ignores all user/tenant/role/token fields supplied by the + artifact** (PLAN §8.7) and derives identity from the server session only. +4. **Authorization order** (PLAN §11.3): valid session → tenant match → app + status/visibility → viewer right → active/requested version → tool in the + App's `toolPolicy` allowlist → MCP server available to *this* user → + per-user OAuth/OBO present → tool risk class → rate/budget limits. +5. **Execution:** the existing `MCPManager` runs the tool in the authenticated + user's context with per-user OAuth. Output is size-limited and sanitized + before returning to the iframe. +6. **HITL:** tool risk class drives confirmation — `read` runs directly (if + allowed), `write` requires a parameter-preview confirmation, `destructive` + requires a highlighted confirmation. Reject never executes. Confirm/reject/ + complete/fail/deny are all audited. +7. **No anonymous execution:** `public-view` is separated from `public-execute`; + v1 allows optional static `public-view` but never anonymous tool execution + (PLAN §12). + +### Fields this ADR fixes into WP1 now + +`ArtifactApp.toolPolicy = { enabled, allowedServers[], allowedTools[], +requireConfirmationForWrites }` and `marketplace.riskClass` are persisted at +publish time so the (later) bridge has an authoritative, immutable-per-version +allowlist to enforce. WP0–WP6 only **store and display** these; no invoke +endpoint is implemented yet. + +## Consequences + +- The `toolPolicy` shape is stable from WP1, so WP9/WP10 add only the invoke + endpoint + bridge without a schema migration. +- Because identity is always server-derived, cross-tenant IDOR and forged + user/tenant IDs (PLAN §15) are structurally prevented at the bridge. +- Suspended/archived apps cannot execute tools (checked in step 3), consistent + with lifecycle rules (PLAN §7). + From 05db585573884d646c0af52d0316d178df938753 Mon Sep 17 00:00:00 2001 From: dsheine <304794417+dsheine@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:15:57 +0000 Subject: [PATCH 03/15] feat(data-provider): artifact app types, zod schemas, endpoints, and data-service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds shared Artifact Apps contracts (PLAN §6/§8/§9): status/visibility/runtime enums, publish/update/version request schemas, response types, query keys, API endpoint builders, and data-service calls. Registers ARTIFACT_APP in the ResourceType/AccessRoleIds enums and permission-bit mapping. Co-Authored-By: Claude Opus 4.7 --- .../data-provider/src/accessPermissions.ts | 7 + packages/data-provider/src/api-endpoints.ts | 23 +++ packages/data-provider/src/artifactApps.ts | 193 ++++++++++++++++++ packages/data-provider/src/data-service.ts | 71 +++++++ packages/data-provider/src/index.ts | 2 + packages/data-provider/src/keys.ts | 3 + 6 files changed, 299 insertions(+) create mode 100644 packages/data-provider/src/artifactApps.ts diff --git a/packages/data-provider/src/accessPermissions.ts b/packages/data-provider/src/accessPermissions.ts index 88f6ef954ba..633e416a714 100644 --- a/packages/data-provider/src/accessPermissions.ts +++ b/packages/data-provider/src/accessPermissions.ts @@ -49,6 +49,7 @@ export enum ResourceType { REMOTE_AGENT = 'remoteAgent', SKILL = 'skill', SHARED_LINK = 'sharedLink', + ARTIFACT_APP = 'artifactApp', } /** @@ -86,6 +87,9 @@ export enum AccessRoleIds { SKILL_OWNER = 'skill_owner', SHARED_LINK_VIEWER = 'sharedLink_viewer', SHARED_LINK_OWNER = 'sharedLink_owner', + ARTIFACT_APP_VIEWER = 'artifactApp_viewer', + ARTIFACT_APP_EDITOR = 'artifactApp_editor', + ARTIFACT_APP_OWNER = 'artifactApp_owner', } // ===== ZOD SCHEMAS ===== @@ -326,12 +330,14 @@ export function accessRoleToPermBits(accessRoleId: string): number { case AccessRoleIds.REMOTE_AGENT_VIEWER: case AccessRoleIds.SKILL_VIEWER: case AccessRoleIds.SHARED_LINK_VIEWER: + case AccessRoleIds.ARTIFACT_APP_VIEWER: return PermissionBits.VIEW; case AccessRoleIds.AGENT_EDITOR: case AccessRoleIds.PROMPTGROUP_EDITOR: case AccessRoleIds.MCPSERVER_EDITOR: case AccessRoleIds.REMOTE_AGENT_EDITOR: case AccessRoleIds.SKILL_EDITOR: + case AccessRoleIds.ARTIFACT_APP_EDITOR: return PermissionBits.VIEW | PermissionBits.EDIT; case AccessRoleIds.AGENT_OWNER: case AccessRoleIds.PROMPTGROUP_OWNER: @@ -339,6 +345,7 @@ export function accessRoleToPermBits(accessRoleId: string): number { case AccessRoleIds.REMOTE_AGENT_OWNER: case AccessRoleIds.SKILL_OWNER: case AccessRoleIds.SHARED_LINK_OWNER: + case AccessRoleIds.ARTIFACT_APP_OWNER: return ( PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE ); diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index b08e9c51068..4f4687bb080 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -526,3 +526,26 @@ export const getAllEffectivePermissions = (resourceType: ResourceType) => // SharePoint Graph API Token export const graphToken = (scopes: string) => `${BASE_URL}/api/auth/graph-token?scopes=${encodeURIComponent(scopes)}`; + +// Artifact Apps +export const artifactAppsRoot = `${BASE_URL}/api/artifact-apps`; + +export const artifactApps = () => artifactAppsRoot; + +export const artifactAppById = (artifactAppId: string) => + `${artifactAppsRoot}/${encodeURIComponent(artifactAppId)}`; + +export const artifactAppVersions = (artifactAppId: string) => + `${artifactAppById(artifactAppId)}/versions`; + +export const artifactAppVersionById = (artifactAppId: string, versionId: string) => + `${artifactAppVersions(artifactAppId)}/${encodeURIComponent(versionId)}`; + +export const artifactAppVersionRelease = (artifactAppId: string, versionId: string) => + `${artifactAppVersionById(artifactAppId, versionId)}/release`; + +export const artifactAppVersionActivate = (artifactAppId: string, versionId: string) => + `${artifactAppVersionById(artifactAppId, versionId)}/activate`; + +export const artifactAppVersionWithdraw = (artifactAppId: string, versionId: string) => + `${artifactAppVersionById(artifactAppId, versionId)}/withdraw`; diff --git a/packages/data-provider/src/artifactApps.ts b/packages/data-provider/src/artifactApps.ts new file mode 100644 index 00000000000..b640a541ffc --- /dev/null +++ b/packages/data-provider/src/artifactApps.ts @@ -0,0 +1,193 @@ +import { z } from 'zod'; + +/** + * Shared types and Zod schemas for the Artifact Apps feature (PLAN.md §6, §8, §9). + * Consumed by both the backend (packages/api, data-schemas) and the client. + */ + +// ===== ENUMS ===== + +export const artifactAppStatusSchema = z.enum([ + 'draft', + 'pending_review', + 'published', + 'suspended', + 'archived', +]); +export type ArtifactAppStatus = z.infer; + +export const artifactAppVisibilitySchema = z.enum(['private', 'restricted', 'tenant', 'public']); +export type ArtifactAppVisibility = z.infer; + +export const artifactRuntimeTypeSchema = z.enum(['react', 'html', 'mermaid']); +export type ArtifactRuntimeType = z.infer; + +export const artifactVersionStateSchema = z.enum(['draft', 'released', 'withdrawn']); +export type ArtifactVersionState = z.infer; + +export const artifactRiskClassSchema = z.enum(['none', 'read', 'write', 'external']); +export type ArtifactRiskClass = z.infer; + +export const artifactCostClassSchema = z.enum(['free', 'low', 'medium', 'high']); +export type ArtifactCostClass = z.infer; + +// ===== NESTED SCHEMAS ===== + +export const artifactToolPolicySchema = z.object({ + enabled: z.boolean().default(false), + allowedServers: z.array(z.string()).default([]), + allowedTools: z.array(z.string()).default([]), + requireConfirmationForWrites: z.boolean().default(true), +}); +export type ArtifactToolPolicy = z.infer; + +export const artifactMarketplaceSchema = z.object({ + listed: z.boolean().default(false), + featured: z.boolean().default(false), + summary: z.string().optional(), + riskClass: artifactRiskClassSchema.default('none'), + costClass: artifactCostClassSchema.default('free'), +}); +export type ArtifactMarketplace = z.infer; + +export const artifactSourceMetadataSchema = z.object({ + conversationId: z.string().optional(), + messageId: z.string().optional(), + originalArtifactId: z.string().optional(), +}); +export type ArtifactSourceMetadata = z.infer; + +export const artifactRuntimeConfigSchema = z.object({ + dependencies: z.record(z.string()).optional(), + entryPoint: z.string().optional(), + renderMode: z.string().optional(), +}); +export type ArtifactRuntimeConfig = z.infer; + +// ===== REQUEST SCHEMAS ===== + +/** The already-extracted artifact payload sent from the client at publish time. */ +export const artifactSnapshotInputSchema = z.object({ + type: artifactRuntimeTypeSchema, + content: z.string().min(1), + title: z.string().optional(), + language: z.string().optional(), + runtimeConfig: artifactRuntimeConfigSchema.optional(), +}); +export type ArtifactSnapshotInput = z.infer; + +/** POST /api/artifact-apps */ +export const publishArtifactAppSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().max(2000).optional(), + icon: z.string().optional(), + category: z.string().optional(), + tags: z.array(z.string()).optional(), + visibility: artifactAppVisibilitySchema.default('private'), + allowEmbed: z.boolean().optional(), + allowFork: z.boolean().optional(), + allowAnonymousView: z.boolean().optional(), + toolPolicy: artifactToolPolicySchema.partial().optional(), + marketplace: artifactMarketplaceSchema.partial().optional(), + artifact: artifactSnapshotInputSchema, + source: artifactSourceMetadataSchema.optional(), + changelog: z.string().optional(), + versionLabel: z.string().optional(), +}); +export type TPublishArtifactAppRequest = z.infer; + +/** PATCH /api/artifact-apps/:id */ +export const updateArtifactAppSchema = z + .object({ + title: z.string().min(1).max(200), + description: z.string().max(2000), + icon: z.string(), + category: z.string(), + tags: z.array(z.string()), + visibility: artifactAppVisibilitySchema, + allowEmbed: z.boolean(), + allowFork: z.boolean(), + allowAnonymousView: z.boolean(), + toolPolicy: artifactToolPolicySchema.partial(), + marketplace: artifactMarketplaceSchema.partial(), + }) + .partial(); +export type TUpdateArtifactAppRequest = z.infer; + +/** POST /api/artifact-apps/:id/versions */ +export const createArtifactVersionSchema = z.object({ + artifact: artifactSnapshotInputSchema, + changelog: z.string().optional(), + versionLabel: z.string().optional(), +}); +export type TCreateArtifactVersionRequest = z.infer; + +// ===== RESPONSE TYPES (client-facing; dates serialized as ISO strings) ===== + +export interface TArtifactApp { + artifactAppId: string; + tenantId?: string; + title: string; + description?: string; + icon?: string; + category?: string; + tags?: string[]; + createdBy: string; + activeVersionId?: string; + latestVersionNumber: number; + status: ArtifactAppStatus; + visibility: ArtifactAppVisibility; + allowEmbed: boolean; + allowFork: boolean; + allowAnonymousView: boolean; + toolPolicy: ArtifactToolPolicy; + marketplace: ArtifactMarketplace; + sourceMetadata?: ArtifactSourceMetadata; + review?: { + submittedAt?: string; + submittedBy?: string; + reviewedAt?: string; + reviewedBy?: string; + result?: 'approved' | 'rejected'; + comment?: string; + }; + createdAt: string; + updatedAt: string; + archivedAt?: string; +} + +export interface TArtifactVersion { + artifactVersionId: string; + artifactAppId: string; + tenantId?: string; + versionNumber: number; + versionLabel?: string; + changelog?: string; + artifactType: ArtifactRuntimeType; + sourceSnapshot: string; + runtimeConfig: ArtifactRuntimeConfig; + integrity: { + sourceHash: string; + schemaVersion: number; + }; + createdBy: string; + createdAt: string; + publication: { + state: ArtifactVersionState; + releasedBy?: string; + releasedAt?: string; + }; +} + +export interface TArtifactAppWithVersion { + app: TArtifactApp; + version: TArtifactVersion; +} + +export interface TArtifactAppList { + apps: TArtifactApp[]; +} + +export interface TArtifactVersionList { + versions: TArtifactVersion[]; +} diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index d06c138e0d0..72ac9701ffa 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -14,6 +14,7 @@ import * as config from './config'; import request from './request'; import * as s from './schemas'; import * as r from './roles'; +import * as aa from './artifactApps'; export function revokeUserKey(name: string): Promise { return request.delete(endpoints.revokeUserKey(name)); @@ -1389,3 +1390,73 @@ export interface ActiveJobsResponse { export const getActiveJobs = (): Promise => { return request.get(endpoints.activeJobs()); }; + +/* Artifact Apps */ + +export function publishArtifactApp( + payload: aa.TPublishArtifactAppRequest, +): Promise { + return request.post(endpoints.artifactApps(), payload); +} + +export function listArtifactApps(): Promise { + return request.get(endpoints.artifactApps()); +} + +export function getArtifactApp( + artifactAppId: string, +): Promise { + return request.get(endpoints.artifactAppById(artifactAppId)); +} + +export function updateArtifactApp( + artifactAppId: string, + payload: aa.TUpdateArtifactAppRequest, +): Promise { + return request.patch(endpoints.artifactAppById(artifactAppId), payload); +} + +export function deleteArtifactApp(artifactAppId: string): Promise<{ success: boolean }> { + return request.delete(endpoints.artifactAppById(artifactAppId)); +} + +export function listArtifactAppVersions( + artifactAppId: string, +): Promise { + return request.get(endpoints.artifactAppVersions(artifactAppId)); +} + +export function getArtifactAppVersion( + artifactAppId: string, + versionId: string, +): Promise { + return request.get(endpoints.artifactAppVersionById(artifactAppId, versionId)); +} + +export function createArtifactAppVersion( + artifactAppId: string, + payload: aa.TCreateArtifactVersionRequest, +): Promise { + return request.post(endpoints.artifactAppVersions(artifactAppId), payload); +} + +export function releaseArtifactAppVersion( + artifactAppId: string, + versionId: string, +): Promise { + return request.post(endpoints.artifactAppVersionRelease(artifactAppId, versionId), {}); +} + +export function activateArtifactAppVersion( + artifactAppId: string, + versionId: string, +): Promise { + return request.post(endpoints.artifactAppVersionActivate(artifactAppId, versionId), {}); +} + +export function withdrawArtifactAppVersion( + artifactAppId: string, + versionId: string, +): Promise { + return request.post(endpoints.artifactAppVersionWithdraw(artifactAppId, versionId), {}); +} diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index 7ecaf4ba8a2..8b41f9c20b7 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -32,6 +32,8 @@ export * from './types/web'; export * from './types/graph'; /* access permissions */ export * from './accessPermissions'; +/* artifact apps */ +export * from './artifactApps'; /* query/mutation keys */ export * from './keys'; /* api call helpers */ diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index 22b9ee959aa..213f3e7e6c3 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -23,6 +23,9 @@ export enum QueryKeys { assistant = 'assistant', agents = 'agents', agent = 'agent', + artifactApps = 'artifactApps', + artifactApp = 'artifactApp', + artifactAppVersions = 'artifactAppVersions', files = 'files', fileConfig = 'fileConfig', tools = 'tools', From 7d5d37503d7c4c7d99ee5ed1bf135ceae7ff78f8 Mon Sep 17 00:00:00 2001 From: dsheine <304794417+dsheine@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:16:03 +0000 Subject: [PATCH 04/15] feat(data-schemas): artifact app persistence layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ArtifactApp and ArtifactVersion schemas, models, and DB methods (PLAN §6.1/§6.2): atomic app+version creation, tenant-scoped queries, version lifecycle (draft/release/activate/withdraw), SHA-256 source hashing, and an idempotent index migration (§6.4). Includes unit tests against an in-memory MongoDB covering CRUD, tenant isolation, snapshot integrity, and immutability. Co-Authored-By: Claude Opus 4.7 --- .../src/methods/artifactApp.spec.ts | 248 +++++++++++++ .../data-schemas/src/methods/artifactApp.ts | 339 ++++++++++++++++++ packages/data-schemas/src/methods/index.ts | 14 +- .../src/migrations/artifactAppIndexes.ts | 58 +++ packages/data-schemas/src/migrations/index.ts | 1 + .../data-schemas/src/models/artifactApp.ts | 13 + .../src/models/artifactVersion.ts | 14 + packages/data-schemas/src/models/index.ts | 6 + .../data-schemas/src/schema/artifactApp.ts | 144 ++++++++ .../src/schema/artifactVersion.ts | 94 +++++ packages/data-schemas/src/schema/index.ts | 2 + .../data-schemas/src/types/artifactApp.ts | 159 ++++++++ packages/data-schemas/src/types/index.ts | 2 + 13 files changed, 1093 insertions(+), 1 deletion(-) create mode 100644 packages/data-schemas/src/methods/artifactApp.spec.ts create mode 100644 packages/data-schemas/src/methods/artifactApp.ts create mode 100644 packages/data-schemas/src/migrations/artifactAppIndexes.ts create mode 100644 packages/data-schemas/src/models/artifactApp.ts create mode 100644 packages/data-schemas/src/models/artifactVersion.ts create mode 100644 packages/data-schemas/src/schema/artifactApp.ts create mode 100644 packages/data-schemas/src/schema/artifactVersion.ts create mode 100644 packages/data-schemas/src/types/artifactApp.ts diff --git a/packages/data-schemas/src/methods/artifactApp.spec.ts b/packages/data-schemas/src/methods/artifactApp.spec.ts new file mode 100644 index 00000000000..10b01ca5241 --- /dev/null +++ b/packages/data-schemas/src/methods/artifactApp.spec.ts @@ -0,0 +1,248 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type { IArtifactApp, IArtifactVersion, CreateArtifactAppInput } from '~/types'; +import { tenantStorage } from '~/config/tenantContext'; +import { createArtifactAppMethods, computeSourceHash, type ArtifactAppMethods } from './artifactApp'; +import { createModels } from '~/models'; + +jest.mock('~/config/winston', () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +})); + +let mongoServer: InstanceType; +let ArtifactApp: mongoose.Model; +let ArtifactVersion: mongoose.Model; +let modelsToCleanup: string[] = []; +let methods: ArtifactAppMethods; + +function baseInput(overrides: Partial = {}): CreateArtifactAppInput { + return { + createdBy: 'user-1', + title: 'My Chart', + visibility: 'private', + version: { + artifactType: 'react', + sourceSnapshot: 'export default () =>
hi
;', + createdBy: 'user-1', + }, + ...overrides, + }; +} + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const models = createModels(mongoose); + modelsToCleanup = Object.keys(models); + ArtifactApp = mongoose.models.ArtifactApp as mongoose.Model; + ArtifactVersion = mongoose.models.ArtifactVersion as mongoose.Model; + methods = createArtifactAppMethods(mongoose); + await mongoose.connect(mongoServer.getUri()); +}, 30000); + +afterAll(async () => { + const collections = mongoose.connection.collections; + for (const key in collections) { + await collections[key].deleteMany({}); + } + for (const modelName of modelsToCleanup) { + if (mongoose.models[modelName]) { + delete (mongoose.models as Record)[modelName]; + } + } + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await ArtifactApp.deleteMany({}); + await ArtifactVersion.deleteMany({}); +}); + +describe('createArtifactAppWithVersion', () => { + test('atomically creates app and version 1 with activeVersionId set', async () => { + const { app, version } = await methods.createArtifactAppWithVersion(baseInput()); + + expect(app.artifactAppId).toMatch(/^app_/); + expect(app.status).toBe('draft'); + expect(app.latestVersionNumber).toBe(1); + expect(version.versionNumber).toBe(1); + expect(version.artifactVersionId).toMatch(/^ver_/); + expect(app.activeVersionId).toBe(version.artifactVersionId); + + const persistedVersions = await ArtifactVersion.find({ artifactAppId: app.artifactAppId }); + expect(persistedVersions).toHaveLength(1); + }); + + test('computes a deterministic SHA-256 integrity hash over the snapshot', async () => { + const input = baseInput(); + const { version } = await methods.createArtifactAppWithVersion(input); + const expected = computeSourceHash( + input.version.artifactType, + input.version.sourceSnapshot, + input.version.runtimeConfig ?? {}, + ); + expect(version.integrity.sourceHash).toBe(expected); + expect(version.integrity.schemaVersion).toBe(1); + }); + + test('snapshot is independent — later app edits do not mutate the version source', async () => { + const { app, version } = await methods.createArtifactAppWithVersion(baseInput()); + const originalSource = version.sourceSnapshot; + + await methods.updateArtifactApp( + { artifactAppId: app.artifactAppId }, + { title: 'Renamed', description: 'new' }, + ); + + const reread = await methods.getArtifactVersion({ + artifactAppId: app.artifactAppId, + versionNumber: 1, + }); + expect(reread?.sourceSnapshot).toBe(originalSource); + }); +}); + +describe('CRUD', () => { + test('get / list / update / delete', async () => { + const { app } = await methods.createArtifactAppWithVersion(baseInput()); + + const fetched = await methods.getArtifactAppByAppId({ artifactAppId: app.artifactAppId }); + expect(fetched?.title).toBe('My Chart'); + + const listed = await methods.listArtifactApps({ createdBy: 'user-1' }); + expect(listed).toHaveLength(1); + + const updated = await methods.updateArtifactApp( + { artifactAppId: app.artifactAppId }, + { title: 'Updated' }, + ); + expect(updated?.title).toBe('Updated'); + + const del = await methods.deleteArtifactApp({ artifactAppId: app.artifactAppId }); + expect(del.deletedApp).toBe(true); + expect(del.deletedVersions).toBe(1); + expect(await methods.getArtifactAppByAppId({ artifactAppId: app.artifactAppId })).toBeNull(); + }); + + test('resolveArtifactAppId returns _id and artifactAppId for ACL checks', async () => { + const { app } = await methods.createArtifactAppWithVersion(baseInput()); + const resolved = await methods.resolveArtifactAppId({ artifactAppId: app.artifactAppId }); + expect(resolved?.artifactAppId).toBe(app.artifactAppId); + expect(resolved?._id).toBeDefined(); + }); +}); + +describe('version lifecycle', () => { + test('createArtifactVersion increments latestVersionNumber and starts as draft', async () => { + const { app } = await methods.createArtifactAppWithVersion(baseInput()); + const v2 = await methods.createArtifactVersion( + { artifactAppId: app.artifactAppId }, + { artifactType: 'react', sourceSnapshot: 'v2', createdBy: 'user-1' }, + ); + expect(v2.versionNumber).toBe(2); + expect(v2.publication.state).toBe('draft'); + + const reread = await methods.getArtifactAppByAppId({ artifactAppId: app.artifactAppId }); + expect(reread?.latestVersionNumber).toBe(2); + }); + + test('release then activate; activate rejects unreleased versions', async () => { + const { app } = await methods.createArtifactAppWithVersion(baseInput()); + const v2 = await methods.createArtifactVersion( + { artifactAppId: app.artifactAppId }, + { artifactType: 'react', sourceSnapshot: 'v2', createdBy: 'user-1' }, + ); + + await expect( + methods.activateArtifactVersion({ + artifactAppId: app.artifactAppId, + versionNumber: 2, + }), + ).rejects.toThrow(/released/); + + const released = await methods.releaseArtifactVersion( + { artifactAppId: app.artifactAppId, versionNumber: 2 }, + 'user-1', + ); + expect(released?.publication.state).toBe('released'); + expect(released?.publication.releasedBy).toBe('user-1'); + + const activated = await methods.activateArtifactVersion({ + artifactAppId: app.artifactAppId, + versionNumber: 2, + }); + expect(activated?.app.activeVersionId).toBe(v2.artifactVersionId); + }); + + test('rollback: activate an older released version', async () => { + const { app, version: v1 } = await methods.createArtifactAppWithVersion(baseInput()); + await methods.releaseArtifactVersion( + { artifactAppId: app.artifactAppId, versionNumber: 1 }, + 'user-1', + ); + const v2 = await methods.createArtifactVersion( + { artifactAppId: app.artifactAppId }, + { artifactType: 'react', sourceSnapshot: 'v2', createdBy: 'user-1' }, + ); + await methods.releaseArtifactVersion( + { artifactAppId: app.artifactAppId, versionNumber: 2 }, + 'user-1', + ); + await methods.activateArtifactVersion({ artifactAppId: app.artifactAppId, versionNumber: 2 }); + + const rolledBack = await methods.activateArtifactVersion({ + artifactAppId: app.artifactAppId, + versionNumber: 1, + }); + expect(rolledBack?.app.activeVersionId).toBe(v1.artifactVersionId); + expect(v2.versionNumber).toBe(2); + }); + + test('released version snapshot/hash are immutable across lifecycle transitions', async () => { + const { app } = await methods.createArtifactAppWithVersion(baseInput()); + const released = await methods.releaseArtifactVersion( + { artifactAppId: app.artifactAppId, versionNumber: 1 }, + 'user-1', + ); + const hashAtRelease = released?.integrity.sourceHash; + + await methods.withdrawArtifactVersion({ artifactAppId: app.artifactAppId, versionNumber: 1 }); + + const reread = await methods.getArtifactVersion({ + artifactAppId: app.artifactAppId, + versionNumber: 1, + }); + expect(reread?.integrity.sourceHash).toBe(hashAtRelease); + expect(reread?.publication.state).toBe('withdrawn'); + }); +}); + +describe('tenant isolation', () => { + test('apps are scoped by tenant; cross-tenant reads return nothing', async () => { + const appA = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + methods.createArtifactAppWithVersion(baseInput({ tenantId: 'tenant-a' })), + ); + await tenantStorage.run({ tenantId: 'tenant-b' }, async () => + methods.createArtifactAppWithVersion(baseInput({ tenantId: 'tenant-b' })), + ); + + const fromB = await tenantStorage.run({ tenantId: 'tenant-b' }, async () => + methods.getArtifactAppByAppId({ artifactAppId: appA.app.artifactAppId }), + ); + expect(fromB).toBeNull(); + + const fromA = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + methods.getArtifactAppByAppId({ artifactAppId: appA.app.artifactAppId }), + ); + expect(fromA?.artifactAppId).toBe(appA.app.artifactAppId); + + const listB = await tenantStorage.run({ tenantId: 'tenant-b' }, async () => + methods.listArtifactApps({}), + ); + expect(listB).toHaveLength(1); + expect(listB[0].tenantId).toBe('tenant-b'); + }); +}); diff --git a/packages/data-schemas/src/methods/artifactApp.ts b/packages/data-schemas/src/methods/artifactApp.ts new file mode 100644 index 00000000000..5830e94c1ce --- /dev/null +++ b/packages/data-schemas/src/methods/artifactApp.ts @@ -0,0 +1,339 @@ +import crypto from 'node:crypto'; +import { nanoid } from 'nanoid'; +import type { ClientSession, Model } from 'mongoose'; +import type { + IArtifactApp, + IArtifactVersion, + ArtifactAppQuery, + ArtifactVersionQuery, + CreateArtifactAppInput, + CreateArtifactVersionInput, + ArtifactAppWithVersion, + ArtifactAppIdResolution, + IArtifactVersionRuntimeConfig, +} from '~/types'; +import { supportsTransactions } from '~/utils/transactions'; + +/** Snapshot schema version — bump when the canonical snapshot shape changes. */ +export const ARTIFACT_SCHEMA_VERSION = 1; + +/** + * Deterministic SHA-256 over the canonical version payload + * ({ artifactType, sourceSnapshot, runtimeConfig }). Keys are sorted so the + * hash is stable regardless of property insertion order. + */ +export function computeSourceHash( + artifactType: string, + sourceSnapshot: string, + runtimeConfig: IArtifactVersionRuntimeConfig = {}, +): string { + const canonical = JSON.stringify({ + artifactType, + sourceSnapshot, + runtimeConfig: canonicalize(runtimeConfig), + }); + return crypto.createHash('sha256').update(canonical).digest('hex'); +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (value && typeof value === 'object') { + const source = value as Record; + return Object.keys(source) + .sort() + .reduce>((acc, key) => { + acc[key] = canonicalize(source[key]); + return acc; + }, {}); + } + return value; +} + +export interface ArtifactAppMethods { + createArtifactAppWithVersion: (input: CreateArtifactAppInput) => Promise; + getArtifactAppByAppId: (query: ArtifactAppQuery) => Promise; + resolveArtifactAppId: (query: ArtifactAppQuery) => Promise; + listArtifactApps: (filter: Partial) => Promise; + updateArtifactApp: ( + query: ArtifactAppQuery, + update: Partial, + ) => Promise; + deleteArtifactApp: ( + query: ArtifactAppQuery, + ) => Promise<{ deletedApp: boolean; deletedVersions: number }>; + getArtifactVersion: (query: ArtifactVersionQuery) => Promise; + listArtifactVersions: (query: ArtifactAppQuery) => Promise; + createArtifactVersion: ( + query: ArtifactAppQuery, + input: CreateArtifactVersionInput, + ) => Promise; + releaseArtifactVersion: ( + query: ArtifactVersionQuery, + releasedBy: string, + ) => Promise; + activateArtifactVersion: ( + query: ArtifactVersionQuery, + ) => Promise; + withdrawArtifactVersion: (query: ArtifactVersionQuery) => Promise; +} + +function buildVersionFilter(query: ArtifactVersionQuery): Record { + const filter: Record = { artifactAppId: query.artifactAppId }; + if (query.artifactVersionId) { + filter.artifactVersionId = query.artifactVersionId; + } + if (typeof query.versionNumber === 'number') { + filter.versionNumber = query.versionNumber; + } + return filter; +} + +export function createArtifactAppMethods( + mongoose: typeof import('mongoose'), +): ArtifactAppMethods { + const getApp = () => mongoose.models.ArtifactApp as Model; + const getVersion = () => mongoose.models.ArtifactVersion as Model; + + function buildVersionDoc( + artifactAppId: string, + tenantId: string | undefined, + versionNumber: number, + input: CreateArtifactVersionInput, + state: 'draft' | 'released', + ): Partial { + const runtimeConfig = input.runtimeConfig ?? {}; + return { + artifactVersionId: `ver_${nanoid()}`, + artifactAppId, + tenantId, + versionNumber, + versionLabel: input.versionLabel, + changelog: input.changelog, + artifactType: input.artifactType, + sourceSnapshot: input.sourceSnapshot, + runtimeConfig, + integrity: { + sourceHash: computeSourceHash(input.artifactType, input.sourceSnapshot, runtimeConfig), + schemaVersion: ARTIFACT_SCHEMA_VERSION, + }, + createdBy: input.createdBy, + publication: { + state, + ...(state === 'released' + ? { releasedBy: input.createdBy, releasedAt: new Date() } + : {}), + }, + } as Partial; + } + + async function createArtifactAppWithVersion( + input: CreateArtifactAppInput, + ): Promise { + const ArtifactApp = getApp(); + const ArtifactVersion = getVersion(); + const artifactAppId = `app_${nanoid()}`; + + const appDoc: Partial = { + artifactAppId, + tenantId: input.tenantId, + title: input.title, + description: input.description, + icon: input.icon, + category: input.category, + tags: input.tags, + createdBy: input.createdBy, + latestVersionNumber: 1, + status: 'draft', + visibility: input.visibility, + allowEmbed: input.allowEmbed ?? false, + allowFork: input.allowFork ?? false, + allowAnonymousView: input.allowAnonymousView ?? false, + toolPolicy: input.toolPolicy as IArtifactApp['toolPolicy'], + marketplace: input.marketplace as IArtifactApp['marketplace'], + sourceMetadata: input.sourceMetadata, + }; + + const versionSeed = buildVersionDoc( + artifactAppId, + input.tenantId, + 1, + input.version, + 'draft', + ); + + const useTransaction = await supportsTransactions(mongoose); + + if (!useTransaction) { + const [app] = await ArtifactApp.create([appDoc]); + const [version] = await ArtifactVersion.create([versionSeed]); + app.activeVersionId = version.artifactVersionId; + await app.save(); + return { app, version }; + } + + const session: ClientSession = await mongoose.startSession(); + try { + let result: ArtifactAppWithVersion | undefined; + await session.withTransaction(async () => { + const [app] = await ArtifactApp.create([appDoc], { session }); + const [version] = await ArtifactVersion.create([versionSeed], { session }); + app.activeVersionId = version.artifactVersionId; + await app.save({ session }); + result = { app, version }; + }); + if (!result) { + throw new Error('[createArtifactAppWithVersion] Transaction produced no result'); + } + return result; + } finally { + await session.endSession(); + } + } + + async function getArtifactAppByAppId(query: ArtifactAppQuery): Promise { + return getApp().findOne({ artifactAppId: query.artifactAppId }).lean().exec(); + } + + async function resolveArtifactAppId( + query: ArtifactAppQuery, + ): Promise { + const doc = await getApp() + .findOne({ artifactAppId: query.artifactAppId }) + .select({ _id: 1, artifactAppId: 1 }) + .lean() + .exec(); + return doc; + } + + async function listArtifactApps(filter: Partial): Promise { + return getApp().find(filter).sort({ updatedAt: -1 }).lean().exec(); + } + + async function updateArtifactApp( + query: ArtifactAppQuery, + update: Partial, + ): Promise { + return getApp() + .findOneAndUpdate({ artifactAppId: query.artifactAppId }, { $set: update }, { new: true }) + .lean() + .exec(); + } + + async function deleteArtifactApp( + query: ArtifactAppQuery, + ): Promise<{ deletedApp: boolean; deletedVersions: number }> { + const app = await getApp().findOneAndDelete({ artifactAppId: query.artifactAppId }).exec(); + if (!app) { + return { deletedApp: false, deletedVersions: 0 }; + } + const { deletedCount } = await getVersion() + .deleteMany({ artifactAppId: query.artifactAppId }) + .exec(); + return { deletedApp: true, deletedVersions: deletedCount ?? 0 }; + } + + async function getArtifactVersion( + query: ArtifactVersionQuery, + ): Promise { + return getVersion().findOne(buildVersionFilter(query)).lean().exec(); + } + + async function listArtifactVersions(query: ArtifactAppQuery): Promise { + return getVersion() + .find({ artifactAppId: query.artifactAppId }) + .sort({ versionNumber: -1 }) + .lean() + .exec(); + } + + async function createArtifactVersion( + query: ArtifactAppQuery, + input: CreateArtifactVersionInput, + ): Promise { + const ArtifactApp = getApp(); + const app = await ArtifactApp.findOne({ artifactAppId: query.artifactAppId }).exec(); + if (!app) { + throw new Error('[createArtifactVersion] Artifact app not found'); + } + const nextNumber = app.latestVersionNumber + 1; + const versionSeed = buildVersionDoc( + app.artifactAppId, + app.tenantId, + nextNumber, + input, + 'draft', + ); + const [version] = await getVersion().create([versionSeed]); + app.latestVersionNumber = nextNumber; + await app.save(); + return version.toObject() as IArtifactVersion; + } + + async function releaseArtifactVersion( + query: ArtifactVersionQuery, + releasedBy: string, + ): Promise { + const version = await getVersion().findOne(buildVersionFilter(query)).exec(); + if (!version) { + return null; + } + if (version.publication.state === 'released') { + return version.toObject() as IArtifactVersion; + } + version.publication = { + state: 'released', + releasedBy, + releasedAt: new Date(), + }; + await version.save(); + return version.toObject() as IArtifactVersion; + } + + async function activateArtifactVersion( + query: ArtifactVersionQuery, + ): Promise { + const version = await getVersion().findOne(buildVersionFilter(query)).exec(); + if (!version) { + return null; + } + if (version.publication.state !== 'released') { + throw new Error('[activateArtifactVersion] Only released versions can be activated'); + } + const app = await getApp().findOne({ artifactAppId: query.artifactAppId }).exec(); + if (!app) { + return null; + } + app.activeVersionId = version.artifactVersionId; + await app.save(); + return { app: app.toObject() as IArtifactApp, version: version.toObject() as IArtifactVersion }; + } + + async function withdrawArtifactVersion( + query: ArtifactVersionQuery, + ): Promise { + const version = await getVersion().findOne(buildVersionFilter(query)).exec(); + if (!version) { + return null; + } + version.publication = { ...version.publication, state: 'withdrawn' }; + await version.save(); + return version.toObject() as IArtifactVersion; + } + + return { + createArtifactAppWithVersion, + getArtifactAppByAppId, + resolveArtifactAppId, + listArtifactApps, + updateArtifactApp, + deleteArtifactApp, + getArtifactVersion, + listArtifactVersions, + createArtifactVersion, + releaseArtifactVersion, + activateArtifactVersion, + withdrawArtifactVersion, + }; +} diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index c3c02492b4f..a5712b46e89 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -101,6 +101,13 @@ import type { import { createAgentMethods, type AgentMethods, type AgentDeps } from './agent'; /* Config */ import { createConfigMethods, type ConfigMethods } from './config'; +/* Artifact Apps */ +import { + createArtifactAppMethods, + computeSourceHash, + ARTIFACT_SCHEMA_VERSION, + type ArtifactAppMethods, +} from './artifactApp'; export { RoleConflictError, DEFAULT_REFRESH_TOKEN_EXPIRY, DEFAULT_SESSION_EXPIRY }; export { tokenValues, cacheTokenValues, premiumTokenValues, defaultRate, createTxMethods }; @@ -117,6 +124,7 @@ export { }; export { AUDIT_SCHEMA_VERSION, MAX_AUDIT_EXPORT_ROWS, MAX_AUDIT_LOG_LIMIT, MAX_AUDIT_VERIFY_ROWS }; export { MAX_TOOL_FAVORITES }; +export { computeSourceHash, ARTIFACT_SCHEMA_VERSION }; export type AllMethods = UserMethods & SessionMethods & @@ -153,7 +161,8 @@ export type AllMethods = UserMethods & SkillMethods & SkillSyncMethods & AgentMethods & - ConfigMethods; + ConfigMethods & + ArtifactAppMethods; /** Dependencies injected from the api layer into createMethods */ export interface CreateMethodsDeps { @@ -288,6 +297,8 @@ export function createMethods( ...agentMethods, /* Config */ ...createConfigMethods(mongoose), + /* Artifact Apps */ + ...createArtifactAppMethods(mongoose), }; } @@ -341,4 +352,5 @@ export type { SkillSyncMethods, AgentMethods, ConfigMethods, + ArtifactAppMethods, }; diff --git a/packages/data-schemas/src/migrations/artifactAppIndexes.ts b/packages/data-schemas/src/migrations/artifactAppIndexes.ts new file mode 100644 index 00000000000..20ebfbe9cdc --- /dev/null +++ b/packages/data-schemas/src/migrations/artifactAppIndexes.ts @@ -0,0 +1,58 @@ +import type { Connection } from 'mongoose'; +import logger from '~/config/winston'; + +type IndexSpec = Record; +type IndexOptions = { unique?: boolean }; + +interface IndexDefinition { + spec: IndexSpec; + options: IndexOptions; +} + +/** + * Compound tenant-scoped indexes for the artifact app collections (PLAN §6.4). + * Mongoose auto-creates these from the schema on model init, but a fresh + * deployment or a restore-from-dump may not have run model init yet. This + * migration ensures the indexes exist. It is idempotent — createIndex is a + * no-op when the index already exists with the same definition. + */ +const COLLECTION_INDEXES: Record = { + artifactapps: [ + { spec: { tenantId: 1, artifactAppId: 1 }, options: { unique: true } }, + { spec: { tenantId: 1, status: 1, visibility: 1 }, options: {} }, + { spec: { tenantId: 1, 'marketplace.listed': 1, 'marketplace.featured': 1 }, options: {} }, + { spec: { tenantId: 1, createdBy: 1 }, options: {} }, + { spec: { tenantId: 1, activeVersionId: 1 }, options: {} }, + ], + artifactversions: [ + { spec: { tenantId: 1, artifactAppId: 1, versionNumber: 1 }, options: { unique: true } }, + { spec: { tenantId: 1, artifactVersionId: 1 }, options: { unique: true } }, + ], +}; + +export async function ensureArtifactAppIndexes( + connection: Connection, +): Promise<{ created: string[]; errors: string[] }> { + const result = { created: [] as string[], errors: [] as string[] }; + + for (const [collectionName, indexes] of Object.entries(COLLECTION_INDEXES)) { + const collection = connection.db!.collection(collectionName); + for (const { spec, options } of indexes) { + try { + const name = await collection.createIndex(spec, options); + result.created.push(`${collectionName}.${name}`); + } catch (err) { + const msg = `${collectionName}.${JSON.stringify(spec)}: ${(err as Error).message}`; + result.errors.push(msg); + logger.error(`[ArtifactAppMigration] Failed to create index: ${msg}`); + } + } + } + + logger.info( + `[ArtifactAppMigration] Ensured artifact app indexes. Created/confirmed ${result.created.length}.`, + ); + return result; +} + +export { COLLECTION_INDEXES }; diff --git a/packages/data-schemas/src/migrations/index.ts b/packages/data-schemas/src/migrations/index.ts index eda2293cb03..d9bfcd15de5 100644 --- a/packages/data-schemas/src/migrations/index.ts +++ b/packages/data-schemas/src/migrations/index.ts @@ -1,2 +1,3 @@ export { dropSupersededTenantIndexes } from './tenantIndexes'; export { dropSupersededPromptGroupIndexes } from './promptGroupIndexes'; +export { ensureArtifactAppIndexes } from './artifactAppIndexes'; diff --git a/packages/data-schemas/src/models/artifactApp.ts b/packages/data-schemas/src/models/artifactApp.ts new file mode 100644 index 00000000000..8d62d52c7d7 --- /dev/null +++ b/packages/data-schemas/src/models/artifactApp.ts @@ -0,0 +1,13 @@ +import { Model } from 'mongoose'; +import type { IArtifactApp } from '~/types'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; +import artifactAppSchema from '~/schema/artifactApp'; + +export function createArtifactAppModel( + mongoose: typeof import('mongoose'), +): Model { + applyTenantIsolation(artifactAppSchema); + return ( + mongoose.models.ArtifactApp || mongoose.model('ArtifactApp', artifactAppSchema) + ); +} diff --git a/packages/data-schemas/src/models/artifactVersion.ts b/packages/data-schemas/src/models/artifactVersion.ts new file mode 100644 index 00000000000..311517d05bf --- /dev/null +++ b/packages/data-schemas/src/models/artifactVersion.ts @@ -0,0 +1,14 @@ +import { Model } from 'mongoose'; +import type { IArtifactVersion } from '~/types'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; +import artifactVersionSchema from '~/schema/artifactVersion'; + +export function createArtifactVersionModel( + mongoose: typeof import('mongoose'), +): Model { + applyTenantIsolation(artifactVersionSchema); + return ( + mongoose.models.ArtifactVersion || + mongoose.model('ArtifactVersion', artifactVersionSchema) + ); +} diff --git a/packages/data-schemas/src/models/index.ts b/packages/data-schemas/src/models/index.ts index 440106f2e08..128b8e63be2 100644 --- a/packages/data-schemas/src/models/index.ts +++ b/packages/data-schemas/src/models/index.ts @@ -27,6 +27,8 @@ import { createPresetModel } from './preset'; import { createPromptModel } from './prompt'; import { createMemoryModel } from './memory'; import { createConfigModel } from './config'; +import { createArtifactAppModel } from './artifactApp'; +import { createArtifactVersionModel } from './artifactVersion'; import { createTokenModel } from './token'; import { createAgentModel } from './agent'; import { createSkillModel } from './skill'; @@ -77,6 +79,8 @@ export function createModels(mongoose: typeof import('mongoose')): { AuditLog: ReturnType; Group: ReturnType; Config: ReturnType; + ArtifactApp: ReturnType; + ArtifactVersion: ReturnType; } { return { User: createUserModel(mongoose), @@ -116,5 +120,7 @@ export function createModels(mongoose: typeof import('mongoose')): { AuditLog: createAuditLogModel(mongoose), Group: createGroupModel(mongoose), Config: createConfigModel(mongoose), + ArtifactApp: createArtifactAppModel(mongoose), + ArtifactVersion: createArtifactVersionModel(mongoose), }; } diff --git a/packages/data-schemas/src/schema/artifactApp.ts b/packages/data-schemas/src/schema/artifactApp.ts new file mode 100644 index 00000000000..6bd5255a8fd --- /dev/null +++ b/packages/data-schemas/src/schema/artifactApp.ts @@ -0,0 +1,144 @@ +import { Schema } from 'mongoose'; +import type { IArtifactApp } from '~/types'; + +const toolPolicySchema = new Schema( + { + enabled: { type: Boolean, default: false }, + allowedServers: { type: [String], default: [] }, + allowedTools: { type: [String], default: [] }, + requireConfirmationForWrites: { type: Boolean, default: true }, + }, + { _id: false }, +); + +const marketplaceSchema = new Schema( + { + listed: { type: Boolean, default: false }, + featured: { type: Boolean, default: false }, + summary: { type: String }, + riskClass: { + type: String, + enum: ['none', 'read', 'write', 'external'], + default: 'none', + }, + costClass: { + type: String, + enum: ['free', 'low', 'medium', 'high'], + default: 'free', + }, + }, + { _id: false }, +); + +const sourceMetadataSchema = new Schema( + { + conversationId: { type: String }, + messageId: { type: String }, + originalArtifactId: { type: String }, + }, + { _id: false }, +); + +const reviewSchema = new Schema( + { + submittedAt: { type: Date }, + submittedBy: { type: String }, + reviewedAt: { type: Date }, + reviewedBy: { type: String }, + result: { type: String, enum: ['approved', 'rejected'] }, + comment: { type: String }, + }, + { _id: false }, +); + +const artifactAppSchema: Schema = new Schema( + { + artifactAppId: { + type: String, + required: true, + }, + tenantId: { + type: String, + }, + title: { + type: String, + required: true, + }, + description: { + type: String, + }, + icon: { + type: String, + }, + category: { + type: String, + }, + tags: { + type: [String], + default: undefined, + }, + createdBy: { + type: String, + required: true, + }, + activeVersionId: { + type: String, + }, + latestVersionNumber: { + type: Number, + default: 0, + }, + status: { + type: String, + enum: ['draft', 'pending_review', 'published', 'suspended', 'archived'], + default: 'draft', + }, + visibility: { + type: String, + enum: ['private', 'restricted', 'tenant', 'public'], + default: 'private', + }, + allowEmbed: { + type: Boolean, + default: false, + }, + allowFork: { + type: Boolean, + default: false, + }, + allowAnonymousView: { + type: Boolean, + default: false, + }, + toolPolicy: { + type: toolPolicySchema, + default: () => ({}), + }, + marketplace: { + type: marketplaceSchema, + default: () => ({}), + }, + sourceMetadata: { + type: sourceMetadataSchema, + default: undefined, + }, + review: { + type: reviewSchema, + default: undefined, + }, + archivedAt: { + type: Date, + }, + }, + { + timestamps: true, + }, +); + +artifactAppSchema.index({ tenantId: 1, artifactAppId: 1 }, { unique: true }); +artifactAppSchema.index({ tenantId: 1, status: 1, visibility: 1 }); +artifactAppSchema.index({ tenantId: 1, 'marketplace.listed': 1, 'marketplace.featured': 1 }); +artifactAppSchema.index({ tenantId: 1, createdBy: 1 }); +artifactAppSchema.index({ tenantId: 1, activeVersionId: 1 }); + +export default artifactAppSchema; diff --git a/packages/data-schemas/src/schema/artifactVersion.ts b/packages/data-schemas/src/schema/artifactVersion.ts new file mode 100644 index 00000000000..6ab442453d7 --- /dev/null +++ b/packages/data-schemas/src/schema/artifactVersion.ts @@ -0,0 +1,94 @@ +import { Schema } from 'mongoose'; +import type { IArtifactVersion } from '~/types'; + +const runtimeConfigSchema = new Schema( + { + dependencies: { type: Schema.Types.Mixed, default: undefined }, + entryPoint: { type: String }, + renderMode: { type: String }, + }, + { _id: false }, +); + +const integritySchema = new Schema( + { + sourceHash: { type: String, required: true }, + schemaVersion: { type: Number, required: true }, + }, + { _id: false }, +); + +const publicationSchema = new Schema( + { + state: { + type: String, + enum: ['draft', 'released', 'withdrawn'], + default: 'draft', + }, + releasedBy: { type: String }, + releasedAt: { type: Date }, + }, + { _id: false }, +); + +const artifactVersionSchema: Schema = new Schema( + { + artifactVersionId: { + type: String, + required: true, + }, + artifactAppId: { + type: String, + required: true, + }, + tenantId: { + type: String, + }, + versionNumber: { + type: Number, + required: true, + }, + versionLabel: { + type: String, + }, + changelog: { + type: String, + }, + artifactType: { + type: String, + enum: ['react', 'html', 'mermaid'], + required: true, + }, + sourceSnapshot: { + type: String, + required: true, + }, + runtimeConfig: { + type: runtimeConfigSchema, + default: () => ({}), + }, + integrity: { + type: integritySchema, + required: true, + }, + createdBy: { + type: String, + required: true, + }, + publication: { + type: publicationSchema, + default: () => ({}), + }, + }, + { + timestamps: { createdAt: true, updatedAt: false }, + }, +); + +artifactVersionSchema.index( + { tenantId: 1, artifactAppId: 1, versionNumber: 1 }, + { unique: true }, +); +artifactVersionSchema.index({ tenantId: 1, artifactVersionId: 1 }, { unique: true }); + +export default artifactVersionSchema; diff --git a/packages/data-schemas/src/schema/index.ts b/packages/data-schemas/src/schema/index.ts index e6ac941d009..3ce59feac51 100644 --- a/packages/data-schemas/src/schema/index.ts +++ b/packages/data-schemas/src/schema/index.ts @@ -32,3 +32,5 @@ export { default as groupSchema } from './group'; export { default as systemGrantSchema } from './systemGrant'; export { default as auditLogSchema } from './auditLog'; export { default as configSchema } from './config'; +export { default as artifactAppSchema } from './artifactApp'; +export { default as artifactVersionSchema } from './artifactVersion'; diff --git a/packages/data-schemas/src/types/artifactApp.ts b/packages/data-schemas/src/types/artifactApp.ts new file mode 100644 index 00000000000..668215b8dc8 --- /dev/null +++ b/packages/data-schemas/src/types/artifactApp.ts @@ -0,0 +1,159 @@ +import type { Document, Types } from 'mongoose'; +import type { + ArtifactAppStatus, + ArtifactAppVisibility, + ArtifactRuntimeType, + ArtifactVersionState, + ArtifactRiskClass, + ArtifactCostClass, +} from 'librechat-data-provider'; + +export interface IArtifactAppToolPolicy { + enabled: boolean; + allowedServers: string[]; + allowedTools: string[]; + requireConfirmationForWrites: boolean; +} + +export interface IArtifactAppMarketplace { + listed: boolean; + featured: boolean; + summary?: string; + riskClass: ArtifactRiskClass; + costClass: ArtifactCostClass; +} + +export interface IArtifactAppSourceMetadata { + conversationId?: string; + messageId?: string; + originalArtifactId?: string; +} + +export interface IArtifactAppReview { + submittedAt?: Date; + submittedBy?: string; + reviewedAt?: Date; + reviewedBy?: string; + result?: 'approved' | 'rejected'; + comment?: string; +} + +export interface IArtifactApp extends Document { + artifactAppId: string; + tenantId?: string; + + title: string; + description?: string; + icon?: string; + category?: string; + tags?: string[]; + + createdBy: string; + activeVersionId?: string; + latestVersionNumber: number; + + status: ArtifactAppStatus; + visibility: ArtifactAppVisibility; + + allowEmbed: boolean; + allowFork: boolean; + allowAnonymousView: boolean; + + toolPolicy: IArtifactAppToolPolicy; + marketplace: IArtifactAppMarketplace; + sourceMetadata?: IArtifactAppSourceMetadata; + review?: IArtifactAppReview; + + createdAt: Date; + updatedAt: Date; + archivedAt?: Date; +} + +export interface IArtifactVersionRuntimeConfig { + dependencies?: Record; + entryPoint?: string; + renderMode?: string; +} + +export interface IArtifactVersionIntegrity { + sourceHash: string; + schemaVersion: number; +} + +export interface IArtifactVersionPublication { + state: ArtifactVersionState; + releasedBy?: string; + releasedAt?: Date; +} + +export interface IArtifactVersion extends Document { + artifactVersionId: string; + artifactAppId: string; + tenantId?: string; + + versionNumber: number; + versionLabel?: string; + changelog?: string; + + artifactType: ArtifactRuntimeType; + sourceSnapshot: string; + + runtimeConfig: IArtifactVersionRuntimeConfig; + integrity: IArtifactVersionIntegrity; + + createdBy: string; + createdAt: Date; + + publication: IArtifactVersionPublication; +} + +/** Version-1 create input threaded through the atomic publish transaction. */ +export interface CreateArtifactAppInput { + tenantId?: string; + createdBy: string; + title: string; + description?: string; + icon?: string; + category?: string; + tags?: string[]; + visibility: ArtifactAppVisibility; + allowEmbed?: boolean; + allowFork?: boolean; + allowAnonymousView?: boolean; + toolPolicy?: Partial; + marketplace?: Partial; + sourceMetadata?: IArtifactAppSourceMetadata; + version: CreateArtifactVersionInput; +} + +export interface CreateArtifactVersionInput { + artifactType: ArtifactRuntimeType; + sourceSnapshot: string; + runtimeConfig?: IArtifactVersionRuntimeConfig; + versionLabel?: string; + changelog?: string; + createdBy: string; +} + +export interface ArtifactAppWithVersion { + app: IArtifactApp; + version: IArtifactVersion; +} + +export type ArtifactAppQuery = { + artifactAppId: string; + tenantId?: string; +}; + +export type ArtifactVersionQuery = { + artifactAppId: string; + artifactVersionId?: string; + versionNumber?: number; + tenantId?: string; +}; + +/** Sole-argument object used to resolve a resource `_id` for ACL checks. */ +export type ArtifactAppIdResolution = { + _id: Types.ObjectId; + artifactAppId: string; +}; diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 878c093a98a..1447ddea254 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -43,3 +43,5 @@ export * from './admin'; export * from './web'; /* MCP Servers */ export * from './mcp'; +/* Artifact Apps */ +export * from './artifactApp'; From b080de358fe8bb706876a7d76814064a51b54750 Mon Sep 17 00:00:00 2001 From: dsheine <304794417+dsheine@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:16:10 +0000 Subject: [PATCH 05/15] feat(data-schemas): artifact app ACL roles, capabilities, and audit actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires ARTIFACT_APP into the existing ACL system (PLAN §7): seeds Owner/Editor/Viewer access roles, adds READ/MANAGE_ARTIFACT_APPS capabilities with ResourceCapabilityMap bypass, and registers artifact audit actions. Makes the share middleware permission map partial so capability-governed resources need no PermissionTypes entry. Updates role/capability tests. Co-Authored-By: Claude Opus 4.7 --- packages/api/src/middleware/share.ts | 2 +- .../data-schemas/src/admin/capabilities.ts | 6 +++ .../src/methods/accessRole.spec.ts | 15 +++++++ .../data-schemas/src/methods/accessRole.ts | 21 ++++++++++ packages/data-schemas/src/types/admin.ts | 40 ++++++++++++++++++- 5 files changed, 82 insertions(+), 2 deletions(-) diff --git a/packages/api/src/middleware/share.ts b/packages/api/src/middleware/share.ts index 2d265308b64..41577024f77 100644 --- a/packages/api/src/middleware/share.ts +++ b/packages/api/src/middleware/share.ts @@ -42,7 +42,7 @@ type ShareMiddleware = ( next: NextFunction, ) => Promise; -const resourceToPermissionType: Record = { +const resourceToPermissionType: Partial> = { [ResourceType.AGENT]: PermissionTypes.AGENTS, [ResourceType.PROMPTGROUP]: PermissionTypes.PROMPTS, [ResourceType.MCPSERVER]: PermissionTypes.MCP_SERVERS, diff --git a/packages/data-schemas/src/admin/capabilities.ts b/packages/data-schemas/src/admin/capabilities.ts index 33a6c2ecf91..64e4cd75f7d 100644 --- a/packages/data-schemas/src/admin/capabilities.ts +++ b/packages/data-schemas/src/admin/capabilities.ts @@ -39,6 +39,8 @@ export const SystemCapabilities = { MANAGE_SKILLS: 'manage:skills', READ_SHARED_LINKS: 'read:sharedlinks', MANAGE_SHARED_LINKS: 'manage:sharedlinks', + READ_ARTIFACT_APPS: 'read:artifactapps', + MANAGE_ARTIFACT_APPS: 'manage:artifactapps', /** Reserved — not yet enforced by any middleware. */ READ_ASSISTANTS: 'read:assistants', MANAGE_ASSISTANTS: 'manage:assistants', @@ -65,6 +67,7 @@ export const CapabilityImplications: Partial = { [ResourceType.REMOTE_AGENT]: SystemCapabilities.MANAGE_AGENTS, [ResourceType.SKILL]: SystemCapabilities.MANAGE_SKILLS, [ResourceType.SHARED_LINK]: SystemCapabilities.MANAGE_SHARED_LINKS, + [ResourceType.ARTIFACT_APP]: SystemCapabilities.MANAGE_ARTIFACT_APPS, }; /** @@ -225,6 +229,8 @@ export const CAPABILITY_CATEGORIES: CapabilityCategory[] = [ SystemCapabilities.MANAGE_MCP_SERVERS, SystemCapabilities.MANAGE_SHARED_LINKS, SystemCapabilities.READ_SHARED_LINKS, + SystemCapabilities.MANAGE_ARTIFACT_APPS, + SystemCapabilities.READ_ARTIFACT_APPS, ], }, { diff --git a/packages/data-schemas/src/methods/accessRole.spec.ts b/packages/data-schemas/src/methods/accessRole.spec.ts index ec9e97ed9bb..1a94eb5c842 100644 --- a/packages/data-schemas/src/methods/accessRole.spec.ts +++ b/packages/data-schemas/src/methods/accessRole.spec.ts @@ -211,6 +211,9 @@ describe('AccessRole Model Tests', () => { AccessRoleIds.SKILL_EDITOR, AccessRoleIds.SKILL_OWNER, AccessRoleIds.SKILL_VIEWER, + AccessRoleIds.ARTIFACT_APP_EDITOR, + AccessRoleIds.ARTIFACT_APP_OWNER, + AccessRoleIds.ARTIFACT_APP_VIEWER, ].sort(), ); @@ -226,6 +229,18 @@ describe('AccessRole Model Tests', () => { const agentOwnerRole = await methods.findRoleByIdentifier(AccessRoleIds.AGENT_OWNER); expect(agentOwnerRole).toBeDefined(); expect(agentOwnerRole?.permBits).toBe(RoleBits.OWNER); + + const artifactViewer = await methods.findRoleByIdentifier(AccessRoleIds.ARTIFACT_APP_VIEWER); + expect(artifactViewer?.resourceType).toBe(ResourceType.ARTIFACT_APP); + expect(artifactViewer?.permBits).toBe(RoleBits.VIEWER); + + const artifactEditor = await methods.findRoleByIdentifier(AccessRoleIds.ARTIFACT_APP_EDITOR); + expect(artifactEditor?.resourceType).toBe(ResourceType.ARTIFACT_APP); + expect(artifactEditor?.permBits).toBe(RoleBits.EDITOR); + + const artifactOwner = await methods.findRoleByIdentifier(AccessRoleIds.ARTIFACT_APP_OWNER); + expect(artifactOwner?.resourceType).toBe(ResourceType.ARTIFACT_APP); + expect(artifactOwner?.permBits).toBe(RoleBits.OWNER); }); test('should not modify existing roles when seeding', async () => { diff --git a/packages/data-schemas/src/methods/accessRole.ts b/packages/data-schemas/src/methods/accessRole.ts index 63a49f3dba9..c28091d876c 100644 --- a/packages/data-schemas/src/methods/accessRole.ts +++ b/packages/data-schemas/src/methods/accessRole.ts @@ -243,6 +243,27 @@ export function createAccessRoleMethods(mongoose: typeof import('mongoose')): { resourceType: ResourceType.SHARED_LINK, permBits: RoleBits.OWNER, }, + { + accessRoleId: AccessRoleIds.ARTIFACT_APP_VIEWER, + name: 'com_ui_role_viewer', + description: 'com_ui_role_viewer_desc', + resourceType: ResourceType.ARTIFACT_APP, + permBits: RoleBits.VIEWER, + }, + { + accessRoleId: AccessRoleIds.ARTIFACT_APP_EDITOR, + name: 'com_ui_role_editor', + description: 'com_ui_role_editor_desc', + resourceType: ResourceType.ARTIFACT_APP, + permBits: RoleBits.EDITOR, + }, + { + accessRoleId: AccessRoleIds.ARTIFACT_APP_OWNER, + name: 'com_ui_role_owner', + description: 'com_ui_role_owner_desc', + resourceType: ResourceType.ARTIFACT_APP, + permBits: RoleBits.OWNER, + }, ]; const result: Record = {}; diff --git a/packages/data-schemas/src/types/admin.ts b/packages/data-schemas/src/types/admin.ts index 0fcbf450e72..0880bff6787 100644 --- a/packages/data-schemas/src/types/admin.ts +++ b/packages/data-schemas/src/types/admin.ts @@ -82,6 +82,7 @@ export const AUDIT_CATEGORIES = [ 'permission', 'auth', 'approval', + 'artifact', ] as const; export type AuditCategory = (typeof AUDIT_CATEGORIES)[number]; @@ -91,13 +92,50 @@ export type AuditCategory = (typeof AUDIT_CATEGORIES)[number]; * action maps unambiguously to a category. The Mongoose schema enum and the * HTTP handler's whitelist both consume this constant so they cannot drift. */ -export const AUDIT_ACTIONS = ['grant.assigned', 'grant.removed'] as const; +export const AUDIT_ACTIONS = [ + 'grant.assigned', + 'grant.removed', + 'artifact_app.created', + 'artifact_app.updated', + 'artifact_app.submitted', + 'artifact_app.approved', + 'artifact_app.rejected', + 'artifact_app.published', + 'artifact_app.suspended', + 'artifact_app.archived', + 'artifact_app.forked', + 'artifact_app.embedded', + 'artifact_version.created', + 'artifact_version.released', + 'artifact_version.activated', + 'artifact_version.withdrawn', + 'artifact_acl.granted', + 'artifact_acl.updated', + 'artifact_acl.revoked', +] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; /** Maps each action to its category so writers never pass both. */ export const AUDIT_ACTION_CATEGORY: Record = { 'grant.assigned': 'grant', 'grant.removed': 'grant', + 'artifact_app.created': 'artifact', + 'artifact_app.updated': 'artifact', + 'artifact_app.submitted': 'artifact', + 'artifact_app.approved': 'artifact', + 'artifact_app.rejected': 'artifact', + 'artifact_app.published': 'artifact', + 'artifact_app.suspended': 'artifact', + 'artifact_app.archived': 'artifact', + 'artifact_app.forked': 'artifact', + 'artifact_app.embedded': 'artifact', + 'artifact_version.created': 'artifact', + 'artifact_version.released': 'artifact', + 'artifact_version.activated': 'artifact', + 'artifact_version.withdrawn': 'artifact', + 'artifact_acl.granted': 'artifact', + 'artifact_acl.updated': 'artifact', + 'artifact_acl.revoked': 'artifact', }; /** Result of the audited operation. Kept first-class instead of being encoded From 62ec3ae0055a61f93cc54585704dbfce0b9c4afb Mon Sep 17 00:00:00 2001 From: dsheine <304794417+dsheine@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:32:02 +0000 Subject: [PATCH 06/15] feat(api): artifact app publishing and versioning HTTP handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the /api/artifact-apps surface (WP3/WP4): typed publishing, CRUD, and version-lifecycle (create/release/activate/withdraw) handlers in packages/api, backed by the existing ACL system and audit log. Actor identity and tenant are derived strictly from the authenticated session per §8.7. Full-source snapshots are immutable once released. Includes mongodb-memory-server integration tests. Co-Authored-By: Claude Opus 4.7 --- api/server/index.js | 1 + .../canAccessArtifactAppResource.js | 34 + .../middleware/accessResources/index.js | 2 + api/server/routes/artifactApps.js | 102 +++ api/server/routes/index.js | 2 + .../artifactApps/__tests__/handlers.spec.ts | 274 ++++++++ packages/api/src/artifactApps/handlers.ts | 587 ++++++++++++++++++ packages/api/src/artifactApps/index.ts | 2 + packages/api/src/index.ts | 2 + packages/data-schemas/src/index.ts | 3 + .../data-schemas/src/methods/artifactApp.ts | 6 +- packages/data-schemas/src/methods/index.ts | 2 +- 12 files changed, 1013 insertions(+), 4 deletions(-) create mode 100644 api/server/middleware/accessResources/canAccessArtifactAppResource.js create mode 100644 api/server/routes/artifactApps.js create mode 100644 packages/api/src/artifactApps/__tests__/handlers.spec.ts create mode 100644 packages/api/src/artifactApps/handlers.ts create mode 100644 packages/api/src/artifactApps/index.ts diff --git a/api/server/index.js b/api/server/index.js index f88b2fd2581..87f144962fb 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -280,6 +280,7 @@ const startServer = async () => { app.use('/api/projects', routes.projects); app.use('/api/prompts', routes.prompts); app.use('/api/skills', routes.skills); + app.use('/api/artifact-apps', routes.artifactApps); app.use('/api/categories', routes.categories); app.use('/api/endpoints', routes.endpoints); app.use('/api/balance', routes.balance); diff --git a/api/server/middleware/accessResources/canAccessArtifactAppResource.js b/api/server/middleware/accessResources/canAccessArtifactAppResource.js new file mode 100644 index 00000000000..0df48493863 --- /dev/null +++ b/api/server/middleware/accessResources/canAccessArtifactAppResource.js @@ -0,0 +1,34 @@ +const { ResourceType } = require('librechat-data-provider'); +const { canAccessResource } = require('./canAccessResource'); +const { resolveArtifactAppId } = require('~/models'); + +/** + * Artifact-app-specific middleware factory that checks access permissions. + * Wraps the generic `canAccessResource` with the ARTIFACT_APP resource type and + * `resolveArtifactAppId` (custom `app_*` id → ObjectId) as the ID resolver. + * + * @param {Object} options + * @param {number} options.requiredPermission - Permission bit required (1=view, 2=edit, 4=delete, 8=share) + * @param {string} [options.resourceIdParam='id'] - Route parameter holding the artifact app id + * @returns {Function} Express middleware + */ +const canAccessArtifactAppResource = (options) => { + const { requiredPermission, resourceIdParam = 'id' } = options || {}; + + if (!requiredPermission || typeof requiredPermission !== 'number') { + throw new Error( + 'canAccessArtifactAppResource: requiredPermission is required and must be a number', + ); + } + + return canAccessResource({ + resourceType: ResourceType.ARTIFACT_APP, + requiredPermission, + resourceIdParam, + idResolver: (artifactAppId) => resolveArtifactAppId({ artifactAppId }), + }); +}; + +module.exports = { + canAccessArtifactAppResource, +}; diff --git a/api/server/middleware/accessResources/index.js b/api/server/middleware/accessResources/index.js index 31548411bac..6af901fade1 100644 --- a/api/server/middleware/accessResources/index.js +++ b/api/server/middleware/accessResources/index.js @@ -5,6 +5,7 @@ const { canAccessPromptViaGroup } = require('./canAccessPromptViaGroup'); const { canAccessPromptGroupResource } = require('./canAccessPromptGroupResource'); const { canAccessMCPServerResource } = require('./canAccessMCPServerResource'); const { canAccessSkillResource } = require('./canAccessSkillResource'); +const { canAccessArtifactAppResource } = require('./canAccessArtifactAppResource'); module.exports = { canAccessResource, @@ -14,4 +15,5 @@ module.exports = { canAccessPromptGroupResource, canAccessMCPServerResource, canAccessSkillResource, + canAccessArtifactAppResource, }; diff --git a/api/server/routes/artifactApps.js b/api/server/routes/artifactApps.js new file mode 100644 index 00000000000..1ce190e9e02 --- /dev/null +++ b/api/server/routes/artifactApps.js @@ -0,0 +1,102 @@ +const express = require('express'); +const { createArtifactAppHandlers } = require('@librechat/api'); +const { PermissionBits } = require('librechat-data-provider'); +const { + createArtifactAppWithVersion, + getArtifactAppByAppId, + resolveArtifactAppId, + listArtifactApps, + updateArtifactApp, + deleteArtifactApp, + getArtifactVersion, + listArtifactVersions, + createArtifactVersion, + releaseArtifactVersion, + activateArtifactVersion, + withdrawArtifactVersion, + recordAuditEntry, +} = require('~/models'); +const { requireJwtAuth, canAccessArtifactAppResource } = require('~/server/middleware'); +const { + findAccessibleResources, + grantPermission, +} = require('~/server/services/PermissionService'); +const configMiddleware = require('~/server/middleware/config/app'); + +const router = express.Router(); + +router.use(requireJwtAuth); +router.use(configMiddleware); + +const handlers = createArtifactAppHandlers({ + createArtifactAppWithVersion, + getArtifactAppByAppId, + resolveArtifactAppId, + listArtifactApps, + updateArtifactApp, + deleteArtifactApp, + getArtifactVersion, + listArtifactVersions, + createArtifactVersion, + releaseArtifactVersion, + activateArtifactVersion, + withdrawArtifactVersion, + findAccessibleResources, + grantPermission, + recordAuditEntry, +}); + +// Collection +router.get('/', handlers.list); +router.post('/', handlers.publish); + +// Single app +router.get( + '/:id', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }), + handlers.get, +); +router.patch( + '/:id', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.update, +); +router.delete( + '/:id', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.DELETE }), + handlers.remove, +); + +// Versions +router.get( + '/:id/versions', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }), + handlers.listVersions, +); +router.post( + '/:id/versions', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.createVersion, +); +router.get( + '/:id/versions/:versionId', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }), + handlers.getVersion, +); +router.post( + '/:id/versions/:versionId/release', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.releaseVersion, +); +router.post( + '/:id/versions/:versionId/activate', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.activateVersion, +); +router.post( + '/:id/versions/:versionId/withdraw', + canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }), + handlers.withdrawVersion, +); + +module.exports = router; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index ac2b38f579a..9491394ef9c 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -17,6 +17,7 @@ const presets = require('./presets'); const projects = require('./projects'); const prompts = require('./prompts'); const skills = require('./skills'); +const artifactApps = require('./artifactApps'); const balance = require('./balance'); const actions = require('./actions'); const apiKeys = require('./apiKeys'); @@ -66,6 +67,7 @@ module.exports = { prompts, projects, skills, + artifactApps, actions, presets, balance, diff --git a/packages/api/src/artifactApps/__tests__/handlers.spec.ts b/packages/api/src/artifactApps/__tests__/handlers.spec.ts new file mode 100644 index 00000000000..be6dbc1bad5 --- /dev/null +++ b/packages/api/src/artifactApps/__tests__/handlers.spec.ts @@ -0,0 +1,274 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { createModels, createArtifactAppMethods } from '@librechat/data-schemas'; +import { ResourceType, AccessRoleIds } from 'librechat-data-provider'; +import type { Types } from 'mongoose'; +import type { Response } from 'express'; +import type { ServerRequest } from '~/types'; +import { createArtifactAppHandlers } from '../handlers'; + +let mongoServer: MongoMemoryServer; +let methods: ReturnType; +let handlers: ReturnType; + +interface GrantRecord { + principalId: string; + resourceType: string; + resourceId: string; + accessRoleId: string; +} + +let grants: GrantRecord[]; +let auditActions: string[]; +let accessibleIds: Types.ObjectId[]; + +function makeRes(): Response & { statusCode: number; body: unknown } { + const res = { + statusCode: 0, + body: undefined as unknown, + status(code: number) { + res.statusCode = code; + return res; + }, + json(payload: unknown) { + res.body = payload; + return res; + }, + }; + return res as unknown as Response & { statusCode: number; body: unknown }; +} + +function makeReq(overrides: Partial): ServerRequest { + return { + user: { id: 'user-1', name: 'User One', tenantId: undefined }, + params: {}, + body: {}, + query: {}, + ...overrides, + } as unknown as ServerRequest; +} + +const samplePublish = { + title: 'My Chart', + description: 'A bar chart', + visibility: 'private' as const, + artifact: { + type: 'react' as const, + content: 'export default () =>
hello
;', + title: 'Chart', + }, +}; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + createModels(mongoose); + methods = createArtifactAppMethods(mongoose); + handlers = createArtifactAppHandlers({ + ...methods, + findAccessibleResources: async () => accessibleIds, + grantPermission: async (params) => { + grants.push({ + principalId: String(params.principalId), + resourceType: params.resourceType, + resourceId: String(params.resourceId), + accessRoleId: params.accessRoleId, + }); + return {}; + }, + recordAuditEntry: async (input) => { + auditActions.push(input.action); + return null; + }, + }); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await mongoose.connection.dropDatabase(); + grants = []; + auditActions = []; + accessibleIds = []; +}); + +describe('publish', () => { + test('creates app + version 1, grants owner ACL, and audits', async () => { + const req = makeReq({ body: samplePublish }); + const res = makeRes(); + await handlers.publish(req, res); + + expect(res.statusCode).toBe(201); + const body = res.body as { app: { artifactAppId: string; createdBy: string }; version: { versionNumber: number; sourceSnapshot: string; integrity: { sourceHash: string } } }; + expect(body.app.artifactAppId).toMatch(/^app_/); + expect(body.app.createdBy).toBe('user-1'); + expect(body.version.versionNumber).toBe(1); + expect(body.version.sourceSnapshot).toBe(samplePublish.artifact.content); + expect(body.version.integrity.sourceHash).toHaveLength(64); + + expect(grants).toHaveLength(1); + expect(grants[0]).toMatchObject({ + principalId: 'user-1', + resourceType: ResourceType.ARTIFACT_APP, + accessRoleId: AccessRoleIds.ARTIFACT_APP_OWNER, + }); + expect(auditActions).toContain('artifact_app.created'); + }); + + test('ignores createdBy/tenantId supplied in the request body (§8.7)', async () => { + const req = makeReq({ + body: { ...samplePublish, createdBy: 'attacker', tenantId: 'other-tenant' } as never, + }); + const res = makeRes(); + await handlers.publish(req, res); + + const body = res.body as { app: { createdBy: string; tenantId?: string } }; + expect(body.app.createdBy).toBe('user-1'); + expect(body.app.tenantId).toBeUndefined(); + }); + + test('rejects invalid payloads with 400', async () => { + const req = makeReq({ body: { title: '' } }); + const res = makeRes(); + await handlers.publish(req, res); + expect(res.statusCode).toBe(400); + }); + + test('snapshot is independent of later publishes of a mutated artifact', async () => { + const first = makeRes(); + await handlers.publish(makeReq({ body: samplePublish }), first); + const firstBody = first.body as { app: { artifactAppId: string } }; + + const mutated = { + ...samplePublish, + artifact: { ...samplePublish.artifact, content: 'export default () =>
changed
;' }, + }; + const second = makeRes(); + await handlers.publish(makeReq({ body: mutated }), second); + + const stored = await methods.getArtifactVersion({ + artifactAppId: firstBody.app.artifactAppId, + versionNumber: 1, + }); + expect(stored?.sourceSnapshot).toBe(samplePublish.artifact.content); + }); +}); + +describe('get / list', () => { + test('get returns app with its active version', async () => { + const created = makeRes(); + await handlers.publish(makeReq({ body: samplePublish }), created); + const appId = (created.body as { app: { artifactAppId: string } }).app.artifactAppId; + + const res = makeRes(); + await handlers.get(makeReq({ params: { id: appId } as never }), res); + expect(res.statusCode).toBe(200); + const body = res.body as { app: { artifactAppId: string }; version: { versionNumber: number } | null }; + expect(body.app.artifactAppId).toBe(appId); + expect(body.version?.versionNumber).toBe(1); + }); + + test('list returns only ACL-accessible apps', async () => { + const a = makeRes(); + await handlers.publish(makeReq({ body: samplePublish }), a); + const b = makeRes(); + await handlers.publish(makeReq({ body: { ...samplePublish, title: 'Second' } }), b); + + const resolvedA = await methods.resolveArtifactAppId({ + artifactAppId: (a.body as { app: { artifactAppId: string } }).app.artifactAppId, + }); + accessibleIds = [resolvedA!._id]; + + const res = makeRes(); + await handlers.list(makeReq({}), res); + const body = res.body as { apps: unknown[] }; + expect(body.apps).toHaveLength(1); + }); + + test('get returns 404 for unknown id', async () => { + const res = makeRes(); + await handlers.get(makeReq({ params: { id: 'app_missing' } as never }), res); + expect(res.statusCode).toBe(404); + }); +}); + +describe('version lifecycle', () => { + async function publishApp(): Promise { + const res = makeRes(); + await handlers.publish(makeReq({ body: samplePublish }), res); + return (res.body as { app: { artifactAppId: string } }).app.artifactAppId; + } + + test('create → release → activate updates the active version', async () => { + const appId = await publishApp(); + + const createRes = makeRes(); + await handlers.createVersion( + makeReq({ + params: { id: appId } as never, + body: { artifact: samplePublish.artifact, changelog: 'v2' }, + }), + createRes, + ); + expect(createRes.statusCode).toBe(201); + const versionId = (createRes.body as { artifactVersionId: string }).artifactVersionId; + + const releaseRes = makeRes(); + await handlers.releaseVersion( + makeReq({ params: { id: appId, versionId } as never }), + releaseRes, + ); + expect((releaseRes.body as { publication: { state: string } }).publication.state).toBe( + 'released', + ); + + const activateRes = makeRes(); + await handlers.activateVersion( + makeReq({ params: { id: appId, versionId } as never }), + activateRes, + ); + expect(activateRes.statusCode).toBe(200); + expect((activateRes.body as { activeVersionId: string }).activeVersionId).toBe(versionId); + }); + + test('activating an unreleased version returns 409', async () => { + const appId = await publishApp(); + const createRes = makeRes(); + await handlers.createVersion( + makeReq({ params: { id: appId } as never, body: { artifact: samplePublish.artifact } }), + createRes, + ); + const versionId = (createRes.body as { artifactVersionId: string }).artifactVersionId; + + const res = makeRes(); + await handlers.activateVersion(makeReq({ params: { id: appId, versionId } as never }), res); + expect(res.statusCode).toBe(409); + }); + + test('released version content is immutable across a re-release', async () => { + const appId = await publishApp(); + const createRes = makeRes(); + await handlers.createVersion( + makeReq({ params: { id: appId } as never, body: { artifact: samplePublish.artifact } }), + createRes, + ); + const versionId = (createRes.body as { artifactVersionId: string }).artifactVersionId; + + const firstRelease = makeRes(); + await handlers.releaseVersion(makeReq({ params: { id: appId, versionId } as never }), firstRelease); + const hashAfterFirst = ( + firstRelease.body as { integrity: { sourceHash: string } } + ).integrity.sourceHash; + + const secondRelease = makeRes(); + await handlers.releaseVersion(makeReq({ params: { id: appId, versionId } as never }), secondRelease); + const hashAfterSecond = ( + secondRelease.body as { integrity: { sourceHash: string } } + ).integrity.sourceHash; + + expect(hashAfterSecond).toBe(hashAfterFirst); + }); +}); diff --git a/packages/api/src/artifactApps/handlers.ts b/packages/api/src/artifactApps/handlers.ts new file mode 100644 index 00000000000..f69502682bb --- /dev/null +++ b/packages/api/src/artifactApps/handlers.ts @@ -0,0 +1,587 @@ +import { logger } from '@librechat/data-schemas'; +import { + ResourceType, + AccessRoleIds, + PrincipalType, + PermissionBits, + publishArtifactAppSchema, + updateArtifactAppSchema, + createArtifactVersionSchema, +} from 'librechat-data-provider'; +import type { + TArtifactApp, + TArtifactVersion, + ArtifactRuntimeType, +} from 'librechat-data-provider'; +import type { + IArtifactApp, + IArtifactVersion, + ArtifactAppQuery, + ArtifactVersionQuery, + ArtifactAppWithVersion, + ArtifactAppIdResolution, + CreateArtifactAppInput, + CreateArtifactVersionInput, + RecordAuditEntryInput, + IAuditLog, +} from '@librechat/data-schemas'; +import type { Response } from 'express'; +import type { Types } from 'mongoose'; +import type { ServerRequest } from '~/types'; + +/** + * All dependencies required to serve Artifact App HTTP requests. Every dep is + * resolved from the legacy api layer (`~/models`, `PermissionService`) so the + * handlers stay pure — no direct mongoose access, no direct filesystem I/O. + */ +export interface ArtifactAppHandlersDeps { + createArtifactAppWithVersion: (input: CreateArtifactAppInput) => Promise; + getArtifactAppByAppId: (query: ArtifactAppQuery) => Promise; + resolveArtifactAppId: (query: ArtifactAppQuery) => Promise; + listArtifactApps: (filter: Record) => Promise; + updateArtifactApp: ( + query: ArtifactAppQuery, + update: Partial, + ) => Promise; + deleteArtifactApp: ( + query: ArtifactAppQuery, + ) => Promise<{ deletedApp: boolean; deletedVersions: number }>; + getArtifactVersion: (query: ArtifactVersionQuery) => Promise; + listArtifactVersions: (query: ArtifactAppQuery) => Promise; + createArtifactVersion: ( + query: ArtifactAppQuery, + input: CreateArtifactVersionInput, + ) => Promise; + releaseArtifactVersion: ( + query: ArtifactVersionQuery, + releasedBy: string, + ) => Promise; + activateArtifactVersion: (query: ArtifactVersionQuery) => Promise; + withdrawArtifactVersion: (query: ArtifactVersionQuery) => Promise; + + findAccessibleResources: (params: { + userId: string; + role?: string | null; + resourceType: string; + requiredPermissions: number; + }) => Promise; + grantPermission: (params: { + principalType: string; + principalId: string | Types.ObjectId; + resourceType: string; + resourceId: string | Types.ObjectId; + accessRoleId: string; + grantedBy: string | Types.ObjectId; + }) => Promise; + recordAuditEntry: (input: RecordAuditEntryInput) => Promise; +} + +function toIso(value: Date | undefined): string { + return (value ?? new Date()).toISOString(); +} + +function toIsoOptional(value: Date | undefined): string | undefined { + return value ? value.toISOString() : undefined; +} + +function serializeApp(app: IArtifactApp): TArtifactApp { + return { + artifactAppId: app.artifactAppId, + tenantId: app.tenantId, + title: app.title, + description: app.description, + icon: app.icon, + category: app.category, + tags: app.tags, + createdBy: app.createdBy, + activeVersionId: app.activeVersionId, + latestVersionNumber: app.latestVersionNumber, + status: app.status, + visibility: app.visibility, + allowEmbed: app.allowEmbed, + allowFork: app.allowFork, + allowAnonymousView: app.allowAnonymousView, + toolPolicy: { + enabled: app.toolPolicy.enabled, + allowedServers: app.toolPolicy.allowedServers, + allowedTools: app.toolPolicy.allowedTools, + requireConfirmationForWrites: app.toolPolicy.requireConfirmationForWrites, + }, + marketplace: { + listed: app.marketplace.listed, + featured: app.marketplace.featured, + summary: app.marketplace.summary, + riskClass: app.marketplace.riskClass, + costClass: app.marketplace.costClass, + }, + sourceMetadata: app.sourceMetadata + ? { + conversationId: app.sourceMetadata.conversationId, + messageId: app.sourceMetadata.messageId, + originalArtifactId: app.sourceMetadata.originalArtifactId, + } + : undefined, + review: app.review + ? { + submittedAt: toIsoOptional(app.review.submittedAt), + submittedBy: app.review.submittedBy, + reviewedAt: toIsoOptional(app.review.reviewedAt), + reviewedBy: app.review.reviewedBy, + result: app.review.result, + comment: app.review.comment, + } + : undefined, + createdAt: toIso(app.createdAt), + updatedAt: toIso(app.updatedAt), + archivedAt: toIsoOptional(app.archivedAt), + }; +} + +function serializeVersion(version: IArtifactVersion): TArtifactVersion { + const runtimeConfig = version.runtimeConfig ?? {}; + return { + artifactVersionId: version.artifactVersionId, + artifactAppId: version.artifactAppId, + tenantId: version.tenantId, + versionNumber: version.versionNumber, + versionLabel: version.versionLabel, + changelog: version.changelog, + artifactType: version.artifactType, + sourceSnapshot: version.sourceSnapshot, + runtimeConfig: { + dependencies: runtimeConfig.dependencies, + entryPoint: runtimeConfig.entryPoint, + renderMode: runtimeConfig.renderMode, + }, + integrity: { + sourceHash: version.integrity.sourceHash, + schemaVersion: version.integrity.schemaVersion, + }, + createdBy: version.createdBy, + createdAt: toIso(version.createdAt), + publication: { + state: version.publication.state, + releasedBy: version.publication.releasedBy, + releasedAt: toIsoOptional(version.publication.releasedAt), + }, + }; +} + +function requireUser(req: ServerRequest, res: Response): ServerRequest['user'] | null { + const user = req.user; + if (!user || !user.id) { + res.status(401).json({ error: 'Authentication required' }); + return null; + } + return user; +} + +/** + * Factory for the typed Express handlers served at `/api/artifact-apps`. + * The legacy route file passes in concrete deps from `~/models` and + * `PermissionService`; §8.7 mandates the server derives actor identity and + * tenant strictly from the authenticated session, never from the request body. + */ +export function createArtifactAppHandlers(deps: ArtifactAppHandlersDeps): { + publish: (req: ServerRequest, res: Response) => Promise; + list: (req: ServerRequest, res: Response) => Promise; + get: (req: ServerRequest, res: Response) => Promise; + update: (req: ServerRequest, res: Response) => Promise; + remove: (req: ServerRequest, res: Response) => Promise; + listVersions: (req: ServerRequest, res: Response) => Promise; + getVersion: (req: ServerRequest, res: Response) => Promise; + createVersion: (req: ServerRequest, res: Response) => Promise; + releaseVersion: (req: ServerRequest, res: Response) => Promise; + activateVersion: (req: ServerRequest, res: Response) => Promise; + withdrawVersion: (req: ServerRequest, res: Response) => Promise; +} { + const { + createArtifactAppWithVersion, + getArtifactAppByAppId, + listArtifactApps, + updateArtifactApp, + deleteArtifactApp, + getArtifactVersion, + listArtifactVersions, + createArtifactVersion, + releaseArtifactVersion, + activateArtifactVersion, + withdrawArtifactVersion, + findAccessibleResources, + grantPermission, + recordAuditEntry, + } = deps; + + function audit(input: RecordAuditEntryInput): void { + recordAuditEntry(input).catch((err) => + logger.error(`[artifactApps] audit write failed for ${input.action}`, err), + ); + } + + function toVersionInput( + artifact: { type: ArtifactRuntimeType; content: string; runtimeConfig?: unknown }, + label: string | undefined, + changelog: string | undefined, + createdBy: string, + ): CreateArtifactVersionInput { + return { + artifactType: artifact.type, + sourceSnapshot: artifact.content, + runtimeConfig: artifact.runtimeConfig as CreateArtifactVersionInput['runtimeConfig'], + versionLabel: label, + changelog, + createdBy, + }; + } + + async function publish(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + + const parsed = publishArtifactAppSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: 'Validation failed', issues: parsed.error.issues }); + } + const data = parsed.data; + + const userId = user.id as string; + const input: CreateArtifactAppInput = { + tenantId: user.tenantId, + createdBy: userId, + title: data.title, + description: data.description, + icon: data.icon, + category: data.category, + tags: data.tags, + visibility: data.visibility, + allowEmbed: data.allowEmbed, + allowFork: data.allowFork, + allowAnonymousView: data.allowAnonymousView, + toolPolicy: data.toolPolicy, + marketplace: data.marketplace, + sourceMetadata: data.source, + version: toVersionInput(data.artifact, data.versionLabel, data.changelog, userId), + }; + + const { app, version } = await createArtifactAppWithVersion(input); + + try { + await grantPermission({ + principalType: PrincipalType.USER, + principalId: userId, + resourceType: ResourceType.ARTIFACT_APP, + resourceId: app._id as Types.ObjectId, + accessRoleId: AccessRoleIds.ARTIFACT_APP_OWNER, + grantedBy: userId, + }); + } catch (permissionError) { + logger.error( + `[POST /artifact-apps] Failed to grant owner permission for ${app.artifactAppId}, rolling back:`, + permissionError, + ); + try { + await deleteArtifactApp({ artifactAppId: app.artifactAppId }); + } catch (rollbackError) { + logger.error( + `[POST /artifact-apps] Compensating delete failed for ${app.artifactAppId}:`, + rollbackError, + ); + } + return res.status(500).json({ error: 'Failed to initialize artifact app permissions' }); + } + + audit({ + tenantId: user.tenantId, + action: 'artifact_app.created', + actor: { type: 'user', id: userId, name: user.name ?? user.username ?? userId }, + target: { type: ResourceType.ARTIFACT_APP, id: app.artifactAppId, name: app.title }, + metadata: { versionNumber: version.versionNumber }, + }); + + return res.status(201).json({ app: serializeApp(app), version: serializeVersion(version) }); + } catch (error) { + logger.error('[POST /artifact-apps] Error publishing artifact app', error); + return res.status(500).json({ error: 'Error publishing artifact app' }); + } + } + + async function list(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + const accessibleIds = await findAccessibleResources({ + userId: user.id as string, + role: user.role, + resourceType: ResourceType.ARTIFACT_APP, + requiredPermissions: PermissionBits.VIEW, + }); + const apps = await listArtifactApps({ _id: { $in: accessibleIds } }); + return res.status(200).json({ apps: apps.map(serializeApp) }); + } catch (error) { + logger.error('[GET /artifact-apps] Error listing artifact apps', error); + return res.status(500).json({ error: 'Error listing artifact apps' }); + } + } + + async function get(req: ServerRequest, res: Response) { + try { + const { id } = req.params as { id: string }; + const app = await getArtifactAppByAppId({ artifactAppId: id }); + if (!app) { + return res.status(404).json({ error: 'Artifact app not found' }); + } + const version = app.activeVersionId + ? await getArtifactVersion({ + artifactAppId: app.artifactAppId, + artifactVersionId: app.activeVersionId, + }) + : null; + return res.status(200).json({ + app: serializeApp(app), + version: version ? serializeVersion(version) : null, + }); + } catch (error) { + logger.error('[GET /artifact-apps/:id] Error fetching artifact app', error); + return res.status(500).json({ error: 'Error fetching artifact app' }); + } + } + + async function update(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + const { id } = req.params as { id: string }; + const parsed = updateArtifactAppSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: 'Validation failed', issues: parsed.error.issues }); + } + if (Object.keys(parsed.data).length === 0) { + return res.status(400).json({ error: 'At least one field must be provided for update' }); + } + + const updated = await updateArtifactApp( + { artifactAppId: id }, + parsed.data as Partial, + ); + if (!updated) { + return res.status(404).json({ error: 'Artifact app not found' }); + } + audit({ + tenantId: user.tenantId, + action: 'artifact_app.updated', + actor: { type: 'user', id: user.id as string, name: user.name ?? user.username ?? '' }, + target: { type: ResourceType.ARTIFACT_APP, id: updated.artifactAppId, name: updated.title }, + }); + return res.status(200).json(serializeApp(updated)); + } catch (error) { + logger.error('[PATCH /artifact-apps/:id] Error updating artifact app', error); + return res.status(500).json({ error: 'Error updating artifact app' }); + } + } + + async function remove(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + const { id } = req.params as { id: string }; + const result = await deleteArtifactApp({ artifactAppId: id }); + if (!result.deletedApp) { + return res.status(404).json({ error: 'Artifact app not found' }); + } + audit({ + tenantId: user.tenantId, + action: 'artifact_app.archived', + actor: { type: 'user', id: user.id as string, name: user.name ?? user.username ?? '' }, + target: { type: ResourceType.ARTIFACT_APP, id }, + metadata: { deletedVersions: result.deletedVersions }, + }); + return res.status(200).json({ success: true }); + } catch (error) { + logger.error('[DELETE /artifact-apps/:id] Error deleting artifact app', error); + return res.status(500).json({ error: 'Error deleting artifact app' }); + } + } + + async function listVersions(req: ServerRequest, res: Response) { + try { + const { id } = req.params as { id: string }; + const versions = await listArtifactVersions({ artifactAppId: id }); + return res.status(200).json({ versions: versions.map(serializeVersion) }); + } catch (error) { + logger.error('[GET /artifact-apps/:id/versions] Error listing versions', error); + return res.status(500).json({ error: 'Error listing artifact app versions' }); + } + } + + async function getVersion(req: ServerRequest, res: Response) { + try { + const { id, versionId } = req.params as { id: string; versionId: string }; + const version = await getArtifactVersion({ + artifactAppId: id, + artifactVersionId: versionId, + }); + if (!version) { + return res.status(404).json({ error: 'Artifact version not found' }); + } + return res.status(200).json(serializeVersion(version)); + } catch (error) { + logger.error('[GET /artifact-apps/:id/versions/:versionId] Error fetching version', error); + return res.status(500).json({ error: 'Error fetching artifact app version' }); + } + } + + async function createVersion(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + const { id } = req.params as { id: string }; + const parsed = createArtifactVersionSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: 'Validation failed', issues: parsed.error.issues }); + } + const data = parsed.data; + let version: IArtifactVersion; + try { + version = await createArtifactVersion( + { artifactAppId: id }, + toVersionInput(data.artifact, data.versionLabel, data.changelog, user.id as string), + ); + } catch (error) { + if ((error as Error).message?.includes('not found')) { + return res.status(404).json({ error: 'Artifact app not found' }); + } + throw error; + } + audit({ + tenantId: user.tenantId, + action: 'artifact_version.created', + actor: { type: 'user', id: user.id as string, name: user.name ?? user.username ?? '' }, + target: { type: ResourceType.ARTIFACT_APP, id, name: version.artifactVersionId }, + metadata: { versionNumber: version.versionNumber }, + }); + return res.status(201).json(serializeVersion(version)); + } catch (error) { + logger.error('[POST /artifact-apps/:id/versions] Error creating version', error); + return res.status(500).json({ error: 'Error creating artifact app version' }); + } + } + + async function releaseVersion(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + const { id, versionId } = req.params as { id: string; versionId: string }; + const version = await releaseArtifactVersion( + { artifactAppId: id, artifactVersionId: versionId }, + user.id as string, + ); + if (!version) { + return res.status(404).json({ error: 'Artifact version not found' }); + } + audit({ + tenantId: user.tenantId, + action: 'artifact_version.released', + actor: { type: 'user', id: user.id as string, name: user.name ?? user.username ?? '' }, + target: { type: ResourceType.ARTIFACT_APP, id, name: version.artifactVersionId }, + metadata: { versionNumber: version.versionNumber }, + }); + return res.status(200).json(serializeVersion(version)); + } catch (error) { + logger.error('[POST /artifact-apps/:id/versions/:versionId/release] Error', error); + return res.status(500).json({ error: 'Error releasing artifact app version' }); + } + } + + async function activateVersion(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + const { id, versionId } = req.params as { id: string; versionId: string }; + let result: ArtifactAppWithVersion | null; + try { + result = await activateArtifactVersion({ + artifactAppId: id, + artifactVersionId: versionId, + }); + } catch (error) { + if ((error as Error).message?.includes('released')) { + return res.status(409).json({ error: 'Only released versions can be activated' }); + } + throw error; + } + if (!result) { + return res.status(404).json({ error: 'Artifact version not found' }); + } + audit({ + tenantId: user.tenantId, + action: 'artifact_version.activated', + actor: { type: 'user', id: user.id as string, name: user.name ?? user.username ?? '' }, + target: { type: ResourceType.ARTIFACT_APP, id, name: result.version.artifactVersionId }, + metadata: { versionNumber: result.version.versionNumber }, + }); + return res.status(200).json(serializeApp(result.app)); + } catch (error) { + logger.error('[POST /artifact-apps/:id/versions/:versionId/activate] Error', error); + return res.status(500).json({ error: 'Error activating artifact app version' }); + } + } + + async function withdrawVersion(req: ServerRequest, res: Response) { + try { + const user = requireUser(req, res); + if (!user) { + return res as Response; + } + const { id, versionId } = req.params as { id: string; versionId: string }; + const version = await withdrawArtifactVersion({ + artifactAppId: id, + artifactVersionId: versionId, + }); + if (!version) { + return res.status(404).json({ error: 'Artifact version not found' }); + } + audit({ + tenantId: user.tenantId, + action: 'artifact_version.withdrawn', + actor: { type: 'user', id: user.id as string, name: user.name ?? user.username ?? '' }, + target: { type: ResourceType.ARTIFACT_APP, id, name: version.artifactVersionId }, + metadata: { versionNumber: version.versionNumber }, + }); + return res.status(200).json(serializeVersion(version)); + } catch (error) { + logger.error('[POST /artifact-apps/:id/versions/:versionId/withdraw] Error', error); + return res.status(500).json({ error: 'Error withdrawing artifact app version' }); + } + } + + return { + publish, + list, + get, + update, + remove, + listVersions, + getVersion, + createVersion, + releaseVersion, + activateVersion, + withdrawVersion, + }; +} + +export type ArtifactAppHandlers = ReturnType; diff --git a/packages/api/src/artifactApps/index.ts b/packages/api/src/artifactApps/index.ts new file mode 100644 index 00000000000..b7a0bddc22e --- /dev/null +++ b/packages/api/src/artifactApps/index.ts @@ -0,0 +1,2 @@ +export { createArtifactAppHandlers } from './handlers'; +export type { ArtifactAppHandlers, ArtifactAppHandlersDeps } from './handlers'; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index fff580d06fe..4042284b77f 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -52,6 +52,8 @@ export * from './projects'; /* Skills */ export * from './skills'; export * from './favorites'; +/* Artifact Apps */ +export * from './artifactApps'; /* Endpoints */ export * from './endpoints'; /* Files */ diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index 85ff1b86fc0..15f067ca74f 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -24,6 +24,9 @@ export { validateSkillFrontmatter, validateSkillDescription, deriveStructuredFrontmatterFields, + createArtifactAppMethods, + computeSourceHash, + ARTIFACT_SCHEMA_VERSION, AUDIT_SCHEMA_VERSION, MAX_AUDIT_EXPORT_ROWS, MAX_AUDIT_LOG_LIMIT, diff --git a/packages/data-schemas/src/methods/artifactApp.ts b/packages/data-schemas/src/methods/artifactApp.ts index 5830e94c1ce..f9dc4cac3c2 100644 --- a/packages/data-schemas/src/methods/artifactApp.ts +++ b/packages/data-schemas/src/methods/artifactApp.ts @@ -1,6 +1,6 @@ import crypto from 'node:crypto'; import { nanoid } from 'nanoid'; -import type { ClientSession, Model } from 'mongoose'; +import type { ClientSession, FilterQuery, Model } from 'mongoose'; import type { IArtifactApp, IArtifactVersion, @@ -55,7 +55,7 @@ export interface ArtifactAppMethods { createArtifactAppWithVersion: (input: CreateArtifactAppInput) => Promise; getArtifactAppByAppId: (query: ArtifactAppQuery) => Promise; resolveArtifactAppId: (query: ArtifactAppQuery) => Promise; - listArtifactApps: (filter: Partial) => Promise; + listArtifactApps: (filter: FilterQuery) => Promise; updateArtifactApp: ( query: ArtifactAppQuery, update: Partial, @@ -207,7 +207,7 @@ export function createArtifactAppMethods( return doc; } - async function listArtifactApps(filter: Partial): Promise { + async function listArtifactApps(filter: FilterQuery): Promise { return getApp().find(filter).sort({ updatedAt: -1 }).lean().exec(); } diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index a5712b46e89..ba01cbb70ab 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -124,7 +124,7 @@ export { }; export { AUDIT_SCHEMA_VERSION, MAX_AUDIT_EXPORT_ROWS, MAX_AUDIT_LOG_LIMIT, MAX_AUDIT_VERIFY_ROWS }; export { MAX_TOOL_FAVORITES }; -export { computeSourceHash, ARTIFACT_SCHEMA_VERSION }; +export { createArtifactAppMethods, computeSourceHash, ARTIFACT_SCHEMA_VERSION }; export type AllMethods = UserMethods & SessionMethods & From 1e2ac77d44c8697f1a59087f3b65b74131645b59 Mon Sep 17 00:00:00 2001 From: dsheine <304794417+dsheine@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:16:54 +0000 Subject: [PATCH 07/15] feat(client): artifact app standalone viewer and data-provider hooks (WIP WP5/WP6) Partial frontend work: standalone app view, app renderer wrapper, react-query queries/mutations for artifact-apps endpoints, i18n keys. Committed to preserve progress before credit exhaustion. --- .../components/ArtifactApps/AppRenderer.tsx | 41 ++++++ .../ArtifactApps/StandaloneAppView.tsx | 107 +++++++++++++++ client/src/components/ArtifactApps/index.ts | 3 + .../src/data-provider/ArtifactApps/index.ts | 2 + .../data-provider/ArtifactApps/mutations.ts | 122 ++++++++++++++++++ .../src/data-provider/ArtifactApps/queries.ts | 82 ++++++++++++ client/src/data-provider/index.ts | 1 + client/src/locales/en/translation.json | 15 +++ packages/data-provider/rollup.fallback.mjs | 54 ++++++++ packages/data-schemas/rollup.fallback.mjs | 43 ++++++ 10 files changed, 470 insertions(+) create mode 100644 client/src/components/ArtifactApps/AppRenderer.tsx create mode 100644 client/src/components/ArtifactApps/StandaloneAppView.tsx create mode 100644 client/src/components/ArtifactApps/index.ts create mode 100644 client/src/data-provider/ArtifactApps/index.ts create mode 100644 client/src/data-provider/ArtifactApps/mutations.ts create mode 100644 client/src/data-provider/ArtifactApps/queries.ts create mode 100644 packages/data-provider/rollup.fallback.mjs create mode 100644 packages/data-schemas/rollup.fallback.mjs diff --git a/client/src/components/ArtifactApps/AppRenderer.tsx b/client/src/components/ArtifactApps/AppRenderer.tsx new file mode 100644 index 00000000000..a64cb204020 --- /dev/null +++ b/client/src/components/ArtifactApps/AppRenderer.tsx @@ -0,0 +1,41 @@ +import { useRef } from 'react'; +import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled'; +import type { TArtifactVersion } from 'librechat-data-provider'; +import type { Artifact } from '~/common'; +import { ArtifactPreview } from '~/components/Artifacts/ArtifactPreview'; +import useArtifactProps from '~/hooks/Artifacts/useArtifactProps'; +import { useGetStartupConfig } from '~/data-provider'; + +export default function AppRenderer({ + title, + version, +}: { + title: string; + version: TArtifactVersion; +}) { + const previewRef = useRef(null as unknown as SandpackPreviewRef); + const { data: startupConfig } = useGetStartupConfig(); + + const artifact: Artifact = { + id: version.artifactVersionId, + lastUpdateTime: Date.now(), + type: version.artifactType, + content: version.sourceSnapshot, + title, + }; + + const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact }); + + return ( +
+ +
+ ); +} diff --git a/client/src/components/ArtifactApps/StandaloneAppView.tsx b/client/src/components/ArtifactApps/StandaloneAppView.tsx new file mode 100644 index 00000000000..e726099341b --- /dev/null +++ b/client/src/components/ArtifactApps/StandaloneAppView.tsx @@ -0,0 +1,107 @@ +import { useParams, useNavigate } from 'react-router-dom'; +import type { TArtifactVersion } from 'librechat-data-provider'; +import { + useGetArtifactAppQuery, + useGetArtifactAppVersionQuery, + useListArtifactAppVersionsQuery, +} from '~/data-provider'; +import useLocalize from '~/hooks/useLocalize'; +import AppRenderer from './AppRenderer'; + +function errorStatus(error: unknown): number | undefined { + return (error as { response?: { status?: number } } | null)?.response?.status; +} + +function StateMessage({ message }: { message: string }) { + return ( +
+

{message}

+
+ ); +} + +export default function StandaloneAppView() { + const localize = useLocalize(); + const navigate = useNavigate(); + const { artifactAppId, versionId } = useParams<{ + artifactAppId: string; + versionId?: string; + }>(); + + const appQuery = useGetArtifactAppQuery(artifactAppId); + const versionQuery = useGetArtifactAppVersionQuery(artifactAppId, versionId, { + enabled: !!versionId, + }); + const versionsQuery = useListArtifactAppVersionsQuery(artifactAppId); + + if (appQuery.isLoading) { + return ; + } + + if (appQuery.isError) { + const status = errorStatus(appQuery.error); + if (status === 403) { + return ; + } + return ; + } + + const app = appQuery.data?.app; + if (!app) { + return ; + } + + if (app.status === 'suspended') { + return ; + } + if (app.status === 'archived') { + return ; + } + + const version: TArtifactVersion | null | undefined = versionId + ? versionQuery.data + : appQuery.data?.version; + + const versions = versionsQuery.data?.versions ?? []; + + return ( +
+
+
+

{app.title}

+ {app.description != null && app.description.length > 0 && ( +

{app.description}

+ )} +
+ {versions.length > 0 && ( + + )} +
+
+ {version ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/client/src/components/ArtifactApps/index.ts b/client/src/components/ArtifactApps/index.ts new file mode 100644 index 00000000000..6e75bc08178 --- /dev/null +++ b/client/src/components/ArtifactApps/index.ts @@ -0,0 +1,3 @@ +export { default as StandaloneAppView } from './StandaloneAppView'; +export { default as AppRenderer } from './AppRenderer'; +export { default as PublishAppDialog } from './PublishAppDialog'; diff --git a/client/src/data-provider/ArtifactApps/index.ts b/client/src/data-provider/ArtifactApps/index.ts new file mode 100644 index 00000000000..d0720956a0e --- /dev/null +++ b/client/src/data-provider/ArtifactApps/index.ts @@ -0,0 +1,2 @@ +export * from './queries'; +export * from './mutations'; diff --git a/client/src/data-provider/ArtifactApps/mutations.ts b/client/src/data-provider/ArtifactApps/mutations.ts new file mode 100644 index 00000000000..01f1212dcd2 --- /dev/null +++ b/client/src/data-provider/ArtifactApps/mutations.ts @@ -0,0 +1,122 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { QueryKeys, dataService } from 'librechat-data-provider'; +import type { UseMutationResult } from '@tanstack/react-query'; +import type { + TArtifactApp, + TArtifactVersion, + TArtifactAppWithVersion, + TPublishArtifactAppRequest, + TUpdateArtifactAppRequest, + TCreateArtifactVersionRequest, +} from 'librechat-data-provider'; + +export const usePublishArtifactAppMutation = (): UseMutationResult< + TArtifactAppWithVersion, + Error, + TPublishArtifactAppRequest +> => { + const queryClient = useQueryClient(); + return useMutation((payload) => dataService.publishArtifactApp(payload), { + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.artifactApps]); + }, + }); +}; + +export const useUpdateArtifactAppMutation = (): UseMutationResult< + TArtifactApp, + Error, + { artifactAppId: string; payload: TUpdateArtifactAppRequest } +> => { + const queryClient = useQueryClient(); + return useMutation( + ({ artifactAppId, payload }) => dataService.updateArtifactApp(artifactAppId, payload), + { + onSuccess: (_data, { artifactAppId }) => { + queryClient.invalidateQueries([QueryKeys.artifactApps]); + queryClient.invalidateQueries([QueryKeys.artifactApp, artifactAppId]); + }, + }, + ); +}; + +export const useDeleteArtifactAppMutation = (): UseMutationResult< + { success: boolean }, + Error, + string +> => { + const queryClient = useQueryClient(); + return useMutation((artifactAppId) => dataService.deleteArtifactApp(artifactAppId), { + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.artifactApps]); + }, + }); +}; + +export const useCreateArtifactVersionMutation = (): UseMutationResult< + TArtifactVersion, + Error, + { artifactAppId: string; payload: TCreateArtifactVersionRequest } +> => { + const queryClient = useQueryClient(); + return useMutation( + ({ artifactAppId, payload }) => dataService.createArtifactAppVersion(artifactAppId, payload), + { + onSuccess: (_data, { artifactAppId }) => { + queryClient.invalidateQueries([QueryKeys.artifactAppVersions, artifactAppId]); + }, + }, + ); +}; + +export const useReleaseArtifactVersionMutation = (): UseMutationResult< + TArtifactVersion, + Error, + { artifactAppId: string; versionId: string } +> => { + const queryClient = useQueryClient(); + return useMutation( + ({ artifactAppId, versionId }) => + dataService.releaseArtifactAppVersion(artifactAppId, versionId), + { + onSuccess: (_data, { artifactAppId }) => { + queryClient.invalidateQueries([QueryKeys.artifactAppVersions, artifactAppId]); + }, + }, + ); +}; + +export const useActivateArtifactVersionMutation = (): UseMutationResult< + TArtifactApp, + Error, + { artifactAppId: string; versionId: string } +> => { + const queryClient = useQueryClient(); + return useMutation( + ({ artifactAppId, versionId }) => + dataService.activateArtifactAppVersion(artifactAppId, versionId), + { + onSuccess: (_data, { artifactAppId }) => { + queryClient.invalidateQueries([QueryKeys.artifactApp, artifactAppId]); + queryClient.invalidateQueries([QueryKeys.artifactAppVersions, artifactAppId]); + }, + }, + ); +}; + +export const useWithdrawArtifactVersionMutation = (): UseMutationResult< + TArtifactVersion, + Error, + { artifactAppId: string; versionId: string } +> => { + const queryClient = useQueryClient(); + return useMutation( + ({ artifactAppId, versionId }) => + dataService.withdrawArtifactAppVersion(artifactAppId, versionId), + { + onSuccess: (_data, { artifactAppId }) => { + queryClient.invalidateQueries([QueryKeys.artifactAppVersions, artifactAppId]); + }, + }, + ); +}; diff --git a/client/src/data-provider/ArtifactApps/queries.ts b/client/src/data-provider/ArtifactApps/queries.ts new file mode 100644 index 00000000000..69cbc4e3d35 --- /dev/null +++ b/client/src/data-provider/ArtifactApps/queries.ts @@ -0,0 +1,82 @@ +import { useQuery } from '@tanstack/react-query'; +import { QueryKeys, dataService } from 'librechat-data-provider'; +import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query'; +import type { + TArtifactApp, + TArtifactAppList, + TArtifactVersion, + TArtifactVersionList, + TArtifactAppWithVersion, +} from 'librechat-data-provider'; + +export const useListArtifactAppsQuery = ( + config?: UseQueryOptions, +): QueryObserverResult => { + return useQuery( + [QueryKeys.artifactApps], + () => dataService.listArtifactApps(), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + ...config, + }, + ); +}; + +export const useGetArtifactAppQuery = ( + artifactAppId: string | null | undefined, + config?: UseQueryOptions, +): QueryObserverResult => { + const enabled = !!artifactAppId; + return useQuery( + [QueryKeys.artifactApp, artifactAppId], + () => dataService.getArtifactApp(artifactAppId as string), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + retry: false, + ...config, + enabled: enabled && (config?.enabled ?? true), + }, + ); +}; + +export const useListArtifactAppVersionsQuery = ( + artifactAppId: string | null | undefined, + config?: UseQueryOptions, +): QueryObserverResult => { + const enabled = !!artifactAppId; + return useQuery( + [QueryKeys.artifactAppVersions, artifactAppId], + () => dataService.listArtifactAppVersions(artifactAppId as string), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + ...config, + enabled: enabled && (config?.enabled ?? true), + }, + ); +}; + +export const useGetArtifactAppVersionQuery = ( + artifactAppId: string | null | undefined, + versionId: string | null | undefined, + config?: UseQueryOptions, +): QueryObserverResult => { + const enabled = !!artifactAppId && !!versionId; + return useQuery( + [QueryKeys.artifactAppVersions, artifactAppId, versionId], + () => dataService.getArtifactAppVersion(artifactAppId as string, versionId as string), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + retry: false, + ...config, + enabled: enabled && (config?.enabled ?? true), + }, + ); +}; diff --git a/client/src/data-provider/index.ts b/client/src/data-provider/index.ts index e4be6aee0bc..1bf20510d49 100644 --- a/client/src/data-provider/index.ts +++ b/client/src/data-provider/index.ts @@ -1,5 +1,6 @@ export * from './Auth'; export * from './Agents'; +export * from './ArtifactApps'; export * from './Endpoints'; export * from './Skills'; export * from './Files'; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 1f19dd928de..068f4fb8945 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -845,6 +845,21 @@ "com_ui_archive": "Archive", "com_ui_archive_delete_error": "Failed to delete archived conversation", "com_ui_archive_error": "Failed to archive conversation", + "com_ui_artifact_app_archived": "This app has been archived and is no longer available.", + "com_ui_artifact_app_forbidden": "You don't have permission to view this app.", + "com_ui_artifact_app_loading": "Loading app…", + "com_ui_artifact_app_no_version": "This app has no active version to display.", + "com_ui_artifact_app_not_found": "App not found.", + "com_ui_artifact_app_publish": "Publish as App", + "com_ui_artifact_app_publish_success": "App published successfully.", + "com_ui_artifact_app_review": "Review", + "com_ui_artifact_app_sharing": "Sharing", + "com_ui_artifact_app_step_general": "General", + "com_ui_artifact_app_step_publish": "Publish", + "com_ui_artifact_app_step_security": "Security", + "com_ui_artifact_app_step_tools": "Tools", + "com_ui_artifact_app_suspended": "This app is currently suspended.", + "com_ui_artifact_app_version": "Version", "com_ui_artifact_click": "Click to open", "com_ui_artifact_preview_pending": "_Preview not available yet — click **Download** to view the file._", "com_ui_artifacts": "Artifacts", diff --git a/packages/data-provider/rollup.fallback.mjs b/packages/data-provider/rollup.fallback.mjs new file mode 100644 index 00000000000..e01b1b51bb4 --- /dev/null +++ b/packages/data-provider/rollup.fallback.mjs @@ -0,0 +1,54 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import alias from '@rollup/plugin-alias'; +import babel from '@rollup/plugin-babel'; +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import replace from '@rollup/plugin-replace'; + +const require = createRequire(import.meta.url); +const rootPkg = require('../../package.json'); +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const srcDir = path.resolve(dirname, 'src'); + +const isExternal = (id) => + !id.startsWith('.') && !path.isAbsolute(id) && !id.startsWith('src/'); + +const plugins = [ + replace({ + preventAssignment: true, + values: { + __IS_DEV__: JSON.stringify(process.env.NODE_ENV === 'development'), + __LIBRECHAT_VERSION__: JSON.stringify(rootPkg.version), + }, + }), + alias({ entries: [{ find: 'src', replacement: srcDir }] }), + nodeResolve({ extensions: ['.ts', '.js', '.json'] }), + commonjs(), + json(), + babel({ + babelHelpers: 'bundled', + extensions: ['.ts', '.js'], + presets: [ + ['@babel/preset-env', { targets: { node: '18' }, modules: false }], + '@babel/preset-typescript', + ], + }), +]; + +export default [ + { + input: { + index: 'src/index.ts', + 'react-query/index': 'src/react-query/index.ts', + }, + external: isExternal, + plugins, + output: [ + { dir: 'dist', format: 'cjs', entryFileNames: '[name].js', exports: 'named' }, + { dir: 'dist', format: 'es', entryFileNames: '[name].mjs' }, + ], + }, +]; diff --git a/packages/data-schemas/rollup.fallback.mjs b/packages/data-schemas/rollup.fallback.mjs new file mode 100644 index 00000000000..2ff2e6f309d --- /dev/null +++ b/packages/data-schemas/rollup.fallback.mjs @@ -0,0 +1,43 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import alias from '@rollup/plugin-alias'; +import babel from '@rollup/plugin-babel'; +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const srcDir = path.resolve(dirname, 'src'); + +const isExternal = (id) => + !id.startsWith('.') && !path.isAbsolute(id) && !id.startsWith('~/') && !id.startsWith('src/'); + +const plugins = [ + alias({ entries: [{ find: /^~\//, replacement: `${srcDir}/` }] }), + nodeResolve({ extensions: ['.ts', '.js', '.json'] }), + commonjs(), + json(), + babel({ + babelHelpers: 'bundled', + extensions: ['.ts', '.js'], + presets: [ + ['@babel/preset-env', { targets: { node: '18' }, modules: false }], + '@babel/preset-typescript', + ], + }), +]; + +export default [ + { + input: { + index: 'src/index.ts', + 'admin/capabilities': 'src/admin/capabilities.ts', + }, + external: isExternal, + plugins, + output: [ + { dir: 'dist', format: 'cjs', entryFileNames: '[name].cjs', exports: 'named' }, + { dir: 'dist', format: 'es', entryFileNames: '[name].mjs' }, + ], + }, +]; From 8e3ca3b53fa01b6081a9fb4a87620bcdc61605da Mon Sep 17 00:00:00 2001 From: dsheine <304794417+dsheine@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:20:18 +0000 Subject: [PATCH 08/15] docs: add continuation status block to PLAN.md for next agent Documents what is done (WP0-WP4 backend + partial WP5/WP6 frontend) and what remains (build verification, WP5 routing, WP6 publish UI, combined PR) so a resuming agent can pick up without re-discovery. --- PLAN.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/PLAN.md b/PLAN.md index dc11ed6070c..cc1d0b6c9f2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,5 +1,26 @@ # LibreChat Artifact Apps – Implementierungsplan +> ## ⚠️ STATUS FÜR FORTSETZENDEN AGENTEN (Stand: 2026-07-14, Branch `feat/artifact-apps-wp0-wp6`) +> +> Ein vorheriger Coding-Agent wurde wegen Credit-Erschöpfung mitten in der Ausführung gestoppt. Der Fortschritt ist committet und gepusht auf `feat/artifact-apps-wp0-wp6`. Bitte **auf diesem Branch weiterarbeiten** (nicht neu von main starten). Es wurde noch **kein PR** geöffnet. +> +> ### ✅ Bereits erledigt (committet) +> - **WP0 – Exploration/ADRs**: `docs/artifact-apps/WP0-exploration.md`, `docs/artifact-apps/adr-0001-snapshot-storage.md`, `docs/artifact-apps/adr-0002-mcp-bridge.md` +> - **WP1 – Persistenz**: `packages/data-schemas/src/schema/artifactApp.ts`, `artifactVersion.ts`, Models, `methods/artifactApp.ts` (+ `.spec.ts`), Index-Migration `migrations/artifactAppIndexes.ts` +> - **WP2 – ACL/Capabilities**: `ARTIFACT_APP`-ResourceType, Owner/Editor/Viewer-Rollen, Capabilities, Audit-Actions in `data-schemas` (admin/capabilities.ts, methods/accessRole.ts + spec) +> - **WP3 – Publishing API**: `packages/api/src/artifactApps/handlers.ts` (+ `.spec.ts`, 274 Zeilen Tests), Express-Routen `api/server/routes/artifactApps.js`, Access-Middleware `canAccessArtifactAppResource.js` +> - **WP4 – Versioning**: Draft/Release/Activate/Withdraw/Rollback-Handler bereits in `handlers.ts` und `data-provider/artifactApps.ts` enthalten +> - **WP5/WP6 – Frontend (NUR TEILWEISE, WIP)**: `client/src/components/ArtifactApps/StandaloneAppView.tsx`, `AppRenderer.tsx`, react-query Queries/Mutationen in `client/src/data-provider/ArtifactApps/`, i18n-Keys in `translation.json` +> +> ### ❌ Noch offen — HIER WEITERMACHEN +> 1. **Build/Lint/Typecheck/Tests einmal komplett grün durchlaufen lassen** und alle Fehler fixen (wurde vor Abbruch nicht final verifiziert). Zwei `rollup.fallback.mjs`-Dateien wurden vom vorherigen Agenten als Workaround angelegt — prüfen, ob diese wirklich nötig sind oder ein echtes Build-Problem verdecken; ggf. richtige Lösung statt Fallback einbauen. +> 2. **WP5 fertigstellen**: Standalone-Viewer-Routen `/apps/:artifactAppId` und `/apps/:artifactAppId/version/:versionId` in den Client-Router einhängen (Routing fehlt noch), 403/404/suspended/archived-States im UI final prüfen, Viewer-Tests ergänzen. +> 3. **WP6 fertigstellen**: "Publish as App"-Aktion im Artifact-Menü ergänzen (fehlt komplett), mehrstufiger Publish-Dialog (§9.1) inkl. Wiederverwendung des bestehenden People Pickers, ACL-Rollenauswahl, Sichtbarkeit, Tool-Policy-Auswahl, Review-Summary. UI-Tests ergänzen. +> 4. **Nach Fertigstellung**: Einen **einzigen kombinierten PR** von `feat/artifact-apps-wp0-wp6` gegen `main` öffnen (per `gh pr create`), mit Beschreibung aller WP0–WP6-Änderungen, Testresultaten und bekannten Einschränkungen. +> 5. Scope bleibt **WP0–WP6** — WP7–WP13 (Embed-Sandbox, Marketplace, MCP-Bridge, HITL, Fork, Export, Hardening) sind explizit NICHT Teil dieses Durchgangs. +> +> --- + ## 1. Ziel Dieser Plan führt die Anforderungen aus folgenden LibreChat-Themen zusammen: From b9cda1be84bb0329efb666ec851a9cb947dfb5a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sch=C3=A4tzl?= Date: Thu, 16 Jul 2026 12:45:01 +0200 Subject: [PATCH 09/15] feat(artifact-apps): WP5 + WP6 frontend implementation --- .../ArtifactApps/ArtifactAppsList.tsx | 105 ++++++ .../ArtifactApps/PublishAppDialog.tsx | 309 ++++++++++++++++++ .../ArtifactApps/PublishArtifactButton.tsx | 49 +++ client/src/components/ArtifactApps/index.ts | 2 + client/src/components/Artifacts/Artifacts.tsx | 2 + client/src/hooks/Nav/useSideNavLinks.ts | 10 + client/src/locales/en/translation.json | 7 +- client/src/routes/index.tsx | 14 + 8 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 client/src/components/ArtifactApps/ArtifactAppsList.tsx create mode 100644 client/src/components/ArtifactApps/PublishAppDialog.tsx create mode 100644 client/src/components/ArtifactApps/PublishArtifactButton.tsx diff --git a/client/src/components/ArtifactApps/ArtifactAppsList.tsx b/client/src/components/ArtifactApps/ArtifactAppsList.tsx new file mode 100644 index 00000000000..1df227beaf1 --- /dev/null +++ b/client/src/components/ArtifactApps/ArtifactAppsList.tsx @@ -0,0 +1,105 @@ +import { useNavigate } from 'react-router-dom'; +import { Rocket, Lock, Globe, Users } from 'lucide-react'; +import { useListArtifactAppsQuery } from '~/data-provider'; +import { useLocalize } from '~/hooks'; +import type { TArtifactApp } from 'librechat-data-provider'; + +function visibilityIcon(v: TArtifactApp['visibility']) { + if (v === 'public') return