@gatemux/dsh-gatemux-login 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/account.js +671 -0
- package/cordis.patch.yml +96 -0
- package/llm.js +103 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GateMux
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# GateMux Sign-In for DeepSeek Harness
|
|
2
|
+
|
|
3
|
+
Sign in to the DeepSeek Harness desktop app with your own GateMux account. Model
|
|
4
|
+
requests are then billed to **your** GateMux balance instead of to the
|
|
5
|
+
application's server key.
|
|
6
|
+
|
|
7
|
+
## What this plugin does
|
|
8
|
+
|
|
9
|
+
It is a DeepSeek Harness **bundle**. Installing it changes three things about the
|
|
10
|
+
composed profile:
|
|
11
|
+
|
|
12
|
+
- Adds a GateMux account service that signs you in through GateMux's OAuth 2.0
|
|
13
|
+
redirect flow (authorization code with PKCE `S256`).
|
|
14
|
+
- Registers a model route, provider id `gatemux-account`, that authenticates every
|
|
15
|
+
request with your own credential.
|
|
16
|
+
- Turns off the shipped vendor account route (`deepseek-account` and
|
|
17
|
+
`llm-deepseek-account`) so there is one account provider instead of two.
|
|
18
|
+
|
|
19
|
+
It also sets the default model to `gatemux/deepseek-v4-flash`. See
|
|
20
|
+
[Changing the default model](#changing-the-default-model) if you would rather
|
|
21
|
+
keep another default.
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- DeepSeek Harness desktop app, `0.1.7.x`.
|
|
26
|
+
The plugin declares `@deepseek-ai/dsh-*` peer dependencies pinned to that line.
|
|
27
|
+
A different DSH release is rejected at install time; installing anyway needs a
|
|
28
|
+
version exemption, which risks crashes and data loss.
|
|
29
|
+
- A GateMux account.
|
|
30
|
+
|
|
31
|
+
No install scripts are run: installation does not execute any build step, and the
|
|
32
|
+
app never asks you to approve package scripts for this plugin.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
In the desktop app:
|
|
37
|
+
|
|
38
|
+
1. Open **插件** (Plugins) in the left sidebar.
|
|
39
|
+
2. Click **添加插件** (Add plugin).
|
|
40
|
+
3. Enter `@gatemux/dsh-gatemux-login` and click **安装** (Install).
|
|
41
|
+
4. When it reports **已安装** (Installed), click **立即启用** (Enable now).
|
|
42
|
+
5. **Restart the app.** The desktop profile is a startup profile, so the new
|
|
43
|
+
bundle is composed at launch, not applied live.
|
|
44
|
+
|
|
45
|
+
The plugin manager writes the dependency and adds the bundle to the profile's
|
|
46
|
+
`dsh.profile.bundles` itself. You do not edit any configuration by hand.
|
|
47
|
+
|
|
48
|
+
From the CLI, for a non-desktop profile:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
dsh plugin --profile <name> add @gatemux/dsh-gatemux-login
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The `desktop` profile is managed exclusively by the Electron application and
|
|
55
|
+
cannot be installed into from the CLI.
|
|
56
|
+
|
|
57
|
+
## Sign in
|
|
58
|
+
|
|
59
|
+
Send a message. Until you are signed in, the GateMux route reports:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
ACCOUNT_SIGN_IN_REQUIRED: Sign in with GateMux to use the GateMux model route.
|
|
63
|
+
Model requests are billed to your own GateMux balance.
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Sign in from the app's account settings. Authorization opens in your browser and
|
|
67
|
+
returns to the app.
|
|
68
|
+
|
|
69
|
+
## Models
|
|
70
|
+
|
|
71
|
+
| Model id | Context | Input |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `gatemux/deepseek-v4-flash` | 1,000,000 | text, image |
|
|
74
|
+
| `gatemux/deepseek-v4-pro` | 1,000,000 | text |
|
|
75
|
+
|
|
76
|
+
Both are served from `https://rest.gatemux.ai/v1`.
|
|
77
|
+
|
|
78
|
+
## Changing the default model
|
|
79
|
+
|
|
80
|
+
The bundle sets the default model in its own patch layer. A profile's own
|
|
81
|
+
`cordis.patch.yml` is applied **after** every bundle layer, so it wins. Selecting
|
|
82
|
+
another model in the app writes exactly that override for you; to set it by hand,
|
|
83
|
+
add to `$DSH_HOME/profiles/<name>/cordis.patch.yml`:
|
|
84
|
+
|
|
85
|
+
```yaml
|
|
86
|
+
- id: agent-default-model
|
|
87
|
+
name: "@deepseek-ai/dsh-agent-default-model"
|
|
88
|
+
config:
|
|
89
|
+
provider: some-other-provider
|
|
90
|
+
model: some-other-model
|
|
91
|
+
reasoningEffort: high
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
A patch `name` is a guard, not a rename: it must match the addressed row's current
|
|
95
|
+
module or the patch is skipped.
|
|
96
|
+
|
|
97
|
+
## Troubleshooting
|
|
98
|
+
|
|
99
|
+
| What you see | Meaning |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `ACCOUNT_SIGN_IN_REQUIRED` | No credential yet. Sign in. |
|
|
102
|
+
| `ACCOUNT_TOKEN_INVALID` | The credential was revoked, expired, or suspended. It has been discarded; sign in again. |
|
|
103
|
+
| `ACCOUNT_QUOTA_EXCEEDED` | Your GateMux balance or spend cap is exhausted. Top up. |
|
|
104
|
+
| `ip_not_allowed` (HTTP 403) | Your network is not in the GateMux application's trusted egress IP allowlist. Contact the plugin operator. Re-authorizing will not fix it. |
|
|
105
|
+
| `scope_denied`, `model_not_allowed` (HTTP 403) | The application registration does not permit this request. Contact the plugin operator. |
|
|
106
|
+
| Install refused for peer dependencies | Your DSH version is outside `0.1.7.x`. |
|
|
107
|
+
|
|
108
|
+
403 errors are reported as authorization faults, not retried: they are
|
|
109
|
+
configuration problems on the application's side.
|
|
110
|
+
|
|
111
|
+
## Credentials and privacy
|
|
112
|
+
|
|
113
|
+
Your GateMux credential is stored locally at
|
|
114
|
+
`$DSH_HOME/.credentials.yaml` under the key `gatemux/account`, with file mode
|
|
115
|
+
`0600`. It never leaves your machine except as the `Authorization: Bearer` header
|
|
116
|
+
on requests to `https://rest.gatemux.ai`.
|
|
117
|
+
|
|
118
|
+
Refresh tokens rotate on every refresh, and the rotated value is persisted
|
|
119
|
+
immediately; a reused rotated refresh token is treated as credential leakage. If
|
|
120
|
+
a token exchange returns `invalid_grant`, that authorization code is voided on the
|
|
121
|
+
server and is not retried.
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
package/account.js
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GateMux OAuth 2.0 account provider.
|
|
3
|
+
*
|
|
4
|
+
* Implements the `ctx.deepseekAccount` contract with the GateMux redirect flow
|
|
5
|
+
* (authorization code + PKCE S256). The user signs in with their own GateMux
|
|
6
|
+
* account, so model requests are billed to the user's balance rather than to
|
|
7
|
+
* the application's server key.
|
|
8
|
+
*
|
|
9
|
+
* Security invariants enforced here:
|
|
10
|
+
* - `code_verifier` is generated per attempt, kept in memory, and never placed
|
|
11
|
+
* in the authorize URL, the `state`, a log line, or an error message.
|
|
12
|
+
* - `state` is compared with a constant-time equality check.
|
|
13
|
+
* - A token exchange that returns `invalid_grant` voids that authorization code
|
|
14
|
+
* on the server, so it is never retried with a corrected verifier.
|
|
15
|
+
* - Refresh tokens rotate on every refresh; the rotated value is persisted
|
|
16
|
+
* immediately and refreshes are single-flight, because reusing a rotated
|
|
17
|
+
* refresh token is treated as credential leakage.
|
|
18
|
+
*
|
|
19
|
+
* @module @gatemux/dsh-gatemux-login/account
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
23
|
+
import { promises as streamPromises } from 'node:stream'
|
|
24
|
+
|
|
25
|
+
import { Service } from '@deepseek-ai/cordis'
|
|
26
|
+
import { credentialKey } from '@deepseek-ai/dsh-credentials'
|
|
27
|
+
import {
|
|
28
|
+
DeepSeekAccount,
|
|
29
|
+
installAccountTaskCancellation,
|
|
30
|
+
} from '@deepseek-ai/dsh-deepseek-account'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deployment defaults. Every one of these is overridable through the row's
|
|
34
|
+
* `config`, so the composition stays the single source of truth and this module
|
|
35
|
+
* carries no deployment-specific literal that a patch cannot correct.
|
|
36
|
+
*/
|
|
37
|
+
const DEFAULT_ISSUER = 'https://rest.gatemux.ai'
|
|
38
|
+
const DEFAULT_CONSOLE_ORIGIN = 'https://gatemux.ai'
|
|
39
|
+
/** Exact callback path registered for this application; loopback permits any port. */
|
|
40
|
+
const CALLBACK_PATH = '/callback'
|
|
41
|
+
const KEY = credentialKey('gatemux', 'account')
|
|
42
|
+
/** Refresh this long before actual expiry so an in-flight request cannot race it. */
|
|
43
|
+
const REFRESH_MARGIN_MS = 60_000
|
|
44
|
+
const ATTEMPT_TIMEOUT_MS = 600_000
|
|
45
|
+
const REQUEST_TIMEOUT_MS = 30_000
|
|
46
|
+
const DEFAULT_SCOPES = ['models:read', 'responses:create', 'media:create', 'account:read', 'openid', 'email']
|
|
47
|
+
|
|
48
|
+
/** Local attempt failure taxonomy; the abstract contract renders these through locale copy. */
|
|
49
|
+
class GateMuxAuthError extends Error {
|
|
50
|
+
/** @param message - diagnostic text without credentials. @param code - stable failure class. */
|
|
51
|
+
constructor(message, code) {
|
|
52
|
+
super(message)
|
|
53
|
+
this.name = 'GateMuxAuthError'
|
|
54
|
+
this.code = code
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Validate the stored grant without trusting its shape. */
|
|
59
|
+
function parseGrant(payload, issuer) {
|
|
60
|
+
if (typeof payload !== 'object' || payload === null) return null
|
|
61
|
+
const { version, accessToken, refreshToken, expiresAt } = payload
|
|
62
|
+
if (version !== 1) return null
|
|
63
|
+
if (typeof accessToken !== 'string' || accessToken.length === 0) return null
|
|
64
|
+
if (typeof refreshToken !== 'string' || refreshToken.length === 0) return null
|
|
65
|
+
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return null
|
|
66
|
+
// A grant minted by any other issuer is not one this deployment may use.
|
|
67
|
+
if (payload.issuer !== issuer) return null
|
|
68
|
+
return { version: 1, accessToken, refreshToken, expiresAt, issuer }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Accept a bare HTTPS origin, rejecting paths, credentials, and non-HTTPS schemes. */
|
|
72
|
+
function originOf(value) {
|
|
73
|
+
let url
|
|
74
|
+
try { url = new URL(value) } catch { throw new GateMuxAuthError('origin must be an absolute URL', 'protocol') }
|
|
75
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
|
76
|
+
throw new GateMuxAuthError('origin must be a bare HTTPS origin without a path', 'protocol')
|
|
77
|
+
}
|
|
78
|
+
return url.origin
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Accept a browser-accessible loopback HTTP origin, mirroring the account seam's expectation. */
|
|
82
|
+
function loginOrigin(value) {
|
|
83
|
+
const explicitPort = /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):([0-9]+)\/?$/i.exec(value)?.[1]
|
|
84
|
+
if (explicitPort === undefined || Number(explicitPort) === 0) {
|
|
85
|
+
throw new GateMuxAuthError('callback origin must be loopback HTTP with an explicit port', 'protocol')
|
|
86
|
+
}
|
|
87
|
+
return `http://${new URL(value).hostname}:${Number(explicitPort)}`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Extract the RFC 6749 §5.2 error code from a token-endpoint response body.
|
|
92
|
+
* The token endpoint uses a flat envelope, unlike the nested API envelope.
|
|
93
|
+
*/
|
|
94
|
+
function tokenErrorCode(body) {
|
|
95
|
+
if (typeof body !== 'object' || body === null) return undefined
|
|
96
|
+
return typeof body.error === 'string' ? body.error : undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Extract the machine code from a nested API error envelope. */
|
|
100
|
+
function apiErrorCode(body) {
|
|
101
|
+
if (typeof body !== 'object' || body === null) return undefined
|
|
102
|
+
const error = body.error
|
|
103
|
+
if (typeof error !== 'object' || error === null) return undefined
|
|
104
|
+
return typeof error.code === 'string' ? error.code : undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** GateMux login state, stored grant, and refresh rotation. */
|
|
108
|
+
export class GateMuxAccount extends DeepSeekAccount {
|
|
109
|
+
static inject = ['credentials', 'authorization']
|
|
110
|
+
|
|
111
|
+
clientId
|
|
112
|
+
issuer
|
|
113
|
+
consoleOrigin
|
|
114
|
+
authorizeEndpoint
|
|
115
|
+
tokenEndpoint
|
|
116
|
+
revokeEndpoint
|
|
117
|
+
accountEndpoint
|
|
118
|
+
userinfoEndpoint
|
|
119
|
+
logger
|
|
120
|
+
scopes
|
|
121
|
+
attempt
|
|
122
|
+
listeners = new Set()
|
|
123
|
+
detailsLifetime = new AbortController()
|
|
124
|
+
lastProfile
|
|
125
|
+
refreshInFlight
|
|
126
|
+
removing
|
|
127
|
+
closed = false
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param ctx - Host with the credentials and authorization services.
|
|
131
|
+
* @param config - application identity and the granted scope set.
|
|
132
|
+
*/
|
|
133
|
+
constructor(ctx, config = {}) {
|
|
134
|
+
super(ctx)
|
|
135
|
+
installAccountTaskCancellation(ctx)
|
|
136
|
+
const clientId = config.clientId
|
|
137
|
+
if (typeof clientId !== 'string' || clientId.length === 0) {
|
|
138
|
+
throw new GateMuxAuthError('clientId is required', 'protocol')
|
|
139
|
+
}
|
|
140
|
+
this.clientId = clientId
|
|
141
|
+
// `rest.gatemux.ai` serves both the API and the OAuth endpoints; the console
|
|
142
|
+
// is a separate origin used only for links the user opens.
|
|
143
|
+
const issuer = originOf(config.authorizeOrigin ?? DEFAULT_ISSUER)
|
|
144
|
+
this.issuer = issuer
|
|
145
|
+
this.consoleOrigin = originOf(config.consoleOrigin ?? DEFAULT_CONSOLE_ORIGIN)
|
|
146
|
+
this.authorizeEndpoint = `${issuer}/v1/oauth/authorize`
|
|
147
|
+
this.tokenEndpoint = `${issuer}/v1/oauth/token`
|
|
148
|
+
this.revokeEndpoint = `${issuer}/v1/oauth/revoke`
|
|
149
|
+
this.accountEndpoint = `${issuer}/v1/account/me`
|
|
150
|
+
this.userinfoEndpoint = `${issuer}/v1/oauth/userinfo`
|
|
151
|
+
this.logger = ctx.logger('gatemux-account')
|
|
152
|
+
this.scopes = Array.isArray(config.scopes) && config.scopes.length > 0 ? [...config.scopes] : DEFAULT_SCOPES
|
|
153
|
+
ctx.authorization.registerFlow({
|
|
154
|
+
key: KEY,
|
|
155
|
+
label: 'GateMux',
|
|
156
|
+
methods: [{ id: 'browser', label: 'GateMux' }],
|
|
157
|
+
run: (session) => {
|
|
158
|
+
const attempt = this.attempt
|
|
159
|
+
if (attempt === undefined) return Promise.reject(new GateMuxAuthError('no attempt', 'protocol'))
|
|
160
|
+
attempt.running = this.run(session, attempt)
|
|
161
|
+
return attempt.running
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
ctx.on('credentials/record-updated', (key) => {
|
|
165
|
+
if (key !== KEY) return
|
|
166
|
+
this.invalidateDetails()
|
|
167
|
+
this.changed()
|
|
168
|
+
})
|
|
169
|
+
ctx.effect(() => async () => {
|
|
170
|
+
this.closed = true
|
|
171
|
+
this.invalidateDetails()
|
|
172
|
+
const active = this.attempt
|
|
173
|
+
if (active !== undefined) {
|
|
174
|
+
if (active.view.phase !== 'committing') active.controller.abort()
|
|
175
|
+
await active.done
|
|
176
|
+
}
|
|
177
|
+
await this.removing
|
|
178
|
+
this.changed()
|
|
179
|
+
}, 'gatemux-account: active attempt lifetime')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Discard a stored grant issued by another environment. */
|
|
183
|
+
async [Service.init]() {
|
|
184
|
+
const record = await this.ctx.credentials.readRecord(KEY)
|
|
185
|
+
if (record === undefined) return
|
|
186
|
+
const grant = record.kind === 'grant' ? parseGrant(record.payload, this.issuer) : null
|
|
187
|
+
if (grant !== null) return
|
|
188
|
+
await this.ctx.credentials.deleteRecord(KEY)
|
|
189
|
+
this.logger.warn('stored grant discarded', { reason: 'unreadable-or-foreign-issuer' })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async getState() {
|
|
193
|
+
const record = await this.ctx.credentials.readRecord(KEY)
|
|
194
|
+
if (record !== undefined && (record.kind !== 'grant' || parseGrant(record.payload, this.issuer) === null)) {
|
|
195
|
+
throw new GateMuxAuthError('stored grant is unreadable', 'storage')
|
|
196
|
+
}
|
|
197
|
+
const attempt = this.attempt?.view ?? null
|
|
198
|
+
return {
|
|
199
|
+
status: record === undefined ? 'signed-out' : 'credential-stored',
|
|
200
|
+
attempt: record === undefined && attempt?.phase === 'succeeded' ? null : attempt,
|
|
201
|
+
links: {
|
|
202
|
+
usageUrl: `${this.consoleOrigin}/app/usage`,
|
|
203
|
+
topUpUrl: `${this.consoleOrigin}/app/billing`,
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async getProfile(client) {
|
|
209
|
+
const lifetime = this.detailsLifetime
|
|
210
|
+
const grant = await this.readGrant(lifetime)
|
|
211
|
+
if (grant === null || lifetime.signal.aborted) return null
|
|
212
|
+
const identity = await this.readIdentity(grant, lifetime, client)
|
|
213
|
+
if (this.detailsLifetime !== lifetime) return null
|
|
214
|
+
if (identity === null) return { status: 'failed' }
|
|
215
|
+
this.cacheProfile(grant.accessToken, identity)
|
|
216
|
+
return { status: 'ready', value: identity }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Wallet balances require the `billing:read` scope, which this application is
|
|
221
|
+
* not granted. A response without a usable wallet is reported as a failed
|
|
222
|
+
* balance rather than an invented zero.
|
|
223
|
+
*/
|
|
224
|
+
async getBalance(client) {
|
|
225
|
+
const lifetime = this.detailsLifetime
|
|
226
|
+
const grant = await this.readGrant(lifetime)
|
|
227
|
+
if (grant === null || lifetime.signal.aborted) return null
|
|
228
|
+
const body = await this.callApi(this.accountEndpoint, grant, lifetime, client)
|
|
229
|
+
if (this.detailsLifetime !== lifetime) return null
|
|
230
|
+
if (body === null) return { status: 'failed' }
|
|
231
|
+
const wallet = body.wallet
|
|
232
|
+
if (typeof wallet !== 'object' || wallet === null) return { status: 'failed' }
|
|
233
|
+
const { currency, balance } = wallet
|
|
234
|
+
if (currency !== 'CNY' && currency !== 'USD') return { status: 'failed' }
|
|
235
|
+
if (typeof balance !== 'string') return { status: 'failed' }
|
|
236
|
+
return { status: 'ready', value: [{ currency, balance }], bonusWallets: [] }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** GateMux grants no campaign bonuses; the contract reports an empty batch. */
|
|
240
|
+
async getUnnotifiedBonuses(client) {
|
|
241
|
+
const lifetime = this.detailsLifetime
|
|
242
|
+
const grant = await this.readGrant(lifetime)
|
|
243
|
+
if (grant === null || lifetime.signal.aborted) return null
|
|
244
|
+
const profile = this.lastProfile?.token === grant.accessToken ? this.lastProfile.profile : null
|
|
245
|
+
const accountId = profile?.id ?? (await this.getProfile(client))?.value?.id ?? null
|
|
246
|
+
if (accountId === null) return null
|
|
247
|
+
return { accountId, bonuses: [] }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** No bonus can be acknowledged because none is ever granted. */
|
|
251
|
+
async ackBonusNotified() {
|
|
252
|
+
return false
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async startSignIn(_client, callbackOrigin, loginSource) {
|
|
256
|
+
if (this.removing !== undefined) await this.removing
|
|
257
|
+
const origin = loginOrigin(callbackOrigin)
|
|
258
|
+
if (this.closed) throw new GateMuxAuthError('provider is closed', 'protocol')
|
|
259
|
+
const active = this.attempt
|
|
260
|
+
if (active !== undefined && ['initializing', 'waiting-browser', 'exchanging', 'committing'].includes(active.view.phase)) {
|
|
261
|
+
return this.getState()
|
|
262
|
+
}
|
|
263
|
+
const attempt = {
|
|
264
|
+
origin,
|
|
265
|
+
loginSource,
|
|
266
|
+
view: { id: randomUUID(), phase: 'initializing' },
|
|
267
|
+
controller: new AbortController(),
|
|
268
|
+
done: Promise.resolve(),
|
|
269
|
+
running: Promise.resolve(),
|
|
270
|
+
}
|
|
271
|
+
this.attempt = attempt
|
|
272
|
+
attempt.done = this.ctx.authorization.begin({
|
|
273
|
+
key: KEY,
|
|
274
|
+
signal: attempt.controller.signal,
|
|
275
|
+
interaction: { notify: () => undefined, prompt: () => Promise.reject(new GateMuxAuthError('no prompt', 'protocol')) },
|
|
276
|
+
}).then((outcome) => {
|
|
277
|
+
this.update(attempt, { phase: outcome.status === 'authorized' ? 'succeeded' : 'cancelled' })
|
|
278
|
+
if (outcome.status === 'authorized' && attempt.completionUrl !== undefined) {
|
|
279
|
+
attempt.callback?.writeHead(302, { location: attempt.completionUrl, 'cache-control': 'no-store' }).end()
|
|
280
|
+
} else {
|
|
281
|
+
attempt.callback?.writeHead(204, { 'cache-control': 'no-store' }).end()
|
|
282
|
+
}
|
|
283
|
+
}).catch((error) => {
|
|
284
|
+
const code = error instanceof GateMuxAuthError ? error.code : 'protocol'
|
|
285
|
+
// Anything that is not a GateMuxAuthError is a defect rather than a rejected
|
|
286
|
+
// attempt, so its message is kept: otherwise every such failure reads as a bare
|
|
287
|
+
// `protocol` and the exchange that rejected it cannot be told from the seam.
|
|
288
|
+
const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
|
|
289
|
+
this.logger.warn('sign-in failed', { errorCode: code, detail })
|
|
290
|
+
this.update(attempt, { phase: code === 'expired' ? 'expired' : 'failed', errorCode: code })
|
|
291
|
+
this.finishFailedCallback(attempt)
|
|
292
|
+
}).then(async () => {
|
|
293
|
+
await attempt.running.catch(() => undefined)
|
|
294
|
+
if (attempt.callback !== undefined) {
|
|
295
|
+
await streamPromises.finished(attempt.callback, { cleanup: true }).catch(() => undefined)
|
|
296
|
+
}
|
|
297
|
+
await attempt.disposeCallback?.()
|
|
298
|
+
})
|
|
299
|
+
this.changed()
|
|
300
|
+
return this.getState()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async cancelSignIn(id) {
|
|
304
|
+
const attempt = this.attempt
|
|
305
|
+
if (attempt?.view.id === id) {
|
|
306
|
+
if (attempt.view.phase !== 'committing') {
|
|
307
|
+
attempt.controller.abort()
|
|
308
|
+
this.ctx.authorization.cancel(KEY)
|
|
309
|
+
}
|
|
310
|
+
await attempt.done
|
|
311
|
+
}
|
|
312
|
+
return this.getState()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
signOut(client) {
|
|
316
|
+
this.removing ??= (async () => {
|
|
317
|
+
if (this.closed) throw new GateMuxAuthError('provider is closed', 'protocol')
|
|
318
|
+
if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id)
|
|
319
|
+
const record = await this.ctx.credentials.readRecord(KEY)
|
|
320
|
+
if (record !== undefined) {
|
|
321
|
+
const grant = record.kind === 'grant' ? parseGrant(record.payload, this.issuer) : null
|
|
322
|
+
if (grant === null) throw new GateMuxAuthError('stored grant is unreadable', 'storage')
|
|
323
|
+
await this.ctx.credentials.deleteRecord(KEY)
|
|
324
|
+
// Revoking the refresh token cascades to every access token it issued.
|
|
325
|
+
this.revoke(grant.refreshToken)
|
|
326
|
+
}
|
|
327
|
+
this.attempt = undefined
|
|
328
|
+
this.ctx.emit('deepseek-account/signed-out')
|
|
329
|
+
this.changed()
|
|
330
|
+
return this.getState()
|
|
331
|
+
})().finally(() => { this.removing = undefined })
|
|
332
|
+
return this.removing
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async *watch(signal) {
|
|
336
|
+
let dirty = true
|
|
337
|
+
let wake
|
|
338
|
+
const changed = () => { dirty = true; wake?.() }
|
|
339
|
+
this.listeners.add(changed)
|
|
340
|
+
signal.addEventListener('abort', changed, { once: true })
|
|
341
|
+
try {
|
|
342
|
+
while (!this.closed && !signal.aborted) {
|
|
343
|
+
if (dirty) { dirty = false; yield await this.getState(); continue }
|
|
344
|
+
await new Promise((resolve) => { wake = resolve })
|
|
345
|
+
}
|
|
346
|
+
} finally {
|
|
347
|
+
this.listeners.delete(changed)
|
|
348
|
+
signal.removeEventListener('abort', changed)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Resolve a token only for the GateMux REST origin, refreshing it when stale. */
|
|
353
|
+
async resolveToken(url) {
|
|
354
|
+
if (this.closed || this.removing !== undefined) return undefined
|
|
355
|
+
let destination
|
|
356
|
+
try { destination = new URL(url) } catch { return undefined }
|
|
357
|
+
if (destination.origin !== this.issuer || destination.username || destination.password) return undefined
|
|
358
|
+
const grant = await this.freshGrant()
|
|
359
|
+
return grant?.accessToken
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Remove a rejected token only while it still matches the stored credential. */
|
|
363
|
+
async rejectToken(token) {
|
|
364
|
+
const lifetime = this.detailsLifetime
|
|
365
|
+
const grant = await this.readGrant(lifetime)
|
|
366
|
+
if (grant?.accessToken !== token) return
|
|
367
|
+
await this.expireCredential(token, lifetime)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** GateMux embeds no console documents, so there is no host-only session to publish. */
|
|
371
|
+
async getPlatformSession() {
|
|
372
|
+
return null
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Run one authorization attempt: PKCE, browser callback, code exchange, commit.
|
|
377
|
+
* @param session - the attempt's cancellation signal and commit channel.
|
|
378
|
+
* @param attempt - the attempt record whose phase drives the UI.
|
|
379
|
+
*/
|
|
380
|
+
async run(session, attempt) {
|
|
381
|
+
const webServer = this.ctx.get('webServer')
|
|
382
|
+
if (webServer === undefined) throw new GateMuxAuthError('web server is unavailable', 'protocol')
|
|
383
|
+
const verifier = randomBytes(32).toString('base64url')
|
|
384
|
+
const state = randomBytes(32).toString('base64url')
|
|
385
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url')
|
|
386
|
+
const redirectUri = `${attempt.origin}${CALLBACK_PATH}`
|
|
387
|
+
const code = Promise.withResolvers()
|
|
388
|
+
// A callback may arrive before initialization settles; observe the rejection immediately.
|
|
389
|
+
void code.promise.catch(() => undefined)
|
|
390
|
+
const deadline = new AbortController()
|
|
391
|
+
const signal = AbortSignal.any([session.signal, deadline.signal])
|
|
392
|
+
// Published to the client and re-checked after the exchange, so it has to be
|
|
393
|
+
// the same instant the deadline aborts at rather than a second clock.
|
|
394
|
+
const expiresAt = Date.now() + ATTEMPT_TIMEOUT_MS
|
|
395
|
+
const timer = setTimeout(() =>{ deadline.abort() }, ATTEMPT_TIMEOUT_MS)
|
|
396
|
+
const abort = () => { code.reject(new GateMuxAuthError('attempt expired', 'expired')) }
|
|
397
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
398
|
+
try {
|
|
399
|
+
attempt.disposeCallback = this.ctx.effect(() => webServer.register({
|
|
400
|
+
kind: 'exact', path: CALLBACK_PATH, handler: (req, res) => {
|
|
401
|
+
let url
|
|
402
|
+
try { url = new URL(req.url ?? '/', 'http://127.0.0.1') }
|
|
403
|
+
catch { res.writeHead(400, { 'cache-control': 'no-store' }).end(); return }
|
|
404
|
+
const receivedCode = url.searchParams.get('code')
|
|
405
|
+
const receivedState = url.searchParams.get('state') ?? ''
|
|
406
|
+
const validState = Buffer.byteLength(receivedState) === Buffer.byteLength(state)
|
|
407
|
+
&& timingSafeEqual(Buffer.from(receivedState), Buffer.from(state))
|
|
408
|
+
if (req.method !== 'GET' || url.pathname !== CALLBACK_PATH || !validState || !receivedCode
|
|
409
|
+
|| url.searchParams.getAll('state').length !== 1 || url.searchParams.getAll('code').length !== 1) {
|
|
410
|
+
res.writeHead(400, { 'cache-control': 'no-store' }).end(); return
|
|
411
|
+
}
|
|
412
|
+
if (signal.aborted || attempt.callback !== undefined || attempt.view.phase !== 'waiting-browser') {
|
|
413
|
+
res.writeHead(410, { 'cache-control': 'no-store' }).end(); return
|
|
414
|
+
}
|
|
415
|
+
attempt.callback = res
|
|
416
|
+
code.resolve(receivedCode)
|
|
417
|
+
},
|
|
418
|
+
}), 'gatemux-account: browser callback')
|
|
419
|
+
signal.throwIfAborted()
|
|
420
|
+
const authorizeUrl = new URL(this.authorizeEndpoint)
|
|
421
|
+
authorizeUrl.searchParams.set('response_type', 'code')
|
|
422
|
+
authorizeUrl.searchParams.set('client_id', this.clientId)
|
|
423
|
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri)
|
|
424
|
+
authorizeUrl.searchParams.set('state', state)
|
|
425
|
+
authorizeUrl.searchParams.set('scope', this.scopes.join(' '))
|
|
426
|
+
authorizeUrl.searchParams.set('code_challenge', challenge)
|
|
427
|
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256')
|
|
428
|
+
this.update(attempt, {
|
|
429
|
+
phase: 'waiting-browser',
|
|
430
|
+
authorizeUrl: authorizeUrl.href,
|
|
431
|
+
expiresAt,
|
|
432
|
+
})
|
|
433
|
+
const receivedCode = await code.promise
|
|
434
|
+
signal.throwIfAborted()
|
|
435
|
+
this.update(attempt, { phase: 'exchanging' })
|
|
436
|
+
const grant = await this.exchange(receivedCode, verifier, redirectUri, signal)
|
|
437
|
+
if (Date.now() >= expiresAt) throw new GateMuxAuthError('attempt expired', 'expired')
|
|
438
|
+
signal.throwIfAborted()
|
|
439
|
+
this.update(attempt, { phase: 'committing' })
|
|
440
|
+
try {
|
|
441
|
+
await session.commit({ kind: 'grant', payload: grant })
|
|
442
|
+
} catch {
|
|
443
|
+
throw new GateMuxAuthError('stored grant could not be written', 'storage')
|
|
444
|
+
}
|
|
445
|
+
} catch (error) {
|
|
446
|
+
if (deadline.signal.aborted) throw new GateMuxAuthError('attempt expired', 'expired')
|
|
447
|
+
throw error
|
|
448
|
+
} finally {
|
|
449
|
+
clearTimeout(timer)
|
|
450
|
+
signal.removeEventListener('abort', abort)
|
|
451
|
+
// begin() settles the browser response and removes only this attempt's route.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Exchange an authorization code for tokens.
|
|
457
|
+
*
|
|
458
|
+
* `invalid_grant` is terminal: the server voids the code when the verifier
|
|
459
|
+
* does not match, so retrying the same code can never succeed.
|
|
460
|
+
*/
|
|
461
|
+
async exchange(code, verifier, redirectUri, signal) {
|
|
462
|
+
const response = await this.postToken({
|
|
463
|
+
grant_type: 'authorization_code',
|
|
464
|
+
code,
|
|
465
|
+
code_verifier: verifier,
|
|
466
|
+
client_id: this.clientId,
|
|
467
|
+
redirect_uri: redirectUri,
|
|
468
|
+
}, signal)
|
|
469
|
+
if (!response.ok) {
|
|
470
|
+
// RFC 6749 §5.2 flat body. A reused `code`, a `code_verifier` that does not match
|
|
471
|
+
// the challenge, and a `redirect_uri` that differs from the authorize request all
|
|
472
|
+
// arrive as `invalid_grant`, so the server's own description is the only thing
|
|
473
|
+
// that tells them apart — discarding it leaves nothing to act on.
|
|
474
|
+
const code = tokenErrorCode(response.body)
|
|
475
|
+
const description = typeof response.body?.error_description === 'string' ? response.body.error_description : undefined
|
|
476
|
+
this.logger.warn('authorization code rejected', { status: response.status, errorCode: code ?? 'unknown', description })
|
|
477
|
+
throw new GateMuxAuthError(`authorization code rejected (${response.status} ${code ?? 'unknown'})`, 'protocol')
|
|
478
|
+
}
|
|
479
|
+
return this.grantFrom(response.body)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Read the newest grant, refreshing it when it is close to expiry. */
|
|
483
|
+
async freshGrant() {
|
|
484
|
+
const grant = await this.readGrant(this.detailsLifetime)
|
|
485
|
+
if (grant === null) return null
|
|
486
|
+
if (grant.expiresAt - Date.now() > REFRESH_MARGIN_MS) return grant
|
|
487
|
+
this.refreshInFlight ??= this.rotate().finally(() => { this.refreshInFlight = undefined })
|
|
488
|
+
try {
|
|
489
|
+
return await this.refreshInFlight
|
|
490
|
+
} catch {
|
|
491
|
+
return null
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Rotate the refresh token exactly once at a time.
|
|
497
|
+
*
|
|
498
|
+
* Refreshes are serialized because GateMux voids the whole authorization
|
|
499
|
+
* chain when a rotated refresh token reappears after a grace period.
|
|
500
|
+
*/
|
|
501
|
+
async rotate() {
|
|
502
|
+
const lifetime = this.detailsLifetime
|
|
503
|
+
const grant = await this.readGrant(lifetime)
|
|
504
|
+
if (grant === null) throw new GateMuxAuthError('signed out', 'expired')
|
|
505
|
+
const response = await this.postToken({
|
|
506
|
+
grant_type: 'refresh_token',
|
|
507
|
+
refresh_token: grant.refreshToken,
|
|
508
|
+
client_id: this.clientId,
|
|
509
|
+
}, lifetime.signal)
|
|
510
|
+
if (!response.ok) {
|
|
511
|
+
const code = tokenErrorCode(response.body)
|
|
512
|
+
// An unknown or expired refresh token means the authorization is gone.
|
|
513
|
+
this.logger.warn('refresh rejected', { errorCode: code ?? 'unknown' })
|
|
514
|
+
await this.expireCredential(grant.accessToken, lifetime)
|
|
515
|
+
throw new GateMuxAuthError('refresh token rejected', 'expired')
|
|
516
|
+
}
|
|
517
|
+
const next = this.grantFrom(response.body)
|
|
518
|
+
// Persist the rotated token before any other request can read the old one.
|
|
519
|
+
await this.ctx.credentials.modifyRecord(KEY, (current) => Promise.resolve(
|
|
520
|
+
current !== undefined && parseGrant(current.payload, this.issuer) !== null
|
|
521
|
+
? { kind: 'grant', payload: next }
|
|
522
|
+
: undefined,
|
|
523
|
+
))
|
|
524
|
+
return next
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Build a validated grant from a token-endpoint response. */
|
|
528
|
+
grantFrom(body) {
|
|
529
|
+
if (typeof body !== 'object' || body === null) throw new GateMuxAuthError('token response was not an object', 'protocol')
|
|
530
|
+
const { access_token: accessToken, refresh_token: refreshToken, expires_in: expiresIn } = body
|
|
531
|
+
if (typeof accessToken !== 'string' || accessToken.length === 0) throw new GateMuxAuthError('token response carried no access token', 'protocol')
|
|
532
|
+
if (typeof refreshToken !== 'string' || refreshToken.length === 0) throw new GateMuxAuthError('token response carried no refresh token', 'protocol')
|
|
533
|
+
const seconds = typeof expiresIn === 'number' && Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600
|
|
534
|
+
return { version: 1, accessToken, refreshToken, expiresAt: Date.now() + seconds * 1000, issuer: this.issuer }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Post one token-endpoint request and decode its RFC 6749 §5.2 envelope. */
|
|
538
|
+
async postToken(fields, signal) {
|
|
539
|
+
const response = await fetch(this.tokenEndpoint, {
|
|
540
|
+
method: 'POST',
|
|
541
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
542
|
+
body: JSON.stringify(fields),
|
|
543
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
|
|
544
|
+
})
|
|
545
|
+
let body
|
|
546
|
+
try { body = await response.json() } catch { body = undefined }
|
|
547
|
+
return { ok: response.ok, status: response.status, body }
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Call one account endpoint, expiring the credential on an authenticated rejection.
|
|
552
|
+
* @returns the decoded body, or null when the credential was rejected.
|
|
553
|
+
*/
|
|
554
|
+
async callApi(url, grant, lifetime, _client) {
|
|
555
|
+
let response
|
|
556
|
+
try {
|
|
557
|
+
response = await fetch(url, {
|
|
558
|
+
headers: { authorization: `Bearer ${grant.accessToken}`, accept: 'application/json' },
|
|
559
|
+
signal: AbortSignal.any([lifetime.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
|
|
560
|
+
})
|
|
561
|
+
} catch {
|
|
562
|
+
return null
|
|
563
|
+
}
|
|
564
|
+
let body
|
|
565
|
+
try { body = await response.json() } catch { body = undefined }
|
|
566
|
+
if (response.ok) return body
|
|
567
|
+
// 401 credential_invalid means re-authorization; other codes are configuration problems.
|
|
568
|
+
if (response.status === 401 || apiErrorCode(body) === 'credential_invalid') {
|
|
569
|
+
if (this.detailsLifetime === lifetime) await this.expireCredential(grant.accessToken, lifetime)
|
|
570
|
+
return null
|
|
571
|
+
}
|
|
572
|
+
return null
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Read the profile identity, enriching it with the OpenID email when available. */
|
|
576
|
+
async readIdentity(grant, lifetime, client) {
|
|
577
|
+
const me = await this.callApi(this.accountEndpoint, grant, lifetime, client)
|
|
578
|
+
if (me === null || typeof me !== 'object') return null
|
|
579
|
+
const accountId = typeof me.account_id === 'string' && me.account_id.length > 0 ? me.account_id : null
|
|
580
|
+
const masked = typeof me.email_masked === 'string' && me.email_masked.length > 0 ? me.email_masked : null
|
|
581
|
+
let contact = masked
|
|
582
|
+
if (contact === null) {
|
|
583
|
+
const info = await this.callApi(this.userinfoEndpoint, grant, lifetime, client)
|
|
584
|
+
if (typeof info === 'object' && info !== null && typeof info.email === 'string' && info.email.length > 0) {
|
|
585
|
+
contact = info.email
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const id = accountId ?? (typeof me.user_id === 'string' && me.user_id.length > 0 ? me.user_id : null)
|
|
589
|
+
if (id === null && contact === null) return null
|
|
590
|
+
return { id, name: contact, contact, avatarUrl: null }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
cacheProfile(token, profile) {
|
|
594
|
+
const previous = this.lastProfile?.profile.id ?? null
|
|
595
|
+
this.lastProfile = { token, profile }
|
|
596
|
+
if (previous !== profile.id) this.changed()
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async readGrant(lifetime) {
|
|
600
|
+
if (this.closed) return null
|
|
601
|
+
const record = await this.ctx.credentials.readRecord(KEY)
|
|
602
|
+
if (record === undefined || lifetime.signal.aborted) return null
|
|
603
|
+
if (record.kind !== 'grant') throw new GateMuxAuthError('stored record is not a grant', 'storage')
|
|
604
|
+
const grant = parseGrant(record.payload, this.issuer)
|
|
605
|
+
if (grant === null) throw new GateMuxAuthError('stored grant is unreadable', 'storage')
|
|
606
|
+
return grant
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Delete the credential and notify consumers that the session ended. */
|
|
610
|
+
async expireCredential(token, lifetime) {
|
|
611
|
+
this.removing ??= (async () => {
|
|
612
|
+
if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id)
|
|
613
|
+
const record = await this.ctx.credentials.readRecord(KEY)
|
|
614
|
+
if (this.closed || this.detailsLifetime !== lifetime || record?.kind !== 'grant') return this.getState()
|
|
615
|
+
const current = parseGrant(record.payload, this.issuer)
|
|
616
|
+
if (current === null || current.accessToken !== token) return this.getState()
|
|
617
|
+
await this.ctx.credentials.deleteRecord(KEY)
|
|
618
|
+
this.ctx.emit('deepseek-account/session-expired')
|
|
619
|
+
this.attempt = undefined
|
|
620
|
+
this.ctx.emit('deepseek-account/signed-out')
|
|
621
|
+
this.changed()
|
|
622
|
+
return this.getState()
|
|
623
|
+
})().finally(() => { this.removing = undefined })
|
|
624
|
+
await this.removing
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Revoke a refresh token, cascading to its access tokens; failures are not fatal. */
|
|
628
|
+
revoke(refreshToken) {
|
|
629
|
+
if (this.closed) return
|
|
630
|
+
void fetch(this.revokeEndpoint, {
|
|
631
|
+
method: 'POST',
|
|
632
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
633
|
+
body: JSON.stringify({ token: refreshToken }),
|
|
634
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
635
|
+
}).then(() => undefined, () => undefined)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
invalidateDetails() {
|
|
639
|
+
this.lastProfile = undefined
|
|
640
|
+
this.detailsLifetime.abort()
|
|
641
|
+
this.detailsLifetime = new AbortController()
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
changed() { for (const listener of this.listeners) listener() }
|
|
645
|
+
|
|
646
|
+
update(attempt, value) {
|
|
647
|
+
const { authorizeUrl: _url, ...rest } = attempt.view
|
|
648
|
+
attempt.view = { ...rest, ...value }
|
|
649
|
+
this.changed()
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
/** Return the browser to the application after a failed exchange. */
|
|
654
|
+
finishFailedCallback(attempt) {
|
|
655
|
+
if (attempt.loginSource === 'web') {
|
|
656
|
+
const nonce = randomBytes(16).toString('base64url')
|
|
657
|
+
const message = 'Sign-in failed. Close this tab and try again in the original tab.'
|
|
658
|
+
attempt.callback?.writeHead(200, {
|
|
659
|
+
'content-type': 'text/html; charset=utf-8',
|
|
660
|
+
'cache-control': 'no-store',
|
|
661
|
+
'content-security-policy': `default-src 'none'; frame-ancestors 'none'`,
|
|
662
|
+
'referrer-policy': 'no-referrer',
|
|
663
|
+
}).end(`<!doctype html><html lang="en"><meta charset="utf-8"><title>${message}</title>`
|
|
664
|
+
+ `<body><p>${message}</p></body></html>`)
|
|
665
|
+
} else {
|
|
666
|
+
attempt.callback?.writeHead(204, { 'cache-control': 'no-store' }).end()
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export default GateMuxAccount
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# GateMux Sign-In for DeepSeek Harness.
|
|
2
|
+
#
|
|
3
|
+
# Composition shape.
|
|
4
|
+
#
|
|
5
|
+
# A patch's `name` is a GUARD, not a rename: `applyEntryPatches` skips any patch
|
|
6
|
+
# whose `name` differs from the addressed row's current module, warning
|
|
7
|
+
# "name mismatch ... skipping". A shipped row's module therefore can never be
|
|
8
|
+
# swapped by a patch, and only a whole new row can be inserted. Two consequences
|
|
9
|
+
# shape this file:
|
|
10
|
+
#
|
|
11
|
+
# * the GateMux account service is added as its own row rather than by
|
|
12
|
+
# re-pointing `deepseek-account`, whose module would ignore the rename;
|
|
13
|
+
# * `ctx.deepseekAccount` is a single service slot — a second provider throws
|
|
14
|
+
# "service has been registered at ..." — so the shipped row is turned off
|
|
15
|
+
# instead, which is what frees the slot for the GateMux implementation.
|
|
16
|
+
#
|
|
17
|
+
# `disabled: true` on a shipped row is a normal patch override and persists
|
|
18
|
+
# across boots, because the bundle layers are re-read from these packages on
|
|
19
|
+
# every launch.
|
|
20
|
+
#
|
|
21
|
+
# Every row `name` below carries this package's own name. Renaming the package
|
|
22
|
+
# in package.json without mirroring it here leaves the rows unresolvable, and
|
|
23
|
+
# the load reports "failed to import" rather than anything naming the rename.
|
|
24
|
+
|
|
25
|
+
- id: deepseek-account
|
|
26
|
+
disabled: true
|
|
27
|
+
|
|
28
|
+
- id: llm-deepseek-account
|
|
29
|
+
disabled: true
|
|
30
|
+
|
|
31
|
+
- insert:
|
|
32
|
+
- id: gatemux-account
|
|
33
|
+
name: '@gatemux/dsh-gatemux-login/account'
|
|
34
|
+
config:
|
|
35
|
+
clientId: app_cbd5c3e0ac4469cf
|
|
36
|
+
authorizeOrigin: https://rest.gatemux.ai
|
|
37
|
+
consoleOrigin: https://gatemux.ai
|
|
38
|
+
# Exactly the scopes granted to this application in the GateMux portal.
|
|
39
|
+
# `responses:create` gates /v1/chat/completions, /v1/messages,
|
|
40
|
+
# /v1/responses, /v1/embeddings and /v1/rerank; it is the only paid LLM
|
|
41
|
+
# scope here, and authorization returns invalid_scope without it.
|
|
42
|
+
scopes:
|
|
43
|
+
- models:read
|
|
44
|
+
- responses:create
|
|
45
|
+
- media:create
|
|
46
|
+
- account:read
|
|
47
|
+
- openid
|
|
48
|
+
- email
|
|
49
|
+
|
|
50
|
+
# The user-funded GateMux route. Its provider id is `gatemux-account`, not
|
|
51
|
+
# `gatemux`: the profile's `llm-pi-ai` row already owns the `gatemux` route
|
|
52
|
+
# with the application's own server key, and the LLM registry throws
|
|
53
|
+
# DUPLICATE_ADAPTER when one route is registered twice.
|
|
54
|
+
- id: gatemux-messages
|
|
55
|
+
name: '@gatemux/dsh-gatemux-login/llm'
|
|
56
|
+
config:
|
|
57
|
+
baseURL: https://rest.gatemux.ai/v1
|
|
58
|
+
maxTokens: 8192
|
|
59
|
+
# GateMux answers both `budget_exceeded` (a spend cap) and
|
|
60
|
+
# `app_rate_limited` (a transient limit) with HTTP 429, and the upstream
|
|
61
|
+
# classifier folds both into RATE_LIMIT, so retries are kept low: an
|
|
62
|
+
# exhausted budget must fail fast for the user to act on rather than be
|
|
63
|
+
# retried as though it were transient.
|
|
64
|
+
retryPolicy:
|
|
65
|
+
mode: normal
|
|
66
|
+
maxRetries: 3
|
|
67
|
+
retryableCodes:
|
|
68
|
+
- RATE_LIMIT
|
|
69
|
+
- SERVER
|
|
70
|
+
- TIMEOUT
|
|
71
|
+
- TRANSPORT
|
|
72
|
+
- EMPTY_RESPONSE
|
|
73
|
+
models:
|
|
74
|
+
- id: gatemux/deepseek-v4-flash
|
|
75
|
+
name: DeepSeek V4 Flash (GateMux)
|
|
76
|
+
description: GateMux-routed DeepSeek V4 Flash; vision, tools and reasoning.
|
|
77
|
+
contextWindow: 1000000
|
|
78
|
+
inputModalities:
|
|
79
|
+
- text
|
|
80
|
+
- image
|
|
81
|
+
- id: gatemux/deepseek-v4-pro
|
|
82
|
+
name: DeepSeek V4 Pro (GateMux)
|
|
83
|
+
description: GateMux-routed DeepSeek V4 Pro; tools and reasoning.
|
|
84
|
+
contextWindow: 1000000
|
|
85
|
+
inputModalities:
|
|
86
|
+
- text
|
|
87
|
+
|
|
88
|
+
# Point the default model at the user-funded route, so installing this bundle is
|
|
89
|
+
# enough to start using it. A profile's own cordis.patch.yml is applied after
|
|
90
|
+
# every bundle layer, so a user who prefers another default still overrides this.
|
|
91
|
+
- id: agent-default-model
|
|
92
|
+
name: '@deepseek-ai/dsh-agent-default-model'
|
|
93
|
+
config:
|
|
94
|
+
provider: gatemux-account
|
|
95
|
+
model: gatemux/deepseek-v4-flash
|
|
96
|
+
reasoningEffort: high
|
package/llm.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GateMux model route authenticated by the signed-in user's OAuth credential.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the DeepSeek Messages adapter because GateMux's `/v1/messages` speaks
|
|
5
|
+
* the Anthropic protocol verbatim, including DeepSeek's proprietary `thinking`
|
|
6
|
+
* and `output_config` request fields. Only authentication differs.
|
|
7
|
+
*
|
|
8
|
+
* The provider id is deliberately NOT `gatemux`: the profile's `llm-pi-ai` row
|
|
9
|
+
* already claims that route with the application's server key, and claiming it
|
|
10
|
+
* twice would throw `DUPLICATE_ADAPTER` and break plugin loading.
|
|
11
|
+
*
|
|
12
|
+
* Endpoint, model catalog, and retry policy come from this entry's `config` in
|
|
13
|
+
* `cordis.patch.yml`, so the composition remains the single source of truth for
|
|
14
|
+
* deployment choices.
|
|
15
|
+
*
|
|
16
|
+
* @module @gatemux/dsh-gatemux-login/llm
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { ACCOUNT_QUOTA_EXCEEDED_CODE, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
|
|
20
|
+
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
|
|
21
|
+
import { Config, catalogModelInfo, plainOptions, registerDeepSeekProvider, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
|
|
22
|
+
|
|
23
|
+
export { Config }
|
|
24
|
+
|
|
25
|
+
/** Route owned by this plugin; distinct from the pi-ai `gatemux` route. */
|
|
26
|
+
export const PROVIDER = 'gatemux-account'
|
|
27
|
+
export const name = 'gatemux-account'
|
|
28
|
+
export const inject = ['llm']
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register the GateMux account-authenticated provider route.
|
|
32
|
+
* @param ctx - Host context with the LLM registry injected.
|
|
33
|
+
* @param config - this entry's Messages protocol configuration.
|
|
34
|
+
*/
|
|
35
|
+
export function apply(ctx, config) {
|
|
36
|
+
const logger = ctx.logger('gatemux-account')
|
|
37
|
+
const options = () => resolveAdapterOptions(plainOptions(config), launchEnvironmentOf(ctx))
|
|
38
|
+
// Validate the composed configuration now so a misconfiguration fails loudly at load.
|
|
39
|
+
options()
|
|
40
|
+
|
|
41
|
+
const resolveAuth = async (connection) => {
|
|
42
|
+
const account = ctx.get('deepseekAccount')
|
|
43
|
+
const token = await account?.resolveToken(connection.baseURL)
|
|
44
|
+
if (token === undefined) {
|
|
45
|
+
throw new LlmError(
|
|
46
|
+
'Sign in with GateMux to use the GateMux model route. Model requests are billed to your own GateMux balance.',
|
|
47
|
+
'ACCOUNT_SIGN_IN_REQUIRED',
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
// GateMux accepts `Authorization: Bearer`; it rejects the DeepSeek-only `x-dsh-auth-token` header.
|
|
52
|
+
headers: { authorization: `Bearer ${token}` },
|
|
53
|
+
onRequestError: async (error) => {
|
|
54
|
+
if (!(error instanceof LlmError)) return error
|
|
55
|
+
const status = error.failure.status
|
|
56
|
+
// 402 insufficient_balance and an exhausted quota both mean the user must top up.
|
|
57
|
+
if (error.code === QUOTA_EXCEEDED_CODE) {
|
|
58
|
+
return new LlmError(error.message, ACCOUNT_QUOTA_EXCEEDED_CODE, { ...error.failure, cause: error })
|
|
59
|
+
}
|
|
60
|
+
// 403 scope_denied / model_not_allowed / ip_not_allowed stay AUTH: they are
|
|
61
|
+
// configuration faults that re-authorizing the same way would not fix.
|
|
62
|
+
if (status !== 401) return error
|
|
63
|
+
// 401 credential_invalid: the credential is revoked, expired, or suspended.
|
|
64
|
+
// Re-authorization is required, so the stored credential is dropped.
|
|
65
|
+
const rejected = new LlmError(error.message, 'ACCOUNT_TOKEN_INVALID', { ...error.failure, cause: error })
|
|
66
|
+
try {
|
|
67
|
+
await account?.rejectToken(token)
|
|
68
|
+
} catch (failure) {
|
|
69
|
+
logger.warn('credential removal after rejection failed', failure)
|
|
70
|
+
}
|
|
71
|
+
return rejected
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
ctx.llm.registerConfigurableProviders([
|
|
77
|
+
{ provider: PROVIDER, displayName: 'GateMux Account', settingsNs: ctx.fiber.entry?.options.id ?? name, settingsPath: [] },
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
registerDeepSeekProvider(ctx, PROVIDER, {
|
|
81
|
+
options,
|
|
82
|
+
resolveAuth,
|
|
83
|
+
providerName: 'GateMux Account',
|
|
84
|
+
discoverModels: async (provider) => {
|
|
85
|
+
const connection = options()
|
|
86
|
+
try {
|
|
87
|
+
await resolveAuth(connection)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
// With no signed-in user the route advertises nothing rather than failing discovery.
|
|
90
|
+
if (error instanceof LlmError && error.code === 'ACCOUNT_SIGN_IN_REQUIRED') return []
|
|
91
|
+
throw error
|
|
92
|
+
}
|
|
93
|
+
return connection.models.map((model) => catalogModelInfo(provider, model))
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The loader normalizes a module with `exports.default ?? exports` and then reads
|
|
99
|
+
// `inject` and `Config` off that value, so named exports beside a default are
|
|
100
|
+
// discarded. A bare `apply` default therefore has no `inject` and `ctx.llm` throws
|
|
101
|
+
// "cannot get property \"llm\" without inject"; the plugin object has to be the
|
|
102
|
+
// default export itself.
|
|
103
|
+
export default { name, inject, Config, apply }
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gatemux/dsh-gatemux-login",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Sign in with GateMux for DeepSeek Harness: users pay from their own GateMux balance.",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"files": [
|
|
11
|
+
"account.js",
|
|
12
|
+
"llm.js",
|
|
13
|
+
"cordis.patch.yml"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
"./account": "./account.js",
|
|
17
|
+
"./llm": "./llm.js",
|
|
18
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"dsh": {
|
|
22
|
+
"bundle": {
|
|
23
|
+
"patch": "./cordis.patch.yml"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"meta": {
|
|
27
|
+
"title": "GateMux Sign-In",
|
|
28
|
+
"description": "Signs in with GateMux and bills model requests to the user's own GateMux balance."
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@deepseek-ai/cordis": "^4.0.4",
|
|
32
|
+
"@deepseek-ai/dsh-authorization": "^0.1.7-rc.2",
|
|
33
|
+
"@deepseek-ai/dsh-credentials": "^0.1.7-rc.2",
|
|
34
|
+
"@deepseek-ai/dsh-deepseek-account": "^0.1.7-rc.2",
|
|
35
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2",
|
|
36
|
+
"@deepseek-ai/dsh-launch-environment": "^0.1.7-rc.2",
|
|
37
|
+
"@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
|
|
38
|
+
"@deepseek-ai/dsh-llm-deepseek": "^0.1.7-rc.2"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@deepseek-ai/cordis": "^4.0.4",
|
|
42
|
+
"@deepseek-ai/dsh-authorization": "^0.1.7-rc.2",
|
|
43
|
+
"@deepseek-ai/dsh-credentials": "^0.1.7-rc.2",
|
|
44
|
+
"@deepseek-ai/dsh-deepseek-account": "^0.1.7-rc.2",
|
|
45
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2",
|
|
46
|
+
"@deepseek-ai/dsh-launch-environment": "^0.1.7-rc.2",
|
|
47
|
+
"@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
|
|
48
|
+
"@deepseek-ai/dsh-llm-deepseek": "^0.1.7-rc.2"
|
|
49
|
+
}
|
|
50
|
+
}
|