@jokerized/decksmith 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ /** One stop, as a host sees it. */
2
+ export interface PlayerStop {
3
+ i: number;
4
+ label: string;
5
+ notes?: string;
6
+ }
7
+ export declare class DecksmithPlayer extends HTMLElement {
8
+ #private;
9
+ static observedAttributes: string[];
10
+ /** Every stop the deck reported, empty until `ds-ready`. */
11
+ get stops(): PlayerStop[];
12
+ /** Where the deck is now. */
13
+ get at(): number;
14
+ /** Whether the deck answered the handshake. */
15
+ get connected(): boolean;
16
+ connectedCallback(): void;
17
+ disconnectedCallback(): void;
18
+ attributeChangedCallback(name: string, was: string | null, now: string | null): void;
19
+ next(): void;
20
+ prev(): void;
21
+ go(at: number): void;
22
+ play(on?: boolean): void;
23
+ }
24
+ /**
25
+ * Register the element, once.
26
+ *
27
+ * GUARDED AND PARAMETERISED because `customElements.define` throws on a
28
+ * duplicate name, and a host page that imports this twice — two bundlers, or a
29
+ * hot reload — should get a no-op rather than an exception that takes the page
30
+ * down. The tag is an argument so a consumer whose page already owns
31
+ * `decksmith-player` can mount it under another name.
32
+ *
33
+ * NOT called at import time. A module with a side effect cannot be imported for
34
+ * its types, and `player-element.ts` exists precisely so the side-effecting
35
+ * entry point is a separate file a consumer opts into.
36
+ */
37
+ export declare function define(tag?: string): void;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * What a host page and a deck say to each other.
3
+ *
4
+ * ONE FILE, IMPORTED BY BOTH ENDS, so `tsc` checks the sender against the
5
+ * receiver. The alternative — a string literal in the runtime and a matching one
6
+ * in the element — is two copies of a contract that drift the first time a field
7
+ * is added, and the drift is invisible because `postMessage` takes `any` and a
8
+ * message nobody understands is simply ignored.
9
+ *
10
+ * WHY A BRIDGE AT ALL, rather than hosting the deck in the page. A deck is a
11
+ * whole document with its own fonts, GSAP, KaTeX, a vendored player and a ready
12
+ * gate, and `frameOf` in runtime.ts can only reach a SAME-ORIGIN frame — it
13
+ * returns null otherwise and the runtime merely warns, so a deck served from
14
+ * another origin would navigate perfectly and paint nothing, silently. Keeping
15
+ * the frame keeps that same-origin pair inside one directory, where it is always
16
+ * true, and makes the host link `postMessage`, which does not care about origin.
17
+ *
18
+ * VERSION COUPLING IS THE HAZARD. Every deck published before this existed has
19
+ * no listener, and a deck is a static artifact that outlives the tool that made
20
+ * it. So silence is a SUPPORTED state, not an error: the element times out, says
21
+ * so with a distinct code, and leaves the deck usable — it is still a deck in a
22
+ * frame, and its own keyboard still works.
23
+ */
24
+ /** The channel name, on every message in both directions. */
25
+ export declare const CHANNEL = "decksmith-deck";
26
+ /** Sent by the host once the frame has loaded, to open the conversation. */
27
+ export interface Hello {
28
+ channel: typeof CHANNEL;
29
+ type: "hello";
30
+ }
31
+ /**
32
+ * The deck's answer, and the only message that establishes the host's origin.
33
+ *
34
+ * NOTHING IS POSTED TO "*" AFTER THIS. A stop carries the slide's speaker
35
+ * notes, and any page on the internet can put a deck in a frame — posting
36
+ * wildcard would hand an arbitrary framer the presenter's notes. The origin is
37
+ * taken from the `hello` event and every later message is addressed to it.
38
+ */
39
+ export interface Ready {
40
+ channel: typeof CHANNEL;
41
+ type: "ready";
42
+ /** Every stop the deck can land on, in order. */
43
+ stops: {
44
+ i: number;
45
+ label: string;
46
+ notes?: string;
47
+ }[];
48
+ /** Where it is now. */
49
+ at: number;
50
+ }
51
+ /** Where the deck landed, after any move. */
52
+ export interface Stopped {
53
+ channel: typeof CHANNEL;
54
+ type: "stop";
55
+ at: number;
56
+ total: number;
57
+ label: string;
58
+ notes?: string;
59
+ playing: boolean;
60
+ }
61
+ /** What a host may ask for. Deliberately small: everything else is a deck concern. */
62
+ export type Command = {
63
+ channel: typeof CHANNEL;
64
+ type: "next";
65
+ } | {
66
+ channel: typeof CHANNEL;
67
+ type: "prev";
68
+ } | {
69
+ channel: typeof CHANNEL;
70
+ type: "go";
71
+ at: number;
72
+ } | {
73
+ channel: typeof CHANNEL;
74
+ type: "play";
75
+ on: boolean;
76
+ };
77
+ export type FromDeck = Ready | Stopped;
78
+ export type ToDeck = Hello | Command;
79
+ /** A message from us, rather than from anything else sharing the window. */
80
+ export declare function isOurs(data: unknown): data is FromDeck | ToDeck;
@@ -57,3 +57,39 @@ export declare function parseHash(hash: string): Pos | null;
57
57
  * longer emit falls back to its slide rather than to nothing.
