@openparachute/hub 0.5.13-rc.21 → 0.5.13-rc.23

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
@@ -8,12 +8,30 @@ The hub coordinates the modules running on your machine: it installs them, runs
8
8
 
9
9
  ## Install
10
10
 
11
+ ### Local (Bun)
12
+
11
13
  ```sh
12
14
  bun add -g @openparachute/hub
13
15
  ```
14
16
 
15
17
  Prereqs: [Bun](https://bun.sh) 1.3.0 or later. `parachute expose` also requires [Tailscale](https://tailscale.com/download) **1.82 or newer** (installed + `tailscale up` run once); the `expose` path is under active polish for launch, so expect rough edges.
16
18
 
19
+ ### Hosted (Render)
20
+
21
+ [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/ParachuteComputer/parachute-hub)
22
+
23
+ One-click Render deploy via the `render.yaml` Blueprint in this repo. Provisions a $7/mo Starter service + 1 GiB persistent disk + auto-deploys from `main`. Comes pre-configured with `PARACHUTE_INSTALL_CHANNEL=latest` so modules you install via the admin SPA (vault, app, scribe, runner) pull stable releases by default.
24
+
25
+ **Want pre-release / rc modules?** Set `PARACHUTE_INSTALL_CHANNEL=rc` in your Render dashboard env vars (useful for dev/testing against the rc release line).
26
+
27
+ After deploy:
28
+
29
+ 1. Open your Render service URL → wizard runs at `/admin/setup`
30
+ 2. Set custom domain (optional) → set `PARACHUTE_HUB_ORIGIN` env to match
31
+ 3. Install modules via the admin SPA at `/admin/modules` (or via the wizard)
32
+
33
+ Render's docs on Blueprints: <https://render.com/docs/blueprint-spec>
34
+
17
35
  ## First 5 minutes
18
36
 
19
37
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.13-rc.21",
3
+ "version": "0.5.13-rc.23",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -0,0 +1,251 @@
1
+ /**
2
+ * `GET /api/hub` — hub version + uptime + install-source surface for the
3
+ * admin SPA's version badge.
4
+ *
5
+ * Tests assert the contract:
6
+ * - 405 on non-GET (matches the shape of other /api/* read endpoints).
7
+ * - 401/403 on missing or under-scoped bearer (host:admin required).
8
+ * - Happy path returns the expected shape + uptime increments between
9
+ * calls.
10
+ * - PARACHUTE_HOME=/parachute (the Render Blueprint pin) overrides
11
+ * `source` to "container".
12
+ * - PARACHUTE_BUILD_TIME passes through as `container_build_time`.
13
+ */
14
+ import { describe, expect, test } from "bun:test";
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { dirname, join, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { type HubStatusResponse, handleApiHub } from "../api-hub.ts";
20
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
21
+ import { signAccessToken } from "../jwt-sign.ts";
22
+ import { mintOperatorToken } from "../operator-token.ts";
23
+ import { rotateSigningKey } from "../signing-keys.ts";
24
+ import { createUser } from "../users.ts";
25
+
26
+ interface Harness {
27
+ dir: string;
28
+ cleanup: () => void;
29
+ }
30
+
31
+ function makeHarness(): Harness {
32
+ const dir = mkdtempSync(join(tmpdir(), "phub-api-hub-"));
33
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
34
+ }
35
+
36
+ const ISSUER = "http://127.0.0.1:1939";
37
+
38
+ // `hubSrcDir` defaults to `dirname(import.meta.url)` of api-hub.ts — but in
39
+ // tests we drive it explicitly so test-side test-double dirs don't accidentally
40
+ // pick up the real package.json. Point it at this file's dir (under __tests__/)
41
+ // so the climb-to-package.json loop walks up to the repo root and finds the
42
+ // real hub package.json.
43
+ const HUB_SRC_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
44
+
45
+ async function bootstrap(
46
+ dir: string,
47
+ ): Promise<{ db: ReturnType<typeof openHubDb>; userId: string }> {
48
+ const db = openHubDb(hubDbPath(dir));
49
+ rotateSigningKey(db);
50
+ const u = await createUser(db, "owner", "pw");
51
+ return { db, userId: u.id };
52
+ }
53
+
54
+ function getRequest(headers: Record<string, string> = {}): Request {
55
+ return new Request("http://localhost/api/hub", {
56
+ method: "GET",
57
+ headers,
58
+ });
59
+ }
60
+
61
+ describe("GET /api/hub (hub version + uptime badge surface)", () => {
62
+ test("405 on non-GET", async () => {
63
+ const h = makeHarness();
64
+ try {
65
+ const { db } = await bootstrap(h.dir);
66
+ try {
67
+ const req = new Request("http://localhost/api/hub", { method: "POST" });
68
+ const resp = await handleApiHub(req, { db, issuer: ISSUER });
69
+ expect(resp.status).toBe(405);
70
+ } finally {
71
+ db.close();
72
+ }
73
+ } finally {
74
+ h.cleanup();
75
+ }
76
+ });
77
+
78
+ test("401 when no Authorization header", async () => {
79
+ const h = makeHarness();
80
+ try {
81
+ const { db } = await bootstrap(h.dir);
82
+ try {
83
+ const resp = await handleApiHub(getRequest(), { db, issuer: ISSUER });
84
+ expect(resp.status).toBe(401);
85
+ } finally {
86
+ db.close();
87
+ }
88
+ } finally {
89
+ h.cleanup();
90
+ }
91
+ });
92
+
93
+ test("403 when bearer scope lacks parachute:host:admin", async () => {
94
+ const h = makeHarness();
95
+ try {
96
+ const { db, userId } = await bootstrap(h.dir);
97
+ try {
98
+ const narrow = await signAccessToken(db, {
99
+ sub: userId,
100
+ // Adjacent host scope but NOT host:admin — host:auth is the
101
+ // tokens-registry scope, not the admin one. Confirms the gate
102
+ // checks the exact scope, not any host:* membership.
103
+ scopes: ["parachute:host:auth"],
104
+ audience: "hub",
105
+ clientId: "parachute-hub",
106
+ issuer: ISSUER,
107
+ ttlSeconds: 3600,
108
+ });
109
+ const resp = await handleApiHub(getRequest({ authorization: `Bearer ${narrow.token}` }), {
110
+ db,
111
+ issuer: ISSUER,
112
+ });
113
+ expect(resp.status).toBe(403);
114
+ } finally {
115
+ db.close();
116
+ }
117
+ } finally {
118
+ h.cleanup();
119
+ }
120
+ });
121
+
122
+ test("happy path: returns version + started_at + uptime_ms + source", async () => {
123
+ const h = makeHarness();
124
+ try {
125
+ const { db, userId } = await bootstrap(h.dir);
126
+ try {
127
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
128
+ const startedAt = new Date(Date.now() - 5000); // started 5s ago
129
+ const resp = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
130
+ db,
131
+ issuer: ISSUER,
132
+ hubSrcDir: HUB_SRC_DIR,
133
+ startedAt,
134
+ // Override env so we're not at the mercy of the host's
135
+ // PARACHUTE_HOME (or PARACHUTE_BUILD_TIME) when the test runs.
136
+ env: {},
137
+ });
138
+ expect(resp.status).toBe(200);
139
+ const body = (await resp.json()) as HubStatusResponse;
140
+ // Version pulled from the real hub package.json — assert SemVer
141
+ // shape rather than pinning a specific number (otherwise this test
142
+ // breaks on every rc bump).
143
+ expect(body.version).toMatch(/^\d+\.\d+\.\d+/);
144
+ expect(body.started_at).toBe(startedAt.toISOString());
145
+ expect(body.uptime_ms).toBeGreaterThanOrEqual(5000);
146
+ // hubSrcDir points at the real repo's src/, so install-source
147
+ // classification will report bun-linked OR npm OR unknown — never
148
+ // "container" because we cleared PARACHUTE_HOME.
149
+ expect(body.source).not.toBe("container");
150
+ expect(body.container_build_time).toBeUndefined();
151
+ } finally {
152
+ db.close();
153
+ }
154
+ } finally {
155
+ h.cleanup();
156
+ }
157
+ });
158
+
159
+ test("uptime_ms increments between calls", async () => {
160
+ const h = makeHarness();
161
+ try {
162
+ const { db, userId } = await bootstrap(h.dir);
163
+ try {
164
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
165
+ const startedAt = new Date("2026-05-23T14:00:00.000Z");
166
+ // Drive "now" so the assertion isn't a flaky timing test.
167
+ const first = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
168
+ db,
169
+ issuer: ISSUER,
170
+ hubSrcDir: HUB_SRC_DIR,
171
+ startedAt,
172
+ now: () => new Date("2026-05-23T14:00:05.000Z"),
173
+ env: {},
174
+ });
175
+ const second = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
176
+ db,
177
+ issuer: ISSUER,
178
+ hubSrcDir: HUB_SRC_DIR,
179
+ startedAt,
180
+ now: () => new Date("2026-05-23T14:00:08.000Z"),
181
+ env: {},
182
+ });
183
+ const firstBody = (await first.json()) as HubStatusResponse;
184
+ const secondBody = (await second.json()) as HubStatusResponse;
185
+ expect(firstBody.uptime_ms).toBe(5000);
186
+ expect(secondBody.uptime_ms).toBe(8000);
187
+ expect(secondBody.uptime_ms).toBeGreaterThan(firstBody.uptime_ms);
188
+ // started_at stays stable across calls (captured once at process
189
+ // start, not per-request).
190
+ expect(secondBody.started_at).toBe(firstBody.started_at);
191
+ } finally {
192
+ db.close();
193
+ }
194
+ } finally {
195
+ h.cleanup();
196
+ }
197
+ });
198
+
199
+ test("PARACHUTE_HOME=/parachute overrides source to 'container'", async () => {
200
+ const h = makeHarness();
201
+ try {
202
+ const { db, userId } = await bootstrap(h.dir);
203
+ try {
204
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
205
+ const resp = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
206
+ db,
207
+ issuer: ISSUER,
208
+ hubSrcDir: HUB_SRC_DIR,
209
+ env: { PARACHUTE_HOME: "/parachute" },
210
+ });
211
+ expect(resp.status).toBe(200);
212
+ const body = (await resp.json()) as HubStatusResponse;
213
+ expect(body.source).toBe("container");
214
+ // bun_linked_path is suppressed under container source — operators
215
+ // on Render don't have a meaningful "checkout path" to surface.
216
+ expect(body.bun_linked_path).toBeUndefined();
217
+ } finally {
218
+ db.close();
219
+ }
220
+ } finally {
221
+ h.cleanup();
222
+ }
223
+ });
224
+
225
+ test("PARACHUTE_BUILD_TIME passes through as container_build_time", async () => {
226
+ const h = makeHarness();
227
+ try {
228
+ const { db, userId } = await bootstrap(h.dir);
229
+ try {
230
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
231
+ const resp = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
232
+ db,
233
+ issuer: ISSUER,
234
+ hubSrcDir: HUB_SRC_DIR,
235
+ env: {
236
+ PARACHUTE_HOME: "/parachute",
237
+ PARACHUTE_BUILD_TIME: "2026-05-23T14:21:00.000Z",
238
+ },
239
+ });
240
+ expect(resp.status).toBe(200);
241
+ const body = (await resp.json()) as HubStatusResponse;
242
+ expect(body.container_build_time).toBe("2026-05-23T14:21:00.000Z");
243
+ expect(body.source).toBe("container");
244
+ } finally {
245
+ db.close();
246
+ }
247
+ } finally {
248
+ h.cleanup();
249
+ }
250
+ });
251
+ });
package/src/api-hub.ts ADDED
@@ -0,0 +1,201 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
6
+ import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
7
+ import { detectHubInstallSource } from "./install-source.ts";
8
+
9
+ /**
10
+ * `GET /api/hub` — hub runtime info for the admin SPA's version badge.
11
+ *
12
+ * Operators (especially Render deployers tracking auto-deploys from `main`)
13
+ * need to tell at a glance: what version is this hub running, when did it
14
+ * last restart, is this the version I expect? The CLI surface `parachute
15
+ * status` already shows the same data; this endpoint mirrors it for the
16
+ * browser SPA.
17
+ *
18
+ * Bearer-gated on `parachute:host:admin` (same as `/vaults` and `/api/grants`).
19
+ * Operators-only: the version + uptime + install-source fingerprint isn't
20
+ * sensitive per se, but it's adjacent to operator-only diagnostics so it
21
+ * lives behind the same gate as the rest of the admin surface.
22
+ *
23
+ * Response shape (snake_case to match other `/api/*` endpoints):
24
+ *
25
+ * {
26
+ * "version": "0.5.13-rc.23",
27
+ * "started_at": "2026-05-23T14:23:45.000Z",
28
+ * "uptime_ms": 8025000,
29
+ * "source": "bun-linked" | "npm" | "container" | "unknown",
30
+ * "bun_linked_path": "/Users/.../parachute-hub" // optional
31
+ * "git_head": "a53af21" // optional
32
+ * "container_build_time": "2026-05-23T14:21:00Z" // optional
33
+ * }
34
+ *
35
+ * - `source` reuses `detectHubInstallSource` from install-source.ts (the
36
+ * same logic `parachute status` uses for the SOURCE column). The fourth
37
+ * value `"container"` is hub-specific: when `process.env.PARACHUTE_HOME
38
+ * === "/parachute"` (the Render Blueprint pin) we override `bun-linked`
39
+ * → `container` so the badge tells operators "you're on Render", not
40
+ * the misleading "bun-linked from /app".
41
+ * - `started_at` is captured once at module load (see `HUB_PROCESS_STARTED_AT`).
42
+ * `uptime_ms` is computed server-side at request time so the client
43
+ * doesn't have to deal with clock skew.
44
+ * - `container_build_time` reads `process.env.PARACHUTE_BUILD_TIME` —
45
+ * passed through by the Dockerfile when set as a build arg (operators
46
+ * can set this themselves in render.yaml or via `--build-arg` to surface
47
+ * the image build time). Not surfaced when unset.
48
+ */
49
+
50
+ export interface ApiHubDeps {
51
+ db: Database;
52
+ /** Hub origin — used to validate the bearer's `iss`. */
53
+ issuer: string;
54
+ /**
55
+ * Override the directory used to locate the hub's package.json and to
56
+ * classify install source. Defaults to `dirname(import.meta.url)` —
57
+ * which is `<repo>/src/` for normal layouts. Test seam.
58
+ */
59
+ hubSrcDir?: string;
60
+ /** Override `process.env` lookups. Test seam. */
61
+ env?: Record<string, string | undefined>;
62
+ /** Override the started-at timestamp. Test seam — defaults to the module-level capture. */
63
+ startedAt?: Date;
64
+ /** Override "now" for uptime computation. Test seam. */
65
+ now?: () => Date;
66
+ }
67
+
68
+ /**
69
+ * The hub process's start time. Captured exactly once at module load and
70
+ * re-exported so the request handler returns a stable value across calls
71
+ * (and so the `parachute status` CLI surface in the same process can pull
72
+ * it from one source).
73
+ */
74
+ export const HUB_PROCESS_STARTED_AT: Date = new Date();
75
+
76
+ /** Wire shape returned by `GET /api/hub`. Snake-case to match other `/api/*` endpoints. */
77
+ export interface HubStatusResponse {
78
+ version: string;
79
+ started_at: string;
80
+ uptime_ms: number;
81
+ source: "bun-linked" | "npm" | "container" | "unknown";
82
+ bun_linked_path?: string;
83
+ git_head?: string;
84
+ container_build_time?: string;
85
+ /** Render-set runtime env vars surfaced when running on Render. Sourced from RENDER_GIT_COMMIT + RENDER_GIT_BRANCH. */
86
+ render_commit?: string;
87
+ render_branch?: string;
88
+ }
89
+
90
+ export async function handleApiHub(req: Request, deps: ApiHubDeps): Promise<Response> {
91
+ if (req.method !== "GET") {
92
+ return jsonError(405, "method_not_allowed", "use GET");
93
+ }
94
+
95
+ // Bearer-gate on `parachute:host:admin`. Same shape as the other admin
96
+ // endpoints — SPA mints via /admin/host-admin-token.
97
+ try {
98
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
99
+ } catch (err) {
100
+ return adminAuthErrorResponse(err);
101
+ }
102
+
103
+ const env = deps.env ?? process.env;
104
+ const startedAt = deps.startedAt ?? HUB_PROCESS_STARTED_AT;
105
+ const now = (deps.now ?? (() => new Date()))();
106
+ const hubSrcDir = deps.hubSrcDir ?? dirname(fileURLToPath(import.meta.url));
107
+
108
+ // detectHubInstallSource climbs from src/ to the nearest package.json,
109
+ // reads its version, classifies as bun-linked vs npm vs unknown.
110
+ const source = detectHubInstallSource(hubSrcDir);
111
+
112
+ // Read version from the nearest package.json (same logic the install-source
113
+ // detector ran). We don't reuse `source.livePackageVersion` because the
114
+ // unknown-source branch leaves it undefined — but the version field on the
115
+ // response must always be present.
116
+ const version = readHubVersion(hubSrcDir) ?? "unknown";
117
+
118
+ // Container override: the Render Blueprint pins PARACHUTE_HOME=/parachute,
119
+ // which is a reliable container-mode signal. The install-source detector
120
+ // would label this `bun-linked` (the image runs from /app/src, not bun
121
+ // globals), which is technically true but misleading for the operator —
122
+ // "container" is what they actually want to see.
123
+ const isContainer = env.PARACHUTE_HOME === "/parachute";
124
+
125
+ const body: HubStatusResponse = {
126
+ version,
127
+ started_at: startedAt.toISOString(),
128
+ uptime_ms: Math.max(0, now.getTime() - startedAt.getTime()),
129
+ source: isContainer ? "container" : source.kind,
130
+ };
131
+
132
+ if (!isContainer && source.kind === "bun-linked" && source.path) {
133
+ body.bun_linked_path = source.path;
134
+ }
135
+ // `git_head` is meaningful only for bun-linked dev installs — the container
136
+ // image strips `.git` at build time so source.gitHead is always undefined
137
+ // there. Explicit !isContainer guard for symmetry with bun_linked_path.
138
+ if (!isContainer && source.gitHead) {
139
+ body.git_head = source.gitHead;
140
+ }
141
+ const buildTime = env.PARACHUTE_BUILD_TIME;
142
+ if (typeof buildTime === "string" && buildTime.length > 0) {
143
+ body.container_build_time = buildTime;
144
+ }
145
+ // Render exposes RENDER_GIT_COMMIT + RENDER_GIT_BRANCH at runtime when
146
+ // the container is running on Render. Surface for operator diagnostics
147
+ // (the commit SHA is a more rigorous identity than build-time wall-clock).
148
+ // Container-mode only — local dev never has these set.
149
+ if (isContainer) {
150
+ const renderCommit = env.RENDER_GIT_COMMIT;
151
+ if (typeof renderCommit === "string" && renderCommit.length > 0) {
152
+ body.render_commit = renderCommit;
153
+ }
154
+ const renderBranch = env.RENDER_GIT_BRANCH;
155
+ if (typeof renderBranch === "string" && renderBranch.length > 0) {
156
+ body.render_branch = renderBranch;
157
+ }
158
+ }
159
+
160
+ return new Response(JSON.stringify(body), {
161
+ status: 200,
162
+ headers: {
163
+ "content-type": "application/json",
164
+ "cache-control": "no-store",
165
+ },
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Read the hub's package.json `version` field. Climbs from `srcDir` to the
171
+ * nearest package.json (same pattern as `findNearestPackageDir` in
172
+ * install-source.ts). Returns undefined if the file's missing or malformed.
173
+ */
174
+ function readHubVersion(srcDir: string): string | undefined {
175
+ let current = resolve(srcDir);
176
+ for (let i = 0; i < 16; i++) {
177
+ try {
178
+ const parsed = JSON.parse(readFileSync(join(current, "package.json"), "utf8")) as unknown;
179
+ if (parsed && typeof parsed === "object") {
180
+ const v = (parsed as Record<string, unknown>).version;
181
+ if (typeof v === "string" && v.length > 0) return v;
182
+ }
183
+ } catch {
184
+ // No package.json here — climb.
185
+ }
186
+ const parent = dirname(current);
187
+ if (parent === current) return undefined;
188
+ current = parent;
189
+ }
190
+ return undefined;
191
+ }
192
+
193
+ function jsonError(status: number, error: string, description: string): Response {
194
+ return new Response(JSON.stringify({ error, error_description: description }), {
195
+ status,
196
+ headers: {
197
+ "content-type": "application/json",
198
+ "cache-control": "no-store",
199
+ },
200
+ });
201
+ }
package/src/hub-server.ts CHANGED
@@ -46,6 +46,7 @@
46
46
  * /admin/host-admin-token (GET) → SPA bearer mint (cookie-gated)
47
47
  * /admin/vault-admin-token/<n> (GET) → per-vault bearer mint (cookie-gated)
48
48
  * /api/me (GET) → who-am-I (session+CSRF or hasSession:false)
49
+ * /api/hub (GET) → hub version + uptime + install-source (host:admin)
49
50
  * /api/modules (GET) → curated + installed module catalog (host:auth)
50
51
  * /api/modules/channel (PUT) → operator channel toggle (host:admin)
51
52
  * /api/modules/:short/install (POST) → bun add + spawn (async op)
@@ -117,6 +118,7 @@ import { handleHostAdminToken } from "./admin-host-admin-token.ts";
117
118
  import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
118
119
  import { handleCreateVault } from "./admin-vaults.ts";
119
120
  import { handleAccountChangePasswordGet, handleAccountChangePasswordPost } from "./api-account.ts";
121
+ import { handleApiHub } from "./api-hub.ts";
120
122
  import { handleApiMe } from "./api-me.ts";
121
123
  import { handleApiMintToken } from "./api-mint-token.ts";
122
124
  import { handleApiModulesConfig, parseModulesConfigPath } from "./api-modules-config.ts";
@@ -1526,6 +1528,17 @@ export function hubFetch(
1526
1528
  return handleApiMe(req, { db: getDb() });
1527
1529
  }
1528
1530
 
1531
+ // Hub version + uptime + install-source — drives the admin SPA's
1532
+ // version badge (hub#348). Bearer-gated on `parachute:host:admin`
1533
+ // (same as the rest of the operator-only admin surface).
1534
+ if (pathname === "/api/hub") {
1535
+ if (!getDb) return dbNotConfigured();
1536
+ return handleApiHub(req, {
1537
+ db: getDb(),
1538
+ issuer: oauthDeps(req).issuer,
1539
+ });
1540
+ }
1541
+
1529
1542
  if (pathname === "/api/modules") {
1530
1543
  if (!getDb) return dbNotConfigured();
1531
1544
  const modulesDeps: Parameters<typeof handleApiModules>[1] = {