{"id":51236745,"url":"https://github.com/kisaesdevlab/vibe-printer","last_synced_at":"2026-06-28T21:01:17.371Z","repository":{"id":367823123,"uuid":"1282458081","full_name":"KisaesDevLab/Vibe-Printer","owner":"KisaesDevLab","description":"LAN print routing gateway — HTTP API routes jobs to ESC/POS thermal (TCP/USB), ZPL labels, Star, and CUPS/IPP printers. FastAPI + React, self-hosted Docker appliance.","archived":false,"fork":false,"pushed_at":"2026-06-27T21:03:29.000Z","size":171,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-27T21:20:13.929Z","etag":null,"topics":["cups","docker","escpos","fastapi","printing","raspberry-pi","react","self-hosted","thermal-printer","zpl"],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/KisaesDevLab.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-27T19:51:32.000Z","updated_at":"2026-06-27T21:03:32.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/KisaesDevLab/Vibe-Printer","commit_stats":null,"previous_names":["kisaesdevlab/vibe-printer"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/KisaesDevLab/Vibe-Printer","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/KisaesDevLab%2FVibe-Printer","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/KisaesDevLab%2FVibe-Printer/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/KisaesDevLab%2FVibe-Printer/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/KisaesDevLab%2FVibe-Printer/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/KisaesDevLab","download_url":"https://codeload.github.com/KisaesDevLab/Vibe-Printer/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/KisaesDevLab%2FVibe-Printer/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34903523,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-28T02:00:05.809Z","response_time":54,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cups","docker","escpos","fastapi","printing","raspberry-pi","react","self-hosted","thermal-printer","zpl"],"created_at":"2026-06-28T21:01:15.770Z","updated_at":"2026-06-28T21:01:17.352Z","avatar_url":"https://github.com/KisaesDevLab.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Vibe Print\n\n[![CI](https://github.com/KisaesDevLab/Vibe-Printer/actions/workflows/ci.yml/badge.svg)](https://github.com/KisaesDevLab/Vibe-Printer/actions/workflows/ci.yml)\n\n**LAN print routing gateway.** Callers send a payload over HTTP, pick a **printer by integer id**,\nand the gateway routes the job to the right device — thermal receipt printers, label printers, or\noffice printers — rendering receipts, labels, and PDFs from reusable templates. A React admin UI\nconfigures everything. It ships as a self-hosted Docker appliance for a Raspberry Pi or NucBox.\n\n```\n   POST /v1/print  {printer: 1, format: 2, data:{…}}\n        │\n        ▼\n┌──────────────────────────────────────────────────────────┐\n│  Vibe Print (FastAPI)                                      │\n│  auth → templating (Jinja) → render → durable queue       │\n│                                   │                        │\n│        ┌──────────────┬──────────┼───────────┬─────────┐  │\n│        ▼              ▼           ▼           ▼         ▼  │\n│   ESC/POS TCP    ESC/POS USB    CUPS/PDF    ZPL label  Star│\n└──────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Table of contents\n\n- [Features](#features)\n- [Quick start (local, no hardware)](#quick-start-local-no-hardware)\n- [Production deployment](#production-deployment)\n- [Printer setup](#printer-setup)\n  - [ESC/POS network (TCP :9100)](#escpos-network-tcp-9100)\n  - [ESC/POS USB](#escpos-usb)\n  - [CUPS / office printers (PDF)](#cups--office-printers-pdf)\n  - [ZPL label printers](#zpl-label-printers)\n  - [Star printers](#star-printers)\n  - [Printer pools / failover](#printer-pools--failover)\n- [Authentication](#authentication)\n- [API reference](#api-reference)\n- [Template variables](#template-variables)\n- [Document format schema (thermal)](#document-format-schema-thermal)\n- [PDF templates (office)](#pdf-templates-office)\n- [Configuration](#configuration-environment-variables)\n- [Remote access](#remote-access)\n- [Backup \u0026 restore](#backup--restore)\n- [Observability \u0026 compliance](#observability--compliance)\n- [Development](#development)\n- [Project status](#project-status)\n- [License](#license)\n\n---\n\n## Features\n\n- **Printer types:** `escpos_network` (TCP :9100), `escpos_usb`, `cups` (office via in-container\n  CUPS), `ipp_network` (**direct IPP** — no CUPS queue; \"reachable\" is a real IPP query),\n  `zpl_network` (Zebra labels), `star_network` (Star Line Mode), `virtual` (dev/test), plus\n  `pool` (failover / round-robin). CUPS device URIs **auto-(re)provision on startup** (durable\n  across rebuilds); a **Provision** button is in the Printers tab.\n- **Office documents:** render from HTML/CSS templates, **overlay variables onto an uploaded base\n  PDF** (visual drag-and-drop editor; text/QR/image fields), **or** print finished **PDF /\n  PostScript / PCL** files directly (`/v1/print/file`).\n- **Cash drawer:** `pulse` element (configurable pin 2/5 + on/off timing) or a one-click\n  **Open drawer** action (`/v1/admin/printers/{id}/open-drawer`) on ESC/POS \u0026 Star printers.\n- **Reliable delivery:** durable SQLite queue, **per-printer serialization** (no interleaved\n  receipts), **idempotency keys**, retry with backoff, and **mid-send → `uncertain`** (never\n  auto-reprints a financial receipt).\n- **Templating:** sandboxed Jinja2 over a JSON element schema (thermal) and HTML/CSS → PDF via\n  WeasyPrint (office). Server-rendered previews (PNG/PDF). QR / barcode / image / tables.\n- **Scheduling:** job `priority`, not-before `scheduled_at`, per-printer **daily quotas**.\n- **Admin UI:** secret-gated SPA at `/admin` — Printers, Document Formats (drag-reorder **visual\n  element builder**), PDF Templates, **PDF Overlays** (pdf.js WYSIWYG), Jobs, **Remote Access**,\n  Device. Each content type supports **edit / delete / test-print to a chosen printer** with live\n  previews. English + Spanish.\n- **Ships with defaults:** bundled formats/templates (Stripe receipt, File Routing Sheet, Invoice)\n  loaded **create-if-missing** on startup so a fresh appliance is usable immediately.\n- **Remote access from the UI:** run a **Cloudflare Tunnel** as a managed process — **quick** mode\n  (instant `*.trycloudflare.com` URL, no account) or **named** (token for a stable hostname);\n  LAN + Cloudflare work **at the same time**; optional **Cloudflare Access** JWT enforced only on\n  tunnelled requests. Also Caddy LAN-TLS and Tailscale.\n- **Security \u0026 compliance:** shared-secret bearer auth, optional **Cloudflare Access** JWT, strict\n  CSP, WeasyPrint SSRF lockdown, **tamper-evident hash-chained audit**, optional **SQLCipher at\n  rest**, payload-hash PII mode, log redaction, configurable retention.\n- **Operations:** signed failure/offline **webhooks**, fleet **heartbeat** + diagnostics bundle,\n  Prometheus `/metrics`, LAN **discovery**, first-boot **provisioning**, safe **self-update** with\n  rollback, and **B2 backup/restore**.\n- **Packaged image:** multi-arch (amd64 + arm64) published to **GHCR** — `ghcr.io/kisaesdevlab/vibe-printer`.\n\n---\n\n## Quick start (local, no hardware)\n\nRequires Python 3.12 and Node 20.\n\n```bash\ngit clone https://github.com/KisaesDevLab/Vibe-Printer.git\ncd Vibe-Printer\n\npython -m venv .venv \u0026\u0026 . .venv/bin/activate     # Windows: .venv\\Scripts\\activate\npip install -e \".[dev]\"\n\nexport VIBE_PRINT_SECRET=dev-secret              # the service refuses to boot without this\npython -m app.seed                               # sample virtual printer + receipt format + PDF template\nuvicorn app.main:app --reload --port 8080        # or: make dev\n```\n\nPrint to the seeded **virtual** printer (id 1) and watch the job complete:\n\n```bash\nSECRET=dev-secret\nJOB=$(curl -s localhost:8080/v1/print \\\n  -H \"Authorization: Bearer $SECRET\" \\\n  -H \"Idempotency-Key: $(python -c 'import uuid;print(uuid.uuid4())')\" \\\n  -d '{\"printer\":1,\"data\":{\"company\":\"Acme\",\"date\":\"2026-06-27\",\n       \"lines\":[{\"name\":\"Widget\",\"qty\":\"2\",\"amt\":\"9.98\"}],\"total\":\"9.98\",\n       \"url\":\"https://example.com/r/1\"}}' | python -c 'import sys,json;print(json.load(sys.stdin)[\"job_id\"])')\n\ncurl -s localhost:8080/v1/jobs/$JOB -H \"Authorization: Bearer $SECRET\"\n```\n\nThe virtual backend writes the rendered ESC/POS bytes to `data/virtual/printer-1.bin`. The admin UI\ndev server is `cd web \u0026\u0026 npm install \u0026\u0026 npm run dev` (proxies the API); in production the UI is built\ninto the app and served at `/admin`.\n\n\u003e **\"Sent\" ≠ \"printed\".** ESC/POS over TCP/USB is fire-and-forget — a job reaching `done` with\n\u003e `delivery: \"sent\"` means bytes were accepted by the printer, not that paper emerged. CUPS jobs are\n\u003e polled to true completion (`delivery: \"completed\"`). Always observe the final state via\n\u003e `GET /v1/jobs/{id}`, not the enqueue response.\n\n---\n\n## Production deployment\n\nTarget: a Raspberry Pi (arm64) or NucBox/mini-PC (amd64) on the same LAN as the printers, running\nDocker on stock Ubuntu 24.04.\n\n```bash\ncd deploy\ncp .env.example .env          # set a UNIQUE VIBE_PRINT_SECRET per appliance\ndocker compose up -d --build  # build locally; or pull the published image (below)\n```\n\n**Or pull the prebuilt multi-arch image from GHCR** (no local build) — set\n`VIBE_PRINT_IMAGE` and skip `--build`:\n\n```bash\nVIBE_PRINT_IMAGE=ghcr.io/kisaesdevlab/vibe-printer:v0.1.0 docker compose up -d\n# tags: :latest (amd64, every main push) · :vX.Y.Z (amd64+arm64, on release tags) · :\u003csha\u003e\n```\n\nThe image bundles WeasyPrint (pango/cairo), fonts, libusb, an in-container CUPS (localhost), and\n`cloudflared` (for the UI-managed tunnel). Data lives in the `vibe-data` volume (`/data`: SQLite DB\n+ assets + backups), so it persists across upgrades.\n\nCompose **profiles**:\n\n| Profile | Command | What it adds |\n|---|---|---|\n| _(default)_ | `docker compose up -d` | LAN-only gateway on `:8080` |\n| `caddy` | `docker compose --profile caddy up -d` | Caddy TLS front (`deploy/Caddyfile`) |\n| `cloudflare` | `docker compose --profile cloudflare up -d` | Outbound Cloudflare Tunnel (set `TUNNEL_TOKEN`); no inbound ports |\n\n**Updates** are operator-run (no auto-update timers):\n\n```bash\ncd deploy\n./upgrade.sh ghcr.io/you/vibe-print@sha256:\u003cdigest\u003e   # pull → health-gate /readyz → rollback on failure\n```\n\nThe app takes a DB backup before applying any migration, and migrations are forward-only.\n\n---\n\n## Printer setup\n\nAdd printers in the admin UI (**Printers → Add printer**) or via the Admin API. Every type below\nincludes a `curl` example. Replace `$SECRET` and host/ids as needed.\n\n### ESC/POS network (TCP :9100)\n\nThe most common thermal receipt setup. Find the printer's IP (its self-test slip, your router's DHCP\ntable, or the built-in scanner below), then:\n\n```bash\ncurl -s localhost:8080/v1/admin/printers -H \"Authorization: Bearer $SECRET\" -d '{\n  \"name\":\"Front Counter\",\n  \"params\":{\"type\":\"escpos_network\",\"host\":\"192.168.1.50\",\"port\":9100,\n            \"columns\":48,\"paper_width_dots\":576,\"encoding\":\"cp437\",\"cut\":true},\n  \"default_format_id\":1\n}'\n```\n\n**Discover** open `:9100` / IPP printers on a subnet:\n\n```bash\ncurl -s localhost:8080/v1/admin/discover -H \"Authorization: Bearer $SECRET\" \\\n  -d '{\"subnet\":\"192.168.1.0/24\"}'\n```\n\nKey params: `columns` (text width, usually 48 for 80 mm / 32 for 58 mm), `paper_width_dots`\n(576 for 80 mm @ 203 dpi, 384 for 58 mm), `encoding`/`codepage`, `cut`.\n\n### ESC/POS USB\n\nFor a USB-attached receipt printer. You need the device's USB **vendor/product id** and must pass the\ndevice into the container.\n\n1. Find the ids on the host: `lsusb` → e.g. `ID 04b8:0e28 Seiko Epson Corp.` → vendor `0x04b8`,\n   product `0x0e28`.\n2. Pass the USB bus into the container — uncomment in `deploy/docker-compose.yml`:\n   ```yaml\n   devices:\n     - \"/dev/bus/usb:/dev/bus/usb\"\n   ```\n   (or scope to the specific device). A udev rule granting access is recommended:\n   ```\n   # /etc/udev/rules.d/99-vibe-print.rules\n   SUBSYSTEM==\"usb\", ATTRS{idVendor}==\"04b8\", ATTRS{idProduct}==\"0e28\", MODE=\"0666\"\n   ```\n3. Register it (ids are integers — `0x04b8` = 1208, `0x0e28` = 3624; or send hex via the UI):\n   ```bash\n   curl -s localhost:8080/v1/admin/printers -H \"Authorization: Bearer $SECRET\" -d '{\n     \"name\":\"USB Receipt\",\n     \"params\":{\"type\":\"escpos_usb\",\"vendor_id\":1208,\"product_id\":3624,\"columns\":48}\n   }'\n   ```\n\nThe backend identifies the device by vendor/product (and optional `serial`) and survives unplug/replug.\n\n### CUPS / office printers (PDF)\n\nFor full-page documents on laser/inkjet printers. CUPS runs **inside the container**. Point a queue at\nthe physical printer (driverless IPP Everywhere is easiest), then register it.\n\n```bash\n# 1) register the printer (queue name + media)\nPID=$(curl -s localhost:8080/v1/admin/printers -H \"Authorization: Bearer $SECRET\" -d '{\n  \"name\":\"Office Laser\",\"params\":{\"type\":\"cups\",\"queue\":\"HP_LaserJet\",\"media\":\"A4\"},\n  \"default_template_id\":1}' | python -c 'import sys,json;print(json.load(sys.stdin)[\"id\"])')\n\n# 2) provision the CUPS queue (driverless)\ncurl -s localhost:8080/v1/admin/printers/$PID/provision-queue -H \"Authorization: Bearer $SECRET\" \\\n  -d '{\"device_uri\":\"ipp://printer.local:631/ipp/print\",\"make_model\":\"everywhere\"}'\n```\n\nCUPS jobs are submitted concurrently (its own spooler) and polled to true completion. The CUPS web\nadmin is disabled and `:631` is bound to localhost — no remote admin surface.\n\nYou can print office output two ways: render an [HTML/CSS template](#pdf-templates-office) with\n`/v1/print`, or send a **finished PDF / PostScript / PCL** with\n[`/v1/print/file`](#post-v1printfile). Example printing an existing PDF:\n\n```bash\ncurl -s localhost:8080/v1/print/file -H \"Authorization: Bearer $SECRET\" -d \"{\n  \\\"printer\\\": $PID, \\\"content_type\\\": \\\"pdf\\\", \\\"media\\\": \\\"Letter\\\",\n  \\\"content\\\": \\\"$(base64 -w0 invoice.pdf)\\\"\n}\"\n```\n\n### ZPL label printers\n\nFor Zebra-style label printers over TCP :9100. Elements render to ZPL II.\n\n```bash\ncurl -s localhost:8080/v1/admin/printers -H \"Authorization: Bearer $SECRET\" -d '{\n  \"name\":\"Shipping Labels\",\n  \"params\":{\"type\":\"zpl_network\",\"host\":\"192.168.1.70\",\"port\":9100,\n            \"dpmm\":8,\"label_width_dots\":812}\n}'\n```\n\n`dpmm` = dots per mm (8 ≈ 203 dpi, 12 ≈ 300 dpi). QR/CODE128 render natively (`^BQN` / `^BC`).\n\n### Star printers\n\nStar printers (Star Line Mode) over TCP :9100. Text/alignment/cut are modeled.\n\n```bash\ncurl -s localhost:8080/v1/admin/printers -H \"Authorization: Bearer $SECRET\" -d '{\n  \"name\":\"Star TSP\",\"params\":{\"type\":\"star_network\",\"host\":\"192.168.1.71\",\"port\":9100,\"columns\":48}\n}'\n```\n\n### Printer pools / failover\n\nRoute to a group of ESC/POS-family members. `failover` picks the first reachable member;\n`round_robin` rotates. Capabilities are the safe intersection of members.\n\n```bash\ncurl -s localhost:8080/v1/admin/printers -H \"Authorization: Bearer $SECRET\" -d '{\n  \"name\":\"Counter Pool\",\n  \"params\":{\"type\":\"pool\",\"members\":[2,3],\"strategy\":\"failover\"}\n}'\n```\n\nPrint to the pool's id like any printer; the gateway resolves a live member at send time.\n\n---\n\n## Authentication\n\n- All `/v1/*` routes require `Authorization: Bearer \u003csecret\u003e`. `/healthz`, `/readyz`, `/metrics`,\n  and the `/admin` UI are open.\n- The secret is set via `VIBE_PRINT_SECRET` and compared in constant time. **The service refuses to\n  start if it is unset/empty** — it never runs open. Use a unique secret per appliance.\n- The admin UI prompts for the secret once and holds it in `sessionStorage` (no cookies → no CSRF).\n- Optional **Cloudflare Access** JWT enforcement on `/v1/admin/*` (see [Remote access](#remote-access)).\n- Real client IP is trusted from `CF-Connecting-IP` / `X-Forwarded-For` **only** when the peer is in\n  `VIBE_PRINT_TRUSTED_PROXIES`; rate limiting and audit use the real IP.\n\n---\n\n## API reference\n\nBase URL: `http://\u003chost\u003e:8080`. All examples assume `-H \"Authorization: Bearer $SECRET\"`.\nInteractive docs: `GET /openapi.json` (and `/docs`). A typed TS client is generated into\n`web/src/api-types.ts`.\n\n### Printing\n\n#### `POST /v1/print`\n\nEnqueue a print job. Provide **one of** `document` (inline), `format` (id), or `template` (id, CUPS);\nomit all to use the printer's default. Returns the enqueue result — observe the outcome via the job.\n\n| Field | Type | Notes |\n|---|---|---|\n| `printer` | int | **required** — printer id |\n| `document` | object | inline element doc `{ \"elements\": [...] }` (thermal/label) |\n| `format` | int | saved format id (thermal/label) |\n| `template` | int | saved PDF template id (CUPS) |\n| `overlay` | int | saved PDF-overlay id — stamps `data` onto an uploaded base PDF |\n| `data` | object | merged into the template via Jinja |\n| `copies` | int | 1–50 (default 1) |\n| `priority` | int | −100…100, higher runs first (default 0) |\n| `scheduled_at` | string | ISO-8601 not-before time |\n\nHeader `Idempotency-Key: \u003cuuid\u003e` (recommended): an identical key+payload returns the original job;\nthe same key with a different payload → `idempotency_conflict`.\n\n```bash\ncurl -s localhost:8080/v1/print -H \"Authorization: Bearer $SECRET\" \\\n  -H \"Idempotency-Key: 9d8f…\" \\\n  -d '{\"printer\":1,\"format\":2,\"data\":{\"company\":\"Acme\",\"total\":\"24.48\"},\"copies\":1,\"priority\":10}'\n# → {\"job_id\":\"\u003cuuid\u003e\",\"status\":\"queued\"}\n```\n\n#### `POST /v1/print/raw`\n\nStream base64 bytes straight to an ESC/POS/ZPL/Star printer. **Disabled per-printer by default** —\nenable with `allow_raw: true`. Rejected for CUPS.\n\n```bash\ncurl -s localhost:8080/v1/print/raw -H \"Authorization: Bearer $SECRET\" \\\n  -d '{\"printer\":1,\"data\":\"G0BoZWxsbwo=\"}'\n```\n\n#### `POST /v1/print/file`\n\nPrint a **finished document** (PDF / PostScript / PCL) to an office (CUPS) printer — no template\nrendering. PDF and PostScript are auto-filtered (and converted for IPP-Everywhere printers); PCL is\npassed through unfiltered to a PCL-capable device. Honors `Idempotency-Key`.\n\n| Field | Type | Notes |\n|---|---|---|\n| `printer` | int | **required** — a CUPS printer |\n| `content` | string | **required** — base64-encoded document bytes |\n| `content_type` | enum | `pdf` (default) · `postscript` · `pcl` |\n| `copies` | int | 1–50 |\n| `media` | string | e.g. `A4`, `Letter` |\n| `priority` / `scheduled_at` | int / string | as for `/v1/print` |\n\n```bash\ncurl -s localhost:8080/v1/print/file -H \"Authorization: Bearer $SECRET\" -d \"{\n  \\\"printer\\\": 3, \\\"content_type\\\": \\\"pdf\\\", \\\"media\\\": \\\"Letter\\\",\n  \\\"content\\\": \\\"$(base64 -w0 invoice.pdf)\\\"\n}\"\n```\n\nA printer advertises what it accepts in `GET /v1/printers` → `capabilities.document_formats`\n(non-office printers return an empty list and reject the call with `unsupported_for_printer`). The\ndefault `MAX_BODY_BYTES` is 5 MiB — raise it for larger documents.\n\n#### `POST /v1/print/preview`\n\nServer-render a preview without printing. Returns `image/png` (thermal) or `application/pdf`.\nAccepts inline `document` / `html`+`css` or a saved `format`/`template`, plus `data`.\n\n```bash\ncurl -s localhost:8080/v1/print/preview -H \"Authorization: Bearer $SECRET\" \\\n  -d '{\"format\":2,\"data\":{\"company\":\"Acme\"}}' -o preview.png\n```\n\n### Jobs\n\n| Method | Path | Purpose |\n|---|---|---|\n| `GET` | `/v1/jobs/{id}` | status, `delivery`, attempts, error |\n| `POST` | `/v1/jobs/{id}/reprint` | re-render \u0026 re-enqueue from the stored payload + recorded version |\n\nJob lifecycle: `queued → rendering → printing → done | failed | dead | canceled | uncertain`.\n`uncertain` = the link died after bytes began streaming; it is **never auto-retried** and requires an\noperator action (resolve / requeue) in the UI or via the admin API.\n\n### Printers \u0026 version\n\n| Method | Path | Purpose |\n|---|---|---|\n| `GET` | `/v1/printers` | list with cached capabilities |\n| `GET` | `/v1/printers/{id}/status` | live reachability (pool → per-member) |\n| `GET` | `/v1/version` | app version, schema migration, image digest |\n\n### Admin API (`/v1/admin/*`)\n\n| Area | Endpoints |\n|---|---|\n| Device | `GET/PUT /device` |\n| Printers | `GET/POST /printers`, `GET/PUT/DELETE /printers/{id}`, `POST /printers/{id}/test`, `POST /printers/{id}/open-drawer`, `GET /printers/{id}/status`, `POST /printers/{id}/provision-queue`, `POST /discover` |\n| Formats | `GET/POST /formats`, `GET/PUT/DELETE /formats/{id}`, `POST /formats/{id}/preview` |\n| Templates | `GET/POST /templates`, `GET/PUT/DELETE /templates/{id}`, `POST /templates/{id}/preview` |\n| Overlays | `GET/POST /overlays`, `GET/PUT/DELETE /overlays/{id}`, `POST /overlays/{id}/preview`, `GET /overlays/{id}/base`, `GET /overlays/{id}/pages` |\n| Assets | `GET/POST /assets`, `DELETE /assets/{id}` |\n| Jobs | `GET /jobs` (filter `status`, `cursor`, `limit`), `POST /jobs/{id}/{cancel\\|requeue\\|resolve}`, `DELETE /jobs/{id}/payload` |\n| Config | `POST /config/export`, `POST /config/import` (`dry_run`) |\n| Audit | `GET /audit/config`, `GET /audit/print`, `GET /audit/verify` (hash chain) |\n| Retention/backup | `POST /retention/prune`, `POST /backup/snapshot` |\n| Fleet/remote | `GET /diagnostics`, `POST /heartbeat/test`, `GET/PUT /remote`, `GET /remote/status`, `POST /remote/tunnel/{start\\|stop}` |\n| Provisioning | `GET /provision/status`, `POST /provision` |\n\nWrites use **optimistic concurrency** — include the resource's `version`; a stale write returns `409`.\nList endpoints support cursor pagination (`cursor` = last seen id).\n\n### Error envelope\n\nEvery error: `{\"error\":{\"code\",\"message\",\"details?}}`. Stable machine codes:\n\n`unauthorized` · `forbidden` · `validation_error` · `unknown_printer` · `not_found` ·\n`unsupported_for_printer` · `idempotency_conflict` · `conflict` · `rate_limited` · `queue_full` ·\n`quota_exceeded` · `render_error` · `printer_unreachable` · `internal_error`.\n\n---\n\n## Template variables\n\nEvery renderable string — thermal element values, PDF template HTML/CSS, and PDF-overlay fields —\nis a **Jinja2** template merged at print time with the request body's **`data`** object (or the\nsaved `sample_data` for previews). The engine is **sandboxed** (no `__dunder__`/attribute escapes)\nand **HTML-autoescaped** for PDF output, so values from `data` can't inject markup or break out.\n\n### Where to reference variables, and how\n\n| Surface | Syntax | Notes |\n|---|---|---|\n| **Thermal format** (`elements[].value`, table `row` cells, `qr`/`barcode` value, `image` asset) | `{{ data.field }}`, nested `{{ data.client.name }}` | merged from `data` |\n| **PDF template** (HTML + CSS) | `{{ data.field }}` **or** top-level `{{ field }}` | full Jinja: `{% for %}`, `{% if %}`, filters |\n| **PDF overlay** (field `value`) | `{{ data.field }}` | text/QR field values |\n\nIn **PDF templates** the request fields are exposed both under `data.*` and at the top level, so a\ndesigner-authored template can use `{{ client.name }}` or `{{ data.client.name }}` interchangeably.\n\n### `data` is what you send\n\n```jsonc\nPOST /v1/print\n{ \"printer\": 1, \"format\": 2,\n  \"data\": { \"company\": \"Acme\", \"total\": \"24.48\",\n            \"lines\": [ {\"name\": \"Widget\", \"qty\": \"2\", \"amt\": \"9.98\"} ] } }\n```\n…then `{{ data.company }}`, `{{ data.total }}`, and a row loop over `data.lines` resolve. For a\npreview with no `data`, the format/template's saved `sample_data` is used.\n\n### Jinja you can use\n\n- **Interpolation:** `{{ data.x }}`, nested `{{ data.a.b }}`.\n- **Loops** (PDF/HTML): `{% for li in data.lines %}{{ li.name }} {{ li.amt }}{% endfor %}`.\n  Thermal tables loop declaratively instead — `\"rows_from\": \"data.lines\"` + `\"row\": [\"{{ item.name }}\", \"{{ item.amt }}\"]`.\n- **Conditionals:** `{% if data.url %}…{% endif %}`.\n- **Defaults / filters:** `{{ data.note | default('—') }}`, `{{ data.name | upper }}`.\n- **QR images in PDF templates:** `\u003cimg src=\"{{ data.url | qr_data_uri(box_size=4, ec='H') }}\"\u003e`\n  (the `qr_data_uri` filter returns an embedded PNG data-URI). Thermal/overlay output uses the\n  dedicated `qr` element/field instead.\n\n### Missing variables\n\n- **Thermal formats \u0026 overlays** are **strict** — an undefined variable raises `render_error`, so a\n  typo in the controlled element schema fails loudly. Keep `data` complete or use the `default` filter.\n- **PDF/HTML templates** are **lenient** (optional-friendly) — a missing variable renders empty, is\n  falsy in `{% if %}`, and iterates to nothing in `{% for %}`. So `{% if logo_url %}` and\n  `{% for s in surcharges %}` work even when those optional fields aren't supplied.\n\nCapability-aware rendering also rejects elements a printer can't do (e.g. `qr` on a non-QR printer)\nwith `unsupported_for_printer`.\n\n---\n\n## Document format schema (thermal)\n\nA document is `{ \"elements\": [ … ] }`. String fields are Jinja templates merged with the request\n`data` (sandboxed, autoescaped for HTML).\n\n```jsonc\n{\n  \"elements\": [\n    {\"type\":\"text\",\"value\":\"{{ data.company }}\",\"align\":\"center\",\"bold\":true,\"size\":[2,2]},\n    {\"type\":\"text\",\"value\":\"Receipt {{ data.date }}\",\"align\":\"center\"},\n    {\"type\":\"rule\"},\n    {\"type\":\"table\",\"cols\":[24,10,12],\"align\":[\"left\",\"right\",\"right\"],\n     \"rows_from\":\"data.lines\",\"row\":[\"{{ item.name }}\",\"{{ item.qty }}\",\"{{ item.amt }}\"]},\n    {\"type\":\"text\",\"value\":\"TOTAL {{ data.total }}\",\"align\":\"right\",\"bold\":true},\n    {\"type\":\"qr\",\"value\":\"{{ data.url }}\",\"size\":6,\"ec\":\"M\",\"native\":false},\n    {\"type\":\"barcode\",\"format\":\"CODE128\",\"value\":\"{{ data.ref }}\"},\n    {\"type\":\"image\",\"asset\":\"logo.png\"},\n    {\"type\":\"pulse\"},\n    {\"type\":\"feed\",\"lines\":2},\n    {\"type\":\"cut\"}\n  ]\n}\n```\n\n| Element | Fields |\n|---|---|\n| `text` | `value`, `align` (left/center/right), `bold`, `size` `[w,h]` (1–8) |\n| `rule` | — (full-width separator) |\n| `table` | `cols` (widths), `align`, static `rows` **or** `rows_from` (data path) + `row` (cell templates) |\n| `qr` | `value`, `size` (1–16), `native` (printer QR command vs raster image), `ec` (L/M/Q/H), `model` (1/2), `center` |\n| `barcode` | `format` (CODE128/EAN13/CODE39/UPC-A), `value` |\n| `image` | `asset` (uploaded asset name; scaled/dithered to paper width) |\n| `feed` | `lines` |\n| `pulse` | `pin` (2 or 5), `on_ms`, `off_ms` — cash-drawer kick |\n| `cut` | — |\n\nCapability-aware: an element a printer can't do (e.g. QR on a printer without QR) returns\n`unsupported_for_printer`. Formats are **versioned**, and the version is recorded on each job for\nreproducible reprints.\n\n---\n\n## PDF templates (office)\n\nCUPS printers render an HTML/CSS template to PDF via WeasyPrint. `data` is merged with Jinja\n(autoescaped). A built-in **`qr_data_uri`** filter embeds a scannable QR:\n\n```html\n\u003ch1\u003e{{ data.title }}\u003c/h1\u003e\n\u003ctable\u003e\n  {% for line in data.lines %}\u003ctr\u003e\u003ctd\u003e{{ line.name }}\u003c/td\u003e\u003ctd\u003e{{ line.amt }}\u003c/td\u003e\u003c/tr\u003e{% endfor %}\n\u003c/table\u003e\n\u003cimg src=\"{{ data.url | qr_data_uri(box_size=4, ec='H') }}\" alt=\"QR\"\u003e\n```\n\n`page_setup` controls `size` (e.g. `A4`) and `margins`. WeasyPrint is **locked to local assets** —\nremote URL fetches are blocked (SSRF), and renders are time/memory-bounded.\n\n---\n\n## PDF overlay templates\n\nWhen you have a fixed **base PDF** — a pre-printed form, letterhead, or government form — you can\noverlay dynamic values onto it instead of recreating it in HTML.\n\n**In the admin UI (Overlays tab):**\n1. Upload the base PDF.\n2. It renders in the browser (pdf.js). Click **+ Text / + QR / + Image**, then **drag** each field\n   onto the page to position it. Pick the page with the page navigator.\n3. Bind each field to a variable (`{{ data.name }}`), set font/size/align/color, and enter sample\n   data. **Preview PDF** stamps the values live; **Save** versions the overlay.\n\n**Field types:** `text` (Jinja value), `qr` (Jinja value → scannable QR), `image` (an uploaded\nasset). Coordinates are stored in PDF points (top-left origin), so they're resolution-independent\nand multi-page aware.\n\n**Print it** to any PDF-capable printer (CUPS, or `virtual` in dev):\n\n```bash\ncurl -s localhost:8080/v1/print -H \"Authorization: Bearer $SECRET\" \\\n  -d '{\"printer\":3,\"overlay\":1,\"data\":{\"name\":\"Acme LLC\",\"url\":\"https://example.com/r/1\"}}'\n```\n\nAt print time the values are stamped onto the original PDF with reportlab + pypdf (PDF/PS auto-filter\nthrough CUPS). PDF/PostScript text is selectable in the output; the base content is preserved.\n\n---\n\n## Recipe: Stripe payment receipts\n\nA ready-made **thermal format** and **PDF template** for Stripe payments live in\n[`config/stripe-receipts.yaml`](config/stripe-receipts.yaml). Import them onto any appliance:\n\n```bash\ncurl -s localhost:8080/v1/admin/config/import -H \"Authorization: Bearer $SECRET\" \\\n  -d \"{\\\"dry_run\\\":false,\\\"yaml\\\":$(python -c 'import json,sys;print(json.dumps(open(\"config/stripe-receipts.yaml\").read()))')}\"\n```\n\nThen map a Stripe `charge.succeeded` object to the print `data` and enqueue with\n[`examples/stripe_to_vibe.py`](examples/stripe_to_vibe.py):\n\n```bash\n# thermal printer with the format; or use --template N for an office/laser printer\nstripe charges retrieve ch_123 | \\\n  python examples/stripe_to_vibe.py http://localhost:8080 \"$SECRET\" --printer 1 --format 2\n```\n\nStripe amounts are in **cents** (the helper formats them); line items aren't on a Charge, so pass\nthem from your order system via `--line-items`. The receipt's QR encodes the Stripe `receipt_url`.\n\n---\n\n## Configuration (environment variables)\n\nAll variables are prefixed `VIBE_PRINT_`.\n\n| Variable | Default | Purpose |\n|---|---|---|\n| `SECRET` | _(required)_ | shared bearer secret; empty → refuse to boot |\n| `DATA_DIR` | `./data` | DB + assets + backups |\n| `TRUSTED_PROXIES` | `[]` | JSON list of proxy IPs/CIDRs to trust for real-IP headers |\n| `MAX_ATTEMPTS` | `5` | retry attempts before `dead` |\n| `RETRY_BASE_SECONDS` / `RETRY_MAX_SECONDS` | `2` / `300` | backoff bounds |\n| `QUEUE_MAX_DEPTH` / `PER_PRINTER_MAX_DEPTH` | `1000` / `100` | backpressure caps |\n| `RATE_LIMIT_PER_MINUTE` | `120` | per-real-IP request limit |\n| `MAX_BODY_BYTES` / `MAX_ASSET_BYTES` | `5 MiB` / `10 MiB` | size caps |\n| `JOB_RETENTION_DAYS` / `AUDIT_RETENTION_DAYS` / `IDEMPOTENCY_TTL_HOURS` | `30` / `365` / `24` | retention windows |\n| `RENDER_TIMEOUT_SECONDS` | `15` | render budget |\n| `STORE_PAYLOADS` | `true` | `false` → keep only a content hash after printing (PII minimization) |\n| `ENCRYPT_AT_REST` / `DB_ENCRYPTION_KEY` | `false` / — | SQLCipher at rest (Linux image) |\n| `WEBHOOK_URL` / `WEBHOOK_SECRET` | — | signed `dead`/`uncertain`/offline alerts |\n| `HEARTBEAT_URL` / `HEARTBEAT_SECRET` / `HEARTBEAT_MINUTES` | — / — / `15` | fleet phone-home |\n| `ACCESS_TEAM_DOMAIN` / `ACCESS_AUD` | — | enable Cloudflare Access JWT on admin routes |\n| `CLOUDFLARED_METRICS_URL` | — | tunnel health for `remote/status` |\n| `REMOTE_ACCESS_MODE` / `REMOTE_HOSTNAME` | `lan` / — | display-only remote-access info |\n| `IMAGE_DIGEST` | `dev` | surfaced in `/v1/version` (set by the update flow) |\n\n---\n\n## Remote access\n\n**Everything from the UI (no host shell):** the **Remote Access** tab runs a `cloudflared` tunnel\nas a managed process inside the appliance. Pick **Quick** (instant `*.trycloudflare.com` URL, no\nCloudflare account) or **Named** (paste a tunnel token for a stable dashboard hostname), then\nStart/Stop — it auto-restarts on reboot. LAN access stays on at the same time. (The compose\n`cloudflare` profile / sidecar remains as an alternative.)\n\nThree selectable modes, all optional:\n\n- **LAN-only** (default). Front with **Caddy** for TLS on a trusted segment (`--profile caddy`).\n- **Cloudflare Tunnel** (`--profile cloudflare`): outbound-only, no inbound ports. Provision the\n  hostname once in the Cloudflare dashboard — the appliance stores **no Cloudflare API token** and\n  never edits DNS/ingress; it only displays the hostname. Add **Cloudflare Access** (Zero Trust) in\n  front of the admin UI, then set `ACCESS_TEAM_DOMAIN` + `ACCESS_AUD` to enforce the JWT app-side\n  (service tokens work for machine clients). `GET /v1/admin/remote/status` reports tunnel health.\n- **Tailscale**: the private-network alternative — join the appliance to your tailnet.\n\n`/healthz` and `/readyz` stay open and outside Access so health checks don't break.\n\n---\n\n## Backup \u0026 restore\n\n- `POST /v1/admin/backup/snapshot` writes a consistent SQLite snapshot (online `VACUUM INTO`).\n- `deploy/backup.sh` ships the snapshot + assets to **Backblaze B2** (S3-compatible, Object Lock for\n  immutability) — run it from cron.\n- `deploy/restore.sh \u003cSTAMP\u003e` restores into the data volume; migrations are idempotent on restart.\n\nRun a periodic **restore drill** to verify backups.\n\n---\n\n## Observability \u0026 compliance\n\n- **Metrics:** Prometheus at `/metrics` (jobs by status, per-printer counts, queue depth, render timings).\n- **Audit:** `config_audit` + `print_audit` are **hash-chained**; `GET /v1/admin/audit/verify`\n  detects any tampering. Logs are JSON (structlog) and **redact** payloads/data.\n- **Webhooks:** HMAC-signed POSTs on `dead`/`uncertain`/printer-offline (`X-Vibe-Signature`).\n- **Fleet:** opt-in heartbeat + `GET /v1/admin/diagnostics` (PII-free) for support.\n- **PII:** `STORE_PAYLOADS=false` replaces a job's payload with a content hash after printing;\n  `DELETE /v1/admin/jobs/{id}/payload` erases on demand; retention prunes jobs/audit/idempotency.\n\n---\n\n## Development\n\n```bash\nmake dev         # run the stack against virtual printers (no hardware)\nmake seed        # load sample fixtures\nmake test        # pytest (79 tests; virtual + socket-mock + soak)\nmake lint        # ruff\nmake typecheck   # mypy (enforced in CI)\nmake gen-api     # regenerate the TS client from OpenAPI (CI fails on drift)\nmake web-build   # build the admin UI into app/static\nmake build       # multi-arch Docker image\ncd web \u0026\u0026 npm run e2e   # Playwright (needs: npx playwright install chromium)\n```\n\nArchitecture lives in `app/` (FastAPI), `web/` (React+TS), and `deploy/` (Docker/compose/Caddy/CUPS).\nSee [`VIBE-PRINT-MASTER-PLAN.md`](VIBE-PRINT-MASTER-PLAN.md) for the full design and\n[`STATUS.md`](STATUS.md) for what's implemented vs. excluded.\n\nContributions: open a PR — CI runs ruff, mypy, the full test suite, the OpenAPI drift gate, the\nfrontend build, and Playwright e2e.\n\n---\n\n## Project status\n\nThe full master plan is implemented and tested (120+ tests, ruff + mypy clean, Playwright e2e\nverified, CI green). First multi-arch release **`v0.1.0`** is published to GHCR. Two items from the\nplan's \"consciously deferred\" list are intentionally **out of scope** because they contradict the\nappliance's locked design (single shared secret; single-process SQLite): **multi-tenant isolation**\nand **HA / multi-node** — both would need a different architecture (per-tenant auth / Postgres +\ndistributed coordination). See `STATUS.md`.\n\n---\n\n## License\n\nNo license file is included yet, so default copyright applies (all rights reserved). If you intend\nothers to use, modify, or distribute this, add a `LICENSE` (e.g. MIT or Apache-2.0).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkisaesdevlab%2Fvibe-printer","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fkisaesdevlab%2Fvibe-printer","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkisaesdevlab%2Fvibe-printer/lists"}