tensorgrid-ui 1.0.5 → 1.1.1

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.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Мост между браузером и стойкой входа — Host-сторона.
3
+ *
4
+ * Стойка `ctx.authorization` ведёт разговор: показывает ссылку, иногда
5
+ * просит код, иногда задаёт вопрос с выбором. Разговор асинхронный и длится
6
+ * минуты, поэтому одним запросом его не обслужить: браузер начинает
7
+ * попытку, затем опрашивает состояние и отвечает на вопросы.
8
+ *
9
+ * Состояние живёт здесь, в одном экземпляре. Это не упрощение: сама стойка
10
+ * допускает ровно одну попытку на ключ, и держать больше было бы враньём о
11
+ * её возможностях.
12
+ *
13
+ * Импортируются только встроенные модули Node — строка композиции грузится
14
+ * раньше, чем о пакете что-либо известно.
15
+ */
16
+
17
+ /** Текущая попытка. null, пока никто не входил. */
18
+ let attempt = null
19
+
20
+ /** Обрезаем историю сообщений: разговор входа короткий, а расти без предела нечему. */
21
+ const MAX_NOTICES = 20
22
+
23
+ function freshAttempt(key, method) {
24
+ return {
25
+ key,
26
+ method: method ?? null,
27
+ notices: [],
28
+ prompt: null,
29
+ resolvePrompt: null,
30
+ outcome: null,
31
+ problem: null,
32
+ startedAt: new Date().toISOString(),
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Состояние попытки в виде, пригодном для браузера: только простые
38
+ * значения, никаких живых объектов и функций.
39
+ */
40
+ function view() {
41
+ if (attempt === null) return { active: false }
42
+ return {
43
+ active: attempt.outcome === null && attempt.problem === null,
44
+ key: attempt.key,
45
+ method: attempt.method,
46
+ startedAt: attempt.startedAt,
47
+ notices: attempt.notices.map((notice) => ({
48
+ message: String(notice.message ?? ''),
49
+ url: typeof notice.url === 'string' ? notice.url : null,
50
+ code: typeof notice.code === 'string' ? notice.code : null,
51
+ })),
52
+ prompt: attempt.prompt === null ? null : {
53
+ kind: String(attempt.prompt.kind ?? 'text'),
54
+ message: String(attempt.prompt.message ?? ''),
55
+ options: Array.isArray(attempt.prompt.options)
56
+ ? attempt.prompt.options.map((option) => ({
57
+ id: String(option.id),
58
+ label: String(option.label),
59
+ description: typeof option.description === 'string' ? option.description : null,
60
+ }))
61
+ : null,
62
+ },
63
+ outcome: attempt.outcome,
64
+ problem: attempt.problem,
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Список провайдеров: что предлагает вход и что уже настроено.
70
+ *
71
+ * Настроенность спрашивается у хранилища учётных данных, а не выводится из
72
+ * попыток: пользователь мог войти в прошлый раз, в другом профиле или
73
+ * вообще через терминал.
74
+ */
75
+ export async function listProviders(ctx) {
76
+ const entries = ctx.authorization.list()
77
+ const credentials = ctx.get('credentials')
78
+
79
+ let configured = new Set()
80
+ if (credentials !== undefined) {
81
+ try {
82
+ const records = await credentials.listRecords()
83
+ configured = new Set(records.map((record) => String(record.key)))
84
+ } catch {
85
+ // Хранилище недоступно — покажем список без отметок, это лучше отказа.
86
+ }
87
+ }
88
+
89
+ return {
90
+ providers: entries.map((entry) => ({
91
+ key: String(entry.key),
92
+ label: String(entry.label),
93
+ inFlight: entry.inFlight === true,
94
+ configured: configured.has(String(entry.key)),
95
+ methods: entry.methods.map((method) => ({ id: String(method.id), label: String(method.label) })),
96
+ })),
97
+ attempt: view(),
98
+ }
99
+ }
100
+
101
+ /** Начинает вход. Предыдущая завершённая попытка забывается. */
102
+ export function begin(ctx, key, method) {
103
+ if (attempt !== null && attempt.outcome === null && attempt.problem === null) {
104
+ return { problem: 'одна попытка уже идёт', attempt: view() }
105
+ }
106
+
107
+ attempt = freshAttempt(key, method)
108
+ const current = attempt
109
+
110
+ const request = {
111
+ key,
112
+ interaction: {
113
+ notify(notice) {
114
+ current.notices.push(notice)
115
+ if (current.notices.length > MAX_NOTICES) current.notices.shift()
116
+ },
117
+ prompt(prompt) {
118
+ current.prompt = prompt
119
+ return new Promise((resolve) => { current.resolvePrompt = resolve })
120
+ },
121
+ },
122
+ }
123
+ if (typeof method === 'string' && method !== '') request.method = method
124
+
125
+ ctx.authorization.begin(request).then(
126
+ (outcome) => {
127
+ current.outcome = String(outcome.status)
128
+ current.prompt = null
129
+ current.resolvePrompt = null
130
+ },
131
+ (error) => {
132
+ current.problem = String(error?.message ?? error)
133
+ current.prompt = null
134
+ current.resolvePrompt = null
135
+ },
136
+ )
137
+
138
+ return { attempt: view() }
139
+ }
140
+
141
+ /** Отвечает на вопрос, который задал поток. */
142
+ export function answer(value) {
143
+ if (attempt === null || attempt.resolvePrompt === null) {
144
+ return { problem: 'поток ничего не спрашивает', attempt: view() }
145
+ }
146
+ const resolve = attempt.resolvePrompt
147
+ attempt.resolvePrompt = null
148
+ attempt.prompt = null
149
+ resolve(String(value))
150
+ return { attempt: view() }
151
+ }
152
+
153
+ /**
154
+ * Снимает попытку. Нужно не только по желанию пользователя: стойка держит
155
+ * ключ занятым, и осиротевшая попытка иначе блокирует повторный вход до
156
+ * перезапуска.
157
+ */
158
+ export function cancel(ctx, key) {
159
+ const target = key ?? attempt?.key
160
+ if (typeof target !== 'string') return { problem: 'нечего отменять', attempt: view() }
161
+ ctx.authorization.cancel(target)
162
+ if (attempt !== null && attempt.key === target) {
163
+ attempt.problem = attempt.problem ?? 'отменено'
164
+ attempt.prompt = null
165
+ attempt.resolvePrompt = null
166
+ }
167
+ return { attempt: view() }
168
+ }
169
+
170
+ /** Текущее состояние без изменений. */
171
+ export function poll() {
172
+ return { attempt: view() }
173
+ }
package/lib/client.js CHANGED
@@ -177,6 +177,20 @@ window.__ModuleLoader__.load({
177
177
  'intensity.vivid': 'Vivid',
178
178
  'accent.title': 'Obsidian / Ion accent',
179
179
  'accent.hint': 'Leading tone of the interface and the glow',
180
+ 'auth.title': 'Provider sign-in',
181
+ 'auth.hint': 'Sign in with a subscription instead of an API key. The account stays on this computer.',
182
+ 'auth.signIn': 'Sign in',
183
+ 'auth.again': 'Sign in again',
184
+ 'auth.connected': 'connected',
185
+ 'auth.openLink': 'Open the sign-in page',
186
+ 'auth.send': 'Send',
187
+ 'auth.cancel': 'Cancel',
188
+ 'auth.done': 'Signed in.',
189
+ 'auth.cancelled': 'Sign-in cancelled.',
190
+ 'auth.unavailable': 'Sign-in is unavailable: the authorization row is not mounted in this profile.',
191
+ 'auth.needsRestart': 'Restart the application: the browser already has the new version, the server does not yet.',
192
+ 'auth.badResponse': 'The server answered {status} instead of data.',
193
+ 'auth.empty': 'No provider offers subscription sign-in.',
180
194
  'update.title': 'Updates',
181
195
  'update.check': 'Check',
182
196
  'update.apply': 'Update',
@@ -208,6 +222,20 @@ window.__ModuleLoader__.load({
208
222
  'intensity.vivid': '强烈',
209
223
  'accent.title': 'Obsidian / Ion 主色',
210
224
  'accent.hint': '界面与光晕的主色调',
225
+ 'auth.title': '登录服务商',
226
+ 'auth.hint': '使用订阅登录,而非 API 密钥。账号仅保存在本机。',
227
+ 'auth.signIn': '登录',
228
+ 'auth.again': '重新登录',
229
+ 'auth.connected': '已连接',
230
+ 'auth.openLink': '打开登录页面',
231
+ 'auth.send': '发送',
232
+ 'auth.cancel': '取消',
233
+ 'auth.done': '已登录。',
234
+ 'auth.cancelled': '登录已取消。',
235
+ 'auth.unavailable': '无法登录:此配置文件未挂载授权行。',
236
+ 'auth.needsRestart': '请重启应用:浏览器已是新版本,服务端还不是。',
237
+ 'auth.badResponse': '服务端返回 {status},而不是数据。',
238
+ 'auth.empty': '没有服务商提供订阅登录。',
211
239
  'update.title': '更新',
212
240
  'update.check': '检查',
213
241
  'update.apply': '更新',
@@ -1536,6 +1564,20 @@ window.__ModuleLoader__.load({
1536
1564
  'intensity.vivid': 'Ярко',
1537
1565
  'accent.title': 'Акцент Obsidian / Ion',
1538
1566
  'accent.hint': 'Ведущий тон интерфейса и свечения',
1567
+ 'auth.title': 'Вход к провайдерам',
1568
+ 'auth.hint': 'Войти по подписке вместо ключа API. Учётная запись остаётся на этом компьютере.',
1569
+ 'auth.signIn': 'Войти',
1570
+ 'auth.again': 'Войти заново',
1571
+ 'auth.connected': 'подключено',
1572
+ 'auth.openLink': 'Открыть страницу входа',
1573
+ 'auth.send': 'Отправить',
1574
+ 'auth.cancel': 'Отменить',
1575
+ 'auth.done': 'Вход выполнен.',
1576
+ 'auth.cancelled': 'Вход отменён.',
1577
+ 'auth.unavailable': 'Вход недоступен: в профиле не смонтирована строка авторизации.',
1578
+ 'auth.needsRestart': 'Перезапустите приложение: у браузера уже новая версия, у сервера ещё нет.',
1579
+ 'auth.badResponse': 'Сервер ответил {status} вместо данных.',
1580
+ 'auth.empty': 'Ни один провайдер не предлагает вход по подписке.',
1539
1581
  'update.title': 'Обновления',
1540
1582
  'update.check': 'Проверить',
1541
1583
  'update.apply': 'Обновить',
@@ -1668,7 +1710,7 @@ window.__ModuleLoader__.load({
1668
1710
  // поэтому строка остаётся согласованной при любой палитре.
1669
1711
  '.dsx-setting{display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;}',
1670
1712
  '.dsx-setting__title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;}',
1671
- '.dsx-setting__hint--strong{color:var(--dsw-alias-label-primary)}.dsx-setting__hint--warn{color:var(--dsw-alias-state-warn-primary)}.dsx-setting__hint{color:var(--dsw-alias-label-secondary);font-size:12px;margin-top:2px;}',
1713
+ '.dsx-setting--stack{flex-direction:column;align-items:stretch;gap:10px}.dsx-auth__list{display:flex;flex-direction:column;gap:6px}.dsx-auth__row{display:flex;align-items:center;justify-content:space-between;gap:12px}.dsx-auth__name{color:var(--dsw-alias-label-primary)}.dsx-auth__badge{margin-left:8px;padding:1px 6px;border-radius:999px;font-size:11px;color:var(--dsw-alias-state-success-primary);border:1px solid var(--dsw-alias-border-l2)}.dsx-auth__panel{display:flex;flex-direction:column;gap:8px;padding:10px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1)}.dsx-auth__notice,.dsx-auth__prompt{display:flex;flex-direction:column;gap:6px;color:var(--dsw-alias-label-secondary)}.dsx-auth__link{color:var(--dsw-alias-brand-primary);word-break:break-all}.dsx-auth__code{font-family:ui-monospace,monospace;letter-spacing:.08em;color:var(--dsw-alias-label-primary)}.dsx-auth__input{flex:1;min-width:0;padding:4px 8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.dsx-setting__hint--strong{color:var(--dsw-alias-label-primary)}.dsx-setting__hint--warn{color:var(--dsw-alias-state-warn-primary)}.dsx-setting__hint{color:var(--dsw-alias-label-secondary);font-size:12px;margin-top:2px;}',
1672
1714
  '.dsx-setting__control{display:inline-flex;padding:2px;gap:2px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);flex:none;}',
1673
1715
  '.dsx-seg{appearance:none;border:0;cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;line-height:18px;background:transparent;color:var(--dsw-alias-label-secondary);transition:background .16s ease,color .16s ease;}',
1674
1716
  '.dsx-seg:hover{color:var(--dsw-alias-label-primary);}',
@@ -1892,6 +1934,212 @@ window.__ModuleLoader__.load({
1892
1934
  })
1893
1935
  }
1894
1936
 
1937
+ // ── Вход по подписке ─────────────────────────────────────────────────
1938
+ //
1939
+ // Стойка входа ведёт разговор: показывает ссылку, иногда просит код,
1940
+ // иногда задаёт вопрос с выбором. Разговор длится минуты, поэтому
1941
+ // браузер начинает попытку и затем опрашивает состояние.
1942
+ const AUTH_PATH = '/api/tensorgrid.auth'
1943
+
1944
+ function AuthRow({ t }) {
1945
+ const [state, setState] = React.useState(null)
1946
+ const [busy, setBusy] = React.useState(false)
1947
+ const [draft, setDraft] = React.useState('')
1948
+
1949
+ const send = React.useCallback(async (body) => {
1950
+ const response = body === undefined
1951
+ ? await fetch(AUTH_PATH)
1952
+ : await fetch(AUTH_PATH, {
1953
+ method: 'POST',
1954
+ headers: { 'content-type': 'application/json' },
1955
+ body: JSON.stringify(body),
1956
+ })
1957
+
1958
+ // Маршрут поднимает host-половина, а она читается только при старте
1959
+ // приложения. После обновления браузер получает новый код раньше
1960
+ // Host-а, запрос уходит в общий заслон и возвращает 401 страницей, а
1961
+ // не JSON. Без этой проверки строка молча показывала пустоту.
1962
+ const type = response.headers.get('content-type') || ''
1963
+ if (!type.includes('application/json')) {
1964
+ return {
1965
+ available: true,
1966
+ providers: [],
1967
+ problem: response.status === 401 || response.status === 404
1968
+ ? t('auth.needsRestart')
1969
+ : t('auth.badResponse').replace('{status}', String(response.status)),
1970
+ }
1971
+ }
1972
+ return response.json()
1973
+ }, [t])
1974
+
1975
+ const refresh = React.useCallback(async (body) => {
1976
+ setBusy(true)
1977
+ try {
1978
+ setState(await send(body))
1979
+ } catch (error) {
1980
+ setState({ available: true, problem: String(error && error.message ? error.message : error) })
1981
+ } finally {
1982
+ setBusy(false)
1983
+ }
1984
+ }, [send])
1985
+
1986
+ React.useEffect(() => { refresh() }, [refresh])
1987
+
1988
+ // Пока попытка идёт, состояние спрашивается повторно: поток отвечает
1989
+ // не сразу, а ссылка и вопросы приходят по ходу дела.
1990
+ const active = state !== null && state.attempt !== undefined && state.attempt !== null && state.attempt.active === true
1991
+ React.useEffect(() => {
1992
+ if (!active) return undefined
1993
+ const id = setInterval(() => { refresh({ action: 'poll' }) }, 2000)
1994
+ return () => clearInterval(id)
1995
+ }, [active, refresh])
1996
+
1997
+ if (state !== null && state.available === false) {
1998
+ return jsx('div', {
1999
+ className: 'dsx-setting',
2000
+ children: jsxs('div', {
2001
+ children: [
2002
+ jsx('div', { className: 'dsx-setting__title', children: t('auth.title') }),
2003
+ jsx('div', { className: 'dsx-setting__hint', children: t('auth.unavailable') }),
2004
+ ],
2005
+ }),
2006
+ })
2007
+ }
2008
+
2009
+ const attempt = state === null || !state.attempt ? null : state.attempt
2010
+ const providers = state === null || !Array.isArray(state.providers) ? [] : state.providers
2011
+ // Показываем только вход по подписке: ключи API у настроек моделей свои.
2012
+ const subscription = providers.filter((p) => p.methods.some((m) => m.id === 'oauth'))
2013
+
2014
+ const rows = []
2015
+
2016
+ if (attempt !== null && (attempt.active || attempt.outcome || attempt.problem)) {
2017
+ for (const notice of attempt.notices || []) {
2018
+ rows.push(jsxs('div', {
2019
+ className: 'dsx-auth__notice',
2020
+ children: [
2021
+ jsx('div', { children: notice.message }),
2022
+ notice.url === null ? null : jsx('a', {
2023
+ className: 'dsx-auth__link',
2024
+ href: notice.url,
2025
+ target: '_blank',
2026
+ rel: 'noreferrer',
2027
+ children: t('auth.openLink'),
2028
+ }),
2029
+ notice.code === null ? null : jsx('code', { className: 'dsx-auth__code', children: notice.code }),
2030
+ ],
2031
+ }, 'notice-' + rows.length))
2032
+ }
2033
+
2034
+ if (attempt.prompt !== null) {
2035
+ const prompt = attempt.prompt
2036
+ rows.push(jsxs('div', {
2037
+ className: 'dsx-auth__prompt',
2038
+ children: [
2039
+ jsx('div', { children: prompt.message }),
2040
+ prompt.options !== null
2041
+ ? jsx('div', {
2042
+ className: 'dsx-setting__control',
2043
+ children: prompt.options.map((option) =>
2044
+ jsx('button', {
2045
+ type: 'button',
2046
+ className: 'dsx-seg',
2047
+ disabled: busy,
2048
+ onClick: () => refresh({ action: 'answer', value: option.id }),
2049
+ children: option.label,
2050
+ }, option.id),
2051
+ ),
2052
+ })
2053
+ : jsxs('div', {
2054
+ className: 'dsx-setting__control',
2055
+ children: [
2056
+ jsx('input', {
2057
+ className: 'dsx-auth__input',
2058
+ type: prompt.kind === 'secret' ? 'password' : 'text',
2059
+ value: draft,
2060
+ disabled: busy,
2061
+ onChange: (event) => setDraft(event.target.value),
2062
+ }),
2063
+ jsx('button', {
2064
+ type: 'button',
2065
+ className: 'dsx-seg dsx-seg--on',
2066
+ disabled: busy || draft === '',
2067
+ onClick: () => { refresh({ action: 'answer', value: draft }); setDraft('') },
2068
+ children: t('auth.send'),
2069
+ }),
2070
+ ],
2071
+ }),
2072
+ ],
2073
+ }, 'prompt'))
2074
+ }
2075
+
2076
+ if (attempt.outcome) {
2077
+ rows.push(jsx('div', {
2078
+ className: 'dsx-setting__hint dsx-setting__hint--strong',
2079
+ children: attempt.outcome === 'authorized' ? t('auth.done') : t('auth.cancelled'),
2080
+ }, 'outcome'))
2081
+ }
2082
+ if (attempt.problem) {
2083
+ rows.push(jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: attempt.problem }, 'problem'))
2084
+ }
2085
+ }
2086
+
2087
+ return jsxs('div', {
2088
+ className: 'dsx-setting dsx-setting--stack',
2089
+ children: [
2090
+ jsxs('div', {
2091
+ children: [
2092
+ jsx('div', { className: 'dsx-setting__title', children: t('auth.title') }),
2093
+ jsx('div', { className: 'dsx-setting__hint', children: t('auth.hint') }),
2094
+ ],
2095
+ }),
2096
+ state !== null && state.problem && (attempt === null || !attempt.problem)
2097
+ ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: state.problem })
2098
+ : null,
2099
+ state !== null && !state.problem && subscription.length === 0
2100
+ ? jsx('div', { className: 'dsx-setting__hint', children: t('auth.empty') })
2101
+ : null,
2102
+ jsx('div', {
2103
+ className: 'dsx-auth__list',
2104
+ children: subscription.map((provider) =>
2105
+ jsxs('div', {
2106
+ className: 'dsx-auth__row',
2107
+ children: [
2108
+ jsxs('div', {
2109
+ children: [
2110
+ jsx('span', { className: 'dsx-auth__name', children: provider.label }),
2111
+ provider.configured
2112
+ ? jsx('span', { className: 'dsx-auth__badge', children: t('auth.connected') })
2113
+ : null,
2114
+ ],
2115
+ }),
2116
+ jsx('button', {
2117
+ type: 'button',
2118
+ className: 'dsx-seg',
2119
+ disabled: busy || (attempt !== null && attempt.active),
2120
+ onClick: () => refresh({ action: 'begin', key: provider.key, method: 'oauth' }),
2121
+ children: provider.configured ? t('auth.again') : t('auth.signIn'),
2122
+ }),
2123
+ ],
2124
+ }, provider.key),
2125
+ ),
2126
+ }),
2127
+ rows.length === 0 ? null : jsx('div', { className: 'dsx-auth__panel', children: rows }),
2128
+ attempt !== null && attempt.active
2129
+ ? jsx('div', {
2130
+ className: 'dsx-setting__control',
2131
+ children: jsx('button', {
2132
+ type: 'button',
2133
+ className: 'dsx-seg',
2134
+ onClick: () => refresh({ action: 'cancel' }),
2135
+ children: t('auth.cancel'),
2136
+ }),
2137
+ })
2138
+ : null,
2139
+ ],
2140
+ })
2141
+ }
2142
+
1895
2143
  // ── Обновления ───────────────────────────────────────────────────────
1896
2144
  //
1897
2145
  // Маршрут поднимает host-половина пакета. Путь относительный, токен не
@@ -2100,6 +2348,13 @@ window.__ModuleLoader__.load({
2100
2348
  ),
2101
2349
  )
2102
2350
 
2351
+ ctx.slots.inject('settings.general.item', () =>
2352
+ ctx.slots.register(
2353
+ { name: 'settings.general.item', id: 'tensorgrid-auth', order: 10, locale: LOCALE_NS },
2354
+ AuthRow,
2355
+ ),
2356
+ )
2357
+
2103
2358
  ctx.slots.inject('settings.general.item', () =>
2104
2359
  ctx.slots.register(
2105
2360
  { name: 'settings.general.item', id: 'obsidian-ion-update', order: 11, locale: LOCALE_NS },
package/lib/index.js CHANGED
@@ -159,8 +159,8 @@ export function apply(ctx) {
159
159
  // модуль по адресу, и после замены файла Host продолжал бы работать по
160
160
  // старой логике до перезапуска. Ключ меняется только когда файл
161
161
  // действительно изменился, поэтому лишних копий в памяти не остаётся.
162
- async function loadModule() {
163
- const path = new URL('./update-check.js', import.meta.url)
162
+ async function loadModule(name = './update-check.js') {
163
+ const path = new URL(name, import.meta.url)
164
164
  let version = ''
165
165
  try {
166
166
  const { statSync } = await import('node:fs')
@@ -187,6 +187,63 @@ export function apply(ctx) {
187
187
  }, 8000)
188
188
  web.effect(() => () => clearTimeout(timer))
189
189
 
190
+ // ── Вход по подписке ────────────────────────────────────────────────
191
+ //
192
+ // Стойка входа монтируется отдельной строкой профиля, и её может не
193
+ // быть. Берём мягко: без неё маршрут честно отвечает, что вход
194
+ // недоступен, вместо того чтобы уронить весь ряд.
195
+ web.effect(() =>
196
+ web.webServer.register({
197
+ kind: 'exact',
198
+ path: '/api/tensorgrid.auth',
199
+ async handler(req, res) {
200
+ try {
201
+ const authorization = web.get('authorization')
202
+ if (authorization === undefined) {
203
+ reply(res, 200, { available: false, problem: 'стойка входа не смонтирована' })
204
+ return
205
+ }
206
+
207
+ const bridge = await loadModule('./auth-bridge.js')
208
+
209
+ if (req.method === 'GET') {
210
+ reply(res, 200, { available: true, ...(await bridge.listProviders(web)) })
211
+ return
212
+ }
213
+
214
+ if (req.method !== 'POST') {
215
+ reply(res, 405, { problem: 'поддерживаются GET и POST' })
216
+ return
217
+ }
218
+
219
+ // Тот же заслон, что у обновлений: браузер не отправит запрос с
220
+ // JSON-заголовком с чужой страницы без предварительной проверки.
221
+ if (!String(req.headers['content-type'] ?? '').includes('application/json')) {
222
+ reply(res, 415, { problem: 'ожидается content-type: application/json' })
223
+ return
224
+ }
225
+
226
+ const body = await readJsonBody(req)
227
+ if (body === null) {
228
+ reply(res, 400, { problem: 'тело запроса не разобрано' })
229
+ return
230
+ }
231
+
232
+ let result
233
+ if (body.action === 'begin') result = bridge.begin(web, String(body.key), body.method)
234
+ else if (body.action === 'answer') result = bridge.answer(body.value)
235
+ else if (body.action === 'cancel') result = bridge.cancel(web, body.key)
236
+ else if (body.action === 'poll') result = bridge.poll()
237
+ else { reply(res, 400, { problem: `неизвестное действие: ${String(body.action)}` }); return }
238
+
239
+ reply(res, 200, { available: true, ...result })
240
+ } catch (error) {
241
+ reply(res, 500, { problem: String(error?.message ?? error) })
242
+ }
243
+ },
244
+ }),
245
+ )
246
+
190
247
  function reply(res, code, value) {
191
248
  const body = JSON.stringify(value)
192
249
  res.writeHead(code, {
@@ -196,6 +253,28 @@ export function apply(ctx) {
196
253
  res.end(body)
197
254
  }
198
255
 
256
+ /**
257
+ * Читает тело запроса как JSON. Размер ограничен: тело здесь всегда
258
+ * крошечное — действие, ключ, ответ на вопрос, — и принимать больше
259
+ * значило бы держать открытой дверь без нужды.
260
+ */
261
+ function readJsonBody(req) {
262
+ return new Promise((resolve) => {
263
+ let text = ''
264
+ let tooBig = false
265
+ req.on('data', (chunk) => {
266
+ if (tooBig) return
267
+ text += chunk
268
+ if (text.length > 16384) { tooBig = true; resolve(null) }
269
+ })
270
+ req.on('end', () => {
271
+ if (tooBig) return
272
+ try { resolve(JSON.parse(text === '' ? '{}' : text)) } catch { resolve(null) }
273
+ })
274
+ req.on('error', () => resolve(null))
275
+ })
276
+ }
277
+
199
278
  web.effect(() =>
200
279
  web.webServer.register({
201
280
  kind: 'exact',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tensorgrid-ui",
3
- "version": "1.0.5",
3
+ "version": "1.1.1",
4
4
  "description": "TENSOR GRID — фирменный интерфейс поверх DeepSeek Harness: палитра, живой ambient-слой, айдентика и русский язык-пакет",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",