@openparachute/hub 0.3.0-rc.1 → 0.5.1

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 (91) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +1063 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +616 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +851 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +269 -38
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. package/web/ui/dist/index.html +14 -0
@@ -0,0 +1,435 @@
1
+ /**
2
+ * `.parachute/module.json` — the contract that makes a package a Parachute
3
+ * module. Author-controlled, shipped in the published artifact, read by the
4
+ * CLI on `parachute install <package>`.
5
+ *
6
+ * The shape mirrors `parachute-patterns/patterns/module-json-extensibility.md`.
7
+ * Third-party modules are first-class: no `@openparachute/` scope or
8
+ * `parachute-*` prefix required — `module.json` is what makes a package a
9
+ * module. First-party modules will eventually ship their own `module.json`
10
+ * and the vendored fallbacks in `service-spec.ts` go away one by one.
11
+ *
12
+ * Design note — what's NOT in this manifest:
13
+ * - `version`: that's the package's own `package.json` version, not a
14
+ * module-protocol versioning lever. If we ever break the manifest shape
15
+ * we'll add `manifestVersion: 1` (deferred until v2 is real).
16
+ * - imperative behaviors like `init` argv, post-install footers, dynamic
17
+ * startCmd that needs per-install entry data: those live in the
18
+ * first-party fallback's `extras` block in `service-spec.ts` because
19
+ * they don't fit a static schema.
20
+ * - runtime metadata: `displayName`, `tagline`, capabilities etc. that the
21
+ * hub renders are at `/.parachute/info` (runtime, can change without
22
+ * reinstall). The boundary: install-time → here; runtime → there.
23
+ */
24
+ import { promises as fs } from "node:fs";
25
+ import { join } from "node:path";
26
+
27
+ export type ModuleKind = "api" | "frontend" | "tool";
28
+
29
+ export interface ModuleScopeBlock {
30
+ /** OAuth scopes this module owns. Namespaced by `name` per oauth-scopes.md. */
31
+ readonly defines?: readonly string[];
32
+ }
33
+
34
+ export interface ModuleDependency {
35
+ /** True = absent dependency is fine; false = install fails without it. */
36
+ readonly optional?: boolean;
37
+ /** Scopes this module wants on the dependency, for auto-wired tokens. */
38
+ readonly scopes?: readonly string[];
39
+ }
40
+
41
+ /**
42
+ * Subset of JSON Schema understood by the hub config portal (#46). Author-
43
+ * controlled: each module declares the keys an operator can edit and the
44
+ * type/constraints on each. The portal renders a form from this declaration,
45
+ * validates+coerces submits against it, and writes the result to
46
+ * `<configDir>/<name>/config.json`.
47
+ *
48
+ * Intentionally narrow at v1 — flat string/number/integer/boolean keys, with
49
+ * optional `enum` and `default`. Nested objects, arrays, oneOf, allOf, $ref
50
+ * are deferred until a concrete module asks for them.
51
+ */
52
+ export type ConfigPropertyType = "string" | "number" | "integer" | "boolean";
53
+
54
+ export interface ConfigSchemaProperty {
55
+ readonly type: ConfigPropertyType;
56
+ /** Operator-facing label rendered next to the input. */
57
+ readonly description?: string;
58
+ /** Pre-fill value when no config.json exists yet. */
59
+ readonly default?: string | number | boolean;
60
+ /** Restrict to a fixed set; rendered as a `<select>`. Only meaningful for string/number/integer. */
61
+ readonly enum?: readonly (string | number)[];
62
+ }
63
+
64
+ export interface ConfigSchema {
65
+ readonly type: "object";
66
+ readonly properties: Record<string, ConfigSchemaProperty>;
67
+ readonly required?: readonly string[];
68
+ }
69
+
70
+ export interface ModuleManifest {
71
+ /** Stable ecosystem identifier — `[a-z][a-z0-9-]*`, also the services.json key. */
72
+ readonly name: string;
73
+ /** User-facing manifest name (often === name). */
74
+ readonly manifestName: string;
75
+ /** Human label rendered on the hub card. */
76
+ readonly displayName?: string;
77
+ /** One-line subtitle rendered under displayName. */
78
+ readonly tagline?: string;
79
+ /** Drives card vs. iframe vs. launcher in the hub. */
80
+ readonly kind: ModuleKind;
81
+ /** Default loopback port. CLI warns on conflict, doesn't block. */
82
+ readonly port: number;
83
+ /** URL paths the module serves under the hub origin. */
84
+ readonly paths: readonly string[];
85
+ /** Path for liveness probes — must start with `/`. */
86
+ readonly health: string;
87
+ /** Argv the CLI invokes for `parachute start <name>`. Resolved relative to
88
+ * the installed package; static (not entry-aware). */
89
+ readonly startCmd?: readonly string[];
90
+ /** OAuth scopes block — see oauth-scopes.md. */
91
+ readonly scopes?: ModuleScopeBlock;
92
+ /** Auto-wire targets — see service-to-service-auth.md. */
93
+ readonly dependencies?: Record<string, ModuleDependency>;
94
+ /**
95
+ * Operator-editable config keys — see hub#46 + this file's `ConfigSchema`.
96
+ * When present, the hub config portal renders a form for these keys and
97
+ * writes the submitted values to `<configDir>/<name>/config.json` (JSON-only
98
+ * at v1 — modules using `.env`/YAML/TOML are deferred). When absent, the
99
+ * portal skips the module rather than rendering an empty form.
100
+ */
101
+ readonly configSchema?: ConfigSchema;
102
+ /**
103
+ * Where the module's admin UI lives. Hub renders a "Manage" link when set
104
+ * (see `parachute-patterns/patterns/module-json-extensibility.md`).
105
+ *
106
+ * Two shapes:
107
+ * - A relative path (e.g. `"/admin"`) — hub resolves against the module's
108
+ * mounted URL: `<module-url><managementUrl>`. Most first-party modules
109
+ * take this path so the admin UI rides the same Tailscale Funnel cap.
110
+ * - A full absolute URL — hub uses verbatim. Escape hatch for modules
111
+ * whose admin UI is hosted off-origin.
112
+ *
113
+ * Absent = no link rendered (CLI-only management). Same back-compat rule
114
+ * as `hasAuth` / `init` / `urlForEntry`.
115
+ */
116
+ readonly managementUrl?: string;
117
+ }
118
+
119
+ export class ModuleManifestError extends Error {
120
+ override name = "ModuleManifestError";
121
+ }
122
+
123
+ const NAME_RE = /^[a-z][a-z0-9-]*$/;
124
+
125
+ function asString(v: unknown, where: string, field: string): string {
126
+ if (typeof v !== "string" || v.length === 0) {
127
+ throw new ModuleManifestError(`${where}: "${field}" must be a non-empty string`);
128
+ }
129
+ return v;
130
+ }
131
+
132
+ function asOptionalString(v: unknown, where: string, field: string): string | undefined {
133
+ if (v === undefined) return undefined;
134
+ if (typeof v !== "string") {
135
+ throw new ModuleManifestError(`${where}: "${field}" must be a string if present`);
136
+ }
137
+ return v;
138
+ }
139
+
140
+ function asKind(v: unknown, where: string): ModuleKind {
141
+ if (v !== "api" && v !== "frontend" && v !== "tool") {
142
+ throw new ModuleManifestError(`${where}: "kind" must be "api" | "frontend" | "tool"`);
143
+ }
144
+ return v;
145
+ }
146
+
147
+ function asPort(v: unknown, where: string): number {
148
+ if (typeof v !== "number" || !Number.isInteger(v) || v <= 0 || v > 65535) {
149
+ throw new ModuleManifestError(`${where}: "port" must be an integer 1..65535`);
150
+ }
151
+ return v;
152
+ }
153
+
154
+ function asStringArray(v: unknown, where: string, field: string): readonly string[] {
155
+ if (!Array.isArray(v) || v.some((p) => typeof p !== "string")) {
156
+ throw new ModuleManifestError(`${where}: "${field}" must be an array of strings`);
157
+ }
158
+ return v as readonly string[];
159
+ }
160
+
161
+ function asHealthPath(v: unknown, where: string): string {
162
+ const s = asString(v, where, "health");
163
+ if (!s.startsWith("/")) {
164
+ throw new ModuleManifestError(`${where}: "health" must start with "/"`);
165
+ }
166
+ return s;
167
+ }
168
+
169
+ function asScopes(v: unknown, where: string): ModuleScopeBlock | undefined {
170
+ if (v === undefined) return undefined;
171
+ if (!v || typeof v !== "object") {
172
+ throw new ModuleManifestError(`${where}: "scopes" must be an object if present`);
173
+ }
174
+ const defines = (v as Record<string, unknown>).defines;
175
+ if (defines === undefined) return {};
176
+ return { defines: asStringArray(defines, where, "scopes.defines") };
177
+ }
178
+
179
+ const CONFIG_PROPERTY_TYPES = new Set<ConfigPropertyType>([
180
+ "string",
181
+ "number",
182
+ "integer",
183
+ "boolean",
184
+ ]);
185
+
186
+ function asConfigSchemaProperty(v: unknown, where: string, field: string): ConfigSchemaProperty {
187
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
188
+ throw new ModuleManifestError(`${where}: "${field}" must be an object`);
189
+ }
190
+ const p = v as Record<string, unknown>;
191
+ if (typeof p.type !== "string" || !CONFIG_PROPERTY_TYPES.has(p.type as ConfigPropertyType)) {
192
+ throw new ModuleManifestError(
193
+ `${where}: "${field}.type" must be one of "string" | "number" | "integer" | "boolean"`,
194
+ );
195
+ }
196
+ const type = p.type as ConfigPropertyType;
197
+ const out: ConfigSchemaProperty = { type };
198
+ if (p.description !== undefined) {
199
+ if (typeof p.description !== "string") {
200
+ throw new ModuleManifestError(`${where}: "${field}.description" must be a string if present`);
201
+ }
202
+ (out as { description?: string }).description = p.description;
203
+ }
204
+ if (p.default !== undefined) {
205
+ const t = typeof p.default;
206
+ if (t !== "string" && t !== "number" && t !== "boolean") {
207
+ throw new ModuleManifestError(
208
+ `${where}: "${field}.default" must be string | number | boolean if present`,
209
+ );
210
+ }
211
+ (out as { default?: string | number | boolean }).default = p.default as
212
+ | string
213
+ | number
214
+ | boolean;
215
+ }
216
+ if (p.enum !== undefined) {
217
+ if (!Array.isArray(p.enum) || p.enum.length === 0) {
218
+ throw new ModuleManifestError(
219
+ `${where}: "${field}.enum" must be a non-empty array if present`,
220
+ );
221
+ }
222
+ if (type === "boolean") {
223
+ throw new ModuleManifestError(`${where}: "${field}.enum" is not meaningful for boolean type`);
224
+ }
225
+ for (const v of p.enum) {
226
+ const t = typeof v;
227
+ if (type === "string" && t !== "string") {
228
+ throw new ModuleManifestError(
229
+ `${where}: "${field}.enum" entries must be strings when type is "string"`,
230
+ );
231
+ }
232
+ if ((type === "number" || type === "integer") && t !== "number") {
233
+ throw new ModuleManifestError(
234
+ `${where}: "${field}.enum" entries must be numbers when type is "${type}"`,
235
+ );
236
+ }
237
+ if (type === "integer" && !Number.isInteger(v)) {
238
+ throw new ModuleManifestError(
239
+ `${where}: "${field}.enum" entries must be integers when type is "integer"`,
240
+ );
241
+ }
242
+ }
243
+ (out as { enum?: readonly (string | number)[] }).enum = p.enum as readonly (string | number)[];
244
+ }
245
+ return out;
246
+ }
247
+
248
+ function asConfigSchema(v: unknown, where: string): ConfigSchema | undefined {
249
+ if (v === undefined) return undefined;
250
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
251
+ throw new ModuleManifestError(`${where}: "configSchema" must be an object if present`);
252
+ }
253
+ const s = v as Record<string, unknown>;
254
+ if (s.type !== "object") {
255
+ throw new ModuleManifestError(`${where}: "configSchema.type" must be "object"`);
256
+ }
257
+ if (!s.properties || typeof s.properties !== "object" || Array.isArray(s.properties)) {
258
+ throw new ModuleManifestError(`${where}: "configSchema.properties" must be an object`);
259
+ }
260
+ const propsRaw = s.properties as Record<string, unknown>;
261
+ const properties: Record<string, ConfigSchemaProperty> = {};
262
+ for (const [k, raw] of Object.entries(propsRaw)) {
263
+ properties[k] = asConfigSchemaProperty(raw, where, `configSchema.properties.${k}`);
264
+ }
265
+ let required: readonly string[] | undefined;
266
+ if (s.required !== undefined) {
267
+ required = asStringArray(s.required, where, "configSchema.required");
268
+ for (const r of required) {
269
+ if (!properties[r]) {
270
+ throw new ModuleManifestError(
271
+ `${where}: "configSchema.required" names "${r}" but it is not declared in "properties"`,
272
+ );
273
+ }
274
+ }
275
+ }
276
+ const out: ConfigSchema = { type: "object", properties };
277
+ if (required !== undefined) (out as { required?: readonly string[] }).required = required;
278
+ return out;
279
+ }
280
+
281
+ function asDependencies(v: unknown, where: string): Record<string, ModuleDependency> | undefined {
282
+ if (v === undefined) return undefined;
283
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
284
+ throw new ModuleManifestError(`${where}: "dependencies" must be an object if present`);
285
+ }
286
+ const out: Record<string, ModuleDependency> = {};
287
+ for (const [k, raw] of Object.entries(v as Record<string, unknown>)) {
288
+ if (!raw || typeof raw !== "object") {
289
+ throw new ModuleManifestError(`${where}: "dependencies.${k}" must be an object`);
290
+ }
291
+ const dep = raw as Record<string, unknown>;
292
+ const entry: ModuleDependency = {};
293
+ if (dep.optional !== undefined) {
294
+ if (typeof dep.optional !== "boolean") {
295
+ throw new ModuleManifestError(`${where}: "dependencies.${k}.optional" must be boolean`);
296
+ }
297
+ (entry as { optional?: boolean }).optional = dep.optional;
298
+ }
299
+ if (dep.scopes !== undefined) {
300
+ (entry as { scopes?: readonly string[] }).scopes = asStringArray(
301
+ dep.scopes,
302
+ where,
303
+ `dependencies.${k}.scopes`,
304
+ );
305
+ }
306
+ out[k] = entry;
307
+ }
308
+ return out;
309
+ }
310
+
311
+ /**
312
+ * Strict validator. Throws `ModuleManifestError` with the source path so
313
+ * malformed third-party modules get a clear-enough error to fix. Required
314
+ * fields are name, manifestName, kind, port, paths, health.
315
+ */
316
+ export function validateModuleManifest(raw: unknown, where: string): ModuleManifest {
317
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
318
+ throw new ModuleManifestError(`${where}: root must be an object`);
319
+ }
320
+ const m = raw as Record<string, unknown>;
321
+
322
+ const name = asString(m.name, where, "name");
323
+ if (!NAME_RE.test(name)) {
324
+ throw new ModuleManifestError(
325
+ `${where}: "name" must match ${NAME_RE} (lowercase letters, digits, hyphens; lead with a letter)`,
326
+ );
327
+ }
328
+ const manifestName = asString(m.manifestName, where, "manifestName");
329
+ const kind = asKind(m.kind, where);
330
+ const port = asPort(m.port, where);
331
+ const paths = asStringArray(m.paths, where, "paths");
332
+ const health = asHealthPath(m.health, where);
333
+ const displayName = asOptionalString(m.displayName, where, "displayName");
334
+ const tagline = asOptionalString(m.tagline, where, "tagline");
335
+
336
+ let startCmd: readonly string[] | undefined;
337
+ if (m.startCmd !== undefined) {
338
+ startCmd = asStringArray(m.startCmd, where, "startCmd");
339
+ if (startCmd.length === 0) {
340
+ throw new ModuleManifestError(`${where}: "startCmd" must be non-empty if present`);
341
+ }
342
+ }
343
+
344
+ const scopes = asScopes(m.scopes, where);
345
+ // Scope-namespace rule: `name:foo` scopes must match the module's name. This
346
+ // prevents a third party from declaring `vault:read` and squatting on a
347
+ // namespace the user already trusts for a different module.
348
+ if (scopes?.defines) {
349
+ for (const s of scopes.defines) {
350
+ const colon = s.indexOf(":");
351
+ if (colon <= 0) {
352
+ throw new ModuleManifestError(
353
+ `${where}: scope "${s}" must be namespaced as "<name>:<verb>"`,
354
+ );
355
+ }
356
+ const ns = s.slice(0, colon);
357
+ if (ns !== name) {
358
+ throw new ModuleManifestError(
359
+ `${where}: scope "${s}" namespace "${ns}" does not match module name "${name}"`,
360
+ );
361
+ }
362
+ }
363
+ }
364
+
365
+ const dependencies = asDependencies(m.dependencies, where);
366
+ const configSchema = asConfigSchema(m.configSchema, where);
367
+ const managementUrl = asManagementUrl(m.managementUrl, where);
368
+
369
+ const out: ModuleManifest = { name, manifestName, kind, port, paths, health };
370
+ if (displayName !== undefined) (out as { displayName?: string }).displayName = displayName;
371
+ if (tagline !== undefined) (out as { tagline?: string }).tagline = tagline;
372
+ if (startCmd !== undefined) (out as { startCmd?: readonly string[] }).startCmd = startCmd;
373
+ if (scopes !== undefined) (out as { scopes?: ModuleScopeBlock }).scopes = scopes;
374
+ if (dependencies !== undefined) {
375
+ (out as { dependencies?: Record<string, ModuleDependency> }).dependencies = dependencies;
376
+ }
377
+ if (configSchema !== undefined) {
378
+ (out as { configSchema?: ConfigSchema }).configSchema = configSchema;
379
+ }
380
+ if (managementUrl !== undefined) {
381
+ (out as { managementUrl?: string }).managementUrl = managementUrl;
382
+ }
383
+ return out;
384
+ }
385
+
386
+ function asManagementUrl(v: unknown, where: string): string | undefined {
387
+ if (v === undefined) return undefined;
388
+ if (typeof v !== "string" || v.length === 0) {
389
+ throw new ModuleManifestError(
390
+ `${where}: "managementUrl" must be a non-empty string if present`,
391
+ );
392
+ }
393
+ // Two valid shapes: a path starting with "/" or a full http(s) URL.
394
+ if (v.startsWith("/")) return v;
395
+ try {
396
+ const u = new URL(v);
397
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
398
+ throw new ModuleManifestError(
399
+ `${where}: "managementUrl" absolute form must use http: or https:`,
400
+ );
401
+ }
402
+ return v;
403
+ } catch (err) {
404
+ if (err instanceof ModuleManifestError) throw err;
405
+ throw new ModuleManifestError(
406
+ `${where}: "managementUrl" must be a path starting with "/" or a full http(s) URL`,
407
+ );
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Read `<packageDir>/.parachute/module.json`. Returns null if the file is
413
+ * absent (caller decides whether that's an error — first-party modules fall
414
+ * back to the vendored manifest; third-party hard-errors). Throws
415
+ * `ModuleManifestError` on parse / validation failure.
416
+ */
417
+ export async function readModuleManifest(packageDir: string): Promise<ModuleManifest | null> {
418
+ const path = join(packageDir, ".parachute", "module.json");
419
+ let buf: string;
420
+ try {
421
+ buf = await fs.readFile(path, "utf8");
422
+ } catch (err) {
423
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
424
+ throw err;
425
+ }
426
+ let parsed: unknown;
427
+ try {
428
+ parsed = JSON.parse(buf);
429
+ } catch (err) {
430
+ throw new ModuleManifestError(
431
+ `${path}: failed to parse JSON: ${err instanceof Error ? err.message : String(err)}`,
432
+ );
433
+ }
434
+ return validateModuleManifest(parsed, path);
435
+ }