signet-auth 1.0.0-beta.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.

Potentially problematic release.


This version of signet-auth might be problematic. Click here for more details.

Files changed (152) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +393 -0
  3. package/bin/sig.js +65 -0
  4. package/dist/auth-manager.d.ts +90 -0
  5. package/dist/auth-manager.js +262 -0
  6. package/dist/browser/adapters/playwright.adapter.d.ts +14 -0
  7. package/dist/browser/adapters/playwright.adapter.js +188 -0
  8. package/dist/browser/flows/form-login.flow.d.ts +6 -0
  9. package/dist/browser/flows/form-login.flow.js +35 -0
  10. package/dist/browser/flows/header-capture.d.ts +23 -0
  11. package/dist/browser/flows/header-capture.js +104 -0
  12. package/dist/browser/flows/hybrid-flow.d.ts +37 -0
  13. package/dist/browser/flows/hybrid-flow.js +104 -0
  14. package/dist/browser/flows/oauth-consent.flow.d.ts +20 -0
  15. package/dist/browser/flows/oauth-consent.flow.js +170 -0
  16. package/dist/cli/commands/doctor.d.ts +6 -0
  17. package/dist/cli/commands/doctor.js +263 -0
  18. package/dist/cli/commands/get.d.ts +2 -0
  19. package/dist/cli/commands/get.js +83 -0
  20. package/dist/cli/commands/init.d.ts +6 -0
  21. package/dist/cli/commands/init.js +244 -0
  22. package/dist/cli/commands/login.d.ts +2 -0
  23. package/dist/cli/commands/login.js +77 -0
  24. package/dist/cli/commands/logout.d.ts +2 -0
  25. package/dist/cli/commands/logout.js +11 -0
  26. package/dist/cli/commands/providers.d.ts +2 -0
  27. package/dist/cli/commands/providers.js +30 -0
  28. package/dist/cli/commands/remote.d.ts +1 -0
  29. package/dist/cli/commands/remote.js +67 -0
  30. package/dist/cli/commands/request.d.ts +2 -0
  31. package/dist/cli/commands/request.js +82 -0
  32. package/dist/cli/commands/status.d.ts +2 -0
  33. package/dist/cli/commands/status.js +41 -0
  34. package/dist/cli/commands/sync.d.ts +2 -0
  35. package/dist/cli/commands/sync.js +62 -0
  36. package/dist/cli/formatters.d.ts +3 -0
  37. package/dist/cli/formatters.js +25 -0
  38. package/dist/cli/main.d.ts +8 -0
  39. package/dist/cli/main.js +125 -0
  40. package/dist/config/generator.d.ts +24 -0
  41. package/dist/config/generator.js +97 -0
  42. package/dist/config/loader.d.ts +21 -0
  43. package/dist/config/loader.js +54 -0
  44. package/dist/config/schema.d.ts +44 -0
  45. package/dist/config/schema.js +8 -0
  46. package/dist/config/validator.d.ts +15 -0
  47. package/dist/config/validator.js +228 -0
  48. package/dist/core/errors.d.ts +57 -0
  49. package/dist/core/errors.js +107 -0
  50. package/dist/core/interfaces/auth-strategy.d.ts +48 -0
  51. package/dist/core/interfaces/auth-strategy.js +1 -0
  52. package/dist/core/interfaces/browser-adapter.d.ts +73 -0
  53. package/dist/core/interfaces/browser-adapter.js +1 -0
  54. package/dist/core/interfaces/provider.d.ts +15 -0
  55. package/dist/core/interfaces/provider.js +1 -0
  56. package/dist/core/interfaces/storage.d.ts +21 -0
  57. package/dist/core/interfaces/storage.js +1 -0
  58. package/dist/core/result.d.ts +21 -0
  59. package/dist/core/result.js +16 -0
  60. package/dist/core/types.d.ts +128 -0
  61. package/dist/core/types.js +6 -0
  62. package/dist/deps.d.ts +20 -0
  63. package/dist/deps.js +54 -0
  64. package/dist/index.d.ts +35 -0
  65. package/dist/index.js +37 -0
  66. package/dist/providers/auto-provision.d.ts +9 -0
  67. package/dist/providers/auto-provision.js +27 -0
  68. package/dist/providers/config-loader.d.ts +7 -0
  69. package/dist/providers/config-loader.js +7 -0
  70. package/dist/providers/provider-registry.d.ts +19 -0
  71. package/dist/providers/provider-registry.js +68 -0
  72. package/dist/storage/cached-storage.d.ts +24 -0
  73. package/dist/storage/cached-storage.js +57 -0
  74. package/dist/storage/directory-storage.d.ts +25 -0
  75. package/dist/storage/directory-storage.js +184 -0
  76. package/dist/storage/memory-storage.d.ts +14 -0
  77. package/dist/storage/memory-storage.js +27 -0
  78. package/dist/strategies/api-token.strategy.d.ts +6 -0
  79. package/dist/strategies/api-token.strategy.js +63 -0
  80. package/dist/strategies/basic-auth.strategy.d.ts +6 -0
  81. package/dist/strategies/basic-auth.strategy.js +41 -0
  82. package/dist/strategies/cookie.strategy.d.ts +6 -0
  83. package/dist/strategies/cookie.strategy.js +118 -0
  84. package/dist/strategies/oauth2.strategy.d.ts +6 -0
  85. package/dist/strategies/oauth2.strategy.js +134 -0
  86. package/dist/strategies/registry.d.ts +13 -0
  87. package/dist/strategies/registry.js +25 -0
  88. package/dist/sync/remote-config.d.ts +8 -0
  89. package/dist/sync/remote-config.js +49 -0
  90. package/dist/sync/sync-engine.d.ts +10 -0
  91. package/dist/sync/sync-engine.js +96 -0
  92. package/dist/sync/transports/ssh.d.ts +18 -0
  93. package/dist/sync/transports/ssh.js +115 -0
  94. package/dist/sync/types.d.ts +17 -0
  95. package/dist/sync/types.js +1 -0
  96. package/dist/utils/duration.d.ts +9 -0
  97. package/dist/utils/duration.js +34 -0
  98. package/dist/utils/http.d.ts +4 -0
  99. package/dist/utils/http.js +10 -0
  100. package/dist/utils/jwt.d.ts +15 -0
  101. package/dist/utils/jwt.js +30 -0
  102. package/package.json +56 -0
  103. package/src/auth-manager.ts +331 -0
  104. package/src/browser/adapters/playwright.adapter.ts +247 -0
  105. package/src/browser/flows/form-login.flow.ts +35 -0
  106. package/src/browser/flows/header-capture.ts +128 -0
  107. package/src/browser/flows/hybrid-flow.ts +165 -0
  108. package/src/browser/flows/oauth-consent.flow.ts +200 -0
  109. package/src/cli/commands/doctor.ts +301 -0
  110. package/src/cli/commands/get.ts +96 -0
  111. package/src/cli/commands/init.ts +289 -0
  112. package/src/cli/commands/login.ts +94 -0
  113. package/src/cli/commands/logout.ts +17 -0
  114. package/src/cli/commands/providers.ts +39 -0
  115. package/src/cli/commands/remote.ts +71 -0
  116. package/src/cli/commands/request.ts +97 -0
  117. package/src/cli/commands/status.ts +48 -0
  118. package/src/cli/commands/sync.ts +71 -0
  119. package/src/cli/formatters.ts +31 -0
  120. package/src/cli/main.ts +144 -0
  121. package/src/config/generator.ts +122 -0
  122. package/src/config/loader.ts +70 -0
  123. package/src/config/schema.ts +75 -0
  124. package/src/config/validator.ts +281 -0
  125. package/src/core/errors.ts +182 -0
  126. package/src/core/interfaces/auth-strategy.ts +65 -0
  127. package/src/core/interfaces/browser-adapter.ts +81 -0
  128. package/src/core/interfaces/provider.ts +19 -0
  129. package/src/core/interfaces/storage.ts +26 -0
  130. package/src/core/result.ts +24 -0
  131. package/src/core/types.ts +194 -0
  132. package/src/deps.ts +80 -0
  133. package/src/index.ts +109 -0
  134. package/src/providers/auto-provision.ts +30 -0
  135. package/src/providers/config-loader.ts +8 -0
  136. package/src/providers/provider-registry.ts +79 -0
  137. package/src/storage/cached-storage.ts +72 -0
  138. package/src/storage/directory-storage.ts +204 -0
  139. package/src/storage/memory-storage.ts +35 -0
  140. package/src/strategies/api-token.strategy.ts +87 -0
  141. package/src/strategies/basic-auth.strategy.ts +64 -0
  142. package/src/strategies/cookie.strategy.ts +153 -0
  143. package/src/strategies/oauth2.strategy.ts +178 -0
  144. package/src/strategies/registry.ts +34 -0
  145. package/src/sync/remote-config.ts +60 -0
  146. package/src/sync/sync-engine.ts +113 -0
  147. package/src/sync/transports/ssh.ts +130 -0
  148. package/src/sync/types.ts +15 -0
  149. package/src/utils/duration.ts +34 -0
  150. package/src/utils/http.ts +11 -0
  151. package/src/utils/jwt.ts +39 -0
  152. package/tsconfig.json +20 -0
