omnigateway 0.2.2 → 0.4.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 (71) hide show
  1. package/README.md +506 -9
  2. package/bin/omni.js +3022 -163
  3. package/gateway.js +4094 -662
  4. package/package.json +1 -1
  5. package/public/assets/{Chip-DJwp7gBu.js → Chip-BltJSw8A.js} +5 -5
  6. package/public/assets/Confirm-gd8Y6snu.js +4 -0
  7. package/public/assets/CopyValue-Dds-Ow9a.js +27 -0
  8. package/public/assets/{Field-BDtp692L.js → Field-D-14HF0x.js} +11 -11
  9. package/public/assets/Lamp-BDf7-dMa.js +25 -0
  10. package/public/assets/Match-CgNxphvS.js +1 -0
  11. package/public/assets/Meter-stpt3vQu.js +14 -0
  12. package/public/assets/Modal-Dj3LvMS9.js +82 -0
  13. package/public/assets/Rack-B4c0dbIb.js +180 -0
  14. package/public/assets/{Readout-CEAoN5hu.js → Readout-Du15WYx0.js} +5 -5
  15. package/public/assets/{States-BEKLros0.js → States-CxLDlCaw.js} +5 -5
  16. package/public/assets/{Table-BBypbVWj.js → Table-CVDZ69Zx.js} +1 -1
  17. package/public/assets/Toggle-DMk46RX2.js +39 -0
  18. package/public/assets/TokenBreakdown-BySwFK6Q.js +31 -0
  19. package/public/assets/_app-Dx_xnlud.js +1 -0
  20. package/public/assets/_app.accounts-DvBYnbfS.js +64 -0
  21. package/public/assets/_app.console-CoaRdkB1.js +44 -0
  22. package/public/assets/_app.database-CES5ec5g.js +39 -0
  23. package/public/assets/_app.index-DwOZe1A-.js +62 -0
  24. package/public/assets/_app.keys-B5HrAu2U.js +76 -0
  25. package/public/assets/_app.logs-NbfIexz0.js +68 -0
  26. package/public/assets/_app.models-B1MdFRtQ.js +148 -0
  27. package/public/assets/_app.plugins._pluginId-ByMziW8p.js +20 -0
  28. package/public/assets/_app.settings-BkDkGkKF.js +46 -0
  29. package/public/assets/_app.usage-DNpM8Nub.js +83 -0
  30. package/public/assets/dist-9ps0Qd15.js +1 -0
  31. package/public/assets/index-gMv8Qs7m.js +178 -0
  32. package/public/assets/login-cCXnl34r.js +36 -0
  33. package/public/assets/matchContext-BOenO3J2.js +1 -0
  34. package/public/assets/plus-fXfUTt6_.js +1 -0
  35. package/public/assets/queries-CIOPbRcE.js +142 -0
  36. package/public/assets/reasons-Cb8yF4Oy.js +1 -0
  37. package/public/assets/shared-B4o-2quR.js +84 -0
  38. package/public/assets/{trash-2-CLYNAAJA.js → trash-2-ZPh3619D.js} +1 -1
  39. package/public/index.html +7 -2
  40. package/public/shared/chunks/jsx-runtime-CznXPbDH.js +1 -0
  41. package/public/shared/chunks/react-GitiQ0dt.js +1 -0
  42. package/public/shared/chunks/react-dom-BTlfKTOo.js +1 -0
  43. package/public/shared/chunks/rolldown-runtime-CbXtAM7H.js +1 -0
  44. package/public/shared/jsx-runtime.js +1 -0
  45. package/public/shared/react-dom-client.js +9 -0
  46. package/public/shared/react-dom.js +1 -0
  47. package/public/shared/react-query.js +1 -0
  48. package/public/shared/react.js +1 -0
  49. package/public/shared/styled-components.js +7 -0
  50. package/public/assets/Confirm-CVLz9F7a.js +0 -4
  51. package/public/assets/CopyValue-CNZbWS59.js +0 -27
  52. package/public/assets/Lamp-B65uWKbW.js +0 -25
  53. package/public/assets/Meter-BREBUFGX.js +0 -14
  54. package/public/assets/Modal-DbydSAlp.js +0 -82
  55. package/public/assets/Rack-Bbo4JfEX.js +0 -151
  56. package/public/assets/Toggle-BUr71O4J.js +0 -39
  57. package/public/assets/TokenBreakdown-C1ziK9d1.js +0 -31
  58. package/public/assets/_app-ByQ1bPbe.js +0 -1
  59. package/public/assets/_app.accounts-BTug_Xfo.js +0 -64
  60. package/public/assets/_app.console-DKyGL9xx.js +0 -44
  61. package/public/assets/_app.index-CSw9gmMf.js +0 -62
  62. package/public/assets/_app.keys-07Kbw4RS.js +0 -39
  63. package/public/assets/_app.logs-C9QiV39p.js +0 -32
  64. package/public/assets/_app.models-CDqVrAeF.js +0 -148
  65. package/public/assets/_app.settings-DmUEeTaP.js +0 -38
  66. package/public/assets/_app.usage-B1WuMGRr.js +0 -83
  67. package/public/assets/dist-Bka7ErcH.js +0 -1
  68. package/public/assets/index-DJfb1i4P.js +0 -186
  69. package/public/assets/login-DKZYqRh0.js +0 -33
  70. package/public/assets/queries-D8LJNQpL.js +0 -144
  71. package/public/assets/shared-CIhZ6FCO.js +0 -84
