@openparachute/app 0.2.0-rc.4
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/.parachute/config/schema +62 -0
- package/.parachute/info +14 -0
- package/.parachute/module.json +14 -0
- package/CHANGELOG.md +405 -0
- package/LICENSE +661 -0
- package/bin/parachute-app.ts +525 -0
- package/dist/admin/assets/index-BXlRNPxk.js +60 -0
- package/dist/admin/assets/index-DaGP1hmw.css +1 -0
- package/dist/admin/index.html +14 -0
- package/package.json +51 -0
- package/src/admin-routes.ts +884 -0
- package/src/auth.ts +212 -0
- package/src/bootstrap.ts +153 -0
- package/src/cache-headers.ts +106 -0
- package/src/config.ts +289 -0
- package/src/dcr.ts +334 -0
- package/src/dev-injection.ts +166 -0
- package/src/dev-mode.ts +205 -0
- package/src/dev-routes.ts +380 -0
- package/src/dev-watcher.ts +479 -0
- package/src/http-server.ts +533 -0
- package/src/index.ts +394 -0
- package/src/meta-schema.ts +662 -0
- package/src/npm-fetch.ts +320 -0
- package/src/operator-token.ts +95 -0
- package/src/provision-schema.ts +180 -0
- package/src/self-register.ts +155 -0
- package/src/services-manifest.ts +104 -0
- package/src/ui-registry.ts +202 -0
package/src/npm-fetch.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm-fetch shorthand for `POST /app/add`.
|
|
3
|
+
*
|
|
4
|
+
* When the operator passes `source: "@openparachute/notes-ui"` (or any npm
|
|
5
|
+
* specifier matching `(@scope/)?name(@version)?`), apps runs `bun add <spec>`
|
|
6
|
+
* into a staging temp dir, copies the package's `dist/` into the UI's home
|
|
7
|
+
* under `~/.parachute/app/uis/<name>/dist/`, and copies `meta.json` if the
|
|
8
|
+
* package ships one.
|
|
9
|
+
*
|
|
10
|
+
* The flow per design doc section 4:
|
|
11
|
+
* 1. Make a fresh staging dir under `/tmp/parachute-app-staging-<random>`.
|
|
12
|
+
* 2. Initialize a minimal `package.json` so `bun add` has somewhere to
|
|
13
|
+
* write. `bun add` requires a package.json in the cwd.
|
|
14
|
+
* 3. `bun add <spec>` — this fetches + installs into `staging/node_modules/`.
|
|
15
|
+
* 4. Locate the installed package: `staging/node_modules/<pkg-from-spec>`.
|
|
16
|
+
* 5. Validate the package has `dist/index.html` (the bundle).
|
|
17
|
+
* 6. Return path-pointers the caller copies + cleans up.
|
|
18
|
+
*
|
|
19
|
+
* Failure modes:
|
|
20
|
+
* - Spec doesn't match the npm naming pattern → `NpmFetchError` `code: "bad_spec"`.
|
|
21
|
+
* - `bun add` exits non-zero → `code: "fetch_failed"` carrying stderr.
|
|
22
|
+
* - Installed package has no `dist/` → `code: "no_dist"`.
|
|
23
|
+
*
|
|
24
|
+
* The caller (admin-routes `/app/add`) cleans up the staging dir in a
|
|
25
|
+
* `finally` regardless of outcome.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
copyFileSync,
|
|
30
|
+
existsSync,
|
|
31
|
+
mkdirSync,
|
|
32
|
+
mkdtempSync,
|
|
33
|
+
readdirSync,
|
|
34
|
+
rmSync,
|
|
35
|
+
statSync,
|
|
36
|
+
writeFileSync,
|
|
37
|
+
} from "node:fs";
|
|
38
|
+
import * as os from "node:os";
|
|
39
|
+
import * as path from "node:path";
|
|
40
|
+
|
|
41
|
+
/** Regex matching valid npm package specifiers, with optional `@version` tail. */
|
|
42
|
+
export const NPM_SPEC_PATTERN = /^((?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)(?:@(.+))?$/;
|
|
43
|
+
|
|
44
|
+
export class NpmFetchError extends Error {
|
|
45
|
+
override name = "NpmFetchError" as const;
|
|
46
|
+
readonly code:
|
|
47
|
+
| "bad_spec"
|
|
48
|
+
| "fetch_failed"
|
|
49
|
+
| "not_found"
|
|
50
|
+
| "no_dist"
|
|
51
|
+
| "network_error"
|
|
52
|
+
| "staging_failed";
|
|
53
|
+
readonly stderr?: string;
|
|
54
|
+
readonly retryHint?: string;
|
|
55
|
+
constructor(
|
|
56
|
+
message: string,
|
|
57
|
+
code: NpmFetchError["code"],
|
|
58
|
+
extra: { stderr?: string; retryHint?: string } = {},
|
|
59
|
+
) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.code = code;
|
|
62
|
+
this.stderr = extra.stderr;
|
|
63
|
+
this.retryHint = extra.retryHint;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse an npm spec into its package name + optional version.
|
|
69
|
+
*
|
|
70
|
+
* Returns `undefined` if the spec doesn't look like an npm package (caller
|
|
71
|
+
* should fall through to local-path handling). Examples that pass:
|
|
72
|
+
* - `notes-ui`
|
|
73
|
+
* - `@openparachute/notes-ui`
|
|
74
|
+
* - `@openparachute/notes-ui@0.1.2`
|
|
75
|
+
* - `@openparachute/notes-ui@latest`
|
|
76
|
+
*
|
|
77
|
+
* Examples that don't pass (caller treats as local path):
|
|
78
|
+
* - `./foo` (relative path, has `/`)
|
|
79
|
+
* - `/tmp/foo` (absolute path, leading `/`)
|
|
80
|
+
* - `foo/bar/baz` (too many segments — not a scope)
|
|
81
|
+
*
|
|
82
|
+
* The pattern distinguishes spec-vs-path via "starts with `@` or contains no
|
|
83
|
+
* `/`" — a single `/` after a `@scope` is part of an npm name, otherwise a
|
|
84
|
+
* `/` means filesystem path.
|
|
85
|
+
*/
|
|
86
|
+
export function parseNpmSpec(spec: string): { pkg: string; version?: string } | undefined {
|
|
87
|
+
if (spec.length === 0) return undefined;
|
|
88
|
+
// Local-path heuristic: starts with `.` or `/`, or contains an internal
|
|
89
|
+
// `/` without a leading `@`.
|
|
90
|
+
if (spec.startsWith(".") || spec.startsWith("/")) return undefined;
|
|
91
|
+
const m = NPM_SPEC_PATTERN.exec(spec);
|
|
92
|
+
if (!m) return undefined;
|
|
93
|
+
return { pkg: m[1]!, version: m[2] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type NpmFetchOpts = {
|
|
97
|
+
/** The spec. `parseNpmSpec` is called internally; an invalid spec throws `NpmFetchError`. */
|
|
98
|
+
spec: string;
|
|
99
|
+
/** Override the staging-dir parent (tests). Defaults to `os.tmpdir()`. */
|
|
100
|
+
stagingParent?: string;
|
|
101
|
+
/**
|
|
102
|
+
* Override the spawner (tests). Receives the full argv array and returns
|
|
103
|
+
* `{exitCode, stderr}`. Defaults to `Bun.spawn`.
|
|
104
|
+
*/
|
|
105
|
+
spawnFn?: NpmSpawnFn;
|
|
106
|
+
/** Logger override; default console. */
|
|
107
|
+
logger?: Pick<Console, "log" | "warn" | "error">;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** Shape `Bun.spawn`-equivalent test mocks need to fulfill. */
|
|
111
|
+
export type NpmSpawnFn = (
|
|
112
|
+
argv: string[],
|
|
113
|
+
cwd: string,
|
|
114
|
+
) => Promise<{ exitCode: number; stderr: string; stdout: string }>;
|
|
115
|
+
|
|
116
|
+
export type NpmFetchResult = {
|
|
117
|
+
/** The parsed spec. */
|
|
118
|
+
pkg: string;
|
|
119
|
+
version?: string;
|
|
120
|
+
/** Absolute path to the staging dir (caller is responsible for cleanup). */
|
|
121
|
+
stagingDir: string;
|
|
122
|
+
/** Absolute path to `staging/node_modules/<pkg>/`. */
|
|
123
|
+
installedPath: string;
|
|
124
|
+
/** Absolute path to `dist/` within the installed package. */
|
|
125
|
+
distPath: string;
|
|
126
|
+
/** Absolute path to `meta.json` if present (sibling of `dist/`); else undefined. */
|
|
127
|
+
metaJsonPath?: string;
|
|
128
|
+
/** Cleanup the staging dir. Safe to call multiple times. */
|
|
129
|
+
cleanup: () => void;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const DEFAULT_SPAWN: NpmSpawnFn = async (argv, cwd) => {
|
|
133
|
+
const proc = Bun.spawn(argv, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
134
|
+
const [stdout, stderr] = await Promise.all([
|
|
135
|
+
new Response(proc.stdout).text(),
|
|
136
|
+
new Response(proc.stderr).text(),
|
|
137
|
+
]);
|
|
138
|
+
const exitCode = await proc.exited;
|
|
139
|
+
return { exitCode, stderr, stdout };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Fetch + extract an npm package. Returns paths the caller copies into the
|
|
144
|
+
* UI's home; the caller MUST call `result.cleanup()` (typically in a
|
|
145
|
+
* `finally`) regardless of outcome.
|
|
146
|
+
*
|
|
147
|
+
* On error: the staging dir is cleaned up before the throw, so callers don't
|
|
148
|
+
* have to wrap every `try` with a `finally` of their own. (The returned
|
|
149
|
+
* `result.cleanup` is for the success path.)
|
|
150
|
+
*/
|
|
151
|
+
export async function fetchNpmPackage(opts: NpmFetchOpts): Promise<NpmFetchResult> {
|
|
152
|
+
const logger = opts.logger ?? console;
|
|
153
|
+
const spawn = opts.spawnFn ?? DEFAULT_SPAWN;
|
|
154
|
+
const stagingParent = opts.stagingParent ?? os.tmpdir();
|
|
155
|
+
|
|
156
|
+
const parsed = parseNpmSpec(opts.spec);
|
|
157
|
+
if (!parsed) {
|
|
158
|
+
throw new NpmFetchError(
|
|
159
|
+
`\"${opts.spec}\" is not a valid npm package specifier (expected name, @scope/name, or @scope/name@version)`,
|
|
160
|
+
"bad_spec",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const { pkg, version } = parsed;
|
|
164
|
+
const specForBunAdd = version ? `${pkg}@${version}` : pkg;
|
|
165
|
+
|
|
166
|
+
// Staging dir. mkdtempSync gives us a unique name; we own it for the
|
|
167
|
+
// remainder of the operation.
|
|
168
|
+
let stagingDir: string;
|
|
169
|
+
try {
|
|
170
|
+
stagingDir = mkdtempSync(path.join(stagingParent, "parachute-app-staging-"));
|
|
171
|
+
} catch (e) {
|
|
172
|
+
throw new NpmFetchError(
|
|
173
|
+
`failed to create staging directory: ${(e as Error).message}`,
|
|
174
|
+
"staging_failed",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const cleanup = (): void => {
|
|
178
|
+
try {
|
|
179
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
180
|
+
} catch (e) {
|
|
181
|
+
logger.warn(`[app-npm] failed to clean up ${stagingDir}: ${(e as Error).message}`);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Seed a minimal package.json so `bun add` has something to write.
|
|
186
|
+
try {
|
|
187
|
+
writeFileSync(
|
|
188
|
+
path.join(stagingDir, "package.json"),
|
|
189
|
+
`${JSON.stringify({ name: "parachute-app-staging", version: "0.0.0", private: true }, null, 2)}\n`,
|
|
190
|
+
);
|
|
191
|
+
} catch (e) {
|
|
192
|
+
cleanup();
|
|
193
|
+
throw new NpmFetchError(
|
|
194
|
+
`failed to seed staging package.json: ${(e as Error).message}`,
|
|
195
|
+
"staging_failed",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// `bun add <spec>` — install into the staging dir.
|
|
200
|
+
// `--ignore-scripts` prevents malicious `postinstall` (et al.) hooks in the
|
|
201
|
+
// fetched package or any of its deps from executing arbitrary code in the
|
|
202
|
+
// daemon's process context. We only need the package's `dist/` output, not
|
|
203
|
+
// any install-time codegen.
|
|
204
|
+
let spawnResult: Awaited<ReturnType<NpmSpawnFn>>;
|
|
205
|
+
try {
|
|
206
|
+
spawnResult = await spawn(["bun", "add", "--ignore-scripts", specForBunAdd], stagingDir);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
cleanup();
|
|
209
|
+
throw new NpmFetchError(`failed to spawn bun: ${(e as Error).message}`, "network_error", {
|
|
210
|
+
retryHint: "ensure `bun` is on PATH",
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (spawnResult.exitCode !== 0) {
|
|
215
|
+
const stderr = spawnResult.stderr;
|
|
216
|
+
cleanup();
|
|
217
|
+
|
|
218
|
+
// Distinguish 404 (package doesn't exist) from generic install failures
|
|
219
|
+
// by sniffing stderr — bun's error messages are stable.
|
|
220
|
+
const looks404 =
|
|
221
|
+
/404\b/.test(stderr) ||
|
|
222
|
+
/not found/i.test(stderr) ||
|
|
223
|
+
/no matching version/i.test(stderr) ||
|
|
224
|
+
/no versions available/i.test(stderr);
|
|
225
|
+
const looksNetwork =
|
|
226
|
+
/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|getaddrinfo|EAI_AGAIN/i.test(stderr) ||
|
|
227
|
+
/network error/i.test(stderr) ||
|
|
228
|
+
/failed to connect/i.test(stderr);
|
|
229
|
+
const code: NpmFetchError["code"] = looks404
|
|
230
|
+
? "not_found"
|
|
231
|
+
: looksNetwork
|
|
232
|
+
? "network_error"
|
|
233
|
+
: "fetch_failed";
|
|
234
|
+
const retryHint = looksNetwork
|
|
235
|
+
? "check your network connection or registry config and retry"
|
|
236
|
+
: looks404
|
|
237
|
+
? `verify the package name + version exist on npm: \`npm view ${specForBunAdd}\``
|
|
238
|
+
: undefined;
|
|
239
|
+
throw new NpmFetchError(
|
|
240
|
+
`\`bun add ${specForBunAdd}\` failed (exit ${spawnResult.exitCode})`,
|
|
241
|
+
code,
|
|
242
|
+
{ stderr, retryHint },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const installedPath = path.join(stagingDir, "node_modules", pkg);
|
|
247
|
+
if (!existsSync(installedPath)) {
|
|
248
|
+
cleanup();
|
|
249
|
+
throw new NpmFetchError(
|
|
250
|
+
`bun reported success but ${installedPath} doesn't exist — registry shape unexpected`,
|
|
251
|
+
"fetch_failed",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Locate the dist/ inside the installed package. Per the convention
|
|
256
|
+
// (Notes' published `@openparachute/notes-ui` will be the canonical
|
|
257
|
+
// example), the bundle lives at `<pkg>/dist/`.
|
|
258
|
+
const distPath = path.join(installedPath, "dist");
|
|
259
|
+
if (!existsSync(distPath)) {
|
|
260
|
+
cleanup();
|
|
261
|
+
throw new NpmFetchError(
|
|
262
|
+
`package ${specForBunAdd} doesn't contain a dist/ directory — not a parachute-app-shaped UI bundle`,
|
|
263
|
+
"no_dist",
|
|
264
|
+
{
|
|
265
|
+
retryHint:
|
|
266
|
+
"the package should publish a `dist/` directory containing `index.html`; ask the maintainer or build locally + `parachute-app add ./path/to/dist`",
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (!existsSync(path.join(distPath, "index.html"))) {
|
|
271
|
+
cleanup();
|
|
272
|
+
throw new NpmFetchError(
|
|
273
|
+
`package ${specForBunAdd} has dist/ but no index.html — not a valid SPA bundle`,
|
|
274
|
+
"no_dist",
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Sibling meta.json is optional — the caller falls back to body-provided
|
|
279
|
+
// values when missing.
|
|
280
|
+
const metaJsonPath = path.join(installedPath, "meta.json");
|
|
281
|
+
const metaJsonPathResolved = existsSync(metaJsonPath) ? metaJsonPath : undefined;
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
pkg,
|
|
285
|
+
version,
|
|
286
|
+
stagingDir,
|
|
287
|
+
installedPath,
|
|
288
|
+
distPath,
|
|
289
|
+
metaJsonPath: metaJsonPathResolved,
|
|
290
|
+
cleanup,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Recursive copy of `srcDir` to `destDir`. Replaces destDir if it exists.
|
|
296
|
+
* Mirror of `cp -r src/. dest/` semantics. Used by `POST /app/add` to copy
|
|
297
|
+
* the staged dist into the UI's permanent home.
|
|
298
|
+
*/
|
|
299
|
+
export function copyDir(srcDir: string, destDir: string): void {
|
|
300
|
+
if (existsSync(destDir)) {
|
|
301
|
+
rmSync(destDir, { recursive: true, force: true });
|
|
302
|
+
}
|
|
303
|
+
mkdirSync(destDir, { recursive: true });
|
|
304
|
+
copyDirInner(srcDir, destDir);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function copyDirInner(src: string, dest: string): void {
|
|
308
|
+
for (const entry of readdirSync(src)) {
|
|
309
|
+
const s = path.join(src, entry);
|
|
310
|
+
const d = path.join(dest, entry);
|
|
311
|
+
const st = statSync(s);
|
|
312
|
+
if (st.isDirectory()) {
|
|
313
|
+
mkdirSync(d, { recursive: true });
|
|
314
|
+
copyDirInner(s, d);
|
|
315
|
+
} else if (st.isFile()) {
|
|
316
|
+
copyFileSync(s, d);
|
|
317
|
+
}
|
|
318
|
+
// Skip symlinks + other entries — keep the bundle to plain files.
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator bearer sourcing for outbound calls from parachute-app to hub.
|
|
3
|
+
*
|
|
4
|
+
* The DCR registration flow (`POST /oauth/register` on hub) needs an operator
|
|
5
|
+
* bearer in the `Authorization` header. Per hub's `handleRegister`:
|
|
6
|
+
*
|
|
7
|
+
* - No bearer → client lands `pending`; an operator has to click approve.
|
|
8
|
+
* - Operator bearer carrying `hub:admin` scope → client lands `approved`.
|
|
9
|
+
*
|
|
10
|
+
* Apps wants approved (so a `parachute-app add` is one command, not "add
|
|
11
|
+
* then go click approve in hub admin"). The bearer source mirrors what every
|
|
12
|
+
* other operator-side caller uses:
|
|
13
|
+
*
|
|
14
|
+
* 1. `PARACHUTE_HUB_TOKEN` env var (highest priority, transient/CI use)
|
|
15
|
+
* 2. `~/.parachute/operator.token` file (canonical persistent location)
|
|
16
|
+
*
|
|
17
|
+
* Both are operator-controlled. App never *generates* an operator token; it
|
|
18
|
+
* only reads one the operator (or hub install path) wrote. The file mode is
|
|
19
|
+
* verified — group/world-readable files refuse to load so a typo'd `chmod`
|
|
20
|
+
* doesn't leak the token via a backup directory.
|
|
21
|
+
*
|
|
22
|
+
* Missing token → undefined, not an error. The caller decides whether that's
|
|
23
|
+
* fatal (DCR auto-register on `parachute-app add` with `auto_register_oauth_clients=true`)
|
|
24
|
+
* or fine (the `parachute-app list` path needs no outbound calls).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
28
|
+
import * as os from "node:os";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the canonical operator-token path. Honors `PARACHUTE_HOME` for
|
|
33
|
+
* sandbox + Render deployments (matches the convention every committed-core
|
|
34
|
+
* module follows).
|
|
35
|
+
*/
|
|
36
|
+
export function resolveOperatorTokenPath(
|
|
37
|
+
env: Record<string, string | undefined> = process.env,
|
|
38
|
+
): string {
|
|
39
|
+
const parachuteHome = env.PARACHUTE_HOME ?? path.join(env.HOME ?? os.homedir(), ".parachute");
|
|
40
|
+
return path.join(parachuteHome, "operator.token");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ReadOperatorTokenOpts = {
|
|
44
|
+
/** Override env (tests). Defaults to `process.env`. */
|
|
45
|
+
env?: Record<string, string | undefined>;
|
|
46
|
+
/** Override file location (tests). Defaults to `resolveOperatorTokenPath`. */
|
|
47
|
+
tokenPath?: string;
|
|
48
|
+
/** Logger override; default console. */
|
|
49
|
+
logger?: Pick<Console, "log" | "warn" | "error">;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the operator bearer.
|
|
54
|
+
*
|
|
55
|
+
* Priority order:
|
|
56
|
+
* 1. `env.PARACHUTE_HUB_TOKEN` — env-var override (CI + transient flows)
|
|
57
|
+
* 2. The on-disk file at `resolveOperatorTokenPath(env)`
|
|
58
|
+
*
|
|
59
|
+
* Returns `undefined` when nothing's available. Logs a warn if the file
|
|
60
|
+
* exists but is group/world-readable (mode-bit defense — the operator
|
|
61
|
+
* mistyped `chmod` and we refuse to load the token).
|
|
62
|
+
*/
|
|
63
|
+
export function readOperatorToken(opts: ReadOperatorTokenOpts = {}): string | undefined {
|
|
64
|
+
const env = opts.env ?? process.env;
|
|
65
|
+
const logger = opts.logger ?? console;
|
|
66
|
+
|
|
67
|
+
const fromEnv = env.PARACHUTE_HUB_TOKEN?.trim();
|
|
68
|
+
if (fromEnv && fromEnv.length > 0) return fromEnv;
|
|
69
|
+
|
|
70
|
+
const tokenPath = opts.tokenPath ?? resolveOperatorTokenPath(env);
|
|
71
|
+
if (!existsSync(tokenPath)) return undefined;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const st = statSync(tokenPath);
|
|
75
|
+
// Posix mode check — refuse to load a token a backup tool might happen
|
|
76
|
+
// to slurp from a group/world-readable file. The expected mode is 0o600
|
|
77
|
+
// (owner-rw only). On Windows the mode bits are meaningless so we skip
|
|
78
|
+
// the check there. `process.platform` is the canonical detector.
|
|
79
|
+
if (process.platform !== "win32") {
|
|
80
|
+
const groupOrWorldReadable = (st.mode & 0o077) !== 0;
|
|
81
|
+
if (groupOrWorldReadable) {
|
|
82
|
+
logger.warn(
|
|
83
|
+
`[app] refusing to load operator token at ${tokenPath}: file is group/world-readable (mode ${(st.mode & 0o777).toString(8)}); chmod 600 to fix`,
|
|
84
|
+
);
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const body = readFileSync(tokenPath, "utf8").trim();
|
|
89
|
+
if (body.length === 0) return undefined;
|
|
90
|
+
return body;
|
|
91
|
+
} catch (e) {
|
|
92
|
+
logger.warn(`[app] failed to read operator token at ${tokenPath}: ${(e as Error).message}`);
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* required_schema auto-provisioner — Phase 2.1.
|
|
3
|
+
*
|
|
4
|
+
* Per patterns#57 ("Surfaces declare required vault schema"), each
|
|
5
|
+
* hosted UI may declare a `required_schema` envelope in its
|
|
6
|
+
* `meta.json`:
|
|
7
|
+
*
|
|
8
|
+
* {
|
|
9
|
+
* "required_schema": {
|
|
10
|
+
* "tags": [
|
|
11
|
+
* {
|
|
12
|
+
* "name": "capture",
|
|
13
|
+
* "description": "Quick captures",
|
|
14
|
+
* "fields": {
|
|
15
|
+
* "source": { "type": "string", "required": true },
|
|
16
|
+
* "createdAt": { "type": "date" }
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
* ]
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* Phase 2.0 landed the *shape* (validate + surface in admin SPA). This
|
|
24
|
+
* file lands the auto-provisioning logic: when `POST /app/add` succeeds
|
|
25
|
+
* and the UI's meta declares `required_schema.tags`, we call
|
|
26
|
+
* `VaultClient.updateTag` against each declared tag so vault has the
|
|
27
|
+
* schema row the app expects.
|
|
28
|
+
*
|
|
29
|
+
* Idempotent — vault's `PUT /api/tags/:name` upserts (omitted keys
|
|
30
|
+
* preserve, declared keys overwrite). Re-running provisioning against
|
|
31
|
+
* a vault that already has the schema is a no-op at the row level.
|
|
32
|
+
*
|
|
33
|
+
* **Best-effort.** If vault is unreachable, the operator token is
|
|
34
|
+
* absent, or the tag-PUT 4xxs, we log + warn but never unwind the
|
|
35
|
+
* install. The operator can re-trigger via `POST /app/<name>/provision-
|
|
36
|
+
* schema` once the underlying issue is fixed.
|
|
37
|
+
*
|
|
38
|
+
* Which vault?
|
|
39
|
+
* - If `meta.vault_default` is set (single-vault apps), we provision
|
|
40
|
+
* against that vault via `<hub_url>/vault/<vault_default>`.
|
|
41
|
+
* - If unset (multi-vault / vault-agnostic apps), we skip with a
|
|
42
|
+
* human-readable reason. Per design doc Section 5, vault-agnostic
|
|
43
|
+
* UIs declare `vault:*:read`; we don't know which vault to seed,
|
|
44
|
+
* so the operator runs `provision-schema` manually against each
|
|
45
|
+
* vault they want set up.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import type { TagUpsertPayload } from "@openparachute/app-client";
|
|
49
|
+
import { VaultClient } from "@openparachute/app-client/vault-client";
|
|
50
|
+
|
|
51
|
+
import type { FetchFn } from "./dcr.ts";
|
|
52
|
+
import type { RegisteredUi } from "./ui-registry.ts";
|
|
53
|
+
|
|
54
|
+
export type ProvisionSchemaOpts = {
|
|
55
|
+
ui: RegisteredUi;
|
|
56
|
+
/** Hub origin (used to construct the per-vault base URL). */
|
|
57
|
+
hubUrl: string;
|
|
58
|
+
/** Resolver for the operator bearer used to authenticate to vault. */
|
|
59
|
+
operatorTokenResolver: () => string | undefined;
|
|
60
|
+
/** Inject fetch (tests). */
|
|
61
|
+
fetchFn?: FetchFn;
|
|
62
|
+
/** Logger override; default console. */
|
|
63
|
+
logger?: Pick<Console, "log" | "warn" | "error">;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type ProvisionSchemaResult = {
|
|
67
|
+
/** Tag names successfully provisioned (PUT returned 2xx). */
|
|
68
|
+
provisioned: string[];
|
|
69
|
+
/** Tags whose PUT failed with the per-tag error message. */
|
|
70
|
+
errors: Array<{ tag: string; error: string }>;
|
|
71
|
+
/**
|
|
72
|
+
* Reason the whole pass was skipped (per-UI), if it was. Examples:
|
|
73
|
+
* - "ui declared no required_schema"
|
|
74
|
+
* - "ui has no vault_default; skip (operator can re-trigger manually)"
|
|
75
|
+
* - "no operator token available"
|
|
76
|
+
*/
|
|
77
|
+
skipReason?: string;
|
|
78
|
+
/** Resolved vault URL, when one was used. */
|
|
79
|
+
vaultUrl?: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Provision the tags declared in `ui.meta.required_schema.tags` into
|
|
84
|
+
* the vault implied by `ui.meta.vault_default`.
|
|
85
|
+
*
|
|
86
|
+
* Best-effort: every error path logs + warns and continues to the next
|
|
87
|
+
* tag. Returns a summary the caller surfaces in the admin response +
|
|
88
|
+
* SSE log.
|
|
89
|
+
*/
|
|
90
|
+
export async function provisionSchemaForUi(
|
|
91
|
+
opts: ProvisionSchemaOpts,
|
|
92
|
+
): Promise<ProvisionSchemaResult> {
|
|
93
|
+
const logger = opts.logger ?? console;
|
|
94
|
+
const required = opts.ui.meta.required_schema;
|
|
95
|
+
const tags = required?.tags ?? [];
|
|
96
|
+
|
|
97
|
+
if (!required || tags.length === 0) {
|
|
98
|
+
return {
|
|
99
|
+
provisioned: [],
|
|
100
|
+
errors: [],
|
|
101
|
+
skipReason: "ui declared no required_schema",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const vaultName = opts.ui.meta.vault_default;
|
|
106
|
+
if (!vaultName) {
|
|
107
|
+
const reason =
|
|
108
|
+
"ui has no vault_default — apps declaring required_schema must pin a vault, or operator must run provision-schema manually";
|
|
109
|
+
logger.warn(`[app-provision] ${opts.ui.meta.name}: ${reason}`);
|
|
110
|
+
return {
|
|
111
|
+
provisioned: [],
|
|
112
|
+
errors: [],
|
|
113
|
+
skipReason: reason,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const operatorToken = opts.operatorTokenResolver();
|
|
118
|
+
if (!operatorToken) {
|
|
119
|
+
const reason =
|
|
120
|
+
"no operator token available (PARACHUTE_HUB_TOKEN unset, ~/.parachute/operator.token missing/unreadable)";
|
|
121
|
+
logger.warn(`[app-provision] ${opts.ui.meta.name}: ${reason}`);
|
|
122
|
+
return {
|
|
123
|
+
provisioned: [],
|
|
124
|
+
errors: [],
|
|
125
|
+
skipReason: reason,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const vaultUrl = `${opts.hubUrl.replace(/\/$/, "")}/vault/${encodeURIComponent(vaultName)}`;
|
|
130
|
+
const fetchImpl = opts.fetchFn ?? (fetch as typeof fetch);
|
|
131
|
+
const client = new VaultClient({
|
|
132
|
+
vaultUrl,
|
|
133
|
+
accessToken: operatorToken,
|
|
134
|
+
// Cast fits — server-side FetchFn matches DOM `typeof fetch` at the
|
|
135
|
+
// call-site shape VaultClient needs (URL string + RequestInit).
|
|
136
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const provisioned: string[] = [];
|
|
140
|
+
const errors: Array<{ tag: string; error: string }> = [];
|
|
141
|
+
|
|
142
|
+
for (const tag of tags) {
|
|
143
|
+
const payload: TagUpsertPayload = {};
|
|
144
|
+
if (tag.description !== undefined) payload.description = tag.description;
|
|
145
|
+
if (tag.fields !== undefined) {
|
|
146
|
+
// Translate the meta.json shape (`{type, required?, description?}`)
|
|
147
|
+
// into vault's `TagFieldSchema` (`{type, description?, enum?}`).
|
|
148
|
+
// The `required` flag from meta.json doesn't have a direct vault
|
|
149
|
+
// equivalent today — vault enforces required-ness at the
|
|
150
|
+
// applySchemaDefaults layer (see vault/routes.ts:applySchemaDefaults).
|
|
151
|
+
// We pass `type` + `description` through; `required` is preserved
|
|
152
|
+
// in the meta.json view + the admin SPA surface, but isn't
|
|
153
|
+
// forwarded as a wire-level flag because vault doesn't store one.
|
|
154
|
+
const fields: Record<string, { type: string; description?: string }> = {};
|
|
155
|
+
for (const [fieldName, decl] of Object.entries(tag.fields)) {
|
|
156
|
+
const f: { type: string; description?: string } = { type: decl.type };
|
|
157
|
+
if (decl.description !== undefined) f.description = decl.description;
|
|
158
|
+
fields[fieldName] = f;
|
|
159
|
+
}
|
|
160
|
+
payload.fields = fields;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
await client.updateTag(tag.name, payload);
|
|
164
|
+
provisioned.push(tag.name);
|
|
165
|
+
logger.log(`[app-provision] ${opts.ui.meta.name}: provisioned tag "${tag.name}"`);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
const msg = (e as Error).message ?? String(e);
|
|
168
|
+
errors.push({ tag: tag.name, error: msg });
|
|
169
|
+
logger.warn(
|
|
170
|
+
`[app-provision] ${opts.ui.meta.name}: failed to provision tag "${tag.name}": ${msg}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
provisioned,
|
|
177
|
+
errors,
|
|
178
|
+
vaultUrl,
|
|
179
|
+
};
|
|
180
|
+
}
|