@openparachute/hub 0.5.2 → 0.5.9-rc.6

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +159 -320
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +123 -0
  12. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  13. package/src/__tests__/expose.test.ts +199 -340
  14. package/src/__tests__/hub-server.test.ts +986 -66
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/install.test.ts +50 -31
  18. package/src/__tests__/jwt-sign.test.ts +205 -0
  19. package/src/__tests__/lifecycle.test.ts +97 -2
  20. package/src/__tests__/module-manifest.test.ts +48 -0
  21. package/src/__tests__/notes-serve.test.ts +154 -2
  22. package/src/__tests__/oauth-handlers.test.ts +1000 -3
  23. package/src/__tests__/operator-token.test.ts +379 -3
  24. package/src/__tests__/origin-check.test.ts +220 -0
  25. package/src/__tests__/port-assign.test.ts +41 -52
  26. package/src/__tests__/rate-limit.test.ts +190 -0
  27. package/src/__tests__/services-manifest.test.ts +341 -0
  28. package/src/__tests__/setup.test.ts +12 -9
  29. package/src/__tests__/status.test.ts +372 -0
  30. package/src/__tests__/well-known.test.ts +69 -0
  31. package/src/admin-clients.ts +139 -0
  32. package/src/admin-handlers.ts +63 -260
  33. package/src/admin-host-admin-token.ts +25 -10
  34. package/src/admin-login-ui.ts +256 -0
  35. package/src/admin-vault-admin-token.ts +1 -1
  36. package/src/api-me.ts +124 -0
  37. package/src/api-mint-token.ts +239 -0
  38. package/src/api-revocation-list.ts +59 -0
  39. package/src/api-revoke-token.ts +153 -0
  40. package/src/api-tokens.ts +224 -0
  41. package/src/commands/auth.ts +408 -51
  42. package/src/commands/expose-2fa-warning.ts +82 -0
  43. package/src/commands/expose-cloudflare.ts +27 -0
  44. package/src/commands/expose-public-auto.ts +3 -7
  45. package/src/commands/expose.ts +88 -173
  46. package/src/commands/install.ts +11 -13
  47. package/src/commands/lifecycle.ts +53 -4
  48. package/src/commands/status.ts +99 -8
  49. package/src/csrf.ts +6 -3
  50. package/src/help.ts +13 -7
  51. package/src/hub-db.ts +63 -0
  52. package/src/hub-server.ts +572 -106
  53. package/src/hub.ts +272 -149
  54. package/src/install-source.ts +291 -0
  55. package/src/jwt-sign.ts +265 -5
  56. package/src/module-manifest.ts +48 -10
  57. package/src/notes-serve.ts +70 -9
  58. package/src/oauth-handlers.ts +395 -29
  59. package/src/oauth-ui.ts +188 -0
  60. package/src/operator-token.ts +272 -18
  61. package/src/origin-check.ts +127 -0
  62. package/src/port-assign.ts +28 -35
  63. package/src/rate-limit.ts +166 -0
  64. package/src/scope-explanations.ts +33 -2
  65. package/src/service-spec.ts +58 -13
  66. package/src/services-manifest.ts +62 -3
  67. package/src/sessions.ts +19 -0
  68. package/src/well-known.ts +54 -1
  69. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  70. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/__tests__/admin-config.test.ts +0 -281
  73. package/src/admin-config-ui.ts +0 -534
  74. package/src/admin-config.ts +0 -226
  75. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  76. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -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
- }