dsh-ssh-tunnel 1.0.1 → 1.0.2

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,125 +1,151 @@
1
- /**
2
- * Pure SSH session policy: timeouts, status invariant, reuse vs tombstone.
3
- * No ssh2, no I/O.
4
- */
5
-
6
- export const SSH_KEEPALIVE_INTERVAL_MS = 30_000
7
- export const SSH_KEEPALIVE_COUNT_MAX = 3
8
- export const SSH_READY_TIMEOUT_MS = 20_000
9
- export const SSH_RECONNECT_MAX_ATTEMPTS = 3
10
- export const SSH_RECONNECT_DELAYS_MS = [5_000, 10_000, 10_000]
11
- export const SSH_RECONNECT_ATTEMPT_TIMEOUT_MS = 20_000
12
- export const SSH_TOOL_TIMEOUT_MS = 300_000
13
- export const SSH_TIMEOUT_MIN_MS = 1_000
14
- export const SSH_TIMEOUT_MAX_MS = 300_000
15
- export const SSH_EXEC_DEFAULT_TIMEOUT_MS = 30_000
16
- export const SSH_SFTP_META_TIMEOUT_MS = 30_000
17
- export const SSH_SFTP_TRANSFER_TIMEOUT_MS = 120_000
18
- export const SSH_SHELL_OPEN_TIMEOUT_MS = 10_000
19
-
20
- export const SSH_STATUS_CONNECTED = 'connected'
21
- export const SSH_STATUS_RECONNECTING = 'reconnecting'
22
- export const SSH_STATUS_DISCONNECTED = 'disconnected'
23
-
24
- export function clampTimeoutMs(value, fallback) {
25
- const n = Number(value)
26
- const base = Number.isFinite(n) && n > 0 ? n : fallback
27
- return Math.min(SSH_TIMEOUT_MAX_MS, Math.max(SSH_TIMEOUT_MIN_MS, Math.floor(base)))
28
- }
29
-
30
- export function defaultTimeoutMsForAction(action) {
31
- const a = String(action || '')
32
- if (a === 'exec') return SSH_EXEC_DEFAULT_TIMEOUT_MS
33
- if (a === 'sftp_read_text' || a === 'sftp_write_text' || a === 'sftp_upload' || a === 'sftp_download') {
34
- return SSH_SFTP_TRANSFER_TIMEOUT_MS
35
- }
36
- if (a.startsWith('sftp_')) return SSH_SFTP_META_TIMEOUT_MS
37
- if (a === 'read_session' || a === 'send_input' || a === 'resize_session') return SSH_SHELL_OPEN_TIMEOUT_MS
38
- return SSH_EXEC_DEFAULT_TIMEOUT_MS
39
- }
40
-
41
- export function isLiveStatus(status, running) {
42
- return status === SSH_STATUS_CONNECTED && running === true
43
- }
44
-
45
- export function assertStatusInvariant(rec) {
46
- const running = rec.status === SSH_STATUS_CONNECTED
47
- return { ...rec, running }
48
- }
49
-
50
- export function findReusableLive(sessions, { projectPathKey, hostId, needsSftp }) {
51
- return sessions
52
- .filter((s) => {
53
- if (s.projectPathKey !== projectPathKey || s.hostId !== hostId) return false
54
- if (!isLiveStatus(s.status, s.running)) return false
55
- if (needsSftp && !s.sftpEnabled) return false
56
- return true
57
- })
58
- .sort((a, b) => a.createdAt - b.createdAt || String(a.id).localeCompare(String(b.id)))[0]
59
- }
60
-
61
- export function pickOldestTombstone(sessions, { projectPathKey, hostId, needsSftp }) {
62
- return sessions
63
- .filter((s) => {
64
- if (s.projectPathKey !== projectPathKey || s.hostId !== hostId) return false
65
- if (s.status !== SSH_STATUS_DISCONNECTED || s.running) return false
66
- if (needsSftp && !s.sftpEnabled) return false
67
- return true
68
- })
69
- .sort((a, b) => a.createdAt - b.createdAt || String(a.id).localeCompare(String(b.id)))[0]
70
- }
71
-
72
- export function disconnectedSessionError() {
73
- return 'SSH session is disconnected. Reconnect in the SSH Tunnel tab, or omit session_id to reuse_or_create.'
74
- }
75
-
76
- export function kbiReconnectError() {
77
- return 'keyboard-interactive auth is not supported; edit the host to password or private key.'
78
- }
79
-
80
- export function kbiUnsupportedError() {
81
- return 'keyboard-interactive auth is not supported; edit the host to password or private key.'
82
- }
83
-
84
- export function withTimeout(work, ms, { signal, onTimeout } = {}) {
85
- const timeoutMs = clampTimeoutMs(ms, SSH_EXEC_DEFAULT_TIMEOUT_MS)
86
- const run = typeof work === 'function' ? work() : work
87
- return new Promise((resolve, reject) => {
88
- let settled = false
89
- const finish = (kind, value) => {
90
- if (settled) return
91
- settled = true
92
- clearTimeout(timer)
93
- if (signal) signal.removeEventListener('abort', onAbort)
94
- if (kind === 'ok') resolve(value)
95
- else reject(value)
96
- }
97
- const onAbort = () => {
98
- try {
99
- onTimeout?.()
100
- } catch {}
101
- const err = new Error('Cancelled')
102
- err.aborted = true
103
- finish('err', err)
104
- }
105
- const timer = setTimeout(() => {
106
- try {
107
- onTimeout?.()
108
- } catch {}
109
- const err = new Error('timeout')
110
- err.timedOut = true
111
- finish('err', err)
112
- }, timeoutMs)
113
- if (signal) {
114
- if (signal.aborted) {
115
- onAbort()
116
- return
117
- }
118
- signal.addEventListener('abort', onAbort, { once: true })
119
- }
120
- Promise.resolve(run).then(
121
- (v) => finish('ok', v),
122
- (e) => finish('err', e),
123
- )
124
- })
125
- }
1
+ /**
2
+ * Pure SSH session policy: timeouts, status invariant, reuse vs tombstone.
3
+ * No ssh2, no I/O.
4
+ */
5
+
6
+ export const SSH_KEEPALIVE_INTERVAL_MS = 30_000
7
+ export const SSH_KEEPALIVE_COUNT_MAX = 3
8
+ export const SSH_READY_TIMEOUT_MS = 20_000
9
+ export const SSH_RECONNECT_MAX_ATTEMPTS = 3
10
+ export const SSH_RECONNECT_DELAYS_MS = [5_000, 10_000, 10_000]
11
+ export const SSH_RECONNECT_ATTEMPT_TIMEOUT_MS = 20_000
12
+ export const SSH_TOOL_TIMEOUT_MS = 300_000
13
+ export const SSH_TIMEOUT_MIN_MS = 1_000
14
+ export const SSH_TIMEOUT_MAX_MS = 300_000
15
+ export const SSH_EXEC_DEFAULT_TIMEOUT_MS = 30_000
16
+ export const SSH_SFTP_META_TIMEOUT_MS = 30_000
17
+ export const SSH_SFTP_TRANSFER_TIMEOUT_MS = 120_000
18
+ export const SSH_SHELL_OPEN_TIMEOUT_MS = 10_000
19
+
20
+ export const SSH_STATUS_CONNECTED = 'connected'
21
+ export const SSH_STATUS_RECONNECTING = 'reconnecting'
22
+ export const SSH_STATUS_DISCONNECTED = 'disconnected'
23
+
24
+ export function clampTimeoutMs(value, fallback) {
25
+ const n = Number(value)
26
+ const base = Number.isFinite(n) && n > 0 ? n : fallback
27
+ return Math.min(SSH_TIMEOUT_MAX_MS, Math.max(SSH_TIMEOUT_MIN_MS, Math.floor(base)))
28
+ }
29
+
30
+ export function defaultTimeoutMsForAction(action) {
31
+ const a = String(action || '')
32
+ if (a === 'exec') return SSH_EXEC_DEFAULT_TIMEOUT_MS
33
+ if (a === 'sftp_read_text' || a === 'sftp_write_text' || a === 'sftp_upload' || a === 'sftp_download') {
34
+ return SSH_SFTP_TRANSFER_TIMEOUT_MS
35
+ }
36
+ if (a.startsWith('sftp_')) return SSH_SFTP_META_TIMEOUT_MS
37
+ if (a === 'read_session' || a === 'send_input' || a === 'resize_session') return SSH_SHELL_OPEN_TIMEOUT_MS
38
+ return SSH_EXEC_DEFAULT_TIMEOUT_MS
39
+ }
40
+
41
+ export function isLiveStatus(status, running) {
42
+ return status === SSH_STATUS_CONNECTED && running === true
43
+ }
44
+
45
+ export function findReusableLive(sessions, { projectPathKey, hostId, needsSftp }) {
46
+ return sessions
47
+ .filter((s) => {
48
+ if (s.projectPathKey !== projectPathKey || s.hostId !== hostId) return false
49
+ if (!isLiveStatus(s.status, s.running)) return false
50
+ if (needsSftp && !s.sftpEnabled) return false
51
+ return true
52
+ })
53
+ .sort((a, b) => a.createdAt - b.createdAt || String(a.id).localeCompare(String(b.id)))[0]
54
+ }
55
+
56
+ export function pickOldestTombstone(sessions, { projectPathKey, hostId, needsSftp }) {
57
+ return sessions
58
+ .filter((s) => {
59
+ if (s.projectPathKey !== projectPathKey || s.hostId !== hostId) return false
60
+ if (s.status !== SSH_STATUS_DISCONNECTED || s.running) return false
61
+ if (needsSftp && !s.sftpEnabled) return false
62
+ return true
63
+ })
64
+ .sort((a, b) => a.createdAt - b.createdAt || String(a.id).localeCompare(String(b.id)))[0]
65
+ }
66
+
67
+ export function disconnectedSessionError() {
68
+ return 'SSH session is disconnected. Reconnect in the SSH Tunnel tab, or omit session_id to reuse_or_create.'
69
+ }
70
+
71
+ export function kbiReconnectError() {
72
+ return 'keyboard-interactive auth is not supported; edit the host to password or private key.'
73
+ }
74
+
75
+ export function kbiUnsupportedError() {
76
+ return 'keyboard-interactive auth is not supported; edit the host to password or private key.'
77
+ }
78
+
79
+ export function withTimeout(work, ms, { signal, onTimeout } = {}) {
80
+ const timeoutMs = clampTimeoutMs(ms, SSH_EXEC_DEFAULT_TIMEOUT_MS)
81
+ const run = typeof work === 'function' ? work() : work
82
+ return new Promise((resolve, reject) => {
83
+ let settled = false
84
+ const finish = (kind, value) => {
85
+ if (settled) return
86
+ settled = true
87
+ clearTimeout(timer)
88
+ if (signal) signal.removeEventListener('abort', onAbort)
89
+ if (kind === 'ok') resolve(value)
90
+ else reject(value)
91
+ }
92
+ const onAbort = () => {
93
+ try {
94
+ onTimeout?.()
95
+ } catch {}
96
+ const err = new Error('Cancelled')
97
+ err.aborted = true
98
+ finish('err', err)
99
+ }
100
+ const timer = setTimeout(() => {
101
+ try {
102
+ onTimeout?.()
103
+ } catch {}
104
+ const err = new Error('timeout')
105
+ err.timedOut = true
106
+ finish('err', err)
107
+ }, timeoutMs)
108
+ if (signal) {
109
+ if (signal.aborted) {
110
+ onAbort()
111
+ return
112
+ }
113
+ signal.addEventListener('abort', onAbort, { once: true })
114
+ }
115
+ Promise.resolve(run).then(
116
+ (v) => finish('ok', v),
117
+ (e) => finish('err', e),
118
+ )
119
+ })
120
+ }
121
+
122
+ export const MAX_BYTES_MIN = 1_024
123
+ export const MAX_BYTES_MAX = 1_048_576
124
+ export const MAX_BYTES_EXEC_DEFAULT = 262_144
125
+ export const MAX_BYTES_SFTP_READ_DEFAULT = 524_288
126
+
127
+ export function clampMaxBytes(value, fallback) {
128
+ const n = Number(value)
129
+ const base = Number.isFinite(n) && n > 0 ? n : fallback
130
+ return Math.min(MAX_BYTES_MAX, Math.max(MAX_BYTES_MIN, Math.floor(base)))
131
+ }
132
+
133
+ /** HTTP status for an API error code; anything unmapped stays a server fault. */
134
+ export const API_ERROR_STATUS = {
135
+ bad_request: 400,
136
+ forbidden: 403,
137
+ not_found: 404,
138
+ conflict: 409,
139
+ gone: 410,
140
+ payload_too_large: 413,
141
+ }
142
+
143
+ export function sshError(code, message) {
144
+ const err = new Error(message)
145
+ err.code = code
146
+ return err
147
+ }
148
+
149
+ export function hostKeyPromptMessage() {
150
+ return 'Connection needs your confirmation: complete the trust/MFA prompt in the SSH Tunnel tab, then retry.'
151
+ }
@@ -1,10 +1,10 @@
1
- /** Incremental shell output slice for polling clients. */
2
- export function chunkFromBuffer(fullText, since) {
3
- const full = fullText == null ? '' : String(fullText)
4
- const start = Math.max(0, Math.min(Number(since) || 0, full.length))
5
- return {
6
- chunk: full.slice(start),
7
- length: full.length,
8
- since: start,
9
- }
10
- }
1
+ /** Incremental shell output slice for polling clients. */
2
+ export function chunkFromBuffer(fullText, since) {
3
+ const full = fullText == null ? '' : String(fullText)
4
+ const start = Math.max(0, Math.min(Number(since) || 0, full.length))
5
+ return {
6
+ chunk: full.slice(start),
7
+ length: full.length,
8
+ since: start,
9
+ }
10
+ }
@@ -1,50 +1,50 @@
1
- /**
2
- * Same-origin static assets for the browser client.
3
- *
4
- * `dsh-ssh-tunnel/lib/client.js` is a raw browser bundle executed by the DSH
5
- * client module system; its `require()` only answers platform seeds and
6
- * registered plugin rows, so `@xterm/xterm` is NOT resolvable there. Instead
7
- * of the removed jsDelivr fallback we serve the UMD builds from our own web
8
- * route (`/dsh-ssh-tunnel/vendor/*`) and load them with plain <script>/<link>
9
- * tags. This module is the host-side allowlist + resolver (Node only).
10
- */
11
-
12
- import { createRequire } from 'node:module'
13
-
14
- const requireHost = createRequire(import.meta.url)
15
-
16
- /** name -> { spec (resolved from this plugin's deps), content-type } */
17
- export const VENDOR_ASSETS = {
18
- 'xterm.js': {
19
- spec: '@xterm/xterm/lib/xterm.js',
20
- type: 'text/javascript; charset=utf-8',
21
- },
22
- 'xterm.css': {
23
- spec: '@xterm/xterm/css/xterm.css',
24
- type: 'text/css; charset=utf-8',
25
- },
26
- 'addon-fit.js': {
27
- spec: '@xterm/addon-fit/lib/addon-fit.js',
28
- type: 'text/javascript; charset=utf-8',
29
- },
30
- }
31
-
32
- /**
33
- * Resolve an allowlisted vendor asset to an absolute file path.
34
- * Anything not in the allowlist throws — callers must treat that as 404 and
35
- * must not interpolate user input into the file lookup.
36
- * @param {string} name exact basename from the allowlist
37
- * @returns {{ path: string, type: string }}
38
- */
39
- export function resolveVendorAsset(name) {
40
- const entry = VENDOR_ASSETS[name]
41
- if (!entry) {
42
- throw new Error(`dsh-ssh-tunnel: vendor asset not allowed: ${String(name)}`)
43
- }
44
- return { path: requireHost.resolve(entry.spec), type: entry.type }
45
- }
46
-
47
- /** @param {string} name checked against the allowlist only */
48
- export function isVendorAssetAllowed(name) {
49
- return Object.prototype.hasOwnProperty.call(VENDOR_ASSETS, name)
1
+ /**
2
+ * Same-origin static assets for the browser client.
3
+ *
4
+ * `dsh-ssh-tunnel/lib/client.js` is a raw browser bundle executed by the DSH
5
+ * client module system; its `require()` only answers platform seeds and
6
+ * registered plugin rows, so `@xterm/xterm` is NOT resolvable there. Instead
7
+ * of the removed jsDelivr fallback we serve the UMD builds from our own web
8
+ * route (`/dsh-ssh-tunnel/vendor/*`) and load them with plain <script>/<link>
9
+ * tags. This module is the host-side allowlist + resolver (Node only).
10
+ */
11
+
12
+ import { createRequire } from 'node:module'
13
+
14
+ const requireHost = createRequire(import.meta.url)
15
+
16
+ /** name -> { spec (resolved from this plugin's deps), content-type } */
17
+ export const VENDOR_ASSETS = {
18
+ 'xterm.js': {
19
+ spec: '@xterm/xterm/lib/xterm.js',
20
+ type: 'text/javascript; charset=utf-8',
21
+ },
22
+ 'xterm.css': {
23
+ spec: '@xterm/xterm/css/xterm.css',
24
+ type: 'text/css; charset=utf-8',
25
+ },
26
+ 'addon-fit.js': {
27
+ spec: '@xterm/addon-fit/lib/addon-fit.js',
28
+ type: 'text/javascript; charset=utf-8',
29
+ },
30
+ }
31
+
32
+ /**
33
+ * Resolve an allowlisted vendor asset to an absolute file path.
34
+ * Anything not in the allowlist throws — callers must treat that as 404 and
35
+ * must not interpolate user input into the file lookup.
36
+ * @param {string} name exact basename from the allowlist
37
+ * @returns {{ path: string, type: string }}
38
+ */
39
+ export function resolveVendorAsset(name) {
40
+ const entry = VENDOR_ASSETS[name]
41
+ if (!entry) {
42
+ throw new Error(`dsh-ssh-tunnel: vendor asset not allowed: ${String(name)}`)
43
+ }
44
+ return { path: requireHost.resolve(entry.spec), type: entry.type }
45
+ }
46
+
47
+ /** @param {string} name checked against the allowlist only */
48
+ export function isVendorAssetAllowed(name) {
49
+ return Object.prototype.hasOwnProperty.call(VENDOR_ASSETS, name)
50
50
  }
package/package.json CHANGED
@@ -1,88 +1,91 @@
1
- {
2
- "name": "dsh-ssh-tunnel",
3
- "version": "1.0.1",
4
- "description": "Multi-host SSH tunnel and SSHManager tool for DeepSeek Harness (project-scoped grants)",
5
- "type": "module",
6
- "license": "MIT",
7
- "author": "thirsty5034",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/OMSociety/dsh-ssh-tunnel.git"
11
- },
12
- "homepage": "https://github.com/OMSociety/dsh-ssh-tunnel#readme",
13
- "bugs": {
14
- "url": "https://github.com/OMSociety/dsh-ssh-tunnel/issues"
15
- },
16
- "contributors": [
17
- {
18
- "name": "OMSociety",
19
- "url": "https://github.com/OMSociety"
20
- }
21
- ],
22
- "publishConfig": {
23
- "access": "public"
24
- },
25
- "keywords": [
26
- "dsh",
27
- "dsh-plugin",
28
- "deepseek-harness",
29
- "ssh",
30
- "sftp",
31
- "cordis",
32
- "better-sidebar",
33
- "dsh-better-sidebar"
34
- ],
35
- "engines": {
36
- "node": ">=20",
37
- "dsh": ">=0.1.7-rc.2 <0.3.0-0"
38
- },
39
- "main": "lib/index.js",
40
- "icon": "./icon.svg",
41
- "exports": {
42
- ".": "./lib/index.js",
43
- "./locale/*.json": "./locale/*.json",
44
- "./client": "./lib/client.js",
45
- "./package.json": "./package.json"
46
- },
47
- "scripts": {
48
- "test": "node scripts/smoke-test.mjs",
49
- "check": "node --check lib/index.js && node scripts/smoke-test.mjs"
50
- },
51
- "dsh": {
52
- "manifestVersion": 1,
53
- "bundle": {
54
- "patch": "./cordis.patch.yml"
55
- },
56
- "client": {
57
- "inject": [
58
- "@deepseek-ai/dsh-client-locale"
59
- ],
60
- "platform": "web"
61
- }
62
- },
63
- "dependencies": {
64
- "@xterm/addon-fit": "0.11.0",
65
- "@xterm/xterm": "5.5.0",
66
- "ssh2": "^1.17.0"
67
- },
68
- "peerDependencies": {
69
- "@deepseek-ai/cordis": "^4.0.4",
70
- "@deepseek-ai/dsh-client-locale": ">=0.1.7-rc.2 <0.3.0-0",
71
- "dsh-better-sidebar": ">=0.12.0"
72
- },
73
- "files": [
74
- "CHANGELOG.md",
75
- "LICENSE",
76
- "README.md",
77
- "README_en.md",
78
- "cordis.patch.yml",
79
- "lib",
80
- "scripts/install.sh",
81
- "scripts/install.ps1",
82
- "scripts/portal-probe.mjs",
83
- "scripts/smoke-test.mjs",
84
- "scripts/sync-to-dsh.sh",
85
- "icon.svg",
86
- "locale/*.json"
87
- ]
88
- }
1
+ {
2
+ "name": "dsh-ssh-tunnel",
3
+ "version": "1.0.2",
4
+ "description": "Multi-host SSH tunnel and SSHManager tool for DeepSeek Harness (project-scoped grants)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "thirsty5034",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/OMSociety/dsh-ssh-tunnel.git"
11
+ },
12
+ "homepage": "https://github.com/OMSociety/dsh-ssh-tunnel#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/OMSociety/dsh-ssh-tunnel/issues"
15
+ },
16
+ "contributors": [
17
+ {
18
+ "name": "OMSociety",
19
+ "url": "https://github.com/OMSociety"
20
+ }
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "keywords": [
26
+ "dsh",
27
+ "dsh-plugin",
28
+ "deepseek-harness",
29
+ "ssh",
30
+ "sftp",
31
+ "cordis",
32
+ "better-sidebar",
33
+ "dsh-better-sidebar"
34
+ ],
35
+ "engines": {
36
+ "node": ">=20",
37
+ "dsh": ">=0.1.7-rc.2 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0"
38
+ },
39
+ "main": "lib/index.js",
40
+ "icon": "./icon.svg",
41
+ "exports": {
42
+ ".": "./lib/index.js",
43
+ "./locale/*.json": "./locale/*.json",
44
+ "./client": "./lib/client.js",
45
+ "./cordis.patch.yml": "./cordis.patch.yml",
46
+ "./package.json": "./package.json"
47
+ },
48
+ "scripts": {
49
+ "test": "node scripts/smoke-test.mjs",
50
+ "check": "node --check lib/index.js && node scripts/smoke-test.mjs"
51
+ },
52
+ "dsh": {
53
+ "manifestVersion": 1,
54
+ "bundle": {
55
+ "patch": "./cordis.patch.yml"
56
+ },
57
+ "client": {
58
+ "inject": [
59
+ "dsh-better-sidebar",
60
+ "@deepseek-ai/dsh-client-locale"
61
+ ],
62
+ "platform": "web"
63
+ }
64
+ },
65
+ "dependencies": {
66
+ "@xterm/addon-fit": "0.11.0",
67
+ "@xterm/xterm": "5.5.0",
68
+ "ssh2": "^1.17.0"
69
+ },
70
+ "peerDependencies": {
71
+ "@deepseek-ai/cordis": "^4.0.4",
72
+ "@deepseek-ai/dsh-client-locale": ">=0.1.7-rc.2 <0.2.0-0 || >=0.2.0-rc.1 <0.3.0-0",
73
+ "dsh-better-sidebar": ">=0.12.0"
74
+ },
75
+ "files": [
76
+ "CHANGELOG.md",
77
+ "LICENSE",
78
+ "README.md",
79
+ "README_en.md",
80
+ "cordis.patch.yml",
81
+ "lib",
82
+ "scripts/install.sh",
83
+ "scripts/install.ps1",
84
+ "scripts/lib/*.cjs",
85
+ "scripts/portal-probe.mjs",
86
+ "scripts/smoke-test.mjs",
87
+ "scripts/sync-to-dsh.sh",
88
+ "icon.svg",
89
+ "locale/*.json"
90
+ ]
91
+ }