Ctrl K
ComunidadGuías…

CLAUDE.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# dsm-mini — working rules for this project

A Telegram bot with a Mini App for managing a Synology NAS: Download Station
tasks and File Station files. Open source, MIT, Go + React.

## The main rule: never guess about the Synology API

The Synology Web API behaves differently from its documentation in places, and
`SYNO.DownloadStation2.*` is not documented at all. The order of work:

1. First **download the official PDF** — links are in [docs/synology-api.md](docs/synology-api.md).
2. Then **check the call against a live NAS** with an integration test.
3. Any discrepancy found goes **straight into `docs/synology-api.md`**, with a sample response.

Explaining API behaviour with a guess is not allowed: this project has already
seen a wrong cause given for a failure that way (see "limit = -1" in the same file).

**When DSM refuses something, read its own logs on the NAS before theorising.**
Over SSH: `/var/log/synopkgmgr.log` carries the verdict of every package check
with a code and an English description, `/var/log/synopkg.log` the install
step by step. Both are `system:log`, mode 660 — root only, and the Web API
does not expose them: `SYNO.Core.SyslogClient.Log` shows "package installed"
and nothing about a refusal. The message in Package Center is written for
somebody else's problem and names neither the stage nor the reason. A month
went into guessing why an upgrade failed while one line of that log named the
cause from the first attempt (see "preupgrade / postupgrade" in
`docs/synology-api.md`).

Known traps that specific lines of code exist for:

- Batch actions answer in **three different shapes**, and in two of them a
  refusal arrives under `success: true`. Failing to parse the array means
  showing the user success where the NAS did nothing.
- `limit = -1` works normally in Download Station and **takes File Station down**
  with HTTP 502 on every API version.
- Parameter encoding differs in three places: ordinary calls use JSON,
  `FileStation.Upload` takes no quotes, `DownloadStation2` create with a file is
  JSON again. With multipart, `_sid` goes **in the query string**.
- Paths: File Station wants a leading slash, Download Station does not.
- A task's files, trackers and peers (`Task.BT.*`) are available **only while the
  task is active**; a finished one answers with code 1913, and that is not a failure.
- **Task** priority applies to eMule, not to torrents: for BT the call is
  accepted but does nothing and cannot be read back. Priority of **files** inside
  a torrent is a working feature.
- When the API says nothing about what it can do, the answer is in the web UI
  code on the NAS: `/var/packages/<Package>/target/ui/*.js` shows which call DSM
  itself makes and with which parameters. Over HTTP the same files are at
  `/webman/3rdparty/<Package>/`, and the package's `config` there names them —
  that is how the whole task-status table was read out of Download Station
  instead of being guessed one failing task at a time.
- **Task statuses come as numbers** in DownloadStation2, and the table is in
  `docs/synology-api.md`. Ours was shifted by one from code 7 on, so a task
  being unpacked (10) was shown as failed. Anything outside the table is an
  error — that is what Download Station's own `getStatusString` does with its
  default branch, and why a failure arrives as 101 with no string of its own.
- In `Task.edit` the parameter is called `id`, in `Task.BT.File` it is `task_id`.
- A trailing slash in the `CopyMove` destination path gives error 418.
- `SYNO.Core.System` does not return the host name — `FileStation.Info` has it.
- The level filter in the system log does not work: we filter on our side.
- `Thumb` and `Download` return bytes, and report an error by switching the
  Content-Type to JSON.
- **The session name at sign-in is not a free label.** Under a name of our
  own (`DsmMiniCheck`) a tester's ordinary account was refused with 402, while
  as `DownloadStation` it signed in; an administrator gets in under either.
  Every client signs in under `DownloadStation`. Why DSM refused is not yet
  checked — see `docs/synology-api.md`.

This file is kept up to date continuously: a new API finding, the cause of a
non-obvious failure or an architecture decision is written here in the same
session, together with the code.

## Tests

```bash
go test ./...                               # no NAS needed, must always pass
DSM_URL=… DSM_USER=… DSM_PASSWORD=… \
  DSM_INSECURE_TLS=true go test -tags=integration ./...
```

Integration tests run against a real NAS and are skipped by default. The ones
that change state need separate permission:

- `DSM_TEST_MUTATIONS=1` — creating and deleting tasks;
- `DSM_TEST_FOLDER=/Download` — the folder for file operations;
- `DSM_TEST_MAGNET`, `DSM_TEST_TORRENT` — your own sources instead of the built-in ones.

A test **must clean up** everything it created through `t.Cleanup`, even when it
fails. If the cleanup did not work — say so in the failure message and name what
has to be deleted by hand.

State-changing actions on the NAS are agreed with the user before they run —
test tasks and files included.

## Where things live

