# Changelog All notable changes to Klarbild are documented here. Newest first. ## 2026-10-10 — Trash, access rules, current dependencies ### Added - **Trash instead of deleting for good.** Deleting moves an image into the trash (`items.deleted_at`, migration 017). It disappears from the library, share links, ZIPs, deliveries, the backup mirror, print sources and the print history, and returns unchanged on restore. A toast offers "Rückgängig"; the trash view lists days left, restore, delete for good, empty. Maintenance purges after `settings.trash_days` (default 30, Admin → Speicher); 0 switches the trash off. API: `DELETE /api/items/:id` (trash), `?purge=1`, `GET|POST|DELETE /api/trash`. - Purging deletes only files no other image still uses — alternatives share their source with the original, including combine sources in `source_paths` (jsonb). - An image trashed while queued is skipped by the worker instead of being generated. - Favicon and iPhone home-screen icon. ### Fixed - **Access checks on every image route.** Only the file route checked visibility before; rename, delete, retry, alternative, reuse and the selection ZIP accepted any id from any account. One rule in `src/lib/itemaccess.ts`: read own (or shared library, never a private session), change own; admins all. - Telegram print sheets had no footer: the footer was passed as a string instead of a `FooterSpec`, so `.text` was undefined. - `npm run typecheck` exists and reports 0 errors (17 before). ### Changed - Dependencies: nodemailer 10, basic-ftp 6, argon2 0.45, archiver 8 (`ZipArchive`), ssh2-sftp-client 12, @astrojs/react 7, Astro 7.3.8, sharp 0.35.5. `npm audit`: 0. pg-boss stays on 10 (12 cannot migrate the v10 schema in place). - basic-ftp 6 no longer redirects the data connection when an FTP server behind NAT announces its private address. Run "Verbindung testen" for each FTPS target. ## 2026-09-01 (later) — Unique names for print files ### Fixed - **Two print PDFs from the same day had the same name** — `2026-09-01_druckbogen.pdf`, every time. The browser appended "(1)"; anyone filing them in the same folder or on the NAS lost the first one. Exactly the defect fixed for library images in August, still sitting in the print module. Print files now follow the same rule as everything else: `2026-09-01_143207_druck-9-teile_a4_4eca.pdf` — date, time to the second, what is on the sheet, the paper, and a short tag. **The tag is the id of the history entry**, so a PDF can be traced back to its run under "Last printed". - Single images carry the motif of their source: `2026-09-01_143207_lea-badge_130x180_25b3.jpg` instead of `2026-09-01_1318.jpg`. - The browser no longer invents its own name. The server sends one and the client reads it from `Content-Disposition` — there were two truths, and the client's was the worse one. Names read from that header are stripped of anything path-like before use. ## 2026-09-01 (later) — Printing from the library, and a print history ### Added - **Pick images from the library** in the print area. Until now a file had to be uploaded even when Klarbild already had it — a studio result went to disk and back through the uploader. The picker has search (filename and description), a folder filter and multi-select, reachable from the empty tray, from the card header and from each cell group. Only finished, visible images are offered, under the same rules as the library itself. - **Last printed** (card 4 on `/drucken`): what was printed recently, with a button to produce the exact same sheet again. **Only the instruction is stored, never the PDF** — an A3 sheet runs to 20–80 MB, an entry to 1–3 KB. That works because the print module computes without a model: the same job yields the same sheet. Verified — two runs of one configuration produced PDFs differing *only* in `CreationDate` and `ModDate`; the image streams are byte-identical. Repeat is offered only when every cell came from the library and every image still exists; otherwise the entry says which of the two reasons applies. The last 100 runs per account are kept, older ones drop off by themselves. You see your own runs; admins see all. - **Mail access from the server environment.** Sending now falls back to `SMTP_*` environment variables when nothing is entered under Admin → E-mail, so a fresh install can send straight away — and with it, password reset works. Credentials live in Coolify, never in the repo. The admin card says which source is actually in effect. ### Fixed - The run summary listed every cell separately: "10 × 15 cm, 10 × 15 cm". Identical sizes are now merged and their counts added. The first attempt turned "13 × 18 cm" into "13 cm ×18" — the × inside a measurement looked like a count. Anchored to the end of the line now, with seven tests holding it there. - "1 image is missing" for a sheet whose source was simply an upload: one counter was serving two different causes. Two counters, two explanations. - "the PDF was 0.0 MB" for a 20 KB sheet — below a megabyte it now counts in KB. - A file missing from disk showed the browser's broken-image icon in the picker and the history. The tile stays quiet now, and still clickable. - On narrow screens the right-hand column was pulled to the top (`order: -1`, so the preview is visible straight away). With a fourth card you would have scrolled past a whole block before reaching the upload area. The cards now run in their own numbering — and the numbers in the card headers are visible again, because they are correct again. ## 2026-09-01 — Sharing, e-mail, password reset, and a security pass ### Added - **Share links** (`/s/`) for a single image or a collection: someone without an account can view them and download at full resolution. These are photos of children, so the defaults lean safe — every link expires by itself (30 days, configurable), can be revoked and un-revoked, optionally carries a passphrase, download can be switched off entirely, and pages and files are noindex. Access is bound to the link, not the image: swapping in another item id on a valid link returns 404. Private-session results can never be shared. - **Download everything at once**: a share with several pictures gets one button above the grid for a ZIP at full resolution. Written to stream, not buffer -- one file at a time, waiting for archiver's `entry` before reading the next. Measured: 440 MB of images, RSS peaked at 196 MB against a 120 MB baseline. Capped at 200 files and 500 MB, past which the archive closes with a note saying what happened. Respects the download lock and the passphrase; rate limited per link and address. - **Passing it on** by e-mail (several recipients, no attachment — a link can be withdrawn later, an attachment cannot) or via `wa.me`, which opens WhatsApp with the text prepared. The WhatsApp Business API needs a Meta account, approved templates and per-conversation fees; this works today for free and the API can slot in behind it. - **E-mail through the account's own mailbox** (SMTP, credentials AES-256-GCM encrypted like every other secret). Powers password reset, optional job-done notifications, and mailing share links. nodemailer has zero dependencies and no native postinstall — checked before adding it, given the history. - **Password reset**: single-use token, only its hash stored, one hour, identical response whether or not the address exists, rate limited per address and per sender. - **`/konto`** for the e-mail address and notifications, **`/teilen`** for managing links with view and download counts. - **IPTC/XMP provenance** on AI results (`trainedAlgorithmicMedia`, or `compositeWithTrainedAlgorithmicMedia` for edited photos) plus the model, written by hand into PNG iTXt and JPEG APP1 rather than pulling in a sharp upgrade. exiftool reads it back from both formats; pixels, dimensions and dpi provably untouched. - **CSP with real hashes** for every inline script, plus nosniff, referrer-policy, X-Frame-Options and a permissions-policy. `style-src` stays open because React sets styles as attributes and attributes cannot be hashed — measured, the hash-only policy breaks the print preview. - `mem_limit: 3g` in compose. sharp can spike; without a limit an outlier takes the whole container down instead of just the process. ### Fixed - **Sessions never expired server-side and survived a password reset.** `Max-Age` is a request to the browser, not a rule for the server. A leaked cookie stayed valid indefinitely, and the very reset meant to rescue a compromised account left the intruder logged in; a demoted admin also kept admin, because the role came from the cookie. Cookies now carry a session epoch, checked against the database on every request, and the epoch is bumped on password reset and on any role change. - **"Allow download: off" did nothing for images with transparency** — exactly the cutouts and stickers this tool exists for. Verified: now 1500x2000 instead of the 2400x3200 original, and the download route returns 403. - **The e-mail settings card saved nothing** while reporting success. Found by the design critique an hour after shipping. - **The public page explained how to bypass "view only"**, printing the long-press hint regardless of the setting. - The share title went into outgoing mail HTML unescaped while the message was escaped. - argon2 ran on every request for protected links, including every image (130 ms, 64 MiB each). Checked once now, then a short-lived signed cookie scoped to that link. The passphrase moved from the query string to POST. - `days` accepted nonsense and quietly produced an unlimited link (`Number('abc')` is NaN, `NaN > 0` is false; `[]` coerces to 0). - Rate limits keyed on the *first* X-Forwarded-For value, which the caller sets. Counting from the right now, trusted-proxy count configurable. - **The path-escape hole in delivery** the wiki had flagged twice as open. - The crop maths lived twice with nothing holding the two copies together. One module now, 60000 randomised cases asserting both entry points agree — which immediately found a real bug: the zoom limits also clamped the *initial* crop, so an extreme format silently broke the aspect ratio. - Smaller: expired links could still be mailed; missing PUBLIC_BASE_URL sent dead links while reporting success; the reset response time revealed whether an account existed; `share_views` grew without bound; double provenance marking appended a second packet with the older model; the colour flag covered the selection circle in the library; the seventh nav item pushed logout off screen at 820px; `/konto` was unreachable on a phone; network failures rendered as empty states. ### Changed - 42 new tests (97 total). `shares`, `mail` and `provenance` had none, and that is where both reviews found the most. - `PRODUCT.md` and `DESIGN.md` added, written from the code and the wiki. ## 2026-08-21 — Unique, chronologically sortable filenames ### Fixed - **Pictures overwrote each other at Picdrop.** Two badges of the same person in the same format on the same day both came out as `2026-07-23_lea_the-frame.png`, so the second upload silently replaced the first. Reported from real use — Till lost images this way. The name now carries the time down to the second plus a short tag derived from the item id: 2026-08-20_143207_lea-badge_the-frame_a3f9.jpg Two pictures can no longer collide: the tag comes from the item id, which is unique. A belt-and-braces loop appends `-2`, `-3` … if a name is somehow still taken — deliberately a code loop rather than a UNIQUE constraint, because a rejected write would fail an already-paid AI job at the very last step. ### Added - **Backfill for the existing library** (Admin → Dateinamen). Preview first — it reports how many names are currently duplicated — then apply. It re-stamps from `created_at` and the item id while keeping the motif and format already chosen, so it is idempotent: a second run changes nothing. Stored files are untouched; they live under their id, not their name. - **Re-deliver everything** under the new name, for copies already sitting at Picdrop. The old files stay where they are — deleting a customer's files automatically is the wrong reflex; that stays a manual decision. ### Changed - Timestamps use **Europe/Berlin** (override with `KLARBILD_TZ`) rather than UTC. A picture taken at 00:30 should not be dated to the previous day. - Naming moved into `src/lib/naming.ts` — dependency-free, like `printlayout.ts`, so the rules are testable and identical in every path (jobs, Telegram, MCP, backfill). The generator strips its own stamp when a result is fed back in as a source, so re-processing cannot accumulate timestamps. - 17 new tests (56 total), including the reported bug as an explicit regression test and a check that lexicographic sorting equals chronological sorting across a DST boundary. ## 2026-08-20 — Oversize sheets, duplicate images, free labelling, new layout ### Added - **Prints larger than the sheet.** The everyday case is 20 × 30 cm on A4 photo paper, where exactly 3 mm are missing. Instead of refusing, each cell now offers two ways out (`src/lib/printoversize.ts`, dependency-free like `printlayout.ts` so browser and server compute the same thing): - `overflow` centres the piece on the **whole sheet**, not the printable area — otherwise you throw away the margin a borderless printer can actually use. How much is lost per side is computed up front and shown before printing. - `tile` splits the piece across several sheets. Tiles overlap by a glue fold (10 mm by default), printed on both neighbours and marked with a dashed line. Each sheet carries its number, row and column. 30 × 40 cm on A4 → 2 × 2 sheets. Verified by rasterising the PDFs at 254 dpi: the overflow case measures 200,1 mm wide at x = 5 mm and bleeds off both long edges; the tiles measure 200 × 286 and 110 × 124 mm, overlapping by 10. - **Duplicate an image.** The same picture can now appear as several cells and be printed in two different sizes on one sheet. Previously you had to copy the file, rename it and upload it again. - **Configurable label.** The footer line can be switched off, given custom text, and placed at one of six positions. It is only drawn if that edge is actually free — it never lands on a picture. - **Public presentation page** at `/vorstellung` (no login, nothing operable). The sample sheet on it is not a drawing: it runs the real packer at render time, so it follows the algorithm. ### Fixed - **The tile number overlapped the picture.** The sheet number is printed at 8 pt from 3 mm off the top edge, which needs about 6 mm — with a 5 mm paper margin it ran 0,8 mm into the motif. `noteInset()`/`tileSheet()` now shift the tile down by the shortfall and shorten the sheet by the same amount, so nothing sticks out at the bottom. Both the browser preview and the PDF call the same helper, so the previewed sheet count cannot drift from the printed one. ### Changed - **Desktop layout.** Work on the left, preview on the right, and the preview column is sticky so it follows along — no more endless column next to an empty one. Setting groups sit collapsed side by side in a two-column grid and expand across the full width, so three-way switches keep their labels instead of being squeezed to 50 px. - **Mobile layout.** Preview first, "Druck-PDF erzeugen" as a fixed bar above the tab navigation, each image row showing thumbnail and controls side by side, touch targets at 38–40 px, and the remove button moved onto the thumbnail so it stops covering the format dropdown. The step numbers ("1 ·", "2 ·") are hidden below 1080 px, where the preview comes first and the numbers would lie. - Advanced setting groups (margins, quality, labelling, delivery) start collapsed — their summary line already says what is set. ## 2026-08-18 (4) — Renamed to "Drucken", 86 sizes, crop editor fixed ### Fixed - **The crop editor distorted the picture.** `.crop-box` had `width:100%` *and* `max-height`, so the height was clamped while the width stayed put — the frame no longer had the target aspect ratio, and `object-fit:fill` stretched the image into it. Measured on a 390 px viewport: frame 0.843 instead of 0.778, picture **8.3 % too wide**. The frame width is now derived from the allowed height and the ratio, and the image carries its own `aspect-ratio` instead of a second percentage — it can no longer stretch, even if the frame is ever off. - **Dragging felt broken.** At "fill" zoom the crop sits exactly at the stop in one axis, so pulling that way does nothing — correct, but invisible. The editor now says which way the picture can move, double-click/double-tap zooms a step, arrow keys nudge (shift for bigger steps), and the zoom slider shows its factor. ### Changed - **Renamed to "Drucken"** — the tab, page, title and Telegram button. `/druck` and `/passbilder` both redirect permanently to `/drucken`. - **86 image sizes in ten groups** (was 41): passport formats per country, cm small sizes, classic photo sizes, **US inch sizes** (wallet to 24 × 36), **instant-film picture areas** (Instax mini/Square/Wide, Polaroid), poster and frame sizes up to 100 × 140, squares, **DIN A0–A7 incl. A3+**, cards (business card, postcard, DIN lang, cheque card) and aspect ratios as physical sizes (16:9, 9:16, 21:9, 2:1, 3:2, 2:3, 4:3, 3:4, 5:4, 4:5, 1:1). - Paper list grew to 19: DIN A1, square photo paper, and the US inch cuts. ## 2026-08-18 (3) — Code review: fixes ### Fixed - **EXIF orientation was ignored.** Phone photos carry their rotation as metadata only. sharp cropped the unrotated raster, so a portrait shot came out of the printer sideways and with the wrong framing. Orientation is now applied before any geometry, and `/api/uploads` reports oriented dimensions so the crop editor agrees with the render. - **Memory blow-up on "Rand lassen".** The padded image was materialised at source resolution before the resize. A panorama into a narrow contain target built a ~960 MB intermediate and then failed outright; it is one extract→resize→extend chain now (sharp's own order) — 90 ms and a few MB. - **`bleedMm` was unbounded** in `/api/print/single` (the sheet endpoint clamped it). - **Delivery gallery could escape the target's base folder** — `posixpath.join` happily resolves `../..`, and the folder is created before upload. Names are validated now. - **Denial of service:** sheet requests are capped at 500 pieces and the packer has a step budget, so a degenerate request can no longer block the single-threaded server. - **Print presets:** delete only your own (admins all), config size and count limits, and `by_name` honours `anonymous_generations`. - **Telegram callbacks** now require an active pairing, like every other path. - **Error responses** no longer leak storage paths or delivery hostnames. - `allowRotate: undefined` meant "no rotation" in one place and "rotation allowed" in two others — a picture that only fits rotated was reported as unplaceable. - The many-formats shortcut dropped a format that only fits rotated. - `unplaced` blamed the first format instead of the one actually missing. - Corner marks could land inside the printed bleed; the offset is raised to clear it. - `capacity()` silently capped at 200. - Image keys could collide with a cell literally named `x::rot`. - `labelMm` rounded away real decimals (11,25 cm became 11,3); `parseSizeMm` ignored the order in `a:b`, so `3:4/15` and `4:3/15` produced the same portrait size. - The footer was drawn over the artwork when the margin was small. - The UI now warns when corner marks do not fit the margin, and when continuous guides are combined with mixed sizes (they cannot run through). ### Changed - Tests grew from 21 to 31 — every finding above has a regression test. ## 2026-08-18 (2) — Passbildfunktion: all sizes, fit rules, mobile ### Added - **Renamed to "Passbilder"** — the tab, page and Telegram button now say what it is. `/druck` permanently redirects to `/passbilder`. - **Every size the AI pipeline knows** is now printable too: 9×13 … 60×90, DIN A6–A2, squares, plus poster/frame sizes up to 70×100 and the screen ratios (16:9 "The Frame", 9:16, 3:2, 4:3, 5:4) as physical measurements. 41 presets in seven groups. - **Fit rule per image** — when the picture does not match the target ratio, choose **Zuschneiden** (fill the format, crop the overflow — default) or **Rand lassen** (keep the whole picture, pad with a border colour: white, black, paper or custom). Nothing is ever distorted. Works in the crop editor, the sheet preview, the PDF, `/api/print/*` (`fit`, `bg`) and the saved templates. - **Mobile layout** — image cards become a row on phones, touch targets grow to ~40 px, the crop editor turns into a full-width bottom sheet, no horizontal overflow. Verified at 390 / 820 / 1440 px; all three produce the identical PDF. ### Fixed - **Sheet cells could come out oversized.** sharp applies `extend` *after* `resize`, so padding was added on top of the finished size — a 35×45 mm cell became 35×171 mm in "Rand lassen" mode, and bleed near an image edge was off too. Crop and padding now run in their own pass. Covered by `tests/printrender.test.ts`. - **Orientation is no longer overridden.** A single portrait passport photo was laid down sideways just because more would fit that way. Rotation now only happens when it actually saves a sheet. ## 2026-08-18 — Print module: exact sizes, sheets & crop marks (no AI) ### Added - **Print module (`/druck`)** — a lab-style, completely AI-free path: crop, scale and place images at exact physical sizes. No model call, no cost, no queue. - **Interactive crop editor** — pan and zoom on a fixed target aspect ratio (rule-of-thirds overlay); the crop is stored relative (0..1) so it survives any source resolution. - **Size presets & free input** — biometric passport 35 × 45 mm, 2 × 3 / 3 × 4 / 4 × 5 / 4,5 × 6 cm, 9 × 13 … 50 × 70 cm, squares, DIN. Free input understands `12x15` (cm), `35x45mm`, `5` (= 5 × 5 cm) and `4:3/15` (ratio + long edge). - **Sheet layout** — paper presets DIN A6–A2 incl. **A3+ (329 × 483 mm)**, photo-paper cuts (9 × 13 … 20 × 30), US Letter/Legal, plus any custom paper size. Portrait/landscape, printer margin, centering, per-image copy count. - **Automatic nesting** — identical sizes produce an exact grid; mixed sizes go through a MaxRects packer that fixes one orientation per format and tries every combination, so eight passport photos land in the free space next to the 13 × 18. - **Crop marks** — `corner` (fine 0.25 pt marks outside the trim box, Photoshop/InDesign convention, 4 mm long / 3 mm offset by default) or `grid` (continuous guides across the sheet that never cross another image). Gap between images adapts to the chosen marks. - **Bleed** (0–10 mm) — the image extends past the trim box, edge pixels are copied rather than stretched; the gap is always ≥ 2 × bleed so neighbouring bleeds cannot overlap. - **PDF output at 1:1** — the PDF page is exactly the sheet size (pdf-lib), with a footer stating the sizes, dpi and the "print at 100 %, not fit-to-page" reminder. - **Single export** — one image at an exact size as PNG/JPG with dpi metadata, plus a resolution warning when the source cannot hold the requested dpi. - **Sheet templates** — save the whole sheet setup (paper, marks, formats, counts) as a reusable preset (`print_presets`, migration `013`). - **All three entrances** — `/druck` in the web app, `📐 Druckbogen` in Telegram (fully button-driven, returns an A4 PDF), and the MCP tools `exact_size` / `print_sheet`. - **API** — `POST /api/print/single`, `GET /api/print/single?size=…` (mm → px calculator), `POST /api/print/sheet`, `GET|POST|DELETE /api/print/presets`. Documented in `/llms.txt`. - **Seeded sheet templates** — "Kita-Satz (A4)", "Schulsatz klein (A4)", "Passbildbogen 35×45 (A4)" and "2 × 10×7,5 auf Fotopapier 10×15". Loading a multi-format template with a single image clones that image into every format, so one click produces the whole set. - **Margin profiles** — borderless (0 mm), standard (5 mm) and safe (10 mm) as one-click buttons. - **Deliver the sheet** — the finished PDF can additionally be pushed to Picdrop, the NAS or any extra delivery target (`deliver` in the sheet request). - **Tests** — `tests/printlayout.test.ts` (`npm test`) covers size parsing, exact mm → px, non-overlap, margins, bleed spacing, mark geometry and multi-sheet paging. ## 2026-07-23 — Compose, generate & second backup ### Added - **Compose mode** — combine multiple images + a text description into one new image. - **Generate mode** — create images from text only, no source needed. - **The Frame "extend"** — outpaint more scene around the motif instead of only cropping. - **Reuse** — take a finished result back into the studio as a new source to edit further. - **Alternatives** — generate additional versions of the same image, grouped with a switcher. - **Folders** — filter, rename and delete in the library; per-folder delivery gallery mapping. - **Bulk actions** — download selected results as a ZIP, move several into a folder at once. - **Thumbnails** — low-res previews for the library grid. - **Models** — quality tiers incl. open-source (FLUX.2); the used model is shown per image. - **Color tags** — Picdrop-style flags (red / orange / green / final) per image, filterable, and written into the companion `.md` metadata. - **Tap-to-save** — "Laden" opens the image full-bleed so on iPhone/iPad a long-press offers "Save to Photos" (plus a classic file download). - **Access control & privacy** — library visibility (own-only vs shared), optional anonymous generations (creator hidden from non-admins), admins always see all. - **Private sessions** — generate-and-forget: no library entry, no delivery/backup, no stored prompt, sources purged, result auto-deleted shortly after. Enabled globally by admin or forced per user. - **NSFW gate** — models flagged NSFW are only selectable/visible when the admin enables it. Operator responsibility; content involving minors is never permitted. - **Single-image transform** — compose now accepts one image, so a single photo can be transformed by prompt (e.g. "make it an oil painting") and put onto a format. - **Multiple delivery targets** — besides the default Picdrop, add any number of FTP/SFTP destinations; selectable in the studio ("Wohin?", incl. NAS); a preset or folder can be hard-wired to a specific target + gallery. - **Backup to any target** — mark any target as a backup destination (like the NAS); one-click "back up all images" mirrors to all backup destinations. - **Metadata sidecar** — optionally ship a companion `.md` (prompt, model, format …) next to each image on Picdrop/NAS/FTP. - **Prompt view** — every generated image shows its prompt in the library (with copy). - **Preset manager** — save current settings as a preset, load and delete presets. - **API token + MCP server** — programmatic access so an assistant can push images from a local folder or iMessage into Klarbild (`mcp/klarbild-mcp.mjs`). - **The Frame** preset now delivers to its own gallery ("TheFrame-Backgrounds"). - **Second backup** — mirror every result over SFTP/FTPS to a NAS (works over Tailscale too), with a "NAS ✓" badge and a "mirror all existing images" action. - **Storage management** — thumbnails, delete sources after processing, auto-retention. - **Telegram** — fully button-driven (persistent keyboard + command menu): edit, compose, "new image", reuse — no slash commands required. - **Reset** — wipe library, jobs and folders cleanly (keeps users, settings, models, recipes), guarded by a typed confirmation. - **Help page**, in-app **changelog**, and a NAS **reachability diagnostic**. ### Fixed / hardened - Real per-image cost cap (monthly budget) enforced by the worker. - Idempotent job completion (no duplicate delivery/notification). - Before/after comparison works for composed images; hidden for text-only results. ## 2026-07-22 — Foundation ### Added - Astro 5 + React islands UI (mobile-first), PostgreSQL, fs/S3 storage abstraction. - pg-boss job queue with in-process worker; sharp pipeline (exact cm/dpi print sizes, sticker contour). - OpenRouter image gateway; admin-managed models. - Picdrop delivery over SFTP/FTPS; recipes; library with before/after. - Telegram bot (grammY webhook); argon2 auth; AES-256-GCM secret encryption.