@ggui-ai/mcp-server 0.1.0-rc.1

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 (141) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +48 -0
  3. package/dist/admin-blueprints-transport.d.ts +114 -0
  4. package/dist/admin-blueprints-transport.d.ts.map +1 -0
  5. package/dist/admin-blueprints-transport.js +118 -0
  6. package/dist/admin-oauth-providers-transport.d.ts +40 -0
  7. package/dist/admin-oauth-providers-transport.d.ts.map +1 -0
  8. package/dist/admin-oauth-providers-transport.js +263 -0
  9. package/dist/auth.d.ts +39 -0
  10. package/dist/auth.d.ts.map +1 -0
  11. package/dist/auth.js +75 -0
  12. package/dist/build-mcp.d.ts +128 -0
  13. package/dist/build-mcp.d.ts.map +1 -0
  14. package/dist/build-mcp.js +113 -0
  15. package/dist/code-store-fs.d.ts +19 -0
  16. package/dist/code-store-fs.d.ts.map +1 -0
  17. package/dist/code-store-fs.js +98 -0
  18. package/dist/console-auth.d.ts +139 -0
  19. package/dist/console-auth.d.ts.map +1 -0
  20. package/dist/console-auth.js +102 -0
  21. package/dist/console-cache.d.ts +78 -0
  22. package/dist/console-cache.d.ts.map +1 -0
  23. package/dist/console-cache.js +105 -0
  24. package/dist/console-headers.d.ts +124 -0
  25. package/dist/console-headers.d.ts.map +1 -0
  26. package/dist/console-headers.js +49 -0
  27. package/dist/console-llm-trace.d.ts +66 -0
  28. package/dist/console-llm-trace.d.ts.map +1 -0
  29. package/dist/console-llm-trace.js +105 -0
  30. package/dist/console-payloads.d.ts +67 -0
  31. package/dist/console-payloads.d.ts.map +1 -0
  32. package/dist/console-payloads.js +105 -0
  33. package/dist/console-theme-routes.d.ts +111 -0
  34. package/dist/console-theme-routes.d.ts.map +1 -0
  35. package/dist/console-theme-routes.js +202 -0
  36. package/dist/console-timeline.d.ts +45 -0
  37. package/dist/console-timeline.d.ts.map +1 -0
  38. package/dist/console-timeline.js +169 -0
  39. package/dist/console-validator.d.ts +67 -0
  40. package/dist/console-validator.d.ts.map +1 -0
  41. package/dist/console-validator.js +105 -0
  42. package/dist/console-welcome.d.ts +7 -0
  43. package/dist/console-welcome.d.ts.map +1 -0
  44. package/dist/console-welcome.js +221 -0
  45. package/dist/csrf-middleware.d.ts +55 -0
  46. package/dist/csrf-middleware.d.ts.map +1 -0
  47. package/dist/csrf-middleware.js +138 -0
  48. package/dist/email-login.d.ts +174 -0
  49. package/dist/email-login.d.ts.map +1 -0
  50. package/dist/email-login.js +254 -0
  51. package/dist/email-resend.d.ts +29 -0
  52. package/dist/email-resend.d.ts.map +1 -0
  53. package/dist/email-resend.js +71 -0
  54. package/dist/email-sender-from-env.d.ts +34 -0
  55. package/dist/email-sender-from-env.d.ts.map +1 -0
  56. package/dist/email-sender-from-env.js +112 -0
  57. package/dist/email-smtp.d.ts +42 -0
  58. package/dist/email-smtp.d.ts.map +1 -0
  59. package/dist/email-smtp.js +81 -0
  60. package/dist/index.d.ts +102 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +122 -0
  63. package/dist/instructions-presets.d.ts +112 -0
  64. package/dist/instructions-presets.d.ts.map +1 -0
  65. package/dist/instructions-presets.js +195 -0
  66. package/dist/llm-backed-negotiator.d.ts +178 -0
  67. package/dist/llm-backed-negotiator.d.ts.map +1 -0
  68. package/dist/llm-backed-negotiator.js +579 -0
  69. package/dist/logger.d.ts +23 -0
  70. package/dist/logger.d.ts.map +1 -0
  71. package/dist/logger.js +41 -0
  72. package/dist/mcp-apps-inbound.d.ts +86 -0
  73. package/dist/mcp-apps-inbound.d.ts.map +1 -0
  74. package/dist/mcp-apps-inbound.js +278 -0
  75. package/dist/mcp-apps-outbound.d.ts +448 -0
  76. package/dist/mcp-apps-outbound.d.ts.map +1 -0
  77. package/dist/mcp-apps-outbound.js +1163 -0
  78. package/dist/mcp-mounts.d.ts +239 -0
  79. package/dist/mcp-mounts.d.ts.map +1 -0
  80. package/dist/mcp-mounts.js +222 -0
  81. package/dist/oauth-login-types.d.ts +160 -0
  82. package/dist/oauth-login-types.d.ts.map +1 -0
  83. package/dist/oauth-login-types.js +9 -0
  84. package/dist/oauth-login.d.ts +77 -0
  85. package/dist/oauth-login.d.ts.map +1 -0
  86. package/dist/oauth-login.js +455 -0
  87. package/dist/oauth-providers/github.d.ts +17 -0
  88. package/dist/oauth-providers/github.d.ts.map +1 -0
  89. package/dist/oauth-providers/github.js +89 -0
  90. package/dist/oauth-providers/google.d.ts +18 -0
  91. package/dist/oauth-providers/google.d.ts.map +1 -0
  92. package/dist/oauth-providers/google.js +59 -0
  93. package/dist/oauth-providers-store.d.ts +32 -0
  94. package/dist/oauth-providers-store.d.ts.map +1 -0
  95. package/dist/oauth-providers-store.js +291 -0
  96. package/dist/oauth.d.ts +347 -0
  97. package/dist/oauth.d.ts.map +1 -0
  98. package/dist/oauth.js +686 -0
  99. package/dist/pairing-transport.d.ts +99 -0
  100. package/dist/pairing-transport.d.ts.map +1 -0
  101. package/dist/pairing-transport.js +223 -0
  102. package/dist/rate-limit-middleware.d.ts +36 -0
  103. package/dist/rate-limit-middleware.d.ts.map +1 -0
  104. package/dist/rate-limit-middleware.js +57 -0
  105. package/dist/render-gate.d.ts +87 -0
  106. package/dist/render-gate.d.ts.map +1 -0
  107. package/dist/render-gate.js +77 -0
  108. package/dist/render-rate-limit.d.ts +59 -0
  109. package/dist/render-rate-limit.d.ts.map +1 -0
  110. package/dist/render-rate-limit.js +73 -0
  111. package/dist/render-signing.d.ts +98 -0
  112. package/dist/render-signing.d.ts.map +1 -0
  113. package/dist/render-signing.js +113 -0
  114. package/dist/request-context.d.ts +113 -0
  115. package/dist/request-context.d.ts.map +1 -0
  116. package/dist/request-context.js +154 -0
  117. package/dist/reserved-validators.d.ts +22 -0
  118. package/dist/reserved-validators.d.ts.map +1 -0
  119. package/dist/reserved-validators.js +101 -0
  120. package/dist/schema-compat.d.ts +167 -0
  121. package/dist/schema-compat.d.ts.map +1 -0
  122. package/dist/schema-compat.js +187 -0
  123. package/dist/security-headers-middleware.d.ts +38 -0
  124. package/dist/security-headers-middleware.d.ts.map +1 -0
  125. package/dist/security-headers-middleware.js +30 -0
  126. package/dist/server.d.ts +2060 -0
  127. package/dist/server.d.ts.map +1 -0
  128. package/dist/server.js +6338 -0
  129. package/dist/session-channel.d.ts +651 -0
  130. package/dist/session-channel.d.ts.map +1 -0
  131. package/dist/session-channel.js +1756 -0
  132. package/dist/storage.d.ts +89 -0
  133. package/dist/storage.d.ts.map +1 -0
  134. package/dist/storage.js +171 -0
  135. package/dist/thread-transport.d.ts +118 -0
  136. package/dist/thread-transport.d.ts.map +1 -0
  137. package/dist/thread-transport.js +478 -0
  138. package/dist/user-session-auth.d.ts +167 -0
  139. package/dist/user-session-auth.d.ts.map +1 -0
  140. package/dist/user-session-auth.js +148 -0
  141. package/package.json +76 -0