```
cmd/dsm-mini/        entry point
internal/dsm/        DSM Web API client: sessions, versions, multipart
  downloadstation/   tasks: DownloadStation2 and legacy behind one interface
  filestation/       files: browsing, upload, rename, delete
internal/httpapi/    REST for the Mini App, Telegram signature check
internal/bot/        the bot: links and .torrent files from chat, bot appearance from code
internal/watcher/    notifications about finished tasks
internal/dsm/system/     load, device details, packages, log
internal/dsm/storage/    disks, pools and volumes
internal/dsm/vmm/        virtual machines
internal/dsm/containers/ Container Manager containers
internal/store/      user settings on top of SQLite
internal/db/         schema and migrations
internal/config/     config.env: reading, defaults, what is still missing
internal/dsmui/      the settings window's side: status, settings, address
internal/dsmnotify/  DSM's own notifications through a webhook
web/                 the React Mini App, built into internal/web/dist
spk/                 the DSM package: scripts, privilege, the window (spk/ui)
contrib/spksrc/      the SynoCommunity recipe, with its copy of the window
docs/synology-api.md what has been learned about the API in practice
```

## Security

The service is exposed to the internet and can delete files on the NAS.

- **Every request to `/api/`** is checked twice: the `initData` signature with a
  key derived from the bot token, and the user id against the allow list. The
  signature only proves that a person opened the bot — and anyone can open it.
- **An empty `ALLOWED_USER_IDS` closes access to everyone**, it does not open it.
- Secrets live only in `.env` (which is in `.gitignore`), mode `0600`. Neither
  the token nor the DSM password goes into the log — on a refusal the reason is
  logged, never the value.
- The outside gets a generic phrase, the details go to the log: a message saying
  exactly what did not match in the signature is a hint on how to forge it.
- For the NAS a **separate user** is created, with access only to the packages
  needed and without two-factor, not an administrator. The price is known and
  shown rather than hidden: DSM refuses an ordinary account the NAS details
  (`SYNO.Core.System` `info`, code 1006), the load and the storage state (105)
  — from a SynoCommunity tester's log. The API answers such a part with the
  section's message plus "the DSM account the service uses has no access to
  this", and `/api/system` answers part by part: failing it whole showed "no
  connection" on a working service, zeros for a refused load would lie, and an
  unread package list marked Download Station "not installed". Only DSM's
  refusals are split up that way: a DSM that does not answer at all is still a
  502, or the home screen would say "online" next to nothing.

- **The project never holds anyone's bot token**, and that rules out one idea
  that keeps looking attractive: a "helper bot" that creates the user's bot for
  them. Telegram does support it — managed bots, `bots.createBot` over MTProto
  — but the manager bot can call `bots.exportBotToken` at any time afterwards,
  with a `revoke` flag that reissues the token and takes the bot away from its
  owner, and the documented methods offer **no way to detach a manager** once
  it is set. So the price of saving one trip to @BotFather is a service that
  permanently holds the keys to every user's bot. Considered and declined in
  September 2026; the settings window explains the BotFather step instead.

- **The service binds loopback only** (`127.0.0.1:58080`), because the DSM
  reverse proxy reaches it through localhost. `:58080` would also publish it to
  the whole local network — which is what the package actually did until
  September 2026, while the README claimed the opposite. A security claim in
  the documentation is worth checking against a live port, not against the
  intent of the person who wrote it.
- **Examples in documentation are invented, never copied from a live `.env`.**
  A real bot token once went into the README as the example token, spread into
  ten translations, nine wizard files and the compose block, and stayed there
  until the bot started getting spam. The only token allowed in the repository
  is `1234567890:AAExampleTokenReplaceThisWithYours0`; CI fails on anything
  else shaped like a token, in the "No live secrets" job. The same goes for
  real Telegram ids — `tools/shoot.js` had one.

Changes in this area are covered by tests in `internal/httpapi/auth_test.go`.

## Frontend

- State of screens that lead out into the NAS overview is **kept in `App`**:
  navigating unmounts the screen, and unsaved edits are lost. This has already
  broken the folder settings and the add screen twice. For the same reason the
  open folder of the Files tab, the selected downloads tab, the path typed by
  hand in the folder settings and the screen a person was interrupted on live
  there too: switching tabs would otherwise reset all of it.
- Adding a download, the folder settings and the task screen are **parts of the
  downloads section**, not separate places: the tab bar is not hidden on them,
  and coming back to the tab lands where the person left off. Tapping the active
  tab again goes back to the start of the section.
- Initial values that arrive as a prop (`initialPath`) are taken **once** through
  `useRef`: sending them back up from a handler makes the screen reload itself.
- A handler that writes and then navigates **waits for the write first**. The
  screens read their data when they mount, so switching first and saving after
  lands on a screen that fetched a moment too early: pinning a folder from the
  NAS browser showed "nothing pinned" until the screen was opened again, while
  the server already had it. Order: write, refresh, then `setScreen`.
- Settings are saved **immediately**, with no "Save" button: the interface
  updates before the server answers and rolls back on an error.
- Padding inside cards comes from the card's own `padding`, not from `margin` on
  its children: such a rule breaks at the very first wrapper.
