@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.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.
Files changed (144) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/CONTRACT-INTEROP.md +77 -0
  12. package/docs/CRASH-SAFETY.md +69 -0
  13. package/docs/DECISION-RELAY.md +46 -0
  14. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  15. package/docs/FORK-SEED.md +64 -0
  16. package/docs/MAIN-CITY-GATE.md +58 -0
  17. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  18. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  19. package/docs/PM-DECISION-POLICY.md +61 -0
  20. package/docs/WORKFLOW-UI.md +86 -0
  21. package/icon.svg +8 -0
  22. package/locale/en.json +6 -0
  23. package/locale/zh.json +6 -0
  24. package/package.json +71 -4
  25. package/sdk/README.md +65 -0
  26. package/sdk/examples/status-badge/README.md +16 -0
  27. package/sdk/examples/status-badge/client.js +60 -0
  28. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  29. package/sdk/examples/status-badge/index.js +12 -0
  30. package/sdk/examples/status-badge/package.json +24 -0
  31. package/sdk/templates/plugin/README.md +36 -0
  32. package/sdk/templates/plugin/client.js +35 -0
  33. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  34. package/sdk/templates/plugin/icon.svg +5 -0
  35. package/sdk/templates/plugin/index.js +23 -0
  36. package/sdk/templates/plugin/locale/en.json +6 -0
  37. package/sdk/templates/plugin/package.json +36 -0
  38. package/src/compose.mjs +67 -0
  39. package/src/contract/VENDOR.json +16 -0
  40. package/src/contract/client.js +93 -0
  41. package/src/contract/constants.js +94 -0
  42. package/src/contract/envelope.js +83 -0
  43. package/src/contract/index.js +20 -0
  44. package/src/contract/negotiate.js +106 -0
  45. package/src/contract/relay.js +223 -0
  46. package/src/contract/server.js +77 -0
  47. package/src/contract/transports.js +319 -0
  48. package/src/index.mjs +11 -0
  49. package/src/kernel/core.mjs +100 -0
  50. package/src/kernel/extensions.mjs +61 -0
  51. package/src/kernel/fsx.mjs +349 -0
  52. package/src/kernel/index.mjs +21 -0
  53. package/src/kernel/ledger.mjs +94 -0
  54. package/src/kernel/paths.mjs +182 -0
  55. package/src/kernel/role.mjs +68 -0
  56. package/src/kernel/rounds.mjs +172 -0
  57. package/src/kernel/schema.mjs +5 -0
  58. package/src/kernel/types.mjs +266 -0
  59. package/src/kernel/vendor/cosmokit.mjs +421 -0
  60. package/src/kernel/vendor/schemastery.mjs +638 -0
  61. package/src/lib/adapter-client.mjs +383 -0
  62. package/src/lib/adapter-protocol.mjs +243 -0
  63. package/src/lib/ai-secret-store.mjs +202 -0
  64. package/src/lib/boot-guard.mjs +186 -0
  65. package/src/lib/branch-store.mjs +84 -0
  66. package/src/lib/branch.mjs +74 -0
  67. package/src/lib/bridge-client.mjs +133 -0
  68. package/src/lib/broker-adapter.mjs +67 -0
  69. package/src/lib/broker.mjs +45 -0
  70. package/src/lib/business-producers.mjs +109 -0
  71. package/src/lib/command-cache.mjs +209 -0
  72. package/src/lib/compat.mjs +170 -0
  73. package/src/lib/composer-journal.mjs +128 -0
  74. package/src/lib/container/index.mjs +806 -0
  75. package/src/lib/decision-events.mjs +129 -0
  76. package/src/lib/decision-ingest-client.mjs +41 -0
  77. package/src/lib/decision-outbox.mjs +129 -0
  78. package/src/lib/decision-policy.mjs +52 -0
  79. package/src/lib/default-essence.mjs +61 -0
  80. package/src/lib/desktop-launch.mjs +138 -0
  81. package/src/lib/essence.mjs +1211 -0
  82. package/src/lib/fork-client.mjs +410 -0
  83. package/src/lib/fork-config-lock.mjs +166 -0
  84. package/src/lib/fork-seed.mjs +304 -0
  85. package/src/lib/fork-sessions.mjs +183 -0
  86. package/src/lib/fork-supervision-settings.mjs +58 -0
  87. package/src/lib/fork-supervisor.mjs +122 -0
  88. package/src/lib/fs-guard.mjs +28 -0
  89. package/src/lib/http-auth.mjs +162 -0
  90. package/src/lib/idle-audit.mjs +178 -0
  91. package/src/lib/interaction-poke.mjs +61 -0
  92. package/src/lib/main-gate.mjs +579 -0
  93. package/src/lib/mirror-etag.mjs +92 -0
  94. package/src/lib/mirror-official-source.mjs +93 -0
  95. package/src/lib/mirror.mjs +478 -0
  96. package/src/lib/mother-link.mjs +156 -0
  97. package/src/lib/mutex.mjs +31 -0
  98. package/src/lib/permissions.mjs +115 -0
  99. package/src/lib/pnpm-store.mjs +189 -0
  100. package/src/lib/preferences.mjs +212 -0
  101. package/src/lib/producer-contract.mjs +162 -0
  102. package/src/lib/reflow.mjs +41 -0
  103. package/src/lib/remote.mjs +408 -0
  104. package/src/lib/restart-contract.mjs +752 -0
  105. package/src/lib/roots.mjs +30 -0
  106. package/src/lib/rule-registry.mjs +1127 -0
  107. package/src/lib/runtime-capture.mjs +307 -0
  108. package/src/lib/semaphore.mjs +34 -0
  109. package/src/lib/session-bundle.mjs +138 -0
  110. package/src/lib/session-format.mjs +672 -0
  111. package/src/lib/sse-parser.mjs +102 -0
  112. package/src/lib/ssrf-guard.mjs +170 -0
  113. package/src/lib/status-store.mjs +257 -0
  114. package/src/lib/task-handle.mjs +622 -0
  115. package/src/lib/ui-actions.mjs +55 -0
  116. package/src/lib/umbrella.mjs +1661 -0
  117. package/src/lib/upgrade-coordinator.mjs +107 -0
  118. package/src/lib/upgrade-status.mjs +43 -0
  119. package/src/plugins/adapter.mjs +1468 -0
  120. package/src/plugins/approver.mjs +1210 -0
  121. package/src/plugins/auditor.mjs +330 -0
  122. package/src/plugins/buffer-keeper.mjs +522 -0
  123. package/src/plugins/decision-center.mjs +2581 -0
  124. package/src/plugins/forker.mjs +1610 -0
  125. package/src/plugins/healer.mjs +702 -0
  126. package/src/plugins/hot-reloader.mjs +274 -0
  127. package/src/plugins/imports.mjs +247 -0
  128. package/src/plugins/orchestrator.mjs +3938 -0
  129. package/src/plugins/restart-guard.mjs +1022 -0
  130. package/src/plugins/router.mjs +714 -0
  131. package/src/plugins/scorer.mjs +661 -0
  132. package/src/plugins/session-bridge.mjs +166 -0
  133. package/src/plugins/session-catalog.mjs +771 -0
  134. package/src/plugins/session-router.mjs +125 -0
  135. package/src/plugins/session-sync.mjs +171 -0
  136. package/src/plugins/umbrella.mjs +419 -0
  137. package/tools/boot-guard.mjs +95 -0
  138. package/tools/decision-ingest.mjs +53 -0
  139. package/tools/desktop-launch.mjs +71 -0
  140. package/tools/fangctl.mjs +408 -0
  141. package/tools/gate/main-city.mjs +204 -0
  142. package/tools/mcp/server.mjs +121 -0
  143. package/tools/mcp/tools.mjs +65 -0
  144. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,125 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // fang-session-router — hybrid routing between sync (files) and bridge (live sessions).
