@tokenbuddy/tb-admin 1.0.30 → 1.0.32

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 (52) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +280 -19
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/client.d.ts +82 -2
  5. package/dist/src/client.d.ts.map +1 -1
  6. package/dist/src/client.js +93 -0
  7. package/dist/src/client.js.map +1 -1
  8. package/dist/src/provider.d.ts +120 -0
  9. package/dist/src/provider.d.ts.map +1 -0
  10. package/dist/src/provider.js +73 -0
  11. package/dist/src/provider.js.map +1 -0
  12. package/dist/src/seller.d.ts +104 -0
  13. package/dist/src/seller.d.ts.map +1 -0
  14. package/dist/src/seller.js +283 -0
  15. package/dist/src/seller.js.map +1 -0
  16. package/dist/src/ui-actions.d.ts +25 -0
  17. package/dist/src/ui-actions.d.ts.map +1 -1
  18. package/dist/src/ui-actions.js +81 -11
  19. package/dist/src/ui-actions.js.map +1 -1
  20. package/dist/src/ui-server.js +9 -0
  21. package/dist/src/ui-server.js.map +1 -1
  22. package/dist/src/ui-state.d.ts +77 -2
  23. package/dist/src/ui-state.d.ts.map +1 -1
  24. package/dist/src/ui-state.js +242 -14
  25. package/dist/src/ui-state.js.map +1 -1
  26. package/dist/src/ui-static.d.ts.map +1 -1
  27. package/dist/src/ui-static.js +95 -17
  28. package/dist/src/ui-static.js.map +1 -1
  29. package/dist/src/vendor-client.d.ts +23 -0
  30. package/dist/src/vendor-client.d.ts.map +1 -0
  31. package/dist/src/vendor-client.js +2 -0
  32. package/dist/src/vendor-client.js.map +1 -0
  33. package/dist/src/vendor-commands.d.ts +35 -0
  34. package/dist/src/vendor-commands.d.ts.map +1 -0
  35. package/dist/src/vendor-commands.js +33 -0
  36. package/dist/src/vendor-commands.js.map +1 -0
  37. package/package.json +1 -1
  38. package/src/cli.ts +305 -31
  39. package/src/client.ts +119 -2
  40. package/src/provider.ts +150 -0
  41. package/src/seller.ts +362 -0
  42. package/src/ui-actions.ts +89 -11
  43. package/src/ui-server.ts +9 -0
  44. package/src/ui-state.ts +293 -15
  45. package/src/ui-static.ts +95 -17
  46. package/src/vendor-client.ts +23 -0
  47. package/src/vendor-commands.ts +65 -0
  48. package/tests/admin.test.ts +20 -1
  49. package/tests/seller.test.ts +307 -0
  50. package/tests/ui-state-fleet.test.ts +257 -0
  51. package/tests/ui-static-row.test.ts +202 -0
  52. package/tests/vendor-cli.test.ts +197 -0
package/src/cli.ts CHANGED
@@ -2,6 +2,7 @@ import { Command } from "commander";
2
2
  import { ConfigManager } from "./config.js";
3
3
  import { AdminClient } from "./client.js";
4
4
  import { FlyProvider } from "./server-cmd.js";
5
+ import { SellerCommandRunner } from "./seller.js";
5
6
  import { bindAdminUiCommand } from "./ui-command.js";