58
58
  */
59
59
  export declare function findStop(stops: readonly Stop[], pos: Pos): number;
60
+ interface Player extends HTMLElement {
61
+ ready?: boolean;
62
+ seek: (t: number) => void;
63
+ pause?: () => void;
64
+ }
65
+ /** Only what we call on a GSAP timeline. */
66
+ interface Seekable {
67
+ seek: (t: number) => void;
68
+ }
69
+ interface Frame {
70
+ doc: Document;
71
+ timelines: Record<string, Seekable>;
72
+ }
73
+ /**
74
+ * The composition, reached through the player's iframe.
75
+ *
76
+ * Same-origin only, which means a deck must be served over http — opening
77
+ * `deck.html` from the filesystem gives the iframe an opaque origin and we
78
+ * cannot drive it. `present()` says so out loud rather than rendering blank.
79
+ */
80
+ export declare function frameOf(player: Player): Frame | null;
81
+ /** One player-page video, keyed in the island by the scene that draws its still. */
82
+ export interface ClipSpec {
83
+ /** Already in embeddable form — `embedUrl` in src/pack/media.ts did that at build time. */
84
+ url: string;
85
+ /** The figure's caption. It titles the frame, which is all a screen reader gets. */
86
+ title: string;
87
+ }
88
+ /**
89
+ * Read the island, defensively, for the same reason `parseNarration` is
90
+ * defensive: a deck built before this existed has no island, and one built by a
91
+ * newer emitter may carry fields this reader has never heard of. Neither may do
92
+ * anything worse than leave the poster alone.
93
+ */
94
+ export declare function parseClips(json: string | null | undefined): Record<string, ClipSpec>;
95
+ export {};
@@ -1,9 +1,2 @@
1
- /**
2
- * A claim from the source, shown next to the figure that backs it.
3
- *
4
- * The layout is chosen from the figure's own aspect ratio rather than being a
5
- * parameter: EXPERIMENT-002 full-bled a 1.98-aspect figure and pushed its
6
- * caption 200px off-canvas. Only a genuine strip earns the full width.
7
- */
8
1
  import type { Emitter } from "../kit.js";
9
2
  export declare const claimFigure: Emitter<"claim-figure">;
@@ -137,6 +137,39 @@ export interface EmitContext {
137
137
  * `hyperframes lint` rejects it (`unscoped_gsap_selector`).
138
138
  */
139
139
  sid: string;
140
+ /**
141
+ * WHERE THIS SCENE BEGINS ON THE DECK'S OWN CLOCK, in seconds, ALREADY ROUNDED
142
+ * to invariant 10's three places — the identical number the scene wrapper
143
+ * publishes as `data-start`. Write it; do not round it again, because two
144
+ * roundings of one number is how a byte moves.
145
+ *
146
+ * Everything else an archetype emits is scene-relative, and that is the right
147
+ * default: an emitter that knew where it sat in the deck would be an emitter a
148
+ * cut could invalidate. ONE thing is not expressible that way. A `<video>` is
149
+ * timed by the RUNTIME, not by this scene's timeline, and the runtime reads
150
+ * `data-start` as an ABSOLUTE composition second — so a clip that declares a
151
+ * scene-relative start is seeked into a window that has already passed.
152
+ *
153
+ * MEASURED, because the alternative looked right on paper. hyperframes'
154
+ * compiler injects `data-start="0" data-hf-auto-start=""` into a media tag
155
+ * that declares no timing, and its runtime resolves that marker against the
156
+ * enclosing `[data-composition-id]`, which is exactly this scene — so the
157
+ * marker ought to have been enough. Rendered at 0.8.27, on a two-beat deck
158
+ * whose clip is red for 2s, green for 2s then blue for 2s and whose second
159
+ * scene starts at 7s: with the marker the plate is BLUE at composition 9.5s,
160
+ * 10.5s and 12.5s — the clip's last frame, frozen, because the render placed
161
+ * it at second 0 and it had ended before the scene began. With `data-start="7"`
162
+ * the same frames are green, green, blue: clip seconds 2.5, 3.5 and 5.5. Every
163
+ * gate is green over both.
164
+ *
165
+ * OPTIONAL, because the shell is not the only caller: an archetype test calls
166
+ * `emitScene` with a context it builds by hand, and twelve of the thirteen
167
+ * emitters have no use for this. The one that does refuses by name when it is
168
+ * missing rather than inventing a second, i.e. wrong, clock. `planCut` passes
169
+ * a provisional 0 for the same reason it passes provisional scene ids — its
170
+ * pass exists to measure holds, and every scene it emits is thrown away.
171
+ */
172
+ start: number;
140
173
  }
141
174
  /**
142
175
  * A value in a GSAP vars payload that is JavaScript rather than data.
@@ -82,11 +82,20 @@ export declare function codexImages(opts?: CodexImagesOptions): ImageProvider;
82
82
  * something better draws it. Same request, same bytes.
83
83
  */
84
84
  export declare function drawSvg(req: ImageRequest): string;
85
- /** The size the tool wrote into its own SVG. `imageSize` is raster-only on purpose. */
86
- export declare function svgSize(bytes: Buffer): {
87
- width: number;
88
- height: number;
89
- };
85
+ /**
86
+ * The size an SVG declares, for `sizeOf` in ./illustrate.ts and for anything
87
+ * else that already imports this module.
88
+ *
89
+ * It USED to live here, as a regex for `viewBox="0 0 %d %d"` over the first 200
90
+ * bytes — four integers, no units, no preamble — which is exactly the SVG
91
+ * `drawSvg` above writes and nothing else. Figure ingest needs to measure an SVG
92
+ * a stranger wrote, so the reader was generalised and moved to src/source/assets.ts
93
+ * beside the other formats. It moved THERE rather than staying here because
94
+ * that module imports nothing but node and the schemas: the reverse would put
95
+ * the Codex runner and this file's OpenAI adapter on the import path of every
96
+ * source parse, for two integers.
97
+ */
98
+ export { svgSize } from "../source/assets.js";
90
99
  /** The last rung. Pure, so it cannot fail, so `illustrate` always finishes. */
91
100
  export declare function toolSvg(): ImageProvider;
92
101
  /**
@@ -8,12 +8,25 @@ import { type Format, type Source, type Storyboard } from "./types.js";
8
8
  */
9
9
  export { parseMarkdown } from "./source/markdown.js";
10
10
  export type { ParseOptions } from "./source/markdown.js";
11
+ /**
12
+ * A web page in, a markdown document and a directory of files out — the step
13
+ * BEFORE `parseMarkdown`, not a replacement for it. Separate because it needs a
14
+ * browser and the network, which `parseMarkdown` deliberately does not.
15
+ *
16
+ * `attachClips` is exported with it because a harvest is not finished without
17
+ * it: the markdown dialect has no way to say `kind: "clip"`, so a page's videos
18
+ * come back beside the document and this is what puts them into the source. It
19
+ * runs BEFORE `fetchFigures`, which passes a clip through untouched.
20
+ */
21
+ export { attachClips, harvest, toMarkdown } from "./source/harvest.js";
22
+ export type { Block, Harvested, HarvestedClip, HarvestOptions } from "./source/harvest.js";
11
23
  /**
12
24
  * Figures referenced by URL, downloaded beside the source. Separate from
13
25
  * `parseMarkdown` because it touches the network and a server may want to fetch
14
26
  * through its own client instead.
15
27
  */
