@jokerized/decksmith 0.5.2 → 0.7.0

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.
package/README.md CHANGED
@@ -249,7 +249,13 @@ artifact. Three are worth reading as patterns rather than bugs:
249
249
  sampled frames, because `marginV` was measured to clear *player chrome* and nothing ever
250
250
  guaranteed it cleared the *composition*. Both in
251
251
  [`.planning/EXPERIMENT-010-reconcile.md`](.planning/EXPERIMENT-010-reconcile.md). The
252
- caption one is **closed**: `build --reserve-captions` gives the band its own strip and
252
+ seam one is **closed** in the render: each scene's clip outlasts its slide and dissolves
253
+ over the next scene's empty opening. It stayed open in `deck.html` until 2026-09-18,
254
+ because the presented deck's own visibility pass read the slide instead of the clip,
255
+ and gliding across a seam still showed eleven 60Hz ticks of background. Every gate was
256
+ green, and it was found by stepping that glide one tick at a time —
257
+ [`.planning/2026-09-18-scene-boundary-blink.md`](.planning/2026-09-18-scene-boundary-blink.md).
258
+ The caption one is **closed**: `build --reserve-captions` gives the band its own strip and
253
259
  `fidelity` fails a deck that draws into it. It is worth reading for how it closed —
254
260
  the plan written to fix it asserted that every archetype lays out into `contentH`, ten
255
261
  of them do not, and the first fix therefore changed the worst stop by nothing at all.
@@ -349,7 +355,7 @@ Startup says what is missing before anyone waits on it:
349
355
  ```
350
356
  decksmith: http://127.0.0.1:8475
351
357
  decksmith: work /tmp/decksmith-server, one job at a time, 8 may wait
352
- decksmith: no auth, no TLS. Bound to 127.0.0.1 — set DECKSMITH_HOST to open it, knowing that.
358
+ decksmith: no auth. Bound to 127.0.0.1, so anyone with an account on this machine can spend your Codex quota — set DECKSMITH_TOKEN_FILE to require a token.
353
359
  decksmith: codex, edge-tts and ffmpeg all found
354
360
  decksmith: images via codex, then svg
355
361
  ```
@@ -360,15 +366,24 @@ for pictures still finishes, on the tool's own SVG if it has to.
360
366
 
361
367
  ### The HTTP surface
362
368
 
363
- | Route | What it does |
364
- |---|---|
365
- | `POST /api/jobs` | multipart: `file` (.md/.markdown/.txt/.zip) plus option fields → `202 {id}` |
366
- | `GET /api/jobs/:id` | `{state, stage, steps[], log[], error, result, queuePosition}` |
367
- | `GET /api/jobs/:id/events` | the same payload as SSE on every change |
368
- | `GET /api/formats` | the presets, themes, tones, densities and canvas bounds the picker draws from |
369
- | `GET /d/:id/...` | the built deck, served statically; `/d/:id/deck.html` is the player |
370
- | `GET /player.js` | the `<decksmith-player>` element, as an ES module — see below |
371
- | `GET /examples/embed.html` | a page that embeds two decks with it, and the file you copy |
369
+ | Route | What it does | With a token file |
370
+ |---|---|---|
371
+ | `POST /api/jobs` | multipart: `file` (.md/.markdown/.txt/.zip) plus option fields → `202 {id}` | token |
372
+ | `POST /api/jobs/:id/retry` | runs a job the server kept on disk again, under a new id | token |
373
+ | `GET /api/jobs/:id` | `{state, stage, steps[], log[], error, result, queuePosition}` | token |
374
+ | `GET /api/jobs/:id/events` | the same payload as SSE on every change | token |
375
+ | `GET /api/formats` | the presets, themes, tones, densities and canvas bounds the picker draws from | token |
376
+ | `GET /d/:id/...` | the built deck, served statically; `/d/:id/deck.html` is the player | public |
377
+ | `GET /player.js` | the `<decksmith-player>` element, as an ES module — see below | public |
378
+ | `GET /examples/embed.html` | a page that embeds two decks with it, and the file you copy | token |
379
+ | `GET /` | the uploader; with a token file and no session, the login page | public |
380
+ | `POST /login`, `POST /logout` | set or clear the session cookie, then 303 to `/` | public, only with a token file |
381
+
382
+ "Token" means `Authorization: Bearer <token>` or the session cookie the login page sets.
383
+ A route that is not in this table answers 401 when a token file is configured, so a route
384
+ added later starts out private. A deck id is 128 random bits and is meant to be shared,
385
+ which is why `/d/` stays public; the job routes are keyed by the same id, which is why
386
+ they do not.
372
387
 
