Skip to content

feat(artifacts): import and manage Claude artifacts in the workspace - #369

Merged
Deodat-Lawson merged 4 commits into
mainfrom
claude/launchstack-artifacts-import-f71f8e
Aug 30, 2026
Merged

feat(artifacts): import and manage Claude artifacts in the workspace#369
Deodat-Lawson merged 4 commits into
mainfrom
claude/launchstack-artifacts-import-f71f8e

Conversation

@Deodat-Lawson

@Deodat-Lawson Deodat-Lawson commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds a Claude Artifacts app at /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.
  • Import takes three routes: paste, file upload (10 MB cap), or server-side URL fetch through the existing SSRF guard. claude.ai share links are refused up front with a structured 422 (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 as sourceUrl provenance.
  • Establishes the codebase's first untrusted-HTML surface: html/svg render in a srcDoc iframe sandboxed without allow-same-origin (artifact scripts run in an opaque origin, cut off from the app session — verified live: document.cookie throws SecurityError inside the frame); markdown/mermaid render natively; react/code show as source. The [id]/raw route is attachment-only with a sandbox CSP and nosniff.

Related

Follows the mindmap app precedent throughout (schema shape, repository scoping, route style, gallery UI).

Checklist

  • pnpm check passes (lint + typecheck) — zero new guardrail warnings
  • pnpm --filter @launchstack/web test passes (full suite green; 20 new tests)
  • Changeset added — n/a, no packages/* changes
  • New env vars documented — n/a, none added
  • UI changes exercised in a browser (not just a green build)
  • Docs updated if behavior changed — feature is self-documenting via the import dialog copy

Testing

  • Unit tests for the shared detection/title/search-text helpers (__tests__/lib/artifact-content.test.ts).
  • Route tests for 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.
  • Migration 20260829203458_claude_artifacts.sql dry-run applied against the dev Postgres inside a rolled-back transaction (FK to pdr_ai_v2_company resolves). Not applied for real — the dev DB also has two unrelated pending product migrations.
  • Visual verification via a new /dev/artifacts harness (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 is stored inline (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 select content (search_text + explicit column list exist for that).
  • Sandbox decision: sandbox="allow-scripts allow-forms allow-popups allow-modals allow-downloads" deliberately omits allow-same-origin. Nothing else in the repo sandboxes HTML; /api/files/[id] still serves text/html inline same-origin, which is why imported artifacts don't go through it.
  • Deferred follow-ups: mindmap-style "publish to Sources", an Add-source-modal tab, and content-hash dedupe surfacing (content_hash is 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: inline content, artifact_type / import_method / source_url, content_hash and search_text, soft-delete via deleted_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 for POST /api/artifacts — paste import with detected type/title and company stamping, 422 claude_share_link when 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.

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>
@vercel

vercel Bot commented Aug 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
launch-stack Error Error Aug 30, 2026 7:37pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
pdr-ai-v2 Ignored Ignored Aug 30, 2026 7:37pm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +68 to +69
const arrayBuf = await response.arrayBuffer();
if (arrayBuf.byteLength > MAX_ARTIFACT_BYTES) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +56 to +59
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(item => item.title.toLowerCase().includes(q));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ 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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 690e562. Configure here.

{ error: "Couldn't fetch that URL, or it didn't return text content" },
{ status: 502 }
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

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>
@Deodat-Lawson
Deodat-Lawson merged commit 3b14a93 into main Aug 30, 2026
12 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant