@powerhousedao/registry 6.2.2-dev.2 → 6.2.2-dev.20

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/dist/cli.d.mts CHANGED
@@ -21,8 +21,10 @@ declare const registryCommand: Partial<cmd_ts_dist_cjs_argparser_js0.Register> &
21
21
  webhooks: string | undefined;
22
22
  publicUrl: string | undefined;
23
23
  authRenown: boolean;
24
+ renownUrl: string | undefined;
24
25
  verdaccioSecret: string | undefined;
25
26
  localPackages: string | undefined;
27
+ databaseUrl: string | undefined;
26
28
  }>>;
27
29
  } & cmd_ts_dist_cjs_helpdoc_js0.PrintHelp & cmd_ts_dist_cjs_helpdoc_js0.ProvidesHelp & cmd_ts_dist_cjs_helpdoc_js0.Named & Partial<cmd_ts_dist_cjs_helpdoc_js0.Versioned> & cmd_ts_dist_cjs_argparser_js0.Register & cmd_ts_dist_cjs_runner_js0.Handling<{
28
30
  port: number;
@@ -41,8 +43,10 @@ declare const registryCommand: Partial<cmd_ts_dist_cjs_argparser_js0.Register> &
41
43
  webhooks: string | undefined;
42
44
  publicUrl: string | undefined;
43
45
  authRenown: boolean;
46
+ renownUrl: string | undefined;
44
47
  verdaccioSecret: string | undefined;
45
48
  localPackages: string | undefined;
49
+ databaseUrl: string | undefined;
46
50
  }, Promise<void>> & {
47
51
  run(context: cmd_ts_dist_cjs_argparser_js0.ParseContext): Promise<cmd_ts_dist_cjs_argparser_js0.ParsingResult<Promise<void>>>;
48
52
  } & Partial<cmd_ts_dist_cjs_helpdoc_js0.Versioned & cmd_ts_dist_cjs_helpdoc_js0.Descriptive & cmd_ts_dist_cjs_helpdoc_js0.Aliased>;
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.mts","names":[],"sources":["../cli.ts"],"mappings":";;;;;cAiBa,eAAA,EAAe,OAAA,CAyI1B,6BAAA,CAzI0B,QAAA;iBAAA,6BAAA,CAAA,YAAA"}
1
+ {"version":3,"file":"cli.d.mts","names":[],"sources":["../cli.ts"],"mappings":";;;;;cAiBa,eAAA,EAAe,OAAA,CAkK1B,6BAAA,CAlK0B,QAAA;iBAAA,6BAAA,CAAA,YAAA"}
package/dist/cli.mjs CHANGED
@@ -1,77 +1,110 @@
1
1
  import { binary, command, flag, number, option, optional, run, string } from "cmd-ts";
2
2
  import express, { Router } from "express";
3
3
  import { findUp } from "find-up";
4
- import crypto, { randomBytes } from "node:crypto";
4
+ import crypto, { randomBytes, randomUUID } from "node:crypto";
5
5
  import { mkdir } from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { runServer } from "verdaccio";
8
- import { signPayload } from "@verdaccio/signature";
9
- import { verifyAuthBearerToken } from "@renown/sdk/node";
8
+ import { Pool } from "pg";
10
9
  import fs from "node:fs";
11
10
  import { pipeline } from "node:stream/promises";
12
11
  import { Readable } from "node:stream";
13
12
  import { extract } from "tar";
14
13
  import { slimManifest } from "@powerhousedao/shared/registry";
14
+ import { fileURLToPath } from "node:url";
15
15
  //#endregion
