bacon-tracker 1.0.0

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,476 @@
1
+ require 'sinatra/base'
2
+ require 'json'
3
+ require 'uri'
4
+ require 'bacon_tracker/launcher'
5
+
6
+ module BaconTracker
7
+ class Server < Sinatra::Base
8
+ # Cap request bodies - stories are tiny; anything larger is a mistake or an
9
+ # attempt to exhaust memory via request.body.read (BT-116).
10
+ MAX_BODY_BYTES = 1_000_000
11
+
12
+ # Origins allowed to make state-changing (POST/PUT/DELETE) requests. The
13
+ # board is same-origin on localhost; this blocks cross-site CSRF (BT-085).
14
+ PERMITTED_ORIGIN_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
15
+
16
+ # The client is served as static assets (extracted from the inline templates
17
+ # so it can be linted, syntax-checked, and unit-tested - BT-112). Read once
18
+ # at load; they never change at runtime.
19
+ ASSETS_DIR = File.expand_path('assets', __dir__).freeze
20
+ APP_JS = File.read(File.join(ASSETS_DIR, 'app.js')).freeze
21
+ THEME_JS = File.read(File.join(ASSETS_DIR, 'theme.js')).freeze
22
+ LOGIC_JS = File.read(File.join(ASSETS_DIR, 'logic.js')).freeze
23
+ DOCS_JS = File.read(File.join(ASSETS_DIR, 'docs.js')).freeze
24
+ DECISIONS_JS = File.read(File.join(ASSETS_DIR, 'decisions.js')).freeze
25
+
26
+ def self.boot(config)
27
+ @mode = :single
28
+ @bt_core = Core.new(config)
29
+ @dashboard = nil
30
+ self
31
+ end
32
+
33
+ def self.boot_dashboard(dashboard)
34
+ @mode = :dashboard
35
+ @dashboard = dashboard
36
+ @bt_core = nil
37
+ self
38
+ end
39
+
40
+ # Route patterns are registered for both single mode and the
41
+ # dashboard-mode /projects/:slug prefix.
42
+ def self.scoped(path)
43
+ [path, "/projects/:slug#{path}"]
44
+ end
45
+
46
+ def self.bt_core = @bt_core
47
+ def self.bt_dashboard = @dashboard
48
+ def self.dashboard_mode? = @mode == :dashboard
49
+
50
+ configure do
51
+ # Templates are files in views/ (BT-137) - __END__ had grown to a
52
+ # thousand lines, and every docs surface adds more. The gemspec must ship
53
+ # *.erb, or the views 404 from the registry while working fine in a path
54
+ # checkout (the troubleshooting.md failure class).
55
+ set :views, File.expand_path('views', __dir__)
56
+ disable :logging
57
+ set :host_authorization, permitted_hosts: %w[localhost 127.0.0.1 ::1]
58
+ # Compile templates once. This also used to guard a real corruption bug:
59
+ # erubi escapes its input in place, and with inline __END__ templates the
60
+ # input WAS the stored template, so every dev-mode recompile corrupted it
61
+ # a little more (see BT-112 and the old transform_values! "fresh copy"
62
+ # belt, removed with the inline registry in BT-137). A file-backed view is
63
+ # re-read from disk per compile, so that failure mode is structurally gone
64
+ # - this is now just the cheap setting.
65
+ set :reload_templates, false
66
+ end
67
+
68
+ # Security headers on every response: forbid framing (clickjacking) and
69
+ # content-sniffing (BT-116). For state-changing methods, reject a request
70
+ # carrying a cross-origin Origin header - a page on another site can fire a
71
+ # CORS-simple POST at localhost, but the browser always attaches its Origin,
72
+ # so this blocks CSRF while leaving same-origin and non-browser (no Origin)
73
+ # callers untouched (BT-085).
74
+ before do
75
+ headers 'X-Frame-Options' => 'DENY',
76
+ 'X-Content-Type-Options' => 'nosniff',
77
+ 'Content-Security-Policy' => "frame-ancestors 'none'"
78
+ if %w[POST PUT DELETE].include?(request.request_method)
79
+ origin = request.env['HTTP_ORIGIN']
80
+ halt 403, json({ error: 'cross-origin request rejected' }) if origin && !permitted_origin?(origin)
81
+ end
82
+ end
83
+
84
+ error do
85
+ e = env['sinatra.error']
86
+ warn "[BaconTracker] #{e.class}: #{e.message}"
87
+ warn e.backtrace.first(10).join("\n")
88
+ content_type :json
89
+ status 500
90
+ { error: 'Internal Server Error' }.to_json
91
+ end
92
+
93
+ helpers do
94
+ def resolve_core
95
+ if self.class.dashboard_mode?
96
+ self.class.bt_dashboard&.project_core(params[:slug]) || halt(404, json({ error: 'Project not found' }))
97
+ else
98
+ halt(404, json({ error: 'Project not found' })) if request.path_info.start_with?('/projects/')
99
+ self.class.bt_core || halt(503, json({ error: 'Not in single-project mode' }))
100
+ end
101
+ end
102
+
103
+ # An Origin header is same-origin on localhost. rescue → nil host → rejected.
104
+ def permitted_origin?(origin)
105
+ host = begin
106
+ URI(origin).host
107
+ rescue URI::InvalidURIError
108
+ nil
109
+ end
110
+ PERMITTED_ORIGIN_HOSTS.include?(host)
111
+ end
112
+
113
+ def h(text)
114
+ Rack::Utils.escape_html(text.to_s)
115
+ end
116
+
117
+ def json_body
118
+ len = request.content_length
119
+ halt 413, json({ error: 'request body too large' }) if len && len.to_i > MAX_BODY_BYTES
120
+ raw = request.body.read(MAX_BODY_BYTES + 1) || ''
121
+ halt 413, json({ error: 'request body too large' }) if raw.bytesize > MAX_BODY_BYTES
122
+
123
+ data = JSON.parse(raw)
124
+ halt 400, json({ error: 'JSON body must be an object' }) unless data.is_a?(Hash)
125
+ data
126
+ rescue JSON::ParserError
127
+ halt 400, json({ error: 'Invalid JSON body' })
128
+ end
129
+
130
+ def json(obj)
131
+ content_type :json
132
+ obj.to_json
133
+ end
134
+
135
+ # Internal-only story keys never serialized to clients.
136
+ HIDDEN_KEYS = %i[dir declared_status].freeze
137
+
138
+ def public_story(story)
139
+ story.reject { |k, _| HIDDEN_KEYS.include?(k) }
140
+ end
141
+
142
+ def stories_json(c)
143
+ # with_reverse_links adds the derived `blocks`/`linked_from` ends so the
144
+ # board can show both sides of a relationship the frontmatter only stores
145
+ # once (a file-based tracker can't write the far story).
146
+ stories = c.with_reverse_links(c.all_stories)
147
+ # Decisions citing each story (BT-148) - the reverse of the record's
148
+ # stories: key, derived here for the same reason blocks/linked_from
149
+ # are: a file-based tracker stores each relation on one side only.
150
+ cited = Hash.new { |h, k| h[k] = [] }
151
+ if c.config.decisions_root
152
+ c.decisions_board.each { |r| r[:stories].each { |sid| cited[sid] << r[:id] } }
153
+ end
154
+ stories = stories.map { |st| st.merge(decisions: cited[st[:id]]) }
155
+ by_stage = stories.group_by { |s| s[:stage] }
156
+ # id => backlog position, so the backlog sort is O(1) per lookup rather
157
+ # than order.index's O(n) scan inside sort_by (O(n^2) on the column) (BT-094).
158
+ order_pos = c.backlog_ids.each_with_index.to_h
159
+
160
+ # Sort on the trailing number, not the first digit run - a namespace
161
+ # that itself contains a digit (e.g. "B2B") would otherwise key every
162
+ # story on that digit and collapse the column ordering (BT-104).
163
+ by_number = ->(s) { (s[:id][/(\d+)\z/] || '0').to_i }
164
+ icebox = (by_stage['1_icebox'] || []).sort_by(&by_number)
165
+ backlog = (by_stage['2_backlog'] || []).sort_by { |s| order_pos[s[:id]] || 9999 }
166
+ started = (by_stage['3_started'] || []).sort_by(&by_number)
167
+ done = (by_stage['4_done'] || []).sort_by(&by_number).reverse
168
+
169
+ json({
170
+ meta: { tracker_root: c.config.tracker_root, backlog_path: c.config.backlog_path },
171
+ icebox: icebox.map { |s| public_story(s) },
172
+ backlog: backlog.map { |s| public_story(s) },
173
+ started: started.map { |s| public_story(s) },
174
+ done: done.map { |s| public_story(s) }
175
+ })
176
+ end
177
+ end
178
+
179
+ # ── Static client assets (served globally; the JS uses window.BT_API_BASE
180
+ # for API calls, so one copy works in both single and dashboard mode) ──
181
+ get '/decisions.js' do
182
+ content_type 'application/javascript'
183
+ DECISIONS_JS
184
+ end
185
+
186
+ get '/docs.js' do
187
+ content_type 'application/javascript'
188
+ DOCS_JS
189
+ end
190
+
191
+ get '/app.js' do
192
+ content_type 'application/javascript'
193
+ APP_JS
194
+ end
195
+
196
+ get '/theme.js' do
197
+ content_type 'application/javascript'
198
+ THEME_JS
199
+ end
200
+
201
+ get '/logic.js' do
202
+ content_type 'application/javascript'
203
+ LOGIC_JS
204
+ end
205
+
206
+ # ── Dashboard index ────────────────────────────────────────────────────────
207
+
208
+ get '/' do
209
+ if self.class.dashboard_mode?
210
+ @project_stats = self.class.bt_dashboard.project_stats
211
+ erb :dashboard
212
+ else
213
+ @namespace = resolve_core.config.namespace
214
+ @api_base = ''
215
+ erb :index
216
+ end
217
+ end
218
+
219
+ # ── Per-project board (dashboard mode) ───────────────────────────────────
220
+
221
+ get '/projects/:slug' do
222
+ slug = params[:slug]
223
+ proj = self.class.bt_dashboard&.projects&.find { |p| p.slug == slug }
224
+ halt 404, 'Project not found' unless proj
225
+ @namespace = proj.namespace
226
+ @project_name = proj.name
227
+ @api_base = "/projects/#{slug}"
228
+ @show_back = true
229
+ erb :index
230
+ end
231
+
232
+ # ── Stats (consumed by BaconTrackerMenu) ─────────────────────────────────
233
+
234
+ get '/api/stats' do
235
+ if self.class.dashboard_mode?
236
+ json(self.class.bt_dashboard.project_stats)
237
+ else
238
+ c = resolve_core
239
+ s = c.stats
240
+ # slug is nil in single mode (there is no /projects/:slug board) but
241
+ # the key is always present so both modes share one response shape.
242
+ json([s.merge(name: c.config.namespace, namespace: c.config.namespace, slug: nil)])
243
+ end
244
+ end
245
+
246
+ # ── Reveal in the file manager ───────────────────────────────────────────
247
+
248
+ scoped('/api/reveal').each do |pat|
249
+ post pat do
250
+ raw = json_body['path'].to_s
251
+ halt 400, json({ error: 'path required' }) if raw.empty?
252
+ path = File.expand_path(raw)
253
+ # Scope to THIS request's project only - resolve_core honors :slug in
254
+ # dashboard mode, so one project's reveal endpoint can't reach another
255
+ # project's files (BT-085). A project now has more than one root
256
+ # (BT-ADR-0016), so the test is "inside ANY of this project's roots" -
257
+ # widened, not loosened. The separator matters: without it, a root of
258
+ # /a/tracker would also admit /a/tracker-evil.
259
+ roots = resolve_core.config.roots
260
+ inside = roots.any? { |r| path == r || path.start_with?(r + File::SEPARATOR) }
261
+ halt 400, json({ error: 'path is outside tracked directories' }) unless inside
262
+ target = File.exist?(path) ? path : File.dirname(path)
263
+ begin
264
+ Launcher.run(Launcher.reveal_argv(target))
265
+ rescue Launcher::Unavailable => e
266
+ halt 501, json({ error: e.message })
267
+ end
268
+ json({ ok: true })
269
+ end
270
+ end
271
+
272
+ # ── Stories API ───────────────────────────────────────────────────────────
273
+
274
+ scoped('/api/stories').each do |pat|
275
+ get pat do
276
+ stories_json(resolve_core)
277
+ end
278
+
279
+ post pat do
280
+ data = json_body
281
+ story = resolve_core.create_story(data['type'], data['title'],
282
+ stage: data['stage'] || '1_icebox',
283
+ size: data['size'])
284
+ status 201
285
+ json(public_story(story)) # full Story incl. path, matching GET/PUT (BT-089)
286
+ rescue ArgumentError => e
287
+ status 400; json({ error: e.message })
288
+ end
289
+ end
290
+
291
+ scoped('/api/stories/backlog/order').each do |pat|
292
+ put pat do
293
+ resolve_core.backlog_reorder(json_body['ids'])
294
+ json({ ok: true })
295
+ rescue ArgumentError, TypeError => e
296
+ status 400; json({ error: e.message })
297
+ end
298
+ end
299
+
300
+ scoped('/api/stories/:id/stage').each do |pat|
301
+ put pat do
302
+ resolve_core.set_stage(params[:id], json_body['stage'])
303
+ json({ ok: true })
304
+ rescue ArgumentError => e
305
+ status 400; json({ error: e.message })
306
+ end
307
+ end
308
+
309
+ # ── Docs surface (BT-138; the browser itself is BT-139) ─────────────────
310
+ scoped('/docs').each do |pat|
311
+ get pat do
312
+ core = resolve_core
313
+ halt 404, 'no docs surface' unless core.config.docs_root && Dir.exist?(core.config.docs_root)
314
+
315
+ @doc_stats = core.docs_stats
316
+ @doc_title = if self.class.dashboard_mode?
317
+ self.class.bt_dashboard.projects.find { |p| p.slug == params[:slug] }&.name
318
+ end || core.config.namespace
319
+ @doc_slug = params[:slug]
320
+ erb :docs
321
+ end
322
+ end
323
+
324
+ scoped('/api/docs/search').each do |pat|
325
+ get pat do
326
+ json resolve_core.docs_search(params[:q])
327
+ end
328
+ end
329
+
330
+ scoped('/api/docs/recent').each do |pat|
331
+ get pat do
332
+ json resolve_core.docs_recent
333
+ end
334
+ end
335
+
336
+ scoped('/api/docs/backlinks').each do |pat|
337
+ get pat do
338
+ json resolve_core.docs_backlinks(params[:path].to_s)
339
+ end
340
+ end
341
+
342
+ scoped('/api/docs/front').each do |pat|
343
+ get pat do
344
+ json resolve_core.project_front
345
+ end
346
+ end
347
+
348
+ scoped('/api/docs/tree').each do |pat|
349
+ get pat do
350
+ json resolve_core.docs_tree
351
+ end
352
+ end
353
+
354
+ scoped('/api/docs/page').each do |pat|
355
+ get pat do
356
+ json resolve_core.render_page(params[:path])
357
+ rescue ArgumentError => e
358
+ status 400
359
+ json({ error: e.message })
360
+ end
361
+ end
362
+
363
+ # Act on the real file (BT-142, BT-143). Same scope check as the page
364
+ # read; the launch mirrors /api/reveal - fire and detach, and a missing
365
+ # launcher is a 501 with the reason rather than a false ok.
366
+ %w[editor reveal].each do |action|
367
+ scoped("/api/docs/#{action}").each do |pat|
368
+ post pat do
369
+ target = resolve_core.docs_file!(json_body['path'].to_s, allow_dir: action == 'reveal')
370
+ argv = action == 'editor' ? Launcher.open_argv(target) : Launcher.reveal_argv(target)
371
+ begin
372
+ Launcher.run(argv)
373
+ rescue Launcher::Unavailable => e
374
+ halt 501, json({ error: e.message })
375
+ end
376
+ json({ ok: true })
377
+ rescue ArgumentError => e
378
+ status 400
379
+ json({ error: e.message })
380
+ end
381
+ end
382
+ end
383
+
384
+ scoped('/api/decisions/proposed/order').each do |pat|
385
+ put pat do
386
+ resolve_core.proposed_reorder(json_body['ids'])
387
+ json({ ok: true })
388
+ rescue ArgumentError => e
389
+ status 400
390
+ json({ error: e.message })
391
+ end
392
+ end
393
+
394
+ scoped('/api/decisions').each do |pat|
395
+ get pat do
396
+ json resolve_core.decisions_board
397
+ end
398
+
399
+ post pat do
400
+ path = resolve_core.create_decision(json_body['title'])
401
+ json({ ok: true, path: File.basename(path) })
402
+ rescue ArgumentError => e
403
+ status 400
404
+ json({ error: e.message })
405
+ end
406
+ end
407
+
408
+ scoped('/docs/decisions').each do |pat|
409
+ get pat do
410
+ core = resolve_core
411
+ halt 404, 'no decisions' unless core.config.decisions_root && Dir.exist?(core.config.decisions_root)
412
+
413
+ @doc_title = if self.class.dashboard_mode?
414
+ self.class.bt_dashboard.projects.find { |p| p.slug == params[:slug] }&.name
415
+ end || core.config.namespace
416
+ @doc_slug = params[:slug]
417
+ erb :decisions
418
+ end
419
+ end
420
+
421
+ # ── Decisions API (BT-135 / BT-ADR-0017) ─────────────────────────────────
422
+ # One transition endpoint mirroring the stage route. Validation is the
423
+ # primitive's: a missing or unresolvable supersede target is a 400, never a
424
+ # 200 with a partial write.
425
+ scoped('/api/decisions/:id/status').each do |pat|
426
+ put pat do
427
+ body = json_body
428
+ begin
429
+ result = resolve_core.set_status(params[:id], body['status'].to_s,
430
+ superseded_by: body['superseded_by'])
431
+ json result
432
+ rescue ArgumentError => e
433
+ halt 400, json({ error: e.message })
434
+ end
435
+ end
436
+ end
437
+
438
+ scoped('/api/stories/:id/subtasks').each do |pat|
439
+ put pat do
440
+ data = json_body
441
+ counts = resolve_core.toggle_subtask(params[:id], data['index'], data['done'])
442
+ json({ ok: true, subtasks: counts })
443
+ rescue ArgumentError => e
444
+ status 400; json({ error: e.message })
445
+ end
446
+ end
447
+
448
+ scoped('/api/stories/:id').each do |pat|
449
+ put pat do
450
+ data = json_body
451
+ core = resolve_core
452
+ core.update_story(params[:id], title: data['title'], body: data['body'],
453
+ size: data['size'], blocked_by: data['blocked_by'],
454
+ linked_to: data['linked_to'], assignee: data['assignee'])
455
+ # Return the re-parsed story so the client gets fresh derived fields
456
+ # (subtasks, subtask_lines, path after a rename) without a refetch. The
457
+ # re-read runs after update_story's lock released, so guard against the
458
+ # story having been deleted/renamed out from under us - a nil here would
459
+ # otherwise NoMethodError into a 500 instead of a clean 404 (BT-080).
460
+ result = core.find_story(params[:id])
461
+ story = result && core.parse_story_file(result[:file], result)
462
+ halt 404, json({ error: "Story #{params[:id]} not found." }) unless story
463
+ json(public_story(story))
464
+ rescue ArgumentError => e
465
+ status 400; json({ error: e.message })
466
+ end
467
+
468
+ delete pat do
469
+ resolve_core.delete_story(params[:id])
470
+ json({ ok: true })
471
+ rescue ArgumentError => e
472
+ status 400; json({ error: e.message })
473
+ end
474
+ end
475
+ end
476
+ end