omnigateway 0.4.12 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +138 -239
  2. package/bin/omni.js +1111 -516
  3. package/gateway.js +2009 -474
  4. package/package.json +1 -1
  5. package/public/assets/{CopyValue-By5BAgv7.js → CopyValue-dQWAaTxf.js} +3 -3
  6. package/public/assets/{Field-B79Yxdit.js → Field-DTQ2LPzA.js} +1 -1
  7. package/public/assets/{Match-BWa6L0L1.js → Match-C85GObiU.js} +1 -1
  8. package/public/assets/{Panel-CoulMx6R.js → Panel-CQVz9yeR.js} +2 -2
  9. package/public/assets/Rack-V32CHmN6.js +316 -0
  10. package/public/assets/{Readout-D1XEfywe.js → Readout-DJR1qKja.js} +1 -1
  11. package/public/assets/States-CauIsA0N.js +54 -0
  12. package/public/assets/{Toggle-ClEeOh0F.js → Toggle-BQI8jz09.js} +1 -1
  13. package/public/assets/{TokenBreakdown-D70VeZ2N.js → TokenBreakdown-B2_gfuqt.js} +1 -1
  14. package/public/assets/_app-DzzXdoyU.js +1 -0
  15. package/public/assets/_app.accounts-owsnxlbn.js +64 -0
  16. package/public/assets/_app.console-bzKNP_8Y.js +44 -0
  17. package/public/assets/_app.database-CsJbkedE.js +24 -0
  18. package/public/assets/_app.index-D4ydl2mY.js +62 -0
  19. package/public/assets/_app.keys-DKX0smty.js +82 -0
  20. package/public/assets/_app.logs-CFFsj2zR.js +68 -0
  21. package/public/assets/_app.models-B0ZxWGHV.js +148 -0
  22. package/public/assets/{_app.plugins._pluginId-B-ZDQNPU.js → _app.plugins._pluginId-C5X4B1cE.js} +3 -3
  23. package/public/assets/{_app.settings-DTnFwTeA.js → _app.settings-Yg_6az-L.js} +4 -4
  24. package/public/assets/_app.usage-BRIz_GCn.js +83 -0
  25. package/public/assets/index-DiCU8bLB.js +178 -0
  26. package/public/assets/{login-DjfMomJJ.js → login-B4LoiALI.js} +3 -3
  27. package/public/assets/plus-xu9reaJp.js +1 -0
  28. package/public/assets/queries-DuSqKybM.js +145 -0
  29. package/public/assets/{shared-Cct_4xwp.js → shared-DYkFSPbd.js} +4 -4
  30. package/public/assets/stream-Dz5HjHBh.js +1 -0
  31. package/public/assets/{trash-2-U2ViYZum.js → trash-2-DbutVvFY.js} +1 -1
  32. package/public/index.html +3 -3
  33. package/public/shared/dashboard-sdk.js +1 -1
  34. package/public/assets/Rack-fDZClWZL.js +0 -314
  35. package/public/assets/States-DtkkMzEu.js +0 -54
  36. package/public/assets/_app-CDPKnQ72.js +0 -1
  37. package/public/assets/_app.accounts-Dz0ox4Br.js +0 -64
  38. package/public/assets/_app.console-hPUmmYjp.js +0 -44
  39. package/public/assets/_app.database-DGtP4NdG.js +0 -24
  40. package/public/assets/_app.index-COcCAmbH.js +0 -62
  41. package/public/assets/_app.keys-yTXTbKTA.js +0 -76
  42. package/public/assets/_app.logs-BhMvO_Fx.js +0 -68
  43. package/public/assets/_app.models-0S3rurIl.js +0 -148
  44. package/public/assets/_app.usage-C2-VLiq9.js +0 -83
  45. package/public/assets/index-COwhxKU7.js +0 -178
  46. package/public/assets/plus-UDQ_BTEr.js +0 -1
  47. package/public/assets/queries-CX9mStps.js +0 -145
package/README.md CHANGED
@@ -81,7 +81,7 @@ graph TD
81
81
  dashboard -. "types only" .-> store
