@openparachute/hub 0.5.7 → 0.5.10-rc.10
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/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-modules-ops.test.ts +658 -0
- package/src/__tests__/api-modules.test.ts +426 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/csrf.test.ts +40 -1
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +584 -67
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +123 -53
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +522 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +222 -0
- package/src/__tests__/setup-wizard.test.ts +2089 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +482 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +37 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-modules-ops.ts +585 -0
- package/src/api-modules.ts +367 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve-boot.ts +133 -0
- package/src/commands/serve.ts +214 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +34 -13
- package/src/help.ts +55 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +87 -0
- package/src/hub-server.ts +767 -136
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +298 -150
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +262 -56
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/request-protocol.ts +48 -0
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +30 -18
- package/src/setup-wizard.ts +2009 -0
- package/src/supervisor.ts +411 -0
- package/src/vault-name.ts +71 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
- package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
package/src/admin-config.ts
DELETED
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hub config portal logic (#46) — pure functions over the module manifest +
|
|
3
|
-
* filesystem. Render-side and HTTP-side helpers live in `admin-config-ui.ts`
|
|
4
|
-
* and `admin-handlers.ts`; this file is io + validation.
|
|
5
|
-
*
|
|
6
|
-
* Design choices:
|
|
7
|
-
*
|
|
8
|
-
* - **JSON-only at v1.** The team-lead's brief covers .env / YAML / TOML
|
|
9
|
-
* follow-ups; this commit ships the JSON path so the surface is real
|
|
10
|
-
* for at least one module. Each configurable module's values land at
|
|
11
|
-
* `<configDir>/<name>/config.json` — the same per-module config dir
|
|
12
|
-
* auto-wire and lifecycle already use.
|
|
13
|
-
*
|
|
14
|
-
* - **Skip modules without a `configSchema`.** A module that hasn't
|
|
15
|
-
* declared its operator-editable keys gets no card on the portal, no
|
|
16
|
-
* empty form. This is the explicit edge case from the brief.
|
|
17
|
-
*
|
|
18
|
-
* - **Atomic writes.** Same `tmp + rename` shape as
|
|
19
|
-
* `services-manifest.ts` and `auto-wire.ts` — a crash mid-write must
|
|
20
|
-
* not leave a half-truncated config.json next to a running module.
|
|
21
|
-
*
|
|
22
|
-
* - **Validation = coercion + check.** HTML form values arrive as
|
|
23
|
-
* strings; `validateAndCoerce` coerces each per its declared type
|
|
24
|
-
* (and the `enum` allow-list, when present), reports the first error
|
|
25
|
-
* per field, and returns the typed object on success. Booleans
|
|
26
|
-
* follow the standard form convention: present (any non-empty value)
|
|
27
|
-
* = true, absent = false. Required booleans are accepted as either
|
|
28
|
-
* state — required means "the key is in the schema", not "must be
|
|
29
|
-
* truthy".
|
|
30
|
-
*/
|
|
31
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
32
|
-
import { dirname, join } from "node:path";
|
|
33
|
-
import {
|
|
34
|
-
type ConfigSchema,
|
|
35
|
-
type ConfigSchemaProperty,
|
|
36
|
-
type ModuleManifest,
|
|
37
|
-
readModuleManifest,
|
|
38
|
-
} from "./module-manifest.ts";
|
|
39
|
-
import type { ServiceEntry, ServicesManifest } from "./services-manifest.ts";
|
|
40
|
-
|
|
41
|
-
export interface ConfigurableModule {
|
|
42
|
-
/** Stable ecosystem name (services.json key, route segment). */
|
|
43
|
-
name: string;
|
|
44
|
-
/** Operator-facing label rendered in the portal. */
|
|
45
|
-
displayName: string;
|
|
46
|
-
/** One-line subtitle if the manifest provides one. */
|
|
47
|
-
tagline?: string;
|
|
48
|
-
/** The validated schema from `.parachute/module.json`. */
|
|
49
|
-
schema: ConfigSchema;
|
|
50
|
-
/** Absolute path to `<configDir>/<name>/config.json`. */
|
|
51
|
-
configPath: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface DiscoverDeps {
|
|
55
|
-
/** Resolves installed services (production: `services-manifest.readManifest`). */
|
|
56
|
-
loadServicesManifest: () => ServicesManifest;
|
|
57
|
-
/** `~/.parachute` (or `$PARACHUTE_HOME`) — per-module config lives at `<configDir>/<name>/config.json`. */
|
|
58
|
-
configDir: string;
|
|
59
|
-
/** Test seam — defaults to `module-manifest.readModuleManifest`. */
|
|
60
|
-
readManifest?: (installDir: string) => Promise<ModuleManifest | null>;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function configPathFor(configDir: string, moduleName: string): string {
|
|
64
|
-
return join(configDir, moduleName, "config.json");
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Walk services.json, read each module's `.parachute/module.json` (when its
|
|
69
|
-
* row carries an `installDir`), keep only those with a `configSchema`. The
|
|
70
|
-
* result is sorted by `displayName` so the portal renders deterministically.
|
|
71
|
-
*/
|
|
72
|
-
export async function discoverConfigurableModules(
|
|
73
|
-
deps: DiscoverDeps,
|
|
74
|
-
): Promise<ConfigurableModule[]> {
|
|
75
|
-
const reader = deps.readManifest ?? readModuleManifest;
|
|
76
|
-
const manifest = deps.loadServicesManifest();
|
|
77
|
-
const out: ConfigurableModule[] = [];
|
|
78
|
-
for (const svc of manifest.services) {
|
|
79
|
-
const mod = await readModuleFor(svc, reader);
|
|
80
|
-
if (!mod || !mod.configSchema) continue;
|
|
81
|
-
const entry: ConfigurableModule = {
|
|
82
|
-
name: svc.name,
|
|
83
|
-
displayName: svc.displayName ?? mod.displayName ?? mod.manifestName ?? svc.name,
|
|
84
|
-
schema: mod.configSchema,
|
|
85
|
-
configPath: configPathFor(deps.configDir, svc.name),
|
|
86
|
-
};
|
|
87
|
-
if (svc.tagline ?? mod.tagline) entry.tagline = svc.tagline ?? mod.tagline;
|
|
88
|
-
out.push(entry);
|
|
89
|
-
}
|
|
90
|
-
out.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
91
|
-
return out;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async function readModuleFor(
|
|
95
|
-
svc: ServiceEntry,
|
|
96
|
-
reader: (installDir: string) => Promise<ModuleManifest | null>,
|
|
97
|
-
): Promise<ModuleManifest | null> {
|
|
98
|
-
if (!svc.installDir) return null;
|
|
99
|
-
try {
|
|
100
|
-
return await reader(svc.installDir);
|
|
101
|
-
} catch {
|
|
102
|
-
// A malformed third-party manifest shouldn't take down the whole portal —
|
|
103
|
-
// skip the module and let the operator see the others. Logging the
|
|
104
|
-
// skip is the lifecycle layer's job; this stays pure.
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Read `<configDir>/<name>/config.json`. Returns `{}` for a missing file,
|
|
111
|
-
* `{}` for a malformed file (with a `parseError` flag) — the portal renders
|
|
112
|
-
* defaults for missing keys either way; surfacing parse failures in the UI
|
|
113
|
-
* without erroring the page lets the operator overwrite a corrupted file.
|
|
114
|
-
*/
|
|
115
|
-
export interface ReadConfigResult {
|
|
116
|
-
data: Record<string, unknown>;
|
|
117
|
-
parseError?: string;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function readModuleConfig(configPath: string): ReadConfigResult {
|
|
121
|
-
if (!existsSync(configPath)) return { data: {} };
|
|
122
|
-
let raw: string;
|
|
123
|
-
try {
|
|
124
|
-
raw = readFileSync(configPath, "utf8");
|
|
125
|
-
} catch (err) {
|
|
126
|
-
return { data: {}, parseError: err instanceof Error ? err.message : String(err) };
|
|
127
|
-
}
|
|
128
|
-
try {
|
|
129
|
-
const parsed = JSON.parse(raw);
|
|
130
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
131
|
-
return { data: {}, parseError: "config.json must contain a JSON object" };
|
|
132
|
-
}
|
|
133
|
-
return { data: parsed as Record<string, unknown> };
|
|
134
|
-
} catch (err) {
|
|
135
|
-
return { data: {}, parseError: err instanceof Error ? err.message : String(err) };
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Atomic write: tmp file + rename, mkdir -p first. Same pattern as
|
|
141
|
-
* services-manifest. Trailing newline so the file plays nice with
|
|
142
|
-
* line-oriented tooling (`wc -l`, diffs).
|
|
143
|
-
*/
|
|
144
|
-
export function writeModuleConfig(configPath: string, data: Record<string, unknown>): void {
|
|
145
|
-
mkdirSync(dirname(configPath), { recursive: true });
|
|
146
|
-
const tmp = `${configPath}.tmp-${process.pid}-${Date.now()}`;
|
|
147
|
-
writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`);
|
|
148
|
-
renameSync(tmp, configPath);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
export interface ValidateConfigResult {
|
|
152
|
-
ok: boolean;
|
|
153
|
-
/** Field-level errors; empty when ok === true. */
|
|
154
|
-
errors: Record<string, string>;
|
|
155
|
-
/** Coerced typed values; populated only when ok === true. */
|
|
156
|
-
data?: Record<string, unknown>;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Coerce + validate a form-data submission against `schema`. `formValues`
|
|
161
|
-
* is keyed by property name; checkbox semantics are encoded in the caller
|
|
162
|
-
* (booleans missing → false). The first failure per field is reported;
|
|
163
|
-
* once any field fails, `data` is omitted.
|
|
164
|
-
*/
|
|
165
|
-
export function validateAndCoerce(
|
|
166
|
-
formValues: Record<string, string | boolean | undefined>,
|
|
167
|
-
schema: ConfigSchema,
|
|
168
|
-
): ValidateConfigResult {
|
|
169
|
-
const errors: Record<string, string> = {};
|
|
170
|
-
const data: Record<string, unknown> = {};
|
|
171
|
-
const required = new Set(schema.required ?? []);
|
|
172
|
-
for (const [key, prop] of Object.entries(schema.properties)) {
|
|
173
|
-
const raw = formValues[key];
|
|
174
|
-
const present = raw !== undefined && raw !== "";
|
|
175
|
-
if (!present) {
|
|
176
|
-
if (prop.type === "boolean") {
|
|
177
|
-
// Booleans are always "present" — checkbox absence = false.
|
|
178
|
-
data[key] = false;
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
if (required.has(key)) {
|
|
182
|
-
errors[key] = "required";
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
// Missing optional field → omit from output rather than write a null.
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
const coerced = coerceValue(raw, prop);
|
|
189
|
-
if ("error" in coerced) {
|
|
190
|
-
errors[key] = coerced.error;
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
if (prop.enum && !prop.enum.includes(coerced.value as string | number)) {
|
|
194
|
-
errors[key] = `must be one of: ${prop.enum.join(", ")}`;
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
data[key] = coerced.value;
|
|
198
|
-
}
|
|
199
|
-
if (Object.keys(errors).length > 0) return { ok: false, errors };
|
|
200
|
-
return { ok: true, errors, data };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function coerceValue(
|
|
204
|
-
raw: string | boolean,
|
|
205
|
-
prop: ConfigSchemaProperty,
|
|
206
|
-
): { value: string | number | boolean } | { error: string } {
|
|
207
|
-
switch (prop.type) {
|
|
208
|
-
case "string":
|
|
209
|
-
return { value: typeof raw === "string" ? raw : String(raw) };
|
|
210
|
-
case "boolean":
|
|
211
|
-
if (typeof raw === "boolean") return { value: raw };
|
|
212
|
-
return { value: raw.length > 0 && raw !== "false" && raw !== "0" };
|
|
213
|
-
case "number": {
|
|
214
|
-
const s = typeof raw === "string" ? raw : String(raw);
|
|
215
|
-
const n = Number(s);
|
|
216
|
-
if (!Number.isFinite(n)) return { error: "must be a number" };
|
|
217
|
-
return { value: n };
|
|
218
|
-
}
|
|
219
|
-
case "integer": {
|
|
220
|
-
const s = typeof raw === "string" ? raw : String(raw);
|
|
221
|
-
const n = Number(s);
|
|
222
|
-
if (!Number.isFinite(n) || !Number.isInteger(n)) return { error: "must be an integer" };
|
|
223
|
-
return { value: n };
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|