@goodandready/dsh-kanban 0.2.34 → 0.2.35

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,282 @@
1
+ import { COLUMN_ORDER, columnsOf, wipLimitField, columnNamesOf } from '../config.js'
2
+ import { parsePlan, planItems, planProgress, taskState, queuePosition } from '../plan.js'
3
+ import { columnStats, doneWithin, stale } from '../metrics.js'
4
+ import { isStale } from '../time.js'
5
+ import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from '../filters.js'
6
+ import { computeNextRun } from '../cron.js'
7
+
8
+ export function withPlan(task, liveOf, config, now, sameSession) {
9
+ const plan = parsePlan(task.plan)
10
+ // Registry error implies agent unavailable; do not fail whole board render.
11
+ let live
12
+ try {
13
+ if (typeof liveOf === 'function' && task.sessionId) live = liveOf(task.sessionId)
14
+ } catch { live = undefined }
15
+ const liveStatus = typeof live === 'object' && live !== null ? live.status : live
16
+ const liveStep = typeof live === 'object' && live !== null
17
+ ? (live.step || live.activity || live.stateDetail || '')
18
+ : ''
19
+
20
+ const costMetrics = task.costMetrics || null
21
+ const costLimitExceeded = (config?.costMeterEnabled !== false && Number(config?.taskCostLimit) > 0 && costMetrics?.costUsd)
22
+ ? costMetrics.costUsd >= Number(config.taskCostLimit)
23
+ : Boolean(task.costLimitExceeded)
24
+
25
+ return {
26
+ ...task,
27
+ plan: { items: planItems(plan), progress: planProgress(plan) },
28
+ state: taskState({ task, live: liveStatus }),
29
+ liveStep: liveStep || task.liveStep || '',
30
+ costMetrics,
31
+ costLimitExceeded,
32
+ // Queue position calculated dynamically across active tasks in same session.
33
+ queuePos: queuePosition(task, sameSession),
34
+ // Label facets extracted on server for consistent client filtering.
35
+ facets: facetsOfTask(task),
36
+ // Stale calculation performed on host where configuration resides.
37
+ stale: isStale({
38
+ now,
39
+ updatedAt: task.updatedAt,
40
+ sessionId: task.sessionId,
41
+ afterMinutes: config?.staleAfterMin,
42
+ }),
43
+ // Overdue calculated on host using server clock.
44
+ overdue: typeof task.dueAt === 'number' && task.dueAt > 0 && task.dueAt < now,
45
+ }
46
+ }
47
+
48
+ /** Order tasks: by column, grouped by repository, with active tasks on top. */
49
+ function orderForBoard(tasks, columns) {
50
+ const out = []
51
+ for (const column of columns) {
52
+ const inColumn = tasks.filter((t) => t.column === column)
53
+ for (const group of groupByRepo(sortByLife(inColumn))) out.push(...group.tasks)
54
+ }
55
+ // Retain tasks in columns not visible on current board to prevent data loss.
56
+ const seen = new Set(out.map((t) => t.id))
57
+ for (const task of tasks) if (!seen.has(task.id)) out.push(task)
58
+ return out
59
+ }
60
+
61
+ /**
62
+ * Build board response payload: columns in canonical order, tasks, and limits.
63
+ */
64
+ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync, projectRoot, costMeterAvailable = false }) {
65
+ // Uniform timestamp across snapshot calculation.
66
+ const now = Date.now()
67
+ // Tasks are sorted and grouped on server: repo grouping with active items first.
68
+ const raw = store.listTasks({ board, repo })
69
+ // Queue position scoped per session.
70
+ const bySession = new Map()
71
+ for (const task of raw) {
72
+ const id = typeof task.sessionId === 'string' ? task.sessionId : ''
73
+ if (id === '') continue
74
+ if (!bySession.has(id)) bySession.set(id, [])
75
+ bySession.get(id).push(task)
76
+ }
77
+ const tasks = orderForBoard(
78
+ raw.map((task) => {
79
+ const enriched = withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])
80
+ if (task.isEpic) {
81
+ enriched.subtasksCount = store.countSubtasks(task.id)
82
+ }
83
+ return enriched
84
+ }),
85
+ columnsOf(store.boardKind(board)),
86
+ )
87
+ const kind = store.boardKind(board)
88
+ // Column custom names sent from server; locale translation handled by client.
89
+ const names = columnNamesOf(config)
90
+ const columns = columnsOf(kind).map((id) => {
91
+ const count = tasks.filter((t) => t.column === id).length
92
+ const limit = wipLimitField(config, id)
93
+ return { id, name: names[id], count, limit, overLimit: limit !== undefined && count > limit }
94
+ })
95
+ // Filter counts calculated over all tasks on board.
96
+ return {
97
+ board, kind, now, boards: store.listBoards(), columns, tasks,
98
+ // Resolved project root {path, set} computed on host.
99
+ projectRoot: projectRoot ?? { path: '', set: false },
100
+
101
+ facets: facetsOf(tasks),
102
+ // Sync telemetry state attached directly to board snapshot.
103
+ sync: sync ?? { state: 'never' },
104
+ costMeterAvailable: Boolean(costMeterAvailable),
105
+ remoteMode: Boolean(config?.remoteMode),
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Card move transition. WIP limit is advisory: move executes, and exceeded status
111
+ * is returned for UI highlighting.
112
+ */
113
+
114
+ export function boardMetrics({ store, board = 'main', now = Date.now(), staleDays = 14 }) {
115
+ const tasks = store.listTasks({ board })
116
+ const byTask = new Map()
117
+ for (const row of store.listAllTransitions()) {
118
+ if (!byTask.has(row.taskId)) byTask.set(row.taskId, [])
119
+ byTask.get(row.taskId).push(row)
120
+ }
121
+ const rows = tasks.map((task) => ({ task, moves: byTask.get(task.id) ?? [] }))
122
+ const day = 24 * 60 * 60 * 1000
123
+
124
+ return {
125
+ tasks: tasks.length,
126
+ columns: columnStats(rows, now),
127
+ done: {
128
+ week: doneWithin(rows, now - 7 * day, now),
129
+ month: doneWithin(rows, now - 30 * day, now),
130
+ },
131
+ staleDays,
132
+ stale: stale(tasks, now, staleDays * day).slice(0, 50),
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Check whether a task can be resumed.
138
+ * Eligible when task has recorded sessionId but no active agent is running.
139
+ */
140
+
141
+ export function exportBoard({ store }) {
142
+ return store.exportAll()
143
+ }
144
+
145
+ /**
146
+ * Restore board from backup snapshot (idempotent; skips existing IDs).
147
+ *
148
+ * @returns {{imported: number, skipped: number, error?: string}}
149
+ */
150
+ export function importBoard({ store, input }) {
151
+ let backupMeta = undefined
152
+ if (typeof store.backupDatabase === 'function') {
153
+ try {
154
+ backupMeta = store.backupDatabase()
155
+ } catch { /* backup best effort */ }
156
+ }
157
+ if (input === null || typeof input !== 'object') return { error: 'bad-import', status: 400 }
158
+ if (input.version !== 1) return { error: 'bad-import-version', status: 400 }
159
+ if (!Array.isArray(input.tasks)) return { error: 'bad-import', status: 400 }
160
+
161
+ let imported = 0
162
+ let skipped = 0
163
+
164
+ const doImport = () => {
165
+ for (const board of Array.isArray(input.boards) ? input.boards : []) {
166
+ if (typeof board?.id !== 'string' || board.id === '') continue
167
+ store.createBoard({
168
+ id: board.id,
169
+ title: board.title,
170
+ kind: board.kind,
171
+ position: board.position,
172
+ createdAt: board.createdAt,
173
+ })
174
+ }
175
+
176
+ for (const task of input.tasks) {
177
+ if (typeof task?.id !== 'string' || task.id === '') continue
178
+ if (store.getTask(task.id) !== undefined) { skipped += 1; continue }
179
+ if (typeof store.restoreTask === 'function') {
180
+ store.restoreTask(task)
181
+ } else {
182
+ store.createTask(task)
183
+ }
184
+ imported += 1
185
+ }
186
+
187
+ for (const trans of Array.isArray(input.transitions) ? input.transitions : []) {
188
+ if (typeof trans?.taskId !== 'string' || trans.taskId === '') continue
189
+ if (typeof store.restoreTransition === 'function') {
190
+ store.restoreTransition(trans)
191
+ } else if (typeof store.addTransition === 'function') {
192
+ store.addTransition(trans)
193
+ }
194
+ }
195
+ }
196
+
197
+ if (typeof store.withTransaction === 'function') {
198
+ store.withTransaction(doImport)
199
+ } else {
200
+ doImport()
201
+ }
202
+
203
+ return { imported, skipped, backup: backupMeta?.backup }
204
+ }
205
+
206
+ /**
207
+ * Export tasks to Excel-compatible CSV with UTF-8 BOM (#223).
208
+ *
209
+ * @param {{store: object, board?: string}} options
210
+ * @returns {string} CSV string with UTF-8 BOM prefix
211
+ */
212
+ export function exportTasksCsv({ store, board = 'main' }) {
213
+ const tasks = store.listTasks({ board })
214
+ const headers = [
215
+ 'ID',
216
+ 'Title',
217
+ 'Column',
218
+ 'Priority',
219
+ 'Repository',
220
+ 'Issue #',
221
+ 'Assignee',
222
+ 'Model',
223
+ 'Provider',
224
+ 'Reasoning Effort',
225
+ 'Workspace',
226
+ 'Branch',
227
+ 'Created',
228
+ 'Updated',
229
+ ]
230
+
231
+ function escapeCsv(val) {
232
+ if (val === null || val === undefined) return ''
233
+ let str = String(val)
234
+ // OWASP CSV Injection mitigation (#340)
235
+ const trimmed = str.replace(/^[\s\t\r\n]+/, '')
236
+ if (/^[=+\-@\t\r]/.test(str) || (trimmed.length > 0 && /^[=+\-@]/.test(trimmed))) {
237
+ str = `'${str}`
238
+ }
239
+ if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
240
+ return `"${str.replace(/"/g, '""')}"`
241
+ }
242
+ return str
243
+ }
244
+
245
+ function formatDate(ts) {
246
+ if (!ts || typeof ts !== 'number') return ''
247
+ try {
248
+ return new Date(ts).toISOString().replace('T', ' ').slice(0, 19)
249
+ } catch {
250
+ return ''
251
+ }
252
+ }
253
+
254
+ const rows = [headers.join(',')]
255
+ for (const t of tasks) {
256
+ const row = [
257
+ escapeCsv(t.id),
258
+ escapeCsv(t.title),
259
+ escapeCsv(t.column),
260
+ escapeCsv(t.priority),
261
+ escapeCsv(t.repo),
262
+ escapeCsv(t.issueNumber ?? ''),
263
+ escapeCsv(t.assignee),
264
+ escapeCsv(t.model),
265
+ escapeCsv(t.provider),
266
+ escapeCsv(t.reasoningEffort ?? ''),
267
+ escapeCsv(t.workspaceId),
268
+ escapeCsv(t.branch),
269
+ escapeCsv(formatDate(t.createdAt)),
270
+ escapeCsv(formatDate(t.updatedAt)),
271
+ ]
272
+ rows.push(row.join(','))
273
+ }
274
+
275
+ // UTF-8 BOM prefix for Microsoft Excel
276
+ return '\uFEFF' + rows.join('\r\n')
277
+ }
278
+
279
+
280
+ /**
281
+ * Clean up task worktree.
282
+ */
@@ -0,0 +1,274 @@
1
+ import crypto from 'node:crypto'
2
+
3
+ export const MAX_BODY_BYTES = 256 * 1024
4
+
5
+ export function isLoopbackAddress(addr) {
6
+ if (!addr || typeof addr !== 'string') return false
7
+ const clean = addr.toLowerCase().replace(/^::ffff:/, '').replace(/^\[|\]$/g, '')
8
+ return (
9
+ clean === '127.0.0.1' ||
10
+ clean === '::1' ||
11
+ clean === 'localhost' ||
12
+ clean === 'localhost.' ||
13
+ clean.startsWith('127.')
14
+ )
15
+ }
16
+
17
+ export function isLanAddress(addr) {
18
+ if (!addr || typeof addr !== 'string') return false
19
+ const clean = addr.toLowerCase().replace(/^::ffff:/, '').replace(/^\[|\]$/g, '')
20
+ if (isLoopbackAddress(clean)) return true
21
+ if (clean.startsWith('10.') || clean.startsWith('192.168.')) return true
22
+ const m = clean.match(/^172\.(\d+)\./)
23
+ if (m) {
24
+ const octet = parseInt(m[1], 10)
25
+ if (octet >= 16 && octet <= 31) return true
26
+ }
27
+ return false
28
+ }
29
+
30
+ export function isTrustedHost(host) {
31
+ if (!host || typeof host !== 'string') return false
32
+ let hostname = host.trim()
33
+ if (hostname.startsWith('[')) {
34
+ const end = hostname.indexOf(']')
35
+ if (end > 0) hostname = hostname.slice(1, end)
36
+ } else {
37
+ hostname = hostname.split(':')[0]
38
+ }
39
+ hostname = hostname.toLowerCase()
40
+ if (!hostname) return false
41
+ if (hostname === 'localhost' || hostname === 'localhost.' || hostname.endsWith('.local')) return true
42
+ if (isLoopbackAddress(hostname) || isLanAddress(hostname)) return true
43
+ return false
44
+ }
45
+
46
+ export function normalizeIp(addr) {
47
+ if (!addr || typeof addr !== 'string') return ''
48
+ return addr.toLowerCase().replace(/^::ffff:/, '').replace(/^\[|\]$/g, '')
49
+ }
50
+
51
+ export function cookieNameForAuthority(authority) {
52
+ if (!authority || typeof authority !== 'string') return ''
53
+ const hash = crypto.createHash('sha256').update(authority.trim()).digest('base64url')
54
+ return 'dsh-auth-' + hash
55
+ }
56
+
57
+ export function isLoopbackCookieName(name, host = '') {
58
+ if (!name || typeof name !== 'string' || !name.startsWith('dsh-auth-')) return false
59
+ const authorities = [
60
+ host,
61
+ '127.0.0.1:3000',
62
+ '127.0.0.1:3080',
63
+ '127.0.0.1:3082',
64
+ '127.0.0.1',
65
+ 'localhost:3000',
66
+ 'localhost:3080',
67
+ 'localhost:3082',
68
+ 'localhost',
69
+ '[::1]:3000',
70
+ '[::1]',
71
+ ]
72
+ for (const auth of authorities) {
73
+ if (auth && isLoopbackAddress(auth.split(':')[0])) {
74
+ if (cookieNameForAuthority(auth) === name) return true
75
+ }
76
+ }
77
+ return false
78
+ }
79
+
80
+ export function hasClientCredentials(req) {
81
+ if (!req || !req.headers) return false
82
+ if (req.headers.authorization) return true
83
+ if (req.headers['x-dsh-lan-pin']) return true
84
+ const cookie = req.headers.cookie
85
+ if (!cookie || typeof cookie !== 'string') return false
86
+ if (cookie.includes('dsh_token=') || cookie.includes('dsh_lan_pin=')) return true
87
+
88
+ const parts = cookie.split(';')
89
+ for (const part of parts) {
90
+ const trimmed = part.trim()
91
+ const eqIdx = trimmed.indexOf('=')
92
+ if (eqIdx === -1) continue
93
+ const name = trimmed.slice(0, eqIdx).trim()
94
+ if (name.startsWith('dsh-auth-')) {
95
+ if (!isLoopbackCookieName(name, req.headers.host)) {
96
+ return true
97
+ }
98
+ }
99
+ }
100
+ return false
101
+ }
102
+
103
+ export function getClientIp(req) {
104
+ const socketAddress =
105
+ req?.socket?.remoteAddress ||
106
+ req?.connection?.remoteAddress ||
107
+ req?.info?.remoteAddress ||
108
+ ''
109
+ const socketIp = normalizeIp(socketAddress)
110
+ const xff = req?.headers?.['x-forwarded-for']
111
+ if (xff && typeof xff === 'string' && (isLoopbackAddress(socketIp) || !socketIp)) {
112
+ const first = xff.split(',')[0].trim()
113
+ if (first) {
114
+ return normalizeIp(first)
115
+ }
116
+ }
117
+ return socketIp
118
+ }
119
+
120
+ /**
121
+ * Cross-site request, DNS rebinding, and authentication validation (#261, #369, #384).
122
+ *
123
+ * Requirements:
124
+ * 1. If DSH connection service is available (options.connection), requestRejection(req)
125
+ * is the authoritative gate for browser session authentication and trusted network access.
126
+ * If rejection is returned, the numeric status (e.g. 401 or 403) is returned.
127
+ * 2. Host header is required and must target a trusted host (loopback, LAN, .local).
128
+ * 3. Cross-site requests ('sec-fetch-site: cross-site' or origin mismatch) are rejected with 403.
129
+ * 4. Origin & Referer headers (if present) must match Host or a trusted host.
130
+ * 5. Network boundary validation:
131
+ * - Loopback callers (127.0.0.1, ::1) are allowed.
132
+ * - Non-loopback / LAN callers require valid connection authentication (fail-closed, return 401).
133
+ * - Unknown remote address (socket present but empty remoteAddress) fails closed with 403.
134
+ *
135
+ * @param {import('node:http').IncomingMessage} req
136
+ * @param {{ connection?: { requestRejection?: (req: any) => number | undefined } }} [options]
137
+ * @returns {number|undefined} Rejection HTTP status code (401, 403), or undefined if allowed.
138
+ */
139
+ export function getRejectionStatus(req, options = {}) {
140
+ const connection = options?.connection
141
+ if (connection && typeof connection.requestRejection === 'function') {
142
+ try {
143
+ const rejection = connection.requestRejection(req)
144
+ if (rejection !== undefined) {
145
+ if (typeof rejection === 'number' && Number.isInteger(rejection) && rejection >= 400 && rejection < 600) {
146
+ return rejection
147
+ }
148
+ return rejection === 401 ? 401 : 403
149
+ }
150
+ } catch {
151
+ // Fail closed if connection service throws
152
+ return 403
153
+ }
154
+ }
155
+
156
+ if (!req || !req.headers) return 403
157
+
158
+ const host = req.headers.host
159
+ if (!host || !isTrustedHost(host)) {
160
+ return 403
161
+ }
162
+
163
+ const site = req.headers['sec-fetch-site']
164
+
165
+ // 1. Explicit cross-site or same-site requests are rejected
166
+ if (site === 'cross-site' || site === 'same-site') {
167
+ return 403
168
+ }
169
+
170
+ // 2. Validate origin against trusted hosts and host header
171
+ const origin = req.headers.origin
172
+ if (origin !== undefined) {
173
+ if (origin === 'null' || !origin) return 403
174
+ try {
175
+ const parsed = new URL(origin)
176
+ if (!isTrustedHost(parsed.hostname)) return 403
177
+ if (parsed.host && parsed.host !== host) return 403
178
+ } catch {
179
+ return 403
180
+ }
181
+ }
182
+
183
+ // 3. Validate referer against trusted hosts and host header if present
184
+ const referer = req.headers.referer
185
+ if (referer !== undefined && referer) {
186
+ try {
187
+ const parsedRef = new URL(referer)
188
+ if (!isTrustedHost(parsedRef.hostname)) return 403
189
+ if (parsedRef.host && parsedRef.host !== host) return 403
190
+ } catch {
191
+ return 403
192
+ }
193
+ }
194
+
195
+ // 4. Validate remote address and network boundary
196
+ const hasSocket = !!(req.socket || req.connection)
197
+ const remote = getClientIp(req)
198
+
199
+ if (hasSocket) {
200
+ if (!remote) {
201
+ return 403
202
+ }
203
+ if (!isLoopbackAddress(remote) && !isLanAddress(remote)) {
204
+ return 403
205
+ }
206
+ // Remote / LAN callers without connection authentication are rejected (#384)
207
+ if (!isLoopbackAddress(remote)) {
208
+ if (!connection) {
209
+ return 401
210
+ }
211
+ // If request was forwarded through a reverse proxy (x-forwarded-for),
212
+ // the proxy may have injected loopback credentials. Validate that the client
213
+ // itself supplied authentic credentials (bearer token, PIN, or client session cookie).
214
+ const xff = req.headers['x-forwarded-for']
215
+ if (xff && !hasClientCredentials(req)) {
216
+ return 401
217
+ }
218
+ }
219
+ } else {
220
+ // Synthetic mock request without socket
221
+ const xff = req.headers['x-forwarded-for']
222
+ if (xff) {
223
+ const first = xff.split(',')[0].trim()
224
+ if (first && !isLoopbackAddress(normalizeIp(first))) {
225
+ if (!connection || !hasClientCredentials(req)) {
226
+ return 401
227
+ }
228
+ }
229
+ } else if (!connection) {
230
+ const hostname = String(host).split(':')[0].toLowerCase()
231
+ if (!isLoopbackAddress(hostname) && hostname !== 'localhost') {
232
+ return 401
233
+ }
234
+ }
235
+ }
236
+
237
+ // 5. Browser request with declared site:
238
+ // Must be same-origin or none
239
+ if (site && site !== 'same-origin' && site !== 'none') {
240
+ return 403
241
+ }
242
+
243
+ // 6. Request without declared site from non-loopback requires connection
244
+ if (!site && remote && !isLoopbackAddress(remote) && !connection) {
245
+ return 401
246
+ }
247
+
248
+ return undefined
249
+ }
250
+
251
+ export function isTrustedRequest(req, options = {}) {
252
+ return getRejectionStatus(req, options) === undefined
253
+ }
254
+
255
+ export function rejectUntrustedRequest(req, res, options = {}) {
256
+ const status = getRejectionStatus(req, options)
257
+ if (status !== undefined) {
258
+ try {
259
+ res.writeHead(status, {
260
+ 'Content-Type': 'application/json; charset=utf-8',
261
+ 'Cache-Control': 'no-store',
262
+ })
263
+ res.end(
264
+ JSON.stringify({
265
+ error: status === 401 ? 'unauthorized' : 'cross-site',
266
+ })
267
+ )
268
+ } catch {
269
+ /* socket closed */
270
+ }
271
+ return true
272
+ }
273
+ return false
274
+ }