373
388
  Options on `POST`, all optional, all defaulted server-side: `format`, `width`+`height`,
374
389
  `theme`, `slides`, `lang`, `tone`, `density`, `speed`, `narrate`, `voice`, `images`,
@@ -392,46 +407,150 @@ generous one.
392
407
  | Variable | Default | Why |
393
408
  |---|---|---|
394
409
  | `PORT` | `8475` | |
395
- | `DECKSMITH_HOST` | `127.0.0.1` | There is no auth. Opening it should require typing something. |
410
+ | `DECKSMITH_HOST` | `127.0.0.1` | `127.0.0.1`, `::1` and `localhost` are loopback. **Anything else refuses to start** without `DECKSMITH_TOKEN_FILE`, `DECKSMITH_TLS_CERT` and `DECKSMITH_TLS_KEY` — `127.0.0.2` and `::ffff:127.0.0.1` included. |
411
+ | `DECKSMITH_TOKEN_FILE` | unset | A file holding the token (`openssl rand -base64 32 > f; chmod 600 f`). Set, every route but the public ones in the table above needs it, on any bind. Refused unless it is a regular file, not readable by group or others, 32–1024 characters, one line. |
412
+ | `DECKSMITH_TLS_CERT` / `DECKSMITH_TLS_KEY` | unset | A PEM chain, leaf first, and its **unencrypted** PEM key (mode 600). Set both or neither. Refused unless the certificate is PEM (not DER), matches the key, is in date, has subjectAltNames, and loads into OpenSSL at TLS 1.2. On an exposed bind those subjectAltNames are the only `Host` names the server answers to, and the startup banner prints one of them. |
413
+ | `DECKSMITH_TOKEN` | — | **Not read, and refused if present**, even empty: ignoring it would leave a server someone believes is protected open, and an environment variable reaches shell history and every process the server starts. |
396
414
  | `DECKSMITH_WORK` | `$TMPDIR/decksmith-server` | One directory per job; swept by age. |
397
415
  | `DECKSMITH_MAX_UPLOAD` | 25 MB | |
398
416
  | `DECKSMITH_MAX_QUEUE` | `8` | Concurrency is 1. Past 8 the answer is "full", not "position 400". |
399
417
  | `DECKSMITH_JOB_TTL_MIN` | `120` | Files outlive the in-memory record; orphans are swept on boot. |
400
418
  | `DECKSMITH_JOBS_PER_HOUR` | `5` | Per IP. Charged only on an **accepted** job. |
401
419
  | `DECKSMITH_REQS_PER_MIN` | `240` | Per IP. |
402
- | `DECKSMITH_FETCH_FIGURES` | off | See below. |
403
- | `DECKSMITH_DECK_SANDBOX` | on | Serves decks under `CSP: sandbox`. |
420
+ | `DECKSMITH_FETCH_FIGURES` | on | See below. |
421
+ | `DECKSMITH_DECK_SANDBOX` | on | Serves decks under `CSP: sandbox`. Turning it off is refused with a token file or on an exposed bind. |
404
422
  | `DECKSMITH_IMAGES` | unset | `openai` names a separate image backend for `illustrate`. Unset, pictures come from the Codex account, then the tool's own SVG. |