@@ -0,0 +1,153 @@
1
+ import type { IAuthStrategy, IAuthStrategyFactory, AuthContext } from '../core/interfaces/auth-strategy.js';
2
+ import type { Credential, CookieCredential, ProviderConfig } from '../core/types.js';
3
+ import type { StrategyConfig, CookieStrategyConfig } from '../config/schema.js';
4
+ import type { Result } from '../core/result.js';
5
+ import { ok, err } from '../core/result.js';
6
+ import { BrowserError, type AuthError } from '../core/errors.js';
7
+ import { parseDuration } from '../utils/duration.js';
8
+ import { runHybridFlow } from '../browser/flows/hybrid-flow.js';
9
+ import { isLoginPage } from '../browser/flows/form-login.flow.js';
10
+ import { hasOAuthTokens } from '../browser/flows/oauth-consent.flow.js';
11
+
12
+ const DEFAULT_TTL = '24h';
13
+
14
+ /**
15
+ * Cookie-based authentication strategy.
16
+ * Launches a browser, navigates to the login page, waits for user auth,
17
+ * then extracts cookies from the authenticated session.
18
+ */
19
+ class CookieStrategy implements IAuthStrategy {
20
+ private readonly ttlMs: number;
21
+ private readonly requiredCookies: string[];
22
+ private readonly strategyConfig: CookieStrategyConfig;
23
+
24
+ constructor(config: CookieStrategyConfig) {
25
+ this.strategyConfig = config;
26
+ this.ttlMs = parseDuration(config.ttl ?? DEFAULT_TTL);
27
+ this.requiredCookies = config.requiredCookies ?? [];
28
+ }
29
+
30
+ validate(credential: Credential): Result<boolean, AuthError> {
31
+ if (credential.type !== 'cookie') return ok(false);
32
+
33
+ // Check TTL based on obtainedAt
34
+ const obtainedAt = new Date(credential.obtainedAt).getTime();
35
+ if (Date.now() - obtainedAt > this.ttlMs) {
36
+ return ok(false);
37
+ }
38
+
39
+ // Check individual cookie expiry
40
+ const now = Date.now() / 1000;
41
+ const hasExpired = credential.cookies.some(
42
+ c => c.expires > 0 && c.expires < now,
43
+ );
44
+ if (hasExpired) return ok(false);
45
+
46
+ // Ensure we have at least one cookie
47
+ if (credential.cookies.length === 0) return ok(false);
48
+
49
+ return ok(true);
50
+ }
51
+
52
+ async authenticate(
53
+ provider: ProviderConfig,
54
+ context: AuthContext,
55
+ ): Promise<Result<Credential, AuthError>> {
56
+ const adapter = context.browserAdapter;
57
+
58
+ if (!provider.entryUrl) {
59
+ return err(new BrowserError(
60
+ `Provider "${provider.id}" requires an entryUrl for cookie authentication.`,
61
+ provider.id,
62
+ ));
63
+ }
64
+
65
+ return await runHybridFlow<Credential>(adapter, {
66
+ entryUrl: provider.entryUrl,
67
+ browserConfig: context.browserConfig,
68
+ forceVisible: provider.forceVisible ?? false,
69
+ // Cookie auth needs networkidle to ensure all post-SSO Set-Cookie responses arrive
70
+ waitUntil: 'networkidle',
71
+ xHeaders: provider.xHeaders,
72
+ providerDomains: provider.domains,
73
+
74
+ isAuthenticated: async (page) => {
75
+ // If requiredCookies is set, auth is complete only when those cookies exist
76
+ if (this.requiredCookies.length > 0) {
77
+ const urls = provider.domains.map(d => `https://${d}/`);
78
+ const cookies = await page.cookies(urls);
79
+ const cookieNames = new Set(cookies.map(c => c.name));
80
+ return this.requiredCookies.every(name => cookieNames.has(name));
81
+ }
82
+
83
+ // Default: auth is complete when we're no longer on a login page
84
+ const onLoginPage = await isLoginPage(page);
85
+ return !onLoginPage;
86
+ },
87
+
88
+ extractCredentials: async (page, xHeaders, meta) => {
89
+ // Only extract cookies matching this provider's domains (not all cookies from the shared profile)
90
+ // Include both domain roots AND current page URL (to capture path-scoped cookies like /wiki)
91
+ const urls = provider.domains.map(d => `https://${d}/`);
92
+ const currentUrl = page.url();
93
+ if (currentUrl && !urls.includes(currentUrl)) urls.push(currentUrl);
94
+ const cookies = await page.cookies(urls);
95
+ if (cookies.length === 0) {
96
+ return err(new BrowserError(
97
+ 'No cookies found after authentication.',
98
+ provider.id,
99
+ ));
100
+ }
101
+
102
+ // Probe for OAuth tokens in browser storage (strategy mismatch detection)
103
+ const oauthTokensDetected = await hasOAuthTokens(page).catch(() => false);
104
+
105
+ const credential: CookieCredential = {
106
+ type: 'cookie',
107
+ cookies,
108
+ obtainedAt: new Date().toISOString(),
109
+ ...(xHeaders && Object.keys(xHeaders).length > 0 ? { xHeaders } : {}),
110
+ };
111
+
112
+ // Attach diagnostics metadata for post-auth validation
113
+ (credential as any).__diagnostics = {
114
+ authDetectedImmediately: meta?.immediateAuth ?? false,
115
+ oauthTokensDetected,
116
+ cookiesExtracted: cookies.length,
117
+ };
118
+
119
+ return ok(credential);
120
+ },
121
+ });
122
+ }
123
+
124
+ async refresh(): Promise<Result<Credential | null, AuthError>> {
125
+ // Cookies can't be refreshed — must re-authenticate via browser
126
+ return ok(null);
127
+ }
128
+
129
+ applyToRequest(credential: Credential): Record<string, string> {
130
+ if (credential.type !== 'cookie') return {};
131
+
132
+ const cookieStr = credential.cookies
133
+ .map(c => `${c.name}=${c.value}`)
134
+ .join('; ');
135
+
136
+ // Apply x-headers first, then set Cookie so it always wins
137
+ const headers: Record<string, string> = { ...credential.xHeaders };
138
+ headers['Cookie'] = cookieStr;
139
+
140
+ return headers;
141
+ }
142
+ }
143
+
144
+ export class CookieStrategyFactory implements IAuthStrategyFactory {
145
+ readonly name = 'cookie';
146
+
147
+ create(config: StrategyConfig): IAuthStrategy {
148
+ if (config.strategy !== 'cookie') {
149
+ throw new Error(`CookieStrategyFactory received wrong config type: ${config.strategy}`);
150
+ }
151
+ return new CookieStrategy(config);
152
+ }
153
+ }
@@ -0,0 +1,178 @@
1
+ import type { IAuthStrategy, IAuthStrategyFactory, AuthContext } from '../core/interfaces/auth-strategy.js';
2
+ import type { Credential, BearerCredential, ProviderConfig } from '../core/types.js';
3
+ import type { StrategyConfig, OAuth2StrategyConfig } from '../config/schema.js';
4
+ import type { Result } from '../core/result.js';
5
+ import { ok, err } from '../core/result.js';
6
+ import { BrowserError, RefreshError, type AuthError } from '../core/errors.js';
7
+ import { runHybridFlow } from '../browser/flows/hybrid-flow.js';
8
+ import { extractOAuthTokens, hasOAuthTokens } from '../browser/flows/oauth-consent.flow.js';
9
+ import { isLoginPage } from '../browser/flows/form-login.flow.js';
10
+
11
+ const EXPIRY_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
12
+
13
+ /**
14
+ * OAuth2 authentication strategy.
15
+ * Supports browser-based authorization with token extraction from localStorage,
16
+ * and silent refresh using refresh tokens.
17
+ */
18
+ class OAuth2Strategy implements IAuthStrategy {
19
+ private readonly strategyConfig: OAuth2StrategyConfig;
20
+
21
+ constructor(config: OAuth2StrategyConfig) {
22
+ this.strategyConfig = config;
23
+ }
24
+
25
+ validate(credential: Credential): Result<boolean, AuthError> {
26
+ if (credential.type !== 'bearer') return ok(false);
27
+
28
+ if (!credential.accessToken || credential.accessToken.trim() === '') {
29
+ return ok(false);
30
+ }
31
+
32
+ // Check expiry with buffer
33
+ if (credential.expiresAt) {
34
+ const expiresAtMs = new Date(credential.expiresAt).getTime();
35
+ if (Date.now() + EXPIRY_BUFFER_MS >= expiresAtMs) {
36
+ return ok(false);
37
+ }
38
+ }
39
+
40
+ return ok(true);
41
+ }
42
+
43
+ async authenticate(
44
+ provider: ProviderConfig,
45
+ context: AuthContext,
46
+ ): Promise<Result<Credential, AuthError>> {
47
+ const adapter = context.browserAdapter;
48
+
49
+ if (!provider.entryUrl) {
50
+ return err(new BrowserError(
51
+ `Provider "${provider.id}" requires an entryUrl for OAuth2 authentication.`,
52
+ provider.id,
53
+ ));
54
+ }
55
+
56
+ return await runHybridFlow<Credential>(adapter, {
57
+ entryUrl: provider.entryUrl,
58
+ browserConfig: context.browserConfig,
59
+ forceVisible: provider.forceVisible ?? false,
60
+ xHeaders: provider.xHeaders,
61
+ providerDomains: provider.domains,
62
+
63
+ isAuthenticated: async (page) => {
64
+ const onLogin = await isLoginPage(page);
65
+ if (onLogin) return false;
66
+ return await hasOAuthTokens(page, this.strategyConfig.audiences);
67
+ },
68
+
69
+ extractCredentials: async (page, xHeaders, meta) => {
70
+ const result = await extractOAuthTokens(page, {
71
+ audiences: this.strategyConfig.audiences,
72
+ extractRefreshToken: true,
73
+ maxRetries: 8, // Up to 16s of waiting for MSAL to store tokens
74
+ });
75
+ // Attach captured headers to the bearer credential
76
+ if (result.ok && xHeaders && Object.keys(xHeaders).length > 0) {
77
+ const cred = result.value as BearerCredential;
78
+ cred.xHeaders = xHeaders;
79
+ }
80
+ // Attach diagnostics metadata for post-auth validation
81
+ if (result.ok) {
82
+ (result.value as any).__diagnostics = {
83
+ authDetectedImmediately: meta?.immediateAuth ?? false,
84
+ oauthTokensDetected: true,
85
+ cookiesExtracted: 0,
86
+ };
87
+ }
88
+ return result;
89
+ },
90
+ });
91
+ }
92
+
93
+ async refresh(
94
+ credential: Credential,
95
+ ): Promise<Result<Credential | null, AuthError>> {
96
+ if (credential.type !== 'bearer') return ok(null);
97
+ if (!credential.refreshToken) return ok(null);
98
+
99
+ if (!this.strategyConfig.tokenEndpoint || !this.strategyConfig.clientId) {
100
+ return ok(null); // Can't refresh without endpoint and client ID
101
+ }
102
+
103
+ try {
104
+ const body = new URLSearchParams({
105
+ grant_type: 'refresh_token',
106
+ client_id: this.strategyConfig.clientId,
107
+ refresh_token: credential.refreshToken,
108
+ });
109
+
110
+ if (this.strategyConfig.scopes && this.strategyConfig.scopes.length > 0) {
111
+ body.set('scope', this.strategyConfig.scopes.join(' '));
112
+ }
113
+
114
+ const response = await fetch(this.strategyConfig.tokenEndpoint, {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
117
+ body: body.toString(),
118
+ });
119
+
120
+ if (!response.ok) {
121
+ const errorBody = await response.text();
122
+ return err(new RefreshError(
123
+ credential.tokenEndpoint ?? 'unknown',
124
+ `Token refresh failed (${response.status}): ${errorBody}`,
125
+ ));
126
+ }
127
+
128
+ const tokenResponse = await response.json() as {
129
+ access_token: string;
130
+ refresh_token?: string;
131
+ expires_in?: number;
132
+ scope?: string;
133
+ };
134
+
135
+ const expiresAt = tokenResponse.expires_in
136
+ ? new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
137
+ : undefined;
138
+
139
+ const refreshed: BearerCredential = {
140
+ type: 'bearer',
141
+ accessToken: tokenResponse.access_token,
142
+ refreshToken: tokenResponse.refresh_token ?? credential.refreshToken,
143
+ expiresAt,
144
+ scopes: tokenResponse.scope?.split(' '),
145
+ tokenEndpoint: credential.tokenEndpoint,
146
+ ...(credential.xHeaders ? { xHeaders: credential.xHeaders } : {}),
147
+ };
148
+
149
+ return ok(refreshed);
150
+ } catch (e: unknown) {
151
+ return err(new RefreshError(
152
+ credential.tokenEndpoint ?? 'unknown',
153
+ (e as Error).message,
154
+ ));
155
+ }
156
+ }
157
+
158
+ applyToRequest(credential: Credential): Record<string, string> {
159
+ if (credential.type !== 'bearer') return {};
160
+
161
+ // Apply x-headers first, then set Authorization so it always wins
162
+ const headers: Record<string, string> = { ...credential.xHeaders };
163
+ headers['Authorization'] = `Bearer ${credential.accessToken}`;
164
+
165
+ return headers;
166
+ }
167
+ }
168
+
169
+ export class OAuth2StrategyFactory implements IAuthStrategyFactory {
170
+ readonly name = 'oauth2';
171
+
172
+ create(config: StrategyConfig): IAuthStrategy {
173
+ if (config.strategy !== 'oauth2') {
174
+ throw new Error(`OAuth2StrategyFactory received wrong config type: ${config.strategy}`);
175
+ }
176
+ return new OAuth2Strategy(config);
177
+ }
178
+ }
@@ -0,0 +1,34 @@
1
+ import type { IAuthStrategy, IAuthStrategyFactory } from '../core/interfaces/auth-strategy.js';
2
+ import type { StrategyConfig } from '../config/schema.js';
3
+ import { ConfigError } from '../core/errors.js';
4
+
5
+ /**
6
+ * Registry that maps strategy names to their factories.
7
+ * Built-in strategies are registered at startup; users can add custom ones.
8
+ */
9
+ export class StrategyRegistry {
10
+ private factories = new Map<string, IAuthStrategyFactory>();
11
+
12
+ register(factory: IAuthStrategyFactory): void {
13
+ this.factories.set(factory.name, factory);
14
+ }
15
+
16
+ get(name: string, config: StrategyConfig): IAuthStrategy {
17
+ const factory = this.factories.get(name);
18
+ if (!factory) {
19
+ const available = Array.from(this.factories.keys()).join(', ');
20
+ throw new ConfigError(
21
+ `Unknown strategy "${name}". Available strategies: ${available || 'none'}`,
22
+ );
23
+ }
24
+ return factory.create(config);
25
+ }
26
+
27
+ has(name: string): boolean {
28
+ return this.factories.has(name);
29
+ }
30
+
31
+ list(): string[] {
32
+ return Array.from(this.factories.keys());
33
+ }
34
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Remote configuration — reads/writes from the unified ~/.signet/config.yaml.
3
+ */
4
+
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import YAML from 'yaml';
9
+ import type { RemoteConfig } from './types.js';
10
+
11
+ const CONFIG_PATH = path.join(os.homedir(), '.signet', 'config.yaml');
12
+
13
+ interface ConfigFile {
14
+ browser?: Record<string, unknown>;
15
+ storage?: Record<string, unknown>;
16
+ providers?: Record<string, unknown>;
17
+ remotes?: Record<string, Omit<RemoteConfig, 'name'>>;
18
+ }
19
+
20
+ async function loadRawConfig(): Promise<ConfigFile> {
21
+ try {
22
+ const content = await fs.readFile(CONFIG_PATH, 'utf-8');
23
+ return YAML.parse(content) ?? {};
24
+ } catch (e: unknown) {
25
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return {};
26
+ throw e;
27
+ }
28
+ }
29
+
30
+ async function saveRawConfig(config: ConfigFile): Promise<void> {
31
+ await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true });
32
+ await fs.writeFile(CONFIG_PATH, YAML.stringify(config), 'utf-8');
33
+ }
34
+
35
+ export async function getRemotes(): Promise<RemoteConfig[]> {
36
+ const config = await loadRawConfig();
37
+ if (!config.remotes) return [];
38
+ return Object.entries(config.remotes).map(([name, r]) => ({ name, ...r }));
39
+ }
40
+
41
+ export async function getRemote(name: string): Promise<RemoteConfig | null> {
42
+ const remotes = await getRemotes();
43
+ return remotes.find(r => r.name === name) ?? null;
44
+ }
45
+
46
+ export async function addRemote(remote: RemoteConfig): Promise<void> {
47
+ const config = await loadRawConfig();
48
+ if (!config.remotes) config.remotes = {};
49
+ const { name, ...rest } = remote;
50
+ config.remotes[name] = rest;
51
+ await saveRawConfig(config);
52
+ }
53
+
54
+ export async function removeRemote(name: string): Promise<boolean> {
55
+ const config = await loadRawConfig();
56
+ if (!config.remotes || !config.remotes[name]) return false;
57
+ delete config.remotes[name];
58
+ await saveRawConfig(config);
59
+ return true;
60
+ }
@@ -0,0 +1,113 @@
1
+ import type { IStorage } from '../core/interfaces/storage.js';
2
+ import type { RemoteConfig, SyncResult } from './types.js';
3
+ import { SshTransport } from './transports/ssh.js';
4
+
5
+ function sanitizeId(providerId: string): string {
6
+ return providerId.replace(/[^a-zA-Z0-9._-]/g, '_');
7
+ }
8
+
9
+ export class SyncEngine {
10
+ private readonly transport: SshTransport;
11
+
12
+ constructor(
13
+ private readonly storage: IStorage,
14
+ private readonly remote: RemoteConfig,
15
+ ) {
16
+ this.transport = new SshTransport();
17
+ }
18
+
19
+ async push(providerIds?: string[], force = false): Promise<SyncResult> {
20
+ const result: SyncResult = { pushed: [], pulled: [], skipped: [], errors: [] };
21
+
22
+ // Get local entries
23
+ const localEntries = await this.storage.list();
24
+ const toPush = providerIds
25
+ ? localEntries.filter(e => providerIds.includes(e.providerId))
26
+ : localEntries;
27
+
28
+ if (toPush.length === 0) {
29
+ return result;
30
+ }
31
+
32
+ // Get remote entries for conflict detection
33
+ const remoteEntries = await this.transport.listRemote(this.remote);
34
+ const remoteMap = new Map(remoteEntries.map(e => [e.providerId, e]));
35
+
36
+ for (const entry of toPush) {
37
+ try {
38
+ const filename = `${sanitizeId(entry.providerId)}.json`;
39
+ const remoteEntry = remoteMap.get(entry.providerId);
40
+
41
+ // Conflict detection: skip if remote is newer
42
+ if (remoteEntry && !force) {
43
+ const localTime = new Date(entry.updatedAt).getTime();
44
+ const remoteTime = new Date(remoteEntry.updatedAt).getTime();
45
+ if (remoteTime > localTime) {
46
+ result.skipped.push(entry.providerId);
47
+ continue;
48
+ }
49
+ }
50
+
51
+ const stored = await this.storage.get(entry.providerId);
52
+ if (!stored) continue;
53
+
54
+ await this.transport.writeRemote(this.remote, filename, stored);
55
+ result.pushed.push(entry.providerId);
56
+ } catch (e: unknown) {
57
+ result.errors.push({
58
+ providerId: entry.providerId,
59
+ error: (e as Error).message,
60
+ });
61
+ }
62
+ }
63
+
64
+ return result;
65
+ }
66
+
67
+ async pull(providerIds?: string[], force = false): Promise<SyncResult> {
68
+ const result: SyncResult = { pushed: [], pulled: [], skipped: [], errors: [] };
69
+
70
+ // Get remote entries
71
+ const remoteEntries = await this.transport.listRemote(this.remote);
72
+ const toPull = providerIds
73
+ ? remoteEntries.filter(e => providerIds.includes(e.providerId))
74
+ : remoteEntries;
75
+
76
+ if (toPull.length === 0) {
77
+ return result;
78
+ }
79
+
80
+ for (const entry of toPull) {
81
+ try {
82
+ // Conflict detection
83
+ if (!force) {
84
+ const local = await this.storage.get(entry.providerId);
85
+ if (local) {
86
+ const localTime = new Date(local.updatedAt).getTime();
87
+ const remoteTime = new Date(entry.updatedAt).getTime();
88
+ if (localTime > remoteTime) {
89
+ result.skipped.push(entry.providerId);
90
+ continue;
91
+ }
92
+ }
93
+ }
94
+
95
+ const stored = await this.transport.readRemote(this.remote, entry.filename);
96
+ if (!stored) {
97
+ result.errors.push({ providerId: entry.providerId, error: 'Failed to read from remote' });
98
+ continue;
99
+ }
100
+
101
+ await this.storage.set(entry.providerId, stored);
102
+ result.pulled.push(entry.providerId);
103
+ } catch (e: unknown) {
104
+ result.errors.push({
105
+ providerId: entry.providerId,
106
+ error: (e as Error).message,
107
+ });
108
+ }
109
+ }
110
+
111
+ return result;
112
+ }
113
+ }
@@ -0,0 +1,130 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import type { RemoteConfig } from '../types.js';
6
+ import type { StoredCredential } from '../../core/types.js';
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ const DEFAULT_REMOTE_PATH = '~/.signet/credentials';
11
+
12
+ export class SshTransport {
13
+
14
+ private sshArgs(remote: RemoteConfig): string[] {
15
+ const args: string[] = [];
16
+ if (remote.sshKey) args.push('-i', remote.sshKey);
17
+ args.push('-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new');
18
+ return args;
19
+ }
20
+
21
+ private remoteTarget(remote: RemoteConfig): string {
22
+ const user = remote.user ?? os.userInfo().username;
23
+ return `${user}@${remote.host}`;
24
+ }
25
+
26
+ private remotePath(remote: RemoteConfig): string {
27
+ return remote.path ?? DEFAULT_REMOTE_PATH;
28
+ }
29
+
30
+ /** List provider files on the remote */
31
+ async listRemote(remote: RemoteConfig): Promise<{ providerId: string; updatedAt: string; filename: string }[]> {
32
+ const target = this.remoteTarget(remote);
33
+ const rpath = this.remotePath(remote);
34
+
35
+ try {
36
+ const { stdout } = await execFileAsync('ssh', [
37
+ ...this.sshArgs(remote),
38
+ target,
39
+ `find ${rpath} -maxdepth 1 -name '*.json' -print 2>/dev/null || true`,
40
+ ]);
41
+
42
+ const files = stdout.trim().split('\n').filter(Boolean);
43
+ const entries: { providerId: string; updatedAt: string; filename: string }[] = [];
44
+
45
+ for (const file of files) {
46
+ const filename = path.basename(file);
47
+ try {
48
+ const { stdout: content } = await execFileAsync('ssh', [
49
+ ...this.sshArgs(remote),
50
+ target,
51
+ `cat "${file}"`,
52
+ ]);
53
+ const data = JSON.parse(content);
54
+ entries.push({
55
+ providerId: data.providerId,
56
+ updatedAt: data.updatedAt,
57
+ filename,
58
+ });
59
+ } catch {
60
+ // Skip unreadable files
61
+ }
62
+ }
63
+
64
+ return entries;
65
+ } catch {
66
+ return [];
67
+ }
68
+ }
69
+
70
+ /** Read a single credential from remote */
71
+ async readRemote(remote: RemoteConfig, filename: string): Promise<StoredCredential | null> {
72
+ const target = this.remoteTarget(remote);
73
+ const rpath = this.remotePath(remote);
74
+
75
+ try {
76
+ const { stdout } = await execFileAsync('ssh', [
77
+ ...this.sshArgs(remote),
78
+ target,
79
+ `cat ${rpath}/"${filename}"`,
80
+ ]);
81
+ const data = JSON.parse(stdout);
82
+ return {
83
+ credential: data.credential,
84
+ providerId: data.providerId,
85
+ strategy: data.strategy,
86
+ updatedAt: data.updatedAt,
87
+ ...(data.metadata ? { metadata: data.metadata } : {}),
88
+ };
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /** Write a credential file to remote via ssh pipe (avoids scp tilde issues) */
95
+ async writeRemote(remote: RemoteConfig, filename: string, stored: StoredCredential): Promise<void> {
96
+ const rpath = this.remotePath(remote);
97
+
98
+ const data = {
99
+ version: 1,
100
+ providerId: stored.providerId,
101
+ credential: stored.credential,
102
+ strategy: stored.strategy,
103
+ updatedAt: stored.updatedAt,
104
+ ...(stored.metadata ? { metadata: stored.metadata } : {}),
105
+ };
106
+
107
+ const content = JSON.stringify(data, null, 2);
108
+
109
+ // Write via ssh stdin pipe — avoids scp's tilde expansion issues
110
+ // The remote shell handles ~ expansion in mkdir and cat redirect
111
+ await this.sshWrite(remote, `mkdir -p ${rpath} && cat > ${rpath}/"${filename}"`, content);
112
+ }
113
+
114
+ private sshWrite(remote: RemoteConfig, command: string, stdin: string): Promise<void> {
115
+ return new Promise((resolve, reject) => {
116
+ const target = this.remoteTarget(remote);
117
+ const proc = execFile('ssh', [
118
+ ...this.sshArgs(remote),
119
+ target,
120
+ command,
121
+ ], (error) => {
122
+ if (error) reject(error);
123
+ else resolve();
124
+ });
125
+
126
+ proc.stdin?.write(stdin);
127
+ proc.stdin?.end();
128
+ });
129
+ }
130
+ }
@@ -0,0 +1,15 @@
1
+ export interface RemoteConfig {
2
+ name: string;
3
+ type: 'ssh';
4
+ host: string;
5
+ user?: string;
6
+ path?: string; // defaults to ~/.signet/credentials
7
+ sshKey?: string; // path to SSH key
8
+ }
9
+
10
+ export interface SyncResult {
11
+ pushed: string[]; // provider IDs successfully synced
12
+ pulled: string[];
13
+ skipped: string[]; // conflicts (skipped unless --force)
14
+ errors: { providerId: string; error: string }[];
15
+ }