16
28
  export { fetchFigures } from "./source/assets.js";
29
+ export { fetchGuarded, isBlockedAddress } from "./net/fetch.js";
17
30
  /**
18
31
  * Subset a CJK webfont over the glyphs a deck actually renders. Exported
19
32
  * because invariant 9 — a font stack naming a family the bundle does not
@@ -90,6 +90,7 @@ export declare const estimateSchema: z.ZodObject<{
90
90
  export declare const createSchema: z.ZodObject<{
91
91
  document_path: z.ZodOptional<z.ZodString>;
92
92
  document_text: z.ZodOptional<z.ZodString>;
93
+ document_url: z.ZodOptional<z.ZodString>;
93
94
  settings: z.ZodOptional<z.ZodObject<{
94
95
  format: z.ZodOptional<z.ZodEnum<{
95
96
  [x: string]: string;
@@ -221,6 +222,25 @@ export declare function deckTools(opts: McpOptions): {
221
222
  state: import("../server/queue.js").StepState;
222
223
  }[];
223
224
  log: string[];
225
+ } | {
226
+ harvest_warnings: string[];
227
+ next: string;
228
+ deck_path?: string | undefined;
229
+ slides?: number | undefined;
230
+ duration_seconds?: number | undefined;
231
+ warnings?: string[] | undefined;
232
+ storyboard_path: string;
233
+ error?: import("../server/errors.js").JobError | undefined;
234
+ job_id: string;
235
+ state: import("../server/queue.js").JobState;
236
+ stage: import("../server/queue.js").Stage | undefined;
237
+ queue_position: number | undefined;
238
+ elapsed_seconds: number;
239
+ steps: {
240
+ name: import("../server/queue.js").Stage;
241
+ state: import("../server/queue.js").StepState;
242
+ }[];
243
+ log: string[];
224
244
  }>;
225
245
  /** Poll, blocking up to `wait_seconds` for the job to move on. */