package/README.md CHANGED
@@ -39,9 +39,14 @@ omni start
39
39
  routes by *pace*: 5% remaining is fine minutes before a reset and urgent with
40
40
  six days to run.
41
41
  - **Issues its own keys.** Hand out gateway keys with per-key model allowlists
42
- and rate limits instead of sharing provider credentials.
42
+ instead of sharing provider credentials, and bound each one by requests,
43
+ tokens, dollars, or requests in flight — per minute, per five hours, and per
44
+ week.
43
45
  - **Reports usage.** Requests, tokens, and cost by provider, model, key, and
44
46
  day — metadata only.
47
+ - **Backs itself up.** Snapshot the database from the console or the CLI, see
48
+ what it occupies, reclaim what deletion left behind, and restore a snapshot
49
+ without stopping the gateway.
45
50
  - **Ships an admin console and a CLI.** Both cover the same ground; use
46
51
  whichever suits the machine you are on.
47
52
 
@@ -65,17 +70,21 @@ graph TD
65
70
  store["@omni/store<br/><i>SQLite + field encryption</i>"]
66
71
  providers["@omni/providers<br/><i>adapters, wire codecs,<br/>catalog, HTTP client</i>"]
67
72
  rtk["@omni/rtk<br/><i>tool-result filters</i>"]
73
+ ratelimit["@omni/ratelimit<br/><i>key limit arithmetic;<br/>no clock, no state</i>"]
68
74
  ir["@omni/ir<br/><i>domain model — depends on nothing</i>"]
69
75
 
70
76
  gateway --> control
71
77
  gateway --> rtk
78
+ gateway --> ratelimit
72
79
  cli --> control
73
80
  dashboard -. "types + catalog only" .-> providers
74
81
  dashboard -. "types only" .-> store
75
82
  control --> router
83
+ control --> ratelimit
76
84
  router --> store
77
85
  router --> providers
78
86
  store --> rtk
87
+ store --> ratelimit
79
88
  providers --> ir
80
89
  rtk --> ir
81
90
  store --> ir
@@ -86,6 +95,14 @@ the work: `@omni/ir` is side-effect free and imports nothing, and `@omni/router`
86
95
  is a pure function — no network, no database, no timers. Everything that has to
87
96
  touch the world is pushed up into the gateway process.
88
97
 
98
+ `@omni/ratelimit` is the same shape applied to key limits: it decides whether a
99
+ request is over a ceiling, and it holds no clock and no counters — `now` is a
100
+ parameter and the counts are handed to it, so it never learns whether a number
101
+ came from memory or from SQLite. It imports nothing but its schema validator, not
102
+ even `@omni/ir`. The rings and the in-flight gauge live in the gateway, because
103
+ state is not the package's job, and that split is what lets sliding-window
104
+ arithmetic be tested without a gateway, a store, or a clock.
105
+
89
106
  The dashboard's edges are dotted because they are type-level only.
90
107
  `@omni/providers/catalog` is deliberately kept import-free so model lists can be
91
108
  bundled into the browser without dragging in the HTTP client.
@@ -183,6 +200,47 @@ Mint a key for your client. **It is printed once and stored only as a hash:**
183
200
  omni keys create --label laptop
184
201
  ```
185
202
 
203
+ Bound what the key can do with `--limit <dimension>:<window>=<value>`, repeated
204
+ once per pair. An unset pair is unlimited:
205
+
206
+ ```bash
207
+ omni keys create --label ci --limit requests:1m=60
208
+ ```
209
+
210
+ Every window is *sliding*, so a key cannot spend two windows' allowance either
211
+ side of a clock edge. `requests` and `tokens` take `1m`, `5h`, and `1w`; `spend`
212
+ takes `5h` and `1w`; `concurrency` is not a window at all but a ceiling on
213
+ requests in flight at once.
214
+
215
+ `tokens` and `spend` are debited once a response completes, because an exact
216
+ count exists only then — a key at its ceiling is refused on its *next* request.
217
+ The `5h` and `1w` counts derive from `request_logs`, so a `1w` limit on an
218
+ installation that prunes logs after three days really enforces three days. They
219
+ are cached for thirty seconds and so can read slightly high, never low: refused
220
+ early rather than let past a ceiling you set. If the database cannot answer they
221
+ stop enforcing and the gateway logs it, while `1m` and `concurrency` are held in
222
+ memory and go on enforcing exactly.
223
+
224
+ > **Breaking:** `--rate-limit N` is removed, not aliased. Use
225
+ > `--limit requests:1m=N`. A script still passing the old flag stops with an
226
+ > unknown-flag error rather than quietly taking a deprecated path.
227
+
228
+ Limits are editable after the key exists, unlike `--no-bodies` below. `omni keys
229
+ list` prints a compact summary; the full matrix and what has gone against it
230
+ need one key's id:
231
+
232
+ ```bash
233
+ omni keys limits <id>
234
+ omni keys limits <id> --set tokens:1w=50000000
235
+ omni keys limits <id> --unset spend:5h
236
+ ```
237
+
238
+ `--unset` names a pair that is actually set, so a typo fails rather than
239
+ reporting a change it did not make. The usage shown counts completed requests
240
+ only, so it reads at or below what the gateway is enforcing, and `concurrency`
241
+ shows no figure — that gauge lives in the process, not the database. The
242
+ console's Keys screen shows and edits the same matrix.
243
+
186
244
  Now use it:
187
245
 
188
246
  ```bash