6
7
  import {
7
8
  loadRegistryFile,
@@ -208,8 +209,9 @@ function withExpectedVersion(body: Record<string, any>, expectedVersion: unknown
208
209
  return body;
209
210
  }
210
211
  const version = Number(expectedVersion);
211
- if (!Number.isInteger(version) || version < 1) {
212
- throw new Error("expected version must be >= 1");
212
+ // Step 14 (v1.1): version 0 means empty db. CLI 跟 server 一起接受 0.
213
+ if (!Number.isInteger(version) || version < 0) {
214
+ throw new Error("expected version must be >= 0");
213
215
  }
214
216
  return { ...body, expectedVersion: version };
215
217
  }
@@ -1179,7 +1181,7 @@ export function buildAdminCli(configManager: ConfigManager): Command {
1179
1181
 
1180
1182
  configCmd
1181
1183
  .command("list")
1182
- .description("List all configured profiles")
1184
+ .description("List all configured profiles in the active config file")
1183
1185
  .action(() => {
1184
1186
  const opts = program.opts();
1185
1187
  const configPath = opts.config;
@@ -1187,7 +1189,7 @@ export function buildAdminCli(configManager: ConfigManager): Command {
1187
1189
 
1188
1190
  const profiles = mgr.listProfiles();
1189
1191
  const config = mgr.load();
1190
- console.log("=== Configured Local Profiles ===");
1192
+ console.log(`=== Configured Local Profiles (config: ${mgr.getConfigPath() || "default"}) ===`);
1191
1193
  for (const p of profiles) {
1192
1194
  const isDefault = p === config.default_profile ? "* " : " ";
1193
1195
  const details = config.profiles[p];
@@ -1195,6 +1197,47 @@ export function buildAdminCli(configManager: ConfigManager): Command {
1195
1197
  }
1196
1198
  });
1197
1199
 
1200
+ // Step 14 (v1.1): 多 vendor 实例隔离.
1201
+ // 默认 config dir 改成 ~/.config/tokenbuddy/profiles/*.toml, 每个 vendor 一份 config.
1202
+ // 启动时 tb-admin (没传 --config) 会扫描 profiles/ 合并成一个虚拟配置 (default_profile 仍来自
1203
+ // ~/.config/tokenbuddy/admin.toml). `tb-admin config ls` 列出当前 active file,
1204
+ // `tb-admin config profiles` 扫描 profiles/ 列出所有 vendor file.
1205
+ configCmd
1206
+ .command("profiles")
1207
+ .description("Scan the vendor profiles directory (~/.config/tokenbuddy/profiles/) and list every vendor config")
1208
+ .action(() => {
1209
+ const profileDir = defaultProfileDir();
1210
+ const files = listProfileFiles(profileDir);
1211
+ if (files.length === 0) {
1212
+ console.log(`(no vendor profiles in ${profileDir})`);
1213
+ console.log("Create one with: tb-admin --config <file> config set <vendor> --url <url> --token <token>");
1214
+ return;
1215
+ }
1216
+ console.log(`=== Vendor Profiles (${profileDir}) ===`);
1217
+ for (const file of files) {
1218
+ try {
1219
+ const mgr = new ConfigManager(file);
1220
+ const config = mgr.load();
1221
+ const profileNames = mgr.listProfiles();
1222
+ const firstProfile = profileNames[0];
1223
+ const url = firstProfile ? config.profiles[firstProfile]?.url : "(no profiles)";
1224
+ console.log(` ${file}\n -> default=${config.default_profile || firstProfile || "?"} url=${url || "?"}`);
1225
+ } catch (err: any) {
1226
+ console.log(` ${file}\n -> (parse failed: ${err.message})`);
1227
+ }
1228
+ }
1229
+ });
1230
+
1231
+ configCmd
1232
+ .command("where")
1233
+ .description("Print the config file path in use (resolves --config, env, or default)")
1234
+ .action(() => {
1235
+ const opts = program.opts();
1236
+ const configPath = opts.config || process.env.TOKENBUDDY_ADMIN_CONFIG;
1237
+ const resolved = configPath || `${process.env.HOME || ""}/.config/tokenbuddy/admin.toml`;
1238
+ console.log(resolved);
1239
+ });
1240
+
1198
1241
  // 8. Seller Command (Fly.io)
1199
1242
  const sellerCmd = program.command("seller").description("Deploy and manage seller containers on Fly.io");
1200
1243
 
@@ -1204,13 +1247,49 @@ export function buildAdminCli(configManager: ConfigManager): Command {
1204
1247
  return new FlyProvider(mgr.getSellerProvider("fly"));
1205
1248
  }
1206
1249
 
1250
+ function getSellerRunner(): SellerCommandRunner {
1251
+ const opts = program.opts();
1252
+ const mgr = opts.config ? new ConfigManager(opts.config) : configManager;
1253
+ return new SellerCommandRunner(mgr);
1254
+ }
1255
+
1256
+ function printResult(res: unknown, json: boolean): void {
1257
+ if (json) {
1258
+ // json 路径 seller.ts 返回的是结构化 object; 直接 stringify 美化
1259
+ if (typeof res === "string") {
1260
+ // 防御: 任何子命令意外返回 string 都直接原样打
1261
+ console.log(res);
1262
+ } else {
1263
+ console.log(JSON.stringify(res, null, 2));
1264
+ }
1265
+ } else {
1266
+ // 文本路径: seller.ts 返回 string (1.0.31 行为) 或 dry-run string
1267
+ console.log(res);
1268
+ }
1269
+ }
1270
+
1207
1271
  sellerCmd
1208
1272
  .command("ls")
1209
1273
  .description("List all deployed apps on Fly.io")
1210
- .action(() => {
1274
+ .option("--json", "Output structured JSON instead of human-readable table")
1275
+ .action((options) => {
1211
1276
  try {
1212
- const out = getFlyProvider().listApps();
1213
- console.log(out);
1277
+ const res = getSellerRunner().ls(Boolean(options.json));
1278
+ printResult(res, Boolean(options.json));
1279
+ } catch (err: any) {
1280
+ console.error("Error:", err.message);
1281
+ process.exit(1);
1282
+ }
1283
+ });
1284
+
1285
+ sellerCmd
1286
+ .command("status <app>")
1287
+ .description("Show Fly.io status for a specific seller app")
1288
+ .option("--json", "Output structured JSON")
1289
+ .action((app, options) => {
1290
+ try {
1291
+ const res = getSellerRunner().status(app, Boolean(options.json));
1292
+ printResult(res, Boolean(options.json));
1214
1293
  } catch (err: any) {
1215
1294
  console.error("Error:", err.message);
1216
1295
  process.exit(1);
@@ -1231,23 +1310,27 @@ export function buildAdminCli(configManager: ConfigManager): Command {
1231
1310
  .option("--initial-config <path>", "Initial seller YAML config to inject as TOKENBUDDY_SELLER_CONFIG_B64")
1232
1311
  .requiredOption("--operator-secret <secret>", "Operator secret to configure")
1233
1312
  .option("--dry-run", "Dry run display without actual execution")
1313
+ .option("--json", "Output structured JSON (dry-run lists planned flyctl commands; real run captures summary)")
1234
1314
  .action((name, options) => {
1235
1315
  try {
1236
- const res = getFlyProvider().createSeller({
1237
- name,
1238
- app: options.app,
1239
- region: options.region,
1240
- image: options.image,
1241
- flyConfig: options.flyConfig,
1242
- volumeName: options.volumeName,
1243
- volumeSizeGb: options.volumeSizeGb,
1244
- volumeId: options.volumeId,
1245
- volumeSnapshotRetentionDays: options.volumeSnapshotRetentionDays,
1246
- initialConfigPath: options.initialConfig,
1247
- operatorSecret: options.operatorSecret,
1248
- dryRun: options.dryRun
1249
- });
1250
- console.log(res);
1316
+ const res = getSellerRunner().create(
1317
+ {
1318
+ name,
1319
+ app: options.app,
1320
+ region: options.region,
1321
+ image: options.image,
1322
+ flyConfig: options.flyConfig,
1323
+ volumeName: options.volumeName,
1324
+ volumeSizeGb: options.volumeSizeGb,
1325
+ volumeId: options.volumeId,
1326
+ volumeSnapshotRetentionDays: options.volumeSnapshotRetentionDays,
1327
+ initialConfigPath: options.initialConfig,
1328
+ operatorSecret: options.operatorSecret,
1329
+ dryRun: options.dryRun
1330
+ },
1331
+ Boolean(options.json)
1332
+ );
1333
+ printResult(res, Boolean(options.json));
1251
1334
  } catch (err: any) {
1252
1335
  console.error("Error:", err.message);
1253
1336
  process.exit(1);
@@ -1259,14 +1342,14 @@ export function buildAdminCli(configManager: ConfigManager): Command {
1259
1342
  .description("Update an existing seller app's Machines to an explicit image without changing Fly.io config or volumes")
1260
1343
  .requiredOption("--image <image>", "Published Docker image, for example registry.fly.io/tb-seller:<v>")
1261
1344
  .option("--dry-run", "Dry run")
1345
+ .option("--json", "Output structured JSON")
1262
1346
  .action((app, options) => {
1263
1347
  try {
1264
- const res = getFlyProvider().deploySeller({
1265
- app,
1266
- image: options.image,
1267
- dryRun: options.dryRun
1268
- });
1269
- console.log(res);
1348
+ const res = getSellerRunner().deploy(
1349
+ { app, image: options.image, dryRun: options.dryRun },
1350
+ Boolean(options.json)
1351
+ );
1352
+ printResult(res, Boolean(options.json));
1270
1353
  } catch (err: any) {
1271
1354
  console.error("Error:", err.message);
1272
1355
  process.exit(1);
@@ -1279,12 +1362,13 @@ export function buildAdminCli(configManager: ConfigManager): Command {
1279
1362
  .option("--app <app>", "Fly.io app name (use if name is ambiguous or already a full app name)")
1280
1363
  .option("--remove-profile", "Also delete the corresponding profile from local admin.toml")
1281
1364
  .option("--dry-run", "Dry run")
1365
+ .option("--json", "Output structured JSON")
1282
1366
  .action((name, options) => {
1283
1367
  try {
1284
1368
  const appName = options.app || name;
1285
- const res = getFlyProvider().removeSeller(appName, options.dryRun);
1286
- console.log(res);
1287
- if (options.removeProfile && !options.dryRun) {
1369
+ const res = getSellerRunner().remove(appName, Boolean(options.dryRun), Boolean(options.json));
1370
+ printResult(res, Boolean(options.json));
1371
+ if (options.removeProfile && !options.dryRun && !options.json) {
1288
1372
  const appBase = appName.includes("-") ? appName.replace(/^tb-seller-/, "") : appName;
1289
1373
  const profileNames = [`tbs-1`, `tbs-2`, `tbs-3`, `tbs-4`, `tbs-5`];
1290
1374
  const toRemove = profileNames.find((p) => {
@@ -1305,5 +1389,195 @@ export function buildAdminCli(configManager: ConfigManager): Command {
1305
1389
  }
1306
1390
  });
1307
1391
 
1392
+ // ------------------------------------------------------------------------
1393
+ // 7. Vendor domain commands (Step 5 of the registry redesign)
1394
+ //
1395
+ // vendor 走 `/platform/*` (Bearer vendor token, 不是 super-admin key).
1396
+ // 这些命令不替代 platform publish / config* / default-seller / seller
1397
+ // create-deploy-remove-ls; vendor 现在只能 stage 一个 seller + 提交
1398
+ // release request. 旧命令保留 (兼容迁移期 smoke), 但 `bootstrap
1399
+ // registry publish` / `bootstrap config*` / `bootstrap default-seller
1400
+ // set` / `seller create|deploy|remove|ls` 在 server 端已不再服务
1401
+ // super-admin 域 (Step 5 删 / Step 7 删 server). 客户端先行禁用:
1402
+ // 这些子命令保留 parser, 但 runtime 立刻报 "deprecated" 错误.
1403
+ // ------------------------------------------------------------------------
1404
+
1405
+ const vendorBootstrap = program.command("vendor-bootstrap")
1406
+ .description("Vendor-domain seller and release management (registry redesign Step 5+)")
1407
+ .option("--base-url <url>", "Override wallet-bootstrap base URL");
1408
+
1409
+ vendorBootstrap
1410
+ .command("me")
1411
+ .description("Show the authenticated vendor identity")
1412
+ .action(async () => {
1413
+ try {
1414
+ const result = await getClient().get("/platform/me");
1415
+ console.log(JSON.stringify(result, null, 2));
1416
+ } catch (err: any) {
1417
+ console.error("Error:", err.message);
1418
+ process.exit(1);
1419
+ }
1420
+ });
1421
+
1422
+ vendorBootstrap
1423
+ .command("sellers")
1424
+ .description("List live sellers visible to the authenticated vendor")
1425
+ .action(async () => {
1426
+ try {
1427
+ const result = await getClient().get("/platform/sellers");
1428
+ const rows = (result.sellers as any[]) || [];
1429
+ const table = new Table({ head: ["ID", "URL", "Status", "Models"] });
1430
+ for (const seller of rows) {
1431
+ table.push([
1432
+ seller.id,
1433
+ seller.url,
1434
+ seller.status || "active",
1435
+ String(seller.models?.length || 0)
1436
+ ]);
1437
+ }
1438
+ console.log(table.toString());
1439
+ } catch (err: any) {
1440
+ console.error("Error:", err.message);
1441
+ process.exit(1);
1442
+ }
1443
+ });
1444
+
1445
+ vendorBootstrap
1446
+ .command("stage")
1447
+ .description("Stage a seller entry for inclusion in the next release request")
1448
+ .requiredOption("--file <path>", "Seller entry JSON file")
1449
+ .action(async (options) => {
1450
+ try {
1451
+ const result = await getClient().post("/platform/sellers/stage", JSON.parse(require("fs").readFileSync(options.file, "utf8")));
1452
+ console.log(`Staged ${(result as any).pendingSeller.id} (status: ${(result as any).pendingSeller.status})`);
1453
+ } catch (err: any) {
1454
+ console.error("Error:", err.message);
1455
+ process.exit(1);
1456
+ }
1457
+ });
1458
+
1459
+ vendorBootstrap
1460
+ .command("pending")
1461
+ .description("List staged pending sellers for the authenticated vendor")
1462
+ .option("--status <status>", "Filter by status: staged|published|rejected", "staged")
1463
+ .action(async (options) => {
1464
+ try {
1465
+ const result = await getClient().get(`/platform/sellers/pending?status=${encodeURIComponent(options.status)}`);
1466
+ const rows = (result as any).pendingSellers as any[];
1467
+ if (rows.length === 0) {
1468
+ console.log(`(no ${options.status} pending sellers)`);
1469
+ return;
1470
+ }
1471
+ const table = new Table({ head: ["ID", "Status", "Submitted", "Decided"] });
1472
+ for (const row of rows) {
1473
+ table.push([row.id, row.status, row.submittedAt, row.decidedAt || "—"]);
1474
+ }
1475
+ console.log(table.toString());
1476
+ } catch (err: any) {
1477
+ console.error("Error:", err.message);
1478
+ process.exit(1);
1479
+ }
1480
+ });
1481
+
1482
+ const releaseCmd = vendorBootstrap
1483
+ .command("release")
1484
+ .description("Submit and inspect release requests");
1485
+
1486
+ releaseCmd
1487
+ .command("submit")
1488
+ .description("Submit a release request that includes one or more staged sellers")
1489
+ .option("--staged <id...>", "One or more staged seller ids to include", collectIds, [])
1490
+ .option("--note <text>", "Note explaining the release")
1491
+ .action(async (options) => {
1492
+ try {
1493
+ const result = await getClient().post("/platform/release-requests", {
1494
+ stagedSellerIds: options.staged,
1495
+ note: options.note
1496
+ });
1497
+ const record = (result as any).releaseRequest;
1498
+ console.log(`Submitted release request #${record.id} (status: ${record.status})`);
1499
+ console.log(` sellers: ${record.payloadSummary.count} (${(record.payloadSummary.sellerIds || []).join(", ") || "—"})`);
1500
+ } catch (err: any) {
1501
+ console.error("Error:", err.message);
1502
+ process.exit(1);
1503
+ }
1504
+ });
1505
+
1506
+ releaseCmd
1507
+ .command("list")
1508
+ .description("List release requests for the authenticated vendor")
1509
+ .option("--limit <n>", "Maximum number of records to return", "20")
1510
+ .action(async (options) => {
1511
+ try {
1512
+ const result = await getClient().get(`/platform/release-requests?limit=${encodeURIComponent(options.limit)}`);
1513
+ const rows = (result as any).releaseRequests as any[];
1514
+ if (rows.length === 0) {
1515
+ console.log("(no release requests)");
1516
+ return;
1517
+ }
1518
+ const table = new Table({ head: ["ID", "Status", "Sellers", "Submitted", "Version"] });
1519
+ for (const r of rows) {
1520
+ table.push([
1521
+ `#${r.id}`,
1522
+ r.status,
1523
+ String(r.payloadSummary?.count || 0),
1524
+ r.submittedAt,
1525
+ r.publishedVersion !== null ? `v${r.publishedVersion}` : "—"
1526
+ ]);
1527
+ }
1528
+ console.log(table.toString());
1529
+ } catch (err: any) {
1530
+ console.error("Error:", err.message);
1531
+ process.exit(1);
1532
+ }
1533
+ });
1534
+
1535
+ releaseCmd
1536
+ .command("show <id>")
1537
+ .description("Show a single release request")
1538
+ .action(async (id) => {
1539
+ try {
1540
+ const result = await getClient().get(`/platform/release-requests/${encodeURIComponent(id)}`);
1541
+ const r = (result as any).releaseRequest;
1542
+ if (!r) {
1543
+ console.error("Release request not found");
1544
+ process.exit(1);
1545
+ }
1546
+ console.log(JSON.stringify(r, null, 2));
1547
+ } catch (err: any) {
1548
+ console.error("Error:", err.message);
1549
+ process.exit(1);
1550
+ }
1551
+ });
1552
+
1308
1553
  return program;
1309
1554
  }
1555
+
1556
+ function collectIds(value: string, previous: string[]): string[] {
1557
+ return previous.concat([value]);
1558
+ }
1559
+
1560
+ // Step 14 (v1.1): 多 vendor 实例 config 隔离.
1561
+ // 默认 profiles 目录是 ~/.config/tokenbuddy/profiles/, 每个 vendor 一份独立 toml.
1562
+ // 默认主 config 仍走 ~/.config/tokenbuddy/admin.toml (default_profile 在此设).
1563
+ // 用户可以:
1564
+ // - tb-admin --config /path/to/vendor-A.toml config ls (单 vendor 显式)
1565
+ // - tb-admin config profiles (扫 profiles/ 看所有 vendor)
1566
+ // - TOKENBUDDY_CONFIG_DIR=/somewhere tb-admin ... (env override)
1567
+ function defaultProfileDir(): string {
1568
+ const override = process.env.TOKENBUDDY_CONFIG_DIR;
1569
+ if (override) {
1570
+ return path.join(override, "profiles");
1571
+ }
1572
+ return path.join(process.env.HOME || "", ".config", "tokenbuddy", "profiles");
1573
+ }
1574
+
1575
+ function listProfileFiles(dir: string): string[] {
1576
+ if (!fs.existsSync(dir)) {
1577
+ return [];
1578
+ }
1579
+ return fs.readdirSync(dir)
1580
+ .filter((name) => name.endsWith(".toml") || name.endsWith(".config"))
1581
+ .map((name) => path.join(dir, name))
1582
+ .sort();
1583
+ }
package/src/client.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  * 内部封装 Bearer Token 鉴权、JSON 序列化、非 2xx 抛错的统一行为。
4
4
  */
5
5
  export class AdminClient {
6
- private baseUrl: string;
7
- private token: string;
6
+ protected baseUrl: string;
7
+ protected token: string;
8
8
 
9
9
  constructor(baseUrl: string, token: string) {
10
10
  this.baseUrl = baseUrl.replace(/\/+$/, "");
@@ -61,3 +61,120 @@ export class AdminClient {
61
61
  return this.request(path, "DELETE", body, headers);
62
62
  }
63
63
  }
64
+
65
+ /**
66
+ * Vendor domain client. Step 5 of the registry redesign: vendor
67
+ * tokens call `/platform/*` (Bearer auth). The old `bootstrap
68
+ * config*`, `bootstrap registry publish`, and `bootstrap default-
69
+ * seller set` commands have been removed; vendors stage sellers and
70
+ * submit release requests instead.
71
+ */
72
+ export class RegistryVendorClient extends AdminClient {
73
+ public async me(): Promise<{ vendor: { id: string; name: string; status: string } }> {
74
+ return this.get("/platform/me");
75
+ }
76
+
77
+ public async listSellers(): Promise<{ sellers: unknown[] }> {
78
+ return this.get("/platform/sellers");
79
+ }
80
+
81
+ /**
82
+ * Step 14 (v1.1): vendor-scoped seller status mutation.
83
+ * Replaces the old `tb-admin bootstrap sellers status` path which called
84
+ * /operator/registry/sellers/:id/status (operator-level, super-admin scope).
85
+ * Vendor can only mutate sellers in its own scope; the platform vendor
86
+ * (DEFAULT_PLATFORM_VENDOR_ID) can mutate any.
87
+ */
88
+ public async setSellerStatus(
89
+ id: string,
90
+ status: "active" | "draining" | "offline",
91
+ options: { expectedVersion?: number; idempotencyKey?: string } = {}
92
+ ): Promise<{ registry: { version: number; sellers: unknown[] } }> {
93
+ const headers: { [key: string]: string } = {};
94
+ if (options.idempotencyKey) {
95
+ headers["Idempotency-Key"] = options.idempotencyKey;
96
+ }
97
+ return this.put(
98
+ `/platform/sellers/${encodeURIComponent(id)}/status`,
99
+ {
100
+ status,
101
+ ...(options.expectedVersion !== undefined ? { expectedVersion: options.expectedVersion } : {})
102
+ },
103
+ headers
104
+ );
105
+ }
106
+
107
+ public async stageSeller(seller: unknown): Promise<{ pendingSeller: unknown }> {
108
+ return this.post("/platform/sellers/stage", { seller });
109
+ }
110
+
111
+ public async listPendingSellers(status?: string): Promise<{ pendingSellers: unknown[] }> {
112
+ const path = status ? `/platform/sellers/pending?status=${encodeURIComponent(status)}` : "/platform/sellers/pending";
113
+ return this.get(path);
114
+ }
115
+
116
+ public async submitRelease(input: { stagedSellerIds: string[]; note?: string }): Promise<{ releaseRequest: unknown }> {
117
+ return this.post("/platform/release-requests", input);
118
+ }
119
+
120
+ public async listReleaseRequests(limit = 20): Promise<{ releaseRequests: unknown[] }> {
121
+ return this.get(`/platform/release-requests?limit=${limit}`);
122
+ }
123
+
124
+ public async getReleaseRequest(id: number): Promise<{ releaseRequest: unknown }> {
125
+ return this.get(`/platform/release-requests/${id}`);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Super-admin client. Used by automated scripts / CI to drive the
131
+ * admin web endpoints (force-publish, registry versions, audit).
132
+ * Step 5 keeps this narrow; the human-facing flow is the browser.
133
+ */
134
+ export class RegistryAdminClient extends AdminClient {
135
+ constructor(baseUrl: string, superAdminKey: string) {
136
+ // We pass the super-admin key as the "token" but override the
137
+ // Authorization header below — super-admin uses the
138
+ // `X-Registry-Admin-Key` header, not Bearer.
139
+ super(baseUrl, superAdminKey);
140
+ }
141
+
142
+ protected overrideHeaders(headers: Record<string, string>): Record<string, string> {
143
+ return { ...headers, "X-Registry-Admin-Key": this.token };
144
+ }
145
+
146
+ private async requestAdmin(path: string, method: string, body?: any): Promise<any> {
147
+ const url = `${this.baseUrl}${path}`;
148
+ const headers = this.overrideHeaders({ "Content-Type": "application/json" });
149
+ const options: RequestInit = { method, headers };
150
+ if (body) options.body = JSON.stringify(body);
151
+ const response = await fetch(url, options);
152
+ if (!response.ok) {
153
+ const text = await response.text();
154
+ throw new Error(`HTTP Error ${response.status}: ${text || response.statusText}`);
155
+ }
156
+ const text = await response.text();
157
+ return text ? JSON.parse(text) : {};
158
+ }
159
+
160
+ public async listVendors(): Promise<{ vendors: unknown[] }> {
161
+ return this.requestAdmin("/platform/vendors", "GET");
162
+ }
163
+
164
+ public async createVendor(name: string): Promise<{ vendor: unknown; token: string }> {
165
+ return this.requestAdmin("/platform/vendors", "POST", { name });
166
+ }
167
+
168
+ public async forcePublish(releaseId: number): Promise<{ releaseRequest: unknown; publishedVersion: number | null }> {
169
+ return this.requestAdmin(`/platform/admin/release-requests/${releaseId}/force-publish`, "POST");
170
+ }
171
+
172
+ public async listVersions(): Promise<{ versions: unknown[] }> {
173
+ return this.requestAdmin("/platform/admin/registry-versions", "GET");
174
+ }
175
+
176
+ public async listAudit(): Promise<{ records: unknown[] }> {
177
+ return this.requestAdmin("/platform/audit", "GET");
178
+ }
179
+ }
180
+