405
423
  | `DECKSMITH_IMAGES_API_KEY` | | The backend's key. Environment only — never a preference, a config file, a `.deck`, or an error message. |
406
424
  | `DECKSMITH_IMAGES_BASE_URL` | `https://api.openai.com/v1` | Any OpenAI-compatible `images/generations` endpoint. |
407
425
  | `DECKSMITH_IMAGES_MODEL` | `gpt-image-2` | The backend's model. The Codex rung draws with the account's own. |
408
426
 
409
- **Remote figures are off by default, and that is a security decision.** `fetchFigures`
410
- does `readFile(src)` on anything that is not an http URL, so an uploaded document
411
- containing `![](../../../etc/ssh/ssh_host_rsa_key)` would have this process read it.
412
- Relative paths are resolved inside the upload directory and confined there; http figures
413
- are dropped with a named warning unless you turn them on. A hostname allowlist does not
414
- close the SSRF — DNS can answer differently the second time — so none is pretended.
427
+ **Remote figures are fetched by default, and what makes that safe is not the flag.** A
428
+ paper's markdown usually links its images rather than attaching them. `guardFigures` in
429
+ `src/server/pipeline.ts` drops any figure whose host resolves to a private, loopback or
430
+ link-local address, and the fetch checks the address again when it connects, because DNS
431
+ can answer differently the second time. `fetchFigures` does `readFile(src)` on anything
432
+ that is not an http URL, so an uploaded document containing
433
+ `![](../../../etc/ssh/ssh_host_rsa_key)` would have this process read it: relative paths
434
+ are resolved inside the upload directory and confined there. `DECKSMITH_FETCH_FIGURES=0`
435
+ drops every http figure with a named warning. (The table above said "off" until
436
+ 2026-09-18, while `main.ts` defaulted it on.)
437
+
438
+ ### Opening it to a network
439
+
440
+ To use a server on another machine yourself, leave it on loopback and tunnel:
441
+ `ssh -L 8475:127.0.0.1:8475 <host>`, then open `http://127.0.0.1:8475` locally. Nothing
442
+ else is needed. If other people have accounts on either machine, add a token file.
443
+
444
+ To bind anything else, the server needs all three:
445
+
446
+ ```sh
447
+ openssl rand -base64 32 > ~/.decksmith-token && chmod 600 ~/.decksmith-token
448
+ DECKSMITH_HOST=0.0.0.0 \
449
+ DECKSMITH_TOKEN_FILE=~/.decksmith-token \
450
+ DECKSMITH_TLS_CERT=/etc/decksmith/fullchain.pem \
451
+ DECKSMITH_TLS_KEY=/etc/decksmith/privkey.pem \
452
+ npm run serve
453
+ ```
454
+
455
+ Missing any of them, it refuses to start, names every problem at once, and exits 1 before
456
+ it touches the work directory. There is no override. The certificate must list the names
457
+ people will type as subjectAltNames: those names, and nothing else, are what the server
458
+ answers to, so DNS rebinding is refused on this bind as well as on loopback — and the
459
+ startup banner prints a URL built from one of those SANs, not from the bind address,
460
+ because `https://0.0.0.0:8475` is a URL this server answers 403 to. The certificate is
461
+ checked as far as OpenSSL will check it before anything is created on disk: a PEM chain
462
+ (a DER file is refused by name, with the `openssl x509 -inform der` line that converts
463
+ it), matching the key, in date, with SANs — and then loaded into a real
464
+ `tls.createSecureContext`, so a key OpenSSL will not use, such as an RSA key under 2048
465
+ bits, is a refusal here rather than an OpenSSL stack trace out of `listen`. There is no
466
+ plain-http listener or redirect beside it, and no HSTS. Renewing the certificate or
467
+ rotating the token means restarting.
468
+
469
+ A browser gets a login page at `/`. The token goes in a password field, so a password
470
+ manager can keep it. The server sets a cookie named for the port it is bound to —
471
+ `__Host-decksmith-8475` over TLS, `decksmith-8475` on plain loopback — `HttpOnly`,
472
+ `SameSite=Strict`, valid seven days. It is signed with a key derived from the token and
473
+ from `host:port`, so it survives a restart, rotating the token file logs every browser
474
+ out, and a second DeckSmith on the same machine neither overwrites this one's cookie nor
475
+ accepts a session it minted. Scripts send `Authorization: Bearer <token>` instead. A
476
+ write that relies on the cookie must carry `Sec-Fetch-Site` or `Origin`; a current
477
+ browser sends both on a POST. Ten wrong tokens from one address lock that address out for
478
+ fifteen minutes, including a right token sent during that time; the lockout is logged
479
+ once and every attempt refused during it is logged too. The log records refusals and
480
+ never the token, a cookie or an `Authorization` header.
481
+
482
+ Three things that cookie does **not** do, each of which this README claimed until
483
+ 2026-09-18:
484
+
485
+ - **Logging out does not revoke the session.** `POST /logout` clears the browser's copy.
486
+ The value is a MAC over an expiry, checked with no server-side state, so a copy taken
487
+ before the logout keeps working until it expires — up to seven days. Rotating the token
488
+ file and restarting is what actually revokes.
489
+ - **`SameSite=Strict` does not stop another site steering a logged-in viewer.** It
490
+ withholds the cookie from a navigation another site starts, and sends it on one this
491
+ origin starts. A deck is public and opens top-level, so a link from anywhere can put a
492
+ hostile deck on screen, and that deck can navigate itself to
493
+ `/examples/embed.html?a=<its own directory>` — a same-site hop, which arrives with the
494
+ session. Measured, and it queued a job. `/examples/embed.html` now carries
495
+ `connect-src 'none'`, which is what refuses that job; the page needs no network of its
496
+ own, and the decks it frames still load.
497
+ - **The cookie is not confined to this port.** Cookies have no port (RFC 6265 §1), so the
498
+ browser sends this one to every listener on the host. See below.
499
+
500
+ Every response that is not a deck file carries `X-Frame-Options: DENY` and
501
+ `Content-Security-Policy: … frame-ancestors 'none'`, so nothing can frame the uploader,
502
+ the API, or an error page.
503
+
504
+ #### What a same-host attacker can still do
505
+
506
+ Anything on this machine that the browser can be made to talk to is handed the session
507
+ cookie, because cookies are scoped by host and never by port. So, with a token file set
508
+ and a browser logged in, another local account can:
509
+
510
+ - **read the session cookie** by getting the browser to make any request to a port it
511
+ listens on — a page the viewer opens, an image, a redirect — and then **replay that
512
+ value from a script**, as the logged-in user, until it expires. Naming the cookie after
513
+ the port does not hide it; nothing this server does can, because the decision is the
514
+ browser's. The fix is not on this host: it is a distinct hostname per service, or decks
515
+ and API on separate origins with a `__Host-` cookie that cannot leave one;
516
+ - **read the token file**, if its mode allows it — which is why a mode other than 600 is a
517
+ refusal at startup;
518
+ - **lock the owner out for fifteen minutes** by sending ten wrong tokens. Every local
519
+ process shares `127.0.0.1` as far as `socket.remoteAddress` is concerned, and that is
520
+ the only key the limiter has. The lockout and each refusal during it are in the log, so
521
+ it is at least visible;
522
+ - **reach a loopback bind at all, when no token file is set.** That is the default.
523
+
524
+ Over the LAN, with an exposed bind, none of the above follows: the cookie is `__Host-`
525
+ and `Secure`, the connection is TLS 1.2 or better with no plain-http listener beside it,
526
+ and a request by any name the certificate does not list is refused before the token is
527
+ even considered. What a LAN attacker gets without the token is the deck files whose
528
+ 128-bit id they already know, and nothing else.
415
529
 
