ollama-agent-router 0.1.7 → 0.1.8

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.
package/README.md CHANGED
@@ -94,6 +94,8 @@ Start with:
94
94
  ollama-agent-router serve --config examples/gex44.yaml
95
95
  ```
96
96
 
97
+ `examples/gex44-secured.yaml` is the same hardware profile with the standalone plane locked down: API key required, anonymous access rejected, per-key rate limits, and the admin plane enabled on localhost. Use it as a starting point when the router is exposed beyond a single user or process.
98
+
97
99
  ## Config Reference
98
100
 
99
101
  Lookup order:
@@ -252,17 +254,22 @@ This prevents the admin API from changing the rules that protect itself. When `a
252
254
 
253
255
  Admin API:
254
256
 
257
+ **Read the current managed access config**
258
+
255
259
  ```bash
256
260
  curl http://127.0.0.1:11435/v1/admin/access/config \
257
261
  -H 'authorization: Bearer admin-secret'
262
+ ```
263
+
264
+ **Replace the entire managed access config** (planes + all keys at once)
258
265
 
266
+ ```bash
259
267
  curl -X PUT http://127.0.0.1:11435/v1/admin/access/config \
260
268
  -H 'authorization: Bearer admin-secret' \
261
269
  -H 'content-type: application/json' \
262
270
  -d '{
263
271
  "expectedVersion": 1,
264
272
  "config": {
265
- "version": 1,
266
273
  "planes": {
267
274
  "standalone": {
268
275
  "enabled": true,
@@ -280,7 +287,51 @@ curl -X PUT http://127.0.0.1:11435/v1/admin/access/config \
280
287
  }'
281
288
  ```
282
289
 
283
- The admin `PUT` supports optimistic concurrency. If `expectedVersion` is present and does not match the active managed access config, the router returns `409`.
290
+ `expectedVersion` enables optimistic concurrency. If present and the value does not match the active managed config version, the router returns `409`.
291
+
292
+ **Add an API key**
293
+
294
+ Generate a key and its SHA-256 hash first:
295
+
296
+ ```bash
297
+ node -e "
298
+ const c = require('crypto'), k = 'onr-' + c.randomBytes(20).toString('hex');
299
+ console.log('key: ', k);
300
+ console.log('hash: sha256:' + c.createHash('sha256').update(k).digest('hex'));
301
+ "
302
+ ```
303
+
304
+ Then add the key:
305
+
306
+ ```bash
307
+ curl -X POST http://127.0.0.1:11435/v1/admin/access/keys \
308
+ -H 'authorization: Bearer admin-secret' \
309
+ -H 'content-type: application/json' \
310
+ -d '{
311
+ "id": "user-alice",
312
+ "name": "Alice",
313
+ "keyHash": "sha256:<hash>",
314
+ "scopes": ["standalone"],
315
+ "limits": {
316
+ "standalone": {"requests": 100, "windowSeconds": 60}
317
+ }
318
+ }'
319
+ ```
320
+
321
+ Returns `201` with the created key entry. Returns `409` if the `id` is already in use.
322
+
323
+ `scopes` controls which planes accept the key. Valid values are `standalone`, `runtimeAgent`, or both. `limits` is optional; when omitted, the plane's `defaultLimit` applies.
324
+
325
+ **Revoke an API key**
326
+
327
+ ```bash
328
+ curl -X DELETE http://127.0.0.1:11435/v1/admin/access/keys/user-alice \
329
+ -H 'authorization: Bearer admin-secret'
330
+ ```
331
+
332
+ Returns `200 { "revoked": { ... } }` with the removed key entry. Returns `404` if the id is not found. The change takes effect immediately without a restart.
333
+
334
+ All admin operations are written atomically to `access.managedConfigPath` and appended to the audit log when `auditLog: true`.
284
335
 
285
336
  For admin client certificate checks, enable HTTPS, configure `server.https.caPath`, and set:
286
337
 
package/dist/cli.js CHANGED
@@ -44,6 +44,17 @@ var protectedPlaneConfigSchema = z.object({
44
44
  }).default({ requireApiKey: false, anonymous: "allow" }),
45
45
  defaultLimit: trafficLimitSchema.optional()
46
46
  });