- Class names are checked against the ones already taken: a card with the class
  `app` inherited the root container's styles and stretched across the screen,
  and an empty disk bay marked `empty` picked up the 44px padding of the big
  "nothing here" block and grew to twice the height of its neighbours. A
  modifier that belongs to one component is named after it — `bay free`, not
  `free`.
- Drive capacity is formatted with `diskSize` (decimal), everything else with
  `size` (binary): disks are sold in decimal, so a 1 TB drive has to read as
  "1 TB" and not as the 0.9 the binary arithmetic gives.
- The theme comes from Telegram's `themeParams`; confirmations and feedback are
  native (`showConfirm`, `HapticFeedback`), not the browser's. The theme and the
  height are re-read on the `themeChanged` and `viewportChanged` events: reading
  them once at startup is not enough.
- The app height is Telegram's `viewportStableHeight`, not `100dvh`: inside the
  client `dvh` equals the whole screen height, and in a collapsed app the tab bar
  slides below the bottom edge. `viewportHeight` will not do for this — it jumps
  around during gestures.
- Vertical swipes are disabled (`disableVerticalSwipes`, Bot API 7.7): otherwise
  a scroll gesture over a list collapses the app.
- New Telegram features are called only under `isVersionAtLeast`: the
  documentation does not promise that a call is safe on an old client. Outside
  Telegram the script stub reports version 6.0 — the checks cover that case too.
- About the safe area (Bot API 8.0) the documentation **says nothing** about
  whether `safeAreaInset` and `contentSafeAreaInset` add up or are nested. As
  long as full-screen mode is not used, it is not used either, and the insets
  come from `env(safe-area-inset-*)`. If it is ever wanted — check it on a live
  client first, rather than picking blindly.
- Until the data arrives the screen shows **placeholders**
  (`components/Skeleton`), not an empty list and not zeros in counters: "No
  machines" while machines are running is a plain lie. Placeholders are built
  from the same classes as the real cards, so the content takes their place
  without the layout jumping. The column with bars needs `sk-grow`: inside a
  `flex` with no width of its own, percentages are computed from zero and the
  bars collapse.
- Screens with lists from the NAS start from `localStorage` (`cache.ts`): on a
  second open there is a picture from the first frame, and placeholders appear
  only when there is genuinely nothing to show.
- `index.html` is served with `Cache-Control: no-cache`, files with a hash in the
  name for a year. Without this Telegram shows the old build until the app is
  restarted.

## Packaging and publishing

- There is **one way to install it: the DSM package.** Docker was dropped in
  September 2026 — a second path meant the same settings documented twice, in
  ten languages, and the two drifting apart. A new required setting is now
  added in two places that CI keeps together: `config.Required` and the window
  (`spk/ui/app.js`, its texts in `tools/make-dsmui-i18n.py`).
- The `.gitignore` entry for the built binary is written with a leading slash
  (`/dsm-mini`): without it the pattern also matches the `cmd/dsm-mini`
  directory, and the entry point quietly drops out of the repository.
- The **`.spk` package** is built with plain tar (`tools/build-spk.sh`), without
  the Synology toolkit: the binary is static, a chroot with their environment is
  not needed. The file order in the archive follows spksrc: `package.tgz`,
  `INFO`, `scripts`, then the rest.
- `package` in INFO is the **identifier**, `displayname` is what people see.
  `/var/packages/dsm-mini`, the upgrade path and every API call go by the
  identifier, so it stays `dsm-mini` whatever the visible name becomes. The
  visible name lives in four places that have to agree: `displayname` in
  INFO, `dname` in the catalogue, `DISPLAY_NAME` in the spksrc recipe and the
  name of the notification webhook in `internal/dsmnotify`, which is what DSM
  lists next to the owner's phone.
- The package ships **`scripts/preupgrade` and `scripts/postupgrade`**, and
  they may do nothing at all. Without them an upgrade from a package source
  is refused at the `prepare` stage with
  `{"code":261,"description":"package does not have preupgrade / postupgrade
  script"}` in `/var/log/synopkgmgr.log`, and the person is shown "Invalid
  file format, contact the package developer" — which says nothing about
  scripts. **Installing the same .spk works**, logging only a harmless
  `{"code":289,"description":"spk is not from synology"}`, so the gap is
  invisible until somebody presses Update. This cost a month and seven
  releases of guessing; the answer was in a log the Web API does not expose.
  The whole account, including what was wrongly blamed first, is in
  `docs/synology-api.md`.
- INFO carries **`checksum`, the md5 of `package.tgz`** because the developer
  guide documents it. It was **not** the cause of "Invalid file format",
  though this file said so for a while: 1.0.0-1 had no `checksum` at all and
  installed from a source without complaint.
- The version in INFO ends with a **build number**, pinned at `-0`. A Synology
  version is `major.minor.build-buildnumber`, and every package that installs
  from a source on a live DSM carries one; ours did while it was `1.0.0-1` and
  lost it when the suffix was dropped for looking like noise. `build-spk.sh`
  and `make-feed.py` both append it, because Package Center compares the
  catalogue's version against INFO's and the two have to be spelled the same
  way. The tag and the file name stay plain — nothing compares those.
