clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

fix: restore draft sharing actions

Dami committed 5f4adc6
commit
5f4adc6ba8fb340cf3fb0a0bf513a42241350e7e
parent
8938728b85b41dfd9ea9ef5875b16a27dda76e3f
author
Dami <dami.etoile@gmail.com>
12 files changed, +237 -102
Mapps/docs/docs/collaboration/sharing.md23++++++++---
Mapps/docs/docs/create/index.md10++++-
Mapps/docs/static/llms-full.txt33++++++++++++----
Mapps/docs/static/skills/diagramming/SKILL.md6+++
Mapps/editor/PERSISTENCE.md23+++++++++++
Mapps/editor/src/main.js57+++++++++++++++++++-------
Mapps/editor/src/state.js27+++----------
Mapps/editor/test/state.test.js65++++++++----------------------
Mservices/shell/src/index.ts56++++++++++++++++++++++++++
Mservices/shell/test/index.test.ts31++++++++++++++
Mservices/shell/wrangler.jsonc2+-
Mskills/diagramming/SKILL.md6+++
M apps/docs/docs/collaboration/sharing.md +17 -6
@@ -15,8 +15,11 @@ The Share menu contains these actions:
1515 - **Publish** creates or updates an open server alias.1616 - **Encrypt & Publish** creates or updates a password-protected server alias.1717 - **Save as File** downloads an editable enriched SVG without server persistence.18-- **Copy SVG URL** copies an image URL for an open published alias.19-- **Copy as Markdown** copies a Markdown image using that SVG URL.18+- **Copy SVG URL** copies the stable render URL for an open published alias. For19+ an anonymous draft, it copies a self-contained URL with the editable SVG data20+ packed into the URL itself.21+- **Copy as Markdown** copies a Markdown image using the same stable or packed22+ SVG URL.2023 2124 Lock, password change, and password removal actions stay beside the content2225 they affect. Save as File is available for a local draft and does not require
@@ -97,14 +100,22 @@ The editor does not overwrite a newer published revision without your choice.
97100 98101 ## Embeds99102 100-The Share dialog provides an SVG image link and a Markdown image link for an101-open published diagram. These links point to the published render.103+The Share dialog provides an SVG image link and a Markdown image link without104+requiring publication. An open published alias uses its stable server render.105+An anonymous draft uses a compressed, self-contained editable SVG URL and does106+not create server state.107+108+Packed URLs contain the rendered SVG, diagram source, options, metadata, and109+presentation settings. Anyone with the URL can inspect that content and reopen110+it as an editable SVG. Do not use a packed URL for secrets. Diagram.zip rejects111+a copy when the complete URL would exceed the safe request limit. Save the112+editable SVG as a file or publish it instead.102113 103114 Locked diagrams cannot be embedded. An embed request cannot ask for a password,104115 so the server does not return encrypted diagram renders as public images.105116 106-Publish the diagram again before you share if the Share dialog says that the107-links show an older published version.117+Publish the diagram again before you share if the Share dialog says that an118+alias link shows an older published version.108119 109120 ## Importing an editable SVG110121
M apps/docs/docs/create/index.md +8 -2
M apps/docs/static/llms-full.txt +25 -8
@@ -78,8 +78,14 @@ when the server copy requires password protection. Both actions opt into server
7878 persistence.7979 8080 Use an edit link when people must save changes. The Share menu also provides81-**Copy SVG URL** and **Copy as Markdown** for image embeds. Read [Share a82-diagram](/collaboration/sharing/) before you send an edit link.81+**Copy SVG URL** and **Copy as Markdown** for image embeds. Anonymous drafts use82+a self-contained packed SVG URL; published diagrams use their stable alias83+render. Read [Share a diagram](/collaboration/sharing/) before you send an edit84+link.85+86+The save dropdown selects **Publish**, **Encrypt & Publish**, or **Save as File**87+as the main button action. Selecting an action does not run it. Copy actions in88+the dropdown run immediately.8389 8490 ---8591
@@ -373,8 +379,11 @@ The Share menu contains these actions:
373379 - **Publish** creates or updates an open server alias.374380 - **Encrypt & Publish** creates or updates a password-protected server alias.375381 - **Save as File** downloads an editable enriched SVG without server persistence.376-- **Copy SVG URL** copies an image URL for an open published alias.377-- **Copy as Markdown** copies a Markdown image using that SVG URL.382+- **Copy SVG URL** copies the stable render URL for an open published alias. For383+ an anonymous draft, it copies a self-contained URL with the editable SVG data384+ packed into the URL itself.385+- **Copy as Markdown** copies a Markdown image using the same stable or packed386+ SVG URL.378387 379388 Lock, password change, and password removal actions stay beside the content380389 they affect. Save as File is available for a local draft and does not require
@@ -455,14 +464,22 @@ The editor does not overwrite a newer published revision without your choice.
455464 456465 ## Embeds457466 458-The Share dialog provides an SVG image link and a Markdown image link for an459-open published diagram. These links point to the published render.467+The Share dialog provides an SVG image link and a Markdown image link without468+requiring publication. An open published alias uses its stable server render.469+An anonymous draft uses a compressed, self-contained editable SVG URL and does470+not create server state.471+472+Packed URLs contain the rendered SVG, diagram source, options, metadata, and473+presentation settings. Anyone with the URL can inspect that content and reopen474+it as an editable SVG. Do not use a packed URL for secrets. Diagram.zip rejects475+a copy when the complete URL would exceed the safe request limit. Save the476+editable SVG as a file or publish it instead.460477 461478 Locked diagrams cannot be embedded. An embed request cannot ask for a password,462479 so the server does not return encrypted diagram renders as public images.463480 464-Publish the diagram again before you share if the Share dialog says that the465-links show an older published version.481+Publish the diagram again before you share if the Share dialog says that an482+alias link shows an older published version.466483 467484 ## Importing an editable SVG468485
M apps/docs/static/skills/diagramming/SKILL.md +6 -0
M apps/editor/PERSISTENCE.md +23 -0
@@ -45,6 +45,18 @@ URL therefore copies a read link, not an edit link.
4545 The existing self-contained diagram hash format is not part of the new public4646 contract and does not need a compatibility path.4747 48+Anonymous SVG and Markdown embeds use a separate self-contained URL:49+50+```text51+https://diagram.zip/svg/{compressed_editable_svg}52+```53+54+The URL contains the rendered SVG and its editable Diagram.zip document. It is55+immutable, creates no alias, and writes nothing to D1 or R2. The shell inflates56+the bounded payload, revalidates and sanitizes the enriched SVG, and returns it57+with an SVG content type and restrictive CSP. Oversized URLs fail closed and58+the UI directs the user to Save as File or Publish.59+4860 ## Identifiers and capabilities4961 5062 - `alias_id`: 12 cryptographically random bytes encoded as unpadded Base64URL,
@@ -193,6 +205,10 @@ the same appearance on stable SVG and Markdown links. `raw` uses the renderer
193205 canvas controls. A shared appearance ignores those legacy controls and194206 materializes its own palette and, for framed appearances, its own canvas.195207 208+An anonymous draft instead exports the current deterministic editable SVG and209+packs those bytes into `/svg/{compressed_editable_svg}`. This path does not210+proxy a render request and supports both HTTP and client renderer outputs.211+196212 ## Editor and share UX197213 198214 Open drafts continue to autosave locally and render after the existing debounce.
@@ -206,6 +222,8 @@ remains recoverable while file export, publishing, sharing, privacy, and type
206222 changes stay blocked.207223 208224 - New draft: **Save as File** downloads an editable SVG and creates no alias.225+- New draft: **Copy SVG URL** and **Copy as Markdown** use a packed editable SVG226+ URL and create no alias.209227 - New draft: **Publish** creates an alias and changes the browser to its read URL.210228 - Writable alias: **Publish** updates with the last revision.211229 - Anonymous example: the reset icon discards its local draft after confirmation.
@@ -216,6 +234,11 @@ changes stay blocked.
216234 link. SVG and Markdown embeds are visibly disabled with the explanation that217235 encrypted diagrams cannot be embedded.218236 237+The header dropdown selects Publish, Encrypt & Publish, or Save as File as the238+main button action without executing it. Encrypt & Publish opens the password239+dialog only when the user clicks the selected main action. Copy actions remain240+immediate commands.241+219242 The password and edit capability should be sent through separate channels when220243 practical. The edit link itself is a bearer credential and the UI must label it221244 that way.
M apps/editor/src/main.js +41 -16
@@ -14,6 +14,7 @@ import {
1414 documentTitle,1515 normalizeMetadata,1616 normalizePresentation,17+ packedSvgUrl,1718 } from './state.js'1819 import {1920 aliasIdFromPath,
@@ -103,7 +104,7 @@ document.querySelector('#app').innerHTML = `
103104 <button class="save-menu-toggle" id="save-menu-toggle" type="button" popovertarget="save-menu" aria-label="Choose save action" title="Choose save action">⌄</button>104105 <div class="action-menu save-action-menu" id="save-menu" popover>105106 <button type="button" data-primary-save="publish">Publish</button>106- <button type="button" data-save-quick-action="encrypt-publish">Encrypt &amp; Publish</button>107+ <button type="button" data-primary-save="encrypt-publish">Encrypt &amp; Publish</button>107108 <button type="button" data-primary-save="file">Save as File</button>108109 <button type="button" data-save-quick-action="svg-url">Copy SVG URL</button>109110 <button type="button" data-save-quick-action="markdown">Copy as Markdown</button>
@@ -766,7 +767,10 @@ function selectedSaveAction() {
766767 }767768 768769 async function performPrimarySaveAction() {769- return selectedSaveAction() === 'publish' ? saveDiagram() : saveEditableFile()770+ const action = selectedSaveAction()771+ if (action === 'publish') return saveDiagram()772+ if (action === 'encrypt-publish') return encryptAndPublish()773+ return saveEditableFile()770774 }771775 772776 async function saveEditableFile() {
@@ -896,8 +900,9 @@ function populateShareDialog() {
896900 : hasAlias897901 ? 'This device has read-only access. Publish a copy to create a new edit link.'898902 : 'The edit link is a bearer credential and appears after publishing.'899- document.querySelector('[data-share-action="svg-url"]').disabled = !svgLink900- document.querySelector('[data-share-action="markdown"]').disabled = !svgLink903+ const embedAvailable = hasAlias ? Boolean(svgLink) : remote.mode !== 'locked'904+ document.querySelector('[data-share-action="svg-url"]').disabled = !embedAvailable905+ document.querySelector('[data-share-action="markdown"]').disabled = !embedAvailable901906 902907 const shareStatus = document.querySelector('#share-status')903908 if (!hasAlias) {
@@ -923,25 +928,39 @@ async function performShareAction(action) {
923928 if (action === 'encrypt-publish') completed = await encryptAndPublish()924929 if (action === 'file') completed = await saveEditableFile()925930 if (action === 'svg-url' || action === 'markdown') {926- const value = publishedEmbedValue(action)927- if (!value) return928931 try {932+ const value = await embedValue(action)933+ if (!value) return929934 await copy(value)930935 showToast(action === 'svg-url' ? 'SVG URL copied' : 'Markdown copied')931936 completed = true932- } catch {933- showToast('Could not copy to the clipboard.', 4000)937+ } catch (error) {938+ showToast(error instanceof Error ? error.message : 'Could not copy to the clipboard.', 4000)934939 }935940 }936941 if (completed) populateShareDialog()937942 }938943 939-function publishedEmbedValue(action) {940- if (!remote.aliasId || remote.savedMode === 'locked' || !remote.savedState) return ''941- const appearance = normalizePresentation(remote.savedState.presentation).appearance942- const svgLink = stableRenderUrl(location.origin, remote.aliasId, 'svg', appearance)944+async function embedValue(action) {945+ let state946+ let svgLink947+ if (remote.aliasId) {948+ if (remote.savedMode === 'locked' || !remote.savedState) return ''949+ state = remote.savedState950+ const appearance = normalizePresentation(state.presentation).appearance951+ svgLink = stableRenderUrl(location.origin, remote.aliasId, 'svg', appearance)952+ } else {953+ if (remote.mode === 'locked') throw new Error('Encrypted diagrams cannot use public SVG embeds.')954+ if (!ensureValidDetails()) return ''955+ commitState(false)956+ state = currentState()957+ await preview.render(state)958+ const canonical = preview.canonicalSvgFor(state)959+ if (!canonical) throw new Error('The current diagram has not finished rendering.')960+ svgLink = packedSvgUrl(location.origin, exportEditableSvg(canonical, state))961+ }943962 if (action === 'svg-url') return svgLink944- const title = remote.savedState.meta.title.trim().replace(/[\[\]\r\n]/g, ' ') || 'Diagram'963+ const title = state.meta.title.trim().replace(/[\[\]\r\n]/g, ' ') || 'Diagram'945964 return `![${title}](${svgLink})`946965 }947966
@@ -1383,11 +1402,11 @@ function updateSaveButton() {
13831402 const fileDirty = action === 'file'13841403 && (lastFileSnapshot === null || lastFileSnapshot !== workingStateSnapshot(currentState()))13851404 let state = 'save'1386- let label = action === 'file' ? 'Save as File' : 'Publish'1405+ let label = action === 'file' ? 'Save as File' : action === 'encrypt-publish' ? 'Encrypt & Publish' : 'Publish'13871406 let disabled = false13881407 if (saving) {13891408 state = 'saving'1390- label = action === 'file' ? 'Saving File' : 'Publishing'1409+ label = action === 'file' ? 'Saving File' : action === 'encrypt-publish' ? 'Encrypting & Publishing' : 'Publishing'13911410 disabled = true13921411 } else if (action === 'file' && !fileDirty) {13931412 state = 'saved'
@@ -1400,6 +1419,10 @@ function updateSaveButton() {
14001419 } else if (action === 'publish' && remote.aliasId && !remote.writeCapability) {14011420 state = 'copy'14021421 label = 'Publish a Copy'1422+ } else if (action === 'encrypt-publish' && remote.aliasId && remote.mode === 'locked' && !remote.dirty) {1423+ state = 'saved'1424+ label = 'Published'1425+ disabled = true14031426 }14041427 button.dataset.saveState = state14051428 button.dataset.saveAction = action
@@ -1413,7 +1436,9 @@ function updateSaveButton() {
14131436 option.dataset.selected = String(selected)14141437 option.setAttribute('aria-pressed', String(selected))14151438 })1416- const publishedEmbedAvailable = Boolean(remote.aliasId && remote.savedMode !== 'locked' && remote.savedState)1439+ const publishedEmbedAvailable = remote.aliasId1440+ ? Boolean(remote.savedMode !== 'locked' && remote.savedState)1441+ : remote.mode !== 'locked'14171442 document.querySelectorAll('[data-save-quick-action="svg-url"], [data-save-quick-action="markdown"]').forEach(option => {14181443 option.disabled = !publishedEmbedAvailable14191444 })
M apps/editor/src/state.js +6 -21
@@ -84,26 +84,11 @@ export function normalizePresentation(presentation = {}) {
8484 return { background, padding, frame, appearance }8585 }8686 87-function hasMetadata(meta) {88- return Boolean(meta.title || meta.description)89-}90-91-function hasPresentation(presentation) {92- return Boolean(presentation.background || presentation.padding || presentation.frame || presentation.appearance !== 'raw')93-}94-95-export function imageUrl(origin, { type, source, options = {}, meta = {}, presentation = {} }) {96- const url = new URL(`/${encodeURIComponent(type)}/svg/${encodeText(source)}`, origin)97- for (const [name, value] of Object.entries(options)) {98- url.searchParams.set(name, String(value))99- }100- const normalizedMeta = normalizeMetadata(meta)101- const normalizedPresentation = normalizePresentation(presentation)102- if (hasMetadata(normalizedMeta) || hasPresentation(normalizedPresentation)) {103- const payload = {}104- if (hasMetadata(normalizedMeta)) payload.meta = normalizedMeta105- if (hasPresentation(normalizedPresentation)) payload.presentation = normalizedPresentation106- url.searchParams.set('dz', encodeText(JSON.stringify(payload)))87+export function packedSvgUrl(origin, source) {88+ if (typeof source !== 'string' || !source.startsWith('<svg')) throw new Error('Packed SVG must be a complete SVG document.')89+ const url = new URL(`/svg/${encodeText(source)}`, origin).toString()90+ if (url.length > MAX_IMAGE_URL_LENGTH) {91+ throw new Error('This diagram is too large for a packed SVG URL. Save it as a file or publish it instead.')10792 }108- return url.toString()93+ return url10994 }
M apps/editor/test/state.test.js +17 -48
@@ -1,14 +1,13 @@
11 import assert from 'node:assert/strict'2-import { readFileSync } from 'node:fs'32 import test from 'node:test'43 import {54 decodeText,65 documentTitle,76 encodeText,8- imageUrl,97 MAX_IMAGE_URL_LENGTH,108 normalizeMetadata,119 normalizePresentation,10+ packedSvgUrl,1211 } from '../src/state.js'1312 1413 test('uses the Kroki-compatible zlib + base64url encoding', () => {
@@ -33,58 +32,28 @@ test('validates metadata values', () => {
3332 assert.throws(() => normalizeMetadata({ title: 42 }), /metadata/)3433 })3534 36-test('builds an encoded image URL with options', () => {37- const url = new URL(imageUrl('https://diagram.zip', {38- type: 'd2',39- source: 'a -> b',40- options: { theme: 200 },41- }))42- assert.match(url.pathname, /^\/d2\/svg\/[A-Za-z0-9_-]+$/)43- assert.equal(url.searchParams.get('theme'), '200')44-})45-46-test('carries metadata in a compact static SVG query payload', () => {47- const url = new URL(imageUrl('https://diagram.zip', {48- type: 'd2',49- source: 'a -> b',50- meta: { title: 'A to B', description: 'A points to B.' },51- }))52- assert.deepEqual(JSON.parse(decodeText(url.searchParams.get('dz'))), {53- meta: { title: 'A to B', description: 'A points to B.' },54- })55-})56-57-test('carries presentation in the static SVG payload', () => {58- const url = new URL(imageUrl('https://diagram.zip', {59- type: 'd2',60- source: 'a -> b',61- presentation: { background: '#f4f4f4', padding: 24, frame: true },62- }))63- assert.deepEqual(JSON.parse(decodeText(url.searchParams.get('dz'))), {64- presentation: { background: '#f4f4f4', padding: 24, frame: true, appearance: 'raw' },65- })66-})67-68-test('carries a shared SVG appearance in presentation state', () => {69- const url = new URL(imageUrl('https://diagram.zip', {70- type: 'd2',71- source: 'a -> b',72- presentation: { appearance: 'auto-transparent' },73- }))74- assert.deepEqual(JSON.parse(decodeText(url.searchParams.get('dz'))), {75- presentation: { background: '', padding: 0, frame: false, appearance: 'auto-transparent' },76- })35+test('packs a complete editable SVG into a self-contained image URL', () => {36+ const source = '<svg xmlns="http://www.w3.org/2000/svg" data-dz-document="1"><text>hello</text></svg>'37+ const url = new URL(packedSvgUrl('https://diagram.zip', source))38+ assert.match(url.pathname, /^\/svg\/[A-Za-z0-9_-]+$/)39+ assert.equal(decodeText(url.pathname.slice('/svg/'.length)), source)7740 })7841 7942 test('keeps shared image URLs below the edge request limit', () => {8043 assert.equal(MAX_IMAGE_URL_LENGTH, 15_000)8144 })8245 83-test('fits the bundled periodic-table example in a shared image URL', () => {84- const source = readFileSync(new URL('../../../examples/diagrams/tikz.tex', import.meta.url), 'utf8')85- const url = imageUrl('https://diagram.zip', { type: 'tikz', source })86- assert.ok(url.length > 4096)87- assert.ok(url.length <= MAX_IMAGE_URL_LENGTH)46+test('rejects packed SVG URLs that exceed the edge request limit', () => {47+ let seed = 0x1234567848+ let payload = ''49+ for (let index = 0; index < 30_000; index++) {50+ seed = (seed * 1664525 + 1013904223) >>> 051+ payload += String.fromCharCode(33 + seed % 90)52+ }53+ assert.throws(54+ () => packedSvgUrl('https://diagram.zip', `<svg xmlns="http://www.w3.org/2000/svg"><metadata>${payload}</metadata></svg>`),55+ /too large for a packed SVG URL/,56+ )8857 })8958 9059 test('uses the diagram title as the unbranded zip document title', () => {
M services/shell/src/index.ts +56 -0
@@ -1,4 +1,8 @@
1+import { attachEditableDocument, extractEditableDocument } from '../../../shared/svg/index.js'2+13 const ALIAS_PATH = /^\/d\/[A-Za-z0-9_-]{16}$/4+const PACKED_SVG_PATH = /^\/svg\/([A-Za-z0-9_-]+)$/5+const MAX_PACKED_SVG_LENGTH = 5_242_88026 37 function textResponse(message: string, status: number, method: string): Response {48 return new Response(method === 'HEAD' ? null : message, {
@@ -27,6 +31,55 @@ async function applicationShell(request: Request, assets: Fetcher): Promise<Resp
2731 })2832 }2933 34+function packedBytes(value: string): Uint8Array {35+ const normalized = value.replaceAll('-', '+').replaceAll('_', '/')36+ const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4)37+ const binary = atob(padded)38+ return Uint8Array.from(binary, character => character.charCodeAt(0))39+}40+41+async function inflatePackedSvg(value: string): Promise<string> {42+ const stream = new Blob([packedBytes(value)]).stream().pipeThrough(new DecompressionStream('deflate'))43+ const reader = stream.getReader()44+ const chunks: Uint8Array[] = []45+ let length = 046+ while (true) {47+ const { done, value: chunk } = await reader.read()48+ if (done) break49+ length += chunk.byteLength50+ if (length > MAX_PACKED_SVG_LENGTH) {51+ await reader.cancel()52+ throw new Error('Packed SVG is too large.')53+ }54+ chunks.push(chunk)55+ }56+ const bytes = new Uint8Array(length)57+ let offset = 058+ for (const chunk of chunks) {59+ bytes.set(chunk, offset)60+ offset += chunk.byteLength61+ }62+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes)63+}64+65+async function packedSvgResponse(request: Request, payload: string): Promise<Response> {66+ try {67+ const source = await inflatePackedSvg(payload)68+ const safeSource = attachEditableDocument(source, extractEditableDocument(source))69+ return new Response(request.method === 'HEAD' ? null : safeSource, {70+ headers: {71+ 'cache-control': 'public, max-age=31536000, immutable',72+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",73+ 'content-type': 'image/svg+xml; charset=utf-8',74+ 'x-content-type-options': 'nosniff',75+ 'x-diagram-document': 'editable-svg-1',76+ },77+ })78+ } catch {79+ return textResponse('Invalid packed SVG.', 400, request.method)80+ }81+}82+3083 export default {3184 async fetch(request, env): Promise<Response> {3285 const url = new URL(request.url)
@@ -39,6 +92,9 @@ export default {
3992 return applicationShell(request, env.ASSETS)4093 }4194 95+ const packedSvg = url.pathname.match(PACKED_SVG_PATH)96+ if (packedSvg) return packedSvgResponse(request, packedSvg[1])97+4298 if (url.pathname === '/healthz') {4399 return textResponse('ok', 200, request.method)44100 }
M services/shell/test/index.test.ts +31 -0
@@ -1,6 +1,14 @@
11 import { env, SELF } from 'cloudflare:test'2+import { deflate } from 'pako'23 import { describe, expect, it } from 'vitest'34 5+function packedSvg(source: string): string {6+ const bytes = deflate(new TextEncoder().encode(source), { level: 9 })7+ let binary = ''8+ for (const byte of bytes) binary += String.fromCharCode(byte)9+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')10+}11+412 describe('diagram.zip shell', () => {513 it('serves the application at the root', async () => {614 const response = await SELF.fetch('https://diagram.zip/')
@@ -26,6 +34,29 @@ describe('diagram.zip shell', () => {
2634 expect(legacyRenderer.status).toBe(404)2735 })2836 37+ it('serves a safe immutable editable SVG from a packed anonymous URL', async () => {38+ const document = '{"diagram":{"options":{},"presentation":{"appearance":"raw","background":"","frame":false,"padding":0},"source":"graph TD\\n A --&gt; B","type":"mermaid"},"metadata":{"description":"","title":"Packed draft"},"schema":1}'39+ const source = `<svg xmlns="http://www.w3.org/2000/svg" data-dz-schema="1" data-dz-document="1"><metadata data-dz-kind="document" data-dz-schema="1">${document}</metadata><script>alert(1)</script><rect width="10" height="10" onload="alert(1)"/></svg>`40+ const response = await SELF.fetch(`https://diagram.zip/svg/${packedSvg(source)}`)41+ const body = await response.text()42+43+ expect(response.status).toBe(200)44+ expect(response.headers.get('content-type')).toContain('image/svg+xml')45+ expect(response.headers.get('cache-control')).toBe('public, max-age=31536000, immutable')46+ expect(response.headers.get('x-diagram-document')).toBe('editable-svg-1')47+ expect(body).toContain('data-dz-document="1"')48+ expect(body).toContain('Packed draft')49+ expect(body).not.toContain('<script')50+ expect(body).not.toContain('onload=')51+ })52+53+ it('rejects malformed packed SVG payloads', async () => {54+ const response = await SELF.fetch('https://diagram.zip/svg/not-deflate')55+56+ expect(response.status).toBe(400)57+ expect(await response.text()).toBe('Invalid packed SVG.')58+ })59+2960 it('does not restore the legacy root POST endpoint', async () => {3061 const response = await SELF.fetch('https://diagram.zip/', {3162 method: 'POST',
M services/shell/wrangler.jsonc +1 -1
M skills/diagramming/SKILL.md +6 -0