codeharbor 0.1.14 → 0.1.15

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/.env.example CHANGED
@@ -74,6 +74,11 @@ ADMIN_PORT=8787
74
74
  # Strongly recommended for any non-localhost access.
75
75
  # Required when exposing admin via reverse proxy/tunnel/public domain.
76
76
  ADMIN_TOKEN=
77
+ # Optional multi-token RBAC (JSON array).
78
+ # Each item: {"token":"...","role":"admin|viewer","actor":"ops-name"}
79
+ # Example:
80
+ # ADMIN_TOKENS_JSON=[{"token":"admin-secret","role":"admin","actor":"ops-admin"},{"token":"viewer-secret","role":"viewer","actor":"ops-audit"}]
81
+ ADMIN_TOKENS_JSON=
77
82
  # Optional IP allowlist (comma-separated, for example: 127.0.0.1,192.168.1.10).
78
83
  ADMIN_IP_ALLOWLIST=
79
84
  # Optional browser origin allowlist for CORS (comma-separated).
package/README.md CHANGED
@@ -311,7 +311,7 @@ Optional overrides:
311
311
  codeharbor admin serve --host 127.0.0.1 --port 8787
312
312
  ```
313
313
 
314
- If you bind Admin to a non-loopback host and `ADMIN_TOKEN` is empty, startup is rejected by default.
314
+ If you bind Admin to a non-loopback host and both `ADMIN_TOKEN` and `ADMIN_TOKENS_JSON` are empty, startup is rejected by default.
315
315
  Explicit bypass exists but is not recommended:
316
316
 
317
317
  ```bash
@@ -336,7 +336,7 @@ Main endpoints:
336
336
  - `GET /api/admin/health`
337
337
  - `GET /api/admin/audit?limit=50`
338
338
 
339
- When `ADMIN_TOKEN` is set, requests must include:
339
+ When `ADMIN_TOKEN` or `ADMIN_TOKENS_JSON` is set, requests must include:
340
340
 