@@ -223,6 +281,36 @@ once.
223
281
  Most tools that accept a custom base URL work unchanged: set it to
224
282
  `http://127.0.0.1:9000` and use a gateway key where the provider key goes.
225
283
 
284
+ ### Rate-limit headers
285
+
286
+ Every response carries the limit headers of the surface you asked on, so an SDK
287
+ backs off using the code it already ships — the Anthropic dialect on
288
+ `/v1/messages`, the OpenAI one on `/v1/chat/completions`:
289
+
290
+ ```http
291
+ anthropic-ratelimit-requests-limit: 2000 x-ratelimit-limit-requests: 2000
292
+ anthropic-ratelimit-requests-remaining: 1841 x-ratelimit-remaining-requests: 1841
293
+ anthropic-ratelimit-requests-reset: 2026-08-19T14:32:07Z x-ratelimit-reset-requests: 4h51m22s
294
+ ```
295
+
296
+ `requests-remaining` counts the request you are being answered, as both vendors
297
+ define it. `tokens-remaining` does not, and cannot: the response is still being
298
+ written when the header goes out, so its token cost is not yet known and
299
+ subtracting anything would be an invented number rather than a measured one.
300
+
301
+ Where a key has several windows on one dimension, the headers report the one
302
+ **nearest exhaustion** — a key comfortable per-minute but one request from its
303
+ weekly ceiling shows you the weekly figures, not the reassuring ones.
304
+
305
+ `spend` and `concurrency` are rendered on neither dialect, because no vendor
306
+ defines a header for them and a number no client parses is noise in every
307
+ response.
308
+
309
+ A refusal is `429` with `Retry-After` in seconds, alongside the usual error body.
310
+ The wait is computed from the oldest request still inside the window that
311
+ refused you, so a weekly ceiling tells you when a slot actually frees rather than
312
+ parking you for seven days.
313
+
226
314
  ### Tools and routing
227
315
 
228
316
  Two kinds of tool behave differently, and the difference decides where a
@@ -260,6 +348,7 @@ Use `--db <path>` to point one command somewhere else.
260
348
  | `omni start` / `stop` / `restart` | run the gateway; `--foreground` attaches it to your terminal |
261
349
  | `omni doctor` | which installation it resolved, and whether it can act on it |
262
350
  | `omni logs` | recent requests as the gateway recorded them |
351
+ | `omni bodies <request-id>` | captured bodies for one request; withheld unless you pass `--full` |
263
352
  | `omni console` | the gateway process's own output: boot, refreshes, quota, errors |
264
353
  | `omni usage` | spend and tokens, by provider, model, key, or day |
265
354
  | `omni quota` | provider quota per window: use, burn rate, and when it runs out |
@@ -267,9 +356,14 @@ Use `--db <path>` to point one command somewhere else.
267
356
  | `omni credentials …` | list, show, enable, disable, retier, refresh, remove |
268
357
  | `omni models …` | list, show, put, remove, `dry-run`, `catalog` |
269
358
  | `omni keys …` | list, create, revoke |