@@ -0,0 +1,59 @@
1
+ const DEFAULT_SCOPES = ['openid', 'email', 'profile'];
2
+ const AUTHORIZE_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
3
+ const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
4
+ export function googleLoginProvider(opts) {
5
+ const scopes = opts.scopes ?? DEFAULT_SCOPES;
6
+ const fetchImpl = opts.fetch ?? globalThis.fetch;
7
+ return {
8
+ providerId: 'google',
9
+ displayName: 'Google',
10
+ authorizeUrl(input) {
11
+ const params = new URLSearchParams({
12
+ response_type: 'code',
13
+ client_id: opts.clientId,
14
+ redirect_uri: input.redirectUri,
15
+ scope: scopes.join(' '),
16
+ state: input.state,
17
+ code_challenge: input.codeChallenge,
18
+ code_challenge_method: 'S256',
19
+ access_type: 'online',
20
+ prompt: 'consent',
21
+ });
22
+ return `${AUTHORIZE_ENDPOINT}?${params.toString()}`;
23
+ },
24
+ async exchangeCode(input) {
25
+ const body = new URLSearchParams({
26
+ code: input.code,
27
+ client_id: opts.clientId,
28
+ client_secret: opts.clientSecret,
29
+ redirect_uri: input.redirectUri,
30
+ grant_type: 'authorization_code',
31
+ code_verifier: input.codeVerifier,
32
+ });
33
+ const res = await fetchImpl(TOKEN_ENDPOINT, {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
36
+ body: body.toString(),
37
+ });
38
+ if (!res.ok) {
39
+ const text = await res.text();
40
+ throw new Error(`google_token_exchange_failed: ${res.status} ${text.slice(0, 200)}`);
41
+ }
42
+ const tokens = (await res.json());
43
+ const payload = decodeIdTokenPayload(tokens.id_token);
44
+ return {
45
+ providerSubject: payload.sub,
46
+ email: payload.email,
47
+ displayName: payload.name,
48
+ };
49
+ },
50
+ };
51
+ }
52
+ function decodeIdTokenPayload(idToken) {
53
+ const parts = idToken.split('.');
54
+ if (parts.length !== 3) {
55
+ throw new Error('google_id_token_malformed');
56
+ }
57
+ const json = Buffer.from(parts[1], 'base64url').toString('utf8');
58
+ return JSON.parse(json);
59
+ }
@@ -0,0 +1,32 @@
1
+ import type { OAuthProviderConfigRecord } from './oauth-login-types.js';
2
+ import type { Logger } from './logger.js';
3
+ export interface OAuthProvidersStoreOptions {
4
+ /** Override file path. Defaults to `~/.ggui/oauth-providers.json`. */
5
+ readonly filePath?: string;
6
+ /** Test seam: process.env replacement. Defaults to `process.env`. */
7
+ readonly env?: Record<string, string | undefined>;
8
+ readonly logger: Logger;
9
+ }
10
+ export interface PutInput {
11
+ readonly providerId: string;
12
+ readonly clientId: string;
13
+ readonly clientSecret: string;
14
+ readonly enabled?: boolean;
15
+ }
16
+ export interface OAuthProvidersStore {
17
+ /** Read all records merged: env-overrides win over file values. */
18
+ list(): Promise<ReadonlyArray<OAuthProviderConfigRecord>>;
19
+ /** Look up one provider by id. Returns null if absent / disabled. */
20
+ get(providerId: string): Promise<OAuthProviderConfigRecord | null>;
21
+ /**
22
+ * Persist or update a file-backed record. Throws on attempt to
23
+ * write a record whose providerId is currently env-overridden.
24
+ */
25
+ put(input: PutInput): Promise<OAuthProviderConfigRecord>;
26
+ /** Toggle enabled. Throws on env-overridden record. */
27
+ setEnabled(providerId: string, enabled: boolean): Promise<void>;
28
+ /** Delete a file-backed record. No-op on env-overridden record. */
29
+ remove(providerId: string): Promise<void>;
30
+ }
31
+ export declare function createOAuthProvidersStore(opts: OAuthProvidersStoreOptions): OAuthProvidersStore;
32
+ //# sourceMappingURL=oauth-providers-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth-providers-store.d.ts","sourceRoot":"","sources":["../src/oauth-providers-store.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,wBAAwB,CAAC;AACxE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAM1C,MAAM,WAAW,0BAA0B;IACzC,sEAAsE;IACtE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,qEAAqE;IACrE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IAClD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,mBAAmB;IAClC,mEAAmE;IACnE,IAAI,IAAI,OAAO,CAAC,aAAa,CAAC,yBAAyB,CAAC,CAAC,CAAC;IAC1D,qEAAqE;IACrE,GAAG,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,yBAAyB,GAAG,IAAI,CAAC,CAAC;IACnE;;;OAGG;IACH,GAAG,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,yBAAyB,CAAC,CAAC;IACzD,uDAAuD;IACvD,UAAU,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChE,mEAAmE;IACnE,MAAM,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3C;AAuLD,wBAAgB,yBAAyB,CACvC,IAAI,EAAE,0BAA0B,GAC/B,mBAAmB,CA+HrB"}
@@ -0,0 +1,291 @@
1
+ /**
2
+ * OAuth provider config storage.
3
+ *
4
+ * Two-layer storage with env-first override:
5
+ *
6
+ * - File: `~/.ggui/oauth-providers.json` (mode `0o600`, atomic
7
+ * writes via tmp+rename). Operator pastes client_id/secret in
8
+ * `/admin/oauth-providers`; values land here.
9
+ * - Env: `GGUI_OAUTH_<PROVIDERID_UPPERCASE>_CLIENT_ID` +
10
+ * `GGUI_OAUTH_<PROVIDERID_UPPERCASE>_CLIENT_SECRET`. When both
11
+ * are set, the env value wins silently — file value stays on
12
+ * disk untouched. Production deploys use env; the API surfaces
13
+ * env-overridden slots as read-only.
14
+ */
15
+ import { promises as fs } from 'node:fs';
16
+ import * as os from 'node:os';
17
+ import * as path from 'node:path';
18
+ const PROVIDER_ID_RE = /^[a-z][a-z0-9-]*$/;
19
+ const ENV_KEY_RE = /^GGUI_OAUTH_([A-Z0-9_]+)_CLIENT_ID$/;
20
+ const FILE_VERSION = '1';
21
+ function defaultFilePath() {
22
+ return path.join(os.homedir(), '.ggui', 'oauth-providers.json');
23
+ }
24
+ /**
25
+ * Convert an env-key segment to a providerId. The env key uses `_` as
26
+ * the separator (`MY_PROVIDER`), but the providerId convention is
27
+ * kebab-case, so lowercase and convert `_` → `-`.
28
+ */
29
+ function envSegmentToProviderId(segment) {
30
+ return segment.toLowerCase().replace(/_/g, '-');
31
+ }
32
+ function validateProviderId(providerId) {
33
+ if (!PROVIDER_ID_RE.test(providerId)) {
34
+ throw new Error(`oauth_provider_invalid_id: '${providerId}' must match /^[a-z][a-z0-9-]*$/`);
35
+ }
36
+ }
37
+ function envOverrideError(providerId) {
38
+ return new Error(`oauth_provider_env_overridden: ${providerId} has env credentials; remove env vars to edit.`);
39
+ }
40
+ /**
41
+ * Scan the env for `GGUI_OAUTH_<X>_CLIENT_ID` + `_CLIENT_SECRET`
42
+ * pairs. Only emit a record when BOTH halves are set (a half-set
43
+ * pair is operator misconfiguration; we don't silently fall back to
44
+ * the file value). Returns map keyed by providerId.
45
+ */
46
+ function scanEnvOverrides(env) {
47
+ const overrides = new Map();
48
+ for (const key of Object.keys(env)) {
49
+ const match = ENV_KEY_RE.exec(key);
50
+ if (!match)
51
+ continue;
52
+ const segment = match[1];
53
+ if (!segment)
54
+ continue;
55
+ const providerId = envSegmentToProviderId(segment);
56
+ if (!PROVIDER_ID_RE.test(providerId))
57
+ continue;
58
+ const clientId = env[`GGUI_OAUTH_${segment}_CLIENT_ID`];
59
+ const clientSecret = env[`GGUI_OAUTH_${segment}_CLIENT_SECRET`];
60
+ if (!clientId || !clientSecret)
61
+ continue;
62
+ overrides.set(providerId, {
63
+ providerId,
64
+ clientId,
65
+ clientSecret,
66
+ source: 'env',
67
+ enabled: true,
68
+ });
69
+ }
70
+ return overrides;
71
+ }
72
+ async function readFileShape(filePath, logger) {
73
+ let raw;
74
+ try {
75
+ raw = await fs.readFile(filePath, 'utf8');
76
+ }
77
+ catch (err) {
78
+ const e = err;
79
+ if (e.code === 'ENOENT') {
80
+ return { version: FILE_VERSION, providers: [] };
81
+ }
82
+ logger.warn('oauth_providers_file_read_error', {
83
+ filePath,
84
+ error: String(err),
85
+ });
86
+ return { version: FILE_VERSION, providers: [] };
87
+ }
88
+ // File-mode lax check (best-effort; non-POSIX FS may not honor 0600).
89
+ try {
90
+ const stat = await fs.stat(filePath);
91
+ // Only the lower 9 bits encode rwx for owner/group/other.
92
+ const mode = stat.mode & 0o777;
93
+ if (mode > 0o600) {
94
+ logger.warn('oauth_providers_file_lax_mode', {
95
+ filePath,
96
+ mode: mode.toString(8),
97
+ });
98
+ }
99
+ }
100
+ catch {
101
+ // Stat failure isn't load-bearing; we already have the file
102
+ // content. Move on.
103
+ }
104
+ let parsed;
105
+ try {
106
+ parsed = JSON.parse(raw);
107
+ }
108
+ catch (err) {
109
+ logger.warn('oauth_providers_file_corrupt', {
110
+ filePath,
111
+ error: String(err),
112
+ });
113
+ return { version: FILE_VERSION, providers: [] };
114
+ }
115
+ if (!parsed || typeof parsed !== 'object') {
116
+ logger.warn('oauth_providers_file_corrupt', {
117
+ filePath,
118
+ reason: 'root_not_object',
119
+ });
120
+ return { version: FILE_VERSION, providers: [] };
121
+ }
122
+ const obj = parsed;
123
+ if (!Array.isArray(obj.providers)) {
124
+ logger.warn('oauth_providers_file_corrupt', {
125
+ filePath,
126
+ reason: 'providers_not_array',
127
+ });
128
+ return { version: FILE_VERSION, providers: [] };
129
+ }
130
+ const providers = [];
131
+ for (const row of obj.providers) {
132
+ if (!row || typeof row !== 'object')
133
+ continue;
134
+ const r = row;
135
+ if (typeof r['providerId'] !== 'string' ||
136
+ typeof r['clientId'] !== 'string' ||
137
+ typeof r['clientSecret'] !== 'string') {
138
+ continue;
139
+ }
140
+ if (!PROVIDER_ID_RE.test(r['providerId']))
141
+ continue;
142
+ providers.push({
143
+ providerId: r['providerId'],
144
+ clientId: r['clientId'],
145
+ clientSecret: r['clientSecret'],
146
+ enabled: typeof r['enabled'] === 'boolean' ? r['enabled'] : true,
147
+ });
148
+ }
149
+ return { version: FILE_VERSION, providers };
150
+ }
151
+ async function writeFileShape(filePath, shape) {
152
+ const dir = path.dirname(filePath);
153
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
154
+ const tmp = `${filePath}.tmp`;
155
+ // Open with mode 0o600 so even if the rename + chmod race, the
156
+ // tmp file is never world-readable.
157
+ const handle = await fs.open(tmp, 'w', 0o600);
158
+ try {
159
+ await handle.writeFile(JSON.stringify(shape, null, 2), 'utf8');
160
+ // fsync via FileHandle.sync — durable before rename, so a crash
161
+ // mid-write can't leave the operator with a half-written file
162
+ // promoted into place.
163
+ await handle.sync();
164
+ }
165
+ finally {
166
+ await handle.close();
167
+ }
168
+ await fs.rename(tmp, filePath);
169
+ // Belt-and-suspenders: re-chmod after rename in case the target
170
+ // pre-existed at a different mode (rename preserves source mode
171
+ // on Linux but spelling it out keeps the contract obvious).
172
+ try {
173
+ await fs.chmod(filePath, 0o600);
174
+ }
175
+ catch {
176
+ // chmod failure on non-POSIX FS isn't fatal — we already wrote
177
+ // with 0o600 on the tmp file and rename preserves it.
178
+ }
179
+ }
180
+ export function createOAuthProvidersStore(opts) {
181
+ const filePath = opts.filePath ?? defaultFilePath();
182
+ const env = opts.env ?? process.env;
183
+ const logger = opts.logger;
184
+ async function listInternal() {
185
+ const [overrides, file] = await Promise.all([
186
+ Promise.resolve(scanEnvOverrides(env)),
187
+ readFileShape(filePath, logger),
188
+ ]);
189
+ return { overrides, file };
190
+ }
191
+ async function list() {
192
+ const { overrides, file } = await listInternal();
193
+ const merged = [];
194
+ for (const rec of overrides.values()) {
195
+ merged.push(rec);
196
+ }
197
+ for (const rec of file.providers) {
198
+ if (overrides.has(rec.providerId))
199
+ continue;
200
+ merged.push({
201
+ providerId: rec.providerId,
202
+ clientId: rec.clientId,
203
+ clientSecret: rec.clientSecret,
204
+ source: 'file',
205
+ enabled: rec.enabled,
206
+ });
207
+ }
208
+ merged.sort((a, b) => (a.providerId < b.providerId ? -1 : a.providerId > b.providerId ? 1 : 0));
209
+ return merged;
210
+ }
211
+ async function get(providerId) {
212
+ const all = await list();
213
+ const found = all.find((r) => r.providerId === providerId && r.enabled);
214
+ return found ?? null;
215
+ }
216
+ async function put(input) {
217
+ validateProviderId(input.providerId);
218
+ if (input.clientId.length === 0) {
219
+ throw new Error('oauth_provider_invalid_client_id: clientId must be non-empty');
220
+ }
221
+ if (input.clientSecret.length === 0) {
222
+ throw new Error('oauth_provider_invalid_client_secret: clientSecret must be non-empty');
223
+ }
224
+ const overrides = scanEnvOverrides(env);
225
+ if (overrides.has(input.providerId)) {
226
+ throw envOverrideError(input.providerId);
227
+ }
228
+ const file = await readFileShape(filePath, logger);
229
+ const enabled = input.enabled ?? true;
230
+ const next = {
231
+ providerId: input.providerId,
232
+ clientId: input.clientId,
233
+ clientSecret: input.clientSecret,
234
+ enabled,
235
+ };
236
+ const idx = file.providers.findIndex((r) => r.providerId === input.providerId);
237
+ if (idx >= 0) {
238
+ file.providers[idx] = next;
239
+ }
240
+ else {
241
+ file.providers.push(next);
242
+ }
243
+ await writeFileShape(filePath, file);
244
+ return {
245
+ providerId: next.providerId,
246
+ clientId: next.clientId,
247
+ clientSecret: next.clientSecret,
248
+ source: 'file',
249
+ enabled: next.enabled,
250
+ };
251
+ }
252
+ async function setEnabled(providerId, enabled) {
253
+ validateProviderId(providerId);
254
+ const overrides = scanEnvOverrides(env);
255
+ if (overrides.has(providerId)) {
256
+ throw envOverrideError(providerId);
257
+ }
258
+ const file = await readFileShape(filePath, logger);
259
+ const idx = file.providers.findIndex((r) => r.providerId === providerId);
260
+ if (idx < 0) {
261
+ throw new Error(`oauth_provider_not_found: ${providerId} is not stored in the file.`);
262
+ }
263
+ const existing = file.providers[idx];
264
+ if (!existing) {
265
+ throw new Error(`oauth_provider_not_found: ${providerId} is not stored in the file.`);
266
+ }
267
+ file.providers[idx] = {
268
+ providerId: existing.providerId,
269
+ clientId: existing.clientId,
270
+ clientSecret: existing.clientSecret,
271
+ enabled,
272
+ };
273
+ await writeFileShape(filePath, file);
274
+ }
275
+ async function remove(providerId) {
276
+ validateProviderId(providerId);
277
+ const overrides = scanEnvOverrides(env);
278
+ if (overrides.has(providerId)) {
279
+ // Spec: no-op on env-overridden record (file value stays
280
+ // untouched; env continues to win).
281
+ return;
282
+ }
283
+ const file = await readFileShape(filePath, logger);
284
+ const idx = file.providers.findIndex((r) => r.providerId === providerId);
285
+ if (idx < 0)
286
+ return;
287
+ file.providers.splice(idx, 1);
288
+ await writeFileShape(filePath, file);
289
+ }
290
+ return { list, get, put, setEnabled, remove };
291
+ }