341
341
  ```http
342
342
  Authorization: Bearer <ADMIN_TOKEN>
@@ -345,9 +345,16 @@ Authorization: Bearer <ADMIN_TOKEN>
345
345
  Access control options:
346
346
 
347
347
  - `ADMIN_TOKEN`: require bearer token for `/api/admin/*`
348
+ - `ADMIN_TOKENS_JSON`: optional multi-token RBAC list (supports `admin` and `viewer` roles)
348
349
  - `ADMIN_IP_ALLOWLIST`: optional comma-separated client IP whitelist (for example `127.0.0.1,192.168.1.10`)
349
350
  - `ADMIN_ALLOWED_ORIGINS`: optional CORS origin allowlist for browser-based cross-origin admin access
350
351
 
352
+ RBAC behavior:
353
+
354
+ - `viewer` tokens can call read endpoints (`GET /api/admin/*`)
355
+ - `admin` tokens can call read + write endpoints (`PUT/POST/DELETE /api/admin/*`)
356
+ - for `ADMIN_TOKENS_JSON`, audit actor is derived from token identity (`actor` field), not `x-admin-actor`
357
+
351
358
  Note: `PUT /api/admin/config/global` writes to `.env` and marks changes as restart-required.
352
359
 
353
360
  ### Admin UI Quick Walkthrough
package/dist/cli.js CHANGED
@@ -646,6 +646,7 @@ var AdminServer = class {
646
646
  host;
647
647
  port;
648
648
  adminToken;
649
+ adminTokens;
649
650
  adminIpAllowlist;
650
651
  adminAllowedOrigins;
651
652
  cwd;
@@ -662,6 +663,7 @@ var AdminServer = class {
662
663
  this.host = options.host;
663
664
  this.port = options.port;
664
665
  this.adminToken = options.adminToken;
666
+ this.adminTokens = buildAdminTokenMap(options.adminTokens ?? []);
665
667
  this.adminIpAllowlist = normalizeAllowlist(options.adminIpAllowlist ?? []);
666
668
  this.adminAllowedOrigins = normalizeOriginAllowlist(options.adminAllowedOrigins ?? []);
667
669
  this.cwd = options.cwd ?? process.cwd();
@@ -753,10 +755,19 @@ var AdminServer = class {
753
755
  this.sendHtml(res, renderAdminConsoleHtml());
754
756
  return;
755
757
  }
756
- if (url.pathname.startsWith("/api/admin/") && !this.isAuthorized(req)) {
758
+ const requiredRole = requiredAdminRoleForRequest(req.method, url.pathname);
759
+ const authIdentity = requiredRole ? this.resolveAdminIdentity(req) : null;
760
+ if (requiredRole && !authIdentity) {
757
761
  this.sendJson(res, 401, {
758
762
  ok: false,
759
- error: "Unauthorized. Provide Authorization: Bearer <ADMIN_TOKEN>."
763
+ error: "Unauthorized. Provide Authorization: Bearer <ADMIN_TOKEN> (or token from ADMIN_TOKENS_JSON)."
764
+ });
765
+ return;
766
+ }
767
+ if (requiredRole && authIdentity && !hasRequiredAdminRole(authIdentity.role, requiredRole)) {
768
+ this.sendJson(res, 403, {
769
+ ok: false,
770
+ error: "Forbidden. This endpoint requires admin write permission."
760
771
  });
761
772
  return;
762
773
  }
@@ -770,7 +781,7 @@ var AdminServer = class {
770
781
  }
771
782
  if (req.method === "PUT" && url.pathname === "/api/admin/config/global") {
772
783
  const body = await readJsonBody(req);
773
- const actor = readActor(req);
784
+ const actor = resolveAuditActor(req, authIdentity);
774
785
  const result = this.updateGlobalConfig(body, actor);
775
786
  this.sendJson(res, 200, {
776
787
  ok: true,
@@ -798,13 +809,13 @@ var AdminServer = class {
798
809
  }
799
810
  if (req.method === "PUT") {
800
811
  const body = await readJsonBody(req);
801
- const actor = readActor(req);
812
+ const actor = resolveAuditActor(req, authIdentity);
802
813
  const room = this.updateRoomConfig(roomId, body, actor);
803
814
  this.sendJson(res, 200, { ok: true, data: room });
804
815
  return;
805
816
  }
806
817
  if (req.method === "DELETE") {
807
- const actor = readActor(req);
818
+ const actor = resolveAuditActor(req, authIdentity);
808
819
  this.configService.deleteRoomSettings(roomId, actor);
809
820
  this.sendJson(res, 200, { ok: true, roomId });
810
821
  return;
@@ -834,7 +845,7 @@ var AdminServer = class {
834
845
  if (req.method === "POST" && url.pathname === "/api/admin/service/restart") {
835
846
  const body = asObject(await readJsonBody(req), "service restart payload");
836
847
  const restartAdmin = normalizeBoolean(body.withAdmin, false);
837
- const actor = readActor(req);
848
+ const actor = resolveAuditActor(req, authIdentity);
838
849
  try {
839
850
  const result = await this.restartServices(restartAdmin);
840
851
  this.stateStore.appendConfigRevision(
@@ -1092,14 +1103,34 @@ var AdminServer = class {
1092
1103
  summary: normalizeOptionalString(body.summary)
1093
1104
  });
1094
1105
  }
1095
- isAuthorized(req) {
1096
- if (!this.adminToken) {
1097
- return true;
1106
+ resolveAdminIdentity(req) {
1107
+ if (!this.adminToken && this.adminTokens.size === 0) {
1108
+ return {
1109
+ role: "admin",
1110
+ actor: null,
1111
+ source: "open"
1112
+ };
1098
1113
  }
1099
- const authorization = req.headers.authorization ?? "";
1100
- const bearer = authorization.startsWith("Bearer ") ? authorization.slice("Bearer ".length).trim() : "";
1101
- const fromHeader = normalizeHeaderValue(req.headers["x-admin-token"]);
1102
- return bearer === this.adminToken || fromHeader === this.adminToken;
1114
+ const token = readAdminToken(req);
1115
+ if (!token) {
1116
+ return null;
1117
+ }
1118
+ if (this.adminToken && token === this.adminToken) {
1119
+ return {
1120
+ role: "admin",
1121
+ actor: null,
1122
+ source: "legacy"
1123
+ };
1124
+ }
1125
+ const mappedIdentity = this.adminTokens.get(token);
1126
+ if (!mappedIdentity) {
1127
+ return null;
1128
+ }
1129
+ return {
1130
+ role: mappedIdentity.role,
1131
+ actor: mappedIdentity.actor,
1132
+ source: "scoped"
1133
+ };
1103
1134
  }
1104
1135
  isClientAllowed(req) {
1105
1136
  if (this.adminIpAllowlist.length === 0) {
@@ -1391,10 +1422,54 @@ function normalizeHeaderValue(value) {
1391
1422
  }
1392
1423
  return value.trim();
1393
1424
  }
1394
- function readActor(req) {
1425
+ function readAdminToken(req) {
1426
+ const authorization = normalizeHeaderValue(req.headers.authorization);
1427
+ if (authorization) {
1428
+ const match = /^bearer\s+(.+)$/i.exec(authorization);
1429
+ const token = match?.[1]?.trim() ?? "";
1430
+ if (token) {
1431
+ return token;
1432
+ }
1433
+ }
1434
+ const fromHeader = normalizeHeaderValue(req.headers["x-admin-token"]);
1435
+ return fromHeader || null;
1436
+ }
1437
+ function resolveAuditActor(req, identity) {
1438
+ if (identity?.source === "scoped") {
1439
+ if (identity.actor) {
1440
+ return identity.actor;
1441
+ }
1442
+ return identity.role === "admin" ? "admin-token" : "viewer-token";
1443
+ }
1395
1444
  const actor = normalizeHeaderValue(req.headers["x-admin-actor"]);
1396
1445
  return actor || null;
1397
1446
  }
1447
+ function requiredAdminRoleForRequest(method, pathname) {
1448
+ if (!pathname.startsWith("/api/admin/")) {
1449
+ return null;
1450
+ }
1451
+ const normalizedMethod = (method ?? "GET").toUpperCase();
1452
+ if (normalizedMethod === "GET" || normalizedMethod === "HEAD") {
1453
+ return "viewer";
1454
+ }
1455
+ return "admin";
1456
+ }
1457
+ function hasRequiredAdminRole(role, requiredRole) {
1458
+ if (requiredRole === "viewer") {
1459
+ return role === "viewer" || role === "admin";
1460
+ }
1461
+ return role === "admin";
1462
+ }
1463
+ function buildAdminTokenMap(tokens) {
1464
+ const mapped = /* @__PURE__ */ new Map();
1465
+ for (const token of tokens) {
1466
+ mapped.set(token.token, {
1467
+ role: token.role,
1468
+ actor: token.actor
1469
+ });
1470
+ }
1471
+ return mapped;
1472
+ }
1398
1473
  function formatError(error) {
1399
1474
  if (error instanceof Error) {
1400
1475
  return error.message;
@@ -5573,6 +5648,7 @@ var CodeHarborAdminApp = class {
5573
5648
  host: options?.host ?? config.adminBindHost,
5574
5649
  port: options?.port ?? config.adminPort,
5575
5650
  adminToken: config.adminToken,
5651
+ adminTokens: config.adminTokens,
5576
5652
  adminIpAllowlist: config.adminIpAllowlist,
5577
5653
  adminAllowedOrigins: config.adminAllowedOrigins
5578
5654
  });
@@ -5583,7 +5659,7 @@ var CodeHarborAdminApp = class {
5583
5659
  this.logger.info("CodeHarbor admin server started", {
5584
5660
  host: address?.host ?? this.config.adminBindHost,
5585
5661
  port: address?.port ?? this.config.adminPort,
5586
- tokenProtected: Boolean(this.config.adminToken)
5662
+ tokenProtected: Boolean(this.config.adminToken) || this.config.adminTokens.length > 0
5587
5663
  });
5588
5664
  }
5589
5665
  async stop() {
@@ -5688,6 +5764,7 @@ var configSchema = import_zod.z.object({
5688
5764
  ADMIN_BIND_HOST: import_zod.z.string().default("127.0.0.1"),
5689
5765
  ADMIN_PORT: import_zod.z.string().default("8787").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().min(1).max(65535)),
5690
5766
  ADMIN_TOKEN: import_zod.z.string().default(""),
5767
+ ADMIN_TOKENS_JSON: import_zod.z.string().default(""),
5691
5768
  ADMIN_IP_ALLOWLIST: import_zod.z.string().default(""),
5692
5769
  ADMIN_ALLOWED_ORIGINS: import_zod.z.string().default(""),
5693
5770
  LOG_LEVEL: import_zod.z.enum(["debug", "info", "warn", "error"]).default("info")
@@ -5747,6 +5824,7 @@ var configSchema = import_zod.z.object({
5747
5824
  adminBindHost: v.ADMIN_BIND_HOST.trim() || "127.0.0.1",
5748
5825
  adminPort: v.ADMIN_PORT,
5749
5826
  adminToken: v.ADMIN_TOKEN.trim() || null,
5827
+ adminTokens: parseAdminTokens(v.ADMIN_TOKENS_JSON),
5750
5828
  adminIpAllowlist: parseCsvList(v.ADMIN_IP_ALLOWLIST),
5751
5829
  adminAllowedOrigins: parseCsvList(v.ADMIN_ALLOWED_ORIGINS),
5752
5830
  logLevel: v.LOG_LEVEL
@@ -5837,6 +5915,53 @@ function parseExtraEnv(raw) {
5837
5915
  function parseCsvList(raw) {
5838
5916
  return raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
5839
5917
  }
5918
+ function parseAdminTokens(raw) {
5919
+ const trimmed = raw.trim();
5920
+ if (!trimmed) {
5921
+ return [];
5922
+ }
5923
+ let parsed;
5924
+ try {
5925
+ parsed = JSON.parse(trimmed);
5926
+ } catch {
5927
+ throw new Error("ADMIN_TOKENS_JSON must be valid JSON.");
5928
+ }
5929
+ if (!Array.isArray(parsed)) {
5930
+ throw new Error("ADMIN_TOKENS_JSON must be a JSON array.");
5931
+ }
5932
+ const seenTokens = /* @__PURE__ */ new Set();
5933
+ return parsed.map((entry, index) => {
5934
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
5935
+ throw new Error(`ADMIN_TOKENS_JSON[${index}] must be an object.`);
5936
+ }
5937
+ const payload = entry;
5938
+ const tokenValue = payload.token;
5939
+ if (typeof tokenValue !== "string" || !tokenValue.trim()) {
5940
+ throw new Error(`ADMIN_TOKENS_JSON[${index}].token must be a non-empty string.`);
5941
+ }
5942
+ const token = tokenValue.trim();
5943
+ if (seenTokens.has(token)) {
5944
+ throw new Error(`ADMIN_TOKENS_JSON contains duplicated token at index ${index}.`);
5945
+ }
5946
+ seenTokens.add(token);
5947
+ let role = "admin";
5948
+ if (payload.role !== void 0) {
5949
+ if (payload.role !== "admin" && payload.role !== "viewer") {
5950
+ throw new Error(`ADMIN_TOKENS_JSON[${index}].role must be "admin" or "viewer".`);
5951
+ }
5952
+ role = payload.role;
5953
+ }
5954
+ if (payload.actor !== void 0 && payload.actor !== null && typeof payload.actor !== "string") {
5955
+ throw new Error(`ADMIN_TOKENS_JSON[${index}].actor must be a string when provided.`);
5956
+ }
5957
+ const actor = typeof payload.actor === "string" ? payload.actor.trim() || null : null;
5958
+ return {
5959
+ token,
5960
+ role,
5961
+ actor
5962
+ };
5963
+ });
5964
+ }
5840
5965
 
5841
5966
  // src/config-snapshot.ts
5842
5967
  var import_node_fs9 = __toESM(require("fs"));
@@ -5891,6 +6016,7 @@ var CONFIG_SNAPSHOT_ENV_KEYS = [
5891
6016
  "ADMIN_BIND_HOST",
5892
6017
  "ADMIN_PORT",
5893
6018
  "ADMIN_TOKEN",
6019
+ "ADMIN_TOKENS_JSON",
5894
6020
  "ADMIN_IP_ALLOWLIST",
5895
6021
  "ADMIN_ALLOWED_ORIGINS",
5896
6022
  "LOG_LEVEL"
@@ -5955,6 +6081,7 @@ var envSnapshotSchema = import_zod2.z.object({
5955
6081
  ADMIN_BIND_HOST: import_zod2.z.string(),
5956
6082
  ADMIN_PORT: integerStringSchema("ADMIN_PORT", 1, 65535),
5957
6083
  ADMIN_TOKEN: import_zod2.z.string(),
6084
+ ADMIN_TOKENS_JSON: jsonArrayStringSchema("ADMIN_TOKENS_JSON", true).default(""),
5958
6085
  ADMIN_IP_ALLOWLIST: import_zod2.z.string(),
5959
6086
  ADMIN_ALLOWED_ORIGINS: import_zod2.z.string().default(""),
5960
6087
  LOG_LEVEL: import_zod2.z.enum(LOG_LEVELS)
@@ -6143,6 +6270,7 @@ function buildSnapshotEnv(config) {
6143
6270
  ADMIN_BIND_HOST: config.adminBindHost,
6144
6271
  ADMIN_PORT: String(config.adminPort),
6145
6272
  ADMIN_TOKEN: config.adminToken ?? "",
6273
+ ADMIN_TOKENS_JSON: serializeAdminTokens(config.adminTokens),
6146
6274
  ADMIN_IP_ALLOWLIST: config.adminIpAllowlist.join(","),
6147
6275
  ADMIN_ALLOWED_ORIGINS: config.adminAllowedOrigins.join(","),
6148
6276
  LOG_LEVEL: config.logLevel
@@ -6232,6 +6360,9 @@ function parseIntStrict(raw) {
6232
6360
  function serializeJsonObject(value) {
6233
6361
  return Object.keys(value).length > 0 ? JSON.stringify(value) : "";
6234
6362
  }
6363
+ function serializeAdminTokens(tokens) {
6364
+ return tokens.length > 0 ? JSON.stringify(tokens) : "";
6365
+ }
6235
6366
  function booleanStringSchema(key) {
6236
6367
  return import_zod2.z.string().refine((value) => BOOLEAN_STRING.test(value), {
6237
6368
  message: `${key} must be a boolean string (true/false).`
@@ -6269,6 +6400,23 @@ function jsonObjectStringSchema(key, allowEmpty) {
6269
6400
  message: `${key} must be an empty string or a JSON object string.`
6270
6401
  });
6271
6402
  }
6403
+ function jsonArrayStringSchema(key, allowEmpty) {
6404
+ return import_zod2.z.string().refine((value) => {
6405
+ const trimmed = value.trim();
6406
+ if (!trimmed) {
6407
+ return allowEmpty;
6408
+ }
6409
+ let parsed;
6410
+ try {
6411
+ parsed = JSON.parse(trimmed);
6412
+ } catch {
6413
+ return false;
6414
+ }
6415
+ return Array.isArray(parsed);
6416
+ }, {
6417
+ message: `${key} must be an empty string or a JSON array string.`
6418
+ });
6419
+ }
6272
6420
 
6273
6421
  // src/preflight.ts
6274
6422
  var import_node_child_process6 = require("child_process");
@@ -6461,11 +6609,12 @@ admin.command("serve").description("Start admin config API server").option("--ho
6461
6609
  const host = options.host?.trim() || config.adminBindHost;
6462
6610
  const port = options.port ? parsePortOption(options.port, config.adminPort) : config.adminPort;
6463
6611
  const allowInsecureNoToken = options.allowInsecureNoToken ?? false;
6464
- if (!config.adminToken && !allowInsecureNoToken && isNonLoopbackHost(host)) {
6612
+ const hasAdminAuth = Boolean(config.adminToken) || config.adminTokens.length > 0;
6613
+ if (!hasAdminAuth && !allowInsecureNoToken && isNonLoopbackHost(host)) {
6465
6614
  process.stderr.write(
6466
6615
  [
6467
- "Refusing to start admin server on non-loopback host without ADMIN_TOKEN.",
6468
- "Fix: set ADMIN_TOKEN in .env, or explicitly pass --allow-insecure-no-token.",
6616
+ "Refusing to start admin server on non-loopback host without admin auth token.",
6617
+ "Fix: set ADMIN_TOKEN or ADMIN_TOKENS_JSON in .env, or explicitly pass --allow-insecure-no-token.",
6469
6618
  ""
6470
6619
  ].join("\n")
6471
6620
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeharbor",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Instant-messaging bridge for Codex CLI sessions",
5
5
  "license": "MIT",
6
6
  "main": "dist/cli.js",