@openparachute/vault 0.4.8 → 0.4.9-rc.10
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/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/mcp.ts +35 -0
- package/core/src/portable-md.test.ts +252 -1
- package/core/src/portable-md.ts +370 -2
- package/core/src/schema.ts +51 -2
- package/core/src/store.ts +68 -2
- package/package.json +1 -1
- package/src/auth.ts +29 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- 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/mcp-http.ts +24 -36
- package/src/mcp-tools.ts +286 -2
- package/src/mirror-config.test.ts +184 -14
- package/src/mirror-config.ts +220 -24
- package/src/mirror-credentials.test.ts +450 -0
- package/src/mirror-credentials.ts +577 -0
- package/src/mirror-deps.ts +42 -1
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +484 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +579 -62
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1096 -5
- package/src/module-config.ts +11 -5
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +165 -1
- package/src/server.ts +21 -8
- package/src/token-store.ts +158 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +380 -1
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/assets/index-DE18QJMx.js +60 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
|
@@ -0,0 +1,577 @@
|
|
|
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/.mirror-credentials.yaml`, perms `0o600`,
|
|
27
|
+
* **not encrypted at rest**. Rationale: encryption-at-rest with the key on
|
|
28
|
+
* the same disk doesn't add real security; OS perms ARE the protection. Same
|
|
29
|
+
* trust model as `~/.git-credentials` (which most operators already use).
|
|
30
|
+
* The file is documented as sensitive; redaction in logs is enforced by
|
|
31
|
+
* `sanitizeCredentials` + a discipline of "never log the raw token."
|
|
32
|
+
*
|
|
33
|
+
* **One credential set per vault.** Multi-credential ("I want repo A pushed
|
|
34
|
+
* with token X, repo B with token Y") isn't supported — vault#382 ships one
|
|
35
|
+
* mirror per vault server today; one credential set per vault matches.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
chmodSync,
|
|
40
|
+
existsSync,
|
|
41
|
+
mkdirSync,
|
|
42
|
+
readFileSync,
|
|
43
|
+
renameSync,
|
|
44
|
+
statSync,
|
|
45
|
+
unlinkSync,
|
|
46
|
+
writeFileSync,
|
|
47
|
+
} from "node:fs";
|
|
48
|
+
import { dirname, join } from "node:path";
|
|
49
|
+
import { homedir } from "node:os";
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Types
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Which credential surface is currently active. Null when none configured.
|
|
57
|
+
* - `github_oauth` — populated `github_oauth` block.
|
|
58
|
+
* - `pat` — populated `pat` block (Personal Access Token + remote URL).
|
|
59
|
+
*/
|
|
60
|
+
export type ActiveMethod = "github_oauth" | "pat" | null;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* GitHub OAuth Device Flow result. Stored verbatim after a successful poll
|
|
64
|
+
* returns `granted`. The `access_token` is what gets embedded in the git
|
|
65
|
+
* remote URL at push time (via `x-access-token:<TOKEN>@github.com/...`).
|
|
66
|
+
*/
|
|
67
|
+
export interface GitHubOAuthCredential {
|
|
68
|
+
/** The `gho_*` token returned by GitHub's `/login/oauth/access_token`. */
|
|
69
|
+
access_token: string;
|
|
70
|
+
/** Scope string GitHub granted (typically "repo"). */
|
|
71
|
+
scope: string;
|
|
72
|
+
/** ISO timestamp captured at the moment we saved the token. */
|
|
73
|
+
authorized_at: string;
|
|
74
|
+
/** GitHub login (`@octocat`). */
|
|
75
|
+
user_login: string;
|
|
76
|
+
/** GitHub numeric user id — stable across login renames. */
|
|
77
|
+
user_id: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Personal Access Token fallback. The operator pastes both the token AND
|
|
82
|
+
* the remote URL — vault doesn't try to guess one from the other (GitHub
|
|
83
|
+
* uses `https://x-access-token:<token>@github.com/...`, GitLab uses
|
|
84
|
+
* `https://oauth2:<token>@gitlab.com/...`, etc., and there's no generic
|
|
85
|
+
* rule). The stored URL is what gets set as the mirror's remote.
|
|
86
|
+
*/
|
|
87
|
+
export interface PATCredential {
|
|
88
|
+
/** Bearer token (ghp_*, glpat-*, etc.). */
|
|
89
|
+
token: string;
|
|
90
|
+
/**
|
|
91
|
+
* Full HTTPS remote URL with auth embedded, e.g.
|
|
92
|
+
* `https://x-access-token:ghp_abc@github.com/owner/repo.git`. The operator
|
|
93
|
+
* supplies this; we don't synthesize.
|
|
94
|
+
*/
|
|
95
|
+
remote_url: string;
|
|
96
|
+
/** Operator-visible label, e.g. "GitHub PAT for backup". */
|
|
97
|
+
label: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The on-disk + on-the-wire shape. One file per vault server (matches the
|
|
102
|
+
* "one mirror per vault server today" invariant from mirror-config.ts).
|
|
103
|
+
*/
|
|
104
|
+
export interface MirrorCredentials {
|
|
105
|
+
/**
|
|
106
|
+
* Which credential method is active. Read paths check this; if null the
|
|
107
|
+
* mirror runs with no embedded credentials (bare `git push` inherits
|
|
108
|
+
* the shell — back-compat with pre-PR operators).
|
|
109
|
+
*/
|
|
110
|
+
active_method: ActiveMethod;
|
|
111
|
+
github_oauth: GitHubOAuthCredential | null;
|
|
112
|
+
pat: PATCredential | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Redacted view of credentials, safe for logs / API responses. Masks tokens
|
|
117
|
+
* to first-4 + last-4 chars; preserves user metadata so the operator can
|
|
118
|
+
* verify "yes, this is the right account/repo" without re-authenticating.
|
|
119
|
+
*/
|
|
120
|
+
export interface MirrorCredentialsPublic {
|
|
121
|
+
active_method: ActiveMethod;
|
|
122
|
+
github_oauth: {
|
|
123
|
+
user_login: string;
|
|
124
|
+
user_id: number;
|
|
125
|
+
scope: string;
|
|
126
|
+
authorized_at: string;
|
|
127
|
+
token_preview: string;
|
|
128
|
+
} | null;
|
|
129
|
+
pat: {
|
|
130
|
+
label: string;
|
|
131
|
+
remote_url: string;
|
|
132
|
+
token_preview: string;
|
|
133
|
+
} | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Path resolution
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Path to the per-vault-server credentials file.
|
|
142
|
+
*
|
|
143
|
+
* Note: this is a SERVER-wide credentials file (`<configDir>/vault/.mirror-credentials.yaml`),
|
|
144
|
+
* not a per-vault file. The mirror manager itself is server-wide (one mirror
|
|
145
|
+
* per vault server today) so the credentials follow that scope. When multi-
|
|
146
|
+
* vault mirroring lands (open question 2 in the design doc), this becomes
|
|
147
|
+
* per-vault and gets keyed by name. Today's shape: one file.
|
|
148
|
+
*
|
|
149
|
+
* Path resolution mirrors `config.ts:vaultHomePath()` — re-reads
|
|
150
|
+
* `PARACHUTE_HOME` on every call so test sandboxes (`PARACHUTE_VAULT_HOME`
|
|
151
|
+
* is a vault-internal var that doesn't override here — we use the canonical
|
|
152
|
+
* `PARACHUTE_HOME` that the rest of vault honors).
|
|
153
|
+
*/
|
|
154
|
+
export function mirrorCredentialsPath(): string {
|
|
155
|
+
const root = process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
|
|
156
|
+
return join(root, "vault", ".mirror-credentials.yaml");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Defaults
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/** Empty credentials — what readCredentials returns when the file is absent. */
|
|
164
|
+
export function emptyCredentials(): MirrorCredentials {
|
|
165
|
+
return {
|
|
166
|
+
active_method: null,
|
|
167
|
+
github_oauth: null,
|
|
168
|
+
pat: null,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// YAML — hand-rolled to match the pattern in mirror-config.ts. No new dep.
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Serialize credentials as YAML. Keeps the file hand-editable for operators
|
|
178
|
+
* who want to rotate a token by `vim`-ing the file.
|
|
179
|
+
*
|
|
180
|
+
* Format:
|
|
181
|
+
*
|
|
182
|
+
* active_method: github_oauth
|
|
183
|
+
* github_oauth:
|
|
184
|
+
* access_token: gho_...
|
|
185
|
+
* scope: repo
|
|
186
|
+
* authorized_at: 2026-05-28T03:14:15.000Z
|
|
187
|
+
* user_login: aaron
|
|
188
|
+
* user_id: 12345
|
|
189
|
+
* pat:
|
|
190
|
+
* token: ghp_...
|
|
191
|
+
* remote_url: https://github.com/aaron/my-vault.git
|
|
192
|
+
* label: "GitHub PAT"
|
|
193
|
+
*/
|
|
194
|
+
export function serializeCredentials(creds: MirrorCredentials): string {
|
|
195
|
+
const lines: string[] = [];
|
|
196
|
+
lines.push(`active_method: ${creds.active_method === null ? "null" : creds.active_method}`);
|
|
197
|
+
if (creds.github_oauth) {
|
|
198
|
+
lines.push("github_oauth:");
|
|
199
|
+
lines.push(` access_token: ${quoteIfNeeded(creds.github_oauth.access_token)}`);
|
|
200
|
+
lines.push(` scope: ${quoteIfNeeded(creds.github_oauth.scope)}`);
|
|
201
|
+
lines.push(` authorized_at: ${quoteIfNeeded(creds.github_oauth.authorized_at)}`);
|
|
202
|
+
lines.push(` user_login: ${quoteIfNeeded(creds.github_oauth.user_login)}`);
|
|
203
|
+
lines.push(` user_id: ${creds.github_oauth.user_id}`);
|
|
204
|
+
} else {
|
|
205
|
+
lines.push("github_oauth: null");
|
|
206
|
+
}
|
|
207
|
+
if (creds.pat) {
|
|
208
|
+
lines.push("pat:");
|
|
209
|
+
lines.push(` token: ${quoteIfNeeded(creds.pat.token)}`);
|
|
210
|
+
lines.push(` remote_url: ${quoteIfNeeded(creds.pat.remote_url)}`);
|
|
211
|
+
lines.push(` label: ${quoteIfNeeded(creds.pat.label)}`);
|
|
212
|
+
} else {
|
|
213
|
+
lines.push("pat: null");
|
|
214
|
+
}
|
|
215
|
+
return lines.join("\n") + "\n";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Quote a YAML scalar when it contains characters that confuse parsers.
|
|
220
|
+
* `gho_*` / `ghp_*` tokens never carry newlines or special chars, but the
|
|
221
|
+
* operator-supplied `label` field has no such guarantee — a label with a
|
|
222
|
+
* literal `\n` would break the parser's per-line section logic. Escape
|
|
223
|
+
* newlines + carriage returns + backslash + quote inside the quoted form.
|
|
224
|
+
* Reviewer-flagged on vault#384.
|
|
225
|
+
*/
|
|
226
|
+
function quoteIfNeeded(value: string): string {
|
|
227
|
+
if (/[:#"'\\\n\r]/.test(value) || value.trim() !== value || value.length === 0) {
|
|
228
|
+
return `"${value
|
|
229
|
+
.replace(/\\/g, "\\\\")
|
|
230
|
+
.replace(/"/g, '\\"')
|
|
231
|
+
.replace(/\n/g, "\\n")
|
|
232
|
+
.replace(/\r/g, "\\r")}"`;
|
|
233
|
+
}
|
|
234
|
+
return value;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Parse a YAML scalar that may be quoted; otherwise return the trimmed value. */
|
|
238
|
+
function parseScalar(raw: string): string {
|
|
239
|
+
const trimmed = raw.trim();
|
|
240
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
|
|
241
|
+
return trimmed
|
|
242
|
+
.slice(1, -1)
|
|
243
|
+
.replace(/\\n/g, "\n")
|
|
244
|
+
.replace(/\\r/g, "\r")
|
|
245
|
+
.replace(/\\"/g, '"')
|
|
246
|
+
.replace(/\\\\/g, "\\");
|
|
247
|
+
}
|
|
248
|
+
return trimmed;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Parse the credentials YAML file. Lenient — unknown fields ignored, missing
|
|
253
|
+
* blocks default to null. Returns `emptyCredentials()` if the file is empty
|
|
254
|
+
* or contains nothing recognized.
|
|
255
|
+
*/
|
|
256
|
+
export function parseCredentials(yaml: string): MirrorCredentials {
|
|
257
|
+
const result = emptyCredentials();
|
|
258
|
+
const lines = yaml.split("\n");
|
|
259
|
+
let section: "github_oauth" | "pat" | null = null;
|
|
260
|
+
// Buffer per-section scalars so we can validate as a block before commit.
|
|
261
|
+
let oauth: Partial<GitHubOAuthCredential> = {};
|
|
262
|
+
let pat: Partial<PATCredential> = {};
|
|
263
|
+
|
|
264
|
+
const commitSection = () => {
|
|
265
|
+
if (section === "github_oauth") {
|
|
266
|
+
if (
|
|
267
|
+
oauth.access_token &&
|
|
268
|
+
oauth.scope !== undefined &&
|
|
269
|
+
oauth.authorized_at &&
|
|
270
|
+
oauth.user_login &&
|
|
271
|
+
typeof oauth.user_id === "number"
|
|
272
|
+
) {
|
|
273
|
+
result.github_oauth = oauth as GitHubOAuthCredential;
|
|
274
|
+
}
|
|
275
|
+
oauth = {};
|
|
276
|
+
} else if (section === "pat") {
|
|
277
|
+
if (pat.token && pat.remote_url && pat.label !== undefined) {
|
|
278
|
+
result.pat = pat as PATCredential;
|
|
279
|
+
}
|
|
280
|
+
pat = {};
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
for (const line of lines) {
|
|
285
|
+
if (line.match(/^\s*$/)) continue;
|
|
286
|
+
|
|
287
|
+
// Top-level scalars / section headers.
|
|
288
|
+
if (line.match(/^\S/)) {
|
|
289
|
+
// Close the previous section before starting a new one.
|
|
290
|
+
commitSection();
|
|
291
|
+
section = null;
|
|
292
|
+
const activeMatch = line.match(/^active_method:\s*(.*)$/);
|
|
293
|
+
if (activeMatch) {
|
|
294
|
+
const v = parseScalar(activeMatch[1]!);
|
|
295
|
+
if (v === "github_oauth" || v === "pat") result.active_method = v;
|
|
296
|
+
else result.active_method = null;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (line.match(/^github_oauth:\s*null\s*$/)) {
|
|
300
|
+
result.github_oauth = null;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (line.match(/^pat:\s*null\s*$/)) {
|
|
304
|
+
result.pat = null;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (line.match(/^github_oauth:\s*$/)) {
|
|
308
|
+
section = "github_oauth";
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (line.match(/^pat:\s*$/)) {
|
|
312
|
+
section = "pat";
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Indented section field.
|
|
319
|
+
const fieldMatch = line.match(/^\s+(\w+):\s*(.*)$/);
|
|
320
|
+
if (!fieldMatch) continue;
|
|
321
|
+
const [, key, rawVal] = fieldMatch;
|
|
322
|
+
if (section === "github_oauth") {
|
|
323
|
+
if (key === "access_token") oauth.access_token = parseScalar(rawVal!);
|
|
324
|
+
else if (key === "scope") oauth.scope = parseScalar(rawVal!);
|
|
325
|
+
else if (key === "authorized_at") oauth.authorized_at = parseScalar(rawVal!);
|
|
326
|
+
else if (key === "user_login") oauth.user_login = parseScalar(rawVal!);
|
|
327
|
+
else if (key === "user_id") {
|
|
328
|
+
const n = Number(parseScalar(rawVal!));
|
|
329
|
+
if (Number.isFinite(n)) oauth.user_id = n;
|
|
330
|
+
}
|
|
331
|
+
} else if (section === "pat") {
|
|
332
|
+
if (key === "token") pat.token = parseScalar(rawVal!);
|
|
333
|
+
else if (key === "remote_url") pat.remote_url = parseScalar(rawVal!);
|
|
334
|
+
else if (key === "label") pat.label = parseScalar(rawVal!);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
commitSection();
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// File I/O
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Read credentials from disk. Returns `null` when the file doesn't exist
|
|
348
|
+
* (operator hasn't connected anything yet); throws when the file is present
|
|
349
|
+
* but unreadable (a permission error is a loud configuration problem).
|
|
350
|
+
*/
|
|
351
|
+
export function readCredentials(): MirrorCredentials | null {
|
|
352
|
+
const path = mirrorCredentialsPath();
|
|
353
|
+
if (!existsSync(path)) return null;
|
|
354
|
+
const raw = readFileSync(path, "utf8");
|
|
355
|
+
return parseCredentials(raw);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Persist credentials atomically:
|
|
360
|
+
* 1. Write to `<path>.tmp` with perms 0600 (write + read by owner only).
|
|
361
|
+
* 2. Rename onto the final path (atomic on POSIX; mostly atomic on Windows).
|
|
362
|
+
* 3. Re-apply 0600 perms in case the rename clobbered them (defense in
|
|
363
|
+
* depth — `mv` shouldn't alter perms, but the test surface is wide).
|
|
364
|
+
*
|
|
365
|
+
* Fails loudly: any errno propagates. Callers (the route handler) catch +
|
|
366
|
+
* surface a 500 with the underlying message — quietly losing credentials
|
|
367
|
+
* would be worse than crashing the request.
|
|
368
|
+
*/
|
|
369
|
+
export function writeCredentials(creds: MirrorCredentials): void {
|
|
370
|
+
const path = mirrorCredentialsPath();
|
|
371
|
+
const dir = dirname(path);
|
|
372
|
+
// Vault home may not exist yet (tests, fresh installs); create it.
|
|
373
|
+
if (!existsSync(dir)) {
|
|
374
|
+
mkdirSync(dir, { recursive: true });
|
|
375
|
+
}
|
|
376
|
+
const tmp = `${path}.tmp`;
|
|
377
|
+
const serialized = serializeCredentials(creds);
|
|
378
|
+
writeFileSync(tmp, serialized, { mode: 0o600 });
|
|
379
|
+
// Belt-and-braces: writeFileSync's `mode` is only honored on file
|
|
380
|
+
// CREATION. If the temp file already existed (interrupted prior write),
|
|
381
|
+
// the existing perms persist. Re-chmod to be sure.
|
|
382
|
+
chmodSync(tmp, 0o600);
|
|
383
|
+
renameSync(tmp, path);
|
|
384
|
+
// Some filesystems preserve the old file's perms across rename; force
|
|
385
|
+
// 0600 on the final path. No-op on the common case.
|
|
386
|
+
chmodSync(path, 0o600);
|
|
387
|
+
|
|
388
|
+
// Defensive verification — if the perms are wrong on disk, throw so the
|
|
389
|
+
// caller surfaces the misconfiguration to the operator. A world-readable
|
|
390
|
+
// credentials file would silently leak the OAuth token.
|
|
391
|
+
const stat = statSync(path);
|
|
392
|
+
const perms = stat.mode & 0o777;
|
|
393
|
+
if (perms !== 0o600) {
|
|
394
|
+
throw new Error(
|
|
395
|
+
`Mirror credentials file at ${path} has perms ${perms.toString(8)}, expected 0600. Refusing to leave a world-readable token on disk.`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Delete the credentials file. Idempotent — missing file is a no-op (the
|
|
402
|
+
* Disconnect UX should succeed even if the file was already removed).
|
|
403
|
+
*/
|
|
404
|
+
export function deleteCredentials(): void {
|
|
405
|
+
const path = mirrorCredentialsPath();
|
|
406
|
+
if (existsSync(path)) unlinkSync(path);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// Redaction
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Mask a token to first-4 + last-4 chars with a fixed-width middle. Designed
|
|
415
|
+
* to be safe to log + display in the UI's status section (operator can verify
|
|
416
|
+
* "yes, this is the token I authorized" without revealing the secret).
|
|
417
|
+
*
|
|
418
|
+
* Short tokens (< 12 chars) get fully masked rather than partially revealed
|
|
419
|
+
* — anything that short isn't a real production token, but defense in depth
|
|
420
|
+
* costs nothing.
|
|
421
|
+
*/
|
|
422
|
+
export function previewToken(token: string): string {
|
|
423
|
+
if (token.length < 12) return "***";
|
|
424
|
+
return `${token.slice(0, 4)}…${token.slice(-4)}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Produce a redacted view of credentials. Use this anywhere credentials
|
|
429
|
+
* leave the trust boundary — logs, HTTP responses, UI state. The full
|
|
430
|
+
* shape lives only in memory + on disk.
|
|
431
|
+
*/
|
|
432
|
+
export function sanitizeCredentials(
|
|
433
|
+
creds: MirrorCredentials | null,
|
|
434
|
+
): MirrorCredentialsPublic {
|
|
435
|
+
if (!creds) {
|
|
436
|
+
return { active_method: null, github_oauth: null, pat: null };
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
active_method: creds.active_method,
|
|
440
|
+
github_oauth: creds.github_oauth
|
|
441
|
+
? {
|
|
442
|
+
user_login: creds.github_oauth.user_login,
|
|
443
|
+
user_id: creds.github_oauth.user_id,
|
|
444
|
+
scope: creds.github_oauth.scope,
|
|
445
|
+
authorized_at: creds.github_oauth.authorized_at,
|
|
446
|
+
token_preview: previewToken(creds.github_oauth.access_token),
|
|
447
|
+
}
|
|
448
|
+
: null,
|
|
449
|
+
pat: creds.pat
|
|
450
|
+
? {
|
|
451
|
+
label: creds.pat.label,
|
|
452
|
+
remote_url: redactRemoteUrl(creds.pat.remote_url),
|
|
453
|
+
token_preview: previewToken(creds.pat.token),
|
|
454
|
+
}
|
|
455
|
+
: null,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Mask the userinfo portion of a remote URL — the operator might have
|
|
461
|
+
* pasted `https://user:token@host/...` and we don't want the raw token
|
|
462
|
+
* leaking via the redacted view. Returns the URL with `user:***@` swapped
|
|
463
|
+
* in for the entire userinfo, leaving the host + path intact.
|
|
464
|
+
*/
|
|
465
|
+
export function redactRemoteUrl(url: string): string {
|
|
466
|
+
try {
|
|
467
|
+
const u = new URL(url);
|
|
468
|
+
if (u.username || u.password) {
|
|
469
|
+
u.username = "***";
|
|
470
|
+
u.password = "";
|
|
471
|
+
return u.toString();
|
|
472
|
+
}
|
|
473
|
+
return url;
|
|
474
|
+
} catch {
|
|
475
|
+
// Non-URL string — return a generic placeholder.
|
|
476
|
+
return "***";
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// Git remote URL helpers
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Build the HTTPS remote URL with an embedded GitHub OAuth token for `owner/repo`.
|
|
486
|
+
*
|
|
487
|
+
* https://x-access-token:<TOKEN>@github.com/<owner>/<repo>.git
|
|
488
|
+
*
|
|
489
|
+
* The `x-access-token` username convention is GitHub-specific. PATs work
|
|
490
|
+
* through the same shape (GitHub treats `gho_*` and `ghp_*` identically at
|
|
491
|
+
* the credential-helper layer); GitLab / Codeberg use different conventions,
|
|
492
|
+
* so the PAT path stores the full URL operator-supplied rather than
|
|
493
|
+
* synthesizing.
|
|
494
|
+
*/
|
|
495
|
+
export function githubAuthedRemoteUrl(
|
|
496
|
+
token: string,
|
|
497
|
+
owner: string,
|
|
498
|
+
repo: string,
|
|
499
|
+
): string {
|
|
500
|
+
// GitHub repo names allow `.`, `-`, `_`. URL-escape just in case
|
|
501
|
+
// someone has a weird name; the path-component encoder is too aggressive
|
|
502
|
+
// here (it would escape `.`), so we trust GitHub's naming rules.
|
|
503
|
+
return `https://x-access-token:${token}@github.com/${owner}/${repo}.git`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Set the embedded-credential remote URL on a mirror repo's git config.
|
|
508
|
+
*
|
|
509
|
+
* Idempotent: calling on an already-configured remote replaces the URL
|
|
510
|
+
* (which is what we want — token rotation should "just work" when the
|
|
511
|
+
* stored credentials update). Adds the remote if it doesn't exist; updates
|
|
512
|
+
* it if it does.
|
|
513
|
+
*
|
|
514
|
+
* **Logs are scrubbed.** We never log the URL itself (it carries the
|
|
515
|
+
* token). We log a redacted form via `redactRemoteUrl` instead.
|
|
516
|
+
*/
|
|
517
|
+
export async function applyToGitRemote(
|
|
518
|
+
repoDir: string,
|
|
519
|
+
remoteUrl: string,
|
|
520
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
521
|
+
// Probe for an existing `origin`. `git remote get-url origin` returns
|
|
522
|
+
// exit 0 if it exists, non-zero otherwise.
|
|
523
|
+
const probe = Bun.spawn(["git", "remote", "get-url", "origin"], {
|
|
524
|
+
cwd: repoDir,
|
|
525
|
+
stdout: "pipe",
|
|
526
|
+
stderr: "pipe",
|
|
527
|
+
});
|
|
528
|
+
const probeCode = await probe.exited;
|
|
529
|
+
const verb = probeCode === 0 ? "set-url" : "add";
|
|
530
|
+
const cmd =
|
|
531
|
+
verb === "set-url"
|
|
532
|
+
? ["git", "remote", "set-url", "origin", remoteUrl]
|
|
533
|
+
: ["git", "remote", "add", "origin", remoteUrl];
|
|
534
|
+
const proc = Bun.spawn(cmd, {
|
|
535
|
+
cwd: repoDir,
|
|
536
|
+
stdout: "pipe",
|
|
537
|
+
stderr: "pipe",
|
|
538
|
+
});
|
|
539
|
+
const exitCode = await proc.exited;
|
|
540
|
+
if (exitCode !== 0) {
|
|
541
|
+
const stderr = new TextDecoder()
|
|
542
|
+
.decode(await new Response(proc.stderr).arrayBuffer())
|
|
543
|
+
.trim();
|
|
544
|
+
return { ok: false, error: stderr };
|
|
545
|
+
}
|
|
546
|
+
return { ok: true };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Remove the embedded-credential remote (when credentials are cleared).
|
|
551
|
+
* Idempotent: missing remote is fine — the operator might never have had
|
|
552
|
+
* one set up.
|
|
553
|
+
*/
|
|
554
|
+
export async function unsetGitRemote(
|
|
555
|
+
repoDir: string,
|
|
556
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
557
|
+
const probe = Bun.spawn(["git", "remote", "get-url", "origin"], {
|
|
558
|
+
cwd: repoDir,
|
|
559
|
+
stdout: "pipe",
|
|
560
|
+
stderr: "pipe",
|
|
561
|
+
});
|
|
562
|
+
const probeCode = await probe.exited;
|
|
563
|
+
if (probeCode !== 0) return { ok: true }; // nothing to unset
|
|
564
|
+
const proc = Bun.spawn(["git", "remote", "remove", "origin"], {
|
|
565
|
+
cwd: repoDir,
|
|
566
|
+
stdout: "pipe",
|
|
567
|
+
stderr: "pipe",
|
|
568
|
+
});
|
|
569
|
+
const exitCode = await proc.exited;
|
|
570
|
+
if (exitCode !== 0) {
|
|
571
|
+
const stderr = new TextDecoder()
|
|
572
|
+
.decode(await new Response(proc.stderr).arrayBuffer())
|
|
573
|
+
.trim();
|
|
574
|
+
return { ok: false, error: stderr };
|
|
575
|
+
}
|
|
576
|
+
return { ok: true };
|
|
577
|
+
}
|
package/src/mirror-deps.ts
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* vault-store + portable-md.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { exportVaultToDir } from "../core/src/portable-md.ts";
|
|
10
|
+
import { exportVaultToDir, hasSchemaContent, pruneOrphans } from "../core/src/portable-md.ts";
|
|
11
11
|
|
|
12
|
+
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
12
13
|
import { readGlobalConfig, writeGlobalConfig, readVaultConfig } from "./config.ts";
|
|
13
14
|
import { defaultMirrorConfig, type MirrorConfig } from "./mirror-config.ts";
|
|
14
15
|
import type { MirrorDeps } from "./mirror-manager.ts";
|
|
@@ -42,6 +43,41 @@ export function buildMirrorDeps(vaultName: string): MirrorDeps {
|
|
|
42
43
|
});
|
|
43
44
|
return { notes: stats.notes };
|
|
44
45
|
},
|
|
46
|
+
runPrune: async ({ outDir }) => {
|
|
47
|
+
const store = getVaultStore(vaultName);
|
|
48
|
+
// Build the valid-id sets the prune sweep needs. Single-query
|
|
49
|
+
// walk per dimension; cheap on typical vaults.
|
|
50
|
+
const allNotes = await store.queryNotes({ limit: 1_000_000, sort: "asc" });
|
|
51
|
+
const validNoteIds = new Set(allNotes.map((n) => n.id));
|
|
52
|
+
// Tag names with schema content drive the schema sidecars. Filter
|
|
53
|
+
// through `hasSchemaContent` — a tag whose schema content was wiped
|
|
54
|
+
// via `deleteTagSchema` keeps its tags-table row (bare name), so a
|
|
55
|
+
// map-by-name set would leave the stale sidecar in the mirror
|
|
56
|
+
// indefinitely. Only schema-bearing tags belong in this set.
|
|
57
|
+
// Reviewer-flagged on vault#382 (Critical #1).
|
|
58
|
+
const tagRecords = await store.listTagRecords();
|
|
59
|
+
const validTagNames = new Set(
|
|
60
|
+
tagRecords.filter((t) => hasSchemaContent(t)).map((t) => t.tag),
|
|
61
|
+
);
|
|
62
|
+
// Attachment IDs across all notes (the prune sweep keys on id).
|
|
63
|
+
const validAttachmentIds = new Set<string>();
|
|
64
|
+
for (const note of allNotes) {
|
|
65
|
+
const atts = await store.getAttachments(note.id);
|
|
66
|
+
for (const a of atts) validAttachmentIds.add(a.id);
|
|
67
|
+
}
|
|
68
|
+
const stats = pruneOrphans({
|
|
69
|
+
outDir,
|
|
70
|
+
validNoteIds,
|
|
71
|
+
validTagNames,
|
|
72
|
+
validAttachmentIds,
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
notes_removed: stats.notes_removed,
|
|
76
|
+
sidecars_removed: stats.sidecars_removed,
|
|
77
|
+
schemas_removed: stats.schemas_removed,
|
|
78
|
+
attachment_dirs_removed: stats.attachment_dirs_removed,
|
|
79
|
+
};
|
|
80
|
+
},
|
|
45
81
|
firstChangedNoteTitle: async (cursor) => {
|
|
46
82
|
if (!cursor) return "";
|
|
47
83
|
try {
|
|
@@ -62,6 +98,11 @@ export function buildMirrorDeps(vaultName: string): MirrorDeps {
|
|
|
62
98
|
global.mirror = config;
|
|
63
99
|
writeGlobalConfig(global);
|
|
64
100
|
},
|
|
101
|
+
// Share the process-wide hook registry so mirror's subscriptions land
|
|
102
|
+
// on the same event bus that `BunSqliteStore` dispatches on. This is
|
|
103
|
+
// load-bearing for the event-driven path; without it, the manager
|
|
104
|
+
// falls back to safety-net polling only.
|
|
105
|
+
hooks: defaultHookRegistry,
|
|
65
106
|
};
|
|
66
107
|
}
|
|
67
108
|
|