Commit e0445db
block 1: critical ship-blockers (upload cap, auth hardening, bounded workq, scoped search) (#60)
* feat(api): cap multipart upload size via MaxBytesReader
New server.max_upload_bytes config (default 100 MiB, env
DOCSIQ_SERVER_MAX_UPLOAD_BYTES). Upload handler wraps request body
with http.MaxBytesReader before ParseMultipartForm so oversized
requests terminate at the transport with 413 instead of allocating
memory or temp files.
Closes the P2-1 TODO in handlers.go.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address Task 1 code-review feedback
- Drop double WriteHeader in *http.MaxBytesError branch
(MaxBytesReader already committed the 413)
- Test asserts JSON contract + Content-Type header
- New TestUploadMaxBytes_UnknownContentLength covers the
chunked/unknown-length slow path
- Extract writeTooLarge helper (DRY the 413 body)
- Comment limit<=0 escape hatch on the config field
- slog.Warn on oversize reject (matches repo convention)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(serve): refuse insecure default on non-loopback bind
validateServeSecurity fails startup when server.api_key is empty AND
server.host is not loopback. Loopback+empty emits a prominent slog
warning instead. Closes the "auth-disabled-by-default" ship-blocker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(serve): address Task 2 code-review feedback
- Refuse empty server.host (binds all interfaces, not loopback)
- Use net.ParseIP().IsLoopback() to accept [::1], 127.0.0.0/8,
and ::ffff:127.0.0.1 as loopback
- Table-driven tests cover new hosts + empty-string case
- Assert DOCSIQ_SERVER_API_KEY is named in the refusal message
- gofmt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(workq): bounded worker pool with graceful drain
New internal/workq package. Pool has fixed worker count and a
bounded submission queue; Submit is non-blocking and returns
ErrQueueFull when saturated. Close(ctx) cancels the pool context
and waits for workers to drain within the caller's deadline.
Preparing to replace fire-and-forget upload indexing goroutine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(workq): guard Submit against send-on-closed-channel race
RWMutex guards the jobs-channel send in Submit against a concurrent
Close that calls close(p.jobs). Previously a Submit that passed the
p.closed check could be preempted and then send on an already-closed
channel. Add a stress test (50 iterations x 32 concurrent Submits +
Close) to exercise the race under `-race`.
Modernize the iteration count in TestPool_CloseDrainsInflight to
Go 1.22's for-range-N form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(api): submit upload indexing through workq with graceful drain
Upload handler now submits the indexing closure to a bounded workq
Pool instead of spawning a detached goroutine. When the queue is full
the handler returns 503 with Retry-After: 30. On shutdown, cmd/serve
calls pool.Close with a 30s deadline so in-flight jobs honour the
cancelled context and partial writes are avoided.
Config: server.workq_workers (default NumCPU), server.workq_depth
(default 64).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address Task 4 code-review follow-ups
- upload(): set progress to rejected state before writing 503
when workq.Submit returns ErrQueueFull or ErrClosed, so
progress polling reflects the rejection instead of stale
"queued" forever.
- upload_workq_test.go: defer close(block) after defer
pool.Close so drain completes even if the test panics.
Also add started-channel sync and fill both channel slots
(capacity = Workers+QueueDepth = 2) so saturation is
deterministic under the race detector.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(api): silence unused-parameter lint in upload_workq_test
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(api): session cookie path alongside bearer auth
New POST /api/session exchanges an Authorization: Bearer key for a
docsiq_session httpOnly; Secure; SameSite=Strict cookie (30-day
Max-Age). DELETE /api/session clears it. Auth middleware now accepts
either the Authorization header or the cookie; /api/session itself is
public (it is the auth boundary).
Preparing to stop injecting the key into served HTML.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): defense-in-depth symmetry for session auth
- bearerAuthMiddleware: explicitly reject when server.api_key
is unset, matching newSessionHandler's guard. Previously
correct only via the no_token branch firing first; fragile
under refactor.
- extractToken: trim whitespace from the session cookie value
for parity with the Authorization header path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ui): cookie-based auth; stop reading bearer from DOM
apiFetch now sends credentials: 'include' on every request; the
Authorization header is no longer attached. On boot, initAuth
exchanges the legacy meta-tag bearer (if present) for a cookie via
POST /api/session exactly once and scrubs the meta tag. Production
installs that already ship cookies out-of-band (via `docsiq login`)
skip the exchange entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ui): surface session-exchange failures, scrub meta tag first
- Check res.ok on POST /api/session. Non-2xx rejects sessionReady
so the UI sees a single clear auth-boundary failure instead of
a cascade of generic 401s on subsequent fetches.
- Scrub the legacy meta tag BEFORE awaiting the fetch. The bearer
is already captured as a closure param; leaving the tag in the
DOM during a slow/hung exchange widens the disclosure window.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): stop injecting API key into served HTML
The SPA handler no longer rewrites index.html to embed the bearer in
a <meta name="docsiq-api-key"> tag. Browser auth now flows through
the docsiq_session cookie set by POST /api/session. Closes the
"API-key-in-HTML" ship-blocker.
The stale spa_meta_test.go asserted the injection was present and
is replaced by spa_test.go which asserts the tag is absent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* perf(search): scope entity fetch to top-hit docs in local search
Replaces the full-table AllEntities scan with EntitiesForDocs, which
joins the entities → relationships tables and filters by
relationships.doc_id with a chunked IN-list (chunk size 900, below
SQLite's 999 variable limit). Local search now scales sub-linearly
with corpus size.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ui): drop legacy meta-tag read in MCP hook, use cookie auth
useMCP had its own getBearer() reading the docsiq-api-key meta tag
and attaching Authorization to /mcp requests — a parallel path that
Tasks 5-7 missed. Now the hook sends credentials: 'include' and
relies on the docsiq_session cookie, matching apiFetch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>1 parent 2a7028d commit e0445db
21 files changed
Lines changed: 1117 additions & 99 deletions
File tree
- cmd
- internal
- api
- config
- search
- store
- workq
- ui/src
- hooks/api
- lib
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| 12 | + | |
| 13 | + | |
12 | 14 | | |
13 | 15 | | |
14 | 16 | | |
15 | 17 | | |
| 18 | + | |
16 | 19 | | |
17 | 20 | | |
18 | 21 | | |
19 | 22 | | |
20 | 23 | | |
| 24 | + | |
21 | 25 | | |
22 | 26 | | |
23 | 27 | | |
| |||
140 | 144 | | |
141 | 145 | | |
142 | 146 | | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
143 | 157 | | |
144 | 158 | | |
145 | 159 | | |
| 160 | + | |
146 | 161 | | |
147 | 162 | | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
148 | 166 | | |
149 | 167 | | |
150 | 168 | | |
| |||
177 | 195 | | |
178 | 196 | | |
179 | 197 | | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
180 | 209 | | |
181 | 210 | | |
182 | 211 | | |
| |||
187 | 216 | | |
188 | 217 | | |
189 | 218 | | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
50 | 50 | | |
51 | 51 | | |
52 | 52 | | |
53 | | - | |
54 | | - | |
55 | | - | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
56 | 71 | | |
57 | 72 | | |
58 | 73 | | |
59 | | - | |
| 74 | + | |
60 | 75 | | |
61 | 76 | | |
62 | 77 | | |
63 | | - | |
64 | 78 | | |
65 | 79 | | |
66 | 80 | | |
| |||
82 | 96 | | |
83 | 97 | | |
84 | 98 | | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
515 | 515 | | |
516 | 516 | | |
517 | 517 | | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
518 | 541 | | |
519 | 542 | | |
520 | 543 | | |
| |||
0 commit comments