416
530
  ### What is missing before this is public
417
531
 
418
- This runs a demo on a laptop. It is **not** ready to face the internet, and the gap is
419
- not a polish gap:
420
-
421
- - **No authentication and no TLS.** Anyone who can reach the port can spend your Codex
422
- quota. This is why the default bind is loopback.
423
- - **DNS rebinding is refused only on a loopback bind.** A write another site's page makes
424
- a browser send is refused on any bind: `Sec-Fetch-Site`, or `Origin` where that is
425
- missing. But a rebinding page is same-origin to itself, and the only thing that gives it
426
- away is a `Host` naming something other than the server. On `127.0.0.1` that check is
427
- on. On any other `DECKSMITH_HOST` the server cannot know its own names, so the check is
428
- off until it is given an allowlist.
532
+ A token and TLS are in: see "Opening it to a network" above. This is what is still
533
+ missing, and some of it is not a polish gap:
534
+
535
+ - **One token, no accounts.** Everyone who holds the token is the same user, with the same
536
+ quota and the same view of every job whose id they know. A single session cannot be
537
+ revoked, and neither can logging out revoke one: rotate the token file and restart. No
538
+ roles, no per-token quota, no OIDC.
539
+ - **The session cookie is shared with every other port on this host**, and a captured one
540
+ replays until it expires. See "What a same-host attacker can still do" above.
541
+ - **No reverse-proxy support.** `X-Forwarded-*` is not read (see the rate-limit item
542
+ below), and on a loopback bind the `Host` check refuses whatever public name a proxy
543
+ forwards.
544
+ - **Without a token file, loopback is open to this machine.** Anyone with an account on it
545
+ can reach `127.0.0.1`. `DECKSMITH_TOKEN_FILE` on loopback turns the token on there too.
429
546
  - **Codex spend is unmetered per upload.** The per-IP hourly limit is the only brake, and