- The `arch` values in INFO are **Synology platform names**, not processors
  (`epyc7002`, `rtd1296`). One package covers every model of the same
  architecture.
- DSM 7 runs package scripts as the package user, not as root (`conf/privilege`
  with `run-as: package`). Writing is only possible into the package's `var` —
  which is also what survives an upgrade, unlike `target`.
- **The package user is `sc-dsm-mini`, group `synocommunity`**, named in
  `conf/privilege` (`username`/`groupname`, documented since DSM 6.0-5940)
  rather than left to default to the package id. That is what the SynoCommunity
  framework calls it, and the two builds have to agree: DSM hands the data
  folder to the new package user on an upgrade — the folder, **not the files
  in it**. When our build ran as `dsm-mini`, Package Center offered it as an
  ordinary update over the SynoCommunity build, and a live NAS ended up with
  `config.env` it could not read, a stopped package and a Repair button;
  `/var/log/packages/dsm-mini.log` had the `Permission denied`. CI checks the
  name. The port is the same in both for the same reason: 58080.
- **There is no install wizard.** The package starts with no settings and is
  set up in its window in the DSM main menu (see "The settings screen inside
  DSM"). Every installation question the wizard had was also a setting the
  window had to offer, so there were two forms, two sets of texts in ten
  languages, and the wizard only ever ran once: installing the package over
  itself re-asked nothing, and changing a setting meant the window, SSH, or an
  uninstall that took the database along.
- The window asks **five** things, and every one of them is something only the
  person installing knows: the DSM user and password, the bot token, the list
  of ids and the public address. The DSM address and the service port have
  defaults and sit under "Advanced" — the service runs on the NAS, so the
  address is loopback, on whatever HTTPS port DSM describes in
  `/usr/syno/etc/www/DSM.json`. A question whose answer is the same for
  everyone is not a question.
- **The public address is optional**, the other four are required
  (`config.Required`). Without it there is no Mini App — Telegram opens one
  only over public HTTPS — but the bot works on its own: links, torrents,
  `/status`, notifications, and nothing on the NAS is published. The bot
  already left out the Mini App buttons without an address; what made the
  address required was only the list of problems. A SynoCommunity tester did
  not want to open ports at all, and the documentation now offers three ways
  to an address — DSM's reverse proxy, Cloudflare Tunnel, Tailscale — two of
  them with nothing opened on the router.
- **How the Mini App is reached is a switch in the window**: the bot only,
  DDNS, an own domain on a static address, the home network only, another way
  (a tunnel, Tailscale, a proxy elsewhere). The service still reads nothing
  but `PUBLIC_URL`; the choice lives in the database (`access` in `meta`) and
  decides what the window explains — the steps, the address a name has to
  lead to (external for DDNS and a domain, the NAS's own local one for the
  home network), the reverse proxy rule. "The bot only" clears the address on
  the server, so the choice and the address never disagree. Before anybody
  chooses, the window works it out from the address: none is the bot only, a
  DSM DDNS name is DDNS, DSM's own rule is a domain, anything else another way.
- **The home network only is the hard way, because of the certificate.**
  DSM's own Let's Encrypt needs the NAS reachable from the internet on port 80
  — its own renewal message says so — and issues wildcards for Synology DDNS
  names only. So the window says to import a certificate obtained through a
  DNS check, and the name has to lead to the NAS's local address inside the
  network (the service lists its own private addresses, leaving out docker,
  bridges and VPN links; on a live NAS the network was `ovs_eth0`).
- Each field carries three things: a short label, an example shown in the
  empty field and a line underneath saying what the setting is for. Whoever
  fills this in has never seen the project and is being asked for a password
  and a token — "DSM user" alone does not say which user, and the list of
  allowed ids does not explain itself at all.
- DSM language codes (`rus`, `ger`, `ptb`) do not match Telegram's, and are what
  `description_<lang>` in INFO uses. **Italian is `ita`** — `itn` was used here
  once, and a suffix DSM does not know is not an error: DSM quietly shows
  English, so only an Italian speaker would ever notice. The full list is in
  `docs/synology-api.md`.
- The listing text lives in **`assets/store.json`**, once: `build-spk.sh` writes
  it into INFO as `description` and `description_<lang>`, `make-feed.py` puts
  the English one into the catalogue. One paragraph, no line breaks and no
  markup — of the 30 packages on a live DSM, Synology's own included, not one
  has a newline in its description.
- Screenshots for the listing come **only from the catalogue**, field
  `snapshot`; the `.spk` has no field for them. Package Center does not fetch
  them from the browser — the NAS does, through
  `SYNO.Core.Package.Screenshot.Server`, so the address has to be reachable
  from the NAS. `tools/make-store.py` composes the slides from the English
  screenshots into `docs/store/`, and `make-feed.py` publishes them.
- The catalogue for DSM "Package Sources" is static files on GitHub Pages, one
  per Synology platform name: Package Center expects a list **filtered by
  architecture**, and static files cannot filter. It is published on a tag; the
  `github-pages` environment has to allow `v*` tags, otherwise the job fails
  before its first step.
- Every catalogue entry carries **`md5` and `size` of the .spk**. Package Center
  reads both out of the feed and passes them to the backend as `checksum` and
  `filesize` — visible in `PkgManApp.js` on the NAS, in the
  `SYNO.Core.Package.Installation` `upgrade`/`install` call. `make-feed.py`
  takes them from the file itself and refuses to build a catalogue without it,
  so an entry cannot describe a package that is not there.
- The `link` in the catalogue points at a host that **does not compress** what
  it serves. GitHub Pages does: asked with `Accept-Encoding: gzip` it returns
  the .spk as a gzip stream, and no `md5` in the catalogue can describe both
  that and the file. Release assets are served as they are, and the redirect
  they answer with is harmless. `make-feed.py --verify` downloads its own link
  and refuses to publish a catalogue that describes something else. This was
  also blamed for "Invalid file format" and was **not** the cause.
- The package is built with `COPYFILE_DISABLE=1` and `--format=ustar`. macOS
  tar writes an AppleDouble companion beside every file (`._INFO` next to
  `INFO`) and hides them again when listing the archive, so a package built on
  a Mac had twenty junk entries and started with `._package.tgz` while
  `tar tvf` showed it clean. `build-spk.sh` reads the finished archive back
  with Python and fails if the junk returns or the first entry is not
  `package.tgz`.
- The catalogue is built from the **.spk files of the release**, downloaded back
  with `gh release download`, not from a fresh build: `md5` has to describe the
  exact file people will get, and a rebuild from a branch that moved on since
  the tag is a different file.
- Every icon in the repository is rendered from **`assets/icon.svg`** by
  `tools/make-icons.sh` — the package icons, the one in the SynoCommunity
  recipe, the Mini App favicon and the one in the READMEs. The PNGs are
  committed because none of the places that need them can run a renderer, so
  the rule is: change the SVG, run the script, never touch a PNG by hand.
  The arrow in the mark is a cut-out, not a white shape on top: at 40 px, the
  size Package Center and Telegram really show, a thin outline disappears.
- The window's dictionary `spk/ui/i18n.js` is generated by
  `tools/make-dsmui-i18n.py` and committed; CI regenerates it and fails if the
  result differs. The same goes for the ten READMEs: a check compares the
  language links, because a translation nothing links to looks perfectly fine
  on its own.
- The recipe for SynoCommunity is in `contrib/spksrc/`, written **their** way
  rather than ported from ours: their generic installer, their package user
  `sc-dsm-mini`, modelled on `spk/ddns-go` and `spk/gentoo-chroot`, and built in
  their container before being committed. Its README lists where it differs
  from our package and why — no `SERVICE_PORT` for a loopback service, the kept
  log. It carries a **copy** of the window in `src/app/` (spksrc builds from its
  own tree); CI fails when the copy differs from `spk/ui/`, and only the Ext JS
  wrapper is its own, for the namespace. On a release: `PKG_VERS`, `SPK_VERS`,
  `tools/build-spksrc.sh digests`, and `SPK_REV` goes up — it never resets.
- **Their framework is a cache, not a project.** `tools/build-spksrc.sh` keeps
  a clone of SynoCommunity/spksrc in `~/Library/Caches/dsm-mini/spksrc` and
  copies the recipe into it on every build. It does not go into `~/Projects`,
  where it once sat looking like a second project with a stale copy of the
  recipe, nor into this repository's folder: 3 GB of somebody else's code,
  Go toolchain sources included, would go through `gofmt -l .`, the IDE and
  backups.
- **The two builds replace each other**: same package id, same package user,
  same port. So our own catalogue can serve betas and SynoCommunity the
  releases, and moving between them is an ordinary update that keeps the
  settings. Checked on a live NAS with 1.0.13: our build started on files the
  SynoCommunity build had created, an upgrade from our build to theirs kept
  everything, and so did their uninstall with "Uninstall only" followed by an
  install. The upgrade from theirs to ours has not been run yet. Anything that
  makes the builds differ in where or as whom they keep files breaks this —
  see the package user above.
- **The port is reserved in DSM** through a service file of our own,
  `dsm-mini.sc` (`ui/` in our package with `conf/resource` `port-config`,
  `src/` in the recipe through `FWPORTS`): DSM's port conflict check then
  names dsm-mini for 58080 — checked on a live NAS with 1.0.14, and
  `/usr/syno/bin/servicetool --conf-port-conflict-check --tcp 58080` needs no
  root when called by its full path. `port_forward="no"`, which DSM's own files use too
  (WS-Transfer in `SMBService.sc`): the service is loopback-only, so offering
  to forward it on the router would be offering nothing. Not `SERVICE_PORT`,
  which writes `"yes"`; and **never `NO_SERVICE_SHORTCUT`** — in spksrc it also
  switches off `DSM_UI_CONFIG`, and the settings window would vanish from the
  menu.
- The recipe's documentation page is `contrib/spksrc/docs/dsm-mini.md`; it goes
  to SynoCommunity as `docs/packages/dsm-mini.md`, after their template, and
  needs two lines of theirs as well: `mkdocs.yml` (under Downloads) and a row
  in `docs/packages/index.md`, which lists every documented package in
  alphabetical order. Without them the page builds and nothing links to it.
- **No brackets in the display name.** spksrc writes it into INFO unquoted
  through the shell, so brackets are a syntax error, and elsewhere quoted into
  `jq`, so escaping them leaks a backslash. The name is "DSM mini — Telegram
  Mini App" in every place it lives.

## Notifications from DSM

DSM has no API for reading its notification feed (`SYNO.Core.DSMNotify`
answers 103), so the service registers itself as a **webhook provider** and
DSM calls it on every notification, over loopback.

- **The method is `post`, lower case.** DSM's check on create accepts `POST`
  and stores it; the sender then refuses it on every send, `send_test` answers
  a bare 4682, and nothing reaches the service. The reason is only in
  `/var/log/synoscgi.log`: `Invalid HTTP method: [POST]. Only 'get' and 'post'
  accepted.` The provider looks perfect in every listing meanwhile.
- The name DSM lists it under is the field `provider`; `target_name` is
  accepted by create and dropped. It is the package's visible name.
- `Ensure` compares the method, the name, the headers and the body **exactly**
  on every start and corrects what differs — that is how a provider an older
  version registered wrongly gets fixed without anyone touching it.
- **The webhook lives and dies with the package.** It is registered on every
  start; on a real uninstall `preuninst` calls `dsm-mini unregister-webhook`,
  because nothing else removes it and DSM would go on calling a port nobody
  listens on. Only when `SYNOPKG_PKG_STATUS` is `UNINSTALL`: DSM runs the old
  version's `preuninst` during an upgrade too, and removing it there would
  only have the next start recreate it under a new number. The uninstall never
  waits on this — a failure is swallowed.
- `Ensure` also removes this service's providers on **other ports**, recognised
  by `http://127.0.0.1:<port>/dsm/notify`. The SynoCommunity build moved the
  port, and the provider on the old one stayed in DSM's list as an orphan
  until it was deleted by hand. Anybody else's webhook is never touched.
- **The binary refuses unknown arguments** and starts the service only with
  none. Older builds ignored them, so a script asking one for a one-off job
  would have started the whole service instead — inside an uninstall, an
  uninstall that never finishes.
- A forwarded notification is logged, and so is one that was not forwarded
  because of the mode. Without both, "DSM called and nothing arrived" cannot
  be answered from the log — and for an hour it could not be.
- **`sepchar` is a space.** It is what DSM puts in place of every space of the
  message — an SMS gateway leftover, for text in a query string. Every preset
  in DSM's own interface sets `" "`, JSON bodies included, and the field is only
  shown for GET; ours was created empty, and "Test Message from NAS" reached
  Telegram as "TestMessagefromNAS". Found by reading the provider back through
  the API and `NotificationVueBundle.js` on the NAS, after the log said
  "forwarded" and the person said "nothing arrived" — it had arrived, without
  spaces.

## The settings screen inside DSM

The package registers a window in the DSM main menu: `dsmuidir="ui"` and
`dsmappname` in INFO, `spk/ui/config` describing the app, and a thin Ext JS
wrapper (`dsm-wrapper.js`) whose only job is an iframe. Everything a person
sees is a plain page — no framework, no build step, because it is served
straight out of the package by DSM's own web server.

It is also **where the package is set up**: there is no install wizard. The
service starts with no settings at all, serves this window and nothing else,
and starts the bot and the Mini App once the window has saved enough to start
on and **Start** has been pressed.

- **Start and Stop are the window's.** The process always runs — it is what
  serves the window — and the bot and the Mini App run only while the window
  says so. The choice is kept in the database (`app_enabled` in `meta`) and
  survives a restart of the NAS; the status reads "stopped" whenever they are
  off, for whatever reason. Start is refused while the settings are not
  enough to start on, so that switched on never means switched on and doing
  nothing. The first start of a version with the button settles it once
  (`switchedOn` in `cmd/dsm-mini`): an installation that has its settings was
  running before the upgrade and goes on running, a fresh one waits for Start.
  Package Center's own Stop still stops everything, the window included.
- **After a fresh install Package Center says where to go next.** `postinst`
  writes one line into `$SYNOPKG_TEMP_LOGFILE` on `INSTALL`, and Package
  Center shows what is in that file in a message box once the installation is
  done (`_showDoneMessage` in `PkgManApp.js` on the NAS). The language is
  `SYNOPKG_DSM_LANGUAGE`, a DSM code; the lines are in `ui/installed.txt`,
  generated with the window's dictionary. In spksrc the script's own output
  goes to that file (`INSTALLER_OUTPUT`), but `service_postinst` runs through
  `call_func … install_log`, which sends everything it prints to the package
  log — so the recipe writes the file itself, as ours does; an `echo` would
  never reach the box. Nothing on an upgrade, and nothing on an install that
  finds `config.env` left by an earlier one ("Uninstall only" keeps it): that
  package is set up already, and "not set up yet" would be untrue.
- **"Check the account"** signs in as the account typed into the window — with
  the saved password when the field is empty and the account is the saved one
  — and goes through what the service does with it, read-only: the sign-in,
  Download Station, File Station and the shared folders it sees (without them
  the bot cannot work), then every section of the Mini App, each with DSM's
  code when refused. It signs in **exactly as the service does**, session name
  included (`DownloadStation`). It used to take a name of its own
  (`DsmMiniCheck`), to keep its sign-out apart from the service's session, and
  a SynoCommunity tester's ordinary account — set up as the documentation says,
  and signed in by the service — was refused right at the check's sign-in with
  402; an administrator gets in under either name, so it never showed here.
  The sign-out closes the check's own session by its SID, as DSM's guide
  describes, and should DSM end the service's session anyway, the service
  signs in again by itself (106, 107, 119). The log gets the account and the
  outcome, never the password.

- **A save applies without restarting the package.** The service reads
  `config.env` itself (not through the start script's environment, which would
  keep the old values) and runs as a loop in one process: a save cancels the
  current run with `errRestart`, the next one starts with the new file. The
  window waits for the status's `since` to change — until it does, the answer
  comes from the run that is about to stop. The notification mode lives in the
  database and needs no restart at all.
- **Nothing that is wrong in the settings stops the process.** Missing values,
  a refused password, a refused token, a Download Station that is not running:
  each is a state the window shows at the top, in the person's language, with
  DSM's own code where there is one. A package that exits on a bad setting is
  shown by Package Center as stopped, with a Repair button that repairs
  nothing — that is what people used to get. Only a port somebody else holds
  still stops it, because then even the window cannot be served.
- A refused DSM sign-in is **not retried** by itself: repeating a wrong password
  only adds failed sign-ins to DSM's log and to what its auto block counts.
  DSM not answering at all is retried every half a minute — during boot it
  comes up after the packages.
- **The window talks to DSM as the administrator who opened it**, through the
  browser's own session (cookie and SynoToken), never as the service's account.
  That is what lets it read the reverse proxy rules and create one before the
  service has an account at all — and the service's account is not an
  administrator anyway. DSM accepts that session over loopback although it was
  made on another machine, with its IP checking on (`skip_ip_checking: false`)
  — checked on DSM 7.4.
- **Files left by another package user** — the result of switching between
  builds that ran as different users — are explained in the window, with the
  one `chown` that hands them back. Saving new settings replaces `config.env`
  anyway (a rename in a folder the service owns needs no permission on the old
  file). A database that cannot be read is set aside as `*.unreadable` once
  the service has settings to run on, and a log that cannot be appended to is
  set aside by the start script, which would otherwise fail to start anything.
- **DSM sends the window's files with no `Cache-Control`**, only `ETag` and
  `Last-Modified`, so a browser may keep the previous version's page for hours
  after an upgrade. The wrapper opens `index.html?v=<now>` and the page loads
  its scripts with the same value.

- **A browser session needs `X-SYNO-TOKEN`; an API session does not.** DSM has
  CSRF protection on by default (`SYNO.Core.Security.DSM`,
  `enable_csrf_protection`), and with it the session cookie alone is refused —
  with a plain `success: false`, not an HTTP error. A session made through
  `SYNO.API.Auth` is not refused, so the check passed every test and failed for
  the first person who opened the screen in DSM. The page reads the token from
  `SYNO.SDS.Session.SynoToken` on the desktop around it — same origin — rather
  than taking it through a URL, where it would land in logs and referrers.
- **`/webman/3rdparty/` is served to anyone.** A request with no session gets
  200, checked against a live NAS. So the path guards nothing and `api.cgi`
  authorises nothing: it only carries the browser's `Cookie` header to the
  service, which asks DSM whose session it is. The call is
  `SYNO.Core.Desktop.Initdata`, whose `Session` block has `user` and
  `is_admin`; there is no documented "who am I", and `SYNO.Core.CurrentUser`
  answers 102. Anything less than an administrator is refused — the screen
  edits the bot token and the NAS password.
- **The CGI cannot read `config.env`**: the file is 0600 and owned by the
  package user, and DSM's web server runs as another. It still needs the
  service's port, so the service writes that one value into `backend.conf` in
  `UI_DIR` every time it starts listening — the window can move the port
  without a package restart. The file is world-readable and holds nothing else.
- **The window reaches DSM on this machine by the address DSM describes
  itself** (`/usr/syno/etc/www/DSM.json`), never by `DSM_URL` from the file:
  it checks who is asking by calling DSM, so a wrong `DSM_URL` would lock
  everybody out of the one place that could fix it.
- **Addresses in the window can only be this NAS.** `DSM_URL` and
  `LISTEN_ADDR` are refused unless they are loopback: a tester put the NAS's
  internet address into `DSM_URL` — a perfectly good URL — and the service
  stopped reaching DSM; `0.0.0.0` in `LISTEN_ADDR` would publish the app to the
  whole network. Only a value being changed is held to it: the window sends
  every field back on a save, and a LAN address or the bare `:58080` an older
  installation left — which work — must not block saving a new token. The
  file itself is not held to this, for development.
- **Empty means the default.** A cleared field with a default drops the key
  from `config.env`, and an empty value found in the file reads as the
  default too — it used to be written as `DSM_URL=''` and read as an address
  of nothing. `ALLOWED_USER_IDS` is the exception: empty there is "nobody".
- **Secrets are write-only.** The password and the token are never sent to the
  browser; the screen shows "set" and an empty field. An empty field means
  "leave it alone" — treating it as "erase" would lock the service out of the
  NAS on the first save of an unrelated setting.
- `config.env` is rewritten through a temporary file, with values in single
  quotes and the shell's escaping: a half-written file is a service that starts
  with half its settings, and whoever reads the file with `.` by hand should
  not be tripped by a password with a space.
- The dictionary is generated by `tools/make-dsmui-i18n.py` and committed; CI
  fails if the two drift apart, and the generator refuses a translation that
  lost a `{name}` placeholder.

## Languages

The interface and the bot are translated into ten languages; the language comes
from the Telegram user's `language_code`, an unknown one gets English.

- Dictionaries: `web/src/i18n/<code>.ts` (interface) and `internal/i18n/<code>.go`
  (bot, notifications, API errors). English is the source of truth in both.
- **A new string is added to every dictionary at once.** On the frontend the type
  enforces it (`Record<Key, Phrase>`), on the server the `internal/i18n` test
  does: it compares keys, `{name}` substitutions and the absence of a foreign
  script — a scrap of Russian text inside a Portuguese string would otherwise
  only be noticed by a user.
- Plurals on the frontend go through `Intl.PluralRules` (the `one`, `few`, `many`
  forms); Go has nothing like it, so the messages are written with the number
  after a colon ("Active tasks: 5") and need no agreement.
- Numbers and dates are formatted through `Intl` with a locale tag, not by hand:
  the decimal separator and the date order differ everywhere.
- API error messages travel as a **key**, not as text: `s.fail(w, r, err,
  "api.readFolder")`. The person gets a translation, the log stays English, and
  the DSM code goes in a separate field — digits read the same in any language.
- The bot's appearance (descriptions, commands, menu button) is set for every
  language and **only when it changes**: across ten languages that is a couple
  of dozen requests otherwise.
- **The name is not set from code.** It belongs to whoever created the bot, and
  writing it on every start silently undid a rename made in @BotFather.
- **The avatar is set only when there is none.** `setMyProfilePhoto` arrived in
  Bot API 9.4 (February 2026) — before that it really was a trip to @BotFather,
  and a comment in this repository said so for a while after it stopped being
  true. The current picture is read with `getUserProfilePhotos` on the bot's
  own id: the documentation describes that method for users, but it answers for
  a bot too — checked against the live API, both branches. An empty profile
  gets `internal/bot/avatar.png`, a picture the owner chose is left alone.

## Build and run

```bash
cd web && npm install && npm run build   # Mini App → internal/web/dist
cd .. && go build ./cmd/dsm-mini         # a single binary with the frontend embedded
```

The SQLite driver is `modernc.org/sqlite`, pure Go. Swapping it for
`mattn/go-sqlite3` is not allowed: that one needs CGO, and with CGO the static
binary the package ships is gone — and with it the reason `build-spk.sh` needs
no Synology toolchain.

## Language of the project

**There is no Russian in the code.** Comments, names, log messages and error
text are in English: the project is open, and not only Russian speakers will
read it.

Russian lives only in **translations**: `internal/i18n/ru.go`,
`web/src/i18n/ru.ts`, the settings window's table in
`tools/make-dsmui-i18n.py` (and the `spk/ui/i18n.js` and `installed.txt`
built from it), the listing text in `assets/store.json`, plus
`DESCRIPTION_RUS` in the SynoCommunity recipe. The other nine languages live
in the same places.

Documentation follows the same rule: `README.md` and this file are English,
translations of the README are separate files (`README_RU.md` and the rest), and
the language links at the top of every one of them are kept in step.

Commit messages stay in Russian: the history has been written that way from the
first commit, and mixing the two would make it unreadable either way.

**No `Co-Authored-By` trailer.** Commits carry the author and nothing else. The
first ninety-one keep theirs — rewriting them would change every hash, move
eleven release tags and break the commit links in the merged pull request, for
a line in a message. New commits simply do not have it.

Text for people is written for people, not for developers: "the folder does not
exist" instead of "code 403" — in any language.