omnigateway 0.11.0 → 0.11.1

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 (43) hide show
  1. package/README.md +16 -17
  2. package/bin/omni.js +184 -35
  3. package/gateway.js +139 -40
  4. package/package.json +2 -2
  5. package/public/assets/{Chip-CmL-9rw7.js → Chip-BBNaA-sL.js} +1 -1
  6. package/public/assets/{CopyValue-CTVbU0O_.js → CopyValue-DQMqneTT.js} +1 -1
  7. package/public/assets/{Lamp-DtHKJH6P.js → Lamp-CosPf-gw.js} +7 -7
  8. package/public/assets/Rack-BGrdPQRN.js +476 -0
  9. package/public/assets/RequestTable-B5pOnwDe.js +11 -0
  10. package/public/assets/SummaryDeck-DAvCcP_N.js +15 -0
  11. package/public/assets/{Toggle-ChjN-tw1.js → Toggle-ZS3zIMNF.js} +1 -1
  12. package/public/assets/{TokenBreakdown-BPoXM3JM.js → TokenBreakdown-VV2U0Fgv.js} +1 -1
  13. package/public/assets/{WindowChart-BPEsEGFL.js → WindowChart-eqPzFE06.js} +2 -2
  14. package/public/assets/_app.accounts-wJdiS7Ue.js +59 -0
  15. package/public/assets/{_app.console-Bkdallia.js → _app.console-DPuEIJl7.js} +2 -2
  16. package/public/assets/{_app.database-DxqN1395.js → _app.database-BWxxqrNy.js} +4 -4
  17. package/public/assets/_app.index-C_jGPDHc.js +67 -0
  18. package/public/assets/_app.keys-vLOZaX9s.js +92 -0
  19. package/public/assets/{_app.logs-DGQD8ZAq.js → _app.logs-4fRTXz5H.js} +3 -3
  20. package/public/assets/{_app.models-D6SV_GVk.js → _app.models-BFWk7FBf.js} +4 -4
  21. package/public/assets/{_app.plugins._pluginId-CJ7ObXXe.js → _app.plugins._pluginId-B2R5TEp9.js} +1 -1
  22. package/public/assets/_app.settings-BKUhzFsw.js +88 -0
  23. package/public/assets/_app.usage-Dme4K70C.js +76 -0
  24. package/public/assets/{chevron-right-BfnK63GB.js → chevron-right-CL2vJUko.js} +1 -1
  25. package/public/assets/client-DIfYMjPd.js +39 -0
  26. package/public/assets/index-teScS3wF.js +13 -0
  27. package/public/assets/{login-CrJK9wRA.js → login-CCLJgPoH.js} +2 -2
  28. package/public/assets/plus-C3pkG04C.js +1 -0
  29. package/public/assets/{preload-helper-BknjH67x.js → preload-helper-Dv9CFok1.js} +1 -1
  30. package/public/assets/{shared-oTMOLWMT.js → shared-5YRtk6PC.js} +12 -12
  31. package/public/assets/{trash-2-CSkf6ME_.js → trash-2-CLOg_lJ7.js} +1 -1
  32. package/public/index.html +4 -4
  33. package/public/assets/Rack-_A_3zqxC.js +0 -476
  34. package/public/assets/RequestTable-Cokk7wKP.js +0 -11
  35. package/public/assets/SummaryDeck-C0IbqnLz.js +0 -15
  36. package/public/assets/_app.accounts-DKmxz7Tl.js +0 -59
  37. package/public/assets/_app.index-BUqor-qH.js +0 -62
  38. package/public/assets/_app.keys-DMCPEj1g.js +0 -82
  39. package/public/assets/_app.settings-BVimpngR.js +0 -88
  40. package/public/assets/_app.usage-Y2Nkaw0t.js +0 -76
  41. package/public/assets/client-WgQ5A2PD.js +0 -39
  42. package/public/assets/index-BTE8vyBz.js +0 -13
  43. package/public/assets/plus-Cv51cK5k.js +0 -1
package/README.md CHANGED
@@ -174,6 +174,22 @@ pair is unlimited. Windows slide, `tokens` and `spend` are debited on
174
174
  completion, and limits are editable afterwards with `omni keys limits`; the