270
- | `omni settings get` / `set` | routing weights, retention, deadlines |
359
+ | `omni plugin …` | list, verify, install, remove; see [Plugins](#plugins) |
360
+ | `omni settings get` / `set` | routing weights, retention, deadlines, and the runtime switches |
271
361
  | `omni admin set-password` | change the console password |
272
362
  | `omni db migrate` | create or upgrade the database |
363
+ | `omni db stats` | size on disk, free pages, schema version, and what snapshots are held |
364
+ | `omni db backup` / `snapshots` | take a snapshot, and list the ones retention has kept |
365
+ | `omni db restore <id>` | put a snapshot back; asks first, and refuses while the gateway is running |
366
+ | `omni db vacuum` | rewrite the database, reclaiming the pages deletion left free |
273
367
 
274
368
  Two worth knowing:
275
369
 
@@ -294,6 +388,29 @@ start` supervises the process itself with a pidfile under
294
388
  `~/.local/state/omnigateway`. Either way, `omni start` returns only once
295
389
  `/health` actually answers.
296
390
 
391
+ ### Restarting and stopping from the console
392
+
393
+ The console's Database screen can restart and shut down the gateway. A restart
394
+ only restarts if something would start the process again, so what the control
395
+ does — and whether it is offered at all — depends on how this installation runs:
396
+
397
+ - **Under systemd**, it works. The gateway asks the manager rather than
398
+ signalling itself — `systemctl [--user] --no-block restart
399
+ omnigateway.service` — because the unit `omni service install` writes sets
400
+ `Restart=on-failure`, and a handled `SIGTERM` exits cleanly, which systemd
401
+ reads as success. A gateway that killed itself would stop and stay stopped.
402
+ - **In a container**, it depends on the container's restart policy, and that
403
+ policy cannot be read from inside the container. The console says so instead
404
+ of promising a restart it has no way to verify. Run with
405
+ `--restart unless-stopped` if you want the control to mean what it says.
406
+ - **With no supervisor** — `omni start` with no unit installed — the control is
407
+ disabled and shows the reason. Nothing is watching the process, so an exit is
408
+ simply the end of it. Use `omni restart` from a terminal on the machine.
409
+
410
+ Shutdown is offered in every shape, because stopping is the point of it. In a
411
+ container it is a one-way door: stopping the only process takes the console that
412
+ would have restarted it, so bringing the gateway back needs access to the host.
413
+
297
414
  ## Configuration
298
415
 
299
416
  Configuration is environment variables, read from the installation's `.env`:
@@ -308,6 +425,7 @@ Configuration is environment variables, read from the installation's `.env`:
308
425
  | `OMNI_STATIC_DIR` | No | the console shipped with the server | Serve a different console build |
309
426
  | `OMNI_LOG_LEVEL` | No | `info` | Stdout threshold: `debug`, `info`, `warn`, or `error` |
310
427
  | `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 |
428
+ | `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) |
311
429
 
312
430
  Gateway events are written to stdout as one greppable line each: process lifecycle, OAuth
313
431
  refreshes, quota probes, failover, and errors.
@@ -330,6 +448,193 @@ environment. Edit it with `omni settings set` or in the console.
330
448
  `.env.example` in the repository documents the optional provider
331
449
  client-identity overrides.
332
450
 
451
+ ## Recording bodies
452
+
453
+ By default the gateway records no prompts and no responses. If you need them for
454
+ an incident — to see what a client actually sent, or what a provider actually
455
+ returned — capture is opt-in and takes **two independent keys, both required**:
456
+
457
+ 1. `OMNI_BODY_LOGGING_ALLOWED=1` in the installation's `.env`, read at boot.
458
+ 2. The **Capture request and response bodies** setting, in the console's
459
+ Settings screen or `omni settings set bodyLoggingEnabled true`. Off by
460
+ default. Raw SSE frames are the separate, far larger
461
+ `bodyLoggingCaptureStreamChunks`.
462
+
463
+ Two keys, because an admin session on its own must not be able to start
464
+ recording your users' prompts. With the environment variable unset the setting
465
+ does nothing at all, and the console says so rather than letting you flip a
466
+ switch that silently no-ops. With the environment variable set you can turn
467
+ capture on and off mid-incident without restarting.
468
+
469
+ Turning capture off stops new capture. It does not delete what was already
470
+ written.
471
+
472
+ **Per-key opt-out.** A gateway key can be created with *Never record this key's
473
+ bodies* — `omni keys create --no-bodies` — and it is then never captured whatever
474
+ the setting says. Use it for a client whose payloads must not be retained. The
475
+ choice is made when the key is issued and cannot be reversed afterwards; reissue
476
+ the key instead. `omni keys list` and the console's Keys screen both show which
477
+ keys are exempt, so an audit does not have to go through the database.
478
+
479
+ **What is captured.** What arrived at `/v1/*` and what was returned, plus the
480
+ request and response of every provider attempt, in dispatch order. The client
481
+ request is the conversation *before* RTK compression and each attempt request is
482
+ the one *after* it, so an artifact is the only place you can read what a filter
483
+ actually removed. The console labels which side is which, and so does the CLI.
484
+
485
+ **Reading them.** Expand a row on the console's Logs screen, or from a terminal:
486
+
487
+ ```bash
488
+ omni bodies req_550e8400-… # the frame: state, size, one line per attempt
489
+ omni bodies req_550e8400-… --full # the payloads themselves
490
+ omni bodies req_550e8400-… --json # the artifact, for a script
491
+ ```
492
+
493
+ **The bare command withholds the bodies and prints only the frame.** Every other
494
+ CLI read prints everything it has; this one prints conversations, and whoever
495
+ runs it during an incident is usually sharing that screen. Asking costs one
496
+ flag; printing by default costs a prompt corpus in someone's scrollback,
497
+ silently. The frame still gives you the state, capture time, size on disk, any
498
+ truncation, and each attempt's provider and byte counts — labelled `pre-RTK` for
499
+ the client request and `post-RTK` for attempts, because they are not the same
500
+ payload.
501
+
502
+ A request with no artifact is an answer rather than an error, and the three
503
+ answers are different: `not captured` means capture was not running, `captured,
504
+ then lost` means retention or the row cap has been through, and `captured, but
505
+ unreadable` means the file is there but will not decrypt — usually a changed
506
+ `OMNI_ENCRYPTION_KEY`.
507
+
508
+ There is no CLI command to delete a captured body. Retention, the row cap, and
509
+ the orphan sweep are the gateway's; a second path that erases forensic evidence
510
+ on request is a way to lose an incident record.
511
+
512
+ **What is never captured.** Headers, at any layer — every provider authenticates
513
+ through headers, so that is where the tokens are. That one is a guarantee: the
514
+ capture layer is never handed a header list, so no provider, present or future,
515
+ can opt its own in.
516
+
517
+ **Masking is best-effort, and a body corpus is sensitive even after it.** Bodies
518
+ are masked before they are written, replacing bearer tokens, `sk-`/`ak-`/`pk-`
519
+ prefixed keys, the well-known vendor prefixes (`ghp_` and the rest of GitHub's,
520
+ `github_pat_`, `AIza`, `GOCSPX-`, `xai-`), and any long opaque token with elided
521
+ forms. Two things follow, and both matter before you turn capture on:
522
+
523
+ - It costs fidelity. The length rule has no idea what it is looking at, so it
524
+ also elides base64 image data, content hashes, and minified source. That is
525
+ deliberate: a corpus that leaks a live credential is the worse failure.
526
+ - It does not catch everything. The length rule is tuned to base64url, so a
527
+ standard-base64 secret or an AWS secret access key can slip through it on a
528
+ `+` or a `/`, and a credential shorter than forty-one characters or exactly
529
+ forty characters long — an Azure OpenAI key, for instance — is out of its reach
530
+ entirely. The prefix rules exist precisely because the length rule cannot be
531
+ the whole answer, and between them they are a reduction in exposure, not a
532
+ guarantee of none.
533
+
534
+ Treat the artifact tree as you would treat the prompts themselves: it is
535
+ encrypted at rest, it belongs on a volume you control, and it is not something to
536
+ copy into a ticket.
537
+
538
+ Nothing changes about stdout. Prompts and responses never reach the log, the
539
+ journal, or the Console screen; capture is a separate encrypted store.
540
+
541
+ **Where it goes.** `request_bodies/YYYY/MM/DD/<requestId>.json.enc` beside the
542
+ database file, encrypted with AES-256-GCM under `OMNI_ENCRYPTION_KEY`, the same
543
+ key as your provider credentials. Copies taken without the key yield nothing.
544
+ Changing the key invalidates every artifact already written.
545
+
546
+ **Bounds.** Two limits, because either alone fails:
547
+
548
+ - Bodies expire on the same **log retention** window as request rows, swept
549
+ hourly, file and row deleted together.
550
+ - A hard cap of **100,000 body rows**, oldest pruned first. The window is what
551
+ you reason about; the row cap is what actually bounds disk, because a week's
552
+ window over sustained traffic bounds nothing.
553
+
554
+ Individual payloads are bounded structurally rather than by byte offset — strings
555
+ past 64 KB, arrays to their last 24 items, nesting past 6 levels, objects to 80
556
+ keys — so a stored artifact is always valid JSON. An artifact still over 512 KB
557
+ after that has its bodies replaced by a marker recording why.
558
+
559
+ **Sizing.** 512 KB is a *plaintext* cap and encryption emits hex, so one artifact
560
+ can reach ~1 MB on disk: with the 100,000-row cap the corpus worst case is about
561
+ **100 GB**, not 50. Most artifacts are a few kilobytes, but that is the number to
562
+ size a volume against. The same cap applies per body held in memory while a
563
+ request is in flight, one per side per attempt — so ~512 KB × (attempts + 1) per
564
+ captured request. Multiply by your concurrency before enabling this on a small
565
+ box.
566
+
567
+ ## Snapshots and restore
568
+
569
+ The console's Database screen reports what this installation occupies — the
570
+ database file, its write-ahead log, the captured-body tree, and the free pages a
571
+ compaction would give back — and takes snapshots. `omni db stats` prints the same
572
+ figures.
573
+
574
+ **What a snapshot is.** One self-contained SQLite file, written into a
575
+ `snapshots/` directory beside the database. The write-ahead log is folded in, so
576
+ there is nothing else to copy alongside it, and taking one is safe while the
577
+ gateway is running: it reads through SQLite rather than copying bytes off disk.
578
+
579
+ **What it is not.** The sibling `request_bodies/` tree is excluded, always. A
580
+ snapshot is never a prompt corpus, and its size tracks your configuration and
581
+ usage history rather than your traffic. The cost is that a restore leaves the
582
+ captured-body tree out of step with the table: files the restored database has no
583
+ row for are collected by the hourly sweep, and a row whose file is gone reads back
584
+ as `captured, then lost`.
585
+
586
+ **A snapshot does carry secrets.** Your provider credentials are in it, encrypted,
587
+ along with your gateway key hashes. They are inert only because
588
+ `OMNI_ENCRYPTION_KEY` is not in the file — it lives in the installation's `.env`.
589
+ So treat a downloaded snapshot exactly as you treat the database itself: anyone
590
+ holding both the file and the key holds your provider accounts. Downloads are
591
+ never cached, and each one is recorded in the gateway's log.
592
+
593
+ **Retention** bounds the directory: at most `keepLatest` snapshots are kept, and
594
+ nothing older than `maxAgeDays` — 5 and 30 by default. Both bounds have to pass,
595
+ so an old snapshot goes even while the count is under the limit, and the newest is
596
+ always kept whatever the numbers say. Pruning runs when a snapshot is taken rather
597
+ than on a timer, so a quiet installation keeps what it already has. **Edit the
598
+ policy on the Database screen**, not on Settings: it is deliberately not part of
599
+ the settings form, so a settings save from a client that has never heard of
600
+ retention leaves your policy alone instead of resetting it.
601
+
602
+ The copy taken automatically on the way into a restore is exempt from retention.
603
+ It is the undo.
604
+
605
+ **Restoring from the console** happens inside the running gateway. Client traffic
606
+ on `/v1/*` is refused with a retryable 503 while the file is replaced; `/api/*`
607
+ and `/health` keep answering, so you can watch the restore and hear how it ended.
608
+ Restoring a database that carries a different console password signs every session
609
+ out; restoring this installation's own snapshot does not. The screen also uploads
610
+ a database file from elsewhere, up to 2 GiB, which is how you move an installation
611
+ to another machine — bring `OMNI_ENCRYPTION_KEY` with it, or the credentials in it
612
+ are unreadable. Either way the file is integrity-checked before anything is
613
+ touched, and a copy of what was there is taken first.
614
+
615
+ **A restore ends by rebuilding the hourly usage rollup, and that step blocks.**
616
+ The rollup is what rate limits count their 5h and 1w windows from, and no file an
617
+ operator hands over says whether its counters agree with its rows — so it is
618
+ recomputed rather than trusted. `bun:sqlite` is synchronous, so the grouped scan
619
+ holds the event loop: roughly 0.4 s per 500k request-log rows, 1.6 s per 2M, and
620
+ about 6.5 s at 8M. `/api/*` and `/health` do not answer during it. It is the last
621
+ thing a restore does, after the swap has already succeeded, and a failure is
622
+ logged rather than raised — the database is live either way, and `omni doctor`
623
+ reports a rollup that disagrees with its rows.
624
+
625
+ **`omni db restore <id>` refuses while a gateway is running** against that
626
+ installation, and there is no override flag. A second process can open its own
627
+ handle but cannot quiesce the gateway's, and moving the file out from under a live
628
+ SQLite connection corrupts the database you were trying to rescue. Run `omni stop`
629
+ first, or restore from the console, which swaps the file behind its own quiesce
630
+ latch.
631
+
632
+ **Compaction.** `omni db vacuum`, or the console's equivalent, rewrites the
633
+ database and reclaims the pages deletion left free. It holds SQLite's write lock
634
+ for the rewrite, so a busy gateway stalls on its writes until it finishes — but
635
+ nothing is lost by running it live, and it reports what it actually gave back to
636
+ the filesystem.
637
+
333
638
  ## Docker
334
639
 
335
640
  ```bash
@@ -346,45 +651,237 @@ The container listens on `0.0.0.0:9000` and keeps its database at
346
651
  serves the APIs and returns 404 for the console. Use the CLI or the control API
347
652
  against it, or install the npm package if you want the console.
348
653
 
654
+ Give the container a restart policy — `--restart unless-stopped` — if you want a
655
+ restart request to bring it back. A container cannot read its own policy, so
656
+ without one an exit is simply the end of the installation until you start it
657
+ again from the host.
658
+
349
659
  ## Scope and limits
350
660
 
351
661
  Worth knowing before you deploy it:
352
662
 
353
663
  - **One machine, one operator.** No multi-tenancy, no clustering, no shared
354
- state. Rate limits are counted per process and reset when it restarts.
664
+ state. The `1m` window and the `concurrency` gauge are counted in the gateway
665
+ process and reset when it restarts; `5h` and `1w` are counted from the database
666
+ and survive one. Two gateways over one database would not see each other's
667
+ short-window counts.
355
668
  - **Two grains of usage history.** Detailed request logs are pruned after 30
356
669
  days by default; a daily rollup is kept for 400 days. A day is your host's
357
670
  local midnight, fixed when the row is written.
671
+ - **Body capture is forensics, not an archive.** It is off unless you turn it on
672
+ with both keys, it expires on the request-log window, and it is capped at
673
+ 100,000 rows. It is not a searchable prompt history and there is no CLI for it.
674
+ - **Snapshots are manual, and local.** Nothing takes one on a schedule and there
675
+ is no off-host target; retention bounds what you have taken, and a restore
676
+ always takes one first. Copy them somewhere else yourself if the disk failing
677
+ is what you are guarding against.
358
678
  - **Quota readings come from the providers**, and their usage endpoints are
359
679
  undocumented. An account with nothing reported is treated as unknown, never
360
680
  as unlimited.
361
681
  - **The gateway does not know which model accepts which request shape.** An
362
682
  unsupported combination surfaces as the provider's own 400 rather than being
363
683
  caught earlier.
364
- - Not in scope for version 1: semantic caching, billing, prompt storage,
365
- horizontal scaling.
684
+ - Not in scope for version 1: semantic caching, billing, horizontal scaling.
366
685
 
367
686
  ## Security
368
687
 
369
688
  - Treat `OMNI_ENCRYPTION_KEY`, gateway keys, and the SQLite file as secrets.
370
689
  Anyone with the file *and* the key has your provider credentials.
371
690
  - Prompts and responses are never logged. Request logs hold metadata and token
372
- counts only.
691
+ counts only, and no body ever reaches stdout, the journal, or the Console
692
+ screen. Bodies are stored only if you opt in to
693
+ [body capture](#recording-bodies), which needs both an environment variable
694
+ and a setting, encrypts what it writes, and can be refused per key.
373
695
  - Gateway keys are stored as hashes. A lost key is reissued, not recovered.
696
+ - A [snapshot](#snapshots-and-restore) carries whatever the database does —
697
+ encrypted provider credentials, gateway key hashes — and is inert only because
698
+ `OMNI_ENCRYPTION_KEY` is not in it. Captured bodies are excluded, so a snapshot
699
+ is never a prompt corpus.
374
700
  - Secrets are never accepted on the command line — `omni` prompts for them or
375
701
  reads stdin — so they stay out of your shell history and the process table.
376
702
  - Behind a reverse proxy, set `OMNI_BASE_URL` to the public HTTPS origin so
377
703
  OAuth callbacks match what the providers have registered.
378
704
  - The gateway talks to your providers and to nobody else. No telemetry, no CDN
379
- fonts, no third-party origins.
705
+ fonts, no third-party origins. A plugin may declare outbound origins of its
706
+ own, and `omni plugin verify <id>` shows exactly which ones it asked for — as
707
+ does its manifest, which is a plain file you can read before installing.
708
+ - **Plugins run inside the gateway process, with its privileges.** The
709
+ capability context they are handed is a guardrail against mistakes, not a
710
+ sandbox against malice: a plugin shares the process holding your encryption
711
+ key and decrypted provider credentials, and can reach past the context if it
712
+ wants to. Install plugins you wrote or audited, and read a plugin's manifest —
713
+ it lists every capability and outbound origin it asked for.
714
+
715
+ ## Plugins
716
+
717
+ A plugin adds routes, storage, and a screen in the console to one installation,
718
+ without being part of OmniGateway. Most installations run none.
719
+
720
+ ```bash
721
+ omni plugin install ./some-plugin # a directory, or a .tgz
722
+ omni plugin install https://…/x.tgz # a tarball over https, never http
723
+ omni plugin install some-plugin@1.2.3 # a package name, through the npm registry
724
+ omni plugin verify some-plugin # every check the next boot will run
725
+ omni plugin list # what is installed, and whether it would load
726
+ omni restart # plugins load at boot, so this is required
727
+ ```
728
+
729
+ **Nothing in the package is executed by any of these.** There is no dependency
730
+ resolution, no `node_modules`, and no lifecycle script — the installer fetches,
731
+ checks, and unpacks, and the plugin's own code is first imported at the next boot.
732
+
733
+ A spec is resolved filesystem-first: directory, then local tarball, then URL,
734
+ then registry. That order is the safe one. The reverse would let a published
735
+ package shadow the directory you are standing in and turn `omni plugin install
736
+ some-plugin` into a download nobody asked for.
737
+
738
+ Installing by name refuses more than it accepts, and each refusal happens before
739
+ any bytes are fetched: the tarball must be served from the registry's own host,
740
+ the registry must advertise an integrity hash or a shasum, and only an exact
741
+ version or the registry's `latest` resolves — no ranges, no other dist-tags. Use
742
+ `--registry` (or `OMNI_PLUGIN_REGISTRY`) for a private registry; it must be
743
+ `https://`.
744
+
745
+ A URL you type is different, and the difference is the point: nothing downstream
746
+ has a digest to check it against, so TLS to the host you named is the only
747
+ assurance there is. That is why `http://` is refused rather than upgraded.
748
+
749
+ `omni plugin list` prints what this installation has — id, name, version, the
750
+ plugin API and console SDK it was built against, the capabilities it declared,
751
+ and whether the gateway would load it:
752
+
753
+ ```
754
+ ID NAME VERSION API SDK CAPABILITIES STATE
755
+ pokemon Pokémon Companion 1.0.0 1 ^1.0.0 storage,files,net:outbound,… ok
756
+ ```
757
+
758
+ A plugin that would *not* load is listed with the reason rather than hidden,
759
+ because a plugin missing from the console is exactly what you are trying to
760
+ explain. For one plugin's full detail — its entry points and the outbound
761
+ origins it declared — use `omni plugin verify <id>`.
762
+
763
+ ### Available plugins
764
+
765
+ There is no curated directory to browse, and there is no plan for one. A plugin
766
+ is a directory, a tarball, a URL or a package name you point `omni plugin
767
+ install` at, and you are expected to know where it came from — see the
768
+ [security note](#security) for why that is the model rather than an omission.
769
+
770
+ Resolving a name through npm makes distribution easier; it does not make an
771
+ unknown plugin safer. Integrity checking proves you received the bytes the
772
+ registry advertised, and nothing about who wrote them or what they do once the
773
+ gateway imports them.
774
+
775
+ One ships in this repository:
776
+
777
+ | Plugin | What it does |
778
+ | --- | --- |
779
+ | [`pokemon`](plugins/pokemon) | A Pokémon companion. Each gateway key raises one that hatches, evolves, and graduates into a Pokédex on the tokens that key spends, with a shop that spends a wallet of those same tokens. |
780
+
781
+ It is not installed by default, is not in the npm package, and is not in the
782
+ Docker image. Build it from a checkout and install the result:
783
+
784
+ ```bash
785
+ bun run build:plugins # writes plugins/pokemon/dist/pokemon
786
+ omni plugin install ./plugins/pokemon/dist/pokemon
787
+ omni restart
788
+ ```
789
+
790
+ The path ends in `pokemon` because the installer takes the target directory name
791
+ from the source and refuses a manifest whose id disagrees with it — so a plugin
792
+ cannot be installed under a name that is not its own.
793
+
794
+ It needs outbound access to `pokeapi.co` and `raw.githubusercontent.com` for
795
+ species data and sprites, which its manifest declares and `omni plugin verify
796
+ pokemon` prints back. Those assets are Nintendo and Game Freak intellectual property, fetched at
797
+ runtime and never vendored into this repository or its published artifacts.
798
+
799
+ `verify` is the one to run before restarting a gateway that people are using: it
800
+ reaches the same verdict the next boot will, from the same code, without loading
801
+ the plugin.
802
+
803
+ ### Installing on a machine with no checkout
804
+
805
+ A published plugin installs by name, and the host needs no checkout and no build
806
+ toolchain:
807
+
808
+ ```bash
809
+ omni plugin install omnigateway-plugin-example
810
+ omni plugin verify example && omni restart
811
+ ```
812
+
813
+ An `npm pack` tarball is rooted at `package/` rather than at the plugin's name,
814
+ and that is fine: the manifest's `id` names the installed directory when the
815
+ archive root does not.
816
+
817
+ For a plugin you build yourself and do not publish, ship the tarball and install
818
+ from the path:
819
+
820
+ ```bash
821
+ # wherever you build — a workstation, CI
822
+ bun run build:plugins
823
+ tar -czf pokemon.tgz -C plugins/pokemon/dist pokemon
824
+
825
+ # on the host
826
+ scp pokemon.tgz gateway-host:/tmp/
827
+ ssh gateway-host 'omni plugin install /tmp/pokemon.tgz && omni plugin verify pokemon && omni restart'
828
+ ```
829
+
830
+ Plaintext `http://` is refused outright and always will be: what arrives over
831
+ that fetch is code the gateway process will `import`, so anyone between you and
832
+ the host would be choosing what the gateway runs. Silently upgrading to `https://`
833
+ would be worse — it would install *something* from a URL you did not type.
834
+
835
+ **In Docker**, the image carries the gateway only, so a plugin arrives on a
836
+ volume rather than through the CLI. Mount it at `<root>/plugins/<id>` — the same
837
+ layout `install` writes — and restart the container:
838
+
839
+ ```bash
840
+ docker run --rm -p 9000:9000 \
841
+ -e OMNI_ENCRYPTION_KEY="$OMNI_ENCRYPTION_KEY" \
842
+ -v omnigateway-data:/data \
843
+ -v "$PWD/pokemon:/data/plugins/pokemon" \
844
+ omnigateway
845
+ ```
846
+
847
+ **Do not mount it `:ro`** if the plugin declares the `files` capability. A
848
+ plugin's cache lives at `<root>/plugins/<id>/data/`, inside its own directory, and
849
+ the capability creates that directory on every call — so a read-only mount fails
850
+ *reads* as well as writes, with an `EACCES` on `mkdir` rather than anything that
851
+ names the mount. Keeping a plugin's code immutable while its cache stays
852
+ writable is not expressible today; mount the directory read-write.
853
+
854
+ Removing one keeps its data:
855
+
856
+ ```bash
857
+ omni plugin remove some-plugin # directory goes, database tables stay
858
+ omni plugin remove some-plugin --purge # tables too, after confirming
859
+ ```
860
+
861
+ That default is deliberate. A plugin directory can be reinstalled from the
862
+ package it came from; whatever it accumulated in your database cannot be
863
+ reinstalled from anything.
864
+
865
+ Note what "directory goes" includes: a plugin's `data/` directory is removed
866
+ with it. That directory holds cached files a plugin can rebuild — it is excluded
867
+ from [snapshots](#snapshots-and-restore) for that reason, so it has no restore
868
+ path and is not meant to need one. Only the database tables are kept, and only
869
+ those are what `--purge` additionally drops. For the same reason, restoring a snapshot onto an
870
+ installation that no longer has a plugin leaves that plugin's tables in place —
871
+ `omni doctor` reports them, and nothing removes them for you.
872
+
873
+ Read the [security note](#security) on what a plugin can reach before installing
874
+ one you did not write. To write one, see
875
+ [docs/writing-a-plugin.md](docs/writing-a-plugin.md).
380
876
 
381
877
  ## Development
382
878
 
383
879
  Contributing, or running from a checkout? See
384
880
  [ARCHITECTURE.md](ARCHITECTURE.md) for how the system fits together,
385
881
  [CLAUDE.md](CLAUDE.md) for the repository map, architectural boundaries, and
386
- conventions, and `docs/superpowers/specs/` for the design documents behind each
387
- feature.
882
+ conventions, [docs/adding-a-provider.md](docs/adding-a-provider.md) for the
883
+ provider checklist, and `docs/superpowers/specs/` for the design documents
884
+ behind each feature.
388
885
 
389
886
  ```bash
390
887
  git clone https://github.com/harismawan/omnigateway.git