expandai 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/build/{esm/Install/Opencode → assets/opencode}/expandai-webfetch.js +22 -4
- package/build/dts/Mcp/RequestHeaders.d.ts +0 -1
- package/build/dts/Mcp/Server.d.ts +1 -2
- package/build/dts/index.d.ts +0 -1
- package/build/esm/index.js +193 -2
- package/build/esm/main.js +691 -200
- package/build/skills/expandai/SKILL.md +100 -8
- package/package.json +20 -12
- package/build/dts/ApiClient.d.ts +0 -165
- package/build/dts/ApiClient.d.ts.map +0 -1
- package/build/dts/Auth/Domain.d.ts +0 -95
- package/build/dts/Auth/Domain.d.ts.map +0 -1
- package/build/dts/Auth/ManualAuthorization.d.ts +0 -107
- package/build/dts/Auth/ManualAuthorization.d.ts.map +0 -1
- package/build/dts/Auth/PendingInit.d.ts +0 -35
- package/build/dts/Auth/PendingInit.d.ts.map +0 -1
- package/build/dts/Auth/Pkce.d.ts +0 -10
- package/build/dts/Auth/Pkce.d.ts.map +0 -1
- package/build/dts/Auth/SetupGrantAuthorization.d.ts +0 -34
- package/build/dts/Auth/SetupGrantAuthorization.d.ts.map +0 -1
- package/build/dts/Authentication.d.ts +0 -24
- package/build/dts/Authentication.d.ts.map +0 -1
- package/build/dts/BrowserOAuth.d.ts +0 -99
- package/build/dts/BrowserOAuth.d.ts.map +0 -1
- package/build/dts/CommandPath.d.ts +0 -2
- package/build/dts/CommandPath.d.ts.map +0 -1
- package/build/dts/Commands/Fetch.d.ts +0 -30
- package/build/dts/Commands/Fetch.d.ts.map +0 -1
- package/build/dts/Commands/Hook.d.ts +0 -17
- package/build/dts/Commands/Hook.d.ts.map +0 -1
- package/build/dts/Commands/Init.d.ts +0 -55
- package/build/dts/Commands/Init.d.ts.map +0 -1
- package/build/dts/Commands/Mcp.d.ts +0 -11
- package/build/dts/Commands/Mcp.d.ts.map +0 -1
- package/build/dts/Commands/McpRun.d.ts +0 -5
- package/build/dts/Commands/McpRun.d.ts.map +0 -1
- package/build/dts/Commands/Search.d.ts +0 -21
- package/build/dts/Commands/Search.d.ts.map +0 -1
- package/build/dts/Commands/Skill.d.ts +0 -12
- package/build/dts/Commands/Skill.d.ts.map +0 -1
- package/build/dts/Commands/Status.d.ts +0 -27
- package/build/dts/Commands/Status.d.ts.map +0 -1
- package/build/dts/Commands/Telemetry.d.ts +0 -7
- package/build/dts/Commands/Telemetry.d.ts.map +0 -1
- package/build/dts/Commands/Uninstall.d.ts +0 -9
- package/build/dts/Commands/Uninstall.d.ts.map +0 -1
- package/build/dts/Config.d.ts +0 -196
- package/build/dts/Config.d.ts.map +0 -1
- package/build/dts/Hook/Claude.d.ts +0 -13
- package/build/dts/Hook/Claude.d.ts.map +0 -1
- package/build/dts/Hook/Errors.d.ts +0 -20
- package/build/dts/Hook/Errors.d.ts.map +0 -1
- package/build/dts/Hook/Opencode.d.ts +0 -13
- package/build/dts/Hook/Opencode.d.ts.map +0 -1
- package/build/dts/Hook/SnapshotNote.d.ts +0 -12
- package/build/dts/Hook/SnapshotNote.d.ts.map +0 -1
- package/build/dts/Init/ClaudeAdapter.d.ts +0 -61
- package/build/dts/Init/ClaudeAdapter.d.ts.map +0 -1
- package/build/dts/Init/ClaudeProfile.d.ts +0 -77
- package/build/dts/Init/ClaudeProfile.d.ts.map +0 -1
- package/build/dts/Init/Domain.d.ts +0 -139
- package/build/dts/Init/Domain.d.ts.map +0 -1
- package/build/dts/Init/HarnessAdapter.d.ts +0 -33
- package/build/dts/Init/HarnessAdapter.d.ts.map +0 -1
- package/build/dts/Init/HarnessDetector.d.ts +0 -19
- package/build/dts/Init/HarnessDetector.d.ts.map +0 -1
- package/build/dts/Init/HarnessRegistry.d.ts +0 -50
- package/build/dts/Init/HarnessRegistry.d.ts.map +0 -1
- package/build/dts/Init/MutationTransaction.d.ts +0 -96
- package/build/dts/Init/MutationTransaction.d.ts.map +0 -1
- package/build/dts/Init/Service.d.ts +0 -72
- package/build/dts/Init/Service.d.ts.map +0 -1
- package/build/dts/Init/StandardAdapters.d.ts +0 -42
- package/build/dts/Init/StandardAdapters.d.ts.map +0 -1
- package/build/dts/Init/Status.d.ts +0 -149
- package/build/dts/Init/Status.d.ts.map +0 -1
- package/build/dts/Install/Claude/Hook.d.ts +0 -88
- package/build/dts/Install/Claude/Hook.d.ts.map +0 -1
- package/build/dts/Install/Claude/Mcp.d.ts +0 -48
- package/build/dts/Install/Claude/Mcp.d.ts.map +0 -1
- package/build/dts/Install/Claude/Skill.d.ts +0 -23
- package/build/dts/Install/Claude/Skill.d.ts.map +0 -1
- package/build/dts/Install/Codex/Mcp.d.ts +0 -45
- package/build/dts/Install/Codex/Mcp.d.ts.map +0 -1
- package/build/dts/Install/Codex/Skill.d.ts +0 -22
- package/build/dts/Install/Codex/Skill.d.ts.map +0 -1
- package/build/dts/Install/Cursor/Mcp.d.ts +0 -45
- package/build/dts/Install/Cursor/Mcp.d.ts.map +0 -1
- package/build/dts/Install/Cursor/Skill.d.ts +0 -29
- package/build/dts/Install/Cursor/Skill.d.ts.map +0 -1
- package/build/dts/Install/Domain.d.ts +0 -103
- package/build/dts/Install/Domain.d.ts.map +0 -1
- package/build/dts/Install/Hook.d.ts +0 -30
- package/build/dts/Install/Hook.d.ts.map +0 -1
- package/build/dts/Install/Mcp.d.ts +0 -28
- package/build/dts/Install/Mcp.d.ts.map +0 -1
- package/build/dts/Install/Opencode/Hook.d.ts +0 -24
- package/build/dts/Install/Opencode/Hook.d.ts.map +0 -1
- package/build/dts/Install/Opencode/Mcp.d.ts +0 -47
- package/build/dts/Install/Opencode/Mcp.d.ts.map +0 -1
- package/build/dts/Install/Opencode/Skill.d.ts +0 -26
- package/build/dts/Install/Opencode/Skill.d.ts.map +0 -1
- package/build/dts/Install/Skill.d.ts +0 -32
- package/build/dts/Install/Skill.d.ts.map +0 -1
- package/build/dts/Install/Uninstall.d.ts +0 -32
- package/build/dts/Install/Uninstall.d.ts.map +0 -1
- package/build/dts/MarkdownAnsi.d.ts +0 -29
- package/build/dts/MarkdownAnsi.d.ts.map +0 -1
- package/build/dts/Mcp/ExpandDocs.d.ts +0 -19
- package/build/dts/Mcp/ExpandDocs.d.ts.map +0 -1
- package/build/dts/Mcp/RequestHeaders.d.ts.map +0 -1
- package/build/dts/Mcp/Server.d.ts.map +0 -1
- package/build/dts/Mcp/Toolkit.d.ts +0 -3
- package/build/dts/Mcp/Toolkit.d.ts.map +0 -1
- package/build/dts/Mcp/tools/Citations.d.ts +0 -266
- package/build/dts/Mcp/tools/Citations.d.ts.map +0 -1
- package/build/dts/Mcp/tools/Fetch.d.ts +0 -168
- package/build/dts/Mcp/tools/Fetch.d.ts.map +0 -1
- package/build/dts/SecureFile.d.ts +0 -23
- package/build/dts/SecureFile.d.ts.map +0 -1
- package/build/dts/Snippets.d.ts +0 -18
- package/build/dts/Snippets.d.ts.map +0 -1
- package/build/dts/Telemetry.d.ts +0 -30
- package/build/dts/Telemetry.d.ts.map +0 -1
- package/build/dts/Version.d.ts +0 -2
- package/build/dts/Version.d.ts.map +0 -1
- package/build/dts/Xdg.d.ts +0 -11
- package/build/dts/Xdg.d.ts.map +0 -1
- package/build/dts/index.d.ts.map +0 -1
- package/build/dts/main.d.ts +0 -3
- package/build/dts/main.d.ts.map +0 -1
- package/build/esm/ApiClient.js +0 -305
- package/build/esm/ApiClient.js.map +0 -1
- package/build/esm/Auth/Domain.js +0 -56
- package/build/esm/Auth/Domain.js.map +0 -1
- package/build/esm/Auth/ManualAuthorization.js +0 -253
- package/build/esm/Auth/ManualAuthorization.js.map +0 -1
- package/build/esm/Auth/PendingInit.js +0 -267
- package/build/esm/Auth/PendingInit.js.map +0 -1
- package/build/esm/Auth/Pkce.js +0 -23
- package/build/esm/Auth/Pkce.js.map +0 -1
- package/build/esm/Auth/SetupGrantAuthorization.js +0 -91
- package/build/esm/Auth/SetupGrantAuthorization.js.map +0 -1
- package/build/esm/Authentication.js +0 -143
- package/build/esm/Authentication.js.map +0 -1
- package/build/esm/BrowserOAuth.js +0 -251
- package/build/esm/BrowserOAuth.js.map +0 -1
- package/build/esm/CommandPath.js +0 -31
- package/build/esm/CommandPath.js.map +0 -1
- package/build/esm/Commands/Fetch.js +0 -119
- package/build/esm/Commands/Fetch.js.map +0 -1
- package/build/esm/Commands/Hook.js +0 -20
- package/build/esm/Commands/Hook.js.map +0 -1
- package/build/esm/Commands/Init.js +0 -371
- package/build/esm/Commands/Init.js.map +0 -1
- package/build/esm/Commands/Mcp.js +0 -19
- package/build/esm/Commands/Mcp.js.map +0 -1
- package/build/esm/Commands/McpRun.js +0 -15
- package/build/esm/Commands/McpRun.js.map +0 -1
- package/build/esm/Commands/Search.js +0 -46
- package/build/esm/Commands/Search.js.map +0 -1
- package/build/esm/Commands/Skill.js +0 -17
- package/build/esm/Commands/Skill.js.map +0 -1
- package/build/esm/Commands/Status.js +0 -186
- package/build/esm/Commands/Status.js.map +0 -1
- package/build/esm/Commands/Telemetry.js +0 -23
- package/build/esm/Commands/Telemetry.js.map +0 -1
- package/build/esm/Commands/Uninstall.js +0 -28
- package/build/esm/Commands/Uninstall.js.map +0 -1
- package/build/esm/Config.js +0 -619
- package/build/esm/Config.js.map +0 -1
- package/build/esm/Hook/Claude.js +0 -74
- package/build/esm/Hook/Claude.js.map +0 -1
- package/build/esm/Hook/Errors.js +0 -18
- package/build/esm/Hook/Errors.js.map +0 -1
- package/build/esm/Hook/Opencode.js +0 -50
- package/build/esm/Hook/Opencode.js.map +0 -1
- package/build/esm/Hook/SnapshotNote.js +0 -65
- package/build/esm/Hook/SnapshotNote.js.map +0 -1
- package/build/esm/Init/ClaudeAdapter.js +0 -137
- package/build/esm/Init/ClaudeAdapter.js.map +0 -1
- package/build/esm/Init/ClaudeProfile.js +0 -344
- package/build/esm/Init/ClaudeProfile.js.map +0 -1
- package/build/esm/Init/Domain.js +0 -118
- package/build/esm/Init/Domain.js.map +0 -1
- package/build/esm/Init/HarnessAdapter.js +0 -21
- package/build/esm/Init/HarnessAdapter.js.map +0 -1
- package/build/esm/Init/HarnessDetector.js +0 -65
- package/build/esm/Init/HarnessDetector.js.map +0 -1
- package/build/esm/Init/HarnessRegistry.js +0 -58
- package/build/esm/Init/HarnessRegistry.js.map +0 -1
- package/build/esm/Init/MutationTransaction.js +0 -494
- package/build/esm/Init/MutationTransaction.js.map +0 -1
- package/build/esm/Init/Service.js +0 -319
- package/build/esm/Init/Service.js.map +0 -1
- package/build/esm/Init/StandardAdapters.js +0 -116
- package/build/esm/Init/StandardAdapters.js.map +0 -1
- package/build/esm/Init/Status.js +0 -215
- package/build/esm/Init/Status.js.map +0 -1
- package/build/esm/Install/Claude/Hook.js +0 -77
- package/build/esm/Install/Claude/Hook.js.map +0 -1
- package/build/esm/Install/Claude/Mcp.js +0 -101
- package/build/esm/Install/Claude/Mcp.js.map +0 -1
- package/build/esm/Install/Claude/Skill.js +0 -56
- package/build/esm/Install/Claude/Skill.js.map +0 -1
- package/build/esm/Install/Codex/Mcp.js +0 -97
- package/build/esm/Install/Codex/Mcp.js.map +0 -1
- package/build/esm/Install/Codex/Skill.js +0 -33
- package/build/esm/Install/Codex/Skill.js.map +0 -1
- package/build/esm/Install/Cursor/Mcp.js +0 -74
- package/build/esm/Install/Cursor/Mcp.js.map +0 -1
- package/build/esm/Install/Cursor/Skill.js +0 -40
- package/build/esm/Install/Cursor/Skill.js.map +0 -1
- package/build/esm/Install/Domain.js +0 -162
- package/build/esm/Install/Domain.js.map +0 -1
- package/build/esm/Install/Hook.js +0 -38
- package/build/esm/Install/Hook.js.map +0 -1
- package/build/esm/Install/Mcp.js +0 -40
- package/build/esm/Install/Mcp.js.map +0 -1
- package/build/esm/Install/Opencode/Hook.js +0 -44
- package/build/esm/Install/Opencode/Hook.js.map +0 -1
- package/build/esm/Install/Opencode/Mcp.js +0 -76
- package/build/esm/Install/Opencode/Mcp.js.map +0 -1
- package/build/esm/Install/Opencode/Skill.js +0 -37
- package/build/esm/Install/Opencode/Skill.js.map +0 -1
- package/build/esm/Install/Skill.js +0 -41
- package/build/esm/Install/Skill.js.map +0 -1
- package/build/esm/Install/Uninstall.js +0 -49
- package/build/esm/Install/Uninstall.js.map +0 -1
- package/build/esm/MarkdownAnsi.js +0 -142
- package/build/esm/MarkdownAnsi.js.map +0 -1
- package/build/esm/Mcp/ExpandDocs.js +0 -56
- package/build/esm/Mcp/ExpandDocs.js.map +0 -1
- package/build/esm/Mcp/RequestHeaders.js +0 -13
- package/build/esm/Mcp/RequestHeaders.js.map +0 -1
- package/build/esm/Mcp/Server.js +0 -12
- package/build/esm/Mcp/Server.js.map +0 -1
- package/build/esm/Mcp/Toolkit.js +0 -16
- package/build/esm/Mcp/Toolkit.js.map +0 -1
- package/build/esm/Mcp/tools/Citations.js +0 -193
- package/build/esm/Mcp/tools/Citations.js.map +0 -1
- package/build/esm/Mcp/tools/Fetch.js +0 -165
- package/build/esm/Mcp/tools/Fetch.js.map +0 -1
- package/build/esm/SecureFile.js +0 -36
- package/build/esm/SecureFile.js.map +0 -1
- package/build/esm/Snippets.js +0 -29
- package/build/esm/Snippets.js.map +0 -1
- package/build/esm/Telemetry.js +0 -157
- package/build/esm/Telemetry.js.map +0 -1
- package/build/esm/Version.js +0 -4
- package/build/esm/Version.js.map +0 -1
- package/build/esm/Xdg.js +0 -15
- package/build/esm/Xdg.js.map +0 -1
- package/build/esm/index.js.map +0 -1
- package/build/esm/main.js.map +0 -1
- package/src/ApiClient.ts +0 -450
- package/src/Auth/Domain.ts +0 -63
- package/src/Auth/ManualAuthorization.ts +0 -296
- package/src/Auth/PendingInit.ts +0 -338
- package/src/Auth/Pkce.ts +0 -26
- package/src/Auth/SetupGrantAuthorization.ts +0 -116
- package/src/Authentication.ts +0 -202
- package/src/BrowserOAuth.ts +0 -359
- package/src/CommandPath.ts +0 -30
- package/src/Commands/Fetch.ts +0 -188
- package/src/Commands/Hook.ts +0 -48
- package/src/Commands/Init.ts +0 -489
- package/src/Commands/Mcp.ts +0 -30
- package/src/Commands/McpRun.ts +0 -25
- package/src/Commands/Search.ts +0 -97
- package/src/Commands/Skill.ts +0 -30
- package/src/Commands/Status.ts +0 -228
- package/src/Commands/Telemetry.ts +0 -42
- package/src/Commands/Uninstall.ts +0 -38
- package/src/Config.ts +0 -840
- package/src/Hook/Claude.ts +0 -81
- package/src/Hook/Errors.ts +0 -19
- package/src/Hook/Opencode.ts +0 -58
- package/src/Hook/SnapshotNote.ts +0 -66
- package/src/Init/ClaudeAdapter.ts +0 -191
- package/src/Init/ClaudeProfile.ts +0 -453
- package/src/Init/Domain.ts +0 -153
- package/src/Init/HarnessAdapter.ts +0 -35
- package/src/Init/HarnessDetector.ts +0 -77
- package/src/Init/HarnessRegistry.ts +0 -87
- package/src/Init/MutationTransaction.ts +0 -747
- package/src/Init/Service.ts +0 -385
- package/src/Init/StandardAdapters.ts +0 -141
- package/src/Init/Status.ts +0 -253
- package/src/Install/Claude/Hook.ts +0 -123
- package/src/Install/Claude/Mcp.ts +0 -129
- package/src/Install/Claude/Skill.ts +0 -77
- package/src/Install/Codex/Mcp.ts +0 -114
- package/src/Install/Codex/Skill.ts +0 -44
- package/src/Install/Cursor/Mcp.ts +0 -85
- package/src/Install/Cursor/Skill.ts +0 -51
- package/src/Install/Domain.ts +0 -221
- package/src/Install/Hook.ts +0 -50
- package/src/Install/Mcp.ts +0 -71
- package/src/Install/Opencode/Hook.ts +0 -55
- package/src/Install/Opencode/Mcp.ts +0 -87
- package/src/Install/Opencode/Skill.ts +0 -48
- package/src/Install/Opencode/expandai-webfetch.d.ts +0 -23
- package/src/Install/Opencode/expandai-webfetch.js +0 -56
- package/src/Install/Skill.ts +0 -57
- package/src/Install/Uninstall.ts +0 -82
- package/src/MarkdownAnsi.ts +0 -162
- package/src/Mcp/ExpandDocs.ts +0 -62
- package/src/Mcp/RequestHeaders.ts +0 -16
- package/src/Mcp/Server.ts +0 -15
- package/src/Mcp/Toolkit.ts +0 -21
- package/src/Mcp/tools/Citations.ts +0 -219
- package/src/Mcp/tools/Fetch.ts +0 -203
- package/src/SecureFile.ts +0 -70
- package/src/Snippets.ts +0 -39
- package/src/Telemetry.ts +0 -186
- package/src/Version.ts +0 -3
- package/src/Xdg.ts +0 -18
- package/src/index.ts +0 -1
- package/src/main.ts +0 -305
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { Effect, Redacted, Schema } from 'effect'
|
|
2
|
-
import { ConfigManager } from '../Config.js'
|
|
3
|
-
import {
|
|
4
|
-
ManualAuthorization,
|
|
5
|
-
ManualAuthorizationCredentialSaveFailed,
|
|
6
|
-
ManualAuthorizationDenied,
|
|
7
|
-
ManualAuthorizationRefused,
|
|
8
|
-
} from './ManualAuthorization.js'
|
|
9
|
-
|
|
10
|
-
interface SetupGrantAuthorizationInput {
|
|
11
|
-
readonly setupToken: Redacted.Redacted<string>
|
|
12
|
-
readonly harness: 'agent-harnesses'
|
|
13
|
-
readonly scope: 'project' | 'global'
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export class SetupGrantAuthorizationError extends Schema.TaggedError<SetupGrantAuthorizationError>()(
|
|
17
|
-
'SetupGrantAuthorizationError',
|
|
18
|
-
{
|
|
19
|
-
code: Schema.Literal(
|
|
20
|
-
'invalid_or_expired',
|
|
21
|
-
'stored_credential_conflict',
|
|
22
|
-
'request_failed',
|
|
23
|
-
'denied',
|
|
24
|
-
'credential_save_failed',
|
|
25
|
-
'refused',
|
|
26
|
-
'config_unreadable',
|
|
27
|
-
),
|
|
28
|
-
recoverable: Schema.Boolean,
|
|
29
|
-
},
|
|
30
|
-
) {
|
|
31
|
-
get message() {
|
|
32
|
-
switch (this.code) {
|
|
33
|
-
// Checked before redemption, so the token is untouched and the user can
|
|
34
|
-
// retry with the same one after clearing the stored credential.
|
|
35
|
-
case 'stored_credential_conflict':
|
|
36
|
-
return 'An Expand API key is already stored on this machine, and completing this setup would replace it with no way to recover it. Nothing was changed and your setup prompt is still valid. Run `expandai logout` to clear the stored key, then run this setup command again.'
|
|
37
|
-
case 'request_failed':
|
|
38
|
-
return 'Could not reach Expand to redeem the setup token. Nothing was sent, so the same token is still good — check the network and try again.'
|
|
39
|
-
case 'denied':
|
|
40
|
-
return 'This setup authorization was denied. Copy a fresh setup prompt from the Expand dashboard.'
|
|
41
|
-
// Checked before redemption, so like stored_credential_conflict the token is
|
|
42
|
-
// untouched and the same prompt works once the file is dealt with.
|
|
43
|
-
case 'config_unreadable':
|
|
44
|
-
return 'The Expand config file on this machine could not be read, so setup cannot tell whether completing it would overwrite an existing API key. Nothing was redeemed and your setup prompt is still valid. Run `expandai whoami` to see which file it is, fix or delete it, then run this setup command again.'
|
|
45
|
-
// Server-side misconfiguration: a fresh prompt is refused identically.
|
|
46
|
-
case 'refused':
|
|
47
|
-
return 'Expand refused to redeem this setup token for a reason a fresh prompt cannot fix — most likely an OAuth client, resource, or scope misconfiguration on the server. Do not request another prompt; report this.'
|
|
48
|
-
// The token redeemed — it is spent — but the credentials could not be
|
|
49
|
-
// written locally. A fresh prompt hits the same wall until the local
|
|
50
|
-
// storage problem is fixed, so say that instead of blaming the token.
|
|
51
|
-
case 'credential_save_failed':
|
|
52
|
-
return 'The setup token was redeemed, but the credentials could not be saved on this machine. Fix credential storage — run `expandai whoami` to see the path it uses, and check its permissions — then copy a fresh setup prompt from the Expand dashboard.'
|
|
53
|
-
default:
|
|
54
|
-
return 'The setup token is invalid, expired, or already used. Copy a fresh setup prompt from the Expand dashboard.'
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function setupGrantError(code: typeof SetupGrantAuthorizationError.fields.code.Type): SetupGrantAuthorizationError {
|
|
60
|
-
return new SetupGrantAuthorizationError({
|
|
61
|
-
code,
|
|
62
|
-
recoverable: code !== 'denied' && code !== 'credential_save_failed' && code !== 'refused',
|
|
63
|
-
})
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Dashboard-minted setup: the "token" in the prompt is a pre-approved RFC 8628
|
|
68
|
-
* device code, already bound to the minting user and pinned to their
|
|
69
|
-
* organization server-side. Redeeming is a single poll of the OAuth token
|
|
70
|
-
* endpoint — better-auth owns generation, expiry and the single-use burn, so
|
|
71
|
-
* there is no redemption protocol of ours left to get wrong.
|
|
72
|
-
*/
|
|
73
|
-
export class SetupGrantAuthorization extends Effect.Service<SetupGrantAuthorization>()(
|
|
74
|
-
'@expandai/cli/Auth/SetupGrantAuthorization',
|
|
75
|
-
{
|
|
76
|
-
dependencies: [ConfigManager.Default, ManualAuthorization.Default],
|
|
77
|
-
effect: Effect.gen(function* () {
|
|
78
|
-
const configManager = yield* ConfigManager
|
|
79
|
-
const manualAuthorization = yield* ManualAuthorization
|
|
80
|
-
const authorize = Effect.fn('SetupGrantAuthorization.authorize')(function* (input: SetupGrantAuthorizationInput) {
|
|
81
|
-
// The *file*, specifically. `loadConfig` collapses env and file into one
|
|
82
|
-
// value, so it cannot tell an ambient `EXPAND_API_KEY` — which must
|
|
83
|
-
// never block setup — from a key written by `expandai login`, which
|
|
84
|
-
// saving new credentials would overwrite with no way to recover it.
|
|
85
|
-
//
|
|
86
|
-
// Refused before redemption: nothing has been spent, so the same prompt
|
|
87
|
-
// still works once the user clears the stored key.
|
|
88
|
-
// `readConfigFile` never fails — it reports an unreadable file through
|
|
89
|
-
// `parseFailed` and hands back an empty config — so `Effect.either` here was
|
|
90
|
-
// always Right and the ApiKey check silently passed on a corrupt file. That
|
|
91
|
-
// matters: redemption is single-use, so proceeding would spend the token and
|
|
92
|
-
// *then* fail to save. Refuse first, while the prompt is still good.
|
|
93
|
-
const storedFile = yield* configManager.readConfigFile()
|
|
94
|
-
if (storedFile.parseFailed) return yield* setupGrantError('config_unreadable')
|
|
95
|
-
if (storedFile.config.authMethod?._tag === 'ApiKey') {
|
|
96
|
-
return yield* setupGrantError('stored_credential_conflict')
|
|
97
|
-
}
|
|
98
|
-
yield* Effect.annotateCurrentSpan({ harness: input.harness, scope: input.scope })
|
|
99
|
-
yield* manualAuthorization.redeemPreApproved(input.setupToken).pipe(
|
|
100
|
-
Effect.mapError((error) => {
|
|
101
|
-
if (error instanceof ManualAuthorizationDenied) return setupGrantError('denied')
|
|
102
|
-
if (error instanceof ManualAuthorizationCredentialSaveFailed) {
|
|
103
|
-
return setupGrantError('credential_save_failed')
|
|
104
|
-
}
|
|
105
|
-
if (error instanceof ManualAuthorizationRefused) return setupGrantError('refused')
|
|
106
|
-
if (error._tag === 'ManualAuthorizationApiError' && error.kind === 'request_failed') {
|
|
107
|
-
return setupGrantError('request_failed')
|
|
108
|
-
}
|
|
109
|
-
return setupGrantError('invalid_or_expired')
|
|
110
|
-
}),
|
|
111
|
-
)
|
|
112
|
-
})
|
|
113
|
-
return { authorize } as const
|
|
114
|
-
}),
|
|
115
|
-
},
|
|
116
|
-
) {}
|
package/src/Authentication.ts
DELETED
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
import { Command, Prompt } from '@effect/cli'
|
|
2
|
-
import { Console, DateTime, Duration, Effect, Either, Match, Schema, String } from 'effect'
|
|
3
|
-
import open from 'open'
|
|
4
|
-
import { BrowserOAuthService } from './BrowserOAuth.js'
|
|
5
|
-
import { ApiKey, ApiKeyConfig, type AuthMethod, ConfigManager, isAuthMethodLocallyUsable } from './Config.js'
|
|
6
|
-
|
|
7
|
-
// Re-exported: these moved to Config.ts, where the credential types live, so
|
|
8
|
-
// that credential precedence can consult usability without a cycle.
|
|
9
|
-
export { type AuthMethodState, authMethodState, isAuthMethodLocallyUsable } from './Config.js'
|
|
10
|
-
|
|
11
|
-
import { Uninstall } from './Install/Uninstall.js'
|
|
12
|
-
|
|
13
|
-
/** Render a user-facing description of the current auth state. Pure given DateTime.now. */
|
|
14
|
-
export const formatAuthStatus = Effect.fn('Authentication.formatAuthStatus')(function* (authMethod: AuthMethod) {
|
|
15
|
-
return yield* Match.value(authMethod).pipe(
|
|
16
|
-
Match.tag('ApiKey', () => Effect.succeed('Authenticated via API key.')),
|
|
17
|
-
Match.tag('OAuth', ({ expiresAt }) =>
|
|
18
|
-
Effect.gen(function* () {
|
|
19
|
-
const now = yield* DateTime.now
|
|
20
|
-
const expired = DateTime.lessThan(expiresAt, now)
|
|
21
|
-
const detail = expired
|
|
22
|
-
? `expired ${Duration.format(DateTime.distanceDuration(expiresAt, now))} ago — run \`expandai login\``
|
|
23
|
-
: `expires in ${Duration.format(DateTime.distanceDuration(now, expiresAt))}`
|
|
24
|
-
return `Authenticated via OAuth (${detail}).`
|
|
25
|
-
}),
|
|
26
|
-
),
|
|
27
|
-
Match.exhaustive,
|
|
28
|
-
)
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
export class Authentication extends Effect.Service<Authentication>()('@expandai/cli/Authentication', {
|
|
32
|
-
dependencies: [ConfigManager.Default, BrowserOAuthService.Default, Uninstall.Default],
|
|
33
|
-
effect: Effect.gen(function* () {
|
|
34
|
-
const configManager = yield* ConfigManager
|
|
35
|
-
const browserOAuth = yield* BrowserOAuthService
|
|
36
|
-
const uninstall = yield* Uninstall
|
|
37
|
-
|
|
38
|
-
const hasLocallyUsableCredentials = Effect.fn('Authentication.hasLocallyUsableCredentials')(function* () {
|
|
39
|
-
yield* configManager.ensureSecureStorage()
|
|
40
|
-
const loaded = yield* Effect.either(configManager.loadConfig())
|
|
41
|
-
if (Either.isLeft(loaded)) {
|
|
42
|
-
if (loaded.left._tag === 'NotAuthenticated') return false
|
|
43
|
-
return yield* Effect.fail(loaded.left)
|
|
44
|
-
}
|
|
45
|
-
const now = yield* DateTime.now
|
|
46
|
-
return isAuthMethodLocallyUsable(loaded.right.authMethod, now)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
const apiKeyLogin = Effect.fn('Authentication.apiKeyLogin')(function* () {
|
|
50
|
-
const apiKey = yield* Prompt.password({ message: 'Enter your API Key' })
|
|
51
|
-
yield* configManager.updateConfig({
|
|
52
|
-
authMethod: ApiKeyConfig.make({ apiKey: yield* Schema.validate(ApiKey)(apiKey) }),
|
|
53
|
-
})
|
|
54
|
-
yield* Console.log('API key saved successfully!')
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
const browserLogin = Effect.fnUntraced(function* <R>(
|
|
58
|
-
showAuthorizeUrl: (authorizeUrl: URL) => Effect.Effect<void, unknown, R>,
|
|
59
|
-
) {
|
|
60
|
-
yield* Console.log(
|
|
61
|
-
String.stripMargin(`
|
|
62
|
-
|
|
|
63
|
-
|Starting browser authorization...`),
|
|
64
|
-
)
|
|
65
|
-
yield* browserOAuth.login(showAuthorizeUrl)
|
|
66
|
-
yield* Console.log(
|
|
67
|
-
String.stripMargin(`
|
|
68
|
-
|
|
|
69
|
-
|Successfully authenticated!`),
|
|
70
|
-
)
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
const printAuthorizeUrl = Effect.fnUntraced(function* (authorizeUrl: URL) {
|
|
74
|
-
yield* Console.log(
|
|
75
|
-
String.stripMargin(`
|
|
76
|
-
|
|
|
77
|
-
|Open this URL in a browser:
|
|
78
|
-
| ${authorizeUrl.toString()}
|
|
79
|
-
|
|
|
80
|
-
|Waiting for browser authorization...`),
|
|
81
|
-
)
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
// `init` already asks for harness and scope. Keep browser login on the
|
|
85
|
-
// existing loopback OAuth path without introducing a third Prompt choice.
|
|
86
|
-
const loginWithBrowser = Effect.fn('Authentication.loginWithBrowser')(function* () {
|
|
87
|
-
yield* browserLogin(printAuthorizeUrl)
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
const oauthLogin = Effect.fn('Authentication.oauthLogin')(function* () {
|
|
91
|
-
yield* browserLogin((authorizeUrl) =>
|
|
92
|
-
Effect.gen(function* () {
|
|
93
|
-
yield* Console.log(
|
|
94
|
-
String.stripMargin(`
|
|
95
|
-
|
|
|
96
|
-
|Open this URL in a browser:
|
|
97
|
-
| ${authorizeUrl.toString()}
|
|
98
|
-
|`),
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
const shouldOpen = yield* Prompt.confirm({ message: 'Open browser now?', initial: true })
|
|
102
|
-
if (shouldOpen) {
|
|
103
|
-
yield* Effect.tryPromise(() => open(authorizeUrl.toString())).pipe(
|
|
104
|
-
Effect.catchAll(() => Console.log('Could not open browser automatically — use the URL above.')),
|
|
105
|
-
)
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
yield* Console.log(
|
|
109
|
-
String.stripMargin(`
|
|
110
|
-
|
|
|
111
|
-
|Waiting for browser authorization...`),
|
|
112
|
-
)
|
|
113
|
-
}),
|
|
114
|
-
)
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
const login = Effect.fn('Authentication.login')(function* () {
|
|
118
|
-
yield* configManager.ensureSecureStorage()
|
|
119
|
-
const existing = yield* Effect.either(configManager.loadConfig())
|
|
120
|
-
|
|
121
|
-
if (Either.isRight(existing)) {
|
|
122
|
-
const status = yield* formatAuthStatus(existing.right.authMethod)
|
|
123
|
-
const proceed = yield* Prompt.confirm({
|
|
124
|
-
message: String.stripMargin(`
|
|
125
|
-
|${status}
|
|
126
|
-
|Do you want to re-authenticate?`),
|
|
127
|
-
})
|
|
128
|
-
if (!proceed) {
|
|
129
|
-
return
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const method = yield* Prompt.select({
|
|
134
|
-
message: 'How would you like to authenticate?',
|
|
135
|
-
choices: [
|
|
136
|
-
{ title: 'Browser Login', value: 'oauth' as const, description: 'Authenticate via browser' },
|
|
137
|
-
{ title: 'API Key', value: 'api-key' as const, description: 'Paste your API key' },
|
|
138
|
-
],
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
yield* Match.value(method).pipe(
|
|
142
|
-
Match.when('api-key', () => apiKeyLogin()),
|
|
143
|
-
Match.when('oauth', () => oauthLogin()),
|
|
144
|
-
Match.exhaustive,
|
|
145
|
-
)
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
const whoami = Effect.fn('Authentication.whoami')(function* () {
|
|
149
|
-
const { authMethod } = yield* configManager.loadConfig()
|
|
150
|
-
const text = yield* formatAuthStatus(authMethod)
|
|
151
|
-
yield* Console.log(text)
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
const logout = Effect.fn('Authentication.logout')(function* () {
|
|
155
|
-
yield* configManager.clearConfig()
|
|
156
|
-
|
|
157
|
-
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
158
|
-
yield* Effect.logInfo(
|
|
159
|
-
String.stripMargin(`
|
|
160
|
-
|Skipping install cleanup — \`expandai logout\` is not interactive in this context.
|
|
161
|
-
|Run \`expandai uninstall\` to remove hooks, skills, and MCP entries.`),
|
|
162
|
-
)
|
|
163
|
-
yield* Console.log('Logged out.')
|
|
164
|
-
return
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const cleanup = yield* Prompt.multiSelect({
|
|
168
|
-
message: 'Remove installed expandai integrations?',
|
|
169
|
-
choices: [
|
|
170
|
-
{ title: 'Global hooks', value: { target: 'hook' as const, scope: 'global' as const } },
|
|
171
|
-
{ title: 'Project hooks', value: { target: 'hook' as const, scope: 'project' as const } },
|
|
172
|
-
{ title: 'Global skills', value: { target: 'skill' as const, scope: 'global' as const } },
|
|
173
|
-
{ title: 'Project skills', value: { target: 'skill' as const, scope: 'project' as const } },
|
|
174
|
-
{ title: 'Global MCP entries', value: { target: 'mcp' as const, scope: 'global' as const } },
|
|
175
|
-
{ title: 'Project MCP entries', value: { target: 'mcp' as const, scope: 'project' as const } },
|
|
176
|
-
],
|
|
177
|
-
})
|
|
178
|
-
if (cleanup.length > 0) {
|
|
179
|
-
yield* Effect.forEach(cleanup, (item) => uninstall.uninstall(item), { discard: true })
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
yield* Console.log('Logged out.')
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
return { hasLocallyUsableCredentials, loginWithBrowser, login, whoami, logout } as const
|
|
186
|
-
}),
|
|
187
|
-
}) {}
|
|
188
|
-
|
|
189
|
-
export const LoginCommand = Command.make('login').pipe(
|
|
190
|
-
Command.withDescription('Authenticate with ExpandAI'),
|
|
191
|
-
Command.withHandler(() => Effect.flatMap(Authentication, (auth) => auth.login())),
|
|
192
|
-
)
|
|
193
|
-
|
|
194
|
-
export const WhoamiCommand = Command.make('whoami').pipe(
|
|
195
|
-
Command.withDescription('Show current authentication status'),
|
|
196
|
-
Command.withHandler(() => Effect.flatMap(Authentication, (auth) => auth.whoami())),
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
export const LogoutCommand = Command.make('logout').pipe(
|
|
200
|
-
Command.withDescription('Clear stored credentials and optionally remove installed integrations'),
|
|
201
|
-
Command.withHandler(() => Effect.flatMap(Authentication, (auth) => auth.logout())),
|
|
202
|
-
)
|
package/src/BrowserOAuth.ts
DELETED
|
@@ -1,359 +0,0 @@
|
|
|
1
|
-
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
|
2
|
-
import { HttpClient, HttpClientRequest, HttpClientResponse } from '@effect/platform'
|
|
3
|
-
import { NodeHttpClient } from '@effect/platform-node'
|
|
4
|
-
import { DateTime, Duration, Effect, Inspectable, Redacted, Schema } from 'effect'
|
|
5
|
-
import { codeChallengeFor, createHighEntropySecret } from './Auth/Pkce.js'
|
|
6
|
-
import { ConfigManager, OAuthConfig } from './Config.js'
|
|
7
|
-
|
|
8
|
-
const CLIENT_ID = 'expandai-cli'
|
|
9
|
-
const OAUTH_AUTHORIZE_ENDPOINT = '/api/auth/oauth2/authorize'
|
|
10
|
-
const OAUTH_TOKEN_ENDPOINT = '/api/auth/oauth2/token'
|
|
11
|
-
const OAUTH_SCOPE = 'openid profile email offline_access'
|
|
12
|
-
const LOOPBACK_HOST = '127.0.0.1'
|
|
13
|
-
const OAUTH_LOGIN_TIMEOUT = Duration.minutes(5)
|
|
14
|
-
|
|
15
|
-
const DurationFromSeconds = Schema.transform(Schema.Number, Schema.DurationFromSelf, {
|
|
16
|
-
strict: true,
|
|
17
|
-
decode: Duration.seconds,
|
|
18
|
-
encode: Duration.toSeconds,
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
export class TokenResponse extends Schema.Class<TokenResponse>('BrowserOAuthTokenResponse')({
|
|
22
|
-
access_token: Schema.String,
|
|
23
|
-
refresh_token: Schema.optional(Schema.String),
|
|
24
|
-
expires_in: DurationFromSeconds,
|
|
25
|
-
token_type: Schema.String,
|
|
26
|
-
scope: Schema.optional(Schema.String),
|
|
27
|
-
}) {}
|
|
28
|
-
|
|
29
|
-
export class BrowserOAuthRequestFailed extends Schema.TaggedError<BrowserOAuthRequestFailed>()(
|
|
30
|
-
'BrowserOAuthRequestFailed',
|
|
31
|
-
{
|
|
32
|
-
endpoint: Schema.String,
|
|
33
|
-
error: Schema.Defect,
|
|
34
|
-
status: Schema.optional(Schema.Number),
|
|
35
|
-
},
|
|
36
|
-
) {
|
|
37
|
-
get message() {
|
|
38
|
-
return `OAuth request to ${this.endpoint} failed${this.status ? ` (${this.status})` : ''}: ${Inspectable.format(this.error)}`
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export class BrowserOAuthCallbackError extends Schema.TaggedError<BrowserOAuthCallbackError>()(
|
|
43
|
-
'BrowserOAuthCallbackError',
|
|
44
|
-
{
|
|
45
|
-
error: Schema.String,
|
|
46
|
-
errorDescription: Schema.optional(Schema.String),
|
|
47
|
-
},
|
|
48
|
-
) {
|
|
49
|
-
get message() {
|
|
50
|
-
return this.errorDescription ? `${this.error}: ${this.errorDescription}` : this.error
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export class BrowserOAuthMissingCode extends Schema.TaggedError<BrowserOAuthMissingCode>()(
|
|
55
|
-
'BrowserOAuthMissingCode',
|
|
56
|
-
{},
|
|
57
|
-
) {
|
|
58
|
-
get message() {
|
|
59
|
-
return 'OAuth callback did not include an authorization code.'
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export class BrowserOAuthStateMismatch extends Schema.TaggedError<BrowserOAuthStateMismatch>()(
|
|
64
|
-
'BrowserOAuthStateMismatch',
|
|
65
|
-
{},
|
|
66
|
-
) {
|
|
67
|
-
get message() {
|
|
68
|
-
return 'OAuth callback state did not match the login request.'
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export class BrowserOAuthLoopbackFailed extends Schema.TaggedError<BrowserOAuthLoopbackFailed>()(
|
|
73
|
-
'BrowserOAuthLoopbackFailed',
|
|
74
|
-
{
|
|
75
|
-
error: Schema.Defect,
|
|
76
|
-
},
|
|
77
|
-
) {
|
|
78
|
-
get message() {
|
|
79
|
-
return `Could not start OAuth loopback server: ${Inspectable.format(this.error)}`
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export class BrowserOAuthTimedOut extends Schema.TaggedError<BrowserOAuthTimedOut>()('BrowserOAuthTimedOut', {}) {
|
|
84
|
-
get message() {
|
|
85
|
-
return 'OAuth browser login timed out before the local callback completed.'
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
type CallbackFailure = BrowserOAuthCallbackError | BrowserOAuthMissingCode | BrowserOAuthStateMismatch
|
|
90
|
-
|
|
91
|
-
interface AuthorizeUrlInput {
|
|
92
|
-
readonly apiUrl: URL
|
|
93
|
-
readonly redirectUri: URL
|
|
94
|
-
readonly state: string
|
|
95
|
-
readonly codeChallenge: string
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
interface ExchangeCodeInput {
|
|
99
|
-
readonly code: string
|
|
100
|
-
readonly redirectUri: URL
|
|
101
|
-
readonly codeVerifier: string
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
interface LoopbackServer {
|
|
105
|
-
readonly redirectUri: URL
|
|
106
|
-
readonly waitForCode: Effect.Effect<string, CallbackFailure>
|
|
107
|
-
readonly close: Effect.Effect<void>
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export const createCodeChallenge = Effect.fn('BrowserOAuth.createCodeChallenge')(function* (codeVerifier: string) {
|
|
111
|
-
return codeChallengeFor(Redacted.make(codeVerifier))
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
export const buildAuthorizeUrl = Effect.fn('BrowserOAuth.buildAuthorizeUrl')(function* ({
|
|
115
|
-
apiUrl,
|
|
116
|
-
redirectUri,
|
|
117
|
-
state,
|
|
118
|
-
codeChallenge,
|
|
119
|
-
}: AuthorizeUrlInput) {
|
|
120
|
-
const authorizeUrl = new URL(OAUTH_AUTHORIZE_ENDPOINT, apiUrl)
|
|
121
|
-
authorizeUrl.searchParams.set('response_type', 'code')
|
|
122
|
-
authorizeUrl.searchParams.set('client_id', CLIENT_ID)
|
|
123
|
-
authorizeUrl.searchParams.set('redirect_uri', redirectUri.toString())
|
|
124
|
-
authorizeUrl.searchParams.set('scope', OAUTH_SCOPE)
|
|
125
|
-
authorizeUrl.searchParams.set('code_challenge', codeChallenge)
|
|
126
|
-
authorizeUrl.searchParams.set('code_challenge_method', 'S256')
|
|
127
|
-
authorizeUrl.searchParams.set('state', state)
|
|
128
|
-
return authorizeUrl
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
function respond(response: ServerResponse, status: number, body: string): void {
|
|
132
|
-
response.writeHead(status, { 'content-type': 'text/html; charset=utf-8' })
|
|
133
|
-
response.end(body)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function validateCallback(request: IncomingMessage, expectedState: string): string {
|
|
137
|
-
const callbackUrl = new URL(request.url ?? '/', `http://${LOOPBACK_HOST}`)
|
|
138
|
-
const error = callbackUrl.searchParams.get('error')
|
|
139
|
-
if (error !== null) {
|
|
140
|
-
throw new BrowserOAuthCallbackError({
|
|
141
|
-
error,
|
|
142
|
-
errorDescription: callbackUrl.searchParams.get('error_description') ?? undefined,
|
|
143
|
-
})
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (callbackUrl.searchParams.get('state') !== expectedState) {
|
|
147
|
-
throw new BrowserOAuthStateMismatch()
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const code = callbackUrl.searchParams.get('code')
|
|
151
|
-
if (code === null || code.length === 0) {
|
|
152
|
-
throw new BrowserOAuthMissingCode()
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return code
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function toCallbackFailure(error: unknown): CallbackFailure {
|
|
159
|
-
if (
|
|
160
|
-
error instanceof BrowserOAuthCallbackError ||
|
|
161
|
-
error instanceof BrowserOAuthMissingCode ||
|
|
162
|
-
error instanceof BrowserOAuthStateMismatch
|
|
163
|
-
) {
|
|
164
|
-
return error
|
|
165
|
-
}
|
|
166
|
-
return new BrowserOAuthCallbackError({ error: 'server_error', errorDescription: Inspectable.format(error) })
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function closeServer(server: Server): Effect.Effect<void> {
|
|
170
|
-
return Effect.async<void>((resume) => {
|
|
171
|
-
server.close((error) => {
|
|
172
|
-
if (error) {
|
|
173
|
-
resume(Effect.void)
|
|
174
|
-
return
|
|
175
|
-
}
|
|
176
|
-
resume(Effect.void)
|
|
177
|
-
})
|
|
178
|
-
})
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const startLoopbackServer = Effect.fn('BrowserOAuth.startLoopbackServer')(function* (expectedState: string) {
|
|
182
|
-
return yield* Effect.async<LoopbackServer, BrowserOAuthLoopbackFailed>((resume) => {
|
|
183
|
-
let settled = false
|
|
184
|
-
let resolveCode: (code: string) => void = () => undefined
|
|
185
|
-
let rejectCode: (error: CallbackFailure) => void = () => undefined
|
|
186
|
-
const codePromise = new Promise<string>((resolve, reject) => {
|
|
187
|
-
resolveCode = resolve
|
|
188
|
-
rejectCode = (error) => reject(error)
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
const server = createServer((request, response) => {
|
|
192
|
-
const requestUrl = new URL(request.url ?? '/', `http://${LOOPBACK_HOST}`)
|
|
193
|
-
if (requestUrl.pathname !== '/callback') {
|
|
194
|
-
respond(response, 404, 'Not found')
|
|
195
|
-
return
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (settled) {
|
|
199
|
-
respond(response, 409, 'OAuth callback already handled.')
|
|
200
|
-
return
|
|
201
|
-
}
|
|
202
|
-
settled = true
|
|
203
|
-
|
|
204
|
-
try {
|
|
205
|
-
const code = validateCallback(request, expectedState)
|
|
206
|
-
respond(response, 200, 'Authentication complete. You can close this tab.')
|
|
207
|
-
resolveCode(code)
|
|
208
|
-
} catch (error) {
|
|
209
|
-
respond(response, 400, 'Authentication failed. Return to the terminal and try again.')
|
|
210
|
-
rejectCode(toCallbackFailure(error))
|
|
211
|
-
}
|
|
212
|
-
})
|
|
213
|
-
|
|
214
|
-
server.once('error', (error) => {
|
|
215
|
-
resume(Effect.fail(new BrowserOAuthLoopbackFailed({ error })))
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
server.listen(0, LOOPBACK_HOST, () => {
|
|
219
|
-
const address = server.address()
|
|
220
|
-
if (address === null || typeof address === 'string') {
|
|
221
|
-
resume(Effect.fail(new BrowserOAuthLoopbackFailed({ error: 'loopback server did not report a port' })))
|
|
222
|
-
return
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
resume(
|
|
226
|
-
Effect.succeed({
|
|
227
|
-
redirectUri: new URL(`http://${LOOPBACK_HOST}:${address.port}/callback`),
|
|
228
|
-
waitForCode: Effect.tryPromise({
|
|
229
|
-
try: () => codePromise,
|
|
230
|
-
catch: toCallbackFailure,
|
|
231
|
-
}),
|
|
232
|
-
close: closeServer(server),
|
|
233
|
-
}),
|
|
234
|
-
)
|
|
235
|
-
})
|
|
236
|
-
})
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
export class BrowserOAuthService extends Effect.Service<BrowserOAuthService>()('@expandai/cli/BrowserOAuthService', {
|
|
240
|
-
dependencies: [NodeHttpClient.layer, ConfigManager.Default],
|
|
241
|
-
effect: Effect.gen(function* () {
|
|
242
|
-
const httpClient = yield* HttpClient.HttpClient
|
|
243
|
-
const configManager = yield* ConfigManager
|
|
244
|
-
|
|
245
|
-
const exchangeToken = Effect.fn('BrowserOAuthService.exchangeToken')(function* (
|
|
246
|
-
form: ReadonlyArray<readonly [string, string]>,
|
|
247
|
-
) {
|
|
248
|
-
yield* configManager.ensureSecureStorage()
|
|
249
|
-
const apiUrl = yield* configManager.getApiUrl()
|
|
250
|
-
const resource = apiUrl.toString()
|
|
251
|
-
const request = HttpClientRequest.post(new URL(OAUTH_TOKEN_ENDPOINT, apiUrl)).pipe(
|
|
252
|
-
HttpClientRequest.bodyUrlParams([...form, ['resource', resource]]),
|
|
253
|
-
)
|
|
254
|
-
|
|
255
|
-
return yield* httpClient.execute(request).pipe(
|
|
256
|
-
Effect.flatMap(
|
|
257
|
-
HttpClientResponse.matchStatus({
|
|
258
|
-
'200': (response) => HttpClientResponse.schemaBodyJson(TokenResponse)(response),
|
|
259
|
-
orElse: (response) =>
|
|
260
|
-
response.text.pipe(
|
|
261
|
-
Effect.flatMap((body) =>
|
|
262
|
-
Effect.fail(
|
|
263
|
-
new BrowserOAuthRequestFailed({
|
|
264
|
-
endpoint: OAUTH_TOKEN_ENDPOINT,
|
|
265
|
-
status: response.status,
|
|
266
|
-
error: body,
|
|
267
|
-
}),
|
|
268
|
-
),
|
|
269
|
-
),
|
|
270
|
-
),
|
|
271
|
-
}),
|
|
272
|
-
),
|
|
273
|
-
Effect.catchTags({
|
|
274
|
-
RequestError: (error) =>
|
|
275
|
-
Effect.fail(new BrowserOAuthRequestFailed({ endpoint: OAUTH_TOKEN_ENDPOINT, error })),
|
|
276
|
-
ResponseError: (error) =>
|
|
277
|
-
Effect.fail(new BrowserOAuthRequestFailed({ endpoint: OAUTH_TOKEN_ENDPOINT, error })),
|
|
278
|
-
ParseError: (error) => Effect.fail(new BrowserOAuthRequestFailed({ endpoint: OAUTH_TOKEN_ENDPOINT, error })),
|
|
279
|
-
}),
|
|
280
|
-
)
|
|
281
|
-
})
|
|
282
|
-
|
|
283
|
-
const exchangeCode = Effect.fn('BrowserOAuthService.exchangeCode')(function* ({
|
|
284
|
-
code,
|
|
285
|
-
redirectUri,
|
|
286
|
-
codeVerifier,
|
|
287
|
-
}: ExchangeCodeInput) {
|
|
288
|
-
return yield* exchangeToken([
|
|
289
|
-
['grant_type', 'authorization_code'],
|
|
290
|
-
['client_id', CLIENT_ID],
|
|
291
|
-
['code', code],
|
|
292
|
-
['redirect_uri', redirectUri.toString()],
|
|
293
|
-
['code_verifier', codeVerifier],
|
|
294
|
-
])
|
|
295
|
-
})
|
|
296
|
-
|
|
297
|
-
const refreshToken = Effect.fn('BrowserOAuthService.refreshToken')(function* (
|
|
298
|
-
refreshToken: string,
|
|
299
|
-
clientId: string = CLIENT_ID,
|
|
300
|
-
) {
|
|
301
|
-
return yield* exchangeToken([
|
|
302
|
-
['grant_type', 'refresh_token'],
|
|
303
|
-
['client_id', clientId],
|
|
304
|
-
['refresh_token', refreshToken],
|
|
305
|
-
])
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
const saveToken = Effect.fn('BrowserOAuthService.saveToken')(function* (tokenResponse: TokenResponse) {
|
|
309
|
-
const now = yield* DateTime.now
|
|
310
|
-
const expiresAt = DateTime.addDuration(now, tokenResponse.expires_in)
|
|
311
|
-
|
|
312
|
-
yield* configManager.updateConfig({
|
|
313
|
-
authMethod: OAuthConfig.make({
|
|
314
|
-
accessToken: Redacted.make(tokenResponse.access_token),
|
|
315
|
-
refreshToken:
|
|
316
|
-
tokenResponse.refresh_token === undefined ? undefined : Redacted.make(tokenResponse.refresh_token),
|
|
317
|
-
expiresAt,
|
|
318
|
-
clientId: CLIENT_ID,
|
|
319
|
-
}),
|
|
320
|
-
})
|
|
321
|
-
})
|
|
322
|
-
|
|
323
|
-
const login = Effect.fn('BrowserOAuthService.login')(function* <R>(
|
|
324
|
-
openAuthorizeUrl: (authorizeUrl: URL) => Effect.Effect<void, unknown, R>,
|
|
325
|
-
) {
|
|
326
|
-
yield* configManager.ensureSecureStorage()
|
|
327
|
-
const codeVerifier = createHighEntropySecret()
|
|
328
|
-
const state = createHighEntropySecret()
|
|
329
|
-
const codeChallenge = yield* createCodeChallenge(codeVerifier)
|
|
330
|
-
const loopback = yield* startLoopbackServer(state)
|
|
331
|
-
const apiUrl = yield* configManager.getApiUrl()
|
|
332
|
-
const authorizeUrl = yield* buildAuthorizeUrl({
|
|
333
|
-
apiUrl,
|
|
334
|
-
redirectUri: loopback.redirectUri,
|
|
335
|
-
state,
|
|
336
|
-
codeChallenge,
|
|
337
|
-
})
|
|
338
|
-
|
|
339
|
-
const code = yield* openAuthorizeUrl(authorizeUrl).pipe(
|
|
340
|
-
Effect.zipRight(loopback.waitForCode),
|
|
341
|
-
Effect.timeoutFail({
|
|
342
|
-
duration: OAUTH_LOGIN_TIMEOUT,
|
|
343
|
-
onTimeout: () => new BrowserOAuthTimedOut(),
|
|
344
|
-
}),
|
|
345
|
-
Effect.ensuring(loopback.close),
|
|
346
|
-
)
|
|
347
|
-
const token = yield* exchangeCode({ code, redirectUri: loopback.redirectUri, codeVerifier })
|
|
348
|
-
if (token === undefined) {
|
|
349
|
-
return yield* new BrowserOAuthRequestFailed({
|
|
350
|
-
endpoint: OAUTH_TOKEN_ENDPOINT,
|
|
351
|
-
error: 'OAuth token endpoint returned an empty response body',
|
|
352
|
-
})
|
|
353
|
-
}
|
|
354
|
-
yield* saveToken(token)
|
|
355
|
-
})
|
|
356
|
-
|
|
357
|
-
return { exchangeCode, refreshToken, saveToken, login } as const
|
|
358
|
-
}),
|
|
359
|
-
}) {}
|