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,122 @@
1
+ /**
2
+ * Config YAML generator for signet.
3
+ * Uses template literals (NOT YAML.stringify) to preserve comments.
4
+ */
5
+
6
+ export interface InitOptions {
7
+ channel: string;
8
+ browserDataDir: string;
9
+ credentialsDir: string;
10
+ headlessTimeout: number;
11
+ visibleTimeout: number;
12
+ waitUntil: string;
13
+ providers?: Array<{
14
+ id: string;
15
+ domains: string[];
16
+ strategy: string;
17
+ entryUrl?: string;
18
+ config?: Record<string, unknown>;
19
+ }>;
20
+ }
21
+
22
+ function yamlArray(items: string[]): string {
23
+ return `[${items.map(d => `"${d}"`).join(', ')}]`;
24
+ }
25
+
26
+ function renderProviderConfig(config: Record<string, unknown>, indent: number): string {
27
+ const spaces = ' '.repeat(indent);
28
+ const lines: string[] = [];
29
+ for (const [key, value] of Object.entries(config)) {
30
+ if (Array.isArray(value)) {
31
+ lines.push(`${spaces}${key}: ${yamlArray(value as string[])}`);
32
+ } else if (typeof value === 'string') {
33
+ lines.push(`${spaces}${key}: ${value}`);
34
+ } else if (typeof value === 'number' || typeof value === 'boolean') {
35
+ lines.push(`${spaces}${key}: ${String(value)}`);
36
+ }
37
+ }
38
+ return lines.join('\n');
39
+ }
40
+
41
+ function renderProvider(provider: {
42
+ id: string;
43
+ domains: string[];
44
+ strategy: string;
45
+ entryUrl?: string;
46
+ config?: Record<string, unknown>;
47
+ }): string {
48
+ const lines: string[] = [];
49
+ lines.push(` ${provider.id}:`);
50
+ lines.push(` domains: ${yamlArray(provider.domains)}`);
51
+ lines.push(` strategy: ${provider.strategy}`);
52
+ if (provider.entryUrl) {
53
+ lines.push(` entryUrl: ${provider.entryUrl}`);
54
+ }
55
+ if (provider.config && Object.keys(provider.config).length > 0) {
56
+ lines.push(' config:');
57
+ lines.push(renderProviderConfig(provider.config, 6));
58
+ }
59
+ return lines.join('\n');
60
+ }
61
+
62
+ /**
63
+ * Generate a commented YAML config string from the given options.
64
+ * Uses template literals to preserve comments and formatting.
65
+ */
66
+ export function generateConfigYaml(options: InitOptions): string {
67
+ const providerSection = options.providers && options.providers.length > 0
68
+ ? options.providers.map(p => renderProvider(p)).join('\n\n')
69
+ : ` # No providers configured yet.
70
+ # Add providers here or use "sig login <url>" for auto-provisioning.
71
+ #
72
+ # Example — cookie-based (SSO):
73
+ # my-jira:
74
+ # domains: ["jira.example.com"]
75
+ # strategy: cookie
76
+ # config:
77
+ # ttl: "12h"
78
+ #
79
+ # Example — API token:
80
+ # github:
81
+ # domains: ["github.com", "api.github.com"]
82
+ # strategy: api-token
83
+ # config:
84
+ # headerName: Authorization
85
+ # headerPrefix: Bearer`;
86
+
87
+ return `# Signet unified configuration
88
+ # Generated by "sig init". All configuration lives in this single file.
89
+ # Documentation: https://github.com/pylon/signet
90
+
91
+ # Browser settings (required)
92
+ # Controls browser automation for cookie and OAuth2 authentication.
93
+ browser:
94
+ browserDataDir: ${options.browserDataDir} # Persistent browser profile directory
95
+ channel: ${options.channel} # Browser channel (chrome, msedge, chromium)
96
+ headlessTimeout: ${options.headlessTimeout} # Timeout for headless auth attempt (ms)
97
+ visibleTimeout: ${options.visibleTimeout} # Timeout for visible/user-assisted auth (ms)
98
+ waitUntil: ${options.waitUntil} # Page load condition (load, networkidle, domcontentloaded, commit)
99
+
100
+ # Storage settings (required)
101
+ # Where per-provider credential files are stored.
102
+ storage:
103
+ credentialsDir: ${options.credentialsDir} # Per-provider credential files directory
104
+
105
+ # Remote credential stores (optional)
106
+ # Sync credentials to/from remote machines via SSH.
107
+ # remotes:
108
+ # dev-server:
109
+ # type: ssh
110
+ # host: dev.example.com
111
+ # user: deploy
112
+ # path: ~/.signet/credentials
113
+ # sshKey: ~/.ssh/id_ed25519
114
+
115
+ # Provider configurations
116
+ # Cookie-based providers (most SSO systems) don't need config at all —
117
+ # just call "sig login https://jira.example.com" and it auto-provisions.
118
+ # Only create entries here for OAuth2, API tokens, or advanced settings.
119
+ providers:
120
+ ${providerSection}
121
+ `;
122
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Single config file loader for signet.
3
+ * Reads ONLY ~/.signet/config.yaml — no cascade, no env vars.
4
+ */
5
+
6
+ import fs from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+ import YAML from 'yaml';
10
+ import type { Result } from '../core/result.js';
11
+ import { ok, err } from '../core/result.js';
12
+ import { ConfigError, type AuthError } from '../core/errors.js';
13
+ import type { SignetConfig } from './schema.js';
14
+ import { validateConfig } from './validator.js';
15
+
16
+ const CONFIG_PATH = path.join(os.homedir(), '.signet', 'config.yaml');
17
+
18
+ /**
19
+ * Load and validate the unified config from ~/.signet/config.yaml.
20
+ * Returns Result<SignetConfig, AuthError>.
21
+ */
22
+ export async function loadConfig(): Promise<Result<SignetConfig, AuthError>> {
23
+ let content: string;
24
+ try {
25
+ content = await fs.readFile(CONFIG_PATH, 'utf-8');
26
+ } catch (e: unknown) {
27
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
28
+ return err(new ConfigError(
29
+ `Config file not found: ${CONFIG_PATH}. ` +
30
+ 'Create it with browser.browserDataDir, storage.credentialsDir, and providers sections.',
31
+ ));
32
+ }
33
+ return err(new ConfigError(
34
+ `Failed to read config from ${CONFIG_PATH}: ${(e as Error).message}`,
35
+ ));
36
+ }
37
+
38
+ let raw: unknown;
39
+ try {
40
+ raw = YAML.parse(content);
41
+ } catch (e: unknown) {
42
+ return err(new ConfigError(
43
+ `Invalid YAML in ${CONFIG_PATH}: ${(e as Error).message}`,
44
+ ));
45
+ }
46
+
47
+ if (!raw || typeof raw !== 'object') {
48
+ return err(new ConfigError(
49
+ `Config file ${CONFIG_PATH} is empty or not an object.`,
50
+ ));
51
+ }
52
+
53
+ return validateConfig(raw as Record<string, unknown>);
54
+ }
55
+
56
+ /**
57
+ * Save the full config back to ~/.signet/config.yaml.
58
+ * Used by remote add/remove commands to persist changes.
59
+ */
60
+ export async function saveConfig(config: SignetConfig): Promise<void> {
61
+ await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true });
62
+ await fs.writeFile(CONFIG_PATH, YAML.stringify(config), 'utf-8');
63
+ }
64
+
65
+ /**
66
+ * Get the config file path (for error messages).
67
+ */
68
+ export function getConfigPath(): string {
69
+ return CONFIG_PATH;
70
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Unified configuration schema for signet.
3
+ * All config lives in ~/.signet/config.yaml — no cascade, no env vars.
4
+ *
5
+ * Strategy config types are defined in core/types.ts (shared vocabulary)
6
+ * and re-exported here for convenience.
7
+ */
8
+
9
+ import type {
10
+ CredentialType,
11
+ XHeaderConfig,
12
+ StrategyName,
13
+ } from '../core/types.js';
14
+
15
+ // Re-export strategy config types from core/types (the source of truth)
16
+ export type {
17
+ CookieStrategyConfig,
18
+ OAuth2StrategyConfig,
19
+ ApiTokenStrategyConfig,
20
+ BasicStrategyConfig,
21
+ StrategyConfig,
22
+ StrategyName,
23
+ } from '../core/types.js';
24
+
25
+ // ============================================================================
26
+ // Top-level Config Sections
27
+ // ============================================================================
28
+
29
+ export interface BrowserConfig {
30
+ browserDataDir: string;
31
+ channel: string;
32
+ headlessTimeout: number;
33
+ visibleTimeout: number;
34
+ waitUntil: 'load' | 'networkidle' | 'domcontentloaded' | 'commit';
35
+ }
36
+
37
+ export interface StorageConfig {
38
+ credentialsDir: string; // MANDATORY
39
+ }
40
+
41
+ export interface RemoteEntry {
42
+ type: 'ssh';
43
+ host: string;
44
+ user?: string;
45
+ path?: string;
46
+ sshKey?: string;
47
+ }
48
+
49
+ // ============================================================================
50
+ // Root Config
51
+ // ============================================================================
52
+
53
+ export interface SignetConfig {
54
+ browser: BrowserConfig;
55
+ storage: StorageConfig;
56
+ remotes?: Record<string, RemoteEntry>;
57
+ providers: Record<string, ProviderEntry>;
58
+ }
59
+
60
+ // ============================================================================
61
+ // Provider Entry (as it appears in YAML)
62
+ // ============================================================================
63
+
64
+ export interface ProviderEntry {
65
+ name?: string;
66
+ domains: string[];
67
+ entryUrl?: string;
68
+ strategy: StrategyName;
69
+ config?: Record<string, unknown>;
70
+ acceptedCredentialTypes?: CredentialType[];
71
+ setupInstructions?: string;
72
+ credentialFile?: string;
73
+ xHeaders?: XHeaderConfig[];
74
+ forceVisible?: boolean;
75
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Runtime validation for the unified signet config.
3
+ * Returns Result<SignetConfig, AuthError>.
4
+ */
5
+
6
+ import type { Result } from '../core/result.js';
7
+ import { ok, err } from '../core/result.js';
8
+ import { ConfigError, type AuthError } from '../core/errors.js';
9
+ import type {
10
+ SignetConfig,
11
+ BrowserConfig,
12
+ StorageConfig,
13
+ ProviderEntry,
14
+ RemoteEntry,
15
+ StrategyName,
16
+ StrategyConfig,
17
+ } from './schema.js';
18
+
19
+ const VALID_STRATEGIES: readonly StrategyName[] = ['cookie', 'oauth2', 'api-token', 'basic'];
20
+ const VALID_WAIT_UNTIL = ['load', 'networkidle', 'domcontentloaded', 'commit'];
21
+
22
+ /**
23
+ * Validate a raw config object parsed from YAML.
24
+ */
25
+ export function validateConfig(raw: Record<string, unknown>): Result<SignetConfig, AuthError> {
26
+ const errors: string[] = [];
27
+
28
+ // --- browser section ---
29
+ if (!raw.browser || typeof raw.browser !== 'object') {
30
+ errors.push('Missing required section: "browser"');
31
+ } else {
32
+ const browser = raw.browser as Record<string, unknown>;
33
+ if (typeof browser.browserDataDir !== 'string' || browser.browserDataDir.trim() === '') {
34
+ errors.push('Missing required field: browser.browserDataDir');
35
+ }
36
+ if (typeof browser.channel !== 'string' || browser.channel.trim() === '') {
37
+ errors.push('Missing required field: browser.channel');
38
+ }
39
+ if (browser.headlessTimeout !== undefined && typeof browser.headlessTimeout !== 'number') {
40
+ errors.push('browser.headlessTimeout must be a number');
41
+ }
42
+ if (browser.visibleTimeout !== undefined && typeof browser.visibleTimeout !== 'number') {
43
+ errors.push('browser.visibleTimeout must be a number');
44
+ }
45
+ if (browser.waitUntil !== undefined && !VALID_WAIT_UNTIL.includes(browser.waitUntil as string)) {
46
+ errors.push(`browser.waitUntil must be one of: ${VALID_WAIT_UNTIL.join(', ')}`);
47
+ }
48
+ }
49
+
50
+ // --- storage section ---
51
+ if (!raw.storage || typeof raw.storage !== 'object') {
52
+ errors.push('Missing required section: "storage"');
53
+ } else {
54
+ const storage = raw.storage as Record<string, unknown>;
55
+ if (typeof storage.credentialsDir !== 'string' || storage.credentialsDir.trim() === '') {
56
+ errors.push('Missing required field: storage.credentialsDir');
57
+ }
58
+ }
59
+
60
+ // --- providers section (optional — null/missing means zero providers) ---
61
+ if (raw.providers !== undefined && raw.providers !== null) {
62
+ if (typeof raw.providers !== 'object') {
63
+ errors.push('"providers" must be an object (or omitted)');
64
+ } else {
65
+ const providers = raw.providers as Record<string, unknown>;
66
+ for (const [id, entry] of Object.entries(providers)) {
67
+ if (!entry || typeof entry !== 'object') {
68
+ errors.push(`Provider "${id}": must be an object`);
69
+ continue;
70
+ }
71
+ const providerErrors = validateProviderEntry(id, entry as Record<string, unknown>);
72
+ errors.push(...providerErrors);
73
+ }
74
+ }
75
+ }
76
+
77
+ // --- remotes section (optional) ---
78
+ if (raw.remotes !== undefined) {
79
+ if (typeof raw.remotes !== 'object' || raw.remotes === null) {
80
+ errors.push('"remotes" must be an object');
81
+ } else {
82
+ const remotes = raw.remotes as Record<string, unknown>;
83
+ for (const [name, entry] of Object.entries(remotes)) {
84
+ if (!entry || typeof entry !== 'object') {
85
+ errors.push(`Remote "${name}": must be an object`);
86
+ continue;
87
+ }
88
+ const r = entry as Record<string, unknown>;
89
+ if (r.type !== 'ssh') {
90
+ errors.push(`Remote "${name}": only type "ssh" is supported`);
91
+ }
92
+ if (typeof r.host !== 'string' || r.host.trim() === '') {
93
+ errors.push(`Remote "${name}": missing required field "host"`);
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ if (errors.length > 0) {
100
+ return err(new ConfigError(
101
+ `Config validation failed:\n - ${errors.join('\n - ')}`,
102
+ ));
103
+ }
104
+
105
+ // Build the validated config
106
+ const browserRaw = raw.browser as Record<string, unknown>;
107
+ const browser: BrowserConfig = {
108
+ browserDataDir: browserRaw.browserDataDir as string,
109
+ channel: browserRaw.channel as string,
110
+ headlessTimeout: typeof browserRaw.headlessTimeout === 'number' ? browserRaw.headlessTimeout : 30_000,
111
+ visibleTimeout: typeof browserRaw.visibleTimeout === 'number' ? browserRaw.visibleTimeout : 120_000,
112
+ waitUntil: typeof browserRaw.waitUntil === 'string' ? browserRaw.waitUntil as BrowserConfig['waitUntil'] : 'load',
113
+ };
114
+
115
+ const storageRaw = raw.storage as Record<string, unknown>;
116
+ const storage: StorageConfig = {
117
+ credentialsDir: storageRaw.credentialsDir as string,
118
+ };
119
+
120
+ const providers: Record<string, ProviderEntry> = {};
121
+ if (raw.providers && typeof raw.providers === 'object') {
122
+ for (const [id, entry] of Object.entries(raw.providers as Record<string, unknown>)) {
123
+ providers[id] = parseProviderEntry(entry as Record<string, unknown>);
124
+ }
125
+ }
126
+
127
+ let remotes: Record<string, RemoteEntry> | undefined;
128
+ if (raw.remotes && typeof raw.remotes === 'object') {
129
+ remotes = {};
130
+ for (const [name, entry] of Object.entries(raw.remotes as Record<string, unknown>)) {
131
+ const r = entry as Record<string, unknown>;
132
+ remotes[name] = {
133
+ type: 'ssh',
134
+ host: r.host as string,
135
+ ...(typeof r.user === 'string' ? { user: r.user } : {}),
136
+ ...(typeof r.path === 'string' ? { path: r.path } : {}),
137
+ ...(typeof r.sshKey === 'string' ? { sshKey: r.sshKey } : {}),
138
+ };
139
+ }
140
+ }
141
+
142
+ const config: SignetConfig = {
143
+ browser,
144
+ storage,
145
+ providers,
146
+ ...(remotes ? { remotes } : {}),
147
+ };
148
+
149
+ return ok(config);
150
+ }
151
+
152
+ function validateProviderEntry(id: string, raw: Record<string, unknown>): string[] {
153
+ const errors: string[] = [];
154
+
155
+ if (!Array.isArray(raw.domains) || raw.domains.length === 0) {
156
+ errors.push(`Provider "${id}": missing required field "domains" (non-empty array)`);
157
+ } else {
158
+ for (const d of raw.domains) {
159
+ if (typeof d !== 'string') {
160
+ errors.push(`Provider "${id}": domains must be strings`);
161
+ break;
162
+ }
163
+ }
164
+ }
165
+
166
+ if (typeof raw.strategy !== 'string') {
167
+ errors.push(`Provider "${id}": missing required field "strategy"`);
168
+ } else if (!VALID_STRATEGIES.includes(raw.strategy as StrategyName)) {
169
+ errors.push(
170
+ `Provider "${id}": invalid strategy "${raw.strategy}". ` +
171
+ `Valid strategies: ${VALID_STRATEGIES.join(', ')}`,
172
+ );
173
+ }
174
+
175
+ // Validate forceVisible at provider level
176
+ if (raw.forceVisible !== undefined && typeof raw.forceVisible !== 'boolean') {
177
+ errors.push(`Provider "${id}": forceVisible must be a boolean`);
178
+ }
179
+
180
+ // Validate strategy-specific config shape
181
+ if (typeof raw.strategy === 'string' && raw.config && typeof raw.config === 'object') {
182
+ const strategyErrors = validateStrategyConfig(
183
+ id,
184
+ raw.strategy as StrategyName,
185
+ raw.config as Record<string, unknown>,
186
+ );
187
+ errors.push(...strategyErrors);
188
+ }
189
+
190
+ return errors;
191
+ }
192
+
193
+ function validateStrategyConfig(
194
+ id: string,
195
+ strategy: StrategyName,
196
+ config: Record<string, unknown>,
197
+ ): string[] {
198
+ const errors: string[] = [];
199
+
200
+ // Cross-strategy field checks: warn about fields that don't belong
201
+ if (strategy === 'cookie') {
202
+ const oauthFields = ['audiences', 'tokenEndpoint', 'clientId', 'scopes'];
203
+ for (const field of oauthFields) {
204
+ if (config[field] !== undefined) {
205
+ errors.push(
206
+ `Provider "${id}": config.${field} is not valid for strategy "cookie"`,
207
+ );
208
+ }
209
+ }
210
+ }
211
+
212
+ if (strategy === 'oauth2') {
213
+ const cookieFields = ['ttl', 'requiredCookies'];
214
+ for (const field of cookieFields) {
215
+ if (config[field] !== undefined) {
216
+ errors.push(
217
+ `Provider "${id}": config.${field} is not valid for strategy "oauth2"`,
218
+ );
219
+ }
220
+ }
221
+ }
222
+
223
+ return errors;
224
+ }
225
+
226
+ /**
227
+ * Merge a provider entry's strategy + config into a typed StrategyConfig.
228
+ */
229
+ export function buildStrategyConfig(
230
+ strategy: StrategyName,
231
+ config?: Record<string, unknown>,
232
+ ): StrategyConfig {
233
+ const c = config ?? {};
234
+
235
+ switch (strategy) {
236
+ case 'cookie':
237
+ return {
238
+ strategy: 'cookie',
239
+ ...(typeof c.ttl === 'string' ? { ttl: c.ttl } : {}),
240
+ ...(Array.isArray(c.requiredCookies) ? { requiredCookies: c.requiredCookies as string[] } : {}),
241
+ };
242
+
243
+ case 'oauth2':
244
+ return {
245
+ strategy: 'oauth2',
246
+ ...(Array.isArray(c.audiences) ? { audiences: c.audiences as string[] } : {}),
247
+ ...(typeof c.tokenEndpoint === 'string' ? { tokenEndpoint: c.tokenEndpoint } : {}),
248
+ ...(typeof c.clientId === 'string' ? { clientId: c.clientId } : {}),
249
+ ...(Array.isArray(c.scopes) ? { scopes: c.scopes as string[] } : {}),
250
+ };
251
+
252
+ case 'api-token':
253
+ return {
254
+ strategy: 'api-token',
255
+ ...(typeof c.headerName === 'string' ? { headerName: c.headerName } : {}),
256
+ ...(typeof c.headerPrefix === 'string' ? { headerPrefix: c.headerPrefix } : {}),
257
+ ...(typeof c.setupInstructions === 'string' ? { setupInstructions: c.setupInstructions } : {}),
258
+ };
259
+
260
+ case 'basic':
261
+ return {
262
+ strategy: 'basic',
263
+ ...(typeof c.setupInstructions === 'string' ? { setupInstructions: c.setupInstructions } : {}),
264
+ };
265
+ }
266
+ }
267
+
268
+ function parseProviderEntry(raw: Record<string, unknown>): ProviderEntry {
269
+ return {
270
+ ...(typeof raw.name === 'string' ? { name: raw.name } : {}),
271
+ domains: raw.domains as string[],
272
+ ...(typeof raw.entryUrl === 'string' ? { entryUrl: raw.entryUrl } : {}),
273
+ strategy: raw.strategy as StrategyName,
274
+ ...(raw.config && typeof raw.config === 'object' ? { config: raw.config as Record<string, unknown> } : {}),
275
+ ...(Array.isArray(raw.acceptedCredentialTypes) ? { acceptedCredentialTypes: raw.acceptedCredentialTypes } : {}),
276
+ ...(typeof raw.setupInstructions === 'string' ? { setupInstructions: raw.setupInstructions } : {}),
277
+ ...(typeof raw.credentialFile === 'string' ? { credentialFile: raw.credentialFile } : {}),
278
+ ...(Array.isArray(raw.xHeaders) ? { xHeaders: raw.xHeaders } : {}),
279
+ ...(typeof raw.forceVisible === 'boolean' ? { forceVisible: raw.forceVisible } : {}),
280
+ };
281
+ }