@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,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `parachute upgrade [<service>]` — pull / re-install / restart in one step.
|
|
3
|
+
*
|
|
4
|
+
* Detects whether the target service is bun-linked from a local checkout (the
|
|
5
|
+
* dev-mode install shape) or npm-installed from a published artifact, then
|
|
6
|
+
* does the right thing for each:
|
|
7
|
+
*
|
|
8
|
+
* bun-linked: git -C <checkout> pull --ff-only;
|
|
9
|
+
* bun install --frozen-lockfile (if package.json/bun.lock changed);
|
|
10
|
+
* bun run build (frontend kind, if `build` script exists);
|
|
11
|
+
* parachute restart <svc>.
|
|
12
|
+
*
|
|
13
|
+
* npm-installed: bun add -g <pkg>@<tag>; parachute restart <svc>.
|
|
14
|
+
*
|
|
15
|
+
* Skip-restart heuristics: bun-linked path skips when HEAD is unchanged after
|
|
16
|
+
* pull; npm path skips when the installed package.json `version` is unchanged
|
|
17
|
+
* after `bun add -g`. Idempotent — re-running the command on an up-to-date
|
|
18
|
+
* install is a fast no-op rather than a needless restart.
|
|
19
|
+
*
|
|
20
|
+
* Refuses to operate on a dirty git working tree. The whole point of the
|
|
21
|
+
* dev-mode flow is to make the operator's checkout a first-class artifact;
|
|
22
|
+
* blowing past their uncommitted changes with `git pull` is exactly what we
|
|
23
|
+
* shouldn't do. They can stash and re-run.
|
|
24
|
+
*
|
|
25
|
+
* Detection of "is this a git checkout?" goes through git itself (`git
|
|
26
|
+
* rev-parse --is-inside-work-tree`). A bare bun-link symlink isn't enough —
|
|
27
|
+
* `bun add -g <abspath>` produces a non-symlinked install dir whose realpath
|
|
28
|
+
* is still the operator's checkout, and we want to treat that the same as a
|
|
29
|
+
* `bun link` install. The git-repo test is the right load-bearing signal.
|
|
30
|
+
*
|
|
31
|
+
* The npm-install branch reads the package.json `version` before and after
|
|
32
|
+
* `bun add -g` to detect "already at latest" (the dist-tag didn't move).
|
|
33
|
+
* Doing this avoids an unnecessary restart on a stable channel — a lot
|
|
34
|
+
* cheaper than re-spawning a daemon that's already running the right code.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
38
|
+
import { homedir } from "node:os";
|
|
39
|
+
import { dirname, join } from "node:path";
|
|
40
|
+
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
41
|
+
import { ModuleManifestError } from "../module-manifest.ts";
|
|
42
|
+
import {
|
|
43
|
+
type ServiceSpec,
|
|
44
|
+
getSpec,
|
|
45
|
+
getSpecFromInstallDir,
|
|
46
|
+
knownServices,
|
|
47
|
+
shortNameForManifest,
|
|
48
|
+
} from "../service-spec.ts";
|
|
49
|
+
import { type ServiceEntry, readManifest } from "../services-manifest.ts";
|
|
50
|
+
import { type LifecycleOpts, restart as lifecycleRestart } from "./lifecycle.ts";
|
|
51
|
+
|
|
52
|
+
export interface UpgradeRunner {
|
|
53
|
+
/** Run a command, inheriting stdio. Returns the child's exit code. */
|
|
54
|
+
run(cmd: readonly string[], opts?: { cwd?: string }): Promise<number>;
|
|
55
|
+
/** Run a command, capturing combined stdout+stderr. Used for git rev-parse / status / diff. */
|
|
56
|
+
capture(
|
|
57
|
+
cmd: readonly string[],
|
|
58
|
+
opts?: { cwd?: string },
|
|
59
|
+
): Promise<{ code: number; stdout: string }>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const defaultRunner: UpgradeRunner = {
|
|
63
|
+
async run(cmd, opts) {
|
|
64
|
+
const proc = Bun.spawn([...cmd], {
|
|
65
|
+
cwd: opts?.cwd,
|
|
66
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
67
|
+
});
|
|
68
|
+
return await proc.exited;
|
|
69
|
+
},
|
|
70
|
+
async capture(cmd, opts) {
|
|
71
|
+
const proc = Bun.spawn([...cmd], {
|
|
72
|
+
cwd: opts?.cwd,
|
|
73
|
+
stdout: "pipe",
|
|
74
|
+
stderr: "pipe",
|
|
75
|
+
});
|
|
76
|
+
const [stdout, stderr] = await Promise.all([
|
|
77
|
+
new Response(proc.stdout).text(),
|
|
78
|
+
new Response(proc.stderr).text(),
|
|
79
|
+
]);
|
|
80
|
+
const code = await proc.exited;
|
|
81
|
+
return { code, stdout: stdout + stderr };
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export interface UpgradeOpts {
|
|
86
|
+
runner?: UpgradeRunner;
|
|
87
|
+
manifestPath?: string;
|
|
88
|
+
configDir?: string;
|
|
89
|
+
log?: (line: string) => void;
|
|
90
|
+
/**
|
|
91
|
+
* Override how we locate a package's bun-globals install. Defaults to
|
|
92
|
+
* scanning bun's standard global node_modules prefixes for
|
|
93
|
+
* `<prefix>/<pkg>/package.json`. Tests inject a deterministic stub that
|
|
94
|
+
* points at a tmp dir.
|
|
95
|
+
*/
|
|
96
|
+
findGlobalInstall?: (pkg: string) => string | null;
|
|
97
|
+
/**
|
|
98
|
+
* Override the lifecycle restart call. Production proxies to
|
|
99
|
+
* `lifecycle.restart(svc, opts)`; tests inject `async () => 0` so the
|
|
100
|
+
* upgrade flow can be exercised without spawning real children.
|
|
101
|
+
*/
|
|
102
|
+
restartFn?: (svc: string, opts: LifecycleOpts) => Promise<number>;
|
|
103
|
+
/** npm dist-tag for the npm-installed branch. Defaults to `latest`. Ignored when bun-linked. */
|
|
104
|
+
tag?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface ResolvedTarget {
|
|
108
|
+
short: string;
|
|
109
|
+
entry: ServiceEntry;
|
|
110
|
+
spec: ServiceSpec | undefined;
|
|
111
|
+
packageName: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface Resolved {
|
|
115
|
+
runner: UpgradeRunner;
|
|
116
|
+
manifestPath: string;
|
|
117
|
+
configDir: string;
|
|
118
|
+
log: (line: string) => void;
|
|
119
|
+
findGlobalInstall: (pkg: string) => string | null;
|
|
120
|
+
restartFn: (svc: string, opts: LifecycleOpts) => Promise<number>;
|
|
121
|
+
tag: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function bunGlobalPrefixes(): string[] {
|
|
125
|
+
const prefixes: string[] = [];
|
|
126
|
+
const fromEnv = process.env.BUN_INSTALL;
|
|
127
|
+
if (fromEnv) prefixes.push(join(fromEnv, "install", "global", "node_modules"));
|
|
128
|
+
prefixes.push(join(homedir(), ".bun", "install", "global", "node_modules"));
|
|
129
|
+
return prefixes;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function defaultFindGlobalInstall(pkg: string): string | null {
|
|
133
|
+
for (const prefix of bunGlobalPrefixes()) {
|
|
134
|
+
const pkgJsonPath = join(prefix, ...pkg.split("/"), "package.json");
|
|
135
|
+
if (existsSync(pkgJsonPath)) return pkgJsonPath;
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function resolve(opts: UpgradeOpts): Resolved {
|
|
141
|
+
return {
|
|
142
|
+
runner: opts.runner ?? defaultRunner,
|
|
143
|
+
manifestPath: opts.manifestPath ?? SERVICES_MANIFEST_PATH,
|
|
144
|
+
configDir: opts.configDir ?? CONFIG_DIR,
|
|
145
|
+
log: opts.log ?? ((line) => console.log(line)),
|
|
146
|
+
findGlobalInstall: opts.findGlobalInstall ?? defaultFindGlobalInstall,
|
|
147
|
+
restartFn: opts.restartFn ?? ((svc, lifecycleOpts) => lifecycleRestart(svc, lifecycleOpts)),
|
|
148
|
+
tag: opts.tag ?? "latest",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function resolveTargets(
|
|
153
|
+
svc: string | undefined,
|
|
154
|
+
manifestPath: string,
|
|
155
|
+
): Promise<{ targets: ResolvedTarget[] } | { error: string }> {
|
|
156
|
+
const manifest = readManifest(manifestPath);
|
|
157
|
+
if (manifest.services.length === 0) {
|
|
158
|
+
return { error: "No services installed yet. Try: parachute install <service>" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (svc !== undefined) {
|
|
162
|
+
const firstPartySpec = getSpec(svc);
|
|
163
|
+
if (firstPartySpec) {
|
|
164
|
+
const entry = manifest.services.find((s) => s.name === firstPartySpec.manifestName);
|
|
165
|
+
if (!entry) {
|
|
166
|
+
return { error: `${svc} isn't installed. Run \`parachute install ${svc}\` first.` };
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
targets: [{ short: svc, entry, spec: firstPartySpec, packageName: firstPartySpec.package }],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const entry = manifest.services.find((s) => s.name === svc);
|
|
173
|
+
if (entry?.installDir) {
|
|
174
|
+
try {
|
|
175
|
+
const spec = (await getSpecFromInstallDir(entry.installDir, entry.name)) ?? undefined;
|
|
176
|
+
return {
|
|
177
|
+
targets: [{ short: svc, entry, spec, packageName: spec?.package ?? entry.name }],
|
|
178
|
+
};
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (err instanceof ModuleManifestError) {
|
|
181
|
+
return { error: `${svc}: invalid module.json — ${err.message}` };
|
|
182
|
+
}
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { error: `unknown service "${svc}". known: ${knownServices().join(", ")}` };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const targets: ResolvedTarget[] = [];
|
|
190
|
+
for (const entry of manifest.services) {
|
|
191
|
+
const short = shortNameForManifest(entry.name);
|
|
192
|
+
if (short) {
|
|
193
|
+
const spec = getSpec(short);
|
|
194
|
+
if (spec) targets.push({ short, entry, spec, packageName: spec.package });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (entry.installDir) {
|
|
198
|
+
try {
|
|
199
|
+
const spec = (await getSpecFromInstallDir(entry.installDir, entry.name)) ?? undefined;
|
|
200
|
+
targets.push({
|
|
201
|
+
short: entry.name,
|
|
202
|
+
entry,
|
|
203
|
+
spec,
|
|
204
|
+
packageName: spec?.package ?? entry.name,
|
|
205
|
+
});
|
|
206
|
+
} catch {
|
|
207
|
+
// Malformed third-party manifest — skip silently here; lifecycle/install
|
|
208
|
+
// surface that error in their own paths.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (targets.length === 0) return { error: "No upgradeable services in services.json." };
|
|
213
|
+
return { targets };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Realpath the package.json in bun's globals to find where the source
|
|
218
|
+
* actually lives. For npm installs this stays inside bun globals; for `bun
|
|
219
|
+
* link` and `bun add -g <abspath>` it follows out to the operator's checkout.
|
|
220
|
+
*/
|
|
221
|
+
function resolveSourceDir(
|
|
222
|
+
packageName: string,
|
|
223
|
+
findGlobalInstall: (pkg: string) => string | null,
|
|
224
|
+
fallbackInstallDir: string | undefined,
|
|
225
|
+
): string | null {
|
|
226
|
+
const fromGlobals = findGlobalInstall(packageName);
|
|
227
|
+
if (fromGlobals) {
|
|
228
|
+
try {
|
|
229
|
+
return dirname(realpathSync(fromGlobals));
|
|
230
|
+
} catch {
|
|
231
|
+
// fall through to manifest-recorded installDir
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (fallbackInstallDir) {
|
|
235
|
+
try {
|
|
236
|
+
return realpathSync(fallbackInstallDir);
|
|
237
|
+
} catch {
|
|
238
|
+
return fallbackInstallDir;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function isGitCheckout(dir: string, runner: UpgradeRunner): Promise<boolean> {
|
|
245
|
+
const { code } = await runner.capture(["git", "rev-parse", "--is-inside-work-tree"], {
|
|
246
|
+
cwd: dir,
|
|
247
|
+
});
|
|
248
|
+
return code === 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function readGitHead(
|
|
252
|
+
dir: string,
|
|
253
|
+
runner: UpgradeRunner,
|
|
254
|
+
): Promise<{ code: number; sha: string }> {
|
|
255
|
+
const { code, stdout } = await runner.capture(["git", "rev-parse", "HEAD"], { cwd: dir });
|
|
256
|
+
return { code, sha: stdout.trim() };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function listChangedFiles(
|
|
260
|
+
dir: string,
|
|
261
|
+
before: string,
|
|
262
|
+
after: string,
|
|
263
|
+
runner: UpgradeRunner,
|
|
264
|
+
): Promise<string[]> {
|
|
265
|
+
if (before === after) return [];
|
|
266
|
+
const { code, stdout } = await runner.capture(
|
|
267
|
+
["git", "diff", "--name-only", `${before}..${after}`],
|
|
268
|
+
{ cwd: dir },
|
|
269
|
+
);
|
|
270
|
+
if (code !== 0) return [];
|
|
271
|
+
return stdout.trim().split("\n").filter(Boolean);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function packageHasScript(pkgJsonPath: string, name: string): boolean {
|
|
275
|
+
try {
|
|
276
|
+
const json = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
|
|
277
|
+
return Boolean(json?.scripts && typeof json.scripts[name] === "string");
|
|
278
|
+
} catch {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function readPackageVersion(pkgJsonPath: string): string | null {
|
|
284
|
+
try {
|
|
285
|
+
const json = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
|
|
286
|
+
return typeof json?.version === "string" ? json.version : null;
|
|
287
|
+
} catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function upgradeLinked(
|
|
293
|
+
target: ResolvedTarget,
|
|
294
|
+
sourceDir: string,
|
|
295
|
+
r: Resolved,
|
|
296
|
+
): Promise<number> {
|
|
297
|
+
r.log(`${target.short}: bun-linked checkout at ${sourceDir}`);
|
|
298
|
+
|
|
299
|
+
const status = await r.runner.capture(["git", "status", "--porcelain"], { cwd: sourceDir });
|
|
300
|
+
if (status.code !== 0) {
|
|
301
|
+
r.log(`✗ ${target.short}: git status failed in ${sourceDir}`);
|
|
302
|
+
return status.code;
|
|
303
|
+
}
|
|
304
|
+
if (status.stdout.trim().length > 0) {
|
|
305
|
+
r.log(
|
|
306
|
+
`✗ ${target.short}: dirty working tree at ${sourceDir} — commit or stash first, then re-run.`,
|
|
307
|
+
);
|
|
308
|
+
return 1;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const before = await readGitHead(sourceDir, r.runner);
|
|
312
|
+
if (before.code !== 0) {
|
|
313
|
+
r.log(`✗ ${target.short}: failed to read HEAD in ${sourceDir}`);
|
|
314
|
+
return before.code;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
r.log(`${target.short}: git pull --ff-only`);
|
|
318
|
+
const pull = await r.runner.run(["git", "pull", "--ff-only"], { cwd: sourceDir });
|
|
319
|
+
if (pull !== 0) {
|
|
320
|
+
r.log(`✗ ${target.short}: git pull --ff-only failed (exit ${pull}). Resolve and retry.`);
|
|
321
|
+
return pull;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const after = await readGitHead(sourceDir, r.runner);
|
|
325
|
+
if (after.code !== 0) {
|
|
326
|
+
r.log(`✗ ${target.short}: failed to read HEAD post-pull`);
|
|
327
|
+
return after.code;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (before.sha === after.sha) {
|
|
331
|
+
r.log(`${target.short}: already up to date (${before.sha.slice(0, 7)}). Skipping restart.`);
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const changed = await listChangedFiles(sourceDir, before.sha, after.sha, r.runner);
|
|
336
|
+
const depsChanged =
|
|
337
|
+
changed.includes("package.json") ||
|
|
338
|
+
changed.includes("bun.lock") ||
|
|
339
|
+
changed.includes("bun.lockb");
|
|
340
|
+
if (depsChanged) {
|
|
341
|
+
r.log(`${target.short}: package.json/bun.lock changed — bun install --frozen-lockfile`);
|
|
342
|
+
const inst = await r.runner.run(["bun", "install", "--frozen-lockfile"], { cwd: sourceDir });
|
|
343
|
+
if (inst !== 0) {
|
|
344
|
+
r.log(`✗ ${target.short}: bun install failed (exit ${inst})`);
|
|
345
|
+
return inst;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (target.spec?.kind === "frontend") {
|
|
350
|
+
const pkgJsonPath = join(sourceDir, "package.json");
|
|
351
|
+
if (packageHasScript(pkgJsonPath, "build")) {
|
|
352
|
+
r.log(`${target.short}: bun run build`);
|
|
353
|
+
const build = await r.runner.run(["bun", "run", "build"], { cwd: sourceDir });
|
|
354
|
+
if (build !== 0) {
|
|
355
|
+
r.log(`✗ ${target.short}: bun run build failed (exit ${build})`);
|
|
356
|
+
return build;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
r.log(`${target.short}: ${before.sha.slice(0, 7)} → ${after.sha.slice(0, 7)}; restarting…`);
|
|
362
|
+
return await r.restartFn(target.short, { manifestPath: r.manifestPath, configDir: r.configDir });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function upgradeNpm(target: ResolvedTarget, sourceDir: string, r: Resolved): Promise<number> {
|
|
366
|
+
r.log(`${target.short}: npm-installed (${sourceDir})`);
|
|
367
|
+
const beforeVersion = readPackageVersion(join(sourceDir, "package.json"));
|
|
368
|
+
const spec = `${target.packageName}@${r.tag}`;
|
|
369
|
+
r.log(`${target.short}: bun add -g ${spec}`);
|
|
370
|
+
const code = await r.runner.run(["bun", "add", "-g", spec]);
|
|
371
|
+
if (code !== 0) {
|
|
372
|
+
r.log(`✗ ${target.short}: bun add -g failed (exit ${code})`);
|
|
373
|
+
return code;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const afterVersion = readPackageVersion(join(sourceDir, "package.json"));
|
|
377
|
+
if (beforeVersion && afterVersion && beforeVersion === afterVersion) {
|
|
378
|
+
r.log(`${target.short}: already at ${afterVersion}. Skipping restart.`);
|
|
379
|
+
return 0;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
r.log(`${target.short}: ${beforeVersion ?? "?"} → ${afterVersion ?? "?"}; restarting…`);
|
|
383
|
+
return await r.restartFn(target.short, { manifestPath: r.manifestPath, configDir: r.configDir });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function upgradeOne(target: ResolvedTarget, r: Resolved): Promise<number> {
|
|
387
|
+
const sourceDir = resolveSourceDir(
|
|
388
|
+
target.packageName,
|
|
389
|
+
r.findGlobalInstall,
|
|
390
|
+
target.entry.installDir,
|
|
391
|
+
);
|
|
392
|
+
if (!sourceDir) {
|
|
393
|
+
r.log(
|
|
394
|
+
`✗ ${target.short}: can't locate install dir for ${target.packageName}. Try \`parachute install ${target.short}\` first.`,
|
|
395
|
+
);
|
|
396
|
+
return 1;
|
|
397
|
+
}
|
|
398
|
+
if (!existsSync(sourceDir)) {
|
|
399
|
+
r.log(`✗ ${target.short}: install dir ${sourceDir} does not exist.`);
|
|
400
|
+
return 1;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (await isGitCheckout(sourceDir, r.runner)) {
|
|
404
|
+
return await upgradeLinked(target, sourceDir, r);
|
|
405
|
+
}
|
|
406
|
+
return await upgradeNpm(target, sourceDir, r);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Sweep one or all installed services through the upgrade flow. Returns 0
|
|
411
|
+
* when every target succeeds; non-zero is the exit code of the first failure
|
|
412
|
+
* (subsequent targets still run — partial success is preferable to halting
|
|
413
|
+
* mid-sweep on a flake).
|
|
414
|
+
*/
|
|
415
|
+
export async function upgrade(svc: string | undefined, opts: UpgradeOpts = {}): Promise<number> {
|
|
416
|
+
const r = resolve(opts);
|
|
417
|
+
const picked = await resolveTargets(svc, r.manifestPath);
|
|
418
|
+
if ("error" in picked) {
|
|
419
|
+
r.log(picked.error);
|
|
420
|
+
return 1;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
let firstFailure = 0;
|
|
424
|
+
for (const target of picked.targets) {
|
|
425
|
+
const code = await upgradeOne(target, r);
|
|
426
|
+
if (code !== 0 && firstFailure === 0) firstFailure = code;
|
|
427
|
+
}
|
|
428
|
+
return firstFailure;
|
|
429
|
+
}
|
package/src/csrf.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSRF protection for state-changing admin POSTs (login, consent, and any
|
|
3
|
+
* future admin form mounted off `/`).
|
|
4
|
+
*
|
|
5
|
+
* Pattern: double-submit cookie. On every GET that renders a form, we ensure
|
|
6
|
+
* a `parachute_hub_csrf` cookie exists (lazily generated, then reused for the
|
|
7
|
+
* cookie's lifetime) and embed the same value as a hidden `__csrf` input in
|
|
8
|
+
* the form. On POST, we compare the form-submitted token to the cookie value
|
|
9
|
+
* via constant-time compare; mismatch = 400 Bad Request. We pick 400 over 403
|
|
10
|
+
* because the failure mode is a malformed/stale form (the operator's tab sat
|
|
11
|
+
* past cookie expiry, two tabs raced, or the form was hand-rolled), not an
|
|
12
|
+
* authorization failure — they're already authenticated; the *form* is what
|
|
13
|
+
* the server can't accept. All callers (admin login, admin config, OAuth
|
|
14
|
+
* authorize) agree on 400.
|
|
15
|
+
*
|
|
16
|
+
* Why this and not session-bound tokens? Login forms are submitted *before*
|
|
17
|
+
* a session exists, so a session-bound CSRF would need a separate "pre-login"
|
|
18
|
+
* track anyway. Double-submit is uniform across both — same helper handles
|
|
19
|
+
* pre-login and post-login forms, and it works no matter how many tabs the
|
|
20
|
+
* operator has open.
|
|
21
|
+
*
|
|
22
|
+
* The cookie is HttpOnly (the form doesn't need JS to read it; the server
|
|
23
|
+
* embeds the value at render time), SameSite=Lax (matches the session
|
|
24
|
+
* cookie), Secure, and Path=/ (covers every admin form, OAuth or otherwise).
|
|
25
|
+
*
|
|
26
|
+
* Token entropy: 32 random bytes, base64url-encoded — same shape as session
|
|
27
|
+
* IDs. No HMAC needed: the value is opaque to the client and only ever
|
|
28
|
+
* compared to itself across the cookie/form boundary.
|
|
29
|
+
*/
|
|
30
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
31
|
+
|
|
32
|
+
export const CSRF_COOKIE_NAME = "parachute_hub_csrf";
|
|
33
|
+
export const CSRF_FIELD_NAME = "__csrf";
|
|
34
|
+
/** 30 days. Cookie outlives the 24h session by design — closing the OAuth
|
|
35
|
+
* tab and reopening it later shouldn't force a re-mint of the CSRF token. */
|
|
36
|
+
export const CSRF_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
37
|
+
|
|
38
|
+
export function generateCsrfToken(): string {
|
|
39
|
+
return randomBytes(32).toString("base64url");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildCsrfCookie(token: string): string {
|
|
43
|
+
return [
|
|
44
|
+
`${CSRF_COOKIE_NAME}=${token}`,
|
|
45
|
+
"HttpOnly",
|
|
46
|
+
"Secure",
|
|
47
|
+
"SameSite=Lax",
|
|
48
|
+
"Path=/",
|
|
49
|
+
`Max-Age=${CSRF_TTL_SECONDS}`,
|
|
50
|
+
].join("; ");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function parseCsrfCookie(cookieHeader: string | null): string | null {
|
|
54
|
+
if (!cookieHeader) return null;
|
|
55
|
+
for (const part of cookieHeader.split(";")) {
|
|
56
|
+
const [name, ...rest] = part.trim().split("=");
|
|
57
|
+
if (name === CSRF_COOKIE_NAME) return rest.join("=");
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface EnsuredCsrf {
|
|
63
|
+
token: string;
|
|
64
|
+
/** Set when the caller must include this Set-Cookie on the response. */
|
|
65
|
+
setCookie?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Ensure the request carries a CSRF token cookie; mint and return one if not.
|
|
70
|
+
* Callers embed `result.token` in the rendered form and attach
|
|
71
|
+
* `result.setCookie` (if defined) to the response.
|
|
72
|
+
*/
|
|
73
|
+
export function ensureCsrfToken(req: Request): EnsuredCsrf {
|
|
74
|
+
const existing = parseCsrfCookie(req.headers.get("cookie"));
|
|
75
|
+
if (existing && existing.length > 0) return { token: existing };
|
|
76
|
+
const token = generateCsrfToken();
|
|
77
|
+
return { token, setCookie: buildCsrfCookie(token) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Verify that a form-submitted CSRF token matches the cookie token via
|
|
82
|
+
* constant-time compare. Both must be present and equal.
|
|
83
|
+
*/
|
|
84
|
+
export function verifyCsrfToken(req: Request, formToken: string | null): boolean {
|
|
85
|
+
const cookieToken = parseCsrfCookie(req.headers.get("cookie"));
|
|
86
|
+
if (!cookieToken || !formToken) return false;
|
|
87
|
+
if (cookieToken.length !== formToken.length) return false;
|
|
88
|
+
try {
|
|
89
|
+
return timingSafeEqual(Buffer.from(cookieToken), Buffer.from(formToken));
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function renderCsrfHiddenInput(token: string): string {
|
|
96
|
+
return `<input type="hidden" name="${CSRF_FIELD_NAME}" value="${escapeAttr(token)}" />`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function escapeAttr(s: string): string {
|
|
100
|
+
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
101
|
+
}
|
package/src/grants.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grants — record of "user U has approved scope-set S for client C".
|
|
3
|
+
*
|
|
4
|
+
* Closes #75. The OAuth consent screen is UX, not protocol — RFC 6749 §3
|
|
5
|
+
* doesn't mandate it. Once a user has approved a set of scopes for a given
|
|
6
|
+
* client, re-running the same flow with the same (or a subset of) scopes
|
|
7
|
+
* shouldn't show consent again. Token-endpoint scope validation still gates
|
|
8
|
+
* actual issuance, so this is purely about not re-asking the human.
|
|
9
|
+
*
|
|
10
|
+
* Storage: one row per (user_id, client_id) in the `grants` table (created
|
|
11
|
+
* in hub-db migration v3). The `scopes` column is a space-separated set of
|
|
12
|
+
* every scope the user has ever approved for this client — recording is
|
|
13
|
+
* UNION semantics, not overwrite. That way, a user who approved [a, b, c]
|
|
14
|
+
* once and later approves only [a, b] for an incremental flow doesn't lose
|
|
15
|
+
* their c grant; the next flow asking [a, c] still skips consent.
|
|
16
|
+
*
|
|
17
|
+
* Skip rule: a flow may skip consent iff every requested scope is already
|
|
18
|
+
* in the grant's set. A strict superset (the client wants something new)
|
|
19
|
+
* shows the consent screen with the full requested set so the user is
|
|
20
|
+
* approving the new addition explicitly. A strict subset skips.
|
|
21
|
+
*
|
|
22
|
+
* Re-registered clients have a fresh client_id, so grants are not carried
|
|
23
|
+
* across — a re-registered app must earn consent again. That's by design:
|
|
24
|
+
* if the client_id changed, the operator can't tell from the URL whether
|
|
25
|
+
* it's the same app or an impostor.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { Database } from "bun:sqlite";
|
|
29
|
+
|
|
30
|
+
export interface Grant {
|
|
31
|
+
userId: string;
|
|
32
|
+
clientId: string;
|
|
33
|
+
scopes: string[];
|
|
34
|
+
grantedAt: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface GrantRow {
|
|
38
|
+
user_id: string;
|
|
39
|
+
client_id: string;
|
|
40
|
+
scopes: string;
|
|
41
|
+
granted_at: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function rowToGrant(row: GrantRow): Grant {
|
|
45
|
+
return {
|
|
46
|
+
userId: row.user_id,
|
|
47
|
+
clientId: row.client_id,
|
|
48
|
+
scopes: row.scopes.split(" ").filter((s) => s.length > 0),
|
|
49
|
+
grantedAt: row.granted_at,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Look up the grant for (user, client). Returns null when none exists. */
|
|
54
|
+
export function findGrant(db: Database, userId: string, clientId: string): Grant | null {
|
|
55
|
+
const row = db
|
|
56
|
+
.prepare(
|
|
57
|
+
"SELECT user_id, client_id, scopes, granted_at FROM grants WHERE user_id = ? AND client_id = ?",
|
|
58
|
+
)
|
|
59
|
+
.get(userId, clientId) as GrantRow | undefined;
|
|
60
|
+
return row ? rowToGrant(row) : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Record a consent approval. Merges `newScopes` into any existing grant for
|
|
65
|
+
* (user, client) — UNION semantics — and bumps `granted_at` to `now`. Empty
|
|
66
|
+
* `newScopes` is a no-op (we don't want to insert empty rows).
|
|
67
|
+
*/
|
|
68
|
+
export function recordGrant(
|
|
69
|
+
db: Database,
|
|
70
|
+
userId: string,
|
|
71
|
+
clientId: string,
|
|
72
|
+
newScopes: readonly string[],
|
|
73
|
+
now: Date = new Date(),
|
|
74
|
+
): Grant {
|
|
75
|
+
// Wrapped in a transaction so the read-merge-write is atomic. Without
|
|
76
|
+
// this, two concurrent consents for the same (user, client) could both
|
|
77
|
+
// SELECT the same prior row and then race to INSERT OR REPLACE, with the
|
|
78
|
+
// later writer's UNION missing scopes the earlier writer added.
|
|
79
|
+
return db.transaction(() => {
|
|
80
|
+
const existing = findGrant(db, userId, clientId);
|
|
81
|
+
const merged = new Set<string>(existing?.scopes ?? []);
|
|
82
|
+
for (const s of newScopes) {
|
|
83
|
+
if (s.length > 0) merged.add(s);
|
|
84
|
+
}
|
|
85
|
+
const scopes = Array.from(merged).sort();
|
|
86
|
+
const grantedAt = now.toISOString();
|
|
87
|
+
db.prepare(
|
|
88
|
+
`INSERT OR REPLACE INTO grants (user_id, client_id, scopes, granted_at)
|
|
89
|
+
VALUES (?, ?, ?, ?)`,
|
|
90
|
+
).run(userId, clientId, scopes.join(" "), grantedAt);
|
|
91
|
+
return { userId, clientId, scopes, grantedAt };
|
|
92
|
+
})();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Test whether `requestedScopes` is fully covered by the existing grant —
|
|
97
|
+
* the rule for skipping the consent screen. Returns false when:
|
|
98
|
+
* - no grant exists for (user, client), or
|
|
99
|
+
* - any requested scope is missing from the grant's set, or
|
|
100
|
+
* - `requestedScopes` is empty (we don't auto-approve "ask for nothing"
|
|
101
|
+
* flows; they're almost certainly client bugs and showing consent will
|
|
102
|
+
* surface that to the operator).
|
|
103
|
+
*/
|
|
104
|
+
export function isCoveredByGrant(
|
|
105
|
+
db: Database,
|
|
106
|
+
userId: string,
|
|
107
|
+
clientId: string,
|
|
108
|
+
requestedScopes: readonly string[],
|
|
109
|
+
): boolean {
|
|
110
|
+
if (requestedScopes.length === 0) return false;
|
|
111
|
+
const grant = findGrant(db, userId, clientId);
|
|
112
|
+
if (!grant) return false;
|
|
113
|
+
const granted = new Set(grant.scopes);
|
|
114
|
+
for (const s of requestedScopes) {
|
|
115
|
+
if (!granted.has(s)) return false;
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** All grants for a user, ordered most-recent first. Used by `parachute auth list-grants`. */
|
|
121
|
+
export function listGrantsForUser(db: Database, userId: string): Grant[] {
|
|
122
|
+
const rows = db
|
|
123
|
+
.prepare(
|
|
124
|
+
"SELECT user_id, client_id, scopes, granted_at FROM grants WHERE user_id = ? ORDER BY granted_at DESC",
|
|
125
|
+
)
|
|
126
|
+
.all(userId) as GrantRow[];
|
|
127
|
+
return rows.map(rowToGrant);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Delete a grant. Returns true when a row was removed; false when no grant
|
|
132
|
+
* existed for (user, client). Note this does not revoke existing tokens —
|
|
133
|
+
* an operator who wants to revoke active sessions runs `/oauth/revoke` (or
|
|
134
|
+
* its CLI wrapper) separately. Removing the grant only forces the next
|
|
135
|
+
* /oauth/authorize flow to show consent again.
|
|
136
|
+
*/
|
|
137
|
+
export function revokeGrant(db: Database, userId: string, clientId: string): boolean {
|
|
138
|
+
const res = db
|
|
139
|
+
.prepare("DELETE FROM grants WHERE user_id = ? AND client_id = ?")
|
|
140
|
+
.run(userId, clientId);
|
|
141
|
+
return res.changes > 0;
|
|
142
|
+
}
|