@gatemux/dsh-gatemux-login 1.0.3 → 1.1.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/README.md +52 -80
- package/cordis.patch.yml +11 -6
- package/package.json +7 -7
- package/account.js +0 -723
- package/llm.js +0 -103
package/README.md
CHANGED
|
@@ -4,32 +4,12 @@ Sign in to the DeepSeek Harness desktop app with your own GateMux account. Model
|
|
|
4
4
|
requests are then billed to **your** GateMux balance instead of to the
|
|
5
5
|
application's server key.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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` or `0.2.x`.
|
|
26
|
-
The plugin declares `@deepseek-ai/dsh-*` peer dependencies across both lines.
|
|
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.
|
|
7
|
+
This is the bundle for the **international** site, `gatemux.ai`. If your account
|
|
8
|
+
is on the mainland China site, install
|
|
9
|
+
[`@gatemux/dsh-gatemux-login-cn`](https://www.npmjs.com/package/@gatemux/dsh-gatemux-login-cn)
|
|
10
|
+
instead. The two sites are independent deployments with separate databases,
|
|
11
|
+
wallets, and OAuth applications: an account on one does not exist on the other,
|
|
12
|
+
and installing the wrong bundle signs you in to a site you have no account on.
|
|
33
13
|
|
|
34
14
|
## Install
|
|
35
15
|
|
|
@@ -39,11 +19,9 @@ In the desktop app:
|
|
|
39
19
|
2. Click **添加插件** (Add plugin).
|
|
40
20
|
3. Enter `@gatemux/dsh-gatemux-login` and click **安装** (Install).
|
|
41
21
|
4. When it reports **已安装** (Installed), click **立即启用** (Enable now).
|
|
42
|
-
5.
|
|
43
|
-
|
|
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.
|
|
22
|
+
5. Restart the app. Replacing installed JavaScript needs a fresh process
|
|
23
|
+
generation, which is why the plugin manager reports that the change takes
|
|
24
|
+
effect at the next start.
|
|
47
25
|
|
|
48
26
|
From the CLI, for a non-desktop profile:
|
|
49
27
|
|
|
@@ -54,47 +32,35 @@ dsh plugin --profile <name> add @gatemux/dsh-gatemux-login
|
|
|
54
32
|
The `desktop` profile is managed exclusively by the Electron application and
|
|
55
33
|
cannot be installed into from the CLI.
|
|
56
34
|
|
|
57
|
-
##
|
|
35
|
+
## What it does
|
|
58
36
|
|
|
59
|
-
|
|
37
|
+
Installing this bundle changes three things about the composed profile:
|
|
60
38
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
serves no hosted completion page, so there is nothing else to show there — and it
|
|
69
|
-
never auto-closes; you can close it once the app is signed in.
|
|
70
|
-
|
|
71
|
-
## Models
|
|
39
|
+
- Adds a GateMux account service that signs you in through GateMux's OAuth 2.0
|
|
40
|
+
redirect flow (authorization code with PKCE `S256`).
|
|
41
|
+
- Registers a model route, provider id `gatemux-account`, that authenticates
|
|
42
|
+
every request with your own credential and advertises the models
|
|
43
|
+
`https://rest.gatemux.ai` reports it serves over the Messages protocol.
|
|
44
|
+
- Turns off the shipped vendor account route (`deepseek-account` and
|
|
45
|
+
`llm-deepseek-account`) so there is one account provider instead of two.
|
|
72
46
|
|
|
73
|
-
|
|
74
|
-
|---|---|---|
|
|
75
|
-
| `gatemux/deepseek-v4-flash` | 1,000,000 | text, image |
|
|
76
|
-
| `gatemux/deepseek-v4-pro` | 1,000,000 | text |
|
|
47
|
+
It also sets the default model to `gatemux/deepseek-v4-flash`.
|
|
77
48
|
|
|
78
|
-
|
|
49
|
+
## Requirements
|
|
79
50
|
|
|
80
|
-
|
|
51
|
+
- DeepSeek Harness desktop app, `0.1.7.x` or `0.2.x`. The bundle declares
|
|
52
|
+
`@deepseek-ai/dsh-*` peer dependencies across both lines; a different DSH
|
|
53
|
+
release is rejected at install time.
|
|
54
|
+
- A GateMux account **on `gatemux.ai`**.
|
|
81
55
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
another model in the app writes exactly that override for you; to set it by hand,
|
|
85
|
-
add to `$DSH_HOME/profiles/<name>/cordis.patch.yml`:
|
|
56
|
+
No install scripts are run: installation does not execute any build step, and the
|
|
57
|
+
app never asks you to approve package scripts for this plugin.
|
|
86
58
|
|
|
87
|
-
|
|
88
|
-
- id: agent-default-model
|
|
89
|
-
name: "@deepseek-ai/dsh-agent-default-model"
|
|
90
|
-
config:
|
|
91
|
-
provider: some-other-provider
|
|
92
|
-
model: some-other-model
|
|
93
|
-
reasoningEffort: high
|
|
94
|
-
```
|
|
59
|
+
## Sign in
|
|
95
60
|
|
|
96
|
-
|
|
97
|
-
|
|
61
|
+
Send a message. Until you are signed in, the route reports
|
|
62
|
+
`ACCOUNT_SIGN_IN_REQUIRED`. Sign in from the app's account settings;
|
|
63
|
+
authorization opens in your browser and returns to the app.
|
|
98
64
|
|
|
99
65
|
## Troubleshooting
|
|
100
66
|
|
|
@@ -103,24 +69,30 @@ module or the patch is skipped.
|
|
|
103
69
|
| `ACCOUNT_SIGN_IN_REQUIRED` | No credential yet. Sign in. |
|
|
104
70
|
| `ACCOUNT_TOKEN_INVALID` | The credential was revoked, expired, or suspended. It has been discarded; sign in again. |
|
|
105
71
|
| `ACCOUNT_QUOTA_EXCEEDED` | Your GateMux balance or spend cap is exhausted. Top up. |
|
|
106
|
-
| `ip_not_allowed` (HTTP 403) | Your network is not in the
|
|
107
|
-
| `scope_denied`, `model_not_allowed` (HTTP 403) | The application registration does not permit this request.
|
|
72
|
+
| `ip_not_allowed` (HTTP 403) | Your network is not in the application's trusted egress IP allowlist. Re-authorizing will not fix it. |
|
|
73
|
+
| `scope_denied`, `model_not_allowed` (HTTP 403) | The application registration does not permit this request. |
|
|
108
74
|
| Install refused for peer dependencies | Your DSH version is outside `0.1.7.x` and `0.2.x`. |
|
|
109
75
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
76
|
+
## This package is configuration only
|
|
77
|
+
|
|
78
|
+
The account provider and the model route ship in
|
|
79
|
+
[`@gatemux/dsh-gatemux-login-core`](https://www.npmjs.com/package/@gatemux/dsh-gatemux-login-core),
|
|
80
|
+
a dependency of this package; this package is the patch layer that points them at
|
|
81
|
+
`gatemux.ai` and nothing else. The mainland bundle depends on the same core and
|
|
82
|
+
differs only in those values.
|
|
83
|
+
|
|
84
|
+
The account provider reads a model's context window, output cap, display name,
|
|
85
|
+
and image support from `https://rest.gatemux.ai/v1/models`, keeps the last good
|
|
86
|
+
catalog across a listing outage, and advertises `gatemux/deepseek-v4-flash` and
|
|
87
|
+
`gatemux/deepseek-v4-pro` before the first read. Your credential is stored at
|
|
88
|
+
`$DSH_HOME/.credentials.yaml` under the key `gatemux/account`, mode `0600`, and
|
|
89
|
+
leaves your machine only as the `Authorization: Bearer` header on requests to
|
|
90
|
+
`rest.gatemux.ai`.
|
|
91
|
+
|
|
92
|
+
To change the default model, or to override any value this bundle sets, add a row
|
|
93
|
+
to `$DSH_HOME/profiles/<name>/cordis.patch.yml` — that layer is applied after
|
|
94
|
+
every bundle layer, so it wins. Overriding a row's `config` replaces the whole
|
|
95
|
+
block rather than merging into it, so restate every field you still want.
|
|
124
96
|
|
|
125
97
|
## License
|
|
126
98
|
|
package/cordis.patch.yml
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
# GateMux Sign-In for DeepSeek Harness.
|
|
1
|
+
# GateMux Sign-In for DeepSeek Harness — international (gatemux.ai).
|
|
2
|
+
#
|
|
3
|
+
# This file is the whole of this package. The account provider and the model
|
|
4
|
+
# route are shipped by @gatemux/dsh-gatemux-login-core, which this bundle depends
|
|
5
|
+
# on; everything that differs between the international and the mainland China
|
|
6
|
+
# deployment is the configuration below.
|
|
2
7
|
#
|
|
3
8
|
# Composition shape.
|
|
4
9
|
#
|
|
@@ -18,9 +23,9 @@
|
|
|
18
23
|
# across boots, because the bundle layers are re-read from these packages on
|
|
19
24
|
# every launch.
|
|
20
25
|
#
|
|
21
|
-
# Every row `name` below
|
|
22
|
-
#
|
|
23
|
-
#
|
|
26
|
+
# Every row `name` below is a module of the core package. Renaming that package
|
|
27
|
+
# without mirroring it here leaves the rows unresolvable, and the load reports
|
|
28
|
+
# "failed to import" rather than anything naming the rename.
|
|
24
29
|
|
|
25
30
|
- id: deepseek-account
|
|
26
31
|
disabled: true
|
|
@@ -30,7 +35,7 @@
|
|
|
30
35
|
|
|
31
36
|
- insert:
|
|
32
37
|
- id: gatemux-account
|
|
33
|
-
name: '@gatemux/dsh-gatemux-login/account'
|
|
38
|
+
name: '@gatemux/dsh-gatemux-login-core/account'
|
|
34
39
|
config:
|
|
35
40
|
clientId: app_cbd5c3e0ac4469cf
|
|
36
41
|
authorizeOrigin: https://rest.gatemux.ai
|
|
@@ -52,7 +57,7 @@
|
|
|
52
57
|
# with the application's own server key, and the LLM registry throws
|
|
53
58
|
# DUPLICATE_ADAPTER when one route is registered twice.
|
|
54
59
|
- id: gatemux-messages
|
|
55
|
-
name: '@gatemux/dsh-gatemux-login/llm'
|
|
60
|
+
name: '@gatemux/dsh-gatemux-login-core/llm'
|
|
56
61
|
config:
|
|
57
62
|
baseURL: https://rest.gatemux.ai/v1
|
|
58
63
|
maxTokens: 8192
|
package/package.json
CHANGED
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gatemux/dsh-gatemux-login",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Sign in with GateMux for DeepSeek Harness: users pay from their own GateMux balance.",
|
|
5
|
+
"description": "Sign in with GateMux for DeepSeek Harness: users pay from their own GateMux balance. International (gatemux.ai) bundle.",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
8
8
|
},
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"files": [
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"cordis.patch.yml"
|
|
11
|
+
"cordis.patch.yml",
|
|
12
|
+
"README.md"
|
|
14
13
|
],
|
|
15
14
|
"exports": {
|
|
16
|
-
"./account": "./account.js",
|
|
17
|
-
"./llm": "./llm.js",
|
|
18
15
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
19
16
|
"./package.json": "./package.json"
|
|
20
17
|
},
|
|
@@ -27,6 +24,9 @@
|
|
|
27
24
|
"title": "GateMux Sign-In",
|
|
28
25
|
"description": "Signs in with GateMux and bills model requests to the user's own GateMux balance."
|
|
29
26
|
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@gatemux/dsh-gatemux-login-core": "^1.0.0"
|
|
29
|
+
},
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
32
32
|
"@deepseek-ai/dsh-authorization": "^0.1.7-rc.2 || ^0.2.0-rc.2",
|
package/account.js
DELETED
|
@@ -1,723 +0,0 @@
|
|
|
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 { platform, release } from 'node:os'
|
|
24
|
-
import { promises as streamPromises } from 'node:stream'
|
|
25
|
-
|
|
26
|
-
import { Service } from '@deepseek-ai/cordis'
|
|
27
|
-
import { credentialKey } from '@deepseek-ai/dsh-credentials'
|
|
28
|
-
import {
|
|
29
|
-
DeepSeekAccount,
|
|
30
|
-
installAccountTaskCancellation,
|
|
31
|
-
} from '@deepseek-ai/dsh-deepseek-account'
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Deployment defaults. Every one of these is overridable through the row's
|
|
35
|
-
* `config`, so the composition stays the single source of truth and this module
|
|
36
|
-
* carries no deployment-specific literal that a patch cannot correct.
|
|
37
|
-
*/
|
|
38
|
-
const DEFAULT_ISSUER = 'https://rest.gatemux.ai'
|
|
39
|
-
const DEFAULT_CONSOLE_ORIGIN = 'https://gatemux.ai'
|
|
40
|
-
/** Exact callback path registered for this application; loopback permits any port. */
|
|
41
|
-
const CALLBACK_PATH = '/callback'
|
|
42
|
-
const KEY = credentialKey('gatemux', 'account')
|
|
43
|
-
/** Refresh this long before actual expiry so an in-flight request cannot race it. */
|
|
44
|
-
const REFRESH_MARGIN_MS = 60_000
|
|
45
|
-
const ATTEMPT_TIMEOUT_MS = 600_000
|
|
46
|
-
const REQUEST_TIMEOUT_MS = 30_000
|
|
47
|
-
const DEFAULT_SCOPES = ['models:read', 'responses:create', 'media:create', 'account:read', 'openid', 'email']
|
|
48
|
-
|
|
49
|
-
/** Local attempt failure taxonomy; the abstract contract renders these through locale copy. */
|
|
50
|
-
class GateMuxAuthError extends Error {
|
|
51
|
-
/** @param message - diagnostic text without credentials. @param code - stable failure class. */
|
|
52
|
-
constructor(message, code) {
|
|
53
|
-
super(message)
|
|
54
|
-
this.name = 'GateMuxAuthError'
|
|
55
|
-
this.code = code
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Validate the stored grant without trusting its shape. */
|
|
60
|
-
function parseGrant(payload, issuer) {
|
|
61
|
-
if (typeof payload !== 'object' || payload === null) return null
|
|
62
|
-
const { version, accessToken, refreshToken, expiresAt } = payload
|
|
63
|
-
if (version !== 1) return null
|
|
64
|
-
if (typeof accessToken !== 'string' || accessToken.length === 0) return null
|
|
65
|
-
if (typeof refreshToken !== 'string' || refreshToken.length === 0) return null
|
|
66
|
-
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return null
|
|
67
|
-
// A grant minted by any other issuer is not one this deployment may use.
|
|
68
|
-
if (payload.issuer !== issuer) return null
|
|
69
|
-
return { version: 1, accessToken, refreshToken, expiresAt, issuer }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Read a stored record as a grant this deployment may use.
|
|
74
|
-
* @param record - record at this provider's key, or undefined when none is stored.
|
|
75
|
-
* @param issuer - origin a grant must carry to be usable here.
|
|
76
|
-
* @returns the parsed grant, or null when no record is stored or it is unusable.
|
|
77
|
-
*/
|
|
78
|
-
function usableGrant(record, issuer) {
|
|
79
|
-
return record !== undefined && record.kind === 'grant' ? parseGrant(record.payload, issuer) : null
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Accept a bare HTTPS origin, rejecting paths, credentials, and non-HTTPS schemes. */
|
|
83
|
-
function originOf(value) {
|
|
84
|
-
let url
|
|
85
|
-
try { url = new URL(value) } catch { throw new GateMuxAuthError('origin must be an absolute URL', 'protocol') }
|
|
86
|
-
if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
|
87
|
-
throw new GateMuxAuthError('origin must be a bare HTTPS origin without a path', 'protocol')
|
|
88
|
-
}
|
|
89
|
-
return url.origin
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Accept a browser-accessible loopback HTTP origin, mirroring the account seam's expectation. */
|
|
93
|
-
function loginOrigin(value) {
|
|
94
|
-
const explicitPort = /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):([0-9]+)\/?$/i.exec(value)?.[1]
|
|
95
|
-
if (explicitPort === undefined || Number(explicitPort) === 0) {
|
|
96
|
-
throw new GateMuxAuthError('callback origin must be loopback HTTP with an explicit port', 'protocol')
|
|
97
|
-
}
|
|
98
|
-
return `http://${new URL(value).hostname}:${Number(explicitPort)}`
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Extract the RFC 6749 §5.2 error code from a token-endpoint response body.
|
|
103
|
-
* The token endpoint uses a flat envelope, unlike the nested API envelope.
|
|
104
|
-
*/
|
|
105
|
-
function tokenErrorCode(body) {
|
|
106
|
-
if (typeof body !== 'object' || body === null) return undefined
|
|
107
|
-
return typeof body.error === 'string' ? body.error : undefined
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Extract the machine code from a nested API error envelope. */
|
|
111
|
-
function apiErrorCode(body) {
|
|
112
|
-
if (typeof body !== 'object' || body === null) return undefined
|
|
113
|
-
const error = body.error
|
|
114
|
-
if (typeof error !== 'object' || error === null) return undefined
|
|
115
|
-
return typeof error.code === 'string' ? error.code : undefined
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** GateMux login state, stored grant, and refresh rotation. */
|
|
119
|
-
export class GateMuxAccount extends DeepSeekAccount {
|
|
120
|
-
static inject = ['credentials', 'authorization']
|
|
121
|
-
|
|
122
|
-
clientId
|
|
123
|
-
issuer
|
|
124
|
-
consoleOrigin
|
|
125
|
-
authorizeEndpoint
|
|
126
|
-
tokenEndpoint
|
|
127
|
-
revokeEndpoint
|
|
128
|
-
accountEndpoint
|
|
129
|
-
userinfoEndpoint
|
|
130
|
-
logger
|
|
131
|
-
scopes
|
|
132
|
-
attempt
|
|
133
|
-
listeners = new Set()
|
|
134
|
-
detailsLifetime = new AbortController()
|
|
135
|
-
lastProfile
|
|
136
|
-
refreshInFlight
|
|
137
|
-
removing
|
|
138
|
-
closed = false
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* @param ctx - Host with the credentials and authorization services.
|
|
142
|
-
* @param config - application identity and the granted scope set.
|
|
143
|
-
*/
|
|
144
|
-
constructor(ctx, config = {}) {
|
|
145
|
-
super(ctx)
|
|
146
|
-
installAccountTaskCancellation(ctx)
|
|
147
|
-
const clientId = config.clientId
|
|
148
|
-
if (typeof clientId !== 'string' || clientId.length === 0) {
|
|
149
|
-
throw new GateMuxAuthError('clientId is required', 'protocol')
|
|
150
|
-
}
|
|
151
|
-
this.clientId = clientId
|
|
152
|
-
// `rest.gatemux.ai` serves both the API and the OAuth endpoints; the console
|
|
153
|
-
// is a separate origin used only for links the user opens.
|
|
154
|
-
const issuer = originOf(config.authorizeOrigin ?? DEFAULT_ISSUER)
|
|
155
|
-
this.issuer = issuer
|
|
156
|
-
this.consoleOrigin = originOf(config.consoleOrigin ?? DEFAULT_CONSOLE_ORIGIN)
|
|
157
|
-
this.authorizeEndpoint = `${issuer}/v1/oauth/authorize`
|
|
158
|
-
this.tokenEndpoint = `${issuer}/v1/oauth/token`
|
|
159
|
-
this.revokeEndpoint = `${issuer}/v1/oauth/revoke`
|
|
160
|
-
this.accountEndpoint = `${issuer}/v1/account/me`
|
|
161
|
-
this.userinfoEndpoint = `${issuer}/v1/oauth/userinfo`
|
|
162
|
-
this.logger = ctx.logger('gatemux-account')
|
|
163
|
-
this.scopes = Array.isArray(config.scopes) && config.scopes.length > 0 ? [...config.scopes] : DEFAULT_SCOPES
|
|
164
|
-
ctx.authorization.registerFlow({
|
|
165
|
-
key: KEY,
|
|
166
|
-
label: 'GateMux',
|
|
167
|
-
methods: [{ id: 'browser', label: 'GateMux' }],
|
|
168
|
-
run: (session) => {
|
|
169
|
-
const attempt = this.attempt
|
|
170
|
-
if (attempt === undefined) return Promise.reject(new GateMuxAuthError('no attempt', 'protocol'))
|
|
171
|
-
attempt.running = this.run(session, attempt)
|
|
172
|
-
return attempt.running
|
|
173
|
-
},
|
|
174
|
-
})
|
|
175
|
-
ctx.on('credentials/record-updated', (key) => {
|
|
176
|
-
if (key !== KEY) return
|
|
177
|
-
this.invalidateDetails()
|
|
178
|
-
this.changed()
|
|
179
|
-
})
|
|
180
|
-
ctx.effect(() => async () => {
|
|
181
|
-
this.closed = true
|
|
182
|
-
this.invalidateDetails()
|
|
183
|
-
const active = this.attempt
|
|
184
|
-
if (active !== undefined) {
|
|
185
|
-
if (active.view.phase !== 'committing') active.controller.abort()
|
|
186
|
-
await active.done
|
|
187
|
-
}
|
|
188
|
-
await this.removing
|
|
189
|
-
this.changed()
|
|
190
|
-
}, 'gatemux-account: active attempt lifetime')
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Discard a stored grant issued by another environment. */
|
|
194
|
-
async [Service.init]() {
|
|
195
|
-
const record = await this.ctx.credentials.readRecord(KEY)
|
|
196
|
-
if (record === undefined || usableGrant(record, this.issuer) !== null) return
|
|
197
|
-
await this.ctx.credentials.deleteRecord(KEY)
|
|
198
|
-
this.logger.warn('stored grant discarded', { reason: 'unreadable-or-foreign-issuer' })
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async getState() {
|
|
202
|
-
const record = await this.ctx.credentials.readRecord(KEY)
|
|
203
|
-
const grant = usableGrant(record, this.issuer)
|
|
204
|
-
// A record this deployment cannot use reads as absent instead of raising: every
|
|
205
|
-
// caller turns a throw into a failure with no diagnosis. `startSignIn` returns
|
|
206
|
-
// this outside its own catch, so a raise there rejects the start call and the
|
|
207
|
-
// dialog reports a sign-in failure for a record the person never supplied, and
|
|
208
|
-
// `watch` ends the account stream. The record is left in place for a sign-in to
|
|
209
|
-
// overwrite; discarding it here could delete a grant committed since the read.
|
|
210
|
-
if (record !== undefined && grant === null) {
|
|
211
|
-
this.logger.warn('stored grant is unreadable', { reason: 'unreadable-or-foreign-issuer' })
|
|
212
|
-
}
|
|
213
|
-
const attempt = this.attempt?.view ?? null
|
|
214
|
-
return {
|
|
215
|
-
status: grant === null ? 'signed-out' : 'credential-stored',
|
|
216
|
-
attempt: grant === null && attempt?.phase === 'succeeded' ? null : attempt,
|
|
217
|
-
links: {
|
|
218
|
-
usageUrl: `${this.consoleOrigin}/app/usage`,
|
|
219
|
-
topUpUrl: `${this.consoleOrigin}/app/billing`,
|
|
220
|
-
},
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
async getProfile(client) {
|
|
225
|
-
const lifetime = this.detailsLifetime
|
|
226
|
-
const grant = await this.readGrant(lifetime)
|
|
227
|
-
if (grant === null || lifetime.signal.aborted) return null
|
|
228
|
-
const identity = await this.readIdentity(grant, lifetime, client)
|
|
229
|
-
if (this.detailsLifetime !== lifetime) return null
|
|
230
|
-
if (identity === null) return { status: 'failed' }
|
|
231
|
-
this.cacheProfile(grant.accessToken, identity)
|
|
232
|
-
return { status: 'ready', value: identity }
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Wallet balances require the `billing:read` scope, which this application is
|
|
237
|
-
* not granted. A response without a usable wallet is reported as a failed
|
|
238
|
-
* balance rather than an invented zero.
|
|
239
|
-
*/
|
|
240
|
-
async getBalance(client) {
|
|
241
|
-
const lifetime = this.detailsLifetime
|
|
242
|
-
const grant = await this.readGrant(lifetime)
|
|
243
|
-
if (grant === null || lifetime.signal.aborted) return null
|
|
244
|
-
const body = await this.callApi(this.accountEndpoint, grant, lifetime, client)
|
|
245
|
-
if (this.detailsLifetime !== lifetime) return null
|
|
246
|
-
if (body === null) return { status: 'failed' }
|
|
247
|
-
const wallet = body.wallet
|
|
248
|
-
if (typeof wallet !== 'object' || wallet === null) return { status: 'failed' }
|
|
249
|
-
const { currency, balance } = wallet
|
|
250
|
-
if (currency !== 'CNY' && currency !== 'USD') return { status: 'failed' }
|
|
251
|
-
if (typeof balance !== 'string') return { status: 'failed' }
|
|
252
|
-
return { status: 'ready', value: [{ currency, balance }], bonusWallets: [] }
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** GateMux grants no campaign bonuses; the contract reports an empty batch. */
|
|
256
|
-
async getUnnotifiedBonuses(client) {
|
|
257
|
-
const lifetime = this.detailsLifetime
|
|
258
|
-
const grant = await this.readGrant(lifetime)
|
|
259
|
-
if (grant === null || lifetime.signal.aborted) return null
|
|
260
|
-
const profile = this.lastProfile?.token === grant.accessToken ? this.lastProfile.profile : null
|
|
261
|
-
const accountId = profile?.id ?? (await this.getProfile(client))?.value?.id ?? null
|
|
262
|
-
if (accountId === null) return null
|
|
263
|
-
return { accountId, bonuses: [] }
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** No bonus can be acknowledged because none is ever granted. */
|
|
267
|
-
async ackBonusNotified() {
|
|
268
|
-
return false
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
async startSignIn(_client, callbackOrigin, loginSource) {
|
|
272
|
-
if (this.removing !== undefined) await this.removing
|
|
273
|
-
const origin = loginOrigin(callbackOrigin)
|
|
274
|
-
if (this.closed) throw new GateMuxAuthError('provider is closed', 'protocol')
|
|
275
|
-
const active = this.attempt
|
|
276
|
-
if (active !== undefined && ['initializing', 'waiting-browser', 'exchanging', 'committing'].includes(active.view.phase)) {
|
|
277
|
-
return this.getState()
|
|
278
|
-
}
|
|
279
|
-
const attempt = {
|
|
280
|
-
origin,
|
|
281
|
-
loginSource,
|
|
282
|
-
view: { id: randomUUID(), phase: 'initializing' },
|
|
283
|
-
controller: new AbortController(),
|
|
284
|
-
done: Promise.resolve(),
|
|
285
|
-
running: Promise.resolve(),
|
|
286
|
-
}
|
|
287
|
-
this.attempt = attempt
|
|
288
|
-
attempt.done = this.ctx.authorization.begin({
|
|
289
|
-
key: KEY,
|
|
290
|
-
signal: attempt.controller.signal,
|
|
291
|
-
interaction: { notify: () => undefined, prompt: () => Promise.reject(new GateMuxAuthError('no prompt', 'protocol')) },
|
|
292
|
-
}).then((outcome) => {
|
|
293
|
-
this.update(attempt, { phase: outcome.status === 'authorized' ? 'succeeded' : 'cancelled' })
|
|
294
|
-
if (outcome.status === 'authorized' && attempt.completionUrl !== undefined) {
|
|
295
|
-
attempt.callback?.writeHead(302, { location: attempt.completionUrl, 'cache-control': 'no-store' }).end()
|
|
296
|
-
} else if (outcome.status === 'authorized') {
|
|
297
|
-
// GateMux serves no hosted completion page, so this tab is the only place
|
|
298
|
-
// the result is visible; answering 204 would leave it blank.
|
|
299
|
-
this.writeCallbackPage(attempt, 'Signed in', attempt.loginSource === 'web'
|
|
300
|
-
? 'Signed in to GateMux. Close this tab and continue in the original tab.'
|
|
301
|
-
: 'Signed in to GateMux. Close this tab and continue in DeepSeek Harness.')
|
|
302
|
-
} else {
|
|
303
|
-
attempt.callback?.writeHead(204, { 'cache-control': 'no-store' }).end()
|
|
304
|
-
}
|
|
305
|
-
}).catch((error) => {
|
|
306
|
-
const code = error instanceof GateMuxAuthError ? error.code : 'protocol'
|
|
307
|
-
// Anything that is not a GateMuxAuthError is a defect rather than a rejected
|
|
308
|
-
// attempt, so its message is kept: otherwise every such failure reads as a bare
|
|
309
|
-
// `protocol` and the exchange that rejected it cannot be told from the seam.
|
|
310
|
-
const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
|
|
311
|
-
this.logger.warn('sign-in failed', { errorCode: code, detail })
|
|
312
|
-
this.update(attempt, { phase: code === 'expired' ? 'expired' : 'failed', errorCode: code })
|
|
313
|
-
this.finishFailedCallback(attempt)
|
|
314
|
-
}).then(async () => {
|
|
315
|
-
await attempt.running.catch(() => undefined)
|
|
316
|
-
if (attempt.callback !== undefined) {
|
|
317
|
-
await streamPromises.finished(attempt.callback, { cleanup: true }).catch(() => undefined)
|
|
318
|
-
}
|
|
319
|
-
await attempt.disposeCallback?.()
|
|
320
|
-
})
|
|
321
|
-
this.changed()
|
|
322
|
-
return this.getState()
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
async cancelSignIn(id) {
|
|
326
|
-
const attempt = this.attempt
|
|
327
|
-
if (attempt?.view.id === id) {
|
|
328
|
-
if (attempt.view.phase !== 'committing') {
|
|
329
|
-
attempt.controller.abort()
|
|
330
|
-
this.ctx.authorization.cancel(KEY)
|
|
331
|
-
}
|
|
332
|
-
await attempt.done
|
|
333
|
-
}
|
|
334
|
-
return this.getState()
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
signOut(client) {
|
|
338
|
-
this.removing ??= (async () => {
|
|
339
|
-
if (this.closed) throw new GateMuxAuthError('provider is closed', 'protocol')
|
|
340
|
-
if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id)
|
|
341
|
-
const record = await this.ctx.credentials.readRecord(KEY)
|
|
342
|
-
if (record !== undefined) {
|
|
343
|
-
const grant = record.kind === 'grant' ? parseGrant(record.payload, this.issuer) : null
|
|
344
|
-
if (grant === null) throw new GateMuxAuthError('stored grant is unreadable', 'storage')
|
|
345
|
-
await this.ctx.credentials.deleteRecord(KEY)
|
|
346
|
-
// Revoking the refresh token cascades to every access token it issued.
|
|
347
|
-
this.revoke(grant.refreshToken)
|
|
348
|
-
}
|
|
349
|
-
this.attempt = undefined
|
|
350
|
-
this.ctx.emit('deepseek-account/signed-out')
|
|
351
|
-
this.changed()
|
|
352
|
-
return this.getState()
|
|
353
|
-
})().finally(() => { this.removing = undefined })
|
|
354
|
-
return this.removing
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
async *watch(signal) {
|
|
358
|
-
let dirty = true
|
|
359
|
-
let wake
|
|
360
|
-
const changed = () => { dirty = true; wake?.() }
|
|
361
|
-
this.listeners.add(changed)
|
|
362
|
-
signal.addEventListener('abort', changed, { once: true })
|
|
363
|
-
try {
|
|
364
|
-
while (!this.closed && !signal.aborted) {
|
|
365
|
-
if (dirty) { dirty = false; yield await this.getState(); continue }
|
|
366
|
-
await new Promise((resolve) => { wake = resolve })
|
|
367
|
-
}
|
|
368
|
-
} finally {
|
|
369
|
-
this.listeners.delete(changed)
|
|
370
|
-
signal.removeEventListener('abort', changed)
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/** Resolve a token only for the GateMux REST origin, refreshing it when stale. */
|
|
375
|
-
async resolveToken(url) {
|
|
376
|
-
if (this.closed || this.removing !== undefined) return undefined
|
|
377
|
-
let destination
|
|
378
|
-
try { destination = new URL(url) } catch { return undefined }
|
|
379
|
-
if (destination.origin !== this.issuer || destination.username || destination.password) return undefined
|
|
380
|
-
const grant = await this.freshGrant()
|
|
381
|
-
return grant?.accessToken
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/** Remove a rejected token only while it still matches the stored credential. */
|
|
385
|
-
async rejectToken(token) {
|
|
386
|
-
const lifetime = this.detailsLifetime
|
|
387
|
-
const grant = await this.readGrant(lifetime)
|
|
388
|
-
if (grant?.accessToken !== token) return
|
|
389
|
-
await this.expireCredential(token, lifetime)
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/** GateMux embeds no console documents, so there is no host-only session to publish. */
|
|
393
|
-
async getPlatformSession() {
|
|
394
|
-
return null
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Read the signed-in account ID without a request and without credentials.
|
|
399
|
-
*
|
|
400
|
-
* GateMux registers no device, so `deviceId` is omitted rather than invented.
|
|
401
|
-
* The ID comes from the last successful profile read for this same grant; a
|
|
402
|
-
* caller that reads it before that profile read gets the OS version alone.
|
|
403
|
-
*
|
|
404
|
-
* @returns the Node platform version, plus the account ID while a profile for the stored grant is cached.
|
|
405
|
-
*/
|
|
406
|
-
async getDeviceIdentity() {
|
|
407
|
-
const lifetime = this.detailsLifetime
|
|
408
|
-
const grant = await this.readGrant(lifetime)
|
|
409
|
-
const profile = grant !== null && this.lastProfile?.token === grant.accessToken
|
|
410
|
-
? this.lastProfile.profile
|
|
411
|
-
: null
|
|
412
|
-
return {
|
|
413
|
-
...(profile?.id == null ? {} : { userId: profile.id }),
|
|
414
|
-
osVersion: `${platform()} ${release()}`,
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* Run one authorization attempt: PKCE, browser callback, code exchange, commit.
|
|
420
|
-
* @param session - the attempt's cancellation signal and commit channel.
|
|
421
|
-
* @param attempt - the attempt record whose phase drives the UI.
|
|
422
|
-
*/
|
|
423
|
-
async run(session, attempt) {
|
|
424
|
-
const webServer = this.ctx.get('webServer')
|
|
425
|
-
if (webServer === undefined) throw new GateMuxAuthError('web server is unavailable', 'protocol')
|
|
426
|
-
const verifier = randomBytes(32).toString('base64url')
|
|
427
|
-
const state = randomBytes(32).toString('base64url')
|
|
428
|
-
const challenge = createHash('sha256').update(verifier).digest('base64url')
|
|
429
|
-
const redirectUri = `${attempt.origin}${CALLBACK_PATH}`
|
|
430
|
-
const code = Promise.withResolvers()
|
|
431
|
-
// A callback may arrive before initialization settles; observe the rejection immediately.
|
|
432
|
-
void code.promise.catch(() => undefined)
|
|
433
|
-
const deadline = new AbortController()
|
|
434
|
-
const signal = AbortSignal.any([session.signal, deadline.signal])
|
|
435
|
-
// Published to the client and re-checked after the exchange, so it has to be
|
|
436
|
-
// the same instant the deadline aborts at rather than a second clock.
|
|
437
|
-
const expiresAt = Date.now() + ATTEMPT_TIMEOUT_MS
|
|
438
|
-
const timer = setTimeout(() =>{ deadline.abort() }, ATTEMPT_TIMEOUT_MS)
|
|
439
|
-
const abort = () => { code.reject(new GateMuxAuthError('attempt expired', 'expired')) }
|
|
440
|
-
signal.addEventListener('abort', abort, { once: true })
|
|
441
|
-
try {
|
|
442
|
-
attempt.disposeCallback = this.ctx.effect(() => webServer.register({
|
|
443
|
-
kind: 'exact', path: CALLBACK_PATH, handler: (req, res) => {
|
|
444
|
-
let url
|
|
445
|
-
try { url = new URL(req.url ?? '/', 'http://127.0.0.1') }
|
|
446
|
-
catch { res.writeHead(400, { 'cache-control': 'no-store' }).end(); return }
|
|
447
|
-
const receivedCode = url.searchParams.get('code')
|
|
448
|
-
const receivedState = url.searchParams.get('state') ?? ''
|
|
449
|
-
const validState = Buffer.byteLength(receivedState) === Buffer.byteLength(state)
|
|
450
|
-
&& timingSafeEqual(Buffer.from(receivedState), Buffer.from(state))
|
|
451
|
-
if (req.method !== 'GET' || url.pathname !== CALLBACK_PATH || !validState || !receivedCode
|
|
452
|
-
|| url.searchParams.getAll('state').length !== 1 || url.searchParams.getAll('code').length !== 1) {
|
|
453
|
-
res.writeHead(400, { 'cache-control': 'no-store' }).end(); return
|
|
454
|
-
}
|
|
455
|
-
if (signal.aborted || attempt.callback !== undefined || attempt.view.phase !== 'waiting-browser') {
|
|
456
|
-
res.writeHead(410, { 'cache-control': 'no-store' }).end(); return
|
|
457
|
-
}
|
|
458
|
-
attempt.callback = res
|
|
459
|
-
code.resolve(receivedCode)
|
|
460
|
-
},
|
|
461
|
-
}), 'gatemux-account: browser callback')
|
|
462
|
-
signal.throwIfAborted()
|
|
463
|
-
const authorizeUrl = new URL(this.authorizeEndpoint)
|
|
464
|
-
authorizeUrl.searchParams.set('response_type', 'code')
|
|
465
|
-
authorizeUrl.searchParams.set('client_id', this.clientId)
|
|
466
|
-
authorizeUrl.searchParams.set('redirect_uri', redirectUri)
|
|
467
|
-
authorizeUrl.searchParams.set('state', state)
|
|
468
|
-
authorizeUrl.searchParams.set('scope', this.scopes.join(' '))
|
|
469
|
-
authorizeUrl.searchParams.set('code_challenge', challenge)
|
|
470
|
-
authorizeUrl.searchParams.set('code_challenge_method', 'S256')
|
|
471
|
-
this.update(attempt, {
|
|
472
|
-
phase: 'waiting-browser',
|
|
473
|
-
authorizeUrl: authorizeUrl.href,
|
|
474
|
-
expiresAt,
|
|
475
|
-
})
|
|
476
|
-
const receivedCode = await code.promise
|
|
477
|
-
signal.throwIfAborted()
|
|
478
|
-
this.update(attempt, { phase: 'exchanging' })
|
|
479
|
-
const grant = await this.exchange(receivedCode, verifier, redirectUri, signal)
|
|
480
|
-
if (Date.now() >= expiresAt) throw new GateMuxAuthError('attempt expired', 'expired')
|
|
481
|
-
signal.throwIfAborted()
|
|
482
|
-
this.update(attempt, { phase: 'committing' })
|
|
483
|
-
try {
|
|
484
|
-
await session.commit({ kind: 'grant', payload: grant })
|
|
485
|
-
} catch {
|
|
486
|
-
throw new GateMuxAuthError('stored grant could not be written', 'storage')
|
|
487
|
-
}
|
|
488
|
-
} catch (error) {
|
|
489
|
-
if (deadline.signal.aborted) throw new GateMuxAuthError('attempt expired', 'expired')
|
|
490
|
-
throw error
|
|
491
|
-
} finally {
|
|
492
|
-
clearTimeout(timer)
|
|
493
|
-
signal.removeEventListener('abort', abort)
|
|
494
|
-
// begin() settles the browser response and removes only this attempt's route.
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* Exchange an authorization code for tokens.
|
|
500
|
-
*
|
|
501
|
-
* `invalid_grant` is terminal: the server voids the code when the verifier
|
|
502
|
-
* does not match, so retrying the same code can never succeed.
|
|
503
|
-
*/
|
|
504
|
-
async exchange(code, verifier, redirectUri, signal) {
|
|
505
|
-
const response = await this.postToken({
|
|
506
|
-
grant_type: 'authorization_code',
|
|
507
|
-
code,
|
|
508
|
-
code_verifier: verifier,
|
|
509
|
-
client_id: this.clientId,
|
|
510
|
-
redirect_uri: redirectUri,
|
|
511
|
-
}, signal)
|
|
512
|
-
if (!response.ok) {
|
|
513
|
-
// RFC 6749 §5.2 flat body. A reused `code`, a `code_verifier` that does not match
|
|
514
|
-
// the challenge, and a `redirect_uri` that differs from the authorize request all
|
|
515
|
-
// arrive as `invalid_grant`, so the server's own description is the only thing
|
|
516
|
-
// that tells them apart — discarding it leaves nothing to act on.
|
|
517
|
-
const code = tokenErrorCode(response.body)
|
|
518
|
-
const description = typeof response.body?.error_description === 'string' ? response.body.error_description : undefined
|
|
519
|
-
this.logger.warn('authorization code rejected', { status: response.status, errorCode: code ?? 'unknown', description })
|
|
520
|
-
throw new GateMuxAuthError(`authorization code rejected (${response.status} ${code ?? 'unknown'})`, 'protocol')
|
|
521
|
-
}
|
|
522
|
-
return this.grantFrom(response.body)
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
/** Read the newest grant, refreshing it when it is close to expiry. */
|
|
526
|
-
async freshGrant() {
|
|
527
|
-
const grant = await this.readGrant(this.detailsLifetime)
|
|
528
|
-
if (grant === null) return null
|
|
529
|
-
if (grant.expiresAt - Date.now() > REFRESH_MARGIN_MS) return grant
|
|
530
|
-
this.refreshInFlight ??= this.rotate().finally(() => { this.refreshInFlight = undefined })
|
|
531
|
-
try {
|
|
532
|
-
return await this.refreshInFlight
|
|
533
|
-
} catch {
|
|
534
|
-
return null
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/**
|
|
539
|
-
* Rotate the refresh token exactly once at a time.
|
|
540
|
-
*
|
|
541
|
-
* Refreshes are serialized because GateMux voids the whole authorization
|
|
542
|
-
* chain when a rotated refresh token reappears after a grace period.
|
|
543
|
-
*/
|
|
544
|
-
async rotate() {
|
|
545
|
-
const lifetime = this.detailsLifetime
|
|
546
|
-
const grant = await this.readGrant(lifetime)
|
|
547
|
-
if (grant === null) throw new GateMuxAuthError('signed out', 'expired')
|
|
548
|
-
const response = await this.postToken({
|
|
549
|
-
grant_type: 'refresh_token',
|
|
550
|
-
refresh_token: grant.refreshToken,
|
|
551
|
-
client_id: this.clientId,
|
|
552
|
-
}, lifetime.signal)
|
|
553
|
-
if (!response.ok) {
|
|
554
|
-
const code = tokenErrorCode(response.body)
|
|
555
|
-
// An unknown or expired refresh token means the authorization is gone.
|
|
556
|
-
this.logger.warn('refresh rejected', { errorCode: code ?? 'unknown' })
|
|
557
|
-
await this.expireCredential(grant.accessToken, lifetime)
|
|
558
|
-
throw new GateMuxAuthError('refresh token rejected', 'expired')
|
|
559
|
-
}
|
|
560
|
-
const next = this.grantFrom(response.body)
|
|
561
|
-
// Persist the rotated token before any other request can read the old one.
|
|
562
|
-
await this.ctx.credentials.modifyRecord(KEY, (current) => Promise.resolve(
|
|
563
|
-
current !== undefined && parseGrant(current.payload, this.issuer) !== null
|
|
564
|
-
? { kind: 'grant', payload: next }
|
|
565
|
-
: undefined,
|
|
566
|
-
))
|
|
567
|
-
return next
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
/** Build a validated grant from a token-endpoint response. */
|
|
571
|
-
grantFrom(body) {
|
|
572
|
-
if (typeof body !== 'object' || body === null) throw new GateMuxAuthError('token response was not an object', 'protocol')
|
|
573
|
-
const { access_token: accessToken, refresh_token: refreshToken, expires_in: expiresIn } = body
|
|
574
|
-
if (typeof accessToken !== 'string' || accessToken.length === 0) throw new GateMuxAuthError('token response carried no access token', 'protocol')
|
|
575
|
-
if (typeof refreshToken !== 'string' || refreshToken.length === 0) throw new GateMuxAuthError('token response carried no refresh token', 'protocol')
|
|
576
|
-
const seconds = typeof expiresIn === 'number' && Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600
|
|
577
|
-
return { version: 1, accessToken, refreshToken, expiresAt: Date.now() + seconds * 1000, issuer: this.issuer }
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
/** Post one token-endpoint request and decode its RFC 6749 §5.2 envelope. */
|
|
581
|
-
async postToken(fields, signal) {
|
|
582
|
-
const response = await fetch(this.tokenEndpoint, {
|
|
583
|
-
method: 'POST',
|
|
584
|
-
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
585
|
-
body: JSON.stringify(fields),
|
|
586
|
-
signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
|
|
587
|
-
})
|
|
588
|
-
let body
|
|
589
|
-
try { body = await response.json() } catch { body = undefined }
|
|
590
|
-
return { ok: response.ok, status: response.status, body }
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
/**
|
|
594
|
-
* Call one account endpoint, expiring the credential on an authenticated rejection.
|
|
595
|
-
* @returns the decoded body, or null when the credential was rejected.
|
|
596
|
-
*/
|
|
597
|
-
async callApi(url, grant, lifetime, _client) {
|
|
598
|
-
let response
|
|
599
|
-
try {
|
|
600
|
-
response = await fetch(url, {
|
|
601
|
-
headers: { authorization: `Bearer ${grant.accessToken}`, accept: 'application/json' },
|
|
602
|
-
signal: AbortSignal.any([lifetime.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
|
|
603
|
-
})
|
|
604
|
-
} catch {
|
|
605
|
-
return null
|
|
606
|
-
}
|
|
607
|
-
let body
|
|
608
|
-
try { body = await response.json() } catch { body = undefined }
|
|
609
|
-
if (response.ok) return body
|
|
610
|
-
// 401 credential_invalid means re-authorization; other codes are configuration problems.
|
|
611
|
-
if (response.status === 401 || apiErrorCode(body) === 'credential_invalid') {
|
|
612
|
-
if (this.detailsLifetime === lifetime) await this.expireCredential(grant.accessToken, lifetime)
|
|
613
|
-
return null
|
|
614
|
-
}
|
|
615
|
-
return null
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
/** Read the profile identity, enriching it with the OpenID email when available. */
|
|
619
|
-
async readIdentity(grant, lifetime, client) {
|
|
620
|
-
const me = await this.callApi(this.accountEndpoint, grant, lifetime, client)
|
|
621
|
-
if (me === null || typeof me !== 'object') return null
|
|
622
|
-
const accountId = typeof me.account_id === 'string' && me.account_id.length > 0 ? me.account_id : null
|
|
623
|
-
const masked = typeof me.email_masked === 'string' && me.email_masked.length > 0 ? me.email_masked : null
|
|
624
|
-
let contact = masked
|
|
625
|
-
if (contact === null) {
|
|
626
|
-
const info = await this.callApi(this.userinfoEndpoint, grant, lifetime, client)
|
|
627
|
-
if (typeof info === 'object' && info !== null && typeof info.email === 'string' && info.email.length > 0) {
|
|
628
|
-
contact = info.email
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
const id = accountId ?? (typeof me.user_id === 'string' && me.user_id.length > 0 ? me.user_id : null)
|
|
632
|
-
if (id === null && contact === null) return null
|
|
633
|
-
return { id, name: contact, contact, avatarUrl: null }
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
cacheProfile(token, profile) {
|
|
637
|
-
const previous = this.lastProfile?.profile.id ?? null
|
|
638
|
-
this.lastProfile = { token, profile }
|
|
639
|
-
if (previous !== profile.id) this.changed()
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
async readGrant(lifetime) {
|
|
643
|
-
if (this.closed) return null
|
|
644
|
-
const record = await this.ctx.credentials.readRecord(KEY)
|
|
645
|
-
if (record === undefined || lifetime.signal.aborted) return null
|
|
646
|
-
if (record.kind !== 'grant') throw new GateMuxAuthError('stored record is not a grant', 'storage')
|
|
647
|
-
const grant = parseGrant(record.payload, this.issuer)
|
|
648
|
-
if (grant === null) throw new GateMuxAuthError('stored grant is unreadable', 'storage')
|
|
649
|
-
return grant
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/** Delete the credential and notify consumers that the session ended. */
|
|
653
|
-
async expireCredential(token, lifetime) {
|
|
654
|
-
this.removing ??= (async () => {
|
|
655
|
-
if (this.attempt !== undefined) await this.cancelSignIn(this.attempt.view.id)
|
|
656
|
-
const record = await this.ctx.credentials.readRecord(KEY)
|
|
657
|
-
if (this.closed || this.detailsLifetime !== lifetime || record?.kind !== 'grant') return this.getState()
|
|
658
|
-
const current = parseGrant(record.payload, this.issuer)
|
|
659
|
-
if (current === null || current.accessToken !== token) return this.getState()
|
|
660
|
-
await this.ctx.credentials.deleteRecord(KEY)
|
|
661
|
-
this.ctx.emit('deepseek-account/session-expired')
|
|
662
|
-
this.attempt = undefined
|
|
663
|
-
this.ctx.emit('deepseek-account/signed-out')
|
|
664
|
-
this.changed()
|
|
665
|
-
return this.getState()
|
|
666
|
-
})().finally(() => { this.removing = undefined })
|
|
667
|
-
await this.removing
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/** Revoke a refresh token, cascading to its access tokens; failures are not fatal. */
|
|
671
|
-
revoke(refreshToken) {
|
|
672
|
-
if (this.closed) return
|
|
673
|
-
void fetch(this.revokeEndpoint, {
|
|
674
|
-
method: 'POST',
|
|
675
|
-
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
676
|
-
body: JSON.stringify({ token: refreshToken }),
|
|
677
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
678
|
-
}).then(() => undefined, () => undefined)
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
invalidateDetails() {
|
|
682
|
-
this.lastProfile = undefined
|
|
683
|
-
this.detailsLifetime.abort()
|
|
684
|
-
this.detailsLifetime = new AbortController()
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
changed() { for (const listener of this.listeners) listener() }
|
|
688
|
-
|
|
689
|
-
update(attempt, value) {
|
|
690
|
-
const { authorizeUrl: _url, ...rest } = attempt.view
|
|
691
|
-
attempt.view = { ...rest, ...value }
|
|
692
|
-
this.changed()
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
/**
|
|
697
|
-
* Answer the browser tab the flow was authorized in with a short page. The
|
|
698
|
-
* application opens that tab outside itself and never reads this response, so
|
|
699
|
-
* the page exists only for the person looking at it; text is static, which is
|
|
700
|
-
* why no script is needed and the policy can forbid every source.
|
|
701
|
-
* @param attempt - in-flight attempt whose callback is being answered.
|
|
702
|
-
* @param title - page title and heading.
|
|
703
|
-
* @param message - what the person should do next.
|
|
704
|
-
*/
|
|
705
|
-
writeCallbackPage(attempt, title, message) {
|
|
706
|
-
attempt.callback?.writeHead(200, {
|
|
707
|
-
'content-type': 'text/html; charset=utf-8',
|
|
708
|
-
'cache-control': 'no-store',
|
|
709
|
-
'content-security-policy': `default-src 'none'; frame-ancestors 'none'`,
|
|
710
|
-
'referrer-policy': 'no-referrer',
|
|
711
|
-
}).end(`<!doctype html><html lang="en"><meta charset="utf-8"><title>${title}</title>`
|
|
712
|
-
+ `<body><p>${message}</p></body></html>`)
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
/** Return the browser to the application after a failed exchange. */
|
|
716
|
-
finishFailedCallback(attempt) {
|
|
717
|
-
this.writeCallbackPage(attempt, 'Sign-in failed', attempt.loginSource === 'web'
|
|
718
|
-
? 'Sign-in failed. Close this tab and try again in the original tab.'
|
|
719
|
-
: 'Sign-in failed. Close this tab and try again in DeepSeek Harness.')
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
export default GateMuxAccount
|
package/llm.js
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
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 }
|