175
175
  semantics are in [docs/operations.md](docs/operations.md#key-limits).
176
176
 
177
+ A key can also carry an expiry, which is optional and off by default — a key
178
+ with none works until it is revoked, and no upgrade ever attaches one:
179
+
180
+ ```bash
181
+ omni keys create --label contractor --expires 2027-01-01
182
+ omni keys expiry <id> --at 2027-06-01T12:00:00Z # move it
183
+ omni keys expiry <id> --never # take it away
184
+ ```
185
+
186
+ Past the instant, every request the key makes is refused with the same answer a
187
+ revoked or unknown key gets — deliberately indistinguishable. Unlike
188
+ `omni keys revoke` this is reversible: a date already behind the clock is
189
+ accepted and means "stop it now", and `--never` brings the key back. Expired
190
+ keys stay listed, with their own state in `omni keys list` and on the console's
191
+ keys board, so a client that stopped working can be found rather than guessed at.
192
+
177
193
  Now use it:
178
194
 
179
195
  ```bash
@@ -265,23 +281,6 @@ Several replicas behind a load balancer are **cluster mode** on Postgres and
265
281
  Redis. All of it, with the nginx block and the Kubernetes base, is in
266
282
  [docs/deploying.md](docs/deploying.md).
267
283
 
268
- ### Upgrading past v0.10.3 — action required
269
-
270
- The first release after v0.10.3 drops two columns from `credential_health`
271
- that every earlier gateway writes on **every successful request**. The moment
272
- the new version migrates the database, every older gateway still running fails
273
- each request *after* the upstream call has completed and been billed: the
274
- client gets its content and then an aborted connection with no terminal frame.
275
-
276
- - **Cluster mode:** a rolling deploy does not protect you — the surviving old
277
- replica is the one that breaks. Scale to **one replica** before deploying
278
- this release, then scale back up, or accept that every old replica serves
279
- only failures until it is replaced.
280
- - **Single process (SQLite):** the upgrade itself is safe; the rollback is not.
281
- - **Rollback is unsafe on both.** An older version cannot re-add the columns
282
- and fails the same way against the new schema. Take a snapshot **before** the
283
- deploy; restoring it is the only way back.
284
-
285
284
  ## Configuration
286
285
 
287
286
  Configuration is environment variables, read from the installation's `.env`:
package/bin/omni.js CHANGED
@@ -75,7 +75,10 @@ function note(ctx, writer, message) {
75
75
  function formatTime(at) {
76
76
  if (at === null || !Number.isFinite(at))
77
77
  return "\u2014";
78
- return new Date(at).toISOString().replace("T", " ").slice(0, 19);
78
+ const date = new Date(at);
79
+ if (Number.isNaN(date.getTime()))
80
+ return "out of range";
81
+ return date.toISOString().replace("T", " ").slice(0, 19);
79
82
  }
80
83
  function formatAge(from, now) {
81
84
  if (from === null || !Number.isFinite(from))
@@ -20124,13 +20127,34 @@ CREATE TRIGGER credential_health_config_version_del
20124
20127
  FOR EACH STATEMENT EXECUTE FUNCTION bump_config_version();
20125
20128
  `;
20126
20129
 
20130
+ // packages/store/src/postgres/migrations/003_key_expiry.sql
20131
+ var _003_key_expiry_default = `-- The Postgres half of the sqlite migration of the same name. \`BIGINT\`, like
20132
+ -- every other epoch-ms column in this schema, and read back through \`numOrNull\`.
20133
+ --
20134
+ -- Nullable rather than \`NOT NULL DEFAULT\`: the column is a deadline, and every
20135
+ -- finite default is a date on which somebody's client stops working. NULL is
20136
+ -- the only value that says nothing was decided, and every reader maps it to
20137
+ -- "never expires".
20138
+ --
20139
+ -- No backfill, deliberately. Every key on every existing installation was
20140
+ -- minted on the promise that it works until revoked, so adding the column and
20141
+ -- stopping is what keeps that promise. The absence of an \`UPDATE\` here is the
20142
+ -- substance of the migration, not an omission.
20143
+ --
20144
+ -- Safe to apply under a rolling deploy, unlike migration 2: this adds a
20145
+ -- nullable column and drops nothing, so a replica running the previous image
20146
+ -- keeps serving \u2014 \`SELECT *\` gains a field its mapper ignores.
20147
+ ALTER TABLE api_keys ADD COLUMN expires_at BIGINT;
20148
+ `;
20149
+
20127
20150
  // packages/store/src/postgres/db.ts
20128
20151
  var num = (v) => Number(v);
20129
20152
  var numOrNull = (v) => v === null ? null : Number(v);
20130
20153
  var MIGRATION_LOCK = 7140641;
20131
20154
  var MIGRATIONS = [
20132
20155
  { id: 1, sql: _001_init_default },
20133
- { id: 2, sql: _002_health_measurements_default }
20156
+ { id: 2, sql: _002_health_measurements_default },
20157
+ { id: 3, sql: _003_key_expiry_default }
20134
20158
  ];
20135
20159
  async function openPg(url2) {
20136
20160
  const sql = new SQL({ url: url2 });
@@ -20328,6 +20352,9 @@ function resolvePin(target, accounts) {
20328
20352
  return;
20329
20353
  return accounts.find((account) => servesTarget(target, account));
20330
20354
  }
20355
+ var keyUsable = (key, now) => key.revokedAt === null && (key.expiresAt === null || key.expiresAt > now);
20356
+ var credentialPastExpiry = (credential, now) => credential.authType === "oauth" && credential.expiresAt !== null && credential.expiresAt <= now;
20357
+ var credentialExpired = (credential, now) => credentialPastExpiry(credential, now) && !credential.hasRefreshToken;
20331
20358
  var NODE_GRACE_MS = 60000;
20332
20359
  var DEFAULT_SETTINGS = {
20333
20360
  weights: { tier: 10, health: 3, quota: 2, load: 2, cost: 1, latency: 1 },
@@ -20807,7 +20834,8 @@ var toKey = (r, logger2) => ({
20807
20834
  limits: parseLimits(r.id, r.limits, logger2),
20808
20835
  bodyLoggingOptOut: r.body_logging_opt_out,
20809
20836
  createdAt: num(r.created_at),
20810
- revokedAt: numOrNull(r.revoked_at)
20837
+ revokedAt: numOrNull(r.revoked_at),
20838
+ expiresAt: numOrNull(r.expires_at)
20811
20839
  });
20812
20840
  function createKeyRepo(sql, logger2) {
20813
20841
  const one = async (where, arg) => {
@@ -20825,8 +20853,8 @@ function createKeyRepo(sql, logger2) {
20825
20853
  async create(input2) {
20826
20854
  const now = Date.now();
20827
20855
  await sql.unsafe(`INSERT INTO api_keys (id, label, prefix, hash, model_allowlist, limits,
20828
- body_logging_opt_out, created_at, revoked_at)
20829
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NULL)`, [
20856
+ body_logging_opt_out, created_at, revoked_at, expires_at)
20857
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NULL,$9)`, [
20830
20858
  input2.id,
20831
20859
  input2.label,
20832
20860
  input2.prefix,
@@ -20834,7 +20862,8 @@ function createKeyRepo(sql, logger2) {
20834
20862
  input2.modelAllowlist === null ? null : JSON.stringify(input2.modelAllowlist),
20835
20863
  JSON.stringify(parseLimitConfig(input2.limits)),
20836
20864
  input2.bodyLoggingOptOut,
20837
- now
20865
+ now,
20866
+ input2.expiresAt
20838
20867
  ]);
20839
20868
  return { ...input2, createdAt: now, revokedAt: null };
20840
20869
  },
@@ -20842,8 +20871,8 @@ function createKeyRepo(sql, logger2) {
20842
20871
  if (row.limits === null)
20843
20872
  throw new Error(`api key ${row.id} has unreadable limits`);
20844
20873
  await sql.unsafe(`INSERT INTO api_keys (id, label, prefix, hash, model_allowlist, limits,
20845
- body_logging_opt_out, created_at, revoked_at)
20846
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [
20874
+ body_logging_opt_out, created_at, revoked_at, expires_at)
20875
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, [
20847
20876
  row.id,
20848
20877
  row.label,
20849
20878
  row.prefix,
@@ -20852,7 +20881,8 @@ function createKeyRepo(sql, logger2) {
20852
20881
  JSON.stringify(parseLimitConfig(row.limits)),
20853
20882
  row.bodyLoggingOptOut,
20854
20883
  row.createdAt,
20855
- row.revokedAt
20884
+ row.revokedAt,
20885
+ row.expiresAt
20856
20886
  ]);
20857
20887
  },
20858
20888
  async setLimits(id, limits) {
@@ -20867,6 +20897,9 @@ function createKeyRepo(sql, logger2) {
20867
20897
  id
20868
20898
  ]);
20869
20899
  },
20900
+ async setExpiry(id, expiresAt) {
20901
+ await sql.unsafe("UPDATE api_keys SET expires_at = $1 WHERE id = $2", [expiresAt, id]);
20902
+ },
20870
20903
  async revoke(id) {
20871
20904
  await sql.unsafe("UPDATE api_keys SET revoked_at = $1 WHERE id = $2", [Date.now(), id]);
20872
20905
  }
@@ -22671,6 +22704,35 @@ ALTER TABLE credential_health DROP COLUMN ewma_ttft_ms;
22671
22704
  ALTER TABLE credential_health DROP COLUMN last_used_at;
22672
22705
  `;
22673
22706
 
22707
+ // packages/store/src/sqlite/migrations/014_key_expiry.sql
22708
+ var _014_key_expiry_default = `-- An optional expiry on a gateway API key: an absolute epoch-ms instant after
22709
+ -- which the key is refused, spelled exactly as \`credentials.expires_at\` is.
22710
+ --
22711
+ -- Nullable rather than \`NOT NULL DEFAULT\`, which is the opposite of the choice
22712
+ -- migration 9 made for \`limits\`, and for the opposite reason. There, every
22713
+ -- possible value is a policy and \`{}\` is the one that means "no ceiling", so a
22714
+ -- default gives "unlimited" a single spelling. Here the column is a deadline,
22715
+ -- and no number means "no deadline" \u2014 every finite default is a date on which
22716
+ -- somebody's client stops working. NULL is the only value that says nothing was
22717
+ -- decided, so it is the column's own default and every reader maps it to
22718
+ -- "never expires".
22719
+ --
22720
+ -- No backfill, deliberately, and this is the whole substance of the migration.
22721
+ -- Every key on every existing installation was minted on the promise that it
22722
+ -- works until revoked; an upgrade that attaches an expiry to those rows breaks
22723
+ -- clients on a date the operator never chose, and the failure surfaces days
22724
+ -- later as an authentication error with nothing in it that names this change.
22725
+ -- Adding the column and stopping is what keeps that promise, so the absence of
22726
+ -- an \`UPDATE\` here is load-bearing rather than an omission \u2014
22727
+ -- \`packages/store/test/migrations.test.ts\` pins it.
22728
+ --
22729
+ -- No index. The column is read only after a row has already been found by
22730
+ -- \`hash\` or by \`id\`, both of which are unique, so there is no scan for an index
22731
+ -- to help; and nothing sweeps expired keys, because an expired key stays listed
22732
+ -- exactly as a revoked one does so its usage history keeps its attribution.
22733
+ ALTER TABLE api_keys ADD COLUMN expires_at INTEGER;
22734
+ `;
22735
+
22674
22736
  // packages/store/src/sqlite/db.ts
22675
22737
  var MIGRATIONS2 = [
22676
22738
  { id: 1, sql: _001_init_default2 },
@@ -22685,7 +22747,8 @@ var MIGRATIONS2 = [
22685
22747
  { id: 10, sql: _010_usage_rollup_default, after: rebuildRollup },
22686
22748
  { id: 11, sql: _011_plugin_migrations_default },
22687
22749
  { id: 12, sql: _012_nodes_default },
22688
- { id: 13, sql: _013_health_measurements_default }
22750
+ { id: 13, sql: _013_health_measurements_default },
22751
+ { id: 14, sql: _014_key_expiry_default }
22689
22752
  ];
22690
22753
  function statements(sql) {
22691
22754
  return sql.split(`
@@ -22741,7 +22804,8 @@ var toKey2 = (r, logger2) => ({
22741
22804
  limits: parseLimits2(r.id, r.limits, logger2),
22742
22805
  bodyLoggingOptOut: r.body_logging_opt_out === 1,
22743
22806
  createdAt: r.created_at,
22744
- revokedAt: r.revoked_at
22807
+ revokedAt: r.revoked_at,
22808
+ expiresAt: r.expires_at
22745
22809
  });
22746
22810
  function createKeyRepo2(db, logger2 = noopLogger) {
22747
22811
  return {
@@ -22759,8 +22823,8 @@ function createKeyRepo2(db, logger2 = noopLogger) {
22759
22823
  async create(input2) {
22760
22824
  const now = Date.now();
22761
22825
  db.run(`INSERT INTO api_keys (id, label, prefix, hash, model_allowlist, limits,
22762
- body_logging_opt_out, created_at, revoked_at)
22763
- VALUES (?,?,?,?,?,?,?,?,NULL)`, [
22826
+ body_logging_opt_out, created_at, revoked_at, expires_at)
22827
+ VALUES (?,?,?,?,?,?,?,?,NULL,?)`, [
22764
22828
  input2.id,
22765
22829
  input2.label,
22766
22830
  input2.prefix,
@@ -22768,7 +22832,8 @@ function createKeyRepo2(db, logger2 = noopLogger) {
22768
22832
  input2.modelAllowlist === null ? null : JSON.stringify(input2.modelAllowlist),
22769
22833
  JSON.stringify(parseLimitConfig(input2.limits)),
22770
22834
  input2.bodyLoggingOptOut ? 1 : 0,
22771
- now
22835
+ now,
22836
+ input2.expiresAt
22772
22837
  ]);
22773
22838
  return { ...input2, createdAt: now, revokedAt: null };
22774
22839
  },
@@ -22776,8 +22841,8 @@ function createKeyRepo2(db, logger2 = noopLogger) {
22776
22841
  if (row.limits === null)
22777
22842
  throw new Error(`api key ${row.id} has unreadable limits`);
22778
22843
  db.run(`INSERT INTO api_keys (id, label, prefix, hash, model_allowlist, limits,
22779
- body_logging_opt_out, created_at, revoked_at)
22780
- VALUES (?,?,?,?,?,?,?,?,?)`, [
22844
+ body_logging_opt_out, created_at, revoked_at, expires_at)
22845
+ VALUES (?,?,?,?,?,?,?,?,?,?)`, [
22781
22846
  row.id,
22782
22847
  row.label,
22783
22848
  row.prefix,
@@ -22786,7 +22851,8 @@ function createKeyRepo2(db, logger2 = noopLogger) {
22786
22851
  JSON.stringify(parseLimitConfig(row.limits)),
22787
22852
  row.bodyLoggingOptOut ? 1 : 0,
22788
22853
  row.createdAt,
22789
- row.revokedAt
22854
+ row.revokedAt,
22855
+ row.expiresAt
22790
22856
  ]);
22791
22857
  },
22792
22858
  async setLimits(id, limits) {
@@ -22801,6 +22867,9 @@ function createKeyRepo2(db, logger2 = noopLogger) {
22801
22867
  id
22802
22868
  ]);
22803
22869
  },
22870
+ async setExpiry(id, expiresAt) {
22871
+ db.run("UPDATE api_keys SET expires_at = ? WHERE id = ?", [expiresAt, id]);
22872
+ },
22804
22873
  async revoke(id) {
22805
22874
  db.run("UPDATE api_keys SET revoked_at = ? WHERE id = ?", [Date.now(), id]);
22806
22875
  }
@@ -23365,6 +23434,7 @@ async function createStore(opts) {
23365
23434
  importRow: (row) => handle.keys.importRow(row),
23366
23435
  setLimits: (id, limits) => handle.keys.setLimits(id, limits),
23367
23436
  setModelAllowlist: (id, modelAllowlist) => handle.keys.setModelAllowlist(id, modelAllowlist),
23437
+ setExpiry: (id, expiresAt) => handle.keys.setExpiry(id, expiresAt),
23368
23438
  revoke: (id) => handle.keys.revoke(id)
23369
23439
  },
23370
23440
  usage: {
@@ -23467,7 +23537,7 @@ function createAdminAuth(store, opts) {
23467
23537
  };
23468
23538
  const keyStillValid = async (apiKeyId) => {
23469
23539
  const key = await store.keys.get(apiKeyId);
23470
- return key !== null && key.revokedAt === null;
23540
+ return key !== null && keyUsable(key, opts.now());
23471
23541
  };
23472
23542
  const applyPassword = async (password) => {
23473
23543
  if (password.length < MIN_PASSWORD_LENGTH) {
@@ -23521,7 +23591,7 @@ function createAdminAuth(store, opts) {
23521
23591
  },
23522
23592
  async loginClient(rawKey) {
23523
23593
  const key = await store.keys.findByHash(await hashApiKey(rawKey));
23524
- if (key === null || key.revokedAt !== null)
23594
+ if (key === null || !keyUsable(key, opts.now()))
23525
23595
  return null;
23526
23596
  return issue2({ kind: "client", apiKeyId: key.id });
23527
23597
  },
@@ -30996,16 +31066,19 @@ var modelSchema = exports_external.object({
30996
31066
  isAlias: exports_external.boolean(),
30997
31067
  targets: exports_external.array(targetSchema).min(1, "a virtual model needs at least one target")
30998
31068
  });
31069
+ var MAX_INSTANT = 8640000000000000;
30999
31070
  var keyCreateSchema = exports_external.object({
31000
31071
  label: exports_external.string().min(1).default("api key"),
31001
31072
  modelAllowlist: exports_external.array(exports_external.string().min(1)).nullable().default(null),
31002
31073
  limits: limitConfigSchema.default({}),
31003
- bodyLoggingOptOut: exports_external.boolean().default(false)
31074
+ bodyLoggingOptOut: exports_external.boolean().default(false),
31075
+ expiresAt: exports_external.number().int().positive().max(MAX_INSTANT).nullable().default(null)
31004
31076
  }).strict();
31005
31077
  var keyLimitsSchema = exports_external.object({ limits: limitConfigSchema }).strict();
31006
31078
  var keyModelsSchema = exports_external.object({
31007
31079
  modelAllowlist: exports_external.array(exports_external.string().min(1)).nullable()
31008
31080
  }).strict();
31081
+ var keyExpirySchema = exports_external.object({ expiresAt: exports_external.number().int().positive().max(MAX_INSTANT).nullable() }).strict();
31009
31082
  var retentionSchema = exports_external.object({
31010
31083
  keepLatest: exports_external.number().int().min(1).max(100),
31011
31084
  maxAgeDays: exports_external.number().int().min(1).max(3650)
@@ -31303,7 +31376,7 @@ function eligible(input2) {
31303
31376
  drop("disabled");
31304
31377
  continue;
31305
31378
  }
31306
- if (credential.authType === "oauth" && credential.expiresAt !== null && credential.expiresAt <= now && !credential.hasRefreshToken) {
31379
+ if (credentialExpired(credential, now)) {
31307
31380
  drop("expired");
31308
31381
  continue;
31309
31382
  }
@@ -32107,7 +32180,8 @@ async function toSummary(store, key, now) {
32107
32180
  limitUsage: await readLimitUsage(store, key.id, key.limits, now),
32108
32181
  bodyLoggingOptOut: key.bodyLoggingOptOut,
32109
32182
  createdAt: key.createdAt,
32110
- revokedAt: key.revokedAt
32183
+ revokedAt: key.revokedAt,
32184
+ expiresAt: key.expiresAt
32111
32185
  };
32112
32186
  }
32113
32187
  async function listKeys(store, now = Date.now()) {
@@ -32124,7 +32198,8 @@ async function createKey(store, input2) {
32124
32198
  hash: await hashApiKey(raw),
32125
32199
  modelAllowlist: body2.modelAllowlist,
32126
32200
  limits: body2.limits,
32127
- bodyLoggingOptOut: body2.bodyLoggingOptOut
32201
+ bodyLoggingOptOut: body2.bodyLoggingOptOut,
32202
+ expiresAt: body2.expiresAt
32128
32203
  });
32129
32204
  return { id: created.id, label: created.label, prefix: created.prefix, key: raw };
32130
32205
  }
@@ -32144,6 +32219,14 @@ async function setKeyModels(store, id, input2, now = Date.now()) {
32144
32219
  await store.keys.setModelAllowlist(id, body2.modelAllowlist);
32145
32220
  return toSummary(store, { ...key, modelAllowlist: body2.modelAllowlist }, now);
32146
32221
  }
32222
+ async function setKeyExpiry(store, id, input2, now = Date.now()) {
32223
+ const body2 = parseOrThrow(keyExpirySchema, input2);
32224
+ const key = await store.keys.get(id);
32225
+ if (key === null)
32226
+ throw new GatewayError("BAD_REQUEST", "no such api key");
32227
+ await store.keys.setExpiry(id, body2.expiresAt);
32228
+ return toSummary(store, { ...key, expiresAt: body2.expiresAt }, now);
32229
+ }
32147
32230
  async function revokeKey(store, id) {
32148
32231
  await store.keys.revoke(id);
32149
32232
  }
@@ -33614,6 +33697,12 @@ function readsNothing(scope) {
33614
33697
  // packages/control/src/quota/history.ts
33615
33698
  var DAY_MS3 = 24 * 60 * 60 * 1000;
33616
33699
  var HOUR_MS3 = 3600000;
33700
+ var MAX_SAMPLES = 50000;
33701
+ var SAMPLE_QUERY_LIMIT = MAX_SAMPLES + 1;
33702
+ function pageSamples(samples) {
33703
+ const truncated = samples.length > MAX_SAMPLES;
33704
+ return { page: truncated ? samples.slice(0, MAX_SAMPLES) : samples, truncated };
33705
+ }
33617
33706
  async function retainedSpan(deps, input2, maxSpanMs) {
33618
33707
  const now = deps.now();
33619
33708
  const settings = await deps.store.config.getSettings();
@@ -33660,12 +33749,14 @@ async function quotaHistory(deps, input2) {
33660
33749
  deps.store.credentials.listQuotaSamples({
33661
33750
  since,
33662
33751
  until,
33752
+ limit: SAMPLE_QUERY_LIMIT,
33663
33753
  ...credentialId === undefined ? {} : { credentialId }
33664
33754
  }),
33665
33755
  deps.store.credentials.listQuota()
33666
33756
  ]);
33667
33757
  const scoped = credentialId === undefined ? quota : quota.filter((w) => w.credentialId === credentialId);
33668
- return { samples, gatewayRates: await gatewayRatesFor(deps.store, scoped) };
33758
+ const { page, truncated } = pageSamples(samples);
33759
+ return { samples: page, truncated, gatewayRates: await gatewayRatesFor(deps.store, scoped) };
33669
33760
  }
33670
33761
 
33671
33762
  // packages/control/src/quota/clientHistory.ts
@@ -34715,12 +34806,13 @@ var console_ = {
34715
34806
  };
34716
34807
 
34717
34808
  // apps/cli/src/commands/credentials.ts
34718
- function condition(credential) {
34809
+ function condition(credential, now) {
34719
34810
  if (!credential.enabled)
34720
34811
  return { ok: false, text: credential.disabledReason ?? "disabled" };
34721
- if (credential.expiresAt !== null && credential.expiresAt <= Date.now()) {
34722
- return credential.hasRefreshToken ? { ok: true, text: "expired (refreshable)" } : { ok: false, text: "expired" };
34723
- }
34812
+ if (credentialExpired(credential, now))
34813
+ return { ok: false, text: "expired" };
34814
+ if (credentialPastExpiry(credential, now))
34815
+ return { ok: true, text: "expired (refreshable)" };
34724
34816
  return { ok: true, text: "enabled" };
34725
34817
  }
34726
34818
  var credentialsList = {
@@ -34741,7 +34833,7 @@ var credentialsList = {
34741
34833
  { header: "STATE" },
34742
34834
  { header: "EXPIRES" }
34743
34835
  ], credentials.map((c) => {
34744
- const status2 = condition(c);
34836
+ const status2 = condition(c, ctx.now());
34745
34837
  return [
34746
34838
  c.id,
34747
34839
  provider(ctx, c.provider),
@@ -34765,7 +34857,7 @@ var credentialsShow = {
34765
34857
  async run(args, { ctx, writer }) {
34766
34858
  const id = requirePositional(args, 0, "credential id");
34767
34859
  const credential = await findCredential(ctx, id);
34768
- const status2 = condition(credential);
34860
+ const status2 = condition(credential, ctx.now());
34769
34861
  const data = {
34770
34862
  id: credential.id,
34771
34863
  provider: credential.provider,
@@ -35287,11 +35379,24 @@ function summarizeLimits(limits) {
35287
35379
  }
35288
35380
  return parts.length === 0 ? "\u2014" : parts.join(" ");
35289
35381
  }
35382
+ function parseWhen(flag, raw) {
35383
+ const at = Date.parse(raw);
35384
+ if (!Number.isFinite(at)) {
35385
+ throw new UsageError(`${flag} must be a date, e.g. 2030-01-01, got "${raw}"`);
35386
+ }
35387
+ return at;
35388
+ }
35389
+ function keyState(key, now) {
35390
+ if (key.revokedAt !== null)
35391
+ return "revoked";
35392
+ return keyUsable(key, now) ? "active" : "expired";
35393
+ }
35290
35394
  var keysList = {
35291
35395
  usage: "keys list",
35292
35396
  summary: "List gateway API keys",
35293
35397
  async run(_args, { ctx, writer }) {
35294
35398
  const keys = await listKeys(await ctx.store());
35399
+ const now = Date.now();
35295
35400
  emit(ctx, writer, { keys }, () => {
35296
35401
  if (keys.length === 0)
35297
35402
  return "no api keys; create one with: omni keys create";
@@ -35303,6 +35408,7 @@ var keysList = {
35303
35408
  { header: "LIMITS" },
35304
35409
  { header: "BODY CAPTURE" },
35305
35410
  { header: "STATE" },
35411
+ { header: "EXPIRES" },
35306
35412
  { header: "CREATED" }
35307
35413
  ], keys.map((key) => [
35308
35414
  key.id,
@@ -35311,28 +35417,32 @@ var keysList = {
35311
35417
  key.modelAllowlist === null ? "all" : key.modelAllowlist.length === 0 ? "none" : key.modelAllowlist.join(","),
35312
35418
  key.limits === null ? paint(ctx, "red", "unreadable") : summarizeLimits(key.limits),
35313
35419
  key.bodyLoggingOptOut ? "no bodies" : "\u2014",
35314
- state(ctx, key.revokedAt === null, key.revokedAt === null ? "active" : "revoked"),
35420
+ ((verdict) => state(ctx, verdict === "active", verdict))(keyState(key, now)),
35421
+ formatTime(key.expiresAt),
35315
35422
  formatTime(key.createdAt)
35316
35423
  ]));
35317
35424
  });
35318
35425
  }
35319
35426
  };
35320
35427
  var keysCreate = {
35321
- usage: "keys create [--label L] [--allow <model> ...] [--limit <d>:<w>=N ...] [--no-bodies]",
35428
+ usage: "keys create [--label L] [--allow <model> ...] [--limit <d>:<w>=N ...] [--expires <when>] [--no-bodies]",
35322
35429
  summary: "Mint a gateway API key, printed once",
35323
35430
  options: {
35324
35431
  label: { type: "string" },
35325
35432
  allow: { type: "string", multiple: true },
35326
35433
  limit: { type: "string", multiple: true },
35434
+ expires: { type: "string" },
35327
35435
  "no-bodies": { type: "boolean" }
35328
35436
  },
35329
35437
  async run(args, { ctx, writer }) {
35330
35438
  const allow = listFlag(args.values, "allow");
35439
+ const expires = stringFlag(args.values, "expires");
35331
35440
  const created = await createKey(await ctx.store(), {
35332
35441
  ...stringFlag(args.values, "label") === undefined ? {} : { label: stringFlag(args.values, "label") },
35333
35442
  modelAllowlist: allow ?? null,
35334
35443
  limits: parseLimitFlags(listFlag(args.values, "limit") ?? [], "--limit"),
35335
- bodyLoggingOptOut: boolFlag(args.values, "no-bodies")
35444
+ bodyLoggingOptOut: boolFlag(args.values, "no-bodies"),
35445
+ expiresAt: expires === undefined ? null : parseWhen("--expires", expires)
35336
35446
  });
35337
35447
  emit(ctx, writer, created, () => {
35338
35448
  note(ctx, writer, paint(ctx, "yellow", "this key is shown once and stored only as a hash"));
@@ -35399,6 +35509,44 @@ ${paint(ctx, "dim", "usage is counted from completed requests still inside each
35399
35509
  });
35400
35510
  }
35401
35511
  };
35512
+ var keysExpiry = {
35513
+ usage: "keys expiry <id> [--at <when>] [--never]",
35514
+ summary: "Show or replace one key's expiry",
35515
+ options: {
35516
+ at: { type: "string" },
35517
+ never: { type: "boolean" }
35518
+ },
35519
+ async run(args, { ctx, writer }) {
35520
+ const id = requirePositional(args, 0, "key id");
35521
+ const at = stringFlag(args.values, "at");
35522
+ const never2 = boolFlag(args.values, "never");
35523
+ const store = await ctx.store();
35524
+ const existing = (await listKeys(store)).find((entry) => entry.id === id);
35525
+ if (existing === undefined)
35526
+ throw new CliError(`no api key "${id}"`);
35527
+ let key = existing;
35528
+ if (never2 || at !== undefined) {
35529
+ if (never2 && at !== undefined) {
35530
+ throw new UsageError("--at and --never cannot be combined");
35531
+ }
35532
+ const next = never2 ? null : parseWhen("--at", at);
35533
+ key = await setKeyExpiry(store, id, { expiresAt: next });
35534
+ }
35535
+ emit(ctx, writer, key, () => {
35536
+ const head = fields([
35537
+ ["id", key.id],
35538
+ ["label", key.label],
35539
+ ["prefix", `${key.prefix}\u2026`]
35540
+ ]);
35541
+ if (key.expiresAt === null)
35542
+ return `${head}
35543
+ expires: never`;
35544
+ const verdict = keyState(key, Date.now());
35545
+ return `${head}
35546
+ expires: ${formatTime(key.expiresAt)}${verdict === "expired" ? ` ${paint(ctx, "red", "(expired)")}` : ""}`;
35547
+ });
35548
+ }
35549
+ };
35402
35550
  var keysRevoke = {
35403
35551
  usage: "keys revoke <id>",
35404
35552
  summary: "Revoke a gateway API key, keeping its usage history",
@@ -35894,7 +36042,7 @@ function createServiceDeps(input2) {
35894
36042
  }
35895
36043
 
35896
36044
  // apps/cli/src/version.ts
35897
- var VERSION = "0.11.0";
36045
+ var VERSION = "0.11.1";
35898
36046
 
35899
36047
  // apps/cli/src/commands/service.ts
35900
36048
  function gatewayArgv(root) {
@@ -36494,7 +36642,7 @@ var logs = {
36494
36642
  return;
36495
36643
  let seen = first[0]?.at ?? 0;
36496
36644
  for (;; ) {
36497
- await Bun.sleep(2000);
36645
+ await Bun.sleep(FOLLOW_INTERVAL_MS);
36498
36646
  const next = (await recentLogs(store, limit)).filter((row) => row.at > seen);
36499
36647
  if (next.length === 0)
36500
36648
  continue;
@@ -36538,6 +36686,7 @@ var COMMANDS = {
36538
36686
  "keys create": keysCreate,
36539
36687
  "keys limits": keysLimits,
36540
36688
  "keys models": keysModels,
36689
+ "keys expiry": keysExpiry,
36541
36690
  "keys revoke": keysRevoke,
36542
36691
  "plugin list": pluginList,
36543
36692
  "plugin verify": pluginVerify,