feat(artifacts): import and manage Claude artifacts in the workspace - #369
Conversation
A new Claude Artifacts app at /employer/artifacts, modeled on the mindmap precedent: a company-scoped pdr_ai_v2_claude_artifacts table (content stored inline, 10 MB cap, soft delete), a companyId-scoped repository, and /api/artifacts routes behind requireWorkspaceContext. Import takes three routes: paste, file upload, or a server-side fetch of a public URL through the existing SSRF guard. claude.ai share links are refused up front with a structured 422 (code "claude_share_link") — the public artifact page is a client-rendered shell behind bot protection, so a server fetch can never capture the artifact; the dialog steers to paste/upload and keeps the link as sourceUrl provenance. Artifact type (html/svg/markdown/mermaid/react/code) and title are detected from the body by shared helpers in ~/lib/artifact-content. Viewing is the codebase's first untrusted-HTML surface: html/svg render in a srcDoc iframe sandboxed without allow-same-origin (scripts run in an opaque origin, cut off from the app session), markdown/mermaid render natively, react/code show as source. The raw download route serves attachment-only with a sandbox CSP. Management mirrors mindmaps: search, folders, star, inline rename, type override, trash/restore/purge, with Studio-menu and command-palette entries. Includes a /dev/artifacts harness (in-memory fetch stub, seeded types) following the dev-preview pattern, unit tests for the detection helpers, and route tests for the import endpoint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 690e562bf4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| Accept: "text/html,image/svg+xml,text/markdown,text/plain;q=0.9,*/*;q=0.8", | ||
| }, | ||
| }); | ||
| clearTimeout(timeout); |
There was a problem hiding this comment.
Keep the timeout active while reading the response body
When a fetched server returns headers promptly but stalls or slowly dribbles the body, this clears the abort timer before response.arrayBuffer() runs, so the advertised 30-second timeout no longer applies and the import request can remain occupied indefinitely. Clear the timer in a finally block after body consumption instead.
Useful? React with 👍 / 👎.
| const arrayBuf = await response.arrayBuffer(); | ||
| if (arrayBuf.byteLength > MAX_ARTIFACT_BYTES) return null; |
There was a problem hiding this comment.
Enforce the fetch size cap while streaming
When a public URL returns a very large or unbounded response, arrayBuffer() accumulates the entire body in memory before the 10 MB check is evaluated, allowing one authenticated import to consume arbitrary server memory. Reject an oversized Content-Length early when present and stream/count response chunks so consumption stops once MAX_ARTIFACT_BYTES is crossed.
Useful? React with 👍 / 👎.
| /** Provenance — usually a claude.ai share link. Kept even for pastes. */ | ||
| sourceUrl: z.string().url().max(2048).optional(), | ||
| /** The artifact body, when pasting or uploading a file. */ | ||
| content: z.string().min(1).max(MAX_ARTIFACT_BYTES).optional(), |
There was a problem hiding this comment.
Validate artifact limits in UTF-8 bytes
For pasted or patched content containing multibyte characters, Zod's string .max() counts UTF-16 code units rather than the UTF-8 bytes represented by MAX_ARTIFACT_BYTES; for example, a payload of repeated three-byte characters can pass validation while occupying roughly 30 MB. This bypasses the documented 10 MB storage cap, so both import and update schemas should refine against Buffer.byteLength or TextEncoder length.
Useful? React with 👍 / 👎.
| const visible = useMemo(() => { | ||
| const q = query.trim().toLowerCase(); | ||
| if (!q) return items; | ||
| return items.filter(item => item.title.toLowerCase().includes(q)); |
There was a problem hiding this comment.
Send gallery searches to the server
When a workspace has more than the collection endpoint's default 200 results, this client-only title filter can never find matching artifacts outside the initially loaded slice; it also leaves the API's indexed body search unused. Pass the query through the list wrapper as q (preferably debounced) and render the returned results instead of filtering only the current page.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 690e562. Configure here.
| const arrayBuf = await response.arrayBuffer(); | ||
| if (arrayBuf.byteLength > MAX_ARTIFACT_BYTES) return null; | ||
|
|
||
| return Buffer.from(arrayBuf).toString("utf-8"); |
There was a problem hiding this comment.
URL import buffers unbounded response bodies
High Severity
fetchArtifactBody clears the abort timeout after headers arrive, then reads the full body with arrayBuffer() before applying MAX_ARTIFACT_BYTES. A missing content-type also skips the text check. A large or never-ending text/* response can exhaust memory or hang the worker, so the 10 MB cap does not protect the fetch.
Reviewed by Cursor Bugbot for commit 690e562. Configure here.
| { error: "Couldn't fetch that URL, or it didn't return text content" }, | ||
| { status: 502 } | ||
| ); | ||
| } |
There was a problem hiding this comment.
Redirect guard failures return 500
Medium Severity
fetchArtifactBody rethrows UrlGuardError so redirect-chain SSRF failures can be returned as 400, but the route only catches that error around the initial assertPublicHttpUrl. A public URL that redirects to a blocked host therefore falls through to the generic handler and returns 500.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 690e562. Configure here.
…rive-linked files The only real conflict was drizzle/meta/_journal.json, which is never hand-merged: took main's meta wholesale, dropped this branch's generated migration, and re-ran db:generate so claude_artifacts lands after main's google_drive_links in the ledger. The regenerated SQL is byte-identical to the original. types.ts (Studio nav) and schema/index.ts (barrel) auto-merged — main's connectors entries and this branch's artifacts entries coexist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Main grew a service-map ratchet while this branch was open — every /api/* route folder must name an owner. Registers artifacts as a workspace-scoped tool alongside mindmaps, with the sandbox rationale in the notes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…orkspace connections Same resolution as the previous merge: the journal is never hand-merged, so this takes main's meta wholesale and re-runs db:generate, landing claude_artifacts after workspace_connections_tokens. The SQL is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>


Summary
/employer/artifacts(Studio menu → Tools, and ⌘K): import pages, diagrams, and snippets built in Claude, then manage them workspace-wide — search, folders, star, inline rename, type override, download, trash/restore/purge.code: "claude_share_link") — verified empirically that the public artifact page is a client-rendered shell behind bot protection, so a server fetch can never capture the artifact. The dialog steers those to paste/upload and keeps the link assourceUrlprovenance.srcDociframe sandboxed withoutallow-same-origin(artifact scripts run in an opaque origin, cut off from the app session — verified live:document.cookiethrowsSecurityErrorinside the frame); markdown/mermaid render natively; react/code show as source. The[id]/rawroute is attachment-only with asandboxCSP andnosniff.Related
Follows the mindmap app precedent throughout (schema shape, repository scoping, route style, gallery UI).
Checklist
pnpm checkpasses (lint + typecheck) — zero new guardrail warningspnpm --filter @launchstack/web testpasses (full suite green; 20 new tests)packages/*changesTesting
__tests__/lib/artifact-content.test.ts).POST /api/artifacts(__tests__/api/artifacts/): paste import with detected type + derived title + company stamping, the claude.ai 422, URL fetch through the mocked SSRF guard, schema refinement rejection, and auth pass-through.20260829203458_claude_artifacts.sqldry-run applied against the dev Postgres inside a rolled-back transaction (FK topdr_ai_v2_companyresolves). Not applied for real — the dev DB also has two unrelated pending product migrations./dev/artifactsharness (in-memory fetch stub, seeded artifact of each type, non-production only): gallery, import dialog (paste detection line, claude.ai inline warning with disabled submit), sandboxed viewer for every type, star/trash/restore/folder flows, both themes.Notes for reviewers
content text) rather than via the storage layer: artifacts are single self-contained text files ≤10 MB, the viewer always reads them whole, and inline works identically on S3 and database backends. Summaries never selectcontent(search_text+ explicit column list exist for that).sandbox="allow-scripts allow-forms allow-popups allow-modals allow-downloads"deliberately omitsallow-same-origin. Nothing else in the repo sandboxes HTML;/api/files/[id]still servestext/htmlinline same-origin, which is why imported artifacts don't go through it.content_hashis already stored).🤖 Generated with Claude Code
Note
Medium Risk
The migration enables storing imported artifact bodies per company; tests document URL-fetch and Claude share-link guardrails but the schema itself is additive.
Overview
Adds a Drizzle migration for workspace-scoped
pdr_ai_v2_claude_artifacts: inlinecontent,artifact_type/import_method/source_url,content_hashandsearch_text, soft-delete viadeleted_at, and indexes on company, folder, and trash queries (FK to company with cascade delete).Test coverage locks in the import pipeline: unit tests for
~/lib/artifact-content(type detection, title derivation, Claude-hosted URL checks, search-text stripping, file extensions) and route tests forPOST /api/artifacts— paste import with detected type/title and company stamping, 422claude_share_linkwhen URL fetch targets Claude share pages (no SSRF fetch), successful URL import through the mocked public URL guard, 400 when body has no content/URL, and auth failure pass-through.Reviewed by Cursor Bugbot for commit 1326373. Bugbot is set up for automated code reviews on this repo. Configure here.