16
- //#region src/auth/renown-middleware.ts
17
- function audienceMatches(aud, expected) {
18
- if (!aud) return false;
19
- if (Array.isArray(aud)) return aud.includes(expected);
20
- return aud === expected;
16
+ //#region src/auth/pg-store.ts
17
+ /** Build a real Postgres pool from a connection string. */
18
+ function createPgPool(databaseUrl) {
19
+ return new Pool({ connectionString: databaseUrl });
21
20
  }
22
21
  /**
23
- * Translates a Renown-signed Bearer token (verifiable from the issuer's DID,
24
- * stateless) into a verdaccio-format Bearer token (signed with verdaccio's
25
- * own secret) that verdaccio's normal auth pipeline accepts.
22
+ * Postgres-backed AuthStore. Two small tables:
23
+ * - registry_users(username PK, password_hash, created_at)
24
+ * - registry_package_owners(package_name PK, owners text[], claimed_at)
26
25
  *
27
- * Falls through (calls `next()` without modifying auth) on any verification
28
- * failure: malformed token, bad signature, expired, audience mismatch, or
29
- * non-renown bearer token. This keeps the legacy htpasswd path usable during
30
- * the migration grace period — verdaccio's own apiJWTmiddleware sees the
31
- * original Authorization header and decides what to do with it.
26
+ * Ownership claim is race-free: `INSERT ... ON CONFLICT DO NOTHING` means the
27
+ * first publisher wins atomically; the follow-up read returns the actual
28
+ * owners so a losing racer is denied.
29
+ *
30
+ * Takes an already-built `pg.Pool` (tests inject a pg-mem pool cast to Pool).
32
31
  */
33
- function createRenownAuthMiddleware(opts) {
34
- const expectedAud = opts.publicUrl;
35
- return async (req, _res, next) => {
36
- const header = req.headers.authorization;
37
- if (!header?.startsWith("Bearer ")) return next();
38
- const token = header.slice(7).trim();
39
- if (!token) return next();
40
- let verified;
41
- try {
42
- verified = await verifyAuthBearerToken(token, { audience: expectedAud });
43
- } catch {
44
- return next();
45
- }
46
- if (!verified) return next();
47
- const payload = verified.payload;
48
- if (!audienceMatches(payload?.aud, expectedAud)) return next();
49
- const subject = verified.verifiableCredential.credentialSubject;
50
- if (!subject?.address) return next();
51
- const address = subject.address.toLowerCase();
52
- const groups = ["$authenticated", "renown"];
53
- let verdaccioJwt;
54
- try {
55
- verdaccioJwt = await signPayload({
56
- name: address,
57
- real_groups: groups,
58
- groups
59
- }, opts.verdaccioSecret, { expiresIn: "5m" });
60
- } catch (err) {
61
- console.error("[registry] failed to mint internal verdaccio token:", err);
62
- return next();
32
+ function createPgStore(pool) {
33
+ let initialized = null;
34
+ return {
35
+ init() {
36
+ initialized ??= (async () => {
37
+ await pool.query(`
38
+ CREATE TABLE IF NOT EXISTS registry_users (
39
+ username text PRIMARY KEY,
40
+ password_hash text NOT NULL,
41
+ created_at timestamptz NOT NULL DEFAULT now()
42
+ )`);
43
+ await pool.query(`
44
+ CREATE TABLE IF NOT EXISTS registry_package_owners (
45
+ package_name text PRIMARY KEY,
46
+ owners text[] NOT NULL,
47
+ claimed_at timestamptz NOT NULL DEFAULT now()
48
+ )`);
49
+ })();
50
+ return initialized;
51
+ },
52
+ async getUser(username) {
53
+ const row = (await pool.query("SELECT password_hash FROM registry_users WHERE username = $1", [username])).rows[0];
54
+ return row ? { passwordHash: row.password_hash } : null;
55
+ },
56
+ async createUser(username, passwordHash) {
57
+ try {
58
+ await pool.query("INSERT INTO registry_users (username, password_hash) VALUES ($1, $2)", [username, passwordHash]);
59
+ return true;
60
+ } catch (err) {
61
+ if (err.code === "23505") return false;
62
+ throw err;
63
+ }
64
+ },
65
+ async getOwners(pkg) {
66
+ return (await pool.query("SELECT owners FROM registry_package_owners WHERE package_name = $1", [pkg])).rows[0]?.owners ?? null;
67
+ },
68
+ async getOwnersFor(pkgs) {
69
+ if (pkgs.length === 0) return {};
70
+ const placeholders = pkgs.map((_, i) => `$${i + 1}`).join(",");
71
+ const res = await pool.query(`SELECT package_name, owners FROM registry_package_owners WHERE package_name IN (${placeholders})`, pkgs);
72
+ const out = {};
73
+ for (const row of res.rows) out[row.package_name] = row.owners;
74
+ return out;
75
+ },
76
+ async claimOwner(pkg, username) {
77
+ await pool.query(`INSERT INTO registry_package_owners (package_name, owners)
78
+ VALUES ($1, ARRAY[$2]::text[])
79
+ ON CONFLICT (package_name) DO NOTHING`, [pkg, username]);
80
+ return (await pool.query("SELECT owners FROM registry_package_owners WHERE package_name = $1", [pkg])).rows[0]?.owners ?? [];
81
+ },
82
+ close() {
83
+ return pool.end();
63
84
  }
64
- req.headers.authorization = `Bearer ${verdaccioJwt}`;
65
- req.renownUser = {
66
- address,
67
- did: payload?.iss,
68
- chainId: subject.chainId,
69
- networkId: subject.networkId
70
- };
71
- return next();
72
85
  };
73
86
  }
74
87
  //#endregion
88
+ //#region src/auth/store-handoff.ts
89
+ const REGISTRY_KEY = Symbol.for("@powerhousedao/registry:auth-store-handoff");
90
+ function registry() {
91
+ const g = globalThis;
92
+ return g[REGISTRY_KEY] ??= /* @__PURE__ */ new Map();
93
+ }
94
+ /** Stash a store instance and return a token to carry through plugin config. */
95
+ function stashAuthStore(store) {
96
+ const token = randomUUID();
97
+ registry().set(token, {
98
+ store,
99
+ loaded: false
100
+ });
101
+ return token;
102
+ }
103
+ /** True once the plugin has loaded the store for this token. */
104
+ function wasStoreLoaded(token) {
105
+ return registry().get(token)?.loaded ?? false;
106
+ }
107
+ //#endregion
75
108
  //#region src/semver.ts
76
109
  /**
77
110
  * Compare two semver version strings for sorting.
@@ -245,6 +278,37 @@ var CdnCache = class {
245
278
  if (fs.readdirSync(pkgDir).length === 0) fs.rmdirSync(pkgDir);
246
279
  } catch {}
247
280
  }
281
+ async reconcileWithRegistry(packageName) {
282
+ const url = `${this.registryUrl}/${encodeURIComponent(packageName)}`;
283
+ let versions;
284
+ try {
285
+ const res = await fetch(url, { headers: { Accept: "application/json" } });
286
+ if (!res.ok) return [];
287
+ const meta = await res.json();
288
+ versions = Object.keys(meta.versions ?? {});
289
+ } catch {
290
+ return [];
291
+ }
292
+ if (versions.length === 0) return [];
293
+ return this.reconcileVersions(packageName, versions);
294
+ }
295
+ reconcileVersions(packageName, keepVersions) {
296
+ const pkgDir = path.join(this.cdnCachePath, packageName);
297
+ const keep = new Set(keepVersions);
298
+ const removed = [];
299
+ let entries;
300
+ try {
301
+ entries = fs.readdirSync(pkgDir, { withFileTypes: true });
302
+ } catch {
303
+ return removed;
304
+ }
305
+ for (const entry of entries) {
306
+ if (!entry.isDirectory() || keep.has(entry.name)) continue;
307
+ this.invalidateVersion(packageName, entry.name);
308
+ removed.push(entry.name);
309
+ }
310
+ return removed;
311
+ }
248
312
  /** Remove all cached version directories except the specified one. */
249
313
  pruneOldVersions(packageName, keepVersion) {
250
314
  const pkgDir = path.join(this.cdnCachePath, packageName);
@@ -340,17 +404,40 @@ function getLatestVersionDir(pkgDir) {
340
404
  versions.sort(compareSemver);
341
405
  return path.join(pkgDir, versions[versions.length - 1]);
342
406
  }
343
- function loadPackage(cdnCachePath, name, version) {
407
+ function loadPackage(cdnCachePath, name, version, storagePath) {
344
408
  const pkgDir = path.join(cdnCachePath, name);
345
409
  const manifestDir = (version ? path.join(pkgDir, version) : getLatestVersionDir(pkgDir)) ?? pkgDir;
346
410
  const manifest = readManifest(manifestDir);
347
411
  if (!manifest) return null;
412
+ const resolvedName = manifest.name || name;
413
+ const { distTags, versions } = readPackageMetadata(storagePath, resolvedName);
348
414
  return {
349
- name: manifest.name || name,
415
+ name: resolvedName,
350
416
  path: `/-/cdn/${name}`,
351
417
  manifest,
352
418
  documentTypes: getDocumentTypesFromManifest(manifest),
353
- version: readPackageJsonVersion(manifestDir)
419
+ version: readPackageJsonVersion(manifestDir),
420
+ distTags,
421
+ versions
422
+ };
423
+ }
424
+ /**
425
+ * Project a full {@link PackageInfo} down to the trimmed shape the paginated
426
+ * package-listing UI renders per row. Version metadata and documentTypes are
427
+ * intentionally dropped — the client fetches them on demand.
428
+ */
429
+ function toPackageListItem(pkg) {
430
+ const publisher = pkg.manifest?.publisher;
431
+ return {
432
+ name: pkg.name,
433
+ path: pkg.path,
434
+ version: pkg.version,
435
+ description: pkg.manifest?.description ?? void 0,
436
+ category: pkg.manifest?.category ?? void 0,
437
+ publisher: publisher ? {
438
+ name: publisher.name,
439
+ url: publisher.url
440
+ } : void 0
354
441
  };
355
442
  }
356
443
  function getDocumentTypesFromManifest(manifest) {
@@ -479,6 +566,20 @@ function createWarmer(config, cdn) {
479
566
  }
480
567
  //#endregion
481
568
  //#region src/middleware.ts
569
+ const DEFAULT_PAGE_SIZE = 30;
570
+ const MAX_PAGE_SIZE = 50;
571
+ /** Parse+clamp a `limit` query param to 1..MAX_PAGE_SIZE (default 30). */
572
+ function clampPageSize(raw) {
573
+ const n = Number.parseInt(String(raw), 10);
574
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_PAGE_SIZE;
575
+ return Math.min(n, MAX_PAGE_SIZE);
576
+ }
577
+ /** Parse a non-negative `offset` query param (default 0). */
578
+ function parseOffset(raw) {
579
+ const n = Number.parseInt(String(raw), 10);
580
+ if (!Number.isFinite(n) || n < 0) return 0;
581
+ return n;
582
+ }
482
583
  const MIME_TYPES = {
483
584
  ".js": "application/javascript",
484
585
  ".mjs": "application/javascript",
@@ -492,6 +593,15 @@ const MIME_TYPES = {
492
593
  function getContentType(filePath) {
493
594
  return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
494
595
  }
596
+ function publisherFromRequest(req) {
597
+ const name = req.remote_user?.name;
598
+ if (!name) return void 0;
599
+ if (name.startsWith("did:pkh:")) return {
600
+ address: (name.split(":").pop() ?? name).toLowerCase(),
601
+ did: name
602
+ };
603
+ return { address: name };
604
+ }
495
605
  /**
496
606
  * Resolve a package version. Exact versions skip the network call. Upstream
497
607
  * errors fall back to the latest cached version; genuine not-found falls back
@@ -531,9 +641,23 @@ function etagMatches(header, etag) {
531
641
  return tag === "*" || opaqueTag(tag) === target;
532
642
  });
533
643
  }
534
- function createPowerhouseRouter(config, sse, webhooks) {
644
+ function createPowerhouseRouter(config, sse, webhooks, ownerStore) {
535
645
  const cdn = new CdnCache(`http://localhost:${config.port}`, config.cdnCachePath);
536
646
  const router = Router();
647
+ const withOwners = async (pkgs) => {
648
+ if (!ownerStore || pkgs.length === 0) return pkgs;
649
+ try {
650
+ await ownerStore.init();
651
+ const map = await ownerStore.getOwnersFor(pkgs.map((p) => p.name));
652
+ return pkgs.map((p) => p.name in map ? {
653
+ ...p,
654
+ owners: map[p.name]
655
+ } : p);
656
+ } catch (err) {
657
+ console.error("[registry] owner lookup failed:", err);
658
+ return pkgs;
659
+ }
660
+ };
537
661
  router.use((_req, res, next) => {
538
662
  res.setHeader("Access-Control-Allow-Origin", "*");
539
663
  next();
@@ -573,16 +697,32 @@ function createPowerhouseRouter(config, sse, webhooks) {
573
697
  });
574
698
  const warm = createWarmer(config, cdn);
575
699
  warm();
576
- router.get("/packages", (req, res) => {
700
+ router.get("/packages", async (req, res) => {
577
701
  warm();
578
702
  const packages = scanPackages(config.cdnCachePath, config.storagePath);
579
703
  const documentType = req.query.documentType;
580
704
  if (documentType) {
581
705
  const filtered = packages.filter((pkg) => pkg.manifest?.documentModels?.some((m) => m.id === documentType));
582
- res.json(filtered);
706
+ res.json(await withOwners(filtered));
707
+ return;
708
+ }
709
+ if (req.query.limit === void 0) {
710
+ res.json(await withOwners(packages));
583
711
  return;
584
712
  }
585
- res.json(packages);
713
+ const limit = clampPageSize(req.query.limit);
714
+ const offset = parseOffset(req.query.offset);
715
+ const search = typeof req.query.search === "string" ? req.query.search.trim().toLowerCase() : "";
716
+ const sorted = (search ? packages.filter((pkg) => pkg.name.toLowerCase().includes(search)) : packages).slice().sort((a, b) => a.name.localeCompare(b.name));
717
+ const total = sorted.length;
718
+ const items = sorted.slice(offset, offset + limit).map(toPackageListItem);
719
+ res.json({
720
+ items,
721
+ total,
722
+ limit,
723
+ offset,
724
+ hasMore: offset + limit < total
725
+ });
586
726
  });
587
727
  router.get("/packages/by-document-type", (req, res) => {
588
728
  const documentType = req.query.type;
@@ -602,12 +742,12 @@ function createPowerhouseRouter(config, sse, webhooks) {
602
742
  return;
603
743
  }
604
744
  const version = resolution.kind === "ok" ? resolution.version : void 0;
605
- const pkg = loadPackage(config.cdnCachePath, name, version);
745
+ const pkg = loadPackage(config.cdnCachePath, name, version, config.storagePath);
606
746
  if (!pkg) {
607
747
  res.status(404).send("Package not found");
608
748
  return;
609
749
  }
610
- res.json(pkg);
750
+ res.json((await withOwners([pkg]))[0]);
611
751
  });
612
752
  router.get("/-/cdn/*", async (req, res) => {
613
753
  const fullPath = req.params[0];
@@ -693,9 +833,41 @@ function parseUnpublishRequest(reqPath) {
693
833
  version
694
834
  };
695
835
  }
836
+ function parseManifestRewrite(reqPath) {
837
+ const revIdx = reqPath.indexOf("/-rev/");
838
+ if (revIdx <= 0) return null;
839
+ const beforeRev = reqPath.slice(1, revIdx);
840
+ if (beforeRev.includes("/-/")) return null;
841
+ return { packageName: decodeURIComponent(beforeRev) };
842
+ }
696
843
  function createUnpublishHook(config, notifications) {
697
844
  const cdn = new CdnCache(`http://localhost:${config.port}`, config.cdnCachePath);
845
+ const handleManifestRewrite = (req, res) => {
846
+ const rewrite = parseManifestRewrite(req.path);
847
+ if (!rewrite) return;
848
+ const originalEnd = res.end.bind(res);
849
+ res.end = function(chunk, encoding, cb) {
850
+ if (res.statusCode >= 200 && res.statusCode < 300) {
851
+ const publishedBy = publisherFromRequest(req);
852
+ cdn.reconcileWithRegistry(rewrite.packageName).then((removed) => {
853
+ for (const version of removed) notifications.notifyUnpublish({
854
+ packageName: rewrite.packageName,
855
+ version,
856
+ publishedBy
857
+ });
858
+ }).catch((err) => {
859
+ console.error(`[registry] CDN reconcile failed for ${rewrite.packageName}:`, err);
860
+ });
861
+ }
862
+ return originalEnd(chunk, encoding, cb);
863
+ };
864
+ };
698
865
  return (req, res, next) => {
866
+ if (req.method === "PUT") {
867
+ handleManifestRewrite(req, res);
868
+ next();
869
+ return;
870
+ }
699
871
  if (req.method !== "DELETE") {
700
872
  next();
701
873
  return;
@@ -710,14 +882,10 @@ function createUnpublishHook(config, notifications) {
710
882
  if (res.statusCode >= 200 && res.statusCode < 300) try {
711
883
  if (parsed.version) cdn.invalidateVersion(parsed.packageName, parsed.version);
712
884
  else cdn.invalidate(parsed.packageName);
713
- const renownUser = req.renownUser;
714
885
  notifications.notifyUnpublish({
715
886
  packageName: parsed.packageName,
716
887
  version: parsed.version,
717
- publishedBy: renownUser ? {
718
- address: renownUser.address,
719
- did: renownUser.did
720
- } : void 0
888
+ publishedBy: publisherFromRequest(req)
721
889
  });
722
890
  } catch (err) {
723
891
  console.error(`[registry] CDN purge failed for ${parsed.packageName}${parsed.version ? `@${parsed.version}` : ""}:`, err);
@@ -747,11 +915,7 @@ function createPublishHook(config, notifications) {
747
915
  return originalEnd(chunk, encoding, cb);
748
916
  }
749
917
  if (versions.length > 1) console.warn(`[registry] Multiple versions published for ${packageName}: ${JSON.stringify(versions)}`);
750
- const renownUser = req.renownUser;
751
- const publishedBy = renownUser ? {
752
- address: renownUser.address,
753
- did: renownUser.did
754
- } : void 0;
918
+ const publishedBy = publisherFromRequest(req);
755
919
  cdn.extractTarball(packageName, version).then(() => {
756
920
  notifications.notifyPublish({
757
921
  packageName,
@@ -888,6 +1052,17 @@ var WebhookChannel = class {
888
1052
  function buildVerdaccioConfig(config) {
889
1053
  const htpasswdPath = path.join(config.storagePath, "htpasswd");
890
1054
  const uplinkUrl = config.uplink ?? "https://registry.npmjs.org/";
1055
+ const usePgAuth = Boolean(config.databaseUrl || config.authStore);
1056
+ const storeToken = config.authStoreToken ?? (config.authStore ? stashAuthStore(config.authStore) : void 0);
1057
+ const pluginsDir = config.pluginsDir ?? path.join(path.dirname(fileURLToPath(import.meta.url)), "plugins");
1058
+ const auth = usePgAuth ? { "registry-auth": {
1059
+ ...config.databaseUrl ? { databaseUrl: config.databaseUrl } : {},
1060
+ ...storeToken ? { storeToken } : {},
1061
+ ...config.renown ? {
1062
+ publicUrl: config.renown.publicUrl,
1063
+ ...config.renown.renownUrl ? { renownUrl: config.renown.renownUrl } : {}
1064
+ } : {}
1065
+ } } : { htpasswd: { file: htpasswdPath } };
891
1066
  const base = {
892
1067
  storage: config.storagePath,
893
1068
  self_path: "./",
@@ -896,7 +1071,8 @@ function buildVerdaccioConfig(config) {
896
1071
  sign: { expiresIn: "90d" },
897
1072
  verify: {}
898
1073
  } } },
899
- auth: { htpasswd: { file: htpasswdPath } },
1074
+ auth,
1075
+ ...usePgAuth ? { plugins: pluginsDir } : {},
900
1076
  uplinks: { npmjs: {
901
1077
  url: uplinkUrl,
902
1078
  maxage: config.uplinkMaxage ?? "2m",
@@ -965,18 +1141,21 @@ async function resolveDir(dir) {
965
1141
  return found;
966
1142
  }
967
1143
  async function runRegistry(args) {
968
- const { port, storageDir, cdnCacheDir, uplink, uplinkMaxage, webEnabled, webhooks, s3AccessKeyId, s3Bucket, s3Endpoint, s3ForcePathStyle, s3KeyPrefix, s3Region, s3SecretAccessKey, publicUrl, authRenown, verdaccioSecret: verdaccioSecretArg, localPackages } = args;
1144
+ const { port, storageDir, cdnCacheDir, uplink, uplinkMaxage, webEnabled, webhooks, s3AccessKeyId, s3Bucket, s3Endpoint, s3ForcePathStyle, s3KeyPrefix, s3Region, s3SecretAccessKey, publicUrl, authRenown, renownUrl, verdaccioSecret: verdaccioSecretArg, localPackages, databaseUrl, pluginsDir, authStore } = args;
969
1145
  const storagePath = await resolveDir(storageDir);
970
1146
  const cdnCachePath = await resolveDir(cdnCacheDir);
971
1147
  const verdaccioSecret = verdaccioSecretArg ?? randomBytes(32).toString("hex");
972
1148
  const renownEnabled = authRenown === true && Boolean(publicUrl);
973
1149
  if (authRenown === true && !publicUrl) console.warn("[registry] auth-renown is enabled but --public-url / PH_REGISTRY_PUBLIC_URL is not set; Renown auth will be disabled.");
1150
+ if (renownEnabled && !databaseUrl && !authStore) console.warn("[registry] Renown auth requires a database (--database-url) for the auth plugin; renown will be inactive.");
974
1151
  console.log({
975
1152
  storagePath,
976
1153
  cdnCachePath
977
1154
  });
978
1155
  const webhookConfigs = webhooks?.split(",").map((url) => url.trim()).filter(Boolean).map((endpoint) => ({ endpoint }));
979
1156
  const localPackagePatterns = localPackages?.split(",").map((p) => p.trim()).filter(Boolean);
1157
+ const sharedAuthStore = authStore ?? (databaseUrl ? createPgStore(createPgPool(databaseUrl)) : void 0);
1158
+ const authStoreToken = sharedAuthStore ? stashAuthStore(sharedAuthStore) : void 0;
980
1159
  const config = {
981
1160
  port,
982
1161
  storagePath,
@@ -986,7 +1165,10 @@ async function runRegistry(args) {
986
1165
  webEnabled,
987
1166
  verdaccioSecret,
988
1167
  ...localPackagePatterns?.length ? { localPackagePatterns } : {},
989
- ...renownEnabled && publicUrl ? { renown: { publicUrl } } : {},
1168
+ ...renownEnabled && publicUrl ? { renown: {
1169
+ publicUrl,
1170
+ ...renownUrl ? { renownUrl } : {}
1171
+ } } : {},
990
1172
  ...webhookConfigs?.length && { notify: { webhooks: webhookConfigs } },
991
1173
  ...s3Bucket && s3Endpoint && s3Region && { s3: {
992
1174
  bucket: s3Bucket,
@@ -996,22 +1178,28 @@ async function runRegistry(args) {
996
1178
  secretAccessKey: s3SecretAccessKey,
997
1179
  keyPrefix: s3KeyPrefix,
998
1180
  s3ForcePathStyle
999
- } }
1181
+ } },
1182
+ ...databaseUrl ? { databaseUrl } : {},
1183
+ ...pluginsDir ? { pluginsDir } : {},
1184
+ ...sharedAuthStore ? { authStore: sharedAuthStore } : {},
1185
+ ...authStoreToken ? { authStoreToken } : {}
1000
1186
  };
1187
+ if (config.databaseUrl || config.authStore) console.log("[registry] Postgres-backed auth plugin active (persistent accounts + package ownership)");
1001
1188
  await mkdir(storagePath, { recursive: true });
1002
1189
  await mkdir(cdnCachePath, { recursive: true });
1003
- const verdaccioHandler = (await runServer(buildVerdaccioConfig(config))).listeners("request")[0];
1190
+ const verdaccioServer = await runServer(buildVerdaccioConfig(config));
1191
+ if (authStoreToken && !wasStoreLoaded(authStoreToken)) {
1192
+ verdaccioServer.close();
1193
+ throw new Error("registry-auth plugin failed to load despite a configured database/auth store; refusing to start without auth and package-ownership enforcement.");
1194
+ }
1195
+ const verdaccioHandler = verdaccioServer.listeners("request")[0];
1004
1196
  const app = express();
1005
1197
  const sseChannel = new SSEChannel();
1006
1198
  const webhookChannel = new WebhookChannel(config.storagePath, config.notify);
1007
1199
  const notifications = new NotificationManager([sseChannel, webhookChannel]);
1008
1200
  const staticDir = await findUp("static", { type: "directory" });
1009
1201
  if (staticDir) app.use("/-/static", express.static(staticDir));
1010
- app.use(createPowerhouseRouter(config, sseChannel, webhookChannel));
1011
- if (config.renown) app.use(createRenownAuthMiddleware({
1012
- publicUrl: config.renown.publicUrl,
1013
- verdaccioSecret
1014
- }));
1202
+ app.use(createPowerhouseRouter(config, sseChannel, webhookChannel, sharedAuthStore));
1015
1203
  app.use(createPublishHook(config, notifications));
1016
1204
  app.use(createUnpublishHook(config, notifications));
1017
1205
  app.use((req, res) => verdaccioHandler(req, res));
@@ -1128,6 +1316,13 @@ const registryCommand = command({
1128
1316
  defaultValue: () => process.env.PH_REGISTRY_AUTH_RENOWN === "true",
1129
1317
  defaultValueIsSerializable: true
1130
1318
  }),
1319
+ renownUrl: option({
1320
+ long: "renown-url",
1321
+ type: optional(string),
1322
+ description: "Renown service base URL for credential verification. Defaults to https://www.renown.id.",
1323
+ defaultValue: () => process.env.PH_REGISTRY_RENOWN_URL,
1324
+ defaultValueIsSerializable: true
1325
+ }),
1131
1326
  verdaccioSecret: option({
1132
1327
  long: "verdaccio-secret",
1133
1328
  type: optional(string),
@@ -1141,10 +1336,23 @@ const registryCommand = command({
1141
1336
  description: "Comma-separated globs (e.g. '@powerhousedao/*,document-model,ph-cmd') served locally only — no npmjs uplink proxy. Lets you re-publish a workspace package whose version already exists on npmjs without bumping.",
1142
1337
  defaultValue: () => process.env.PH_REGISTRY_LOCAL_PACKAGES,
1143
1338
  defaultValueIsSerializable: true
1339
+ }),
1340
+ databaseUrl: option({
1341
+ long: "database-url",
1342
+ type: optional(string),
1343
+ description: "Postgres connection string. When set, the registry uses the DB-backed auth plugin (persistent accounts + npm-style package ownership) instead of the built-in htpasswd.",
1344
+ defaultValue: () => process.env.PH_REGISTRY_DATABASE_URL ?? process.env.DATABASE_URL,
1345
+ defaultValueIsSerializable: true
1144
1346
  })
1145
1347
  },
1146
1348
  handler: async (args) => {
1147
- console.log(args);
1349
+ const redact = (v) => v ? "[redacted]" : void 0;
1350
+ console.log({
1351
+ ...args,
1352
+ databaseUrl: redact(args.databaseUrl),
1353
+ verdaccioSecret: redact(args.verdaccioSecret),
1354
+ s3SecretAccessKey: redact(args.s3SecretAccessKey)
1355
+ });
1148
1356
  try {
1149
1357
  await runRegistry(args);
1150
1358
  } catch (error) {