dsh-plugin-subscription-oauth 0.0.0-stage → 0.2.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 +413 -2
- package/cordis.patch.yml +13 -0
- package/lib/auth/device-flow.d.ts +112 -0
- package/lib/auth/device-flow.js +319 -0
- package/lib/auth/device-id.d.ts +31 -0
- package/lib/auth/device-id.js +58 -0
- package/lib/auth/flow.d.ts +33 -0
- package/lib/auth/flow.js +12 -0
- package/lib/auth/glm-flow.d.ts +99 -0
- package/lib/auth/glm-flow.js +194 -0
- package/lib/auth/headers.d.ts +61 -0
- package/lib/auth/headers.js +106 -0
- package/lib/auth/pkce-flow.d.ts +78 -0
- package/lib/auth/pkce-flow.js +205 -0
- package/lib/auth/provider-controller.d.ts +241 -0
- package/lib/auth/provider-controller.js +502 -0
- package/lib/auth/registry.d.ts +91 -0
- package/lib/auth/registry.js +149 -0
- package/lib/auth/rpc.d.ts +31 -0
- package/lib/auth/rpc.js +200 -0
- package/lib/auth/store.d.ts +99 -0
- package/lib/auth/store.js +213 -0
- package/lib/auth/tokens.d.ts +76 -0
- package/lib/auth/tokens.js +103 -0
- package/lib/client/SubscriptionsSection.d.ts +34 -0
- package/lib/client/SubscriptionsSection.js +742 -0
- package/lib/client/index.d.ts +36 -0
- package/lib/client/index.js +47 -0
- package/lib/client/locales.d.ts +187 -0
- package/lib/client/locales.js +185 -0
- package/lib/client/styles.d.ts +18 -0
- package/lib/client/styles.js +257 -0
- package/lib/client/subscriptions-rpc.d.ts +24 -0
- package/lib/client/subscriptions-rpc.js +44 -0
- package/lib/client.js +2078 -0
- package/lib/client.js.map +1 -0
- package/lib/constants.d.ts +79 -0
- package/lib/constants.js +91 -0
- package/lib/errors.d.ts +28 -0
- package/lib/errors.js +34 -0
- package/lib/http.d.ts +24 -0
- package/lib/http.js +98 -0
- package/lib/index.d.ts +57 -0
- package/lib/index.js +185 -0
- package/lib/protocol.d.ts +316 -0
- package/lib/protocol.js +29 -0
- package/lib/providers/antigravity-catalog.d.ts +53 -0
- package/lib/providers/antigravity-catalog.js +269 -0
- package/lib/providers/antigravity.d.ts +331 -0
- package/lib/providers/antigravity.js +821 -0
- package/lib/providers/auto.d.ts +151 -0
- package/lib/providers/auto.js +351 -0
- package/lib/providers/catalog.d.ts +69 -0
- package/lib/providers/catalog.js +78 -0
- package/lib/providers/common.d.ts +51 -0
- package/lib/providers/common.js +169 -0
- package/lib/providers/glm-provider.d.ts +41 -0
- package/lib/providers/glm-provider.js +165 -0
- package/lib/providers/glm.d.ts +159 -0
- package/lib/providers/glm.js +445 -0
- package/lib/providers/grok.d.ts +224 -0
- package/lib/providers/grok.js +700 -0
- package/lib/providers/kimi-provider.d.ts +45 -0
- package/lib/providers/kimi-provider.js +148 -0
- package/lib/providers/kimi.d.ts +151 -0
- package/lib/providers/kimi.js +400 -0
- package/lib/providers/responses-stream.d.ts +58 -0
- package/lib/providers/responses-stream.js +115 -0
- package/lib/providers/usage.d.ts +65 -0
- package/lib/providers/usage.js +309 -0
- package/lib/settings.d.ts +122 -0
- package/lib/settings.js +333 -0
- package/lib/translate/antigravity-schema.d.ts +24 -0
- package/lib/translate/antigravity-schema.js +174 -0
- package/lib/translate/antigravity.d.ts +184 -0
- package/lib/translate/antigravity.js +419 -0
- package/lib/translate/responses.d.ts +156 -0
- package/lib/translate/responses.js +395 -0
- package/lib/translate/sse.d.ts +27 -0
- package/lib/translate/sse.js +82 -0
- package/package.json +126 -4
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider registry: one erased façade over every route's controller.
|
|
3
|
+
*
|
|
4
|
+
* The RPC layer talks to this object only, so it never has to know how many
|
|
5
|
+
* providers exist, what their routes are called, or which login shape each one
|
|
6
|
+
* uses. Each route's controller is generic over its own session type; this
|
|
7
|
+
* façade is the single place that erasure happens.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-plugin-subscription-oauth/auth/registry
|
|
10
|
+
*/
|
|
11
|
+
import { BAD_REQUEST, ProviderError } from '../errors.js';
|
|
12
|
+
/**
|
|
13
|
+
* Where a route sits in the page.
|
|
14
|
+
*
|
|
15
|
+
* `auto` is a way to reach every other route, so it leads the list; the routes it
|
|
16
|
+
* stands in for keep the order they were configured in. Registration order is
|
|
17
|
+
* therefore not the reading order, and this is the one place that says so.
|
|
18
|
+
*
|
|
19
|
+
* @param id - the route key.
|
|
20
|
+
* @returns the sort rank; lower reads first.
|
|
21
|
+
*/
|
|
22
|
+
function displayRank(id) {
|
|
23
|
+
return id === 'auto' ? 0 : 1;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Routes every Settings call to the provider it names.
|
|
27
|
+
*
|
|
28
|
+
* A provider key the plugin does not serve is a request error, not an internal
|
|
29
|
+
* failure: the message names the routes that do exist.
|
|
30
|
+
*/
|
|
31
|
+
export class SubscriptionRegistry {
|
|
32
|
+
byId;
|
|
33
|
+
constructor(routes) {
|
|
34
|
+
this.byId = new Map(routes.map((route) => [route.id, route]));
|
|
35
|
+
}
|
|
36
|
+
/** Route keys this registry serves, in display order. */
|
|
37
|
+
ids() {
|
|
38
|
+
return [...this.byId.keys()];
|
|
39
|
+
}
|
|
40
|
+
/** Every route, in registration order. */
|
|
41
|
+
all() {
|
|
42
|
+
return [...this.byId.values()];
|
|
43
|
+
}
|
|
44
|
+
/** One route by key, without the request-error path. */
|
|
45
|
+
get(id) {
|
|
46
|
+
return this.byId.get(id);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Add one route.
|
|
50
|
+
*
|
|
51
|
+
* `auto` is built last on purpose: it stands in for the others, so it is
|
|
52
|
+
* registered once their catalogs exist, without a circular construction order.
|
|
53
|
+
* That is a construction detail, not a display one — the page shows `auto`
|
|
54
|
+
* first, so the reading order is stated here rather than inherited from the
|
|
55
|
+
* order the routes happened to be built in.
|
|
56
|
+
*
|
|
57
|
+
* @param route - the route to serve from now on.
|
|
58
|
+
* @throws {ProviderError} when the key is already served.
|
|
59
|
+
*/
|
|
60
|
+
register(route) {
|
|
61
|
+
if (this.byId.has(route.id)) {
|
|
62
|
+
throw new ProviderError(`provider ${route.id} is already registered`, BAD_REQUEST);
|
|
63
|
+
}
|
|
64
|
+
this.byId.set(route.id, route);
|
|
65
|
+
// Re-insert in display order: a leading route (Auto) is built last but read first.
|
|
66
|
+
const ordered = [...this.byId.entries()].sort(([left], [right]) => displayRank(left) - displayRank(right));
|
|
67
|
+
this.byId.clear();
|
|
68
|
+
for (const [id, entry] of ordered)
|
|
69
|
+
this.byId.set(id, entry);
|
|
70
|
+
}
|
|
71
|
+
/** One route, or a request error naming the known ones. */
|
|
72
|
+
route(provider) {
|
|
73
|
+
const found = this.byId.get(provider);
|
|
74
|
+
if (found === undefined) {
|
|
75
|
+
throw new ProviderError(`unknown provider ${JSON.stringify(provider)}; this plugin serves ${[...this.byId.keys()].join(', ')}`, BAD_REQUEST);
|
|
76
|
+
}
|
|
77
|
+
return found;
|
|
78
|
+
}
|
|
79
|
+
/** Every provider's state, in registration order. */
|
|
80
|
+
async status() {
|
|
81
|
+
return { providers: await Promise.all([...this.byId.values()].map((route) => route.status())) };
|
|
82
|
+
}
|
|
83
|
+
/** Start a login on one route. */
|
|
84
|
+
async login(provider, account) {
|
|
85
|
+
const started = await this.route(provider).login(account);
|
|
86
|
+
return { prompt: started.prompt, status: await this.status() };
|
|
87
|
+
}
|
|
88
|
+
/** Abandon one route's pending login. */
|
|
89
|
+
async cancel(provider) {
|
|
90
|
+
await this.route(provider).cancel();
|
|
91
|
+
return this.status();
|
|
92
|
+
}
|
|
93
|
+
/** Forget one account of one route. */
|
|
94
|
+
async logout(provider, account) {
|
|
95
|
+
await this.route(provider).logout(account);
|
|
96
|
+
return this.status();
|
|
97
|
+
}
|
|
98
|
+
/** Promote one account to default. */
|
|
99
|
+
async setDefault(provider, account) {
|
|
100
|
+
await this.route(provider).setDefault(account);
|
|
101
|
+
return this.status();
|
|
102
|
+
}
|
|
103
|
+
/** Refresh one account's token now. */
|
|
104
|
+
async refresh(provider, account) {
|
|
105
|
+
const result = await this.route(provider).refresh(account);
|
|
106
|
+
return { account: result.account, expiresAt: result.expiresAt, status: await this.status() };
|
|
107
|
+
}
|
|
108
|
+
/** Re-read one route's live model list. */
|
|
109
|
+
async discover(provider, force = false) {
|
|
110
|
+
await this.route(provider).discover(force);
|
|
111
|
+
return this.status();
|
|
112
|
+
}
|
|
113
|
+
/** Set one model's picker visibility. */
|
|
114
|
+
async setModelVisible(provider, id, visible) {
|
|
115
|
+
await this.route(provider).setModelVisible(id, visible);
|
|
116
|
+
return this.status();
|
|
117
|
+
}
|
|
118
|
+
/** Replace one route's model order with one dropped arrangement. */
|
|
119
|
+
async setModelOrder(provider, ids) {
|
|
120
|
+
await this.route(provider).setModelOrder(ids);
|
|
121
|
+
return this.status();
|
|
122
|
+
}
|
|
123
|
+
/** Add one model id by hand to one route. */
|
|
124
|
+
async addModel(provider, id) {
|
|
125
|
+
await this.route(provider).addModel(id);
|
|
126
|
+
return this.status();
|
|
127
|
+
}
|
|
128
|
+
/** Forget one hand-added model id. */
|
|
129
|
+
async removeModel(provider, id) {
|
|
130
|
+
await this.route(provider).removeModel(id);
|
|
131
|
+
return this.status();
|
|
132
|
+
}
|
|
133
|
+
/** Read one route's plan and metered allowances. */
|
|
134
|
+
async usage(provider, force = false) {
|
|
135
|
+
const route = this.route(provider);
|
|
136
|
+
const result = await route.usage(force);
|
|
137
|
+
return {
|
|
138
|
+
provider: route.id,
|
|
139
|
+
usage: result.usage,
|
|
140
|
+
...result.error === undefined ? {} : { error: result.error },
|
|
141
|
+
status: await this.status(),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** Re-read every credential file and the shared preferences. */
|
|
145
|
+
async reload() {
|
|
146
|
+
await Promise.all([...this.byId.values()].map((route) => route.reload()));
|
|
147
|
+
return this.status();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host-side transport behind the Settings page.
|
|
3
|
+
*
|
|
4
|
+
* Each endpoint is served as its own `POST /api/subscription-oauth.<endpoint>`
|
|
5
|
+
* Fetch route on the `connection` service. `connection` is deliberately not in
|
|
6
|
+
* the plugin's `inject` list: a headless composition has no connection service,
|
|
7
|
+
* and the provider routes must still work there. Nothing else is registered
|
|
8
|
+
* when it is absent, so the browser half simply finds no routes.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-plugin-subscription-oauth/auth/rpc
|
|
11
|
+
*/
|
|
12
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
13
|
+
import type { RpcResult } from '../protocol.js';
|
|
14
|
+
import type { SubscriptionRegistry } from './registry.js';
|
|
15
|
+
/**
|
|
16
|
+
* Run one endpoint against the registry, converting any failure into the
|
|
17
|
+
* envelope's error branch — a route handler therefore never throws.
|
|
18
|
+
*
|
|
19
|
+
* @param registry - every registered provider route.
|
|
20
|
+
* @param endpoint - endpoint name, without the prefix.
|
|
21
|
+
* @param payload - the request's decoded payload.
|
|
22
|
+
* @returns the envelope result.
|
|
23
|
+
*/
|
|
24
|
+
export declare function dispatchRpc(registry: SubscriptionRegistry, endpoint: string, payload: unknown): Promise<RpcResult>;
|
|
25
|
+
/**
|
|
26
|
+
* Register every endpoint route on the `connection` service, when one exists.
|
|
27
|
+
*
|
|
28
|
+
* @param ctx - the plugin's context.
|
|
29
|
+
* @param registry - the provider routes every route dispatches through.
|
|
30
|
+
*/
|
|
31
|
+
export declare function registerSubscriptionRpc(ctx: Context, registry: SubscriptionRegistry): void;
|
package/lib/auth/rpc.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host-side transport behind the Settings page.
|
|
3
|
+
*
|
|
4
|
+
* Each endpoint is served as its own `POST /api/subscription-oauth.<endpoint>`
|
|
5
|
+
* Fetch route on the `connection` service. `connection` is deliberately not in
|
|
6
|
+
* the plugin's `inject` list: a headless composition has no connection service,
|
|
7
|
+
* and the provider routes must still work there. Nothing else is registered
|
|
8
|
+
* when it is absent, so the browser half simply finds no routes.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-plugin-subscription-oauth/auth/rpc
|
|
11
|
+
*/
|
|
12
|
+
import { BAD_REQUEST, ProviderError } from '../errors.js';
|
|
13
|
+
import { RPC_ENDPOINTS, RPC_PREFIX } from '../protocol.js';
|
|
14
|
+
/** Wrap a value as a successful envelope result. */
|
|
15
|
+
function success(value) {
|
|
16
|
+
return { ok: true, value };
|
|
17
|
+
}
|
|
18
|
+
/** Classify a thrown failure into the envelope's error shape. */
|
|
19
|
+
function failure(error) {
|
|
20
|
+
if (error instanceof ProviderError) {
|
|
21
|
+
return { ok: false, error: { code: error.code, message: error.message, details: {} } };
|
|
22
|
+
}
|
|
23
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
24
|
+
return { ok: false, error: { code: 'RPC_FAILED', message, details: {} } };
|
|
25
|
+
}
|
|
26
|
+
/** Build the JSON body of one response envelope. */
|
|
27
|
+
function envelope(rpcId, result, status = 200) {
|
|
28
|
+
return Response.json({ type: 'server-response', rpcId, result }, { status });
|
|
29
|
+
}
|
|
30
|
+
/** Reject with an envelope the client can read, using the given HTTP status. */
|
|
31
|
+
function rejection(rpcId, code, message, status) {
|
|
32
|
+
const error = { code, message, details: {} };
|
|
33
|
+
return envelope(rpcId, { ok: false, error }, status);
|
|
34
|
+
}
|
|
35
|
+
/** Read a non-empty trimmed string field from a payload. */
|
|
36
|
+
function stringField(payload, field) {
|
|
37
|
+
if (typeof payload !== 'object' || payload === null)
|
|
38
|
+
return undefined;
|
|
39
|
+
const value = payload[field];
|
|
40
|
+
if (typeof value !== 'string')
|
|
41
|
+
return undefined;
|
|
42
|
+
const trimmed = value.trim();
|
|
43
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
44
|
+
}
|
|
45
|
+
/** Read a boolean field from a payload, defaulting to false when absent. */
|
|
46
|
+
function booleanField(payload, field) {
|
|
47
|
+
if (typeof payload !== 'object' || payload === null)
|
|
48
|
+
return false;
|
|
49
|
+
return payload[field] === true;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Read a boolean field that the endpoint cannot default.
|
|
53
|
+
*
|
|
54
|
+
* A missing value must not silently mean `false` — for a visibility toggle that
|
|
55
|
+
* would hide the model the caller meant to show.
|
|
56
|
+
*
|
|
57
|
+
* @param payload - the request payload.
|
|
58
|
+
* @param field - required field name.
|
|
59
|
+
* @returns the supplied boolean.
|
|
60
|
+
* @throws {ProviderError} when the field is absent or not a boolean.
|
|
61
|
+
*/
|
|
62
|
+
function requiredBooleanField(payload, field) {
|
|
63
|
+
const value = typeof payload === 'object' && payload !== null
|
|
64
|
+
? payload[field]
|
|
65
|
+
: undefined;
|
|
66
|
+
if (typeof value !== 'boolean') {
|
|
67
|
+
throw new ProviderError(`payload.${field} must be a boolean`, BAD_REQUEST);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
/** Read the provider route a payload names. */
|
|
72
|
+
function requiredProvider(payload, endpoint) {
|
|
73
|
+
const provider = stringField(payload, 'provider');
|
|
74
|
+
if (provider === undefined) {
|
|
75
|
+
throw new ProviderError(`${endpoint} requires a provider`, BAD_REQUEST);
|
|
76
|
+
}
|
|
77
|
+
return provider;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Run one endpoint against the registry, converting any failure into the
|
|
81
|
+
* envelope's error branch — a route handler therefore never throws.
|
|
82
|
+
*
|
|
83
|
+
* @param registry - every registered provider route.
|
|
84
|
+
* @param endpoint - endpoint name, without the prefix.
|
|
85
|
+
* @param payload - the request's decoded payload.
|
|
86
|
+
* @returns the envelope result.
|
|
87
|
+
*/
|
|
88
|
+
export async function dispatchRpc(registry, endpoint, payload) {
|
|
89
|
+
try {
|
|
90
|
+
return success(await runEndpoint(registry, endpoint, payload));
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
return failure(error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The endpoint table.
|
|
98
|
+
* @throws {ProviderError} for a request the registry refuses.
|
|
99
|
+
*/
|
|
100
|
+
async function runEndpoint(registry, endpoint, payload) {
|
|
101
|
+
switch (endpoint) {
|
|
102
|
+
case 'status':
|
|
103
|
+
return registry.status();
|
|
104
|
+
case 'login':
|
|
105
|
+
return registry.login(requiredProvider(payload, endpoint), stringField(payload, 'account'));
|
|
106
|
+
case 'cancel':
|
|
107
|
+
return registry.cancel(requiredProvider(payload, endpoint));
|
|
108
|
+
case 'logout':
|
|
109
|
+
return registry.logout(requiredProvider(payload, endpoint), stringField(payload, 'account'));
|
|
110
|
+
case 'setDefault': {
|
|
111
|
+
const account = stringField(payload, 'account');
|
|
112
|
+
if (account === undefined)
|
|
113
|
+
throw new ProviderError('setDefault requires an account', BAD_REQUEST);
|
|
114
|
+
return registry.setDefault(requiredProvider(payload, endpoint), account);
|
|
115
|
+
}
|
|
116
|
+
case 'refresh':
|
|
117
|
+
return registry.refresh(requiredProvider(payload, endpoint), stringField(payload, 'account'));
|
|
118
|
+
case 'discover':
|
|
119
|
+
return registry.discover(requiredProvider(payload, endpoint), booleanField(payload, 'force'));
|
|
120
|
+
case 'setModelVisible': {
|
|
121
|
+
const id = stringField(payload, 'id');
|
|
122
|
+
if (id === undefined)
|
|
123
|
+
throw new ProviderError('setModelVisible requires a model id', BAD_REQUEST);
|
|
124
|
+
return registry.setModelVisible(requiredProvider(payload, endpoint), id, requiredBooleanField(payload, 'visible'));
|
|
125
|
+
}
|
|
126
|
+
case 'addModel':
|
|
127
|
+
case 'removeModel': {
|
|
128
|
+
const id = stringField(payload, 'id');
|
|
129
|
+
if (id === undefined)
|
|
130
|
+
throw new ProviderError(`${endpoint} requires a model id`, BAD_REQUEST);
|
|
131
|
+
const provider = requiredProvider(payload, endpoint);
|
|
132
|
+
return endpoint === 'addModel'
|
|
133
|
+
? registry.addModel(provider, id)
|
|
134
|
+
: registry.removeModel(provider, id);
|
|
135
|
+
}
|
|
136
|
+
case 'setModelOrder': {
|
|
137
|
+
const payloadRecord = typeof payload === 'object' && payload !== null
|
|
138
|
+
? payload
|
|
139
|
+
: {};
|
|
140
|
+
const ids = payloadRecord.ids;
|
|
141
|
+
if (!Array.isArray(ids) || ids.some((id) => typeof id !== 'string')) {
|
|
142
|
+
throw new ProviderError('setModelOrder requires an array of model ids', BAD_REQUEST);
|
|
143
|
+
}
|
|
144
|
+
return registry.setModelOrder(requiredProvider(payload, endpoint), ids);
|
|
145
|
+
}
|
|
146
|
+
case 'usage':
|
|
147
|
+
return registry.usage(requiredProvider(payload, endpoint), booleanField(payload, 'force'));
|
|
148
|
+
case 'reload':
|
|
149
|
+
return registry.reload();
|
|
150
|
+
default:
|
|
151
|
+
throw new ProviderError(`unknown endpoint: ${endpoint}`, BAD_REQUEST);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Build the Fetch route for one endpoint. */
|
|
155
|
+
function rpcRoute(endpoint, registry) {
|
|
156
|
+
const method = `${RPC_PREFIX}${endpoint}`;
|
|
157
|
+
return {
|
|
158
|
+
path: `/api/${method}`,
|
|
159
|
+
methods: ['POST'],
|
|
160
|
+
requestBody: 'buffered',
|
|
161
|
+
fetch: async (request) => {
|
|
162
|
+
const contentType = request.headers.get('content-type') ?? '';
|
|
163
|
+
if (!contentType.toLowerCase().includes('application/json')) {
|
|
164
|
+
return rejection('', 'gateway/unsupported-media-type', `${method} requires an application/json body`, 415);
|
|
165
|
+
}
|
|
166
|
+
const body = await request.text();
|
|
167
|
+
let parsed;
|
|
168
|
+
try {
|
|
169
|
+
parsed = body.length === 0 ? {} : JSON.parse(body);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return rejection('', 'gateway/bad-request', `${method} received an unparseable JSON body`, 400);
|
|
173
|
+
}
|
|
174
|
+
const envelopeRequest = parsed;
|
|
175
|
+
const rpcId = typeof envelopeRequest.rpcId === 'string' ? envelopeRequest.rpcId : '';
|
|
176
|
+
if (envelopeRequest.type !== 'client-request' || envelopeRequest.method !== method) {
|
|
177
|
+
return rejection(rpcId, 'gateway/bad-request', `${method} received an envelope for another method`, 400);
|
|
178
|
+
}
|
|
179
|
+
return envelope(rpcId, await dispatchRpc(registry, endpoint, envelopeRequest.payload));
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Register every endpoint route on the `connection` service, when one exists.
|
|
185
|
+
*
|
|
186
|
+
* @param ctx - the plugin's context.
|
|
187
|
+
* @param registry - the provider routes every route dispatches through.
|
|
188
|
+
*/
|
|
189
|
+
export function registerSubscriptionRpc(ctx, registry) {
|
|
190
|
+
ctx.inject(['connection'], (connectionCtx) => {
|
|
191
|
+
const connection = connectionCtx.get('connection');
|
|
192
|
+
if (connection === undefined)
|
|
193
|
+
return;
|
|
194
|
+
const register = (route) => connection.fetch.register(route);
|
|
195
|
+
for (const endpoint of RPC_ENDPOINTS) {
|
|
196
|
+
const method = `${RPC_PREFIX}${endpoint}`;
|
|
197
|
+
connectionCtx.effect(() => register(rpcRoute(endpoint, registry)), `dsh-plugin-subscription-oauth: /api/${method} route`);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable credentials, keyed by provider.
|
|
3
|
+
*
|
|
4
|
+
* The file mirrors the subscriptions plugin's layout so both plugins' token
|
|
5
|
+
* files stay recognisable to a user browsing `~/.dsh/plugins`: a JSON object
|
|
6
|
+
* keyed by provider, each entry holding a `default` account key and an
|
|
7
|
+
* `accounts` record. Writes are atomic (temp file plus rename) with owner-only
|
|
8
|
+
* permissions, so a crash mid-write cannot leave a truncated credential file.
|
|
9
|
+
*
|
|
10
|
+
* The store is generic over the session shape: each provider declares its own
|
|
11
|
+
* session fields plus the parser that validates them, while account keying,
|
|
12
|
+
* defaults, and the file lifecycle stay shared.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-plugin-subscription-oauth/auth/store
|
|
15
|
+
*/
|
|
16
|
+
/** Fields every provider's stored session carries. */
|
|
17
|
+
export interface SessionBase {
|
|
18
|
+
accessToken: string;
|
|
19
|
+
/** Refresh token; absent for a token set the service issued without one. */
|
|
20
|
+
refreshToken?: string;
|
|
21
|
+
/** Epoch milliseconds after which `accessToken` must not be sent. */
|
|
22
|
+
expiresAt: number;
|
|
23
|
+
/** Service-disclosed account identity, when the tokens carry one. */
|
|
24
|
+
accountId?: string;
|
|
25
|
+
email?: string;
|
|
26
|
+
}
|
|
27
|
+
/** True for a plain object usable as a JSON record. */
|
|
28
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
29
|
+
/** Read a non-empty trimmed string, or undefined. */
|
|
30
|
+
export declare function nonEmpty(value: unknown): string | undefined;
|
|
31
|
+
/** Read a finite epoch-millisecond value, or undefined. */
|
|
32
|
+
export declare function finiteInstant(value: unknown): number | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* Derive the account key for one session: the identity the service disclosed,
|
|
35
|
+
* falling back to a digest of the refresh token so distinct logins stay
|
|
36
|
+
* distinct even when both tokens are opaque.
|
|
37
|
+
* @param session - the session to key.
|
|
38
|
+
* @returns a stable account key.
|
|
39
|
+
*/
|
|
40
|
+
export declare function accountKeyOf(session: SessionBase): string;
|
|
41
|
+
/** Label shown for one account in the Settings page. */
|
|
42
|
+
export declare function accountLabelOf(session: SessionBase): string;
|
|
43
|
+
/**
|
|
44
|
+
* Owns the on-disk credential file for one provider.
|
|
45
|
+
*
|
|
46
|
+
* In-memory state is authoritative between writes: a failed read leaves the
|
|
47
|
+
* store empty rather than throwing, so a corrupt file degrades to "logged out"
|
|
48
|
+
* instead of breaking plugin startup.
|
|
49
|
+
*/
|
|
50
|
+
export declare class TokenStore<S extends SessionBase> {
|
|
51
|
+
private readonly file;
|
|
52
|
+
private readonly provider;
|
|
53
|
+
private readonly parse;
|
|
54
|
+
private entries;
|
|
55
|
+
private loaded;
|
|
56
|
+
/**
|
|
57
|
+
* The in-flight load, shared by every caller.
|
|
58
|
+
*
|
|
59
|
+
* Without this, a second caller saw `loaded` already true, skipped the read and
|
|
60
|
+
* wrote its own state — while the first read was still on its way back and then
|
|
61
|
+
* replaced that state with the file it had read a moment earlier. A sign-in that
|
|
62
|
+
* settled quickly enough to overlap the first load was therefore wiped: the
|
|
63
|
+
* account was saved in memory, the pending read overwrote it, and the next write
|
|
64
|
+
* persisted the empty document. A flow whose session arrives in milliseconds,
|
|
65
|
+
* like GLM's polled one, hits that window every time.
|
|
66
|
+
*/
|
|
67
|
+
private loading;
|
|
68
|
+
/**
|
|
69
|
+
* @param file - absolute path of the credential file.
|
|
70
|
+
* @param provider - section key this store owns.
|
|
71
|
+
* @param parse - validates and coerces one stored account into a session.
|
|
72
|
+
*/
|
|
73
|
+
constructor(file: string, provider: string, parse: (value: unknown) => S | undefined);
|
|
74
|
+
/** Load the file once; later calls share the same in-flight read. */
|
|
75
|
+
private ensureLoaded;
|
|
76
|
+
/** Read the document from disk into memory. */
|
|
77
|
+
private read;
|
|
78
|
+
/** Re-read the file from disk, discarding in-memory changes. */
|
|
79
|
+
reload(): Promise<void>;
|
|
80
|
+
private providerEntry;
|
|
81
|
+
/** Every stored session, keyed by account. */
|
|
82
|
+
list(): Promise<Record<string, S>>;
|
|
83
|
+
/** The default account key, falling back to the only stored account. */
|
|
84
|
+
defaultAccount(): Promise<string | undefined>;
|
|
85
|
+
/** Read one account's session. */
|
|
86
|
+
get(account?: string): Promise<S | undefined>;
|
|
87
|
+
/** Whether any account is stored. */
|
|
88
|
+
hasAny(): Promise<boolean>;
|
|
89
|
+
/** Insert or replace one account, making it the default when none is set. */
|
|
90
|
+
save(key: string, session: S, options?: {
|
|
91
|
+
makeDefault?: boolean;
|
|
92
|
+
}): Promise<void>;
|
|
93
|
+
/** Promote one account to default. */
|
|
94
|
+
setDefault(key: string): Promise<void>;
|
|
95
|
+
/** Remove one account; the default moves to a remaining account when any. */
|
|
96
|
+
remove(key: string): Promise<void>;
|
|
97
|
+
/** Write the file atomically with owner-only permissions. */
|
|
98
|
+
private persist;
|
|
99
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable credentials, keyed by provider.
|
|
3
|
+
*
|
|
4
|
+
* The file mirrors the subscriptions plugin's layout so both plugins' token
|
|
5
|
+
* files stay recognisable to a user browsing `~/.dsh/plugins`: a JSON object
|
|
6
|
+
* keyed by provider, each entry holding a `default` account key and an
|
|
7
|
+
* `accounts` record. Writes are atomic (temp file plus rename) with owner-only
|
|
8
|
+
* permissions, so a crash mid-write cannot leave a truncated credential file.
|
|
9
|
+
*
|
|
10
|
+
* The store is generic over the session shape: each provider declares its own
|
|
11
|
+
* session fields plus the parser that validates them, while account keying,
|
|
12
|
+
* defaults, and the file lifecycle stay shared.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-plugin-subscription-oauth/auth/store
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
/** True for a plain object usable as a JSON record. */
|
|
20
|
+
export function isRecord(value) {
|
|
21
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
/** Read a non-empty trimmed string, or undefined. */
|
|
24
|
+
export function nonEmpty(value) {
|
|
25
|
+
if (typeof value !== 'string')
|
|
26
|
+
return undefined;
|
|
27
|
+
const trimmed = value.trim();
|
|
28
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
29
|
+
}
|
|
30
|
+
/** Read a finite epoch-millisecond value, or undefined. */
|
|
31
|
+
export function finiteInstant(value) {
|
|
32
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Derive the account key for one session: the identity the service disclosed,
|
|
36
|
+
* falling back to a digest of the refresh token so distinct logins stay
|
|
37
|
+
* distinct even when both tokens are opaque.
|
|
38
|
+
* @param session - the session to key.
|
|
39
|
+
* @returns a stable account key.
|
|
40
|
+
*/
|
|
41
|
+
export function accountKeyOf(session) {
|
|
42
|
+
if (session.accountId !== undefined)
|
|
43
|
+
return session.accountId;
|
|
44
|
+
if (session.email !== undefined)
|
|
45
|
+
return session.email;
|
|
46
|
+
const secret = session.refreshToken ?? session.accessToken;
|
|
47
|
+
return createHash('sha256').update(secret).digest('hex').slice(0, 16);
|
|
48
|
+
}
|
|
49
|
+
/** Label shown for one account in the Settings page. */
|
|
50
|
+
export function accountLabelOf(session) {
|
|
51
|
+
return session.email ?? session.accountId ?? accountKeyOf(session);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Owns the on-disk credential file for one provider.
|
|
55
|
+
*
|
|
56
|
+
* In-memory state is authoritative between writes: a failed read leaves the
|
|
57
|
+
* store empty rather than throwing, so a corrupt file degrades to "logged out"
|
|
58
|
+
* instead of breaking plugin startup.
|
|
59
|
+
*/
|
|
60
|
+
export class TokenStore {
|
|
61
|
+
file;
|
|
62
|
+
provider;
|
|
63
|
+
parse;
|
|
64
|
+
entries = {};
|
|
65
|
+
loaded = false;
|
|
66
|
+
/**
|
|
67
|
+
* The in-flight load, shared by every caller.
|
|
68
|
+
*
|
|
69
|
+
* Without this, a second caller saw `loaded` already true, skipped the read and
|
|
70
|
+
* wrote its own state — while the first read was still on its way back and then
|
|
71
|
+
* replaced that state with the file it had read a moment earlier. A sign-in that
|
|
72
|
+
* settled quickly enough to overlap the first load was therefore wiped: the
|
|
73
|
+
* account was saved in memory, the pending read overwrote it, and the next write
|
|
74
|
+
* persisted the empty document. A flow whose session arrives in milliseconds,
|
|
75
|
+
* like GLM's polled one, hits that window every time.
|
|
76
|
+
*/
|
|
77
|
+
loading;
|
|
78
|
+
/**
|
|
79
|
+
* @param file - absolute path of the credential file.
|
|
80
|
+
* @param provider - section key this store owns.
|
|
81
|
+
* @param parse - validates and coerces one stored account into a session.
|
|
82
|
+
*/
|
|
83
|
+
constructor(file, provider, parse) {
|
|
84
|
+
this.file = file;
|
|
85
|
+
this.provider = provider;
|
|
86
|
+
this.parse = parse;
|
|
87
|
+
}
|
|
88
|
+
/** Load the file once; later calls share the same in-flight read. */
|
|
89
|
+
async ensureLoaded(reload = false) {
|
|
90
|
+
if (this.loaded && !reload)
|
|
91
|
+
return;
|
|
92
|
+
if (reload) {
|
|
93
|
+
this.loaded = false;
|
|
94
|
+
this.loading = undefined;
|
|
95
|
+
}
|
|
96
|
+
this.loading ??= this.read().finally(() => {
|
|
97
|
+
this.loading = undefined;
|
|
98
|
+
this.loaded = true;
|
|
99
|
+
});
|
|
100
|
+
await this.loading;
|
|
101
|
+
}
|
|
102
|
+
/** Read the document from disk into memory. */
|
|
103
|
+
async read() {
|
|
104
|
+
let raw;
|
|
105
|
+
try {
|
|
106
|
+
raw = await readFile(this.file, 'utf8');
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
this.entries = {};
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(raw);
|
|
114
|
+
this.entries = isRecord(parsed) ? parsed : {};
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
this.entries = {};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Re-read the file from disk, discarding in-memory changes. */
|
|
121
|
+
async reload() {
|
|
122
|
+
await this.ensureLoaded(true);
|
|
123
|
+
}
|
|
124
|
+
providerEntry() {
|
|
125
|
+
return this.entries[this.provider] ?? {};
|
|
126
|
+
}
|
|
127
|
+
/** Every stored session, keyed by account. */
|
|
128
|
+
async list() {
|
|
129
|
+
await this.ensureLoaded();
|
|
130
|
+
const accounts = this.providerEntry().accounts;
|
|
131
|
+
if (!isRecord(accounts))
|
|
132
|
+
return {};
|
|
133
|
+
const result = {};
|
|
134
|
+
for (const [key, value] of Object.entries(accounts)) {
|
|
135
|
+
const session = this.parse(value);
|
|
136
|
+
if (session !== undefined)
|
|
137
|
+
result[key] = session;
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
/** The default account key, falling back to the only stored account. */
|
|
142
|
+
async defaultAccount() {
|
|
143
|
+
await this.ensureLoaded();
|
|
144
|
+
const entry = this.providerEntry();
|
|
145
|
+
const accounts = await this.list();
|
|
146
|
+
const keys = Object.keys(accounts);
|
|
147
|
+
const preferred = nonEmpty(entry.default);
|
|
148
|
+
if (preferred !== undefined && keys.includes(preferred))
|
|
149
|
+
return preferred;
|
|
150
|
+
return keys[0];
|
|
151
|
+
}
|
|
152
|
+
/** Read one account's session. */
|
|
153
|
+
async get(account) {
|
|
154
|
+
const accounts = await this.list();
|
|
155
|
+
const key = account ?? await this.defaultAccount();
|
|
156
|
+
return key === undefined ? undefined : accounts[key];
|
|
157
|
+
}
|
|
158
|
+
/** Whether any account is stored. */
|
|
159
|
+
async hasAny() {
|
|
160
|
+
return Object.keys(await this.list()).length > 0;
|
|
161
|
+
}
|
|
162
|
+
/** Insert or replace one account, making it the default when none is set. */
|
|
163
|
+
async save(key, session, options = {}) {
|
|
164
|
+
await this.ensureLoaded();
|
|
165
|
+
const entry = this.providerEntry();
|
|
166
|
+
const accounts = { ...isRecord(entry.accounts) ? entry.accounts : {}, [key]: session };
|
|
167
|
+
const makeDefault = options.makeDefault ?? true;
|
|
168
|
+
const next = {
|
|
169
|
+
...entry,
|
|
170
|
+
accounts,
|
|
171
|
+
...makeDefault || entry.default === undefined ? { default: key } : {},
|
|
172
|
+
};
|
|
173
|
+
this.entries = { ...this.entries, [this.provider]: next };
|
|
174
|
+
await this.persist();
|
|
175
|
+
}
|
|
176
|
+
/** Promote one account to default. */
|
|
177
|
+
async setDefault(key) {
|
|
178
|
+
await this.ensureLoaded();
|
|
179
|
+
const entry = this.providerEntry();
|
|
180
|
+
this.entries = { ...this.entries, [this.provider]: { ...entry, default: key } };
|
|
181
|
+
await this.persist();
|
|
182
|
+
}
|
|
183
|
+
/** Remove one account; the default moves to a remaining account when any. */
|
|
184
|
+
async remove(key) {
|
|
185
|
+
await this.ensureLoaded();
|
|
186
|
+
const entry = this.providerEntry();
|
|
187
|
+
const accounts = { ...isRecord(entry.accounts) ? entry.accounts : {} };
|
|
188
|
+
delete accounts[key];
|
|
189
|
+
const remaining = Object.keys(accounts);
|
|
190
|
+
const fallback = entry.default === key ? remaining[0] : entry.default;
|
|
191
|
+
const next = { ...entry, accounts };
|
|
192
|
+
if (fallback === undefined)
|
|
193
|
+
delete next.default;
|
|
194
|
+
else
|
|
195
|
+
next.default = fallback;
|
|
196
|
+
this.entries = { ...this.entries, [this.provider]: next };
|
|
197
|
+
await this.persist();
|
|
198
|
+
}
|
|
199
|
+
/** Write the file atomically with owner-only permissions. */
|
|
200
|
+
async persist() {
|
|
201
|
+
await mkdir(path.dirname(this.file), { recursive: true });
|
|
202
|
+
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
203
|
+
await writeFile(temporary, `${JSON.stringify(this.entries, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
204
|
+
try {
|
|
205
|
+
await rename(temporary, this.file);
|
|
206
|
+
await chmod(this.file, 0o600);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
await rm(temporary, { force: true });
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|