430
547
  it is per-IP.
431
548
  - **Rate limiting is by `socket.remoteAddress` only.** `X-Forwarded-For` is deliberately
432
549
  not read, because unproxied it is a header the client writes. Behind a reverse proxy
433
550
  every client therefore looks like one address and the limits collapse — teach the proxy
434
- to rate-limit, or teach this to trust exactly one hop.
551
+ to rate-limit, or teach this to trust exactly one hop. On a loopback bind every local
552
+ process is already that one address, so the failed-token lockout is a denial of service
553
+ any local account can aim at the owner; it is logged, not prevented.
435
554
  - **Deck files are readable by anyone holding the 128-bit id.**
436
555
  - **The deck sandbox does not isolate the origin, and cannot on one host.** Decks are
437
556
  served under `Content-Security-Policy: sandbox allow-scripts allow-same-origin
@@ -440,6 +559,18 @@ not a polish gap:
440
559
  drives the composition through `iframe.contentDocument`, so an opaque origin makes
441
560
  every slide render blank while the job reports `done`. Real isolation means serving
442
561
  `/d/:id` from a **separate origin**, which is a second listener and is not built.
562
+ - **A deck shown inside the uploader can act as whoever is logged in.** The deck is
563
+ same-origin with the page around it, so its script can call `parent.fetch`. That call
564
+ runs under the uploader's policy, not the deck's `connect-src 'none'`, and carries the
565
+ session. Measured: a stub deck doing this queued a job
566
+ (`.planning/2026-09-18-deck-parent-reach.md`). A deck opened on its own cannot: nothing
567
+ it could frame will load, and the one page it can navigate the viewer to,
568
+ `/examples/embed.html`, carries `connect-src 'none'` — both measured, both with their
569
+ control. So what is left needs hostile content converted by the operator, script that
570
+ survives into the deck, and the operator watching that deck **in the uploader**, which
571
+ is the one page that must be able to reach the API.
572
+ `DECKSMITH_JOBS_PER_HOUR` still caps what it can spend. The fix is the separate deck
573
+ origin above.
443
574
  - **No persistence.** A restart forgets every job record. The files survive and are swept
444
575
  by age on boot.
445
576
  - **Disk is bounded only by TTL × queue rate**, and uploaded files are not scanned.
@@ -719,8 +850,8 @@ Three, each a position rather than a hue.
719
850
 
720
851
  `mono`'s four tones are a grey ladder plus one red rather than four hues, because value is
721
852
  what survives a bad projector and hue is not; four hues would have collapsed into one
722
- grey. All three keep the Inter stack — font families auto-resolve from a fixed allowlist,
723
- and a serif naming a family the bundle does not declare falls back silently.
853
+ grey. All three keep the Inter stack — `build` ships Inter beside every deck, and a serif
854
+ naming a family the deck does not declare falls back silently.
724
855
 
725
856
  A theme is a name and a palette, and that is the whole extension point: a new one is a
726
857
  file in `src/emit/themes/` plus a line in `THEMES`. No archetype learns it exists.
@@ -745,6 +876,20 @@ that just appeared, and the deck advances on speech rather than on a number some
745
876
  guessed. Write one sentence per reveal, in reveal order. Fewer sentences than stops leaves
746
877
  the later reveals silent; more, and the surplus joins the last one.
747
878
 
879
+ **A beat's stop count can depend on the canvas.** At 1600×900 the demo's stack beat does
880
+ not fit, so `narrate` puts all four of its sentences on one stop; at 1920×1080 it has four.
881
+ So `narrate` takes the same `--format`, `--width`/`--height` and `--reserve-captions` flags
882
+ as `build`, and `narration.json` records the stop count each narrated beat was split over.
883
+ `build` compares that count with its own staging for every beat it keeps, and refuses a
884
+ beat whose sentences it would split differently. The error names the beats and prints the
885
+ `narrate` flags that fix it. Narration made once at the default canvas still builds the
886
+ short, the `--reserve-captions` deck and an unpacked `.deck` wherever those stage the kept
887
+ beats the same way, which on the demo is every one. Re-narrating in the directory the
888
+ narration was made in only synthesises sentences that now split differently. An unpacked
889
+ `.deck` has no TTS cache, so there it synthesises all of them. A `narration.json` or pack
890
+ written before the counts were recorded still builds, and `build` says it could not check
891
+ it. See `.planning/2026-09-18-narrate-build-canvas.md`.
892
+
748
893
  Playback reads the audio element's own clock, never a timer: a timer agrees with the audio
749
894
  right up until the first stall, and a stall is exactly when a viewer looks at the subtitle
750
895
  to find out what they missed. `m` mutes (captions keep tracking, and a muted element is
@@ -1059,8 +1204,10 @@ mechanically rather than free-hand.
1059
1204
  presented, never rendered, and its subtitle loop legitimately reads a clock.
1060
1205
  - `deck.html` never contains the string `data-composition-id`. A root-level HTML file that
1061
1206
  does trips lint's `multiple_root_compositions`, and the deck stops being navigable.
1062
- - Fonts auto-resolve only from a fixed allowlist. Inter is on it, Noto Sans KR is not, so
1063
- every CJK deck ships its own subsetted `@font-face` or the text silently falls back.
1207
+ - Every deck declares the face its stack names. A CJK deck ships a subsetted Noto; every
1208
+ other deck ships Inter from `@fontsource-variable/inter`. HyperFrames resolves Inter from
1209
+ its own allowlist, but only inside a render: the gates' page and `deck.html` never run its
1210
+ compiler, and without the deck's own face they drew SF on a Mac and DejaVu on Linux.
1064
1211
  - Audience text never goes below 40px at 1920x1080, display equations sit at 60–76px. A
1065
1212
  30px equation passes every automated gate and is unreadable from row six.
1066
1213
  - **No archetype declares its own content width.** The box comes from