@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.
- package/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- 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
|
+
}
|