- commit
- 92f4e4963b028ee61b8e19bbf7e02951fe475cc8
- parent
- 438f90ad102789364ce5a463dafe8875e869a59e
- author
- Dami <dami.etoile@gmail.com>
| M | apps/ | 27 | ++++++++++++--------------- |
| M | services/ | 15 | +++------------ |
| M | services/ | 24 | ++++++++++++++++++++++-- |
@@ -7,9 +7,10 @@ sidebar_position: 1
77 88 # Tiny transform99 10-tinyrelay is a self-hosted Nostr relay with custom views. A view POSTs source events to a transform URL and stores the11-answer as static artifacts attached to the source event. Diagram.zip provides12-the diagram transform at this endpoint:10+tinyrelay is a self-hosted Nostr relay with custom views. A view POSTs the11+fenced snippets of a source event to a transform URL and stores the answer as12+static artifacts attached to that event. Diagram.zip provides the diagram13+transform at this endpoint:1314 1415 ```text1516 POST https://diagram.zip/transform/tiny@@ -23,22 +24,15 @@ it directly.
2324 2425 ## Request2526 26-Send a JSON body with the source event and the fenced blocks the relay found in27-its content:27+Send a JSON body with the fenced blocks the relay found in the event content.28+The body identifies the source event but never carries it:2829 2930 ```json3031 {3132 "relay": "https://relay.example",3233 "view": "diagrams",3334 "appearance": "auto-transparent",34- "event": {35- "id": "<hex>",36- "kind": 30818,37- "pubkey": "<hex>",38- "created_at": 0,39- "content": "...",40- "tags": [["title", "Delivery flow"]]41- },35+ "source": { "id": "<hex>", "kind": 30818 },4236 "blocks": [4337 { "index": 0, "lang": "mermaid", "source": "graph TD; a-->b" },4438 { "index": 1, "lang": "python", "source": "print(1)" }@@ -51,11 +45,14 @@ its content:
5145 | `relay` | The relay origin. Informational. |5246 | `view` | The view name. Informational. |5347 | `appearance` | Optional. One of the seven SVG appearances. The default is `auto-transparent`. |54-| `event` | The source event. A `title` tag becomes the SVG title. |48+| `source` | Optional. The id and kind of the source event, kept as metadata. The endpoint reads nothing else from it. |5549 | `blocks[].index` | The block position in the event content. It is echoed back on each artifact. |5650 | `blocks[].lang` | The fence language. Blocks whose language is not a renderer are skipped. |5751 | `blocks[].source` | The diagram source. |5852 53+A body that includes an `event` field is rejected with `400`. The endpoint54+never receives event content, and this rule keeps that boundary visible.55+5956 Send these headers with the request:6057 6158 | Header | Value |@@ -146,7 +143,7 @@ The endpoint rejects a request with a JSON error in these cases:
146143 | Body larger than 1 MiB | `413` |147144 | More than 32 blocks | `413` |148145 | A block source longer than 512 KiB | `413` |149-| Unknown appearance, invalid JSON, or a malformed block | `400` |146+| Unknown appearance, invalid JSON, a malformed block, or an `event` field | `400` |150147 | Missing or wrong signature | `401` |151148 | Secret not configured | `503` |152149 @@ -7,7 +7,6 @@ export const DEFAULT_APPEARANCE: SvgAppearance = 'auto-transparent'
77 const MAX_BODY_BYTES = 1_048_57688 const MAX_BLOCKS = 3299 const MAX_BLOCK_SOURCE_LENGTH = 524_28810-const MAX_TITLE_LENGTH = 2001110 const RENDER_TIMEOUT_MS = 20_0001211 const RENDER_CONCURRENCY = 61312 const MAX_ERROR_DETAIL_LENGTH = 200@@ -140,16 +139,6 @@ function objectValue(value: unknown, name: string): Record<string, unknown> {
140139 return value as Record<string, unknown>141140 }142141 143-function eventTitle(event: unknown): string {144- if (event === undefined) return ''145- const tags = objectValue(event, 'event').tags146- if (!Array.isArray(tags)) return ''147- for (const tag of tags) {148- if (Array.isArray(tag) && tag[0] === 'title' && typeof tag[1] === 'string') return tag[1].slice(0, MAX_TITLE_LENGTH)149- }150- return ''151-}152-153142 function parseBlock(value: unknown, position: number): TinyBlock {154143 const block = objectValue(value, `blocks[${position}]`)155144 const index = block.index ?? position@@ -166,6 +155,8 @@ function parseTransformRequest(bytes: Uint8Array): TinyTransformRequest {
166155 throw new TransformError(400, 'invalid_json', 'Transform request is not valid JSON.')167156 }168157 const body = objectValue(input, 'request')158+ if ('event' in body) throw new TransformError(400, 'unexpected_field', 'The transform request must not include the source event; send source and blocks only.')159+ if (body.source !== undefined) objectValue(body.source, 'source')169160 if (!Array.isArray(body.blocks)) throw new TransformError(400, 'invalid_request', 'blocks must be an array.')170161 if (body.blocks.length > MAX_BLOCKS) throw new TransformError(413, 'too_many_blocks', `blocks cannot contain more than ${MAX_BLOCKS} entries.`)171162 const appearance = body.appearance ?? DEFAULT_APPEARANCE@@ -174,7 +165,7 @@ function parseTransformRequest(bytes: Uint8Array): TinyTransformRequest {
174165 }175166 return {176167 appearance: appearance as SvgAppearance,177- metadata: { title: eventTitle(body.event), description: '' },168+ metadata: { title: '', description: '' },178169 blocks: body.blocks.map(parseBlock),179170 }180171 }@@ -36,7 +36,7 @@ function payload(blocks: Array<Record<string, unknown>>, extra: Record<string, u
3636 return JSON.stringify({3737 relay: 'https://relay.example',3838 view: 'diagrams',39- event: { id: 'ab'.repeat(32), kind: 30818, pubkey: 'cd'.repeat(32), created_at: 0, content: '', tags: [['title', 'Delivery']] },39+ source: { id: 'ab'.repeat(32), kind: 30818 },4040 blocks,4141 ...extra,4242 })@@ -137,7 +137,7 @@ describe('POST /transform/tiny', () => {
137137 source: 'graph TD; a-->b',138138 format: 'svg',139139 options: {},140- metadata: { title: 'Delivery', description: '' },140+ metadata: { title: '', description: '' },141141 presentation: { background: '', padding: 0, frame: false },142142 })143143 expect(unit.calls[0].body).not.toHaveProperty('engine')@@ -212,15 +212,35 @@ describe('POST /transform/tiny', () => {
212212 it('rejects unknown appearances and malformed requests', async () => {213213 const appearance = await transform(payload([], { appearance: 'sepia' }))214214 const blocks = await transform(payload([{ index: 0, lang: 'mermaid' }]))215+ const source = await transform(payload([], { source: 'not-an-object' }))215216 const invalidJson = await transform('{')216217 217218 expect(appearance.status).toBe(400)218219 expect((await appearance.json() as { error: { code: string } }).error.code).toBe('invalid_appearance')219220 expect(blocks.status).toBe(400)221+ expect(source.status).toBe(400)220222 expect(invalidJson.status).toBe(400)221223 expect((await invalidJson.json() as { error: { code: string } }).error.code).toBe('invalid_json')222224 })223225 226+ it('rejects a body that carries the source event', async () => {227+ const fetchSpy = vi.fn()228+ vi.stubGlobal('fetch', fetchSpy)229+ const response = await transform(payload([{ index: 0, lang: 'mermaid', source: 'graph TD; a-->b' }], { event: { id: 'ab'.repeat(32), kind: 30818, content: 'secret' } }))230+231+ expect(response.status).toBe(400)232+ expect((await response.json() as { error: { code: string } }).error.code).toBe('unexpected_field')233+ expect(fetchSpy).not.toHaveBeenCalled()234+ })235+236+ it('accepts a body without source metadata', async () => {237+ unitFetch(() => svgResponse(MERMAID_CANONICAL))238+ const response = await transform(JSON.stringify({ blocks: [{ index: 0, lang: 'mermaid', source: 'graph TD; a-->b' }] }))239+240+ expect(response.status).toBe(200)241+ expect((await response.json() as { artifacts: unknown[] }).artifacts).toHaveLength(1)242+ })243+224244 it('enforces the body, block count and block source limits', async () => {225245 const fetchSpy = vi.fn()226246 vi.stubGlobal('fetch', fetchSpy)