82
82
  control --> router
83
83
  control --> ratelimit
84
- router --> store
84
+ router -. "types + 2 pure helpers,<br/>via /types subpath" .-> store
85
85
  router --> providers
86
86
  store --> rtk
87
87
  store --> ratelimit
@@ -107,6 +107,12 @@ The dashboard's edges are dotted because they are type-level only.
107
107
  `@omni/providers/catalog` is deliberately kept import-free so model lists can be
108
108
  bundled into the browser without dragging in the HTTP client.
109
109
 
110
+ The router's dotted edge into `@omni/store` is nearly the same story: its
111
+ package-root imports are all `import type`. Two pure arithmetic helpers,
112
+ `durationFor` and `cacheReadRate`, do run at runtime — imported through the
113
+ `@omni/store/types` leaf subpath, so neither SQLite nor crypto enters the
114
+ router's module graph and the no-I/O rule holds.
115
+
110
116
  The gateway and the CLI are two front ends over the same `@omni/control`
111
117
  functions. The CLI does not call the running server's API — it opens the same
112
118
  SQLite file directly, which is why it still works when the gateway is down.
@@ -192,22 +198,12 @@ omni models catalog # what is available
192
198
  omni models put fast --from-catalog anthropic:claude-sonnet-5
193
199
  ```
194
200
 
195
- **A note on grok pricing.** xAI charges by request size: at or above 200K
196
- context the rate roughly doubles, and the higher rate applies to *every token
197
- in the request*, not just the tokens past the mark. A target holds one flat
198
- price, so the catalog carries xAI's sub-200K figures and long-context traffic
199
- is reported cheaper than it was billed. Catalog pricing is only the default a
200
- new target starts from — if you run grok at long context, edit the saved
201
- target's price to match the tier you are actually paying.
202
-
203
- **A note on `kilo-auto/*` pricing.** Kilo's `frontier`, `balanced`, and
204
- `efficient` routers choose an upstream model per request, and Kilo states no
205
- rate for them. The catalog records zero, which the router reads as *unpriced*
206
- and leaves out of its cost ranking — the same stored figure `kilo-auto/free`
207
- carries because it genuinely is free. So a `kilo-auto` target seeded from the
208
- catalog is not free, it is unranked: cost never counts for or against it. If you
209
- want one ranked against your other accounts, set a real `costPerMTok` on the
210
- saved target for the tier you expect it to land in.
201
+ Two catalog pricing caveats: xAI doubles its rate at or above 200K context —
202
+ the higher rate applies to every token, but a target holds one flat price, so
203
+ edit the saved target if you run grok long-context; and Kilo's `kilo-auto/*`
204
+ routers carry no published rate, so the router treats them as unpriced rather
205
+ than free set a real `costPerMTok` on the saved target to have one ranked.
206
+ Details in [docs/adding-a-provider.md](docs/adding-a-provider.md).
211
207
 
212
208
  Mint a key for your client. **It is printed once and stored only as a hash:**
213
209
 
@@ -308,23 +304,13 @@ anthropic-ratelimit-requests-remaining: 1841 x-ratelimit-remaining-requests:
308
304
  anthropic-ratelimit-requests-reset: 2026-08-19T14:32:07Z x-ratelimit-reset-requests: 4h51m22s
309
305
  ```
310
306
 
311
- `requests-remaining` counts the request you are being answered, as both vendors
312
- define it. `tokens-remaining` does not, and cannot: the response is still being
313
- written when the header goes out, so its token cost is not yet known and
314
- subtracting anything would be an invented number rather than a measured one.
315
-
307
+ `requests-remaining` counts the request being answered; `tokens-remaining` does
308
+ not and cannot the response is still being written when the header goes out.
316
309
  Where a key has several windows on one dimension, the headers report the one
317
- **nearest exhaustion** a key comfortable per-minute but one request from its
318
- weekly ceiling shows you the weekly figures, not the reassuring ones.
319
-
320
- `spend` and `concurrency` are rendered on neither dialect, because no vendor
321
- defines a header for them and a number no client parses is noise in every
322
- response.
323
-
324
- A refusal is `429` with `Retry-After` in seconds, alongside the usual error body.
325
- The wait is computed from the oldest request still inside the window that
326
- refused you, so a weekly ceiling tells you when a slot actually frees rather than
327
- parking you for seven days.
310
+ **nearest exhaustion**, not the reassuring ones. `spend` and `concurrency`
311
+ render on neither dialect, because no vendor defines a header for them. A
312
+ refusal is `429` with `Retry-After` in seconds, computed from the oldest request
313
+ still inside the window that refused you.
328
314
 
329
315
  ### Tools and routing
330
316
 
@@ -407,29 +393,61 @@ start` supervises the process itself with a pidfile under
407
393
 
408
394
  ### Restarting and stopping from the console
409
395
 
410
- The console can restart and shut down the gateway, from the foot of its sidebar
411
- reachable from every screen, because a gateway you need to restart is rarely
412
- one you were looking at the Database screen of. A restart only restarts if
413
- something would start the process again, so what the control does — and whether
414
- it is offered at all depends on how this installation runs:
415
-
416
- - **Under systemd**, it works. The gateway asks the manager rather than
417
- signalling itself — `systemctl [--user] --no-block restart
418
- omnigateway.service` — because the unit `omni service install` writes sets
419
- `Restart=on-failure`, and a handled `SIGTERM` exits cleanly, which systemd
420
- reads as success. A gateway that killed itself would stop and stay stopped.
421
- - **In a container**, it depends on the container's restart policy, and that
422
- policy cannot be read from inside the container. The console says so instead
423
- of promising a restart it has no way to verify. Run with
424
- `--restart unless-stopped` if you want the control to mean what it says.
425
- - **With no supervisor** `omni start` with no unit installed — the control is
426
- disabled, and the section names the supervisor it found. Nothing is watching
427
- the process, so an exit is simply the end of it. Use `omni restart` from a
428
- terminal on the machine.
429
-
430
- Shutdown is offered in every shape, because stopping is the point of it. In a
431
- container it is a one-way door: stopping the only process takes the console that
432
- would have restarted it, so bringing the gateway back needs access to the host.
396
+ The console can restart and shut down the gateway from the foot of its sidebar.
397
+ Restart works under systemd the gateway asks the manager rather than
398
+ signalling itself, because a handled SIGTERM exits cleanly, which systemd's
399
+ `Restart=on-failure` reads as success reports uncertainty in a container,
400
+ whose restart policy cannot be read from inside, and disables itself with no
401
+ supervisor; use `omni restart` from a terminal there. Shutdown is offered in
402
+ every shape. In a container it is a one-way door: bring the process back from
403
+ the host.
404
+
405
+ ### Behind a reverse proxy
406
+
407
+ Set `OMNI_BASE_URL` to the public HTTPS origin, or OAuth callbacks come back to
408
+ the wrong host.
409
+
410
+ Beyond that, two things travel badly through a proxy, and both are streams.
411
+ Client responses on `/v1/*` are server-sent events, and the console keeps one
412
+ WebSocket open on `/api/stream`. Neither is optional: buffer the first and every
413
+ token of an agent's reply arrives at once at the end, and drop the second and
414
+ the console silently falls back to polling.
415
+
416
+ Caddy and Cloudflare pass WebSockets and unbuffered responses by default and
417
+ need nothing. nginx needs telling:
418
+
419
+ ```nginx
420
+ location / {
421
+ proxy_pass http://127.0.0.1:9000;
422
+ proxy_http_version 1.1;
423
+
424
+ # Without these two the Upgrade handshake never reaches the gateway and the
425
+ # console shows LIVE·POLL instead of LIVE·PUSH. It keeps working — the
426
+ # fallback exists for exactly this — but you paid for a socket you are not
427
+ # getting.
428
+ proxy_set_header Upgrade $http_upgrade;
429
+ proxy_set_header Connection "upgrade";
430
+
431
+ proxy_set_header Host $host;
432
+ proxy_set_header X-Forwarded-Proto $scheme;
433
+
434
+ # The socket's heartbeat is 20s and it gives up on a missed pong at 60s.
435
+ # A read timeout below that closes a healthy connection from the outside,
436
+ # and the console reconnects in a loop that looks like an unstable gateway.
437
+ proxy_read_timeout 300s;
438
+
439
+ # SSE must not be buffered. The gateway already sends
440
+ # `x-accel-buffering: no` on streaming responses, which nginx honours on its
441
+ # own, so this line is belt and braces for a proxy chain where something
442
+ # else strips that header before nginx sees it.
443
+ proxy_buffering off;
444
+ }
445
+ ```
446
+
447
+ The gateway sends downstream `: keepalive` comments on streaming responses
448
+ because provider heartbeats are decoded away, so an idle stream still looks
449
+ alive to whatever sits in between. Keep any idle timeout in the proxy above
450
+ your longest expected request.
433
451
 
434
452
  ## Configuration
435
453
 
@@ -446,7 +464,6 @@ Configuration is environment variables, read from the installation's `.env`:
446
464
  | `OMNI_LOG_LEVEL` | No | `info` | Stdout threshold: `debug`, `info`, `warn`, or `error` |
447
465
  | `OMNI_LOG_FILE` | No | the systemd journal, when there is one | Where stdout was already redirected, so the Console screen can read it back. Names a file; does not create one |
448
466
  | `OMNI_BODY_LOGGING_ALLOWED` | No | unset | Permits request/response body capture on this installation. Read at boot. Capture also needs the runtime setting; see [Recording bodies](#recording-bodies) |
449
- | `OMNI_EXPOSE_CLAUDE_CODE_ALIASES` | No | off | Advertises the reserved `claude/*` aliases on `/v1/models`. Read at boot |
450
467
  | `OMNI_ROOT` | No | the installation in the current directory, else `~/.config/omnigateway` | Which installation the CLI acts on, when `--root` is not passed |
451
468
  | `OMNI_PLUGIN_REGISTRY` | No | the public npm registry | Registry `omni plugin install <name>` resolves through; must be `https://` |
452
469
 
@@ -488,39 +505,24 @@ deliberately edited on the Database screen instead; see
488
505
 
489
506
  ## Recording bodies
490
507
 
491
- By default the gateway records no prompts and no responses. If you need them for
492
- an incident to see what a client actually sent, or what a provider actually
493
- returned capture is opt-in and takes **two independent keys, both required**:
494
-
495
- 1. `OMNI_BODY_LOGGING_ALLOWED=1` in the installation's `.env`, read at boot.
496
- 2. The **Capture request and response bodies** setting, in the console's
497
- Settings screen or `omni settings set bodyLoggingEnabled true`. Off by
498
- default. Raw SSE frames are the separate, far larger
499
- `bodyLoggingCaptureStreamChunks`.
500
-
501
- Two keys, because an admin session on its own must not be able to start
502
- recording your users' prompts. With the environment variable unset the setting
503
- does nothing at all, and the console says so rather than letting you flip a
504
- switch that silently no-ops. With the environment variable set you can turn
505
- capture on and off mid-incident without restarting.
506
-
507
- Turning capture off stops new capture. It does not delete what was already
508
- written.
509
-
510
- **Per-key opt-out.** A gateway key can be created with *Never record this key's
511
- bodies* — `omni keys create --no-bodies` — and it is then never captured whatever
512
- the setting says. Use it for a client whose payloads must not be retained. The
513
- choice is made when the key is issued and cannot be reversed afterwards; reissue
514
- the key instead. `omni keys list` and the console's Keys screen both show which
515
- keys are exempt, so an audit does not have to go through the database.
516
-
517
- **What is captured.** What arrived at `/v1/*` and what was returned, plus the
518
- request and response of every provider attempt, in dispatch order. The client
519
- request is the conversation *before* RTK compression and each attempt request is
520
- the one *after* it, so an artifact is the only place you can read what a filter
521
- actually removed. The console labels which side is which, and so does the CLI.
522
-
523
- **Reading them.** Expand a row on the console's Logs screen, or from a terminal:
508
+ By default the gateway records no prompts and no responses. For incident
509
+ forensics, capture is opt-in and needs **two independent keys, both required**:
510
+ `OMNI_BODY_LOGGING_ALLOWED=1` read at boot, plus the **Capture request and
511
+ response bodies** setting (console Settings, or `omni settings set
512
+ bodyLoggingEnabled true`). An admin session alone cannot start recording your
513
+ users' prompts; with the variable unset the console says the switch does
514
+ nothing rather than letting you flip it. Capture can be toggled mid-incident;
515
+ turning it off stops new capture and does not delete what was written.
516
+
517
+ A gateway key created with `--no-bodies` is never captured whatever the setting
518
+ says made at issue time, not reversible afterwards; reissue instead. Raw SSE
519
+ frames are captured separately, under `bodyLoggingCaptureStreamChunks`, and are
520
+ far the larger store.
521
+
522
+ What is captured: what arrived at `/v1/*` and what was returned, plus every
523
+ provider attempt in dispatch order — the client side pre-RTK, attempts post-RTK,
524
+ labelled as such in console and CLI. Headers are never captured, at any layer.
525
+ Read them from the console's Logs screen or:
524
526
 
525
527
  ```bash
526
528
  omni bodies req_550e8400-… # the frame: state, size, one line per attempt
@@ -528,79 +530,24 @@ omni bodies req_550e8400-… --full # the payloads themselves
528
530
  omni bodies req_550e8400-… --json # the artifact, for a script
529
531
  ```
530
532
 
531
- **The bare command withholds the bodies and prints only the frame.** Every other
532
- CLI read prints everything it has; this one prints conversations, and whoever
533
- runs it during an incident is usually sharing that screen. Asking costs one
534
- flag; printing by default costs a prompt corpus in someone's scrollback,
535
- silently. The frame still gives you the state, capture time, size on disk, any
536
- truncation, and each attempt's provider and byte counts — labelled `pre-RTK` for
537
- the client request and `post-RTK` for attempts, because they are not the same
538
- payload.
539
-
540
- A request with no artifact is an answer rather than an error, and the three
541
- answers are different: `not captured` means capture was not running, `captured,
542
- then lost` means retention or the row cap has been through, and `captured, but
543
- unreadable` means the file is there but will not decrypt usually a changed
544
- `OMNI_ENCRYPTION_KEY`.
545
-
546
- There is no CLI command to delete a captured body. Retention, the row cap, and
547
- the orphan sweep are the gateway's; a second path that erases forensic evidence
548
- on request is a way to lose an incident record.
549
-
550
- **What is never captured.** Headers, at any layer — every provider authenticates
551
- through headers, so that is where the tokens are. That one is a guarantee: the
552
- capture layer is never handed a header list, so no provider, present or future,
553
- can opt its own in.
554
-
555
- **Masking is best-effort, and a body corpus is sensitive even after it.** Bodies
556
- are masked before they are written, replacing bearer tokens, `sk-`/`ak-`/`pk-`
557
- prefixed keys, the well-known vendor prefixes (`ghp_` and the rest of GitHub's,
558
- `github_pat_`, `AIza`, `GOCSPX-`, `xai-`), and any long opaque token with elided
559
- forms. Two things follow, and both matter before you turn capture on:
560
-
561
- - It costs fidelity. The length rule has no idea what it is looking at, so it
562
- also elides base64 image data, content hashes, and minified source. That is
563
- deliberate: a corpus that leaks a live credential is the worse failure.
564
- - It does not catch everything. The length rule is tuned to base64url, so a
565
- standard-base64 secret or an AWS secret access key can slip through it on a
566
- `+` or a `/`, and a credential shorter than forty-one characters or exactly
567
- forty characters long — an Azure OpenAI key, for instance — is out of its reach
568
- entirely. The prefix rules exist precisely because the length rule cannot be
569
- the whole answer, and between them they are a reduction in exposure, not a
570
- guarantee of none.
571
-
572
- Treat the artifact tree as you would treat the prompts themselves: it is
573
- encrypted at rest, it belongs on a volume you control, and it is not something to
574
- copy into a ticket.
575
-
576
- Nothing changes about stdout. Prompts and responses never reach the log, the
577
- journal, or the Console screen; capture is a separate encrypted store.
578
-
579
- **Where it goes.** `request_bodies/YYYY/MM/DD/<requestId>.json.enc` beside the
580
- database file, encrypted with AES-256-GCM under `OMNI_ENCRYPTION_KEY`, the same
581
- key as your provider credentials. Copies taken without the key yield nothing.
582
- Changing the key invalidates every artifact already written.
583
-
584
- **Bounds.** Two limits, because either alone fails:
585
-
586
- - Bodies expire on the same **log retention** window as request rows, swept
587
- hourly, file and row deleted together.
588
- - A hard cap of **100,000 body rows**, oldest pruned first. The window is what
589
- you reason about; the row cap is what actually bounds disk, because a week's
590
- window over sustained traffic bounds nothing.
591
-
592
- Individual payloads are bounded structurally rather than by byte offset — strings
593
- past 64 KB, arrays to their last 24 items, nesting past 6 levels, objects to 80
594
- keys — so a stored artifact is always valid JSON. An artifact still over 512 KB
595
- after that has its bodies replaced by a marker recording why.
596
-
597
- **Sizing.** 512 KB is a *plaintext* cap and encryption emits hex, so one artifact
598
- can reach ~1 MB on disk: with the 100,000-row cap the corpus worst case is about
599
- **100 GB**, not 50. Most artifacts are a few kilobytes, but that is the number to
600
- size a volume against. The same cap applies per body held in memory while a
601
- request is in flight, one per side per attempt — so ~512 KB × (attempts + 1) per
602
- captured request. Multiply by your concurrency before enabling this on a small
603
- box.
533
+ The bare command prints only the frame, never conversations asking costs one
534
+ flag. A missing artifact answers rather than errors: `not captured`, `captured,
535
+ then lost` (retention or the row cap), or `captured, but unreadable` (usually a
536
+ changed `OMNI_ENCRYPTION_KEY`). There is no command to delete a captured body;
537
+ a second path that erases forensic evidence on request loses incident records.
538
+
539
+ Artifacts live at `request_bodies/YYYY/MM/DD/<requestId>.json.enc` beside the
540
+ database, AES-256-GCM under `OMNI_ENCRYPTION_KEY`; changing that key invalidates
541
+ every artifact. Bounds: log-retention expiry plus a hard **100,000-row cap**, so
542
+ capture is forensics, not an archive size a volume against roughly 100 GB
543
+ worst case, though most artifacts are kilobytes.
544
+
545
+ Masking is best-effort bearer tokens, vendor-prefixed keys, long opaque
546
+ tokens are elided before write — a reduction in exposure, not a guarantee, and
547
+ it costs fidelity. Treat the tree as you would the prompts themselves: encrypted
548
+ at rest, on a volume you control, never pasted into a ticket.
549
+ [ARCHITECTURE.md](ARCHITECTURE.md#body-capture-forensics) documents the storage
550
+ format, structural bounds, and masking rules.
604
551
 
605
552
  ## Snapshots and restore
606
553
 
@@ -621,12 +568,10 @@ captured-body tree out of step with the table: files the restored database has n
621
568
  row for are collected by the hourly sweep, and a row whose file is gone reads back
622
569
  as `captured, then lost`.
623
570
 
624
- **A snapshot does carry secrets.** Your provider credentials are in it, encrypted,
625
- along with your gateway key hashes. They are inert only because
626
- `OMNI_ENCRYPTION_KEY` is not in the file it lives in the installation's `.env`.
627
- So treat a downloaded snapshot exactly as you treat the database itself: anyone
628
- holding both the file and the key holds your provider accounts. Downloads are
629
- never cached, and each one is recorded in the gateway's log.
571
+ **A snapshot does carry secrets** encrypted provider credentials and gateway
572
+ key hashes inert only because `OMNI_ENCRYPTION_KEY` is not in the file.
573
+ Anyone holding both the file and the key holds your provider accounts; treat a
574
+ downloaded snapshot as the database itself.
630
575
 
631
576
  **Retention** bounds the directory: at most `keepLatest` snapshots are kept, and
632
577
  nothing older than `maxAgeDays` — 5 and 30 by default. Both bounds have to pass,
@@ -642,23 +587,14 @@ It is the undo.
642
587
 
643
588
  **Restoring from the console** happens inside the running gateway. Client traffic
644
589
  on `/v1/*` is refused with a retryable 503 while the file is replaced; `/api/*`
645
- and `/health` keep answering, so you can watch the restore and hear how it ended.
646
- Restoring a database that carries a different console password signs every session
647
- out; restoring this installation's own snapshot does not. The screen also uploads
648
- a database file from elsewhere, up to 2 GiB, which is how you move an installation
649
- to another machine bring `OMNI_ENCRYPTION_KEY` with it, or the credentials in it
650
- are unreadable. Either way the file is integrity-checked before anything is
651
- touched, and a copy of what was there is taken first.
652
-
653
- **A restore ends by rebuilding the hourly usage rollup, and that step blocks.**
654
- The rollup is what rate limits count their 5h and 1w windows from, and no file an
655
- operator hands over says whether its counters agree with its rows — so it is
656
- recomputed rather than trusted. `bun:sqlite` is synchronous, so the grouped scan
657
- holds the event loop: roughly 0.4 s per 500k request-log rows, 1.6 s per 2M, and
658
- about 6.5 s at 8M. `/api/*` and `/health` do not answer during it. It is the last
659
- thing a restore does, after the swap has already succeeded, and a failure is
660
- logged rather than raised — the database is live either way, and `omni doctor`
661
- reports a rollup that disagrees with its rows.
590
+ and `/health` keep answering. The screen also uploads a database file from
591
+ elsewhere, up to 2 GiB bring `OMNI_ENCRYPTION_KEY` with it, or the credentials
592
+ in it are unreadable. The file is integrity-checked before anything is touched,
593
+ and a copy of what was there is taken first. A restore ends by rebuilding the
594
+ usage rollup, which briefly blocks even `/api/*`: roughly 0.4 s per 500k
595
+ request-log rows, 1.6 s at 2M, 6.5 s at 8M. A failure is logged rather than
596
+ raised the database is live either way, and `omni doctor` reports a rollup
597
+ that disagrees with its rows.
662
598
 
663
599
  **`omni db restore <id>` refuses while a gateway is running** against that
664
600
  installation, and there is no override flag. A second process can open its own
@@ -743,12 +679,9 @@ Worth knowing before you deploy it:
743
679
  fonts, no third-party origins. A plugin may declare outbound origins of its
744
680
  own, and `omni plugin verify <id>` shows exactly which ones it asked for — as
745
681
  does its manifest, which is a plain file you can read before installing.
746
- - **Plugins run inside the gateway process, with its privileges.** The
747
- capability context they are handed is a guardrail against mistakes, not a
748
- sandbox against malice: a plugin shares the process holding your encryption
749
- key and decrypted provider credentials, and can reach past the context if it
750
- wants to. Install plugins you wrote or audited, and read a plugin's manifest —
751
- it lists every capability and outbound origin it asked for.
682
+ - **Plugins run inside the gateway process, with its privileges** — the
683
+ capability context is a guardrail, not a sandbox. Read the
684
+ [security note](#plugins) before installing one you did not write.
752
685
 
753
686
  ## Plugins
754
687
 
@@ -793,6 +726,11 @@ ID NAME VERSION API SDK CAPABILITIES
793
726
  pokemon Pokémon Companion 1.0.0 1 ^1.0.0 storage,files,net:outbound,… ok
794
727
  ```
795
728
 
729
+ The capabilities a manifest may declare are `storage`, `files`, `net:outbound`,
730
+ `events:request`, `events:limit` and `channels` — the last being namespaced
731
+ topics on the gateway's push socket, which a plugin owns without ever touching a
732
+ connection. Anything a plugin did not declare is absent from what it is handed.
733
+
796
734
  A plugin that would *not* load is listed with the reason rather than hidden,
797
735
  because a plugin missing from the console is exactly what you are trying to
798
736
  explain. For one plugin's full detail — its entry points and the outbound
@@ -837,60 +775,21 @@ the plugin.
837
775
 
838
776
  ### Installing on a machine with no checkout
839
777
 
840
- A published plugin installs by name, and the host needs no checkout and no build
841
- toolchain:
778
+ A published plugin installs by name no checkout, no build toolchain:
842
779
 
843
780
  ```bash
844
781
  omni plugin install omnigateway-plugin-example
845
782
  omni plugin verify example && omni restart
846
783
  ```
847
784
 
848
- An `npm pack` tarball is rooted at `package/` rather than at the plugin's name,
849
- and that is fine: the manifest's `id` names the installed directory when the
850
- archive root does not.
851
-
852
- For a plugin you build yourself and do not publish, ship the tarball and install
853
- from the path:
854
-
855
- ```bash
856
- # wherever you build — a workstation, CI
857
- bun run build # your plugin's own build
858
- tar -czf my-plugin.tgz -C dist my-plugin
859
-
860
- # on the host
861
- scp my-plugin.tgz gateway-host:/tmp/
862
- ssh gateway-host 'omni plugin install /tmp/my-plugin.tgz && omni plugin verify my-plugin && omni restart'
863
- ```
864
-
865
- Whatever you pack, the manifest must sit at the **root** of the archive once one
866
- wrapping directory is stripped. A build that nests it — `dist/my-plugin/omni-plugin.json`
867
- inside a tarball made from the repository root — is refused with "has no
868
- omni-plugin.json at its root", and that is the most common way a plugin that
869
- builds fine turns out not to install.
870
-
871
- Plaintext `http://` is refused outright and always will be: what arrives over
872
- that fetch is code the gateway process will `import`, so anyone between you and
873
- the host would be choosing what the gateway runs. Silently upgrading to `https://`
874
- would be worse — it would install *something* from a URL you did not type.
875
-
876
- **In Docker**, the image carries the gateway only, so a plugin arrives on a
877
- volume rather than through the CLI. Mount it at `<root>/plugins/<id>` — the same
878
- layout `install` writes — and restart the container:
879
-
880
- ```bash
881
- docker run --rm -p 9000:9000 \
882
- -e OMNI_ENCRYPTION_KEY="$OMNI_ENCRYPTION_KEY" \
883
- -v omnigateway-data:/data \
884
- -v "$PWD/pokemon:/data/plugins/pokemon" \
885
- omnigateway
886
- ```
785
+ Building and shipping your own plugin tarball layout, the manifest-at-root
786
+ rule, why plaintext `http://` stays refused, Docker mounting is covered in
787
+ [docs/writing-a-plugin.md](docs/writing-a-plugin.md).
887
788
 
888
- **Do not mount it `:ro`** if the plugin declares the `files` capability. A
889
- plugin's cache lives at `<root>/plugins/<id>/data/`, inside its own directory, and
890
- the capability creates that directory on every call so a read-only mount fails
891
- *reads* as well as writes, with an `EACCES` on `mkdir` rather than anything that
892
- names the mount. Keeping a plugin's code immutable while its cache stays
893
- writable is not expressible today; mount the directory read-write.
789
+ **In Docker**, mount the plugin at `<root>/plugins/<id>` on a volume the same
790
+ layout `install` writes and restart the container; read-write, not `:ro`,
791
+ because a plugin declaring `files` writes its cache inside its own directory.
792
+ See [docs/writing-a-plugin.md](docs/writing-a-plugin.md).
894
793
 
895
794
  Removing one keeps its data:
896
795