@openparachute/vault 0.4.8 → 0.4.9-rc.11
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/core/src/core.test.ts +4 -1
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +99 -41
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +304 -1
- package/core/src/portable-md.ts +418 -2
- package/core/src/schema.ts +114 -2
- package/core/src/store.ts +185 -2
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +147 -0
- package/src/auth.ts +121 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/cli.ts +131 -36
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/mcp-http.ts +48 -39
- package/src/mcp-install-interactive.test.ts +10 -21
- package/src/mcp-install-interactive.ts +12 -21
- package/src/mcp-install.test.ts +141 -30
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +460 -3
- package/src/mirror-config.test.ts +277 -14
- package/src/mirror-config.ts +482 -31
- package/src/mirror-credentials.test.ts +601 -0
- package/src/mirror-credentials.ts +700 -0
- package/src/mirror-deps.ts +67 -17
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +487 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +621 -72
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1111 -7
- package/src/module-config.ts +11 -5
- package/src/routes.ts +38 -1
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +193 -20
- package/src/server.ts +116 -35
- package/src/storage.test.ts +132 -7
- package/src/token-store.ts +300 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +681 -2
- package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
package/src/mirror-routes.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP surface for the mirror lifecycle.
|
|
3
3
|
*
|
|
4
|
-
* GET /vault/<name>/.parachute/mirror
|
|
5
|
-
* PUT /vault/<name>/.parachute/mirror
|
|
4
|
+
* GET /vault/<name>/.parachute/mirror — read current config + runtime status
|
|
5
|
+
* PUT /vault/<name>/.parachute/mirror — update config + reload watch loop
|
|
6
|
+
* POST /vault/<name>/.parachute/mirror/run-now — fire a one-shot export+commit+push pass
|
|
6
7
|
*
|
|
7
8
|
* URL note: the design doc names this `/admin/mirror`, but vault's
|
|
8
9
|
* existing routing already mounts the admin SPA's static-file bundle at
|
|
@@ -30,6 +31,39 @@ import {
|
|
|
30
31
|
type MirrorConfig,
|
|
31
32
|
} from "./mirror-config.ts";
|
|
32
33
|
import type { MirrorManager } from "./mirror-manager.ts";
|
|
34
|
+
import {
|
|
35
|
+
applyToGitRemote,
|
|
36
|
+
deleteCredentials,
|
|
37
|
+
emptyCredentials,
|
|
38
|
+
readCredentials,
|
|
39
|
+
sanitizeCredentials,
|
|
40
|
+
unsetGitRemote,
|
|
41
|
+
writeCredentials,
|
|
42
|
+
type MirrorCredentials,
|
|
43
|
+
} from "./mirror-credentials.ts";
|
|
44
|
+
import {
|
|
45
|
+
createRepo,
|
|
46
|
+
fetchUser,
|
|
47
|
+
getGithubClientId,
|
|
48
|
+
isPlaceholderClientId,
|
|
49
|
+
listRepos,
|
|
50
|
+
pollForToken,
|
|
51
|
+
requestDeviceCode,
|
|
52
|
+
type FetchLike,
|
|
53
|
+
type GitHubRepoInfo,
|
|
54
|
+
} from "./github-device-flow.ts";
|
|
55
|
+
import {
|
|
56
|
+
CloneFailedError,
|
|
57
|
+
ImportConflictError,
|
|
58
|
+
NotAVaultExportError,
|
|
59
|
+
cloneAndImport,
|
|
60
|
+
type GitSpawn,
|
|
61
|
+
type ImportAuth,
|
|
62
|
+
type ImportResult,
|
|
63
|
+
} from "./mirror-import.ts";
|
|
64
|
+
import { redactToken } from "./export-watch.ts";
|
|
65
|
+
import { getVaultStore } from "./vault-store.ts";
|
|
66
|
+
import { assetsDir } from "./routes.ts";
|
|
33
67
|
|
|
34
68
|
/**
|
|
35
69
|
* `GET /vault/<name>/.parachute/mirror` — return the persisted config +
|
|
@@ -41,7 +75,11 @@ import type { MirrorManager } from "./mirror-manager.ts";
|
|
|
41
75
|
* any persistence has happened yet.
|
|
42
76
|
*/
|
|
43
77
|
export function handleMirrorGet(manager: MirrorManager): Response {
|
|
44
|
-
|
|
78
|
+
// Per-vault (vault#400): `getEffectiveConfig()` returns THIS vault's
|
|
79
|
+
// persisted config even when the lazily-built manager hasn't started yet,
|
|
80
|
+
// so a non-default vault's page shows ITS config — never the default
|
|
81
|
+
// vault's. That's the exact "same remote on every vault page" symptom.
|
|
82
|
+
const config = manager.getEffectiveConfig();
|
|
45
83
|
const status = manager.getStatus();
|
|
46
84
|
return Response.json(
|
|
47
85
|
{
|
|
@@ -58,9 +96,12 @@ export function handleMirrorGet(manager: MirrorManager): Response {
|
|
|
58
96
|
* lifecycle.
|
|
59
97
|
*
|
|
60
98
|
* Request shape: same JSON as the MirrorConfig type — { enabled,
|
|
61
|
-
* location, external_path,
|
|
62
|
-
* commit_template,
|
|
63
|
-
* fields fall back to defaults.
|
|
99
|
+
* location, external_path, sync_mode, auto_commit, auto_push,
|
|
100
|
+
* commit_template, safety_net_seconds }. All fields optional; missing
|
|
101
|
+
* fields fall back to defaults. Legacy `watch: boolean` and
|
|
102
|
+
* `interval_seconds: number` are also accepted (back-compat with
|
|
103
|
+
* hand-edited configs); they translate to `sync_mode` / `safety_net_seconds`
|
|
104
|
+
* via `validateMirrorConfigShape`.
|
|
64
105
|
*
|
|
65
106
|
* Validation surface:
|
|
66
107
|
* - JSON shape: location ∈ {internal, external}, types match, etc.
|
|
@@ -93,7 +134,9 @@ export async function handleMirrorPut(
|
|
|
93
134
|
);
|
|
94
135
|
}
|
|
95
136
|
|
|
96
|
-
|
|
137
|
+
// Per-vault (vault#399): bind the auto_push/internal credential gate to
|
|
138
|
+
// the mirror-owning vault so it reads that vault's own credentials file.
|
|
139
|
+
const shape = validateMirrorConfigShape(body, { vaultName: manager.getVaultName() });
|
|
97
140
|
if (!shape.ok) {
|
|
98
141
|
return Response.json(
|
|
99
142
|
{
|
|
@@ -137,6 +180,82 @@ export async function handleMirrorPut(
|
|
|
137
180
|
);
|
|
138
181
|
}
|
|
139
182
|
|
|
183
|
+
/**
|
|
184
|
+
* `POST /vault/<name>/.parachute/mirror/run-now` — fire a one-shot export
|
|
185
|
+
* cycle right now (export → optional commit → optional push), using the
|
|
186
|
+
* persisted config. Same response shape as GET so the admin SPA reuses
|
|
187
|
+
* one decoder for both initial-load and after-trigger refresh.
|
|
188
|
+
*
|
|
189
|
+
* Refuses to fire (400) when the mirror is disabled: `runNow()` would
|
|
190
|
+
* already no-op in that case, but returning a 200 with stale status
|
|
191
|
+
* lets a misclick look successful. The 400 is the actionable surface
|
|
192
|
+
* — "enable the mirror first, then re-trigger."
|
|
193
|
+
*
|
|
194
|
+
* Mutating verb, vault:admin-gated upstream in `routing.ts` (alongside
|
|
195
|
+
* the GET/PUT). Auth is already enforced by the time this handler runs.
|
|
196
|
+
*/
|
|
197
|
+
export async function handleMirrorRunNow(
|
|
198
|
+
manager: MirrorManager,
|
|
199
|
+
): Promise<Response> {
|
|
200
|
+
const status = manager.getStatus();
|
|
201
|
+
if (!status.enabled) {
|
|
202
|
+
return Response.json(
|
|
203
|
+
{
|
|
204
|
+
error: "Mirror not enabled",
|
|
205
|
+
message:
|
|
206
|
+
"Mirror must be enabled (and successfully bootstrapped) before a manual run can fire. Enable it via PUT /.parachute/mirror first.",
|
|
207
|
+
},
|
|
208
|
+
{ status: 400 },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
const updated = await manager.runNow();
|
|
212
|
+
return Response.json(
|
|
213
|
+
{
|
|
214
|
+
config: manager.getConfig(),
|
|
215
|
+
status: updated,
|
|
216
|
+
},
|
|
217
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* `POST /vault/<name>/.parachute/mirror/push-now` — Cut 6 of vault#392.
|
|
223
|
+
*
|
|
224
|
+
* Fire a push against the currently-committed state of the mirror dir.
|
|
225
|
+
* Distinguished from `/run-now` which exports + commits + pushes —
|
|
226
|
+
* `push-now` is the credentials-side flow where the operator just wants
|
|
227
|
+
* to see "did the credentials I just saved actually work?"
|
|
228
|
+
*
|
|
229
|
+
* Returns the post-push status snapshot. Errors (no path, mirror
|
|
230
|
+
* disabled, push failed) surface as a JSON response — push failures
|
|
231
|
+
* are NOT 500s because the operator's expected next-step is to look at
|
|
232
|
+
* `last_push_error` and fix their remote, not "vault crashed."
|
|
233
|
+
*/
|
|
234
|
+
export async function handleMirrorPushNow(
|
|
235
|
+
manager: MirrorManager,
|
|
236
|
+
): Promise<Response> {
|
|
237
|
+
const status = manager.getStatus();
|
|
238
|
+
if (!status.enabled) {
|
|
239
|
+
return Response.json(
|
|
240
|
+
{
|
|
241
|
+
error: "Mirror not enabled",
|
|
242
|
+
message:
|
|
243
|
+
"Mirror must be enabled before a push can fire. Enable it via PUT /.parachute/mirror first.",
|
|
244
|
+
},
|
|
245
|
+
{ status: 400 },
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const result = await manager.pushNow();
|
|
249
|
+
return Response.json(
|
|
250
|
+
{
|
|
251
|
+
config: manager.getConfig(),
|
|
252
|
+
status: manager.getStatus(),
|
|
253
|
+
push: result,
|
|
254
|
+
},
|
|
255
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
140
259
|
/**
|
|
141
260
|
* Convenience for tests + future callers: build the GET response from a
|
|
142
261
|
* known-good config without needing a real MirrorManager.
|
|
@@ -150,3 +269,988 @@ export function buildMirrorGetResponse(
|
|
|
150
269
|
status,
|
|
151
270
|
};
|
|
152
271
|
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Credential routes — Cut 3 of the UI-configurable push credentials work.
|
|
275
|
+
//
|
|
276
|
+
// Six surfaces, all `vault:<name>:admin`-gated upstream:
|
|
277
|
+
//
|
|
278
|
+
// POST /.parachute/mirror/auth/github/device-code — start GitHub Device
|
|
279
|
+
// Flow; returns { polling_id, user_code, verification_uri, expires_in,
|
|
280
|
+
// interval }. The full device_code is kept server-side; the SPA polls
|
|
281
|
+
// by polling_id (a short opaque token) so the device_code doesn't
|
|
282
|
+
// land on the wire twice.
|
|
283
|
+
// POST /.parachute/mirror/auth/github/poll — poll for token, body
|
|
284
|
+
// { polling_id }. On `granted`: fetch user, save credentials, set
|
|
285
|
+
// remote URL, return { state: "granted", user }. Other states
|
|
286
|
+
// surface verbatim.
|
|
287
|
+
// POST /.parachute/mirror/auth/pat — validate + store a
|
|
288
|
+
// PAT (token + remote_url + label). Validates via `git ls-remote`.
|
|
289
|
+
// GET /.parachute/mirror/auth — current connection
|
|
290
|
+
// status (NO secrets). Returns the sanitized public shape.
|
|
291
|
+
// DELETE /.parachute/mirror/auth — wipe credentials,
|
|
292
|
+
// unset embedded-credential remote URL.
|
|
293
|
+
// GET /.parachute/mirror/auth/github/repos — list operator's
|
|
294
|
+
// GitHub repos via stored OAuth token.
|
|
295
|
+
// POST /.parachute/mirror/auth/github/create-repo — create a new private
|
|
296
|
+
// repo on behalf of the operator.
|
|
297
|
+
//
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* In-memory device-flow polling sessions. Maps a short opaque `polling_id`
|
|
302
|
+
* to the server-side `device_code` + metadata. Lives in process memory only;
|
|
303
|
+
* a vault restart blanks them (the operator restarts the flow, no big deal —
|
|
304
|
+
* the OAuth app's tokens that have already landed in credentials.yaml
|
|
305
|
+
* survive).
|
|
306
|
+
*
|
|
307
|
+
* 15-minute TTL — matches GitHub's default `expires_in` and saves us
|
|
308
|
+
* leaking polling slots on abandoned flows.
|
|
309
|
+
*/
|
|
310
|
+
interface DeviceFlowSession {
|
|
311
|
+
device_code: string;
|
|
312
|
+
client_id: string;
|
|
313
|
+
expires_at: number; // epoch ms
|
|
314
|
+
interval: number;
|
|
315
|
+
}
|
|
316
|
+
const deviceFlowSessions = new Map<string, DeviceFlowSession>();
|
|
317
|
+
|
|
318
|
+
function generatePollingId(): string {
|
|
319
|
+
// 16 hex chars from crypto. Not a secret (it's just a session-lookup
|
|
320
|
+
// key) but cryptographic strength keeps two concurrent operators from
|
|
321
|
+
// ever colliding.
|
|
322
|
+
const bytes = new Uint8Array(8);
|
|
323
|
+
crypto.getRandomValues(bytes);
|
|
324
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function reapExpiredSessions(now = Date.now()): void {
|
|
328
|
+
for (const [k, v] of deviceFlowSessions.entries()) {
|
|
329
|
+
if (v.expires_at <= now) deviceFlowSessions.delete(k);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Test seam — flush all in-memory sessions. */
|
|
334
|
+
export function _resetDeviceFlowSessionsForTest(): void {
|
|
335
|
+
deviceFlowSessions.clear();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Errors out cleanly when the operator hasn't replaced the placeholder
|
|
340
|
+
* client_id. The user-facing message explains the next step.
|
|
341
|
+
*/
|
|
342
|
+
function placeholderClientIdResponse(): Response {
|
|
343
|
+
return Response.json(
|
|
344
|
+
{
|
|
345
|
+
error: "GitHub OAuth not configured",
|
|
346
|
+
error_type: "placeholder_client_id",
|
|
347
|
+
message:
|
|
348
|
+
"This Parachute Vault build doesn't have a registered GitHub OAuth App client_id. Set the PARACHUTE_GITHUB_CLIENT_ID environment variable (see src/github-device-flow.ts for setup steps) or use the Personal Access Token path instead.",
|
|
349
|
+
},
|
|
350
|
+
{ status: 503 },
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* `POST /.parachute/mirror/auth/github/device-code` — kick off the device
|
|
356
|
+
* flow. Server retains the `device_code`; the SPA gets a short
|
|
357
|
+
* `polling_id` it uses to poll without re-sending the device_code on
|
|
358
|
+
* every round-trip.
|
|
359
|
+
*/
|
|
360
|
+
export async function handleAuthGithubDeviceCode(
|
|
361
|
+
fetchImpl?: FetchLike,
|
|
362
|
+
): Promise<Response> {
|
|
363
|
+
const clientId = getGithubClientId();
|
|
364
|
+
if (isPlaceholderClientId(clientId)) {
|
|
365
|
+
return placeholderClientIdResponse();
|
|
366
|
+
}
|
|
367
|
+
let result;
|
|
368
|
+
try {
|
|
369
|
+
result = await requestDeviceCode(clientId, fetchImpl);
|
|
370
|
+
} catch (err) {
|
|
371
|
+
return Response.json(
|
|
372
|
+
{
|
|
373
|
+
error: "Device code request failed",
|
|
374
|
+
message: (err as Error).message ?? String(err),
|
|
375
|
+
},
|
|
376
|
+
{ status: 502 },
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
reapExpiredSessions();
|
|
380
|
+
const polling_id = generatePollingId();
|
|
381
|
+
deviceFlowSessions.set(polling_id, {
|
|
382
|
+
device_code: result.device_code,
|
|
383
|
+
client_id: clientId,
|
|
384
|
+
expires_at: Date.now() + result.expires_in * 1000,
|
|
385
|
+
interval: result.interval,
|
|
386
|
+
});
|
|
387
|
+
return Response.json(
|
|
388
|
+
{
|
|
389
|
+
polling_id,
|
|
390
|
+
user_code: result.user_code,
|
|
391
|
+
verification_uri: result.verification_uri,
|
|
392
|
+
expires_in: result.expires_in,
|
|
393
|
+
interval: result.interval,
|
|
394
|
+
},
|
|
395
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* `POST /.parachute/mirror/auth/github/poll` — poll for token (body
|
|
401
|
+
* `{polling_id}`). On `granted`, fetch user, save credentials, return
|
|
402
|
+
* `{state: "granted", user}`. On `pending`/`slow_down`, return state +
|
|
403
|
+
* any new interval. On terminal failure, return state + cleanup the
|
|
404
|
+
* session entry.
|
|
405
|
+
*/
|
|
406
|
+
export async function handleAuthGithubPoll(
|
|
407
|
+
req: Request,
|
|
408
|
+
manager: MirrorManager,
|
|
409
|
+
fetchImpl?: FetchLike,
|
|
410
|
+
): Promise<Response> {
|
|
411
|
+
let body: { polling_id?: unknown };
|
|
412
|
+
try {
|
|
413
|
+
body = (await req.json()) as { polling_id?: unknown };
|
|
414
|
+
} catch (err) {
|
|
415
|
+
return Response.json(
|
|
416
|
+
{ error: "Invalid JSON body", message: (err as Error).message ?? String(err) },
|
|
417
|
+
{ status: 400 },
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
if (typeof body.polling_id !== "string") {
|
|
421
|
+
return Response.json(
|
|
422
|
+
{ error: "polling_id required", message: "Pass the polling_id from /auth/github/device-code." },
|
|
423
|
+
{ status: 400 },
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
reapExpiredSessions();
|
|
427
|
+
const session = deviceFlowSessions.get(body.polling_id);
|
|
428
|
+
if (!session) {
|
|
429
|
+
return Response.json(
|
|
430
|
+
{
|
|
431
|
+
state: "expired",
|
|
432
|
+
message: "Polling session not found or expired. Start the device flow again.",
|
|
433
|
+
},
|
|
434
|
+
{ status: 404 },
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
let poll;
|
|
438
|
+
try {
|
|
439
|
+
poll = await pollForToken(session.client_id, session.device_code, fetchImpl);
|
|
440
|
+
} catch (err) {
|
|
441
|
+
return Response.json(
|
|
442
|
+
{
|
|
443
|
+
error: "Poll failed",
|
|
444
|
+
message: (err as Error).message ?? String(err),
|
|
445
|
+
},
|
|
446
|
+
{ status: 502 },
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (poll.state === "granted") {
|
|
451
|
+
// Fetch user info to populate credentials.
|
|
452
|
+
let user;
|
|
453
|
+
try {
|
|
454
|
+
user = await fetchUser(poll.access_token, fetchImpl);
|
|
455
|
+
} catch (err) {
|
|
456
|
+
return Response.json(
|
|
457
|
+
{
|
|
458
|
+
error: "Token granted but /user fetch failed",
|
|
459
|
+
message: (err as Error).message ?? String(err),
|
|
460
|
+
},
|
|
461
|
+
{ status: 502 },
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
// Persist credentials. Keep any existing PAT — only swap active method.
|
|
465
|
+
const vaultName = manager.getVaultName();
|
|
466
|
+
const existing = readCredentials(vaultName) ?? emptyCredentials();
|
|
467
|
+
const next: MirrorCredentials = {
|
|
468
|
+
...existing,
|
|
469
|
+
active_method: "github_oauth",
|
|
470
|
+
github_oauth: {
|
|
471
|
+
access_token: poll.access_token,
|
|
472
|
+
scope: poll.scope,
|
|
473
|
+
authorized_at: new Date().toISOString(),
|
|
474
|
+
user_login: user.login,
|
|
475
|
+
user_id: user.id,
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
try {
|
|
479
|
+
writeCredentials(vaultName, next);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
return Response.json(
|
|
482
|
+
{
|
|
483
|
+
error: "Credentials write failed",
|
|
484
|
+
message: (err as Error).message ?? String(err),
|
|
485
|
+
},
|
|
486
|
+
{ status: 500 },
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
// Clean up the polling session.
|
|
490
|
+
deviceFlowSessions.delete(body.polling_id);
|
|
491
|
+
// Apply to git remote if mirror is currently running on an external
|
|
492
|
+
// path that's a git repo. The credentials become active on next push;
|
|
493
|
+
// the operator doesn't have to restart vault. We don't have an owner/
|
|
494
|
+
// repo yet (the operator hasn't picked a repo) — that wiring happens
|
|
495
|
+
// in the create-repo or repo-picked path. So at this point we just
|
|
496
|
+
// store credentials; the URL gets set when the operator picks a repo.
|
|
497
|
+
return Response.json(
|
|
498
|
+
{
|
|
499
|
+
state: "granted",
|
|
500
|
+
user: {
|
|
501
|
+
login: user.login,
|
|
502
|
+
id: user.id,
|
|
503
|
+
name: user.name,
|
|
504
|
+
avatar_url: user.avatar_url,
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (poll.state === "expired" || poll.state === "denied") {
|
|
512
|
+
deviceFlowSessions.delete(body.polling_id);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Pending / slow_down / expired / denied — surface verbatim.
|
|
516
|
+
const responseBody: Record<string, unknown> = { state: poll.state };
|
|
517
|
+
if (poll.state === "slow_down") responseBody.interval = poll.interval;
|
|
518
|
+
return Response.json(responseBody, {
|
|
519
|
+
headers: { "Access-Control-Allow-Origin": "*" },
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* `POST /.parachute/mirror/auth/pat` — store a PAT + remote URL.
|
|
525
|
+
*
|
|
526
|
+
* Validation:
|
|
527
|
+
* - `token` is a non-empty string.
|
|
528
|
+
* - `remote_url` is a non-empty string that parses as an HTTPS URL.
|
|
529
|
+
* - `git ls-remote <remote_url>` succeeds with timeout 10s. The token
|
|
530
|
+
* can be embedded in the URL or rely on the server's git config —
|
|
531
|
+
* we just need git to be able to talk to the remote.
|
|
532
|
+
*
|
|
533
|
+
* Probes via `git ls-remote` with GIT_TERMINAL_PROMPT=0 (no interactive
|
|
534
|
+
* prompts; bad creds fail fast) and a 10-second hard timeout.
|
|
535
|
+
*/
|
|
536
|
+
export async function handleAuthPat(
|
|
537
|
+
req: Request,
|
|
538
|
+
manager: MirrorManager,
|
|
539
|
+
): Promise<Response> {
|
|
540
|
+
let body: { token?: unknown; remote_url?: unknown; label?: unknown };
|
|
541
|
+
try {
|
|
542
|
+
body = (await req.json()) as Record<string, unknown>;
|
|
543
|
+
} catch (err) {
|
|
544
|
+
return Response.json(
|
|
545
|
+
{ error: "Invalid JSON body", message: (err as Error).message ?? String(err) },
|
|
546
|
+
{ status: 400 },
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
const token = typeof body.token === "string" ? body.token.trim() : "";
|
|
550
|
+
const remote_url = typeof body.remote_url === "string" ? body.remote_url.trim() : "";
|
|
551
|
+
const label =
|
|
552
|
+
typeof body.label === "string" && body.label.trim().length > 0
|
|
553
|
+
? body.label.trim()
|
|
554
|
+
: "Custom git credential";
|
|
555
|
+
if (token.length === 0) {
|
|
556
|
+
return Response.json(
|
|
557
|
+
{
|
|
558
|
+
error: "token required",
|
|
559
|
+
field: "token",
|
|
560
|
+
message: "Provide a personal access token (e.g. ghp_...).",
|
|
561
|
+
},
|
|
562
|
+
{ status: 400 },
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
if (remote_url.length === 0) {
|
|
566
|
+
return Response.json(
|
|
567
|
+
{
|
|
568
|
+
error: "remote_url required",
|
|
569
|
+
field: "remote_url",
|
|
570
|
+
message: "Provide the full HTTPS remote URL (e.g. https://github.com/owner/repo.git).",
|
|
571
|
+
},
|
|
572
|
+
{ status: 400 },
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
// Quick URL shape check.
|
|
576
|
+
let parsed: URL;
|
|
577
|
+
try {
|
|
578
|
+
parsed = new URL(remote_url);
|
|
579
|
+
} catch {
|
|
580
|
+
return Response.json(
|
|
581
|
+
{
|
|
582
|
+
error: "remote_url invalid",
|
|
583
|
+
field: "remote_url",
|
|
584
|
+
message: "remote_url must be a valid URL (https://host/owner/repo.git).",
|
|
585
|
+
},
|
|
586
|
+
{ status: 400 },
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
590
|
+
return Response.json(
|
|
591
|
+
{
|
|
592
|
+
error: "remote_url invalid",
|
|
593
|
+
field: "remote_url",
|
|
594
|
+
message: "remote_url must use http:// or https://; SSH remotes need a different flow.",
|
|
595
|
+
},
|
|
596
|
+
{ status: 400 },
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Validate via `git ls-remote <embedded-auth-url>` — uses the same
|
|
601
|
+
// x-access-token shape we'd embed at push time so the probe exercises
|
|
602
|
+
// the actual auth path. If the operator pasted a URL that already has
|
|
603
|
+
// userinfo, use it verbatim; otherwise embed token via x-access-token.
|
|
604
|
+
const authedUrl =
|
|
605
|
+
parsed.username || parsed.password
|
|
606
|
+
? remote_url
|
|
607
|
+
: (() => {
|
|
608
|
+
const u = new URL(remote_url);
|
|
609
|
+
u.username = "x-access-token";
|
|
610
|
+
u.password = token;
|
|
611
|
+
return u.toString();
|
|
612
|
+
})();
|
|
613
|
+
const probeResult = await probeGitLsRemote(authedUrl, 10_000);
|
|
614
|
+
if (!probeResult.ok) {
|
|
615
|
+
return Response.json(
|
|
616
|
+
{
|
|
617
|
+
error: "Probe failed",
|
|
618
|
+
message: `git ls-remote could not reach ${parsed.host}: ${probeResult.error}`,
|
|
619
|
+
},
|
|
620
|
+
{ status: 400 },
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Save the userinfo'd URL — that's what gets embedded as `origin` so
|
|
625
|
+
// bare `git push` works without needing GIT_ASKPASS etc. Per-vault
|
|
626
|
+
// (vault#399): the PAT + remote_url land in this vault's own file, not a
|
|
627
|
+
// server-wide one — so configuring vault B never reuses vault A's remote.
|
|
628
|
+
const vaultName = manager.getVaultName();
|
|
629
|
+
const next: MirrorCredentials = {
|
|
630
|
+
...(readCredentials(vaultName) ?? emptyCredentials()),
|
|
631
|
+
active_method: "pat",
|
|
632
|
+
pat: {
|
|
633
|
+
token,
|
|
634
|
+
remote_url: authedUrl,
|
|
635
|
+
label,
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
try {
|
|
639
|
+
writeCredentials(vaultName, next);
|
|
640
|
+
} catch (err) {
|
|
641
|
+
return Response.json(
|
|
642
|
+
{
|
|
643
|
+
error: "Credentials write failed",
|
|
644
|
+
message: (err as Error).message ?? String(err),
|
|
645
|
+
},
|
|
646
|
+
{ status: 500 },
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Push the new URL onto the mirror's git remote if it's currently
|
|
651
|
+
// resolved + on disk. Non-fatal if the mirror isn't running.
|
|
652
|
+
await applyCredentialsToMirror(manager);
|
|
653
|
+
|
|
654
|
+
// Cut 3: auto-enable auto_push when credentials save. Operator wiring
|
|
655
|
+
// credentials almost certainly wants pushes to fire — silent
|
|
656
|
+
// "credentials saved but pushes never happen" is the bug Aaron hit.
|
|
657
|
+
// If `auto_push` was already true, leave it alone (idempotent).
|
|
658
|
+
const autoPushChange = await maybeEnableAutoPush(manager);
|
|
659
|
+
|
|
660
|
+
// Cut 6: kick off an initial push-now so the operator sees the push
|
|
661
|
+
// happen immediately rather than waiting for the next write event.
|
|
662
|
+
// Only when (a) auto_push ended up true AND (b) there are local commits
|
|
663
|
+
// ahead of the (now-configured) remote.
|
|
664
|
+
const initialPush = await maybeFireInitialPush(manager, autoPushChange.auto_push_now_enabled);
|
|
665
|
+
|
|
666
|
+
return Response.json(
|
|
667
|
+
{
|
|
668
|
+
...sanitizeCredentials(next),
|
|
669
|
+
auto_push_was_already_enabled: autoPushChange.was_already_enabled,
|
|
670
|
+
auto_push_enabled: autoPushChange.auto_push_now_enabled,
|
|
671
|
+
initial_push: initialPush,
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
headers: { "Access-Control-Allow-Origin": "*" },
|
|
675
|
+
},
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Run `git ls-remote <url>` with a hard timeout, no interactive prompts.
|
|
681
|
+
*
|
|
682
|
+
* Threat-model note (reviewer-flagged on vault#384):
|
|
683
|
+
* The URL passed here contains the user's token in userinfo position
|
|
684
|
+
* (`https://x-access-token:<TOKEN>@host/repo.git`). On Linux that means
|
|
685
|
+
* the token sits in `/proc/<pid>/cmdline` for the ~10s probe window,
|
|
686
|
+
* readable by any process running as another UID with default perms.
|
|
687
|
+
* For vault's threat model (owner-operated, single-user self-host) this
|
|
688
|
+
* is acceptable — anyone with shell on the box already has read access
|
|
689
|
+
* to `~/.parachute/vault/.mirror-credentials.yaml` (0600) and
|
|
690
|
+
* `<mirror>/.git/config` (0644). Same posture as `~/.git-credentials`
|
|
691
|
+
* and `~/.netrc`. If we ever ship multi-tenant vault (cloud Tier 2)
|
|
692
|
+
* this needs to switch to env-based auth via a credential helper script
|
|
693
|
+
* so the token never enters argv.
|
|
694
|
+
*/
|
|
695
|
+
async function probeGitLsRemote(
|
|
696
|
+
url: string,
|
|
697
|
+
timeoutMs: number,
|
|
698
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
699
|
+
// GIT_TERMINAL_PROMPT=0 ensures bad credentials FAIL FAST instead of
|
|
700
|
+
// sitting at "Username:" indefinitely (which the timeout would then
|
|
701
|
+
// catch, but failing fast on the auth wall is the better UX).
|
|
702
|
+
const proc = Bun.spawn(["git", "ls-remote", url], {
|
|
703
|
+
stdout: "pipe",
|
|
704
|
+
stderr: "pipe",
|
|
705
|
+
env: {
|
|
706
|
+
...process.env,
|
|
707
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
708
|
+
// Also kill any system credential helper from intercepting — we
|
|
709
|
+
// want the probe to use ONLY the URL-embedded credential, not
|
|
710
|
+
// whatever's in keychain.
|
|
711
|
+
GIT_ASKPASS: "/bin/echo",
|
|
712
|
+
},
|
|
713
|
+
});
|
|
714
|
+
const timer = setTimeout(() => {
|
|
715
|
+
try {
|
|
716
|
+
proc.kill();
|
|
717
|
+
} catch {
|
|
718
|
+
// already exited
|
|
719
|
+
}
|
|
720
|
+
}, timeoutMs);
|
|
721
|
+
const exitCode = await proc.exited;
|
|
722
|
+
clearTimeout(timer);
|
|
723
|
+
if (exitCode === 0) return { ok: true };
|
|
724
|
+
const stderr = new TextDecoder()
|
|
725
|
+
.decode(await new Response(proc.stderr).arrayBuffer())
|
|
726
|
+
.trim();
|
|
727
|
+
// Redact userinfo from anything we surface back; git's error messages
|
|
728
|
+
// sometimes echo the URL.
|
|
729
|
+
const redacted = stderr.replace(/https?:\/\/[^@\s]+@/g, "https://***@");
|
|
730
|
+
return { ok: false, error: redacted };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* `GET /.parachute/mirror/auth` — connection status (no secrets). Reads the
|
|
735
|
+
* mirror-owning vault's per-vault credentials (vault#399).
|
|
736
|
+
*/
|
|
737
|
+
export function handleAuthGet(manager: MirrorManager): Response {
|
|
738
|
+
const creds = readCredentials(manager.getVaultName());
|
|
739
|
+
return Response.json(sanitizeCredentials(creds), {
|
|
740
|
+
headers: { "Access-Control-Allow-Origin": "*" },
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* `DELETE /.parachute/mirror/auth` — wipe credentials, clear the embedded
|
|
746
|
+
* remote URL on the mirror dir.
|
|
747
|
+
*/
|
|
748
|
+
export async function handleAuthDelete(manager: MirrorManager): Promise<Response> {
|
|
749
|
+
deleteCredentials(manager.getVaultName());
|
|
750
|
+
// Strip origin from the mirror dir if one is set.
|
|
751
|
+
const status = manager.getStatus();
|
|
752
|
+
if (status.mirror_path) {
|
|
753
|
+
try {
|
|
754
|
+
await unsetGitRemote(status.mirror_path);
|
|
755
|
+
} catch (err) {
|
|
756
|
+
console.warn(
|
|
757
|
+
`[mirror-auth] failed to unset git remote (non-fatal): ${(err as Error).message ?? err}`,
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return Response.json(sanitizeCredentials(null), {
|
|
762
|
+
headers: { "Access-Control-Allow-Origin": "*" },
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* `GET /.parachute/mirror/auth/github/repos` — list operator's repos via
|
|
768
|
+
* the stored OAuth token. Requires `active_method === "github_oauth"`.
|
|
769
|
+
*/
|
|
770
|
+
export async function handleAuthGithubRepos(
|
|
771
|
+
manager: MirrorManager,
|
|
772
|
+
fetchImpl?: FetchLike,
|
|
773
|
+
): Promise<Response> {
|
|
774
|
+
const creds = readCredentials(manager.getVaultName());
|
|
775
|
+
if (!creds || creds.active_method !== "github_oauth" || !creds.github_oauth) {
|
|
776
|
+
return Response.json(
|
|
777
|
+
{
|
|
778
|
+
error: "Not connected to GitHub",
|
|
779
|
+
message: "Run the device flow first (POST /.parachute/mirror/auth/github/device-code).",
|
|
780
|
+
},
|
|
781
|
+
{ status: 400 },
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
let result;
|
|
785
|
+
try {
|
|
786
|
+
result = await listRepos(creds.github_oauth.access_token, {}, fetchImpl);
|
|
787
|
+
} catch (err) {
|
|
788
|
+
return Response.json(
|
|
789
|
+
{
|
|
790
|
+
error: "Repo list failed",
|
|
791
|
+
message: (err as Error).message ?? String(err),
|
|
792
|
+
},
|
|
793
|
+
{ status: 502 },
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
return Response.json(
|
|
797
|
+
{ repos: result.repos, truncated: result.truncated },
|
|
798
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* `POST /.parachute/mirror/auth/github/create-repo` — create a new repo on
|
|
804
|
+
* the operator's account, return the new RepoInfo. The SPA flows straight
|
|
805
|
+
* from this into the "repo selected" state.
|
|
806
|
+
*/
|
|
807
|
+
export async function handleAuthGithubCreateRepo(
|
|
808
|
+
req: Request,
|
|
809
|
+
manager: MirrorManager,
|
|
810
|
+
fetchImpl?: FetchLike,
|
|
811
|
+
): Promise<Response> {
|
|
812
|
+
const creds = readCredentials(manager.getVaultName());
|
|
813
|
+
if (!creds || creds.active_method !== "github_oauth" || !creds.github_oauth) {
|
|
814
|
+
return Response.json(
|
|
815
|
+
{
|
|
816
|
+
error: "Not connected to GitHub",
|
|
817
|
+
message: "Run the device flow first.",
|
|
818
|
+
},
|
|
819
|
+
{ status: 400 },
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
let body: { name?: unknown; description?: unknown; private?: unknown };
|
|
823
|
+
try {
|
|
824
|
+
body = (await req.json()) as Record<string, unknown>;
|
|
825
|
+
} catch (err) {
|
|
826
|
+
return Response.json(
|
|
827
|
+
{ error: "Invalid JSON body", message: (err as Error).message ?? String(err) },
|
|
828
|
+
{ status: 400 },
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
832
|
+
if (name.length === 0) {
|
|
833
|
+
return Response.json(
|
|
834
|
+
{ error: "name required", field: "name", message: "Provide a repo name." },
|
|
835
|
+
{ status: 400 },
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
const isPrivate = body.private === false ? false : true; // default true
|
|
839
|
+
const description = typeof body.description === "string" ? body.description : undefined;
|
|
840
|
+
let repo: GitHubRepoInfo;
|
|
841
|
+
try {
|
|
842
|
+
repo = await createRepo(
|
|
843
|
+
creds.github_oauth.access_token,
|
|
844
|
+
{ name, description, private: isPrivate },
|
|
845
|
+
fetchImpl,
|
|
846
|
+
);
|
|
847
|
+
} catch (err) {
|
|
848
|
+
return Response.json(
|
|
849
|
+
{ error: "Create failed", message: (err as Error).message ?? String(err) },
|
|
850
|
+
{ status: 502 },
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
return Response.json(repo, { headers: { "Access-Control-Allow-Origin": "*" } });
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* `POST /.parachute/mirror/auth/github/select-repo` — operator picked a
|
|
858
|
+
* repo from the list (or just created one). Body `{owner, name}`. Writes
|
|
859
|
+
* the embedded-credential URL onto the mirror dir's `origin`.
|
|
860
|
+
*/
|
|
861
|
+
export async function handleAuthGithubSelectRepo(
|
|
862
|
+
req: Request,
|
|
863
|
+
manager: MirrorManager,
|
|
864
|
+
): Promise<Response> {
|
|
865
|
+
const creds = readCredentials(manager.getVaultName());
|
|
866
|
+
if (!creds || creds.active_method !== "github_oauth" || !creds.github_oauth) {
|
|
867
|
+
return Response.json(
|
|
868
|
+
{
|
|
869
|
+
error: "Not connected to GitHub",
|
|
870
|
+
message: "Run the device flow first.",
|
|
871
|
+
},
|
|
872
|
+
{ status: 400 },
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
let body: { owner?: unknown; name?: unknown };
|
|
876
|
+
try {
|
|
877
|
+
body = (await req.json()) as Record<string, unknown>;
|
|
878
|
+
} catch (err) {
|
|
879
|
+
return Response.json(
|
|
880
|
+
{ error: "Invalid JSON body", message: (err as Error).message ?? String(err) },
|
|
881
|
+
{ status: 400 },
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
const owner = typeof body.owner === "string" ? body.owner.trim() : "";
|
|
885
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
886
|
+
if (!owner || !name) {
|
|
887
|
+
return Response.json(
|
|
888
|
+
{
|
|
889
|
+
error: "owner and name required",
|
|
890
|
+
message: "Provide both `owner` and `name` for the repo to push to.",
|
|
891
|
+
},
|
|
892
|
+
{ status: 400 },
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
// Reach into mirror-credentials.ts for the authed URL builder.
|
|
896
|
+
const { githubAuthedRemoteUrl } = await import("./mirror-credentials.ts");
|
|
897
|
+
const authedUrl = githubAuthedRemoteUrl(
|
|
898
|
+
creds.github_oauth.access_token,
|
|
899
|
+
owner,
|
|
900
|
+
name,
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
// Apply to the mirror dir if running. If the mirror isn't running (no
|
|
904
|
+
// mirror_path), we still consider this a success — the credentials are
|
|
905
|
+
// stored, and the URL will get applied next time the mirror starts.
|
|
906
|
+
const status = manager.getStatus();
|
|
907
|
+
let applied = false;
|
|
908
|
+
if (status.mirror_path) {
|
|
909
|
+
const res = await applyToGitRemote(status.mirror_path, authedUrl);
|
|
910
|
+
if (!res.ok) {
|
|
911
|
+
return Response.json(
|
|
912
|
+
{
|
|
913
|
+
error: "Failed to set remote URL on mirror",
|
|
914
|
+
message: res.error,
|
|
915
|
+
},
|
|
916
|
+
{ status: 500 },
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
applied = true;
|
|
920
|
+
}
|
|
921
|
+
// Cut 3 / Cut 6: auto-enable auto_push + fire initial push if there's
|
|
922
|
+
// anything to push. Same logic as handleAuthPat — operator picking a
|
|
923
|
+
// repo wants the push to fire.
|
|
924
|
+
const autoPushChange = await maybeEnableAutoPush(manager);
|
|
925
|
+
const initialPush = await maybeFireInitialPush(manager, autoPushChange.auto_push_now_enabled);
|
|
926
|
+
|
|
927
|
+
return Response.json(
|
|
928
|
+
{
|
|
929
|
+
ok: true,
|
|
930
|
+
applied,
|
|
931
|
+
owner,
|
|
932
|
+
name,
|
|
933
|
+
// Echo the redacted form back so the SPA can show "pushing to <repo>".
|
|
934
|
+
// No raw token in the response.
|
|
935
|
+
remote: `https://github.com/${owner}/${name}.git`,
|
|
936
|
+
auto_push_was_already_enabled: autoPushChange.was_already_enabled,
|
|
937
|
+
auto_push_enabled: autoPushChange.auto_push_now_enabled,
|
|
938
|
+
initial_push: initialPush,
|
|
939
|
+
},
|
|
940
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Cut 3: when credentials save, flip `auto_push` from false → true on
|
|
946
|
+
* the persisted config. Operators wiring credentials almost certainly
|
|
947
|
+
* want pushes to fire — silent "credentials saved but no push" was the
|
|
948
|
+
* three-stacking-gaps bug Aaron hit.
|
|
949
|
+
*
|
|
950
|
+
* Idempotent: if `auto_push` was already true, no config write happens.
|
|
951
|
+
* If `auto_push` is false, write the flipped config via `manager.reload`
|
|
952
|
+
* — that restarts the lifecycle and applies the credentials to the
|
|
953
|
+
* remote in the same pass.
|
|
954
|
+
*
|
|
955
|
+
* Returns a small struct so the route handler can shape the response:
|
|
956
|
+
* - `was_already_enabled` — true iff auto_push was true before this call.
|
|
957
|
+
* - `auto_push_now_enabled` — true iff auto_push is true after this call.
|
|
958
|
+
*
|
|
959
|
+
* Both being false means the operator deliberately left auto_push off
|
|
960
|
+
* and we leave it that way (we never touch a config whose mirror is
|
|
961
|
+
* disabled, and we never flip true → false).
|
|
962
|
+
*/
|
|
963
|
+
async function maybeEnableAutoPush(
|
|
964
|
+
manager: MirrorManager,
|
|
965
|
+
): Promise<{ was_already_enabled: boolean; auto_push_now_enabled: boolean }> {
|
|
966
|
+
const config = manager.getConfig();
|
|
967
|
+
// Don't muck with auto_push when the mirror is disabled — the operator
|
|
968
|
+
// is configuring credentials before turning the mirror on, which is a
|
|
969
|
+
// legitimate sequence. They'll flip enabled themselves.
|
|
970
|
+
if (!config.enabled) {
|
|
971
|
+
return {
|
|
972
|
+
was_already_enabled: config.auto_push,
|
|
973
|
+
auto_push_now_enabled: config.auto_push,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
if (config.auto_push) {
|
|
977
|
+
return { was_already_enabled: true, auto_push_now_enabled: true };
|
|
978
|
+
}
|
|
979
|
+
try {
|
|
980
|
+
await manager.reload({ ...config, auto_push: true });
|
|
981
|
+
return { was_already_enabled: false, auto_push_now_enabled: true };
|
|
982
|
+
} catch (err) {
|
|
983
|
+
console.warn(
|
|
984
|
+
`[mirror-auth] could not auto-enable auto_push (non-fatal): ${(err as Error).message ?? err}`,
|
|
985
|
+
);
|
|
986
|
+
return { was_already_enabled: false, auto_push_now_enabled: false };
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Cut 6: after credential save, fire a `push-now` immediately so the
|
|
992
|
+
* operator sees the push happen rather than waiting for the next write
|
|
993
|
+
* event. Only when (a) auto_push is enabled (we just turned it on, or it
|
|
994
|
+
* was already on) AND (b) there are local commits to push.
|
|
995
|
+
*
|
|
996
|
+
* Best-effort: non-fatal on any failure. Returns a struct the caller
|
|
997
|
+
* folds into its response.
|
|
998
|
+
*/
|
|
999
|
+
async function maybeFireInitialPush(
|
|
1000
|
+
manager: MirrorManager,
|
|
1001
|
+
autoPushEnabled: boolean,
|
|
1002
|
+
): Promise<
|
|
1003
|
+
| { fired: false; reason: "auto_push_disabled" | "no_mirror_path" | "manager_skipped" | "not_enabled" }
|
|
1004
|
+
| { fired: true; pushed: boolean; error?: string; sha?: string }
|
|
1005
|
+
> {
|
|
1006
|
+
if (!autoPushEnabled) return { fired: false, reason: "auto_push_disabled" };
|
|
1007
|
+
const status = manager.getStatus();
|
|
1008
|
+
if (!status.mirror_path) return { fired: false, reason: "no_mirror_path" };
|
|
1009
|
+
// Need an active enabled mirror with a resolved path to push from.
|
|
1010
|
+
if (!status.enabled) return { fired: false, reason: "manager_skipped" };
|
|
1011
|
+
try {
|
|
1012
|
+
const result = await manager.pushNow();
|
|
1013
|
+
return result;
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
// Defense-in-depth: every other push-error site routes through
|
|
1016
|
+
// `redactToken` before surfacing — this catch-block was the one
|
|
1017
|
+
// outlier where a thrown error's `.message` could carry an
|
|
1018
|
+
// un-redacted token from a future code path that throws before the
|
|
1019
|
+
// existing internal redaction runs. Apply the same scrub here.
|
|
1020
|
+
// Reviewer-flagged on vault#392.
|
|
1021
|
+
const rawMessage = (err as Error).message ?? String(err);
|
|
1022
|
+
const safeMessage = redactToken(rawMessage);
|
|
1023
|
+
console.warn(`[mirror-auth] initial push-now failed (non-fatal): ${safeMessage}`);
|
|
1024
|
+
return { fired: true, pushed: false, error: safeMessage };
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Apply the active credential's remote URL to the running mirror dir.
|
|
1030
|
+
* Idempotent. Called from auth/pat (after store) + auth/github/select-repo
|
|
1031
|
+
* (after store). Non-fatal on failure — the credentials are saved either
|
|
1032
|
+
* way; the next mirror restart picks them up.
|
|
1033
|
+
*/
|
|
1034
|
+
export async function applyCredentialsToMirror(
|
|
1035
|
+
manager: MirrorManager,
|
|
1036
|
+
): Promise<void> {
|
|
1037
|
+
const status = manager.getStatus();
|
|
1038
|
+
if (!status.mirror_path) return;
|
|
1039
|
+
const creds = readCredentials(manager.getVaultName());
|
|
1040
|
+
if (!creds || !creds.active_method) {
|
|
1041
|
+
await unsetGitRemote(status.mirror_path);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
if (creds.active_method === "pat" && creds.pat) {
|
|
1045
|
+
await applyToGitRemote(status.mirror_path, creds.pat.remote_url);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
// GitHub OAuth — needs the operator to have picked a repo; the URL
|
|
1049
|
+
// wiring happens in handleAuthGithubSelectRepo. Nothing to apply here
|
|
1050
|
+
// until a repo is selected. Caller is responsible for invoking
|
|
1051
|
+
// select-repo separately.
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// ---------------------------------------------------------------------------
|
|
1055
|
+
// Import-from-git route — symmetric counterpart to the mirror export work.
|
|
1056
|
+
//
|
|
1057
|
+
// `POST /vault/<name>/.parachute/mirror/import` — clone a vault export from
|
|
1058
|
+
// a git remote and import it into the named vault.
|
|
1059
|
+
//
|
|
1060
|
+
// Request body:
|
|
1061
|
+
// {
|
|
1062
|
+
// "remote_url": "https://github.com/aaron/my-vault.git",
|
|
1063
|
+
// "mode": "merge" | "replace",
|
|
1064
|
+
// "credentials": null
|
|
1065
|
+
// | { "kind": "pat", "token": "ghp_..." }
|
|
1066
|
+
// | { "kind": "none" }
|
|
1067
|
+
// }
|
|
1068
|
+
//
|
|
1069
|
+
// `credentials: null` means "use the stored mirror credentials." Passing
|
|
1070
|
+
// `{kind: "pat", token}` is the one-shot path — token doesn't get persisted.
|
|
1071
|
+
//
|
|
1072
|
+
// Response:
|
|
1073
|
+
// 200 { notes_imported, tags_imported, attachments_imported,
|
|
1074
|
+
// notes_deleted?, warnings }
|
|
1075
|
+
// 400 { error, error_type, message } — validation / not-a-vault-export
|
|
1076
|
+
// 409 { error, error_type, message } — concurrent import for this vault
|
|
1077
|
+
// 502 { error, message } — clone failed (auth, network, …)
|
|
1078
|
+
//
|
|
1079
|
+
// Admin-gated upstream in routing.ts.
|
|
1080
|
+
// ---------------------------------------------------------------------------
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* `POST /vault/<name>/.parachute/mirror/import`. See block comment above.
|
|
1084
|
+
*
|
|
1085
|
+
* Synchronous response: imports complete in <30s for typical vaults
|
|
1086
|
+
* (a 1k-note vault clones+imports in well under that bound on Aaron's hardware).
|
|
1087
|
+
* If/when bigger vaults arrive we promote to async polling — for now the
|
|
1088
|
+
* synchronous path keeps the UI flow simple.
|
|
1089
|
+
*
|
|
1090
|
+
* `spawnOverride` is a test seam: lets the test inject a fake git binary.
|
|
1091
|
+
* Production callers omit it; `cloneAndImport` falls back to `defaultGitSpawn`.
|
|
1092
|
+
*/
|
|
1093
|
+
export async function handleMirrorImport(
|
|
1094
|
+
req: Request,
|
|
1095
|
+
vaultName: string,
|
|
1096
|
+
spawnOverride?: GitSpawn,
|
|
1097
|
+
): Promise<Response> {
|
|
1098
|
+
let body: {
|
|
1099
|
+
remote_url?: unknown;
|
|
1100
|
+
mode?: unknown;
|
|
1101
|
+
credentials?: unknown;
|
|
1102
|
+
};
|
|
1103
|
+
try {
|
|
1104
|
+
body = (await req.json()) as Record<string, unknown>;
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
return Response.json(
|
|
1107
|
+
{
|
|
1108
|
+
error: "Invalid JSON body",
|
|
1109
|
+
error_type: "invalid_json",
|
|
1110
|
+
message: (err as Error).message ?? String(err),
|
|
1111
|
+
},
|
|
1112
|
+
{ status: 400 },
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const remote_url =
|
|
1117
|
+
typeof body.remote_url === "string" ? body.remote_url.trim() : "";
|
|
1118
|
+
if (remote_url.length === 0) {
|
|
1119
|
+
return Response.json(
|
|
1120
|
+
{
|
|
1121
|
+
error: "remote_url required",
|
|
1122
|
+
error_type: "validation",
|
|
1123
|
+
field: "remote_url",
|
|
1124
|
+
message: "Provide an HTTPS or SSH clone URL.",
|
|
1125
|
+
},
|
|
1126
|
+
{ status: 400 },
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
const mode = body.mode;
|
|
1130
|
+
if (mode !== "merge" && mode !== "replace") {
|
|
1131
|
+
return Response.json(
|
|
1132
|
+
{
|
|
1133
|
+
error: "mode invalid",
|
|
1134
|
+
error_type: "validation",
|
|
1135
|
+
field: "mode",
|
|
1136
|
+
message: 'mode must be "merge" or "replace".',
|
|
1137
|
+
},
|
|
1138
|
+
{ status: 400 },
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Resolve auth shape. The wire format mirrors the internal ImportAuth
|
|
1143
|
+
// type, but tightened: only `kind` and `token` cross the wire. `none`
|
|
1144
|
+
// and `null` both fall through to "use stored credentials" because the
|
|
1145
|
+
// common case is "I configured mirror creds; use them" — and the
|
|
1146
|
+
// shorthand keeps the SPA from having to track which kind to send.
|
|
1147
|
+
let auth: ImportAuth;
|
|
1148
|
+
const creds = body.credentials;
|
|
1149
|
+
if (creds === null || creds === undefined) {
|
|
1150
|
+
auth = { kind: "credentialsFile" };
|
|
1151
|
+
} else if (typeof creds === "object") {
|
|
1152
|
+
const credsObj = creds as Record<string, unknown>;
|
|
1153
|
+
if (credsObj.kind === "pat") {
|
|
1154
|
+
const token = typeof credsObj.token === "string" ? credsObj.token.trim() : "";
|
|
1155
|
+
if (token.length === 0) {
|
|
1156
|
+
return Response.json(
|
|
1157
|
+
{
|
|
1158
|
+
error: "credentials.token required",
|
|
1159
|
+
error_type: "validation",
|
|
1160
|
+
field: "credentials.token",
|
|
1161
|
+
message: 'When credentials.kind is "pat", credentials.token must be a non-empty string.',
|
|
1162
|
+
},
|
|
1163
|
+
{ status: 400 },
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
auth = { kind: "pat", token };
|
|
1167
|
+
} else if (credsObj.kind === "none") {
|
|
1168
|
+
auth = { kind: "none" };
|
|
1169
|
+
} else if (credsObj.kind === "credentialsFile") {
|
|
1170
|
+
auth = { kind: "credentialsFile" };
|
|
1171
|
+
} else {
|
|
1172
|
+
return Response.json(
|
|
1173
|
+
{
|
|
1174
|
+
error: "credentials.kind invalid",
|
|
1175
|
+
error_type: "validation",
|
|
1176
|
+
field: "credentials.kind",
|
|
1177
|
+
message: 'credentials.kind must be "pat", "credentialsFile", or "none". Or pass credentials: null.',
|
|
1178
|
+
},
|
|
1179
|
+
{ status: 400 },
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
} else {
|
|
1183
|
+
return Response.json(
|
|
1184
|
+
{
|
|
1185
|
+
error: "credentials invalid",
|
|
1186
|
+
error_type: "validation",
|
|
1187
|
+
field: "credentials",
|
|
1188
|
+
message: "credentials must be an object or null.",
|
|
1189
|
+
},
|
|
1190
|
+
{ status: 400 },
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Resolve the target vault's store + assets dir. The route is gated on
|
|
1195
|
+
// `vault:<name>:admin`, so we trust vaultName is real by the time we
|
|
1196
|
+
// reach this code path; defensively re-resolve in case the vault was
|
|
1197
|
+
// deleted between auth and now.
|
|
1198
|
+
const store = getVaultStore(vaultName);
|
|
1199
|
+
const assets = assetsDir(vaultName);
|
|
1200
|
+
|
|
1201
|
+
let result: ImportResult;
|
|
1202
|
+
try {
|
|
1203
|
+
result = await cloneAndImport({
|
|
1204
|
+
vaultName,
|
|
1205
|
+
remoteUrl: remote_url,
|
|
1206
|
+
auth,
|
|
1207
|
+
mode,
|
|
1208
|
+
store,
|
|
1209
|
+
assetsDir: assets,
|
|
1210
|
+
spawn: spawnOverride,
|
|
1211
|
+
});
|
|
1212
|
+
} catch (err) {
|
|
1213
|
+
if (err instanceof ImportConflictError) {
|
|
1214
|
+
return Response.json(
|
|
1215
|
+
{
|
|
1216
|
+
error: "Import already running",
|
|
1217
|
+
error_type: "concurrent_import",
|
|
1218
|
+
message: err.message,
|
|
1219
|
+
},
|
|
1220
|
+
{ status: 409 },
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
if (err instanceof NotAVaultExportError) {
|
|
1224
|
+
return Response.json(
|
|
1225
|
+
{
|
|
1226
|
+
error: "Not a vault export",
|
|
1227
|
+
error_type: "not_a_vault_export",
|
|
1228
|
+
message: err.message,
|
|
1229
|
+
},
|
|
1230
|
+
{ status: 400 },
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
if (err instanceof CloneFailedError) {
|
|
1234
|
+
return Response.json(
|
|
1235
|
+
{
|
|
1236
|
+
error: "Clone failed",
|
|
1237
|
+
error_type: "clone_failed",
|
|
1238
|
+
message: err.message,
|
|
1239
|
+
},
|
|
1240
|
+
{ status: 502 },
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
return Response.json(
|
|
1244
|
+
{
|
|
1245
|
+
error: "Import failed",
|
|
1246
|
+
error_type: "internal",
|
|
1247
|
+
message: (err as Error).message ?? String(err),
|
|
1248
|
+
},
|
|
1249
|
+
{ status: 500 },
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
return Response.json(result, {
|
|
1254
|
+
headers: { "Access-Control-Allow-Origin": "*" },
|
|
1255
|
+
});
|
|
1256
|
+
}
|