226
246
  status(input: z.infer<typeof statusSchema>): Promise<{
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The one guarded door out of this process for a URL somebody else chose.
3
+ *
4
+ * Two holes made this file. `src/source/assets.ts` fetched a figure with a bare
5
+ * `fetch(src)` — no timeout, no size cap, no idea what came back. And
6
+ * `src/server/pipeline.ts` grew an SSRF pre-check, `reachable()`, which resolves
7
+ * the hostname, refuses the private ranges, and then hands the URL to `fetch`,
8
+ * which resolves it again. A pre-check the socket never honours stops the naive
9
+ * class and nothing else: a public host that 302s to 169.254.169.254 walks
10
+ * straight past it, because the address that was checked is not the address that
11
+ * got connected to.
12
+ *
13
+ * WHY THIS IS node:http/node:https AND NOT `fetch`. Exactly one reason, and it
14
+ * is the reason the file exists: `fetch` gives no way to say "connect to THIS
15
+ * address". `http.request` takes a `lookup`, so the address this module
16
+ * validated is the address the socket opens, and the second DNS answer that
17
+ * would have been a rebinding attack is never asked for. Everything else here —
18
+ * the manual redirect walk, the streaming cap, the timeout, the content-type
19
+ * check — could have been written over `fetch`. The pin could not, and
20
+ * pipeline.ts's own comment named that gap and called it unclosable. It was
21
+ * closable; it just needed a different client.
22
+ *
23
+ * WHAT IS STILL OPEN, because a guard that oversells itself is worse than none:
24
+ * - Resolution is `dns.lookup`, the OS resolver, so `/etc/hosts` and any local
25
+ * override are honoured. That is deliberate — it is how this machine resolves
26
+ * every other name — but it does mean a poisoned hosts file is trusted.
27
+ * - A hostname with several A records is refused if ANY of them is blocked, and
28
+ * the socket is pinned to the first. That is stricter than a browser and is
29
+ * the intended trade: a round-robin where one member is internal is not a
30
+ * host this process wants to talk to at all.
31
+ * - Nothing here bounds how many of these run at once. The caller owns that.
32
+ */
33
+ export declare function isBlockedAddress(ip: string): string | null;
34
+ export interface GuardOptions {
35
+ /**
36
+ * Cap on the body, applied to DECODED bytes — see `readCapped` and `decode`
37
+ * for the two places it is enforced and why it takes both.
38
+ */
39
+ maxBytes: number;
40
+ /** Whole-call budget: DNS, connect, every hop, and reading the body. */
41
+ timeoutMs: number;
42
+ /**
43
+ * Refuse unless the response's bare media type matches. Matched against the
44
+ * type WITHOUT its parameters, so `/^image\//` and `/^text\/html$/` both do
45
+ * what they look like against `text/html; charset=utf-8`.
46
+ */
47
+ accept?: RegExp;
48
+ /**
49
+ * Extra request headers, lowercased on the way in. `authorization` and
50
+ * `cookie` are dropped the moment a redirect changes origin.
51
+ */
52
+ headers?: Record<string, string>;
53
+ /**
54
+ * TEST SEAM, and the only way to point this at a server on this machine.
55
+ *
56
+ * It exempts loopback from the address block and lifts the 80/443 rule, which
57
+ * together is exactly what it takes to drive a `node:http` server on an
58
+ * ephemeral port through this module's real code path — see test/net.test.ts,
59
+ * which is the only caller. It relaxes NOTHING else: link-local, the private
60
+ * ranges, the non-http schemes, the hop cap, the size cap, the timeout and the
61
+ * content-type check all still apply, which is what makes those tests evidence
62
+ * about the guard rather than about a bypass of it. Nothing in src passes it.
63
+ */
64
+ allowLoopback?: boolean;
65
+ }
66
+ export interface Fetched {
67
+ bytes: Buffer;
68
+ /** The `Content-Type` header as sent, parameters and all — a caller decoding
69
+ * text needs the charset, and dropping it here would lose it for good. */
70
+ contentType: string;
71
+ /** The URL the bytes actually came from, after redirects. What a caller must
72
+ * resolve relative links against, and what it should cache under. */
73
+ url: string;
74
+ }
75
+ /**
76
+ * Fetch `url`, refusing loudly at the first thing that looks wrong.
77
+ *
78
+ * Every refusal names the URL, what was wrong with it, and what to do; a caller
79
+ * surfacing `err.message` to a user is giving them something actionable.
80
+ */
81
+ export declare function fetchGuarded(url: string, opts: GuardOptions): Promise<Fetched>;
@@ -37,6 +37,18 @@ export interface MediaPlan {
37
37
  }
38
38
  /** A player URL is one whose host is in the list, or a subdomain of one. */
39
39
  export declare function isEmbed(url: string): boolean;
40
+ /**
41
+ * Every origin an embed can come from, deduped.
42
+ *
43
+ * DERIVED FROM THE RULES ABOVE RATHER THAN RESTATED BESIDE THEM, because the
44
+ * served deck's `frame-src` names exactly this list (src/server/http.ts): a host
45
+ * added here and forgotten there is a frame the browser refuses, and CSP refuses
46
+ * it the way CSP refuses everything — an empty rectangle and a line in a console
47
+ * nobody has open.
48
+ */
49
+ export declare const EMBED_ORIGINS: readonly string[];
50
+ /** The embeddable form of a player page, or undefined when we cannot say. */
51
+ export declare function embedUrl(url: string): string | undefined;
40
52
  /**
41
53
  * The final policy for one asset.
42
54
  *
@@ -48,6 +48,7 @@ export declare const SYSTEM: string;
48
48
  *
49
49
  * The figure block carries two facts beyond the caption — the section the image
50
50
  * sat under and the prose that refers to it — because a planner that cannot see
51
- * the picture has nothing else to decide what the picture is FOR.
51
+ * the picture has nothing else to decide what the picture is FOR. A clip carries
52
+ * one more: what the deck can actually show of it, which is a still.
52
53
  */
53
54
  export declare function renderSource(source: Source): string;
@@ -1,21 +1,44 @@
1
- import { type ImageProvider, type Runner } from "../index.js";
1
+ import { type HarvestOptions, type ImageProvider, type Runner, type Source } from "../index.js";
2
2
  import type { JobOptions } from "./options.js";
3
3
  import type { JobHandle, JobResult, Stage } from "./queue.js";
4
4
  import { type Upload } from "./upload.js";
5
5
  export interface PipelineInput {
6
- upload: Upload;
6
+ /**
7
+ * The document that arrived, when a file was posted. Exactly one of this and
8
+ * `url` is set — `parseSubmission` is what guarantees it, and `ingest` refuses
9
+ * loudly rather than guessing if a direct caller hands it both or neither.
10
+ */
11
+ upload?: Upload;
12
+ /** The page to harvest, when a URL was posted instead of a file. */
13
+ url?: string;
7
14
  options: JobOptions;
8
15
  /**
9
16
  * Whether a figure named by an http(s) URL may be downloaded.
10
17
  *
11
- * OFF by default, and that is a security decision, not a performance one. The
12
- * document is a stranger's; `![](http://169.254.169.254/latest/meta-data/)` is
13
- * a request this process would make from inside the network it runs in, and a
14
- * hostname allowlist does not close it because DNS can answer differently the
15
- * second time. Off, the figure is dropped and named in the warnings. On (the
16
- * owner's own box, own papers), it is fetched with a count and a timeout.
18
+ * ON by default src/server/main.ts sets `DECKSMITH_FETCH_FIGURES` to true
19
+ * because a paper's markdown links its images and a deck that silently drops
20
+ * them is not the deck anyone asked for. The flag is therefore NOT the guard;
21
+ * `guardFigures` below is, and it refuses any URL resolving to a private,
22
+ * loopback or link-local address before the fetch is attempted.
23
+ *
24
+ * When it is on, the fetch itself is `fetchGuarded` in src/net/fetch.ts by way
25
+ * of `fetchFigures`: a 32 MB streaming cap and a 20s whole-call timeout at the
26
+ * socket, with the connection PINNED to the address that was validated. That
27
+ * last part is what closed the DNS-rebinding hole an earlier version of this
28
+ * comment called unclosable. `MAX_REMOTE_FIGURES` below is the count.
29
+ *
30
+ * Off, a remote figure is dropped and named in the warnings.
17
31
  */
18
32
  fetchRemoteFigures: boolean;
33
+ /**
34
+ * Caps for the harvest of `url`, overriding `HARVEST_LIMITS`.
35
+ *
36
+ * TEST SEAM, the same shape as `imageChain` and `run` below. It is the only
37
+ * way to point a harvest at a `node:http` server on loopback, which is what
38
+ * test/server.test.ts needs to drive this path with no network. Nothing in
39
+ * src/ sets it, so production always gets `HARVEST_LIMITS` exactly.
40
+ */
41
+ harvest?: HarvestOptions;
19
42
  /**
20
43
  * The rungs `illustrate` draws through. A test injects the tool's own SVG and
21
44
  * nothing else; absent, the stage resolves its providers from the environment
@@ -33,3 +56,23 @@ export interface PipelineInput {
33
56
  /** Which rows the step list should have, decided before anything runs. */
34
57
  export declare function stagesFor(options: JobOptions): Stage[];
35
58
  export declare function runPipeline(job: JobHandle, input: PipelineInput): Promise<JobResult>;
59
+ /**
60
+ * Rewrite every figure's `src` to something safe to read, or drop the figure.
61
+ *
62
+ * `fetchFigures` does `readFile(src)` for anything that is not an http URL, and
63
+ * `src` is whatever the document's markdown said. A document containing
64
+ * `![](../../../../etc/ssh/ssh_host_rsa_key)` would otherwise have this process
65
+ * read it — the image sniffer rejects it a moment later, but the read already
66
+ * happened and the error message quotes the path. So relative paths are resolved
67
+ * inside the upload directory and confined there, and everything else is dropped
68
+ * unless remote fetching has been deliberately switched on.
69
+ *
70
+ * Dropped figures leave before the planner sees the source, so no beat can cite
71
+ * one and `assertRefsResolve` has nothing to fail on.
72
+ *
73
+ * EXPORTED FOR ITS TEST, like `safeUrlPath` and `parseRange` in ./http.ts. Its
74
+ * warnings are the only evidence of which rule fired, and they do not reach a
75
+ * `JobHandle` — so reading them through a whole `runPipeline` is impossible and
76
+ * the alternative was to leave the guard untested.
77
+ */
78
+ export declare function guardFigures(source: Source, root: string, allowRemote: boolean, warnings: string[]): Promise<Source>;
@@ -2,9 +2,10 @@
2
2
  * What arrives on the socket, treated as hostile.
3
3
  *
4
4
  * Three jobs, in order: get the bytes off the wire without letting the sender
5
- * choose how much memory we spend, turn them into a file and a set of fields,
6
- * and if the file is a zip get its contents onto disk without letting an
7
- * entry name decide where "onto disk" is.
5
+ * choose how much memory we spend, turn them into a `Submission` a file or a
6
+ * URL, never both and never neither, plus the option fields and, if the file
7
+ * is a zip, get its contents onto disk without letting an entry name decide
8
+ * where "onto disk" is.
8
9
  *
9
10
  * Everything here is pure except `readBody`, which is why the zip half can be
10
11
  * tested against an actually malicious archive with no server running.
@@ -44,7 +45,30 @@ export interface Upload {
44
45
  fields: Record<string, string>;
45
46
  }
46
47
  /**
47
- * multipart/form-data, with no dependency.
48
+ * What one POST carries: a document, or the address of one.
49
+ *
50
+ * A union rather than two optional fields on one record, because "exactly one of
51
+ * these" IS the rule, and a shape that can hold both — or neither — is a shape
52
+ * every reader downstream has to re-check. `parseSubmission` is the last place
53
+ * that can still answer the client, so both refusals live there and nothing
54
+ * after it asks the question again.
55
+ *
56
+ * `fields` is on both arms because the option fields belong to the REQUEST, and a
57
+ * URL submission has them just as an upload does. On the file arm it is the very
58
+ * same object as `upload.fields`, not a copy — read either; `Upload` keeps its
59
+ * own reference because src/mcp/tools.ts builds one without ever seeing a form.
60
+ */
61
+ export type Submission = {
62
+ kind: "file";
63
+ upload: Upload;
64
+ fields: Record<string, string>;
65
+ } | {
66
+ kind: "url";
67
+ url: string;
68
+ fields: Record<string, string>;
69
+ };
70
+ /**
71
+ * multipart/form-data, with no dependency, and the exactly-one rule enforced.
48
72
  *
49
73
  * `new Response(body, { headers }).formData()` is undici's parser, which ships
50
74
  * in Node — measured against a hand-built body with a filename containing a
@@ -52,7 +76,7 @@ export interface Upload {
52
76
  * with the right bytes. It throws a bare TypeError on a malformed body, which
53
77
  * is not a sentence anyone can act on, so it is translated here.
54
78
  */
55
- export declare function parseMultipart(body: Buffer, contentType: string): Promise<Upload>;
79
+ export declare function parseSubmission(body: Buffer, contentType: string): Promise<Submission>;
56
80
  /** A zip starts "PK". Asked before unzipping so a PDF reads as a PDF. */
57
81
  export declare function looksLikeZip(bytes: Uint8Array): boolean;
58
82
  /**
@@ -1,11 +1,84 @@
1
1
  import { type Source } from "../types.js";
2
- /** Fetch every figure into `dir`, rewrite `src` to the local name, measure it. */
3
- export declare function fetchFigures(source: Source, dir: string): Promise<Source>;
4
2
  /**
5
- * Intrinsic size from the file header. Just enough PNG/JPEG/GIF to answer the
6
- * one question layout asks — four field reads do not justify a dependency.
3
+ * Fetch every figure into `dir`, rewrite `src` to the local name, measure it.
4
+ *
5
+ * A figure that cannot be fetched, cannot be measured, or cannot be represented
6
+ * is DROPPED and named in `warnings` — the same shape `guardFigures` in
7
+ * src/server/pipeline.ts already uses for the figures it refuses to fetch at
8
+ * all. The reason is what the caller is: a whole ingest. A paper with eleven
9
+ * figures and one dead CDN link is still the deck someone asked for, and before
10
+ * this the dead link ended the run with `unrecognised image header` and no
11
+ * indication of which figure had produced it.
12
+ *
13
+ * `warnings` is an out-parameter rather than a second return value so the two
14
+ * callers that do not collect them (`decksmith ingest`, and the pipeline's own
15
+ * call) keep compiling unchanged, and so a caller that does collect them can
16
+ * pass the SAME array it already threads through `guardFigures` and have both
17
+ * sets of drops read as one list. Omit it and the drops go to stderr instead —
18
+ * dropping a figure only stops being an improvement over aborting if somebody
19
+ * is told which figure went.
20
+ */
21
+ export declare function fetchFigures(source: Source, dir: string, warnings?: string[]): Promise<Source>;
22
+ /** Every format this reads. Also every format a page is likely to serve a figure in. */
23
+ export type ImageFormat = "png" | "jpeg" | "gif" | "webp" | "avif" | "svg";
24
+ /**
25
+ * The format the bytes claim to be, from the header alone.
26
+ *
27
+ * Exported and separate from `imageSize` because two callers ask two different
28
+ * questions of the same eight bytes: what to name the file, and how to measure
29
+ * it. Keeping them one function is how a file ends up named after the reader
30
+ * that happened to succeed.
31
+ */
32
+ export declare function sniffFormat(b: Buffer): ImageFormat | undefined;
33
+ /**
34
+ * Intrinsic size from the file header, for every format a page may hand us.
35
+ *
36
+ * PNG, JPEG and GIF were enough while a source was a markdown file with its own
37
+ * images beside it. A URL is not: a page today serves WebP for the photograph,
38
+ * AVIF for the hero and SVG for the diagram, and a reader that knows three
39
+ * formats measures the other three as "unrecognised" and drops them.
40
+ *
41
+ * Still no dependency for this. Each reader below is a handful of field reads
42
+ * against a published header layout, and the alternative is a decoder that
43
+ * pulls in the whole pixel pipeline to answer two integers.
44
+ *
45
+ * It THROWS rather than returning zeros, and the message names what it saw.
46
+ * `figureSchema` requires a positive int, so a zero would not be caught here at
47
+ * all — it would surface much later as `width: too small` against a figure id,
48
+ * with nothing to say which URL had produced it.
7
49
  */
8
50
  export declare function imageSize(b: Buffer): {
9
51
  width: number;
10
52
  height: number;
11
53
  };
54
+ /**
55
+ * The size an SVG declares, in CSS pixels.
56
+ *
57
+ * Two callers: figure ingest above, and `sizeOf` in src/images/illustrate.ts
58
+ * through the re-export in src/images/providers.ts, where this used to live as a
59
+ * regex for `viewBox="0 0 %d %d"` over the first 200 bytes. That was honest for
60
+ * the SVG the tool draws itself and wrong for everything else, because a real
61
+ * SVG opens with a licence comment above the root element, spells the viewBox
62
+ * with floats, writes `width="640px"`, or carries no viewBox at all.
63
+ *
64
+ * `width`/`height` win when BOTH resolve to an absolute length, because that is
65
+ * the intrinsic size a browser would use. The viewBox is the fallback, and it is
66
+ * also the right answer for the very common `width="100%"`: a percentage is a
67
+ * fraction of a viewport this pipeline has not created yet, so it is deliberately
68
+ * left unresolved rather than read as 100 pixels.
69
+ *
70
+ * CHECKED against the browser that draws these: 60 real SVGs off this machine
71
+ * measured here and loaded in Chrome, and all 60 agree with `naturalWidth` and
72
+ * `naturalHeight` to the pixel. Two deliberate divergences remain. A fractional
73
+ * viewBox is rounded, where Chrome's own rounding differs by up to a pixel in
74
+ * either direction (300.5 → 300, 300.4 → 299, 99.9 → 100), which is a ratio
75
+ * unchanged in the third decimal. And an SVG declaring NEITHER a size nor a
76
+ * viewBox is refused rather than given the 300x150 a browser hands a replaced
77
+ * element with no intrinsic size: that default is a CSS fallback, not a fact
78
+ * about the picture, and everything downstream frames the figure against the box
79
+ * recorded here.
80
+ */
81
+ export declare function svgSize(bytes: Buffer): {
82
+ width: number;
83
+ height: number;
84
+ };