@tyapinalexandr/dsh-account-identity-fallback 0.2.4

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tyapinalexandr
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,100 @@
1
+ # @tyapinalexandr/dsh-account-identity-fallback
2
+
3
+ Keeps the DeepSeek account name and avatar visible when the Platform profile
4
+ endpoint is unavailable, with an in-app editor for both.
5
+
6
+ ## What it does
7
+
8
+ The account UI shows the generic avatar and "Signed in to DeepSeek" when
9
+ `platform.deepseek.com/auth-api/v0/users/current` is blocked (AWS WAF
10
+ challenge / CloudFront 403). The plugin wraps the `deepseekAccount` service: a
11
+ successful profile read is cached, and a failed read is answered from that
12
+ cache or from the identity saved in the plugin settings. It does not bypass bot
13
+ protection; balance, sign-in/out and model calls are untouched.
14
+
15
+ ## Install
16
+
17
+ Plugins → Add plugin → `@tyapinalexandr/dsh-account-identity-fallback` →
18
+ Install → Enable now.
19
+
20
+ ```sh
21
+ dsh plugin --profile <profile> add @tyapinalexandr/dsh-account-identity-fallback
22
+ ```
23
+
24
+ After installing or updating, reload the Harness page once so the browser half
25
+ loads.
26
+
27
+ ## Configure
28
+
29
+ Plugins → Installed plugins → the plugin's page: set **Account name** and
30
+ **Avatar** (image URL or a local file up to 512 KB, stored in the profile
31
+ configuration), then **Save**. Saved fields apply immediately without a restart
32
+ and take priority over the cached profile; empty fields use the cached value.
33
+
34
+ The same two fields can be set in the profile's `cordis.patch.yml` (`name`,
35
+ `avatarUrl`).
36
+
37
+ ## Cache
38
+
39
+ `$DSH_HOME/cache/account-identity-fallback/profile-cache.json`
40
+ (`~/.dsh/cache/...` by default). Delete it to fall back to the configured
41
+ identity.
42
+
43
+ ## Uninstall
44
+
45
+ Plugins → Remove, or:
46
+
47
+ ```sh
48
+ dsh plugin --profile <profile> remove @tyapinalexandr/dsh-account-identity-fallback
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Русский
54
+
55
+ Показывает имя и аватар аккаунта DeepSeek, когда платформа не отдаёт профиль,
56
+ и позволяет задать их в интерфейсе DSH.
57
+
58
+ ### Что делает
59
+
60
+ Аккаунт показывает стандартную иконку и «Signed in to DeepSeek», когда
61
+ `platform.deepseek.com/auth-api/v0/users/current` блокируется (AWS WAF challenge
62
+ / CloudFront 403). Плагин оборачивает сервис `deepseekAccount`: успешное чтение
63
+ профиля кешируется, неуспешное — отвечает из кеша или из настроек плагина.
64
+ Антибот-защиту он не обходит; баланс, вход/выход и вызовы моделей не
65
+ затрагиваются.
66
+
67
+ ### Установка
68
+
69
+ **Plugins → Add plugin** → `@tyapinalexandr/dsh-account-identity-fallback` →
70
+ **Install** → **Enable now**.
71
+
72
+ ```sh
73
+ dsh plugin --profile <профиль> add @tyapinalexandr/dsh-account-identity-fallback
74
+ ```
75
+
76
+ После установки или обновления один раз перезагрузите страницу Harness, чтобы
77
+ загрузилась браузерная часть.
78
+
79
+ ### Настройка
80
+
81
+ **Plugins → Installed plugins** → страница плагина: задайте **Имя в аккаунте**
82
+ и **Аватар** (ссылка или локальный файл до 512 КБ, сохраняется в конфигурации
83
+ профиля), затем **Сохранить**. Сохранённые поля применяются сразу, без
84
+ перезапуска, и имеют приоритет над кешем; пустые поля используют кеш.
85
+
86
+ Те же два поля можно задать в `cordis.patch.yml` профиля (`name`,
87
+ `avatarUrl`).
88
+
89
+ ### Кеш
90
+
91
+ `$DSH_HOME/cache/account-identity-fallback/profile-cache.json` (по умолчанию
92
+ `~/.dsh/cache/...`). Удалите файл, чтобы вернуться к значениям из настроек.
93
+
94
+ ### Удаление
95
+
96
+ **Plugins → Remove** или:
97
+
98
+ ```sh
99
+ dsh plugin --profile <профиль> remove @tyapinalexandr/dsh-account-identity-fallback
100
+ ```
package/client.js ADDED
@@ -0,0 +1,343 @@
1
+ // Browser half of @tyapinalexandr/dsh-account-identity-fallback.
2
+ //
3
+ // Registers a row configuration page on the Plugins page: Plugins → this
4
+ // bundle → the "account-identity-fallback" row → Configure. The page edits the
5
+ // plugin's volatile identity fields, so a save is applied to the running Host
6
+ // plugin without a restart and the account surfaces refresh.
7
+ window.__ModuleLoader__.load({
8
+ id: "@tyapinalexandr/dsh-account-identity-fallback",
9
+ factory: (require) => {
10
+ var module = { exports: {} };
11
+ var exports = module.exports;
12
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
13
+ const React = require("react");
14
+ const h = React.createElement;
15
+
16
+ const NS = "accountIdentityFallback";
17
+ const PACKAGE = "@tyapinalexandr/dsh-account-identity-fallback";
18
+ const ROW_ID = "account-identity-fallback";
19
+ const FIELD_NAMES = ["name", "avatarUrl"];
20
+ const MAX_AVATAR_BYTES = 512 * 1024;
21
+
22
+ const en = {
23
+ summary: "Set the account name and avatar shown when the Platform profile is unavailable.",
24
+ title: "Account identity fallback",
25
+ loading: "Loading settings…",
26
+ unavailable: "Configuration is not available for this row.",
27
+ readOnly: "This profile does not accept configuration writes.",
28
+ nameLabel: "Account name",
29
+ nameHint: "Shown in the account menu and Settings. Leave empty to use the last known Platform profile.",
30
+ avatarLabel: "Avatar",
31
+ avatarHint: "Paste an image URL, or choose a local image file (up to 512 KB; it is embedded in the profile configuration). Leave empty to use the last known Platform avatar.",
32
+ avatarChoose: "Choose image…",
33
+ replaceImage: "Replace image…",
34
+ useLink: "Use a link instead",
35
+ uploadedImage: "Uploaded image",
36
+ avatarClear: "Remove image",
37
+ save: "Save",
38
+ saving: "Saving…",
39
+ saved: "Saved",
40
+ noChanges: "Nothing to save",
41
+ failed: "Could not save the settings",
42
+ notImage: "Choose an image file",
43
+ tooLarge: "The image is larger than 512 KB",
44
+ readFailed: "Could not read the image file",
45
+ cacheHint: "Empty fields fall back to the last known Platform profile, then to the built-in account label.",
46
+ };
47
+
48
+ const ru = {
49
+ summary: "Имя и аватар аккаунта, когда профиль платформы недоступен.",
50
+ title: "Имя и аватар аккаунта",
51
+ loading: "Загрузка настроек…",
52
+ unavailable: "Настройки для этой строки недоступны.",
53
+ readOnly: "Этот профиль не принимает записи настроек.",
54
+ nameLabel: "Имя в аккаунте",
55
+ nameHint: "Показывается в меню аккаунта и настройках. Пусто — использовать последний известный профиль платформы.",
56
+ avatarLabel: "Аватар",
57
+ avatarHint: "Вставьте ссылку на изображение или выберите файл (до 512 КБ; он сохраняется внутри конфигурации профиля). Пусто — использовать последний известный аватар платформы.",
58
+ avatarChoose: "Выбрать изображение…",
59
+ replaceImage: "Заменить изображение…",
60
+ useLink: "Вместо файла — ссылка",
61
+ uploadedImage: "Загруженное изображение",
62
+ avatarClear: "Убрать изображение",
63
+ save: "Сохранить",
64
+ saving: "Сохранение…",
65
+ saved: "Сохранено",
66
+ noChanges: "Нечего сохранять",
67
+ failed: "Не удалось сохранить настройки",
68
+ notImage: "Выберите файл изображения",
69
+ tooLarge: "Изображение больше 512 КБ",
70
+ readFailed: "Не удалось прочитать файл изображения",
71
+ cacheHint: "Пустые поля берут последний известный профиль платформы, а затем встроенную подпись аккаунта.",
72
+ };
73
+
74
+ const styles = {
75
+ root: { display: "flex", flexDirection: "column", gap: "14px", padding: "4px 0", fontSize: "13px", lineHeight: "20px" },
76
+ grid: { display: "flex", flexDirection: "column", gap: "12px" },
77
+ row: { display: "flex", flexDirection: "column", gap: "4px" },
78
+ label: { color: "var(--dsw-alias-label-primary)", fontWeight: 500 },
79
+ hint: { color: "var(--dsw-alias-label-tertiary)", fontSize: "12px", lineHeight: "18px" },
80
+ input: {
81
+ boxSizing: "border-box", width: "100%", height: "32px", padding: "0 10px",
82
+ color: "var(--dsw-alias-label-primary)", background: "var(--dsw-alias-bg-base)",
83
+ border: "0.5px solid var(--dsw-alias-border-l2)", borderRadius: "var(--dsw-radius-md)", font: "inherit",
84
+ },
85
+ avatarRow: { display: "flex", gap: "10px", alignItems: "center", flexWrap: "wrap" },
86
+ avatar: {
87
+ width: "48px", height: "48px", borderRadius: "50%", objectFit: "cover",
88
+ background: "var(--dsw-alias-bg-skeleton)", flex: "none",
89
+ },
90
+ uploaded: {
91
+ display: "inline-flex", alignItems: "center", gap: "8px", minHeight: "32px", padding: "0 10px",
92
+ color: "var(--dsw-alias-label-secondary)", background: "var(--dsw-alias-bg-skeleton)",
93
+ border: "0.5px solid var(--dsw-alias-border-l2)", borderRadius: "var(--dsw-radius-md)",
94
+ },
95
+ uploadedSize: { color: "var(--dsw-alias-label-tertiary)", fontSize: "12px" },
96
+ actions: { display: "flex", gap: "8px", alignItems: "center", flexWrap: "wrap" },
97
+ button: {
98
+ height: "32px", padding: "0 14px", borderRadius: "var(--dsw-radius-md)",
99
+ border: "0.5px solid var(--dsw-alias-border-l3)", background: "transparent",
100
+ color: "var(--dsw-alias-label-primary)", font: "inherit", cursor: "pointer",
101
+ },
102
+ primary: {
103
+ background: "var(--dsw-alias-button-primary-fill)", borderColor: "transparent",
104
+ color: "var(--dsw-alias-label-primary-foreground)", fontWeight: 500,
105
+ },
106
+ ok: { color: "var(--dsw-alias-label-tertiary)" },
107
+ fail: { color: "var(--dsw-alias-state-error-primary, #e5484d)" },
108
+ };
109
+
110
+ /** Read the form's current identity values into a plain draft object. */
111
+ function draftFrom(snapshot) {
112
+ const value = snapshot && snapshot.value && typeof snapshot.value === "object" ? snapshot.value : {};
113
+ const draft = {};
114
+ for (const field of FIELD_NAMES) {
115
+ draft[field] = typeof value[field] === "string" ? value[field] : "";
116
+ }
117
+ return draft;
118
+ }
119
+
120
+ /** Whether a value is an embedded image URL. */
121
+ function isDataUrl(value) {
122
+ return typeof value === "string" && value.startsWith("data:");
123
+ }
124
+
125
+ /** Approximate decoded size in KB of a data: URL. */
126
+ function dataUrlSizeKb(value) {
127
+ const comma = value.indexOf(",");
128
+ const payload = comma === -1 ? "" : value.slice(comma + 1);
129
+ const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
130
+ return Math.max(1, Math.round((payload.length * 3) / 4 - padding) / 1024);
131
+ }
132
+
133
+ /** Render one label + control + hint block. */
134
+ function fieldBlock(label, hint, control) {
135
+ return h("label", { style: styles.row, key: label }, [
136
+ h("span", { style: styles.label, key: "label" }, label),
137
+ control,
138
+ hint ? h("span", { style: styles.hint, key: "hint" }, hint) : null,
139
+ ]);
140
+ }
141
+
142
+ /** Configuration page rendered on the plugin's page in Plugins. */
143
+ function ConfigPage(props) {
144
+ const t = typeof props.t === "function" ? props.t : (key) => en[key] ?? key;
145
+ const form = props.form;
146
+ const [snapshot, setSnapshot] = React.useState(
147
+ () => (form !== undefined && typeof form.getSnapshot === "function" ? form.getSnapshot() : undefined),
148
+ );
149
+ const [draft, setDraft] = React.useState(() => draftFrom(snapshot));
150
+ const [saving, setSaving] = React.useState(false);
151
+ const [notice, setNotice] = React.useState(null);
152
+ const fileRef = React.useRef(null);
153
+ const revision = snapshot ? snapshot.revision : undefined;
154
+
155
+ React.useEffect(() => {
156
+ if (form === undefined || typeof form.getSnapshot !== "function" || typeof form.subscribe !== "function") return undefined;
157
+ const publish = () => setSnapshot(form.getSnapshot());
158
+ publish();
159
+ return form.subscribe(publish);
160
+ }, [form]);
161
+
162
+ React.useEffect(() => {
163
+ setDraft(draftFrom(snapshot));
164
+ setNotice(null);
165
+ }, [revision]);
166
+
167
+ if (props.view === "summary") return h("span", null, t("summary"));
168
+ if (form === undefined || snapshot === undefined) return h("p", { style: styles.hint }, t("unavailable"));
169
+ if (snapshot.status === "loading") return h("p", { style: styles.hint }, t("loading"));
170
+ if (snapshot.status === "unavailable") return h("p", { style: styles.hint }, t("unavailable"));
171
+ const writable = snapshot.writable === true;
172
+
173
+ const setField = (field) => (event) => {
174
+ const value = event.target.value;
175
+ setDraft((previous) => ({ ...previous, [field]: value }));
176
+ };
177
+
178
+ const chooseFile = () => {
179
+ if (fileRef.current !== null) fileRef.current.click();
180
+ };
181
+
182
+ const readFile = (event) => {
183
+ const file = event.target.files && event.target.files[0];
184
+ event.target.value = "";
185
+ if (file === undefined) return;
186
+ if (typeof file.type === "string" && file.type !== "" && !file.type.startsWith("image/")) {
187
+ setNotice({ ok: false, text: t("notImage") });
188
+ return;
189
+ }
190
+ if (file.size > MAX_AVATAR_BYTES) {
191
+ setNotice({ ok: false, text: t("tooLarge") });
192
+ return;
193
+ }
194
+ const reader = new FileReader();
195
+ reader.onload = () => {
196
+ setDraft((previous) => ({ ...previous, avatarUrl: String(reader.result || "") }));
197
+ setNotice(null);
198
+ };
199
+ reader.onerror = () => setNotice({ ok: false, text: t("readFailed") });
200
+ reader.readAsDataURL(file);
201
+ };
202
+
203
+ const save = async () => {
204
+ if (saving || !writable) return;
205
+ setSaving(true);
206
+ setNotice(null);
207
+ try {
208
+ const before = draftFrom(snapshot);
209
+ const ops = [];
210
+ for (const field of FIELD_NAMES) {
211
+ const next = String(draft[field] ?? "").trim();
212
+ if (next === before[field]) continue;
213
+ if (next === "") ops.push({ op: "unset", path: [field] });
214
+ else ops.push({ op: "set", path: [field], value: next });
215
+ }
216
+ if (ops.length === 0) {
217
+ setNotice({ ok: true, text: t("noChanges") });
218
+ return;
219
+ }
220
+ const accepted = await form.mutate(ops, snapshot.revision);
221
+ setNotice({ ok: accepted, text: t(accepted ? "saved" : "failed") });
222
+ } catch (_error) {
223
+ setNotice({ ok: false, text: t("failed") });
224
+ } finally {
225
+ setSaving(false);
226
+ }
227
+ };
228
+
229
+ const avatarPreview = draft.avatarUrl === ""
230
+ ? null
231
+ : h("img", {
232
+ key: "preview",
233
+ style: styles.avatar,
234
+ src: draft.avatarUrl,
235
+ alt: t("avatarLabel"),
236
+ referrerPolicy: "no-referrer",
237
+ onError: (event) => { event.currentTarget.style.visibility = "hidden"; },
238
+ onLoad: (event) => { event.currentTarget.style.visibility = "visible"; },
239
+ });
240
+
241
+ return h("div", { style: styles.root, "data-account-identity-fallback": "" }, [
242
+ !writable ? h("p", { style: styles.hint, key: "readonly" }, t("readOnly")) : null,
243
+ h("div", { style: styles.grid, key: "grid" }, [
244
+ fieldBlock(t("nameLabel"), t("nameHint"), h("input", {
245
+ key: "name",
246
+ style: styles.input,
247
+ type: "text",
248
+ value: draft.name,
249
+ disabled: !writable || saving,
250
+ onChange: setField("name"),
251
+ placeholder: t("nameLabel"),
252
+ })),
253
+ fieldBlock(t("avatarLabel"), t("avatarHint"), h("div", { style: styles.avatarRow, key: "avatar" }, [
254
+ avatarPreview,
255
+ isDataUrl(draft.avatarUrl)
256
+ ? h("span", { key: "uploaded", style: styles.uploaded }, [
257
+ h("span", { key: "label" }, t("uploadedImage")),
258
+ h("span", { key: "size", style: styles.uploadedSize }, `${dataUrlSizeKb(draft.avatarUrl)} KB`),
259
+ ])
260
+ : h("input", {
261
+ key: "avatar-url",
262
+ style: { ...styles.input, flex: "1 1 240px" },
263
+ type: "text",
264
+ value: draft.avatarUrl,
265
+ disabled: !writable || saving,
266
+ onChange: setField("avatarUrl"),
267
+ placeholder: "https://…",
268
+ }),
269
+ h("input", {
270
+ key: "file",
271
+ ref: fileRef,
272
+ type: "file",
273
+ accept: "image/*",
274
+ style: { display: "none" },
275
+ onChange: readFile,
276
+ }),
277
+ h("button", {
278
+ key: "choose",
279
+ type: "button",
280
+ style: styles.button,
281
+ disabled: !writable || saving,
282
+ onClick: chooseFile,
283
+ }, t(isDataUrl(draft.avatarUrl) ? "replaceImage" : "avatarChoose")),
284
+ isDataUrl(draft.avatarUrl) ? h("button", {
285
+ key: "use-link",
286
+ type: "button",
287
+ style: styles.button,
288
+ disabled: !writable || saving,
289
+ onClick: () => setDraft((previous) => ({ ...previous, avatarUrl: "" })),
290
+ }, t("useLink")) : null,
291
+ draft.avatarUrl === "" ? null : h("button", {
292
+ key: "clear",
293
+ type: "button",
294
+ style: styles.button,
295
+ disabled: !writable || saving,
296
+ onClick: () => setDraft((previous) => ({ ...previous, avatarUrl: "" })),
297
+ }, t("avatarClear")),
298
+ ])),
299
+ ]),
300
+ h("div", { style: styles.actions, key: "actions" }, [
301
+ h("button", {
302
+ key: "save",
303
+ type: "button",
304
+ style: { ...styles.button, ...styles.primary },
305
+ disabled: !writable || saving,
306
+ onClick: save,
307
+ }, t(saving ? "saving" : "save")),
308
+ notice === null ? null : h("span", {
309
+ key: "notice",
310
+ style: notice.ok ? styles.ok : styles.fail,
311
+ role: notice.ok ? "status" : "alert",
312
+ }, notice.text),
313
+ ]),
314
+ h("p", { style: styles.hint, key: "cache-hint" }, t("cacheHint")),
315
+ ]);
316
+ }
317
+
318
+ /** Required services: slot registry, locale dictionaries and configuration forms. */
319
+ const inject = ["slots", "locale", "configForms"];
320
+
321
+ /**
322
+ * Register the configuration page on the bundle's own page in Plugins.
323
+ * The page declares `plugins.bundle.config` without a form, so this
324
+ * plugin obtains the settings form for its row itself.
325
+ * @param ctx - browser plugin context.
326
+ */
327
+ function apply(ctx) {
328
+ ctx.effect(() => ctx.locale.register(NS, { en, ru }), "account-identity-fallback: dictionaries");
329
+ const t = ctx.locale.bind(NS);
330
+ const form = ctx.configForms.get(ROW_ID);
331
+ ctx.effect(() => ctx.configForms.whileServed([ROW_ID], () => ctx.slots.inject("plugins.bundle.config", () => ctx.slots.register({
332
+ name: "plugins.bundle.config",
333
+ key: PACKAGE,
334
+ locale: NS,
335
+ inject: () => ({ t, form }),
336
+ }, ConfigPage))), "account-identity-fallback: configuration page");
337
+ }
338
+
339
+ exports.apply = apply;
340
+ exports.inject = inject;
341
+ return module.exports;
342
+ }
343
+ });
@@ -0,0 +1,21 @@
1
+ # Bundle layer: insert the fallback plugin row.
2
+ #
3
+ # The identity itself (name / avatar / contact) is per-deployment data, so this
4
+ # layer is intentionally empty. Override it in the profile's own
5
+ # cordis.patch.yml, which is applied after every bundle layer:
6
+ #
7
+ # - id: account-identity-fallback
8
+ # config:
9
+ # name: 'Your display name'
10
+ # avatarUrl: 'https://static.deepseek.com/user-avatar/<avatar-id>'
11
+ # contact: ''
12
+ # id: ''
13
+ #
14
+ - insert:
15
+ - id: account-identity-fallback
16
+ name: '@tyapinalexandr/dsh-account-identity-fallback'
17
+ config:
18
+ name: ''
19
+ contact: ''
20
+ avatarUrl: ''
21
+ id: ''
package/icon.svg ADDED
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="Account identity fallback">
2
+ <rect width="64" height="64" rx="14" fill="#1f2833"/>
3
+ <circle cx="32" cy="24" r="9" fill="#e8eef7"/>
4
+ <path d="M32 36c-9.4 0-17 5.4-17 12v2h34v-2c0-6.6-7.6-12-17-12z" fill="#e8eef7"/>
5
+ <path d="M47 17a11 11 0 0 0-15.2 2.6" fill="none" stroke="#4f9dff" stroke-width="4" stroke-linecap="round"/>
6
+ <path d="M51.4 12.6 49 20l-7.4-2.2" fill="none" stroke="#4f9dff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
7
+ <path d="M52 47a11 11 0 0 1-2.6 15.2" fill="none" stroke="#4f9dff" stroke-width="4" stroke-linecap="round" transform="translate(0 0)"/>
8
+ </svg>
package/index.js ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Account identity fallback for DeepSeek Harness.
3
+ *
4
+ * The DeepSeek Platform profile read (`platform.deepseek.com/auth-api/v0/users/current`)
5
+ * can be rejected by the Platform's edge protection (AWS WAF challenge / CloudFront 403)
6
+ * even while the stored account credential is still valid. In that state the account UI
7
+ * shows the generic avatar and the "Signed in to DeepSeek" label, because the profile
8
+ * payload (name, contact, picture) never arrives.
9
+ *
10
+ * This Host plugin wraps the `deepseekAccount` service so that:
11
+ * - a successful profile read refreshes a small on-disk profile cache;
12
+ * - a failed (or throwing) profile read is answered from that cache, or from the
13
+ * plugin configuration when no cache exists yet.
14
+ *
15
+ * The identity is editable in the Harness UI (Plugins → this bundle → its row →
16
+ * Configure): `name`, `contact` and `avatarUrl` are volatile config fields, so a
17
+ * save reaches the running plugin in place and the account surfaces refresh.
18
+ *
19
+ * The account UI and Settings then render the last known identity instead of the
20
+ * fallback label. Balance, sign-out, sign-in and model usage are untouched.
21
+ */
22
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
23
+ import { homedir } from 'node:os'
24
+ import { dirname, join, resolve } from 'node:path'
25
+ import Schema from '@deepseek-ai/schemastery'
26
+
27
+ export const name = 'account-identity-fallback'
28
+ export const inject = ['deepseekAccount']
29
+
30
+ /**
31
+ * Identity fields exposed by the Plugins configuration page. These are volatile
32
+ * fields: the settings service projects them into a form, and a volatile-only
33
+ * edit is committed into the running plugin without restarting it.
34
+ */
35
+ export const Config = Schema.object({
36
+ name: Schema.string()
37
+ .default('')
38
+ .volatile()
39
+ .description('Account name shown in the account menu and Settings. Empty falls back to the last known profile.'),
40
+ contact: Schema.string()
41
+ .default('')
42
+ .description('Advanced (YAML only): contact line shown in Settings → Account under the name.'),
43
+ avatarUrl: Schema.string()
44
+ .default('')
45
+ .volatile()
46
+ .description('Avatar image URL (https:) or data: URL. Empty falls back to the last known profile.'),
47
+ id: Schema.string()
48
+ .default('')
49
+ .description('Advanced (YAML only): account id used to prefill the Feedback form.'),
50
+ })
51
+
52
+ const LOG_PREFIX = '[account-identity-fallback]'
53
+ const CACHE_VERSION = 1
54
+ /** Account-service instance marker holding the original profile reader. */
55
+ const ORIGINAL_GET_PROFILE = Symbol.for('@tyapinalexandr/dsh-account-identity-fallback/original-get-profile')
56
+
57
+ /**
58
+ * Resolve the Harness data root the same way the harness does: an explicit
59
+ * `$DSH_HOME` wins, and `~/.dsh` is the default. The cache must live outside
60
+ * the installed package so reinstalling or upgrading the plugin keeps it.
61
+ * @returns absolute DSH home directory.
62
+ */
63
+ function resolveDshHome() {
64
+ const configured = process.env.DSH_HOME
65
+ if (typeof configured === 'string' && configured.trim() !== '') return resolve(configured.trim())
66
+ return join(homedir(), '.dsh')
67
+ }
68
+
69
+ /** Last-known profile cache under the DSH data root. */
70
+ const CACHE_FILE = join(resolveDshHome(), 'cache', 'account-identity-fallback', 'profile-cache.json')
71
+
72
+ /** Return a non-empty string, or null. */
73
+ function textOrNull(value) {
74
+ return typeof value === 'string' && value.length > 0 ? value : null
75
+ }
76
+
77
+ /**
78
+ * Project an untrusted profile payload onto the account UI's profile shape.
79
+ * @param value - value received from the account service.
80
+ * @returns sanitized profile, or undefined when nothing usable remains.
81
+ */
82
+ function projectProfile(value) {
83
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
84
+ return {
85
+ id: textOrNull(value.id),
86
+ name: textOrNull(value.name),
87
+ contact: textOrNull(value.contact),
88
+ avatarUrl: textOrNull(value.avatarUrl),
89
+ }
90
+ }
91
+
92
+ /** Whether a profile carries anything the account UI can display. */
93
+ function hasIdentity(profile) {
94
+ return profile !== undefined
95
+ && (profile.name !== null || profile.contact !== null || profile.avatarUrl !== null)
96
+ }
97
+
98
+ /**
99
+ * Read one volatile configuration reference. Volatile fields arrive as
100
+ * `Volatile` references whose `get()` returns the value committed by the
101
+ * loader; reading at call time therefore sees a save made in the UI.
102
+ * @param value - volatile reference or plain value.
103
+ * @returns current value.
104
+ */
105
+ function readVolatile(value) {
106
+ return value !== null && typeof value === 'object' && typeof value.get === 'function' ? value.get() : value
107
+ }
108
+
109
+ /**
110
+ * Build the identity named by the live plugin configuration.
111
+ * @param config - live row configuration.
112
+ * @returns configured profile, or undefined when the row names no identity.
113
+ */
114
+ function configuredProfile(config) {
115
+ const value = config ?? {}
116
+ const profile = projectProfile({
117
+ id: readVolatile(value.id),
118
+ name: readVolatile(value.name),
119
+ contact: readVolatile(value.contact),
120
+ avatarUrl: readVolatile(value.avatarUrl),
121
+ })
122
+ return hasIdentity(profile) ? profile : undefined
123
+ }
124
+
125
+ /**
126
+ * Merge the configured identity over the last known profile, field by field.
127
+ * Explicit settings win; an empty field keeps the cached value.
128
+ * @param configured - identity named by the configuration, if any.
129
+ * @param cached - last profile read from the Platform, if any.
130
+ * @returns the identity to serve, or undefined when neither source has one.
131
+ */
132
+ function mergeProfile(configured, cached) {
133
+ if (configured === undefined) return cached
134
+ if (cached === undefined) return configured
135
+ const merged = {
136
+ id: configured.id ?? cached.id,
137
+ name: configured.name ?? cached.name,
138
+ contact: configured.contact ?? cached.contact,
139
+ avatarUrl: configured.avatarUrl ?? cached.avatarUrl,
140
+ }
141
+ return hasIdentity(merged) ? merged : undefined
142
+ }
143
+
144
+ /** Read the persisted last-known profile; an absent or corrupt cache is not an error. */
145
+ async function readCachedProfile() {
146
+ try {
147
+ const parsed = JSON.parse(await readFile(CACHE_FILE, 'utf8'))
148
+ if (parsed === null || typeof parsed !== 'object' || parsed.version !== CACHE_VERSION) return undefined
149
+ return projectProfile(parsed.profile)
150
+ } catch (error) {
151
+ if (error?.code !== 'ENOENT') console.error(`${LOG_PREFIX} profile cache read failed`, { code: error?.code })
152
+ return undefined
153
+ }
154
+ }
155
+
156
+ /** Persist the last successful profile for the next blocked read. */
157
+ async function writeCachedProfile(profile) {
158
+ try {
159
+ await mkdir(dirname(CACHE_FILE), { recursive: true })
160
+ const temp = `${CACHE_FILE}.${process.pid}.tmp`
161
+ await writeFile(temp, JSON.stringify({
162
+ version: CACHE_VERSION,
163
+ updatedAt: new Date().toISOString(),
164
+ profile,
165
+ }), { encoding: 'utf8', mode: 0o600 })
166
+ await rename(temp, CACHE_FILE)
167
+ } catch (error) {
168
+ console.error(`${LOG_PREFIX} profile cache write failed`, { code: error?.code })
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Install the profile-read adapter.
174
+ * @param ctx - Host context owning the account service.
175
+ * @param config - volatile identity configuration; `name`, `contact`, `avatarUrl`, `id`.
176
+ */
177
+ export function apply(ctx, config) {
178
+ let cached
179
+ const cacheReady = readCachedProfile().then((value) => { cached = value })
180
+ const configuredSignature = () => {
181
+ const profile = configuredProfile(config)
182
+ if (profile === undefined) return ''
183
+ return [profile.name, profile.contact, profile.avatarUrl, profile.id].map((value) => value ?? '').join('\u0000')
184
+ }
185
+ const fallback = () => mergeProfile(configuredProfile(config), cached)
186
+ const nudge = () => {
187
+ try {
188
+ if (typeof account.changed === 'function') account.changed()
189
+ } catch (error) {
190
+ console.error(`${LOG_PREFIX} account change notification failed`, { code: error?.code })
191
+ }
192
+ }
193
+
194
+ const account = ctx.deepseekAccount
195
+ if (typeof account?.getProfile !== 'function') {
196
+ console.error(`${LOG_PREFIX} deepseekAccount.getProfile is unavailable; fallback was not installed`)
197
+ return
198
+ }
199
+
200
+ // Keep one true original reader on the service instance. A configuration
201
+ // change can remount the plugin before the previous instance is disposed;
202
+ // re-using the marker prevents stacking a patched reader on a patched
203
+ // reader, which would make a fallback reply look like a real Platform read.
204
+ const original = account[ORIGINAL_GET_PROFILE] ?? account.getProfile
205
+ if (account[ORIGINAL_GET_PROFILE] === undefined) {
206
+ try {
207
+ Object.defineProperty(account, ORIGINAL_GET_PROFILE, { value: original, configurable: true })
208
+ } catch (_error) {}
209
+ }
210
+ async function getProfile(client) {
211
+ let result
212
+ try {
213
+ result = await original.call(account, client)
214
+ } catch (error) {
215
+ console.error(`${LOG_PREFIX} profile read threw`, { code: error?.code })
216
+ result = undefined
217
+ }
218
+
219
+ if (result !== null && typeof result === 'object' && result.status === 'ready') {
220
+ const profile = projectProfile(result.value)
221
+ if (profile !== undefined) {
222
+ cached = profile
223
+ void writeCachedProfile(profile)
224
+ }
225
+ return result
226
+ }
227
+
228
+ // Only a signed-in-but-unreadable profile is replaced; null keeps sign-out.
229
+ const blocked = result === undefined
230
+ || (result !== null && typeof result === 'object' && result.status === 'failed')
231
+ if (blocked) {
232
+ await cacheReady
233
+ const profile = fallback()
234
+ if (profile !== undefined) return { status: 'ready', value: profile }
235
+ }
236
+ return result
237
+ }
238
+
239
+ account.getProfile = getProfile
240
+ ctx.effect(() => () => {
241
+ if (account.getProfile === getProfile) account.getProfile = original
242
+ }, 'account-identity-fallback: restore profile read')
243
+
244
+ // A configuration save from the Plugins page commits volatile fields in
245
+ // place and emits `loader/volatile-update`; ask connected account surfaces to
246
+ // read the profile again so the edited identity appears immediately.
247
+ ctx.on('loader/volatile-update', nudge)
248
+
249
+ // The loader event is the primary signal; the signature watch also covers a
250
+ // remount race where the save lands between plugin instances.
251
+ let lastSignature = configuredSignature()
252
+ const watch = setInterval(() => {
253
+ const next = configuredSignature()
254
+ if (next === lastSignature) return
255
+ lastSignature = next
256
+ nudge()
257
+ }, 1000)
258
+ watch.unref?.()
259
+ ctx.effect(() => () => clearInterval(watch), 'account-identity-fallback: configuration watch')
260
+
261
+ // Nudge connected clients once at load so the restored identity is read now
262
+ // instead of waiting for the next Settings visit or app restart.
263
+ setTimeout(nudge, 0)
264
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Account identity fallback",
4
+ "description": "Keeps the DeepSeek account name and avatar visible when the Platform profile endpoint is blocked."
5
+ }
6
+ }
package/locale/ru.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Имя и аватар аккаунта",
4
+ "description": "Показывает имя и аватар аккаунта DeepSeek, когда платформа не отдаёт профиль."
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@tyapinalexandr/dsh-account-identity-fallback",
3
+ "version": "0.2.4",
4
+ "description": "Keeps the DeepSeek account name and avatar visible when the Platform profile endpoint is blocked, with an in-app name/avatar editor",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./client.js",
10
+ "./package.json": "./package.json",
11
+ "./locale/*.json": "./locale/*.json"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "client.js",
16
+ "cordis.patch.yml",
17
+ "README.md",
18
+ "icon.svg",
19
+ "locale/*.json"
20
+ ],
21
+ "icon": "./icon.svg",
22
+ "keywords": [
23
+ "dsh-plugin",
24
+ "deepseek-harness",
25
+ "deepseek",
26
+ "account",
27
+ "profile",
28
+ "fallback"
29
+ ],
30
+ "author": "tyapinalexandr",
31
+ "license": "MIT",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "dsh": {
36
+ "bundle": {
37
+ "patch": "./cordis.patch.yml"
38
+ },
39
+ "client": {
40
+ "platform": "web"
41
+ }
42
+ },
43
+ "peerDependencies": {
44
+ "@deepseek-ai/schemastery": "^3.18.0"
45
+ },
46
+ "devDependencies": {
47
+ "@deepseek-ai/schemastery": "^3.18.0"
48
+ },
49
+ "engines": {
50
+ "dsh": ">=0.2.1-rc.5 <0.3.0"
51
+ }
52
+ }