4
+ //
5
+ // The decision is a pure truth table, exposed for tests and for the panel:
6
+ // cross-machine + realtime -> deny (bridge is loopback-only, sync not realtime)
7
+ // same machine + host + bridge -> bridge
8
+ // otherwise -> sync
9
+ import { z, roleGate, resolveFangLayout, SERVICE, FANG_VERSION } from '../kernel/index.mjs'
10
+ import { createHttpGuard } from '../lib/http-auth.mjs'
11
+ import { readBridgeDescriptor } from '../lib/bridge-client.mjs'
12
+
13
+ export const name = 'fang-session-router'
14
+ export const inject = []
15
+
16
+ export const Config = z.object({
17
+ sameMachine: z.boolean().default(true)
18
+ .description('PC 宿主与 Web 母体是否同机(bridge 只支持同机回环)'),
19
+ bridgeEnabled: z.boolean().default(true)
20
+ .description('是否允许选择 bridge 模式(descriptor 存在且宿主在跑才会真正选中)'),
21
+ needsRealtime: z.boolean().default(false)
22
+ .description('默认是否要求实时(任务级可选择覆盖)'),
23
+ dshHome: z.string().default('')
24
+ .description('PC 宿主 DSH_HOME(查找 desktop-bridge.json);留空 = 本机 $DSH_HOME'),
25
+ prefix: z.string().default('/fang'),
26
+ routes: z.object({ enabled: z.boolean().default(true) }).default({}),
27
+ authToken: z.string().default('')
28
+ .description('安装级鉴权 token;留空 = env DSH_FANG_AUTH_TOKEN 或 <dataDir>/fang-auth.json(0600,自动生成)'),
29
+ authEnabled: z.boolean().default(true)
30
+ .description('是否要求 /fang/api/route 携带安装级鉴权 token(默认开启;仅测试/本地调试可关)'),
31
+ })
32
+
33
+ /** Pure decision function; `deny` means the request cannot be satisfied honestly. */
34
+ export function chooseMode(context = {}, config = {}) {
35
+ const sameMachine = context.sameMachine ?? config.sameMachine ?? true
36
+ const bridgeEnabled = context.bridgeEnabled ?? config.bridgeEnabled ?? true
37
+ const pcHostRunning = context.pcHostRunning === true
38
+ const needsRealtime = context.needsRealtime ?? config.needsRealtime ?? false
39
+ const oneTimeMigration = context.oneTimeMigration === true
40
+ if (!sameMachine && needsRealtime) return 'deny'
41
+ if (oneTimeMigration) return 'sync'
42
+ if (sameMachine && bridgeEnabled && pcHostRunning && needsRealtime) return 'bridge'
43
+ return 'sync'
44
+ }
45
+
46
+ export function apply(ctx, config) {
47
+ const gate = roleGate('sessionRouter', { logger: ctx.logger })
48
+ if (!gate.active) return
49
+ const cfg = { ...Config({}), ...(config ?? {}) }
50
+ const prefix = String(cfg.prefix ?? '/fang').replace(/\/+$/, '') || '/fang'
51
+ const authGuard = createHttpGuard({
52
+ dataDir: resolveFangLayout().dataDir,
53
+ token: cfg.authToken,
54
+ enabled: cfg.authEnabled !== false,
55
+ })
56
+
57
+ const probe = (context = {}) => {
58
+ const desc = readBridgeDescriptor({ dshHome: cfg.dshHome })
59
+ const pcHostRunning = desc.ok
60
+ const merged = {
61
+ sameMachine: context.sameMachine ?? cfg.sameMachine,
62
+ bridgeEnabled: context.bridgeEnabled ?? cfg.bridgeEnabled,
63
+ needsRealtime: context.needsRealtime ?? cfg.needsRealtime,
64
+ oneTimeMigration: context.oneTimeMigration === true,
65
+ pcHostRunning,
66
+ }
67
+ const mode = chooseMode(merged, cfg)
68
+ return {
69
+ ok: true,
70
+ role: gate.role,
71
+ version: FANG_VERSION,
72
+ mode,
73
+ reason: mode === 'deny'
74
+ ? '跨机 + 实时:bridge 只支持回环,sync 不满足实时'
75
+ : mode === 'bridge'
76
+ ? '同机 + PC 宿主在跑 + bridge 可用 + 需要实时'
77
+ : '文件级同步(可离线 / 跨机 / 一次性迁移)',
78
+ bridge: desc.ok ? { available: true, pid: desc.info.pid } : { available: false, code: desc.code, error: desc.error },
79
+ context: merged,
80
+ }
81
+ }
82
+
83
+ const service = {
84
+ role: gate.role,
85
+ version: FANG_VERSION,
86
+ choose: (context) => chooseMode(context, cfg),
87
+ probe,
88
+ }
89
+ ctx.effect(() => ctx.provide(SERVICE.sessionRouter, service))
90
+
91
+ if (cfg.routes?.enabled === false) return
92
+ ctx.inject(['webServer'], (child) => {
93
+ let webServer = null
94
+ try { webServer = child?.get?.('webServer') ?? child?.webServer ?? null } catch { webServer = null }
95
+ if (!webServer || typeof webServer.register !== 'function') return
96
+ webServer.register({
97
+ kind: 'exact',
98
+ path: `${prefix}/api/route`,
99
+ method: 'GET',
100
+ handler: async (req, res) => {
101
+ const decision = authGuard.check(req)
102
+ if (!decision.ok) {
103
+ res.statusCode = decision.status
104
+ res.setHeader?.('content-type', 'application/json; charset=utf-8')
105
+ res.end(JSON.stringify({ ok: false, error: decision.code, code: decision.code }))
106
+ return
107
+ }
108
+ if (String(req?.method ?? 'GET').toUpperCase() !== 'GET') {
109
+ res.statusCode = 405
110
+ res.setHeader?.('allow', 'GET')
111
+ res.end(JSON.stringify({ ok: false, error: 'method-not-allowed', code: 'method-not-allowed' }))
112
+ return
113
+ }
114
+ const q = new URL(String(req?.url ?? '/'), 'http://fang.local').searchParams
115
+ const out = probe({
116
+ needsRealtime: q.get('realtime') === '1' || q.get('realtime') === 'true',
117
+ oneTimeMigration: q.get('migration') === '1' || q.get('migration') === 'true',
118
+ })
119
+ res.statusCode = out.mode === 'deny' ? 409 : 200
120
+ res.setHeader('content-type', 'application/json; charset=utf-8')
121
+ res.end(JSON.stringify(out))
122
+ },
123
+ })
124
+ })
125
+ }
@@ -0,0 +1,171 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // fang-session-sync — selected-session export/import (file-level sync mode).
4
+ // Export is read-only on the source; import never overwrites (keep-both + fork).
5
+ import path from 'node:path'
6
+ import {
7
+ z, roleGate, resolveDshHome, resolveFangLayout, SERVICE, FANG_VERSION, ensureDir, readJson, exists,
8
+ } from '../kernel/index.mjs'
9
+ import { exportSessions, importSessions } from '../lib/session-bundle.mjs'
10
+ import { encodeWorkspaceKey } from '../lib/session-format.mjs'
11
+ import { createHttpGuard } from '../lib/http-auth.mjs'
12
+
13
+ export const name = 'fang-session-sync'
14
+ export const inject = []
15
+
16
+ export const Config = z.object({
17
+ sessionsRoot: z.string().default('')
18
+ .description('源会话目录(PC);留空 = <DSH_HOME>/sessions'),
19
+ storagesRoot: z.string().default('')
20
+ .description('workspace.json 所在目录(源);留空 = <DSH_HOME>/storages'),
21
+ bundleRoot: z.string().default('')
22
+ .description('交接 bundle 根;留空 = <fang dataDir>/sync-bundles'),
23
+ prefix: z.string().default('/fang'),
24
+ maxBundleBytes: z.number().min(0).max(4 * 1024 * 1024 * 1024).default(512 * 1024 * 1024)
25
+ .description('单个 bundle 的大小上限(字节);超过则拒绝导出'),
26
+ routes: z.object({ enabled: z.boolean().default(true) }).default({}),
27
+ authToken: z.string().default('')
28
+ .description('安装级鉴权 token;留空 = env DSH_FANG_AUTH_TOKEN 或 <dataDir>/fang-auth.json(0600,自动生成)'),
29
+ authEnabled: z.boolean().default(true)
30
+ .description('是否要求 /fang/api/sync/* 携带安装级鉴权 token(默认开启;仅测试/本地调试可关)'),
31
+ })
32
+
33
+ export function apply(ctx, config) {
34
+ const gate = roleGate('sessionSync', { logger: ctx.logger })
35
+ if (!gate.active) return
36
+ const cfg = { ...Config({}), ...(config ?? {}) }
37
+ const layout = resolveFangLayout()
38
+ const sessionsRoot = cfg.sessionsRoot ? path.resolve(cfg.sessionsRoot) : path.join(resolveDshHome(), 'sessions')
39
+ const storagesRoot = cfg.storagesRoot ? path.resolve(cfg.storagesRoot) : path.join(resolveDshHome(), 'storages')
40
+ const bundleRoot = cfg.bundleRoot ? path.resolve(cfg.bundleRoot) : path.join(layout.dataDir, 'sync-bundles')
41
+ const prefix = String(cfg.prefix ?? '/fang').replace(/\/+$/, '') || '/fang'
42
+ const authGuard = createHttpGuard({
43
+ dataDir: layout.dataDir,
44
+ token: cfg.authToken,
45
+ enabled: cfg.authEnabled !== false,
46
+ })
47
+
48
+ const workspacesOf = (homeDir) => {
49
+ const file = path.join(homeDir, 'storages', 'workspace.json')
50
+ if (!exists(file)) return []
51
+ try {
52
+ const doc = readJson(file)
53
+ const table = doc?.tables?.workspaces
54
+ if (!table || typeof table !== 'object') return []
55
+ return Object.entries(table).map(([id, ws]) => ({ id, path: ws?.path, title: ws?.title })).filter((w) => typeof w.path === 'string')
56
+ } catch { return [] }
57
+ }
58
+
59
+ const service = {
60
+ role: gate.role,
61
+ version: FANG_VERSION,
62
+ sessionsRoot,
63
+ storagesRoot,
64
+ bundleRoot,
65
+ status() {
66
+ return { ok: true, role: gate.role, sessionsRoot, bundleRoot, exists: exists(sessionsRoot) }
67
+ },
68
+ /** PC 侧:把勾选会话只读地导出到 staging bundle。 */
69
+ exportSelected(input = {}) {
70
+ const workspaces = workspacesOf(storagesRoot)
71
+ const requestedBundleId = input.bundleId === undefined ? `sync-${Date.now()}` : String(input.bundleId)
72
+ // v3.0.5/B3:bundleId 只允许安全段(防 `../` 写出 bundleRoot)
73
+ if (!/^[A-Za-z0-9._-]{1,128}$/.test(requestedBundleId) || requestedBundleId === '.' || requestedBundleId === '..') {
74
+ return { ok: false, code: 'invalid-bundle-id' }
75
+ }
76
+ const out = exportSessions({
77
+ sessionsRoot,
78
+ bundleDir: path.join(bundleRoot, requestedBundleId),
79
+ sessionIds: input.sessionIds,
80
+ workspaces,
81
+ })
82
+ if (out.ok && cfg.maxBundleBytes > 0 && out.bytes > cfg.maxBundleBytes) {
83
+ return { ok: false, code: 'bundle-too-large', bytes: out.bytes, limit: cfg.maxBundleBytes }
84
+ }
85
+ return out
86
+ },
87
+ /** Web 侧:把 bundle 导入目标 sessionsRoot;workospace 由目标 workspace.json 决定。 */
88
+ importInto(input = {}) {
89
+ const bundleDir = path.resolve(String(input.bundleDir ?? ''))
90
+ const targetHome = path.resolve(String(input.targetHome ?? ''))
91
+ // v3.0.5/B3:bundle 必须位于 bundleRoot 内;目标 home 只允许当前 DSH_HOME 或 fork homes 根
92
+ const inside = (base, target) => {
93
+ const rel = path.relative(base, target)
94
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
95
+ }
96
+ if (!inside(bundleRoot, bundleDir)) return { ok: false, code: 'bundle-outside-root' }
97
+ const allowedHomes = [resolveDshHome(), layout.forkHomesDir].filter((p) => typeof p === 'string' && p.length > 0)
98
+ if (!allowedHomes.some((base) => inside(base, targetHome))) return { ok: false, code: 'target-outside-allowed' }
99
+ const targetSessions = path.join(targetHome, 'sessions')
100
+ ensureDir(targetSessions)
101
+ const workspaces = workspacesOf(path.join(targetHome, 'storages'))
102
+ const out = importSessions({
103
+ bundleDir,
104
+ targetSessionsRoot: targetSessions,
105
+ workspaces,
106
+ encodeWorkspaceKey,
107
+ })
108
+ return { ...out, targetHome, targetSessionsRoot: targetSessions }
109
+ },
110
+ }
111
+
112
+ ctx.effect(() => ctx.provide(SERVICE.sessionSync, service))
113
+
114
+ if (cfg.routes?.enabled === false) return
115
+ ctx.inject(['webServer'], (child) => {
116
+ let webServer = null
117
+ try { webServer = child?.get?.('webServer') ?? child?.webServer ?? null } catch { webServer = null }
118
+ if (!webServer || typeof webServer.register !== 'function') return
119
+ const json = (res, status, value) => {
120
+ res.statusCode = status
121
+ res.setHeader('content-type', 'application/json; charset=utf-8')
122
+ res.end(JSON.stringify(value))
123
+ }
124
+ const readBody = async (req) => {
125
+ const chunks = []
126
+ let size = 0
127
+ for await (const chunk of req) {
128
+ const buf = Buffer.from(chunk)
129
+ size += buf.length
130
+ if (size > 1_000_000) return { __tooLarge: true }
131
+ chunks.push(buf)
132
+ }
133
+ const text = Buffer.concat(chunks).toString('utf8')
134
+ if (!text) return {}
135
+ try { return JSON.parse(text) } catch { return { __parseError: true } }
136
+ }
137
+ const route = (method, routePath, handler) => {
138
+ webServer.register({
139
+ kind: 'exact',
140
+ path: routePath,
141
+ method,
142
+ handler: async (req, res) => {
143
+ try {
144
+ const decision = authGuard.check(req)
145
+ if (!decision.ok) return json(res, decision.status, { ok: false, error: decision.code, code: decision.code })
146
+ if (String(req?.method ?? 'GET').toUpperCase() !== method) {
147
+ try { res.setHeader?.('allow', method) } catch { /* fake res */ }
148
+ return json(res, 405, { ok: false, error: 'method-not-allowed', code: 'method-not-allowed' })
149
+ }
150
+ return await handler(req, res)
151
+ } catch (e) { return json(res, 500, { ok: false, error: String(e?.message ?? e), code: 'handler-threw' }) }
152
+ },
153
+ })
154
+ }
155
+ route('GET', `${prefix}/api/sync/status`, (req, res) => json(res, 200, service.status()))
156
+ route('POST', `${prefix}/api/sync/export`, async (req, res) => {
157
+ const body = await readBody(req)
158
+ if (body.__tooLarge) return json(res, 413, { ok: false, error: 'body-too-large' })
159
+ if (body.__parseError) return json(res, 400, { ok: false, error: 'bad-json' })
160
+ const out = service.exportSelected(body)
161
+ json(res, out.ok ? 200 : 400, out)
162
+ })
163
+ route('POST', `${prefix}/api/sync/import`, async (req, res) => {
164
+ const body = await readBody(req)
165
+ if (body.__tooLarge) return json(res, 413, { ok: false, error: 'body-too-large' })
166
+ if (body.__parseError) return json(res, 400, { ok: false, error: 'bad-json' })
167
+ const out = service.importInto(body)
168
+ json(res, out.ok ? 200 : 400, out)
169
+ })
170
+ })
171
+ }