@rezti/dsh-rez-suite 0.1.4 → 0.1.6

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.
@@ -1,117 +1,89 @@
1
1
  /**
2
- * dsh-rez-suite panel styles. Scoped by plugin data attributes, colors ride
3
- * dsh --dsw-* tokens. The center-column takeover rules are attribute-scoped
4
- * and must stay in this stylesheet (imported by mount.tsx).
2
+ * Settings-native styles for the ReZ card/tab. Colors ride dsh --dsw-* tokens
3
+ * so the form matches 设置 → 插件. The stylesheet is inlined into client.js
4
+ * at wrap time dsh only loads the JS module, not lib/style.css.
5
5
  */
6
6
 
7
- [data-pane='conversation'] {
8
- position: relative;
7
+ .shell {
8
+ display: flex;
9
+ flex-direction: column;
10
+ gap: 12px;
11
+ min-width: 0;
9
12
  }
10
13
 
11
- [data-dsh-rez-view] {
12
- position: absolute;
13
- inset: 0;
14
- display: none;
15
- z-index: 60;
16
- background: var(--dsw-alias-bg-base);
14
+ .card {
15
+ list-style: none;
16
+ border: 1px solid var(--dsw-alias-border-l2);
17
+ border-radius: 12px;
18
+ background: var(--dsw-alias-bg-layer-3);
19
+ transition: border-color 0.16s, background 0.16s;
17
20
  }
18
21
 
19
- html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) [data-dsh-rez-view] {
20
- display: block;
22
+ .card:hover {
23
+ border-color: var(--dsw-alias-label-dimmed);
21
24
  }
22
25
 
23
- html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) [data-pane='conversation'] > :not([data-dsh-rez-view]),
24
- html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) [class*='centerCol'] > :not([data-dsh-rez-view]) {
25
- display: none !important;
26
+ .cardOpen {
27
+ background: var(--dsw-alias-bg-layer-2);
28
+ border-color: var(--dsw-alias-label-dimmed);
26
29
  }
27
30
 
28
- .entry {
29
- display: flex;
30
- align-items: center;
31
- gap: 8px;
31
+ .header {
32
32
  width: 100%;
33
- height: 32px;
34
- padding: 0 12px;
35
- background: transparent;
36
- border: none;
37
- border-radius: 8px;
38
- color: var(--dsw-alias-label-secondary);
33
+ appearance: none;
34
+ border: 0;
35
+ background: none;
36
+ font: inherit;
37
+ color: inherit;
38
+ text-align: left;
39
39
  cursor: pointer;
40
- font-size: 13px;
41
- white-space: nowrap;
42
- }
43
-
44
- .entry:hover {
45
- background: var(--dsw-specific-sidebar-nav-item-hover);
46
- color: var(--dsw-alias-label-primary);
47
- }
48
-
49
- .entryIcon {
50
- display: inline-flex;
40
+ display: flex;
51
41
  align-items: center;
52
- justify-content: center;
53
- flex: none;
54
- }
55
-
56
- .entryLabel {
57
- overflow: hidden;
58
- text-overflow: ellipsis;
59
- }
60
-
61
- [data-dsh-frame][data-sidebar-collapsed] .entry {
62
- justify-content: center;
63
- padding: 0;
64
- width: 100%;
65
- }
66
-
67
- [data-dsh-frame][data-sidebar-collapsed] .entryLabel {
68
- display: none;
42
+ gap: 12px;
43
+ padding: 14px 16px;
44
+ border-radius: 12px;
69
45
  }
70
46
 
71
- .view {
72
- overflow: hidden;
47
+ .header:focus-visible {
48
+ outline: 2px solid var(--dsw-alias-brand-primary);
49
+ outline-offset: -2px;
73
50
  }
74
51
 
75
- .panel {
52
+ .headText {
53
+ flex: 1;
54
+ min-width: 0;
76
55
  display: flex;
77
56
  flex-direction: column;
78
- height: 100%;
79
- min-width: 0;
80
- min-height: 0;
81
- padding: 14px 16px 16px;
82
- gap: 10px;
83
- background: var(--dsw-alias-bg-base);
57
+ gap: 4px;
58
+ }
59
+
60
+ .name {
61
+ font-size: 15px;
62
+ font-weight: 600;
63
+ line-height: 1.4;
84
64
  color: var(--dsw-alias-label-primary);
85
- font-family: var(--dsw-font-family);
86
65
  }
87
66
 
88
- .panelHeader {
89
- display: flex;
90
- align-items: center;
91
- gap: 10px;
92
- flex: none;
67
+ .description {
68
+ font-size: 13px;
69
+ line-height: 1.5;
70
+ color: var(--dsw-alias-label-tertiary);
93
71
  }
94
72
 
95
- .panelTitle {
96
- margin: 0;
97
- flex: 1;
98
- font-size: 16px;
99
- font-weight: 700;
73
+ .chevron {
74
+ flex: none;
75
+ color: var(--dsw-alias-label-tertiary);
76
+ transition: transform 0.16s;
100
77
  }
101
78
 
102
- .iconButton {
103
- border: none;
104
- background: transparent;
105
- color: var(--dsw-alias-label-secondary);
106
- cursor: pointer;
107
- font-size: 14px;
108
- padding: 4px 8px;
109
- border-radius: 6px;
79
+ .chevronOpen {
80
+ transform: rotate(180deg);
110
81
  }
111
82
 
112
- .iconButton:hover {
113
- background: var(--dsw-alias-interactive-bg-hover);
114
- color: var(--dsw-alias-label-primary);
83
+ .body {
84
+ border-top: 1px solid var(--dsw-alias-border-l2);
85
+ margin: 0 16px 12px;
86
+ padding-top: 8px;
115
87
  }
116
88
 
117
89
  .tabBar {
@@ -143,21 +115,12 @@ html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-act
143
115
  border-bottom-color: var(--dsw-alias-state-business-primary);
144
116
  }
145
117
 
146
- .panelContent {
147
- flex: 1;
148
- min-height: 0;
149
- display: flex;
150
- flex-direction: column;
151
- overflow: hidden;
152
- }
153
-
154
118
  .tabBody {
155
119
  display: flex;
156
120
  flex-direction: column;
157
121
  gap: 12px;
158
- flex: 1;
159
- min-height: 0;
160
- overflow-y: auto;
122
+ padding: 12px 0 4px;
123
+ min-width: 0;
161
124
  }
162
125
 
163
126
  .section {
@@ -174,13 +137,14 @@ html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-act
174
137
  margin: 0;
175
138
  font-size: 13px;
176
139
  font-weight: 600;
140
+ color: var(--dsw-alias-label-primary);
177
141
  }
178
142
 
179
143
  .field {
180
- display: grid;
181
- grid-template-columns: 140px 1fr;
182
- gap: 8px;
183
- align-items: center;
144
+ display: flex;
145
+ flex-direction: column;
146
+ align-items: stretch;
147
+ gap: 4px;
184
148
  }
185
149
 
186
150
  .label {
@@ -188,11 +152,12 @@ html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-act
188
152
  color: var(--dsw-alias-label-secondary);
189
153
  }
190
154
 
191
- .input, .select {
155
+ .input,
156
+ .select {
192
157
  padding: 6px 8px;
193
158
  font-size: 13px;
194
159
  color: var(--dsw-alias-label-primary);
195
- background: var(--dsw-specific-input-major);
160
+ background: var(--dsw-specific-input-major, var(--dsw-alias-bg-layer-3));
196
161
  border: 1px solid var(--dsw-alias-border-l2);
197
162
  border-radius: 8px;
198
163
  outline: none;
@@ -217,28 +182,24 @@ html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-act
217
182
  font-size: 13px;
218
183
  border-radius: 8px;
219
184
  border: 1px solid var(--dsw-alias-border-l2);
220
- background: var(--dsw-specific-input-major);
185
+ background: var(--dsw-specific-input-major, var(--dsw-alias-bg-layer-3));
221
186
  color: var(--dsw-alias-label-primary);
222
187
  cursor: pointer;
223
188
  }
224
189
 
225
- .button:hover {
190
+ .button:hover:not(:disabled) {
226
191
  background: var(--dsw-alias-interactive-bg-hover);
227
192
  }
228
193
 
229
- .qr {
230
- display: block;
231
- width: 240px;
232
- height: 240px;
233
- margin: 8px 0;
234
- background: #fff;
235
- border-radius: 8px;
194
+ .button:disabled {
195
+ opacity: 0.4;
196
+ cursor: default;
236
197
  }
237
198
 
238
199
  .primaryButton {
239
- background: var(--dsw-alias-state-business-primary);
240
- border-color: var(--dsw-alias-state-business-primary);
241
- color: #fff;
200
+ background: var(--dsw-alias-label-primary);
201
+ border-color: var(--dsw-alias-label-primary);
202
+ color: var(--dsw-alias-bg-layer-3);
242
203
  }
243
204
 
244
205
  .dangerButton {
@@ -247,8 +208,19 @@ html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-act
247
208
  color: #fff;
248
209
  }
249
210
 
211
+ .qr {
212
+ display: block;
213
+ width: 200px;
214
+ height: 200px;
215
+ margin: 8px 0;
216
+ background: #fff;
217
+ border-radius: 8px;
218
+ }
219
+
250
220
  .message {
221
+ margin: 0;
251
222
  font-size: 12.5px;
223
+ line-height: 1.5;
252
224
  color: var(--dsw-alias-label-secondary);
253
225
  }
254
226
 
@@ -257,8 +229,6 @@ html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-act
257
229
  }
258
230
 
259
231
  .tableWrap {
260
- flex: 1;
261
- min-height: 0;
262
232
  overflow: auto;
263
233
  border: 1px solid var(--dsw-alias-border-l1);
264
234
  border-radius: 10px;
@@ -304,11 +274,11 @@ html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-act
304
274
 
305
275
  .cards {
306
276
  display: grid;
307
- grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
277
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
308
278
  gap: 8px;
309
279
  }
310
280
 
311
- .card {
281
+ .stat {
312
282
  padding: 10px;
313
283
  border: 1px solid var(--dsw-alias-border-l1);
314
284
  border-radius: 10px;
@@ -322,4 +292,5 @@ html[data-dsh-rez-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-act
322
292
  .cardValue {
323
293
  font-size: 15px;
324
294
  font-weight: 600;
295
+ color: var(--dsw-alias-label-primary);
325
296
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * ReZ settings pages: one accordion card in 设置 → 插件 → 插件配置,
3
+ * plus a dedicated Plugins tab for the same content.
4
+ */
5
+
6
+ import { useMemo, useState } from 'react'
7
+ import { RezApi } from './api.ts'
8
+ import { tt } from './panel/helpers.ts'
9
+ import { AuditTab } from './panel/AuditTab.tsx'
10
+ import { ConfigTab } from './panel/ConfigTab.tsx'
11
+ import { StatusTab } from './panel/StatusTab.tsx'
12
+ import { WeixinTab } from './panel/WeixinTab.tsx'
13
+ import css from './panel/panel.module.css'
14
+
15
+ export type RezTab = 'config' | 'weixin' | 'status' | 'audit'
16
+
17
+ const TABS: ReadonlyArray<{ id: RezTab; label: () => string }> = [
18
+ { id: 'config', label: () => tt('tab.config') },
19
+ { id: 'weixin', label: () => tt('tab.weixin') },
20
+ { id: 'status', label: () => tt('tab.status') },
21
+ { id: 'audit', label: () => tt('tab.audit') },
22
+ ]
23
+
24
+ export function RezSettingsPage() {
25
+ const api = useMemo(() => new RezApi(), [])
26
+ const [activeTab, setActiveTab] = useState<RezTab>('config')
27
+ return (
28
+ <div className={css.shell}>
29
+ <div className={css.tabBar} role="tablist">
30
+ {TABS.map(tab => (
31
+ <button
32
+ key={tab.id}
33
+ type="button"
34
+ role="tab"
35
+ aria-selected={activeTab === tab.id}
36
+ data-active={activeTab === tab.id ? '' : undefined}
37
+ className={css.tab}
38
+ onClick={() => { setActiveTab(tab.id) }}
39
+ >
40
+ {tab.label()}
41
+ </button>
42
+ ))}
43
+ </div>
44
+ {activeTab === 'config' && <ConfigTab api={api} />}
45
+ {activeTab === 'weixin' && <WeixinTab api={api} />}
46
+ {activeTab === 'status' && <StatusTab api={api} />}
47
+ {activeTab === 'audit' && <AuditTab api={api} />}
48
+ </div>
49
+ )
50
+ }
51
+
52
+ export function RezPluginCard() {
53
+ const [open, setOpen] = useState(false)
54
+ return (
55
+ <li className={css.card + (open ? ' ' + css.cardOpen : '')}>
56
+ <button type="button" className={css.header} onClick={() => { setOpen(!open) }}>
57
+ <div className={css.headText}>
58
+ <div className={css.name}>{tt('settings.title')}</div>
59
+ <div className={css.description}>{tt('settings.description')}</div>
60
+ </div>
61
+ <svg className={css.chevron + (open ? ' ' + css.chevronOpen : '')} viewBox="0 0 14 14" width="14" height="14" aria-hidden="true">
62
+ <path d="M3 5l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
63
+ </svg>
64
+ </button>
65
+ {open ? <div className={css.body}><RezSettingsPage /></div> : null}
66
+ </li>
67
+ )
68
+ }
package/src/index.ts CHANGED
@@ -2,9 +2,9 @@
2
2
  * dsh-rez-suite — employee-facing host half.
3
3
  *
4
4
  * MCP connections are owned by official @deepseek-ai/dsh-mcp-client rows in
5
- * this package's cordis.patch.yml. This plugin only mounts the Rez panel,
6
- * loopback settings API, and a short system-prompt announcement. Do not
7
- * start a second MCP host here.
5
+ * this package's cordis.patch.yml. This plugin only mounts the loopback
6
+ * settings API, a Settings → Plugins card, and a short system-prompt
7
+ * announcement. Do not start a second MCP host here.
8
8
  */
9
9
 
10
10
  import type { Context } from '@deepseek-ai/cordis'
@@ -15,6 +15,7 @@ import z from '@deepseek-ai/schemastery'
15
15
  import type {} from '@deepseek-ai/dsh-host-webserver'
16
16
  import type {} from '@deepseek-ai/dsh-system-prompt'
17
17
  import type {} from '@deepseek-ai/dsh-tools'
18
+ import { REZ_CREDENTIAL_REFS, REZ_DEFAULT_CONNECTIONS, secretConfigured } from '@rezti/dsh-rez-sso'
18
19
  import type { McpHost } from './mcp-host.ts'
19
20
  import { makeRoutes } from './routes.ts'
20
21
  import { defaultConfig, mergeConfig, saveConfig } from './store.ts'
@@ -45,6 +46,24 @@ export const Config: z<RezConfig> = z.object({
45
46
  announceToAgent: z.boolean().default(true),
46
47
  role: z.union(['engineer', 'sales', 'operations', 'all'] as const).default('all'),
47
48
  servers: z.dict(serverSchema).default({}),
49
+ connections: z.object({
50
+ odoo: z.object({
51
+ enabled: z.boolean().default(true),
52
+ url: z.string().default(REZ_DEFAULT_CONNECTIONS.odoo.url),
53
+ }).default(REZ_DEFAULT_CONNECTIONS.odoo),
54
+ nextcloud: z.object({
55
+ enabled: z.boolean().default(true),
56
+ url: z.string().default(REZ_DEFAULT_CONNECTIONS.nextcloud.url),
57
+ username: z.string().default(REZ_DEFAULT_CONNECTIONS.nextcloud.username),
58
+ }).default(REZ_DEFAULT_CONNECTIONS.nextcloud),
59
+ wechat: z.object({
60
+ enabled: z.boolean().default(true),
61
+ }).default(REZ_DEFAULT_CONNECTIONS.wechat),
62
+ homeassistant: z.object({
63
+ enabled: z.boolean().default(true),
64
+ url: z.string().default(REZ_DEFAULT_CONNECTIONS.homeassistant.url),
65
+ }).default(REZ_DEFAULT_CONNECTIONS.homeassistant),
66
+ }).default(REZ_DEFAULT_CONNECTIONS),
48
67
  billing: z.object({
49
68
  inputCostPer1k: z.number().default(0.001),
50
69
  outputCostPer1k: z.number().default(0.002),
@@ -60,7 +79,7 @@ export const REZ_GUIDANCE = [
60
79
  '本机已安装 ReZ-TI 套件(一次安装):工作身份由 dsh-rez-sso 提供,MCP 由官方 dsh-mcp-client 接入 Odoo / Nextcloud / 企业微信 / Home Assistant。',
61
80
  '工具名 mcp__odoo__*、mcp__nextcloud__*、mcp__wechat__*、mcp__homeassistant__*。',
62
81
  '密钥:REZ_ODOO_TOKEN、REZ_NEXTCLOUD_PASSWORD、REZ_WECHAT_WEBHOOK、REZ_HA_TOKEN。',
63
- '个人微信(QClaw/ClawBot):打开 Rez 面板「微信」页扫码,然后直接在微信里说话,不必再跑命令、也不要调用微信 MCP 工具。',
82
+ '个人微信(QClaw/ClawBot):打开 设置 插件 → ReZ-TI → 微信 扫码,然后直接在微信里说话,不必再跑命令、也不要调用微信 MCP 工具。',
64
83
  '意图用 rez_set_intent(erp/files/chat/home/auto)。审计用 rez_audit。',
65
84
  '涉及资金、对外发送、设备控制、生产写入时先征得用户批准。',
66
85
  ].join('')
@@ -73,16 +92,19 @@ function delegatedHost(): McpHost {
73
92
  const status = (): RezServerStatus[] => COMPOSED_SERVERS.map(server => ({
74
93
  server,
75
94
  enabled: true,
76
- state: 'disconnected',
95
+ state: 'disconnected' as const,
77
96
  toolCount: 0,
78
97
  registeredTools: 0,
79
- lastError: 'owned by @deepseek-ai/dsh-mcp-client',
80
- }))
81
- const test = async (): Promise<RezTestResult[]> => COMPOSED_SERVERS.map(server => ({
82
- server,
83
- ok: false,
84
- error: 'MCP is composed by dsh-mcp-client; set REZ_* credentials and check dsh logs',
98
+ lastError: secretConfigured(server)
99
+ ? 'owned by @deepseek-ai/dsh-mcp-client'
100
+ : 'missing ' + REZ_CREDENTIAL_REFS[server],
85
101
  }))
102
+ const test = async (): Promise<RezTestResult[]> => COMPOSED_SERVERS.map(server => {
103
+ const ok = secretConfigured(server)
104
+ return ok
105
+ ? { server, ok: true, serverInfo: REZ_CREDENTIAL_REFS[server] + ' configured' }
106
+ : { server, ok: false, error: 'missing ' + REZ_CREDENTIAL_REFS[server] }
107
+ })
86
108
  return {
87
109
  status,
88
110
  test,
@@ -119,6 +141,10 @@ export function apply(ctx: Context, config?: RezConfig): void {
119
141
  },
120
142
  host,
121
143
  tokens,
144
+ onCredentialWritten: (ref) => {
145
+ const emit = ctx.emit as unknown as (event: string, value: string) => void
146
+ emit('credentials/updated', ref)
147
+ },
122
148
  })
123
149
 
124
150
  let disposeRoutes: (() => void) | undefined
package/src/protocol.ts CHANGED
@@ -35,12 +35,29 @@ export interface RezMcpServerSpec {
35
35
  toolCallTimeoutMs?: number
36
36
  }
37
37
 
38
+ /** Company MCP endpoints persisted under ~/.dsh/dsh-rez-suite.json. Secrets are not stored here. */
39
+ export interface RezConnections {
40
+ odoo: { enabled: boolean; url: string }
41
+ nextcloud: { enabled: boolean; url: string; username: string }
42
+ wechat: { enabled: boolean }
43
+ homeassistant: { enabled: boolean; url: string }
44
+ }
45
+
46
+ export interface RezPublicConnection {
47
+ enabled: boolean
48
+ url?: string
49
+ username?: string
50
+ secretConfigured: boolean
51
+ secretRef: string
52
+ }
53
+
38
54
  /** Full plugin configuration persisted under ~/.dsh/dsh-rez-suite.json. */
39
55
  export interface RezConfig {
40
56
  enabled: boolean
41
57
  announceToAgent: boolean
42
58
  role: RezRoleId
43
59
  servers: Record<string, RezMcpServerSpec>
60
+ connections: RezConnections
44
61
  billing: {
45
62
  inputCostPer1k: number
46
63
  outputCostPer1k: number
@@ -56,6 +73,12 @@ export interface RezPublicConfig {
56
73
  enabled: boolean
57
74
  role: RezRoleId
58
75
  servers: Record<string, RezPublicServer>
76
+ connections: {
77
+ odoo: RezPublicConnection
78
+ nextcloud: RezPublicConnection
79
+ wechat: RezPublicConnection
80
+ homeassistant: RezPublicConnection
81
+ }
59
82
  billing: { inputCostPer1k: number; outputCostPer1k: number; monthlyBudget: number }
60
83
  }
61
84
 
package/src/routes.ts CHANGED
@@ -6,6 +6,13 @@
6
6
 
7
7
  import type { IncomingMessage, ServerResponse } from 'node:http'
8
8
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
9
+ import {
10
+ REZ_CREDENTIAL_REFS,
11
+ REZ_SYSTEMS,
12
+ mergeConnections,
13
+ writeManagedCredential,
14
+ type RezSystem,
15
+ } from '@rezti/dsh-rez-sso'
9
16
  import type { RezConfig, RezMcpServerSpec, RezRoleId } from './protocol.ts'
10
17
  import { REZ_API } from './protocol.ts'
11
18
  import type { McpHost } from './mcp-host.ts'
@@ -74,11 +81,25 @@ function mergeMaskedRecord(base: Record<string, string> | undefined, incoming: u
74
81
  }
75
82
 
76
83
  /** Apply a browser-safe public patch without overwriting masked secrets. */
77
- function applyPublicPatch(current: RezConfig, patch: Record<string, unknown>): RezConfig {
84
+ function applyPublicPatch(current: RezConfig, patch: Record<string, unknown>): { next: RezConfig; secrets: Array<{ system: RezSystem; value: string }> } {
78
85
  const next: RezConfig = JSON.parse(JSON.stringify(current)) as RezConfig
86
+ const secrets: Array<{ system: RezSystem; value: string }> = []
79
87
  if (typeof patch.enabled === 'boolean') next.enabled = patch.enabled
80
88
  if (patch.role === 'engineer' || patch.role === 'sales' || patch.role === 'operations' || patch.role === 'all') next.role = patch.role as RezRoleId
81
89
 
90
+ if (typeof patch.connections === 'object' && patch.connections !== null) {
91
+ const incoming = patch.connections as Record<string, unknown>
92
+ next.connections = mergeConnections({ ...next.connections, ...incoming })
93
+ for (const system of Object.keys(REZ_CREDENTIAL_REFS) as RezSystem[]) {
94
+ const row = incoming[system]
95
+ if (typeof row !== 'object' || row === null) continue
96
+ const secret = (row as { secret?: unknown }).secret
97
+ if (typeof secret === 'string' && secret.length > 0 && secret !== SECRET_MASK) {
98
+ secrets.push({ system, value: secret })
99
+ }
100
+ }
101
+ }
102
+
82
103
  if (typeof patch.servers === 'object' && patch.servers !== null) {
83
104
  for (const [id, raw] of Object.entries(patch.servers as Record<string, unknown>)) {
84
105
  if (typeof raw !== 'object' || raw === null) continue
@@ -104,7 +125,7 @@ function applyPublicPatch(current: RezConfig, patch: Record<string, unknown>): R
104
125
  if (typeof value.outputCostPer1k === 'number') next.billing.outputCostPer1k = value.outputCostPer1k
105
126
  if (typeof value.monthlyBudget === 'number') next.billing.monthlyBudget = value.monthlyBudget
106
127
  }
107
- return next
128
+ return { next, secrets }
108
129
  }
109
130
 
110
131
  export interface RezRoutesDeps {
@@ -113,11 +134,12 @@ export interface RezRoutesDeps {
113
134
  updateConfig: (next: RezConfig) => Promise<RezConfig>
114
135
  host: McpHost
115
136
  tokens: TokenManager
137
+ onCredentialWritten?: (ref: string) => void
116
138
  }
117
139
 
118
140
  /** Build every /api/dsh-rez-suite route (exact paths). */
119
141
  export function makeRoutes(deps: RezRoutesDeps): WebRoute[] {
120
- const { getConfig, updateConfig, host, tokens } = deps
142
+ const { getConfig, updateConfig, host, tokens, onCredentialWritten } = deps
121
143
 
122
144
  const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
123
145
  if (!isLoopbackRequest(req)) {
@@ -146,8 +168,14 @@ export function makeRoutes(deps: RezRoutesDeps): WebRoute[] {
146
168
  writeJson(res, 400, { error: 'invalid JSON body' })
147
169
  return
148
170
  }
149
- const next = applyPublicPatch(getConfig(), body)
171
+ const { next, secrets } = applyPublicPatch(getConfig(), body)
150
172
  const saved = await updateConfig(next)
173
+ for (const item of secrets) {
174
+ writeManagedCredential(REZ_CREDENTIAL_REFS[item.system], item.value)
175
+ }
176
+ for (const system of REZ_SYSTEMS) {
177
+ onCredentialWritten?.(REZ_CREDENTIAL_REFS[system])
178
+ }
151
179
  writeJson(res, 200, { config: publicConfig(saved) })
152
180
  },
153
181
  },
package/src/store.ts CHANGED
@@ -8,6 +8,12 @@
8
8
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import { dirname, join, resolve } from 'node:path'
11
+ import {
12
+ REZ_CREDENTIAL_REFS,
13
+ REZ_DEFAULT_CONNECTIONS,
14
+ mergeConnections,
15
+ secretConfigured,
16
+ } from '@rezti/dsh-rez-sso'
11
17
  import type { RezConfig, RezMcpServerSpec, RezPublicConfig } from './protocol.ts'
12
18
 
13
19
  /** File format version. */
@@ -36,13 +42,14 @@ export function expandHome(path: string): string {
36
42
  return resolve(path)
37
43
  }
38
44
 
39
- /** Defaults: panel-only. Company MCP rows live in cordis.patch.yml. */
45
+ /** Defaults: company MCP endpoints. Secrets live in credentials.yaml / env. */
40
46
  export function defaultConfig(): RezConfig {
41
47
  return {
42
48
  enabled: true,
43
49
  announceToAgent: true,
44
50
  role: 'all',
45
51
  servers: {},
52
+ connections: JSON.parse(JSON.stringify(REZ_DEFAULT_CONNECTIONS)) as RezConfig['connections'],
46
53
  billing: {
47
54
  inputCostPer1k: 0.001,
48
55
  outputCostPer1k: 0.002,
@@ -74,10 +81,37 @@ export function publicConfig(config: RezConfig): RezPublicConfig {
74
81
  for (const [id, server] of Object.entries(config.servers)) {
75
82
  servers[id] = maskServer(server)
76
83
  }
84
+ const connections = mergeConnections(config.connections)
77
85
  return {
78
86
  enabled: config.enabled,
79
87
  role: config.role,
80
88
  servers,
89
+ connections: {
90
+ odoo: {
91
+ enabled: connections.odoo.enabled,
92
+ url: connections.odoo.url,
93
+ secretConfigured: secretConfigured('odoo'),
94
+ secretRef: REZ_CREDENTIAL_REFS.odoo,
95
+ },
96
+ nextcloud: {
97
+ enabled: connections.nextcloud.enabled,
98
+ url: connections.nextcloud.url,
99
+ username: connections.nextcloud.username,
100
+ secretConfigured: secretConfigured('nextcloud'),
101
+ secretRef: REZ_CREDENTIAL_REFS.nextcloud,
102
+ },
103
+ wechat: {
104
+ enabled: connections.wechat.enabled,
105
+ secretConfigured: secretConfigured('wechat'),
106
+ secretRef: REZ_CREDENTIAL_REFS.wechat,
107
+ },
108
+ homeassistant: {
109
+ enabled: connections.homeassistant.enabled,
110
+ url: connections.homeassistant.url,
111
+ secretConfigured: secretConfigured('homeassistant'),
112
+ secretRef: REZ_CREDENTIAL_REFS.homeassistant,
113
+ },
114
+ },
81
115
  billing: { ...config.billing },
82
116
  }
83
117
  }
@@ -96,6 +130,8 @@ export function mergeConfig(base: RezConfig, override: unknown): RezConfig {
96
130
  if (typeof value.announceToAgent === 'boolean') result.announceToAgent = value.announceToAgent
97
131
  if (value.role === 'engineer' || value.role === 'sales' || value.role === 'operations' || value.role === 'all') result.role = value.role
98
132
 
133
+ result.connections = mergeConnections(value.connections ?? result.connections)
134
+
99
135
  if (typeof value.servers === 'object' && value.servers !== null) {
100
136
  for (const [id, raw] of Object.entries(value.servers as Record<string, unknown>)) {
101
137
  if (typeof raw !== 'object' || raw === null) continue