@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
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirror credentials store — UI-configurable git push credentials.
|
|
3
|
+
*
|
|
4
|
+
* After vault#382 (event-driven mirror), `auto_push: true` works only if the
|
|
5
|
+
* operator's shell has the right git credential plumbing wired (SSH key,
|
|
6
|
+
* GH_TOKEN, system credential helper). For self-hosted users that pattern is
|
|
7
|
+
* a non-starter — most have never opened a terminal. This module owns the
|
|
8
|
+
* UI-configurable alternative.
|
|
9
|
+
*
|
|
10
|
+
* Two surfaces:
|
|
11
|
+
* - **GitHub OAuth Device Flow** — the recommended path. Operator opens a
|
|
12
|
+
* modal, vault calls GitHub's device-code endpoint, the operator types
|
|
13
|
+
* a code at github.com/login/device, vault polls until granted, the
|
|
14
|
+
* resulting `gho_*` token is stored and embedded in the mirror's git
|
|
15
|
+
* remote URL so bare `git push` works. Same UX as `gh auth login`.
|
|
16
|
+
* **Why Device Flow, not Web Flow:** Web Flow needs a pre-registered
|
|
17
|
+
* callback URL per OAuth app; self-hosted vaults have unpredictable
|
|
18
|
+
* origins (localhost:1940, random Tailscale FQDN, custom domain). Device
|
|
19
|
+
* Flow needs only a public `client_id` and works against any vault
|
|
20
|
+
* origin without infrastructure.
|
|
21
|
+
* - **Personal Access Token (PAT) fallback** — provider-agnostic. Operator
|
|
22
|
+
* pastes a token + a remote URL with HTTPS auth; vault stores both and
|
|
23
|
+
* embeds them in the mirror's remote URL. Works against GitHub, GitLab,
|
|
24
|
+
* Codeberg, Gitea, anything that accepts an HTTPS token in the URL.
|
|
25
|
+
*
|
|
26
|
+
* **Storage:** `<configDir>/vault/data/<vaultName>/.mirror-credentials.yaml`,
|
|
27
|
+
* perms `0o600`, **not encrypted at rest**. Rationale: encryption-at-rest
|
|
28
|
+
* with the key on the same disk doesn't add real security; OS perms ARE the
|
|
29
|
+
* protection. Same trust model as `~/.git-credentials` (which most operators
|
|
30
|
+
* already use). The file is documented as sensitive; redaction in logs is
|
|
31
|
+
* enforced by `sanitizeCredentials` + a discipline of "never log the raw
|
|
32
|
+
* token."
|
|
33
|
+
*
|
|
34
|
+
* **Per-vault (vault#399).** Credentials — both the PAT and the embedded
|
|
35
|
+
* `remote_url` — live under each vault's own data dir, alongside its SQLite
|
|
36
|
+
* DB + vault.yaml. This is the existing per-vault-state pattern. Before
|
|
37
|
+
* vault#399 they lived in a single SERVER-WIDE file
|
|
38
|
+
* (`<configDir>/vault/.mirror-credentials.yaml`); that leaked the first
|
|
39
|
+
* vault's remote + PAT onto every other vault that configured git sync —
|
|
40
|
+
* pointing vault B at vault A's GitHub repo. The legacy server-wide file is
|
|
41
|
+
* migrated to its owning vault on first per-vault read (see
|
|
42
|
+
* `migrateLegacyServerWideCredentials`).
|
|
43
|
+
*
|
|
44
|
+
* **One credential set per vault.** Multi-credential ("I want repo A pushed
|
|
45
|
+
* with token X, repo B with token Y") within a single vault isn't supported —
|
|
46
|
+
* vault#382 ships one mirror per vault; one credential set per vault matches.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import {
|
|
50
|
+
chmodSync,
|
|
51
|
+
existsSync,
|
|
52
|
+
mkdirSync,
|
|
53
|
+
readFileSync,
|
|
54
|
+
renameSync,
|
|
55
|
+
statSync,
|
|
56
|
+
unlinkSync,
|
|
57
|
+
writeFileSync,
|
|
58
|
+
} from "node:fs";
|
|
59
|
+
import { dirname, join } from "node:path";
|
|
60
|
+
import { homedir } from "node:os";
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Types
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Which credential surface is currently active. Null when none configured.
|
|
68
|
+
* - `github_oauth` — populated `github_oauth` block.
|
|
69
|
+
* - `pat` — populated `pat` block (Personal Access Token + remote URL).
|
|
70
|
+
*/
|
|
71
|
+
export type ActiveMethod = "github_oauth" | "pat" | null;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* GitHub OAuth Device Flow result. Stored verbatim after a successful poll
|
|
75
|
+
* returns `granted`. The `access_token` is what gets embedded in the git
|
|
76
|
+
* remote URL at push time (via `x-access-token:<TOKEN>@github.com/...`).
|
|
77
|
+
*/
|
|
78
|
+
export interface GitHubOAuthCredential {
|
|
79
|
+
/** The `gho_*` token returned by GitHub's `/login/oauth/access_token`. */
|
|
80
|
+
access_token: string;
|
|
81
|
+
/** Scope string GitHub granted (typically "repo"). */
|
|
82
|
+
scope: string;
|
|
83
|
+
/** ISO timestamp captured at the moment we saved the token. */
|
|
84
|
+
authorized_at: string;
|
|
85
|
+
/** GitHub login (`@octocat`). */
|
|
86
|
+
user_login: string;
|
|
87
|
+
/** GitHub numeric user id — stable across login renames. */
|
|
88
|
+
user_id: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Personal Access Token fallback. The operator pastes both the token AND
|
|
93
|
+
* the remote URL — vault doesn't try to guess one from the other (GitHub
|
|
94
|
+
* uses `https://x-access-token:<token>@github.com/...`, GitLab uses
|
|
95
|
+
* `https://oauth2:<token>@gitlab.com/...`, etc., and there's no generic
|
|
96
|
+
* rule). The stored URL is what gets set as the mirror's remote.
|
|
97
|
+
*/
|
|
98
|
+
export interface PATCredential {
|
|
99
|
+
/** Bearer token (ghp_*, glpat-*, etc.). */
|
|
100
|
+
token: string;
|
|
101
|
+
/**
|
|
102
|
+
* Full HTTPS remote URL with auth embedded, e.g.
|
|
103
|
+
* `https://x-access-token:ghp_abc@github.com/owner/repo.git`. The operator
|
|
104
|
+
* supplies this; we don't synthesize.
|
|
105
|
+
*/
|
|
106
|
+
remote_url: string;
|
|
107
|
+
/** Operator-visible label, e.g. "GitHub PAT for backup". */
|
|
108
|
+
label: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The on-disk + on-the-wire shape. One file per VAULT (vault#399) — keyed by
|
|
113
|
+
* the vault's data dir, not the server. Each vault has its own PAT + its own
|
|
114
|
+
* `remote_url`, so configuring git sync for vault B never reuses vault A's
|
|
115
|
+
* remote.
|
|
116
|
+
*/
|
|
117
|
+
export interface MirrorCredentials {
|
|
118
|
+
/**
|
|
119
|
+
* Which credential method is active. Read paths check this; if null the
|
|
120
|
+
* mirror runs with no embedded credentials (bare `git push` inherits
|
|
121
|
+
* the shell — back-compat with pre-PR operators).
|
|
122
|
+
*/
|
|
123
|
+
active_method: ActiveMethod;
|
|
124
|
+
github_oauth: GitHubOAuthCredential | null;
|
|
125
|
+
pat: PATCredential | null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Redacted view of credentials, safe for logs / API responses. Masks tokens
|
|
130
|
+
* to first-4 + last-4 chars; preserves user metadata so the operator can
|
|
131
|
+
* verify "yes, this is the right account/repo" without re-authenticating.
|
|
132
|
+
*/
|
|
133
|
+
export interface MirrorCredentialsPublic {
|
|
134
|
+
active_method: ActiveMethod;
|
|
135
|
+
github_oauth: {
|
|
136
|
+
user_login: string;
|
|
137
|
+
user_id: number;
|
|
138
|
+
scope: string;
|
|
139
|
+
authorized_at: string;
|
|
140
|
+
token_preview: string;
|
|
141
|
+
} | null;
|
|
142
|
+
pat: {
|
|
143
|
+
label: string;
|
|
144
|
+
remote_url: string;
|
|
145
|
+
token_preview: string;
|
|
146
|
+
} | null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Path resolution
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/** The vault home root — `<configDir>/vault`. Re-reads PARACHUTE_HOME per call. */
|
|
154
|
+
function vaultHomeRoot(): string {
|
|
155
|
+
const root = process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
|
|
156
|
+
return join(root, "vault");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Path to a vault's per-vault credentials file (vault#399).
|
|
161
|
+
*
|
|
162
|
+
* `<configDir>/vault/data/<vaultName>/.mirror-credentials.yaml` — under the
|
|
163
|
+
* vault's own data dir, alongside its SQLite DB (`vault.db`) + config
|
|
164
|
+
* (`vault.yaml`). This is the canonical per-vault-state location; every
|
|
165
|
+
* vault carries its own PAT + remote_url so git sync for one vault never
|
|
166
|
+
* reuses another vault's remote.
|
|
167
|
+
*
|
|
168
|
+
* Path resolution mirrors `config.ts:vaultDir()` rather than importing it —
|
|
169
|
+
* mirror-config.ts imports this module and config.ts imports mirror-config.ts,
|
|
170
|
+
* so importing config.ts here would close an import cycle. We re-derive the
|
|
171
|
+
* path from `PARACHUTE_HOME` (the canonical override the rest of vault honors)
|
|
172
|
+
* instead, which keeps this module dependency-light.
|
|
173
|
+
*/
|
|
174
|
+
export function mirrorCredentialsPath(vaultName: string): string {
|
|
175
|
+
return join(vaultHomeRoot(), "data", vaultName, ".mirror-credentials.yaml");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Path to the LEGACY server-wide credentials file
|
|
180
|
+
* (`<configDir>/vault/.mirror-credentials.yaml`) used before vault#399.
|
|
181
|
+
* Retained only so the migration can find + attribute + rename it.
|
|
182
|
+
*/
|
|
183
|
+
export function legacyServerWideCredentialsPath(): string {
|
|
184
|
+
return join(vaultHomeRoot(), ".mirror-credentials.yaml");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Defaults
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/** Empty credentials — what readCredentials returns when the file is absent. */
|
|
192
|
+
export function emptyCredentials(): MirrorCredentials {
|
|
193
|
+
return {
|
|
194
|
+
active_method: null,
|
|
195
|
+
github_oauth: null,
|
|
196
|
+
pat: null,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// YAML — hand-rolled to match the pattern in mirror-config.ts. No new dep.
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Serialize credentials as YAML. Keeps the file hand-editable for operators
|
|
206
|
+
* who want to rotate a token by `vim`-ing the file.
|
|
207
|
+
*
|
|
208
|
+
* Format:
|
|
209
|
+
*
|
|
210
|
+
* active_method: github_oauth
|
|
211
|
+
* github_oauth:
|
|
212
|
+
* access_token: gho_...
|
|
213
|
+
* scope: repo
|
|
214
|
+
* authorized_at: 2026-05-28T03:14:15.000Z
|
|
215
|
+
* user_login: aaron
|
|
216
|
+
* user_id: 12345
|
|
217
|
+
* pat:
|
|
218
|
+
* token: ghp_...
|
|
219
|
+
* remote_url: https://github.com/aaron/my-vault.git
|
|
220
|
+
* label: "GitHub PAT"
|
|
221
|
+
*/
|
|
222
|
+
export function serializeCredentials(creds: MirrorCredentials): string {
|
|
223
|
+
const lines: string[] = [];
|
|
224
|
+
lines.push(`active_method: ${creds.active_method === null ? "null" : creds.active_method}`);
|
|
225
|
+
if (creds.github_oauth) {
|
|
226
|
+
lines.push("github_oauth:");
|
|
227
|
+
lines.push(` access_token: ${quoteIfNeeded(creds.github_oauth.access_token)}`);
|
|
228
|
+
lines.push(` scope: ${quoteIfNeeded(creds.github_oauth.scope)}`);
|
|
229
|
+
lines.push(` authorized_at: ${quoteIfNeeded(creds.github_oauth.authorized_at)}`);
|
|
230
|
+
lines.push(` user_login: ${quoteIfNeeded(creds.github_oauth.user_login)}`);
|
|
231
|
+
lines.push(` user_id: ${creds.github_oauth.user_id}`);
|
|
232
|
+
} else {
|
|
233
|
+
lines.push("github_oauth: null");
|
|
234
|
+
}
|
|
235
|
+
if (creds.pat) {
|
|
236
|
+
lines.push("pat:");
|
|
237
|
+
lines.push(` token: ${quoteIfNeeded(creds.pat.token)}`);
|
|
238
|
+
lines.push(` remote_url: ${quoteIfNeeded(creds.pat.remote_url)}`);
|
|
239
|
+
lines.push(` label: ${quoteIfNeeded(creds.pat.label)}`);
|
|
240
|
+
} else {
|
|
241
|
+
lines.push("pat: null");
|
|
242
|
+
}
|
|
243
|
+
return lines.join("\n") + "\n";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Quote a YAML scalar when it contains characters that confuse parsers.
|
|
248
|
+
* `gho_*` / `ghp_*` tokens never carry newlines or special chars, but the
|
|
249
|
+
* operator-supplied `label` field has no such guarantee — a label with a
|
|
250
|
+
* literal `\n` would break the parser's per-line section logic. Escape
|
|
251
|
+
* newlines + carriage returns + backslash + quote inside the quoted form.
|
|
252
|
+
* Reviewer-flagged on vault#384.
|
|
253
|
+
*/
|
|
254
|
+
function quoteIfNeeded(value: string): string {
|
|
255
|
+
if (/[:#"'\\\n\r]/.test(value) || value.trim() !== value || value.length === 0) {
|
|
256
|
+
return `"${value
|
|
257
|
+
.replace(/\\/g, "\\\\")
|
|
258
|
+
.replace(/"/g, '\\"')
|
|
259
|
+
.replace(/\n/g, "\\n")
|
|
260
|
+
.replace(/\r/g, "\\r")}"`;
|
|
261
|
+
}
|
|
262
|
+
return value;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Parse a YAML scalar that may be quoted; otherwise return the trimmed value. */
|
|
266
|
+
function parseScalar(raw: string): string {
|
|
267
|
+
const trimmed = raw.trim();
|
|
268
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
|
|
269
|
+
return trimmed
|
|
270
|
+
.slice(1, -1)
|
|
271
|
+
.replace(/\\n/g, "\n")
|
|
272
|
+
.replace(/\\r/g, "\r")
|
|
273
|
+
.replace(/\\"/g, '"')
|
|
274
|
+
.replace(/\\\\/g, "\\");
|
|
275
|
+
}
|
|
276
|
+
return trimmed;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Parse the credentials YAML file. Lenient — unknown fields ignored, missing
|
|
281
|
+
* blocks default to null. Returns `emptyCredentials()` if the file is empty
|
|
282
|
+
* or contains nothing recognized.
|
|
283
|
+
*/
|
|
284
|
+
export function parseCredentials(yaml: string): MirrorCredentials {
|
|
285
|
+
const result = emptyCredentials();
|
|
286
|
+
const lines = yaml.split("\n");
|
|
287
|
+
let section: "github_oauth" | "pat" | null = null;
|
|
288
|
+
// Buffer per-section scalars so we can validate as a block before commit.
|
|
289
|
+
let oauth: Partial<GitHubOAuthCredential> = {};
|
|
290
|
+
let pat: Partial<PATCredential> = {};
|
|
291
|
+
|
|
292
|
+
const commitSection = () => {
|
|
293
|
+
if (section === "github_oauth") {
|
|
294
|
+
if (
|
|
295
|
+
oauth.access_token &&
|
|
296
|
+
oauth.scope !== undefined &&
|
|
297
|
+
oauth.authorized_at &&
|
|
298
|
+
oauth.user_login &&
|
|
299
|
+
typeof oauth.user_id === "number"
|
|
300
|
+
) {
|
|
301
|
+
result.github_oauth = oauth as GitHubOAuthCredential;
|
|
302
|
+
}
|
|
303
|
+
oauth = {};
|
|
304
|
+
} else if (section === "pat") {
|
|
305
|
+
if (pat.token && pat.remote_url && pat.label !== undefined) {
|
|
306
|
+
result.pat = pat as PATCredential;
|
|
307
|
+
}
|
|
308
|
+
pat = {};
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
for (const line of lines) {
|
|
313
|
+
if (line.match(/^\s*$/)) continue;
|
|
314
|
+
|
|
315
|
+
// Top-level scalars / section headers.
|
|
316
|
+
if (line.match(/^\S/)) {
|
|
317
|
+
// Close the previous section before starting a new one.
|
|
318
|
+
commitSection();
|
|
319
|
+
section = null;
|
|
320
|
+
const activeMatch = line.match(/^active_method:\s*(.*)$/);
|
|
321
|
+
if (activeMatch) {
|
|
322
|
+
const v = parseScalar(activeMatch[1]!);
|
|
323
|
+
if (v === "github_oauth" || v === "pat") result.active_method = v;
|
|
324
|
+
else result.active_method = null;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (line.match(/^github_oauth:\s*null\s*$/)) {
|
|
328
|
+
result.github_oauth = null;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (line.match(/^pat:\s*null\s*$/)) {
|
|
332
|
+
result.pat = null;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (line.match(/^github_oauth:\s*$/)) {
|
|
336
|
+
section = "github_oauth";
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (line.match(/^pat:\s*$/)) {
|
|
340
|
+
section = "pat";
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Indented section field.
|
|
347
|
+
const fieldMatch = line.match(/^\s+(\w+):\s*(.*)$/);
|
|
348
|
+
if (!fieldMatch) continue;
|
|
349
|
+
const [, key, rawVal] = fieldMatch;
|
|
350
|
+
if (section === "github_oauth") {
|
|
351
|
+
if (key === "access_token") oauth.access_token = parseScalar(rawVal!);
|
|
352
|
+
else if (key === "scope") oauth.scope = parseScalar(rawVal!);
|
|
353
|
+
else if (key === "authorized_at") oauth.authorized_at = parseScalar(rawVal!);
|
|
354
|
+
else if (key === "user_login") oauth.user_login = parseScalar(rawVal!);
|
|
355
|
+
else if (key === "user_id") {
|
|
356
|
+
const n = Number(parseScalar(rawVal!));
|
|
357
|
+
if (Number.isFinite(n)) oauth.user_id = n;
|
|
358
|
+
}
|
|
359
|
+
} else if (section === "pat") {
|
|
360
|
+
if (key === "token") pat.token = parseScalar(rawVal!);
|
|
361
|
+
else if (key === "remote_url") pat.remote_url = parseScalar(rawVal!);
|
|
362
|
+
else if (key === "label") pat.label = parseScalar(rawVal!);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
commitSection();
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// File I/O
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Read a vault's credentials from disk. Returns `null` when the file doesn't
|
|
376
|
+
* exist (operator hasn't connected anything yet); throws when the file is
|
|
377
|
+
* present but unreadable (a permission error is a loud configuration problem).
|
|
378
|
+
*
|
|
379
|
+
* vault#399 migration: if the per-vault file is absent but the legacy
|
|
380
|
+
* server-wide file exists, `migrateLegacyServerWideCredentials` may have
|
|
381
|
+
* promoted it to THIS vault (when it's the migration target). The migration
|
|
382
|
+
* runs at boot (server.ts) and is idempotent; this read just picks up
|
|
383
|
+
* whatever landed on disk.
|
|
384
|
+
*/
|
|
385
|
+
export function readCredentials(vaultName: string): MirrorCredentials | null {
|
|
386
|
+
const path = mirrorCredentialsPath(vaultName);
|
|
387
|
+
if (!existsSync(path)) return null;
|
|
388
|
+
const raw = readFileSync(path, "utf8");
|
|
389
|
+
return parseCredentials(raw);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Persist credentials atomically:
|
|
394
|
+
* 1. Write to `<path>.tmp` with perms 0600 (write + read by owner only).
|
|
395
|
+
* 2. Rename onto the final path (atomic on POSIX; mostly atomic on Windows).
|
|
396
|
+
* 3. Re-apply 0600 perms in case the rename clobbered them (defense in
|
|
397
|
+
* depth — `mv` shouldn't alter perms, but the test surface is wide).
|
|
398
|
+
*
|
|
399
|
+
* Fails loudly: any errno propagates. Callers (the route handler) catch +
|
|
400
|
+
* surface a 500 with the underlying message — quietly losing credentials
|
|
401
|
+
* would be worse than crashing the request.
|
|
402
|
+
*/
|
|
403
|
+
export function writeCredentials(vaultName: string, creds: MirrorCredentials): void {
|
|
404
|
+
const path = mirrorCredentialsPath(vaultName);
|
|
405
|
+
const dir = dirname(path);
|
|
406
|
+
// Vault home may not exist yet (tests, fresh installs); create it.
|
|
407
|
+
if (!existsSync(dir)) {
|
|
408
|
+
mkdirSync(dir, { recursive: true });
|
|
409
|
+
}
|
|
410
|
+
const tmp = `${path}.tmp`;
|
|
411
|
+
const serialized = serializeCredentials(creds);
|
|
412
|
+
writeFileSync(tmp, serialized, { mode: 0o600 });
|
|
413
|
+
// Belt-and-braces: writeFileSync's `mode` is only honored on file
|
|
414
|
+
// CREATION. If the temp file already existed (interrupted prior write),
|
|
415
|
+
// the existing perms persist. Re-chmod to be sure.
|
|
416
|
+
chmodSync(tmp, 0o600);
|
|
417
|
+
renameSync(tmp, path);
|
|
418
|
+
// Some filesystems preserve the old file's perms across rename; force
|
|
419
|
+
// 0600 on the final path. No-op on the common case.
|
|
420
|
+
chmodSync(path, 0o600);
|
|
421
|
+
|
|
422
|
+
// Defensive verification — if the perms are wrong on disk, throw so the
|
|
423
|
+
// caller surfaces the misconfiguration to the operator. A world-readable
|
|
424
|
+
// credentials file would silently leak the OAuth token.
|
|
425
|
+
const stat = statSync(path);
|
|
426
|
+
const perms = stat.mode & 0o777;
|
|
427
|
+
if (perms !== 0o600) {
|
|
428
|
+
throw new Error(
|
|
429
|
+
`Mirror credentials file at ${path} has perms ${perms.toString(8)}, expected 0600. Refusing to leave a world-readable token on disk.`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Delete the credentials file. Idempotent — missing file is a no-op (the
|
|
436
|
+
* Disconnect UX should succeed even if the file was already removed).
|
|
437
|
+
*/
|
|
438
|
+
export function deleteCredentials(vaultName: string): void {
|
|
439
|
+
const path = mirrorCredentialsPath(vaultName);
|
|
440
|
+
if (existsSync(path)) unlinkSync(path);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// Migration — legacy server-wide → per-vault (vault#399)
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* One-time migration of the legacy server-wide credentials file to the
|
|
449
|
+
* per-vault layout (vault#399).
|
|
450
|
+
*
|
|
451
|
+
* The bug: pre-vault#399, credentials (PAT + embedded `remote_url`) lived in a
|
|
452
|
+
* single `<configDir>/vault/.mirror-credentials.yaml` shared across ALL
|
|
453
|
+
* vaults. Configuring git sync for a second vault reused the first vault's
|
|
454
|
+
* remote, pointing it at the wrong GitHub repo.
|
|
455
|
+
*
|
|
456
|
+
* Attribution policy (SAFEST per the design): attribute the legacy creds to
|
|
457
|
+
* the FIRST vault — the earliest-created one, which is what
|
|
458
|
+
* `resolveMirrorVaultName()` already bound the single server-wide mirror to.
|
|
459
|
+
* That's the vault whose remote/PAT the legacy file actually corresponds to.
|
|
460
|
+
* We do NOT copy the same remote/PAT onto every vault — that would recreate
|
|
461
|
+
* the leak.
|
|
462
|
+
*
|
|
463
|
+
* Safety:
|
|
464
|
+
* - No-op when no legacy file exists (fresh installs, already-migrated).
|
|
465
|
+
* - No-op when the target vault already has a per-vault file (don't clobber
|
|
466
|
+
* creds the operator set post-migration).
|
|
467
|
+
* - Leaves the legacy file in place renamed `.bak` (never silently deleted)
|
|
468
|
+
* so nothing is lost if attribution was wrong — the operator can recover.
|
|
469
|
+
* - Logs the attribution decision clearly.
|
|
470
|
+
*
|
|
471
|
+
* @param targetVaultName the vault to attribute the legacy creds to (caller
|
|
472
|
+
* passes the result of `resolveMirrorVaultName()`, i.e. default-or-first).
|
|
473
|
+
* @returns a struct describing what happened, for logging + tests.
|
|
474
|
+
*/
|
|
475
|
+
export function migrateLegacyServerWideCredentials(
|
|
476
|
+
targetVaultName: string | null,
|
|
477
|
+
):
|
|
478
|
+
| { migrated: false; reason: "no_legacy_file" | "no_target_vault" | "target_already_has_creds" }
|
|
479
|
+
| { migrated: true; targetVaultName: string; backupPath: string } {
|
|
480
|
+
const legacyPath = legacyServerWideCredentialsPath();
|
|
481
|
+
if (!existsSync(legacyPath)) {
|
|
482
|
+
return { migrated: false, reason: "no_legacy_file" };
|
|
483
|
+
}
|
|
484
|
+
if (!targetVaultName) {
|
|
485
|
+
// Legacy file present but no vault to attribute it to. Leave it in
|
|
486
|
+
// place — a future boot (once a vault exists) migrates it.
|
|
487
|
+
return { migrated: false, reason: "no_target_vault" };
|
|
488
|
+
}
|
|
489
|
+
const targetPath = mirrorCredentialsPath(targetVaultName);
|
|
490
|
+
if (existsSync(targetPath)) {
|
|
491
|
+
// The target vault already has per-vault creds (operator configured
|
|
492
|
+
// them post-migration, or a prior migration ran). Don't clobber.
|
|
493
|
+
// Still rename the legacy file so we don't re-evaluate it forever.
|
|
494
|
+
const backupPath = `${legacyPath}.bak`;
|
|
495
|
+
try {
|
|
496
|
+
if (!existsSync(backupPath)) renameSync(legacyPath, backupPath);
|
|
497
|
+
} catch {
|
|
498
|
+
// Non-fatal — worst case we re-check next boot and short-circuit here.
|
|
499
|
+
}
|
|
500
|
+
return { migrated: false, reason: "target_already_has_creds" };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Read the legacy creds + write them to the target vault's per-vault file.
|
|
504
|
+
const raw = readFileSync(legacyPath, "utf8");
|
|
505
|
+
const creds = parseCredentials(raw);
|
|
506
|
+
writeCredentials(targetVaultName, creds);
|
|
507
|
+
|
|
508
|
+
// Rename the legacy file to .bak rather than deleting — never silently
|
|
509
|
+
// lose an operator's only copy of a token/remote.
|
|
510
|
+
const backupPath = `${legacyPath}.bak`;
|
|
511
|
+
try {
|
|
512
|
+
renameSync(legacyPath, backupPath);
|
|
513
|
+
} catch {
|
|
514
|
+
// If the rename fails (e.g. .bak already exists from a partial prior
|
|
515
|
+
// run), fall back to unlinking the original — the creds are now safely
|
|
516
|
+
// in the per-vault file.
|
|
517
|
+
try {
|
|
518
|
+
unlinkSync(legacyPath);
|
|
519
|
+
} catch {
|
|
520
|
+
// Leave it; the target_already_has_creds branch short-circuits next boot.
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
console.log(
|
|
525
|
+
`[mirror] migrated legacy server-wide mirror credentials → vault "${targetVaultName}" (per-vault, vault#399). ` +
|
|
526
|
+
`Other vaults start with no mirror credentials (configure each separately). ` +
|
|
527
|
+
`Legacy file preserved at ${backupPath}.`,
|
|
528
|
+
);
|
|
529
|
+
return { migrated: true, targetVaultName, backupPath };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
// Redaction
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Mask a token to first-4 + last-4 chars with a fixed-width middle. Designed
|
|
538
|
+
* to be safe to log + display in the UI's status section (operator can verify
|
|
539
|
+
* "yes, this is the token I authorized" without revealing the secret).
|
|
540
|
+
*
|
|
541
|
+
* Short tokens (< 12 chars) get fully masked rather than partially revealed
|
|
542
|
+
* — anything that short isn't a real production token, but defense in depth
|
|
543
|
+
* costs nothing.
|
|
544
|
+
*/
|
|
545
|
+
export function previewToken(token: string): string {
|
|
546
|
+
if (token.length < 12) return "***";
|
|
547
|
+
return `${token.slice(0, 4)}…${token.slice(-4)}`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Produce a redacted view of credentials. Use this anywhere credentials
|
|
552
|
+
* leave the trust boundary — logs, HTTP responses, UI state. The full
|
|
553
|
+
* shape lives only in memory + on disk.
|
|
554
|
+
*/
|
|
555
|
+
export function sanitizeCredentials(
|
|
556
|
+
creds: MirrorCredentials | null,
|
|
557
|
+
): MirrorCredentialsPublic {
|
|
558
|
+
if (!creds) {
|
|
559
|
+
return { active_method: null, github_oauth: null, pat: null };
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
active_method: creds.active_method,
|
|
563
|
+
github_oauth: creds.github_oauth
|
|
564
|
+
? {
|
|
565
|
+
user_login: creds.github_oauth.user_login,
|
|
566
|
+
user_id: creds.github_oauth.user_id,
|
|
567
|
+
scope: creds.github_oauth.scope,
|
|
568
|
+
authorized_at: creds.github_oauth.authorized_at,
|
|
569
|
+
token_preview: previewToken(creds.github_oauth.access_token),
|
|
570
|
+
}
|
|
571
|
+
: null,
|
|
572
|
+
pat: creds.pat
|
|
573
|
+
? {
|
|
574
|
+
label: creds.pat.label,
|
|
575
|
+
remote_url: redactRemoteUrl(creds.pat.remote_url),
|
|
576
|
+
token_preview: previewToken(creds.pat.token),
|
|
577
|
+
}
|
|
578
|
+
: null,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Mask the userinfo portion of a remote URL — the operator might have
|
|
584
|
+
* pasted `https://user:token@host/...` and we don't want the raw token
|
|
585
|
+
* leaking via the redacted view. Returns the URL with `user:***@` swapped
|
|
586
|
+
* in for the entire userinfo, leaving the host + path intact.
|
|
587
|
+
*/
|
|
588
|
+
export function redactRemoteUrl(url: string): string {
|
|
589
|
+
try {
|
|
590
|
+
const u = new URL(url);
|
|
591
|
+
if (u.username || u.password) {
|
|
592
|
+
u.username = "***";
|
|
593
|
+
u.password = "";
|
|
594
|
+
return u.toString();
|
|
595
|
+
}
|
|
596
|
+
return url;
|
|
597
|
+
} catch {
|
|
598
|
+
// Non-URL string — return a generic placeholder.
|
|
599
|
+
return "***";
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// Git remote URL helpers
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Build the HTTPS remote URL with an embedded GitHub OAuth token for `owner/repo`.
|
|
609
|
+
*
|
|
610
|
+
* https://x-access-token:<TOKEN>@github.com/<owner>/<repo>.git
|
|
611
|
+
*
|
|
612
|
+
* The `x-access-token` username convention is GitHub-specific. PATs work
|
|
613
|
+
* through the same shape (GitHub treats `gho_*` and `ghp_*` identically at
|
|
614
|
+
* the credential-helper layer); GitLab / Codeberg use different conventions,
|
|
615
|
+
* so the PAT path stores the full URL operator-supplied rather than
|
|
616
|
+
* synthesizing.
|
|
617
|
+
*/
|
|
618
|
+
export function githubAuthedRemoteUrl(
|
|
619
|
+
token: string,
|
|
620
|
+
owner: string,
|
|
621
|
+
repo: string,
|
|
622
|
+
): string {
|
|
623
|
+
// GitHub repo names allow `.`, `-`, `_`. URL-escape just in case
|
|
624
|
+
// someone has a weird name; the path-component encoder is too aggressive
|
|
625
|
+
// here (it would escape `.`), so we trust GitHub's naming rules.
|
|
626
|
+
return `https://x-access-token:${token}@github.com/${owner}/${repo}.git`;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Set the embedded-credential remote URL on a mirror repo's git config.
|
|
631
|
+
*
|
|
632
|
+
* Idempotent: calling on an already-configured remote replaces the URL
|
|
633
|
+
* (which is what we want — token rotation should "just work" when the
|
|
634
|
+
* stored credentials update). Adds the remote if it doesn't exist; updates
|
|
635
|
+
* it if it does.
|
|
636
|
+
*
|
|
637
|
+
* **Logs are scrubbed.** We never log the URL itself (it carries the
|
|
638
|
+
* token). We log a redacted form via `redactRemoteUrl` instead.
|
|
639
|
+
*/
|
|
640
|
+
export async function applyToGitRemote(
|
|
641
|
+
repoDir: string,
|
|
642
|
+
remoteUrl: string,
|
|
643
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
644
|
+
// Probe for an existing `origin`. `git remote get-url origin` returns
|
|
645
|
+
// exit 0 if it exists, non-zero otherwise.
|
|
646
|
+
const probe = Bun.spawn(["git", "remote", "get-url", "origin"], {
|
|
647
|
+
cwd: repoDir,
|
|
648
|
+
stdout: "pipe",
|
|
649
|
+
stderr: "pipe",
|
|
650
|
+
});
|
|
651
|
+
const probeCode = await probe.exited;
|
|
652
|
+
const verb = probeCode === 0 ? "set-url" : "add";
|
|
653
|
+
const cmd =
|
|
654
|
+
verb === "set-url"
|
|
655
|
+
? ["git", "remote", "set-url", "origin", remoteUrl]
|
|
656
|
+
: ["git", "remote", "add", "origin", remoteUrl];
|
|
657
|
+
const proc = Bun.spawn(cmd, {
|
|
658
|
+
cwd: repoDir,
|
|
659
|
+
stdout: "pipe",
|
|
660
|
+
stderr: "pipe",
|
|
661
|
+
});
|
|
662
|
+
const exitCode = await proc.exited;
|
|
663
|
+
if (exitCode !== 0) {
|
|
664
|
+
const stderr = new TextDecoder()
|
|
665
|
+
.decode(await new Response(proc.stderr).arrayBuffer())
|
|
666
|
+
.trim();
|
|
667
|
+
return { ok: false, error: stderr };
|
|
668
|
+
}
|
|
669
|
+
return { ok: true };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Remove the embedded-credential remote (when credentials are cleared).
|
|
674
|
+
* Idempotent: missing remote is fine — the operator might never have had
|
|
675
|
+
* one set up.
|
|
676
|
+
*/
|
|
677
|
+
export async function unsetGitRemote(
|
|
678
|
+
repoDir: string,
|
|
679
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
680
|
+
const probe = Bun.spawn(["git", "remote", "get-url", "origin"], {
|
|
681
|
+
cwd: repoDir,
|
|
682
|
+
stdout: "pipe",
|
|
683
|
+
stderr: "pipe",
|
|
684
|
+
});
|
|
685
|
+
const probeCode = await probe.exited;
|
|
686
|
+
if (probeCode !== 0) return { ok: true }; // nothing to unset
|
|
687
|
+
const proc = Bun.spawn(["git", "remote", "remove", "origin"], {
|
|
688
|
+
cwd: repoDir,
|
|
689
|
+
stdout: "pipe",
|
|
690
|
+
stderr: "pipe",
|
|
691
|
+
});
|
|
692
|
+
const exitCode = await proc.exited;
|
|
693
|
+
if (exitCode !== 0) {
|
|
694
|
+
const stderr = new TextDecoder()
|
|
695
|
+
.decode(await new Response(proc.stderr).arrayBuffer())
|
|
696
|
+
.trim();
|
|
697
|
+
return { ok: false, error: stderr };
|
|
698
|
+
}
|
|
699
|
+
return { ok: true };
|
|
700
|
+
}
|