tr-plugin-auth 0.0.0-stage → 1.0.0

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.
Files changed (60) hide show
  1. package/README.md +45 -2
  2. package/cli.mjs +384 -0
  3. package/package.json +40 -4
  4. package/plugin.json +266 -0
  5. package/src/access-keys.mjs +225 -0
  6. package/src/agent-dir.mjs +3 -0
  7. package/src/browser-session.mjs +240 -0
  8. package/src/cli-secret.mjs +1687 -0
  9. package/src/config.mjs +4 -0
  10. package/src/configuration.mjs +19 -0
  11. package/src/credential-preservation.mjs +28 -0
  12. package/src/crypto.mjs +195 -0
  13. package/src/env-import.mjs +502 -0
  14. package/src/fact-seeker.mjs +3 -0
  15. package/src/frontmatter.mjs +3 -0
  16. package/src/google-oauth.mjs +139 -0
  17. package/src/host-runtime.mjs +7 -0
  18. package/src/leader-election.mjs +9 -0
  19. package/src/logger.mjs +3 -0
  20. package/src/mesh-auth.mjs +7 -0
  21. package/src/mesh-identity.mjs +16 -0
  22. package/src/mesh-secrets.mjs +146 -0
  23. package/src/mesh.mjs +23 -0
  24. package/src/metadata-state.mjs +17 -0
  25. package/src/project-id.mjs +3 -0
  26. package/src/provider-account-sync.mjs +1145 -0
  27. package/src/provider-catalog.mjs +515 -0
  28. package/src/provider-rotation-recipes.mjs +299 -0
  29. package/src/research-gate.mjs +3 -0
  30. package/src/research-queue.mjs +3 -0
  31. package/src/rotation-capability.mjs +189 -0
  32. package/src/routes/auth.mjs +207 -0
  33. package/src/routes/keys.mjs +61 -0
  34. package/src/routes/secrets-runtime.mjs +5 -0
  35. package/src/routes/secrets.mjs +747 -0
  36. package/src/routes/webauthn.mjs +163 -0
  37. package/src/runtime-credentials.mjs +19 -0
  38. package/src/runtime.mjs +3 -0
  39. package/src/secret-integration-research.mjs +340 -0
  40. package/src/secrets-env-diff.mjs +132 -0
  41. package/src/secrets-env-export.mjs +322 -0
  42. package/src/secrets-gateway.mjs +253 -0
  43. package/src/secrets-keychain.mjs +83 -0
  44. package/src/secrets-rekey.mjs +366 -0
  45. package/src/secrets-remote-deploy.mjs +250 -0
  46. package/src/secrets-rotate.mjs +437 -0
  47. package/src/secrets-store.mjs +756 -0
  48. package/src/secrets-sync.mjs +81 -0
  49. package/src/secure-file.mjs +71 -0
  50. package/src/server-auth.mjs +500 -0
  51. package/src/session-credentials.mjs +71 -0
  52. package/src/ssss-store.mjs +13 -0
  53. package/src/stdin.mjs +31 -0
  54. package/src/throttled-fetch.mjs +3 -0
  55. package/src/vault.mjs +3 -0
  56. package/src/vfs-documents.mjs +3 -0
  57. package/src/webauthn-store.mjs +265 -0
  58. package/ui/DESIGN.md +19 -0
  59. package/ui/card.js +274 -0
  60. package/ui/panel.js +338 -0
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Authenticated browser sessions for TR.
3
+ *
4
+ * TR's existing Playwright use (source-adapters.playwrightScrape) is stateless
5
+ * scraping: `chromium.launch()` with a throwaway context. Provider consoles
6
+ * require a logged-in session, so rotation needs a *persistent* profile that
7
+ * survives across runs — you log in once per provider, and the daemon can drive
8
+ * rotations later without re-auth.
9
+ *
10
+ * SECURITY: the profile directory holds live session cookies for Stripe,
11
+ * GitHub, etc. It is at least as sensitive as secrets.enc and is created 0700.
12
+ * Never copy it, never sync it, never log its contents.
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ const PROFILE_DIRNAME = 'browser-profile';
19
+
20
+ const PROFILE_README = `# TR browser profile — DO NOT SYNC, DO NOT COPY
21
+
22
+ This directory holds live authenticated sessions (Stripe, GitHub, cloud consoles)
23
+ used by \`total-recall secret rotate-auto\`.
24
+
25
+ Treat it as equivalent to secrets.enc:
26
+ - never commit it
27
+ - never include it in backups that leave this machine
28
+ - delete it with \`total-recall secret browser-logout\` when no longer needed
29
+ `;
30
+
31
+ /**
32
+ * Load chromium if playwright is installed. Mirrors source-adapters.getPlaywright
33
+ * so browser support stays an optional dependency.
34
+ * @returns {Promise<any|null>}
35
+ */
36
+ export async function getChromium() {
37
+ try {
38
+ const pw = await import('playwright');
39
+ return pw.chromium || pw.default?.chromium || null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * @param {string} brainDir
47
+ * @returns {string}
48
+ */
49
+ export function resolveProfileDir(brainDir) {
50
+ return path.join(brainDir, PROFILE_DIRNAME);
51
+ }
52
+
53
+ /**
54
+ * Create the profile dir with restrictive permissions if absent.
55
+ * @param {string} brainDir
56
+ */
57
+ export function ensureProfileDir(brainDir) {
58
+ const dir = resolveProfileDir(brainDir);
59
+ if (!fs.existsSync(dir)) {
60
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
61
+ fs.writeFileSync(path.join(dir, 'README.md'), PROFILE_README, { mode: 0o600 });
62
+ }
63
+ try {
64
+ fs.chmodSync(dir, 0o700);
65
+ } catch {
66
+ /* best effort on exotic filesystems */
67
+ }
68
+ return dir;
69
+ }
70
+
71
+ /**
72
+ * Launch a persistent, authenticated browser context.
73
+ *
74
+ * @param {string} brainDir
75
+ * @param {{ headless?: boolean, timeoutMs?: number }} [opts]
76
+ * @returns {Promise<{ ok: boolean, context?: any, error?: string, profileDir?: string }>}
77
+ */
78
+ export async function launchRotationContext(brainDir, opts = {}) {
79
+ const chromium = await getChromium();
80
+ if (!chromium) {
81
+ return {
82
+ ok: false,
83
+ error:
84
+ 'playwright is not installed. Run: npm install playwright && npx playwright install chromium',
85
+ };
86
+ }
87
+
88
+ const profileDir = ensureProfileDir(brainDir);
89
+ // Headed by default: provider logins routinely require human 2FA, and a
90
+ // headless first-run would simply hang on an invisible challenge.
91
+ const headless = opts.headless === true;
92
+
93
+ const base = {
94
+ headless,
95
+ viewport: { width: 1440, height: 900 },
96
+ args: ['--disable-blink-features=AutomationControlled', '--no-first-run'],
97
+ };
98
+
99
+ // Playwright's bundled Chromium is not available on every host — it has
100
+ // dropped support for older macOS versions, and the pinned build is often
101
+ // simply not downloaded. A rotation that dies here strands the operator with
102
+ // live, burned credentials, so fall back to any locally installed Chrome or
103
+ // Edge before giving up.
104
+ const attempts = [
105
+ { label: 'bundled chromium', options: base },
106
+ { label: 'system chrome', options: { ...base, channel: 'chrome' } },
107
+ { label: 'system edge', options: { ...base, channel: 'msedge' } },
108
+ ];
109
+
110
+ const failures = [];
111
+ for (const attempt of attempts) {
112
+ try {
113
+ const context = await chromium.launchPersistentContext(profileDir, attempt.options);
114
+ context.setDefaultTimeout(opts.timeoutMs ?? 30_000);
115
+ return { ok: true, context, profileDir, browser: attempt.label };
116
+ } catch (err) {
117
+ failures.push(`${attempt.label}: ${err.message.split('\n')[0]}`);
118
+ }
119
+ }
120
+
121
+ return {
122
+ ok: false,
123
+ error:
124
+ `failed to launch a browser profile. Tried ${attempts.length} browsers —\n ` +
125
+ failures.join('\n ') +
126
+ '\nInstall one with: npx playwright install chromium (or install Google Chrome).',
127
+ profileDir,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Navigate to a console and report whether the session is authenticated.
133
+ *
134
+ * Recipes describe authentication by what is visible, not by URL alone, because
135
+ * most consoles redirect to a login page rather than returning an error status.
136
+ *
137
+ * @param {any} context
138
+ * @param {{ console_url: string, signed_in?: string, signed_out?: string }} recipe
139
+ * @returns {Promise<{ page: any, authenticated: boolean, url: string }>}
140
+ */
141
+ export async function openConsole(context, recipe) {
142
+ const page = context.pages()[0] || (await context.newPage());
143
+ await page.goto(recipe.console_url, { waitUntil: 'domcontentloaded' });
144
+ // Consoles are SPA-heavy; give client-side auth redirects a beat to settle.
145
+ await page.waitForTimeout(1500);
146
+
147
+ const authenticated = await isAuthenticated(page, recipe);
148
+ return { page, authenticated, url: page.url() };
149
+ }
150
+
151
+ /**
152
+ * @param {any} page
153
+ * @param {{ signed_in?: string, signed_out?: string }} recipe
154
+ * @returns {Promise<boolean>}
155
+ */
156
+ export async function isAuthenticated(page, recipe, opts = {}) {
157
+ // A visible signed-out marker (login form, "Sign in" CTA) is decisive.
158
+ if (recipe.signed_out) {
159
+ const out = await page.locator(recipe.signed_out).first().isVisible().catch(() => false);
160
+ if (out) return false;
161
+ }
162
+
163
+ if (recipe.signed_in) {
164
+ // Consoles are SPA-heavy: the positive marker frequently renders a second
165
+ // or two after DOMContentLoaded. Give it a real wait instead of a single
166
+ // instantaneous probe.
167
+ const appeared = await page
168
+ .locator(recipe.signed_in)
169
+ .first()
170
+ .waitFor({ state: 'visible', timeout: opts.markerTimeoutMs ?? 8000 })
171
+ .then(() => true)
172
+ .catch(() => false);
173
+ if (appeared) return true;
174
+
175
+ // The positive marker never showed and no login form matched. Selectors go
176
+ // stale constantly, so fall back to the one signal every console agrees on:
177
+ // an unauthenticated request gets redirected to a login URL.
178
+ return !looksLikeLoginUrl(page.url());
179
+ }
180
+
181
+ return !looksLikeLoginUrl(page.url());
182
+ }
183
+
184
+ /**
185
+ * Provider-agnostic sign-in detection. Consoles redirect anonymous visitors to
186
+ * a login route rather than returning an error status, so the landed URL is a
187
+ * far more durable signal than any per-provider CSS selector.
188
+ *
189
+ * @param {string} url
190
+ * @returns {boolean}
191
+ */
192
+ export function looksLikeLoginUrl(url) {
193
+ return /\/(login|log-in|sign[_-]?in|signin|auth\/login|sessions\/new|u\/login)(\/|\?|#|$)/i.test(
194
+ String(url || ''),
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Wait for a human to complete login/2FA in the headed window.
200
+ *
201
+ * @param {any} page
202
+ * @param {object} recipe
203
+ * @param {{ timeoutMs?: number, pollMs?: number, onWait?: (s:number)=>void }} [opts]
204
+ * @returns {Promise<boolean>}
205
+ */
206
+ export async function waitForLogin(page, recipe, opts = {}) {
207
+ const timeoutMs = opts.timeoutMs ?? 180_000;
208
+ const pollMs = opts.pollMs ?? 2000;
209
+ const started = Date.now();
210
+
211
+ while (Date.now() - started < timeoutMs) {
212
+ if (await isAuthenticated(page, recipe)) return true;
213
+ opts.onWait?.(Math.round((Date.now() - started) / 1000));
214
+ await page.waitForTimeout(pollMs);
215
+ }
216
+ return false;
217
+ }
218
+
219
+ /**
220
+ * Close a context without throwing.
221
+ * @param {any} context
222
+ */
223
+ export async function closeContext(context) {
224
+ try {
225
+ await context?.close();
226
+ } catch {
227
+ /* already closed */
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Delete the persistent profile (logout everywhere).
233
+ * @param {string} brainDir
234
+ */
235
+ export function clearProfile(brainDir) {
236
+ const dir = resolveProfileDir(brainDir);
237
+ if (!fs.existsSync(dir)) return { removed: false, path: dir };
238
+ fs.rmSync(dir, { recursive: true, force: true });
239
+ return { removed: true, path: dir };
240
+ }