47
+ var apiKeySchema = z.object({
48
+ id: z.string().min(1).regex(/^[a-zA-Z0-9._:-]+$/, "api key id may contain only letters, numbers, dots, underscores, colons, and dashes"),
49
+ name: z.string().min(1).optional(),
50
+ keyHash: z.string().regex(/^sha256:[a-fA-F0-9]{64}$/, "keyHash must use sha256:<64 hex chars>"),
51
+ enabled: z.boolean().default(true),
52
+ scopes: z.array(z.enum(["standalone", "runtimeAgent"])).min(1),
53
+ limits: z.object({
54
+ standalone: trafficLimitSchema.optional(),
55
+ runtimeAgent: trafficLimitSchema.optional()
56
+ }).optional()
57
+ });
47
58
  var managedAccessConfigSchema = z.object({
48
59
  version: z.number().int().nonnegative().default(1),
49
60
  updatedAt: z.string().datetime().optional(),
@@ -60,19 +71,7 @@ var managedAccessConfigSchema = z.object({
60
71
  standalone: { enabled: true, auth: { requireApiKey: false, anonymous: "allow" } },
61
72
  runtimeAgent: { enabled: true, auth: { requireApiKey: false, anonymous: "allow" } }
62
73
  }),
63
- apiKeys: z.array(
64
- z.object({
65
- id: z.string().min(1).regex(/^[a-zA-Z0-9._:-]+$/, "api key id may contain only letters, numbers, dots, underscores, colons, and dashes"),
66
- name: z.string().min(1).optional(),
67
- keyHash: z.string().regex(/^sha256:[a-fA-F0-9]{64}$/, "keyHash must use sha256:<64 hex chars>"),
68
- enabled: z.boolean().default(true),
69
- scopes: z.array(z.enum(["standalone", "runtimeAgent"])).min(1),
70
- limits: z.object({
71
- standalone: trafficLimitSchema.optional(),
72
- runtimeAgent: trafficLimitSchema.optional()
73
- }).optional()
74
- })
75
- ).default([])
74
+ apiKeys: z.array(apiKeySchema).default([])
76
75
  });
77
76
  var defaultManagedAccessConfig = managedAccessConfigSchema.parse({});
78
77
  var adminPlaneConfigSchema = z.object({
@@ -1652,6 +1651,51 @@ var AccessControlStore = class {
1652
1651
  });
1653
1652
  return structuredClone(updated);
1654
1653
  }
1654
+ async addApiKey(input2) {
1655
+ const key = apiKeySchema.parse(input2);
1656
+ await this.enqueueWrite(async () => {
1657
+ if (this.managed.apiKeys.some((k) => k.id === key.id)) {
1658
+ throw new AccessHttpError(409, `API key with id '${key.id}' already exists`);
1659
+ }
1660
+ if (!this.access.managedConfigPath) {
1661
+ throw new AccessHttpError(500, "access.managedConfigPath is not configured");
1662
+ }
1663
+ const next = {
1664
+ ...this.managed,
1665
+ version: this.managed.version + 1,
1666
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1667
+ apiKeys: [...this.managed.apiKeys, key]
1668
+ };
1669
+ await writeManagedAccessConfig(this.access.managedConfigPath, next);
1670
+ this.managed = next;
1671
+ this.access.managed = next;
1672
+ });
1673
+ return structuredClone(key);
1674
+ }
1675
+ async revokeApiKey(id) {
1676
+ let removed;
1677
+ await this.enqueueWrite(async () => {
1678
+ const idx = this.managed.apiKeys.findIndex((k) => k.id === id);
1679
+ if (idx === -1) {
1680
+ throw new AccessHttpError(404, `API key '${id}' not found`);
1681
+ }
1682
+ if (!this.access.managedConfigPath) {
1683
+ throw new AccessHttpError(500, "access.managedConfigPath is not configured");
1684
+ }
1685
+ removed = this.managed.apiKeys[idx];
1686
+ const next = {
1687
+ ...this.managed,
1688
+ version: this.managed.version + 1,
1689
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1690
+ apiKeys: this.managed.apiKeys.filter((_, i) => i !== idx)
1691
+ };
1692
+ await writeManagedAccessConfig(this.access.managedConfigPath, next);
1693
+ this.managed = next;
1694
+ this.access.managed = next;
1695
+ this.limiter.clear();
1696
+ });
1697
+ return structuredClone(removed);
1698
+ }
1655
1699
  publicMiddleware(planeOrPlanes) {
1656
1700
  return (req, res, next) => {
1657
1701
  const planes = Array.isArray(planeOrPlanes) ? planeOrPlanes : [planeOrPlanes];
@@ -1962,6 +2006,26 @@ function createApp(config, deps) {
1962
2006
  next(error);
1963
2007
  }
1964
2008
  });
2009
+ api.post("/v1/admin/access/keys", adminAccess, async (req, res, next) => {
2010
+ try {
2011
+ const key = await access3.addApiKey(req.body);
2012
+ auditAdmin(config.access.admin, req, "success", "key_added", res.locals.admin?.remoteIp, key.id);
2013
+ res.status(201).json(key);
2014
+ } catch (error) {
2015
+ auditAdmin(config.access.admin, req, "failure", error instanceof Error ? error.message : String(error), res.locals.admin?.remoteIp);
2016
+ next(error);
2017
+ }
2018
+ });
2019
+ api.delete("/v1/admin/access/keys/:id", adminAccess, async (req, res, next) => {
2020
+ try {
2021
+ const revoked = await access3.revokeApiKey(req.params.id);
2022
+ auditAdmin(config.access.admin, req, "success", "key_revoked", res.locals.admin?.remoteIp, revoked.id);
2023
+ res.json({ revoked });
2024
+ } catch (error) {
2025
+ auditAdmin(config.access.admin, req, "failure", error instanceof Error ? error.message : String(error), res.locals.admin?.remoteIp);
2026
+ next(error);
2027
+ }
2028
+ });
1965
2029
  api.get("/v1/router/capabilities", runtimeAgentAccess, (_req, res) => {
1966
2030
  res.json(buildCapabilities(config));
1967
2031
  });