diff --git a/PROGRESS.md b/PROGRESS.md
index 0996631..4a6c111 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -8,3 +8,4 @@
- Phase 5 (admin): customer CRUD wizard with per-system encrypted credentials + test connection (jira/ado/youtrack/demo connectors), user management (roles, disable w/ session revoke, force-reset, delete, self-lockout guards), runtime settings doc, audit log on every privileged mutation + list API, service-status panel (mongo/atomizer/WP health + latency, late-bound breaker/sync/jobs providers), tabbed admin UI - tests green. Fixed mongod WT_PANIC: container nofile ulimit was 1024, raised to 64000 in compose.
- Phase 6 (sync workers): per-customer pollers with reconcile loop (start/stop/interval changes), §5.3 idempotent upsert keyed (system,key,customerId) w/ content-hash change detection, upstream edits refresh only while imported (timeline+notification after), attachment caching to GridFS, orphan flagging (never deletes), admin sync-now trigger + worker statuses, default budget prefill — demo-type integration tests green.
- Phase 7 (atomization): circuit breaker (3 fails→open, 60s half-open probe) + retrying bearer JSON client shared by both external services, atomizer client w/ §5.1 coefficient normalization (±0.001 ok, ≤0.05 renormalized, else 502-class), persisted jobs queue (panic-safe, backoff ×3, stale requeue, restart-safe), subdivide/extend endpoints (202 async, re-run replaces unpublished children after confirm), task editing w/ bounty recompute + optional budget cascade, publish single/bulk, WS hub (origin check, 30s heartbeats) + live board, consultant atomization board UI (tree, sliders, sum indicator, modals, shimmer, health gating) — unit + integration green. Fixed tasks unique index: sparse→partial (sparse compound matched every task via customerId).
+- Phase 8 (bounty board + lifecycle): developer board (pool-scoped, filters/search/sort, stale age badges, hide-competing-claims setting, saved filters), claim/withdraw/decline/approve, assign-to-AI via §5.2 client + HMAC-verified idempotent callback w/ artifact ingestion into GridFS, start/submit/abandon/unassign, comments (sanitized, @mentions→notifications), time log, review queue + per-AC checklist on timeline, immutable bountyAwards on approve (humans only), notifications center + bell + WS toasts, my-tasks kanban, task detail page, pool management UI, server-side HTML sanitizer w/ XSS vector tests — full lifecycle integration tests green.
diff --git a/cmd/app/main.go b/cmd/app/main.go
index 3c17fc1..411010e 100644
--- a/cmd/app/main.go
+++ b/cmd/app/main.go
@@ -24,6 +24,7 @@ import (
"bountyboard/internal/metrics"
"bountyboard/internal/store"
syncpkg "bountyboard/internal/sync"
+ "bountyboard/internal/workperform"
"bountyboard/internal/ws"
)
@@ -98,6 +99,12 @@ func run() error {
srv.SetAtomizerInfo(atomClient)
srv.AddReadinessCheck(httpx.ReadinessCheck{Name: "atomizer", Probe: atomClient.Healthy})
+ // Work Performer client — fully independent service (§5).
+ wpClient := workperform.New(func() string { return cfg.WorkPerformerBaseURL },
+ cfg.WorkPerformerToken, cfg.WorkPerformerHTTPTimeout)
+ srv.SetPerformerClient(wpClient)
+ srv.AddReadinessCheck(httpx.ReadinessCheck{Name: "work-performer", Probe: wpClient.Healthy})
+
// Background job queue + atomization handlers.
runner := jobs.NewRunner(st, log, reg, 4)
atomSvc := atomize.NewService(st, atomClient, log, cfg.AppBaseURL,
diff --git a/internal/chat/sanitize.go b/internal/chat/sanitize.go
new file mode 100644
index 0000000..a4c609a
--- /dev/null
+++ b/internal/chat/sanitize.go
@@ -0,0 +1,168 @@
+// Package chat implements messaging primitives. sanitize.go is the
+// server-side rich-text whitelist sanitizer (§2.2): allowed tags are
+// b i u s a code pre ul ol li br p blockquote; only keeps an attribute
+// (href, http/https/mailto only). Everything else is escaped as text.
+// Implemented with a small hand-written tokenizer — no parser dependency.
+package chat
+
+import (
+ "html"
+ "strings"
+)
+
+var allowedTags = map[string]bool{
+ "b": true, "i": true, "u": true, "s": true, "a": true, "code": true,
+ "pre": true, "ul": true, "ol": true, "li": true, "br": true, "p": true,
+ "blockquote": true,
+}
+
+var voidTags = map[string]bool{"br": true}
+
+// SanitizeHTML rewrites untrusted rich text into safe HTML. Unknown tags are
+// dropped (their text content kept), attributes are stripped (except a safe
+// href on ), tags are re-balanced so output never leaks open elements.
+func SanitizeHTML(in string) string {
+ var out strings.Builder
+ var stack []string
+
+ i := 0
+ for i < len(in) {
+ c := in[i]
+ if c != '<' {
+ j := strings.IndexByte(in[i:], '<')
+ if j < 0 {
+ out.WriteString(html.EscapeString(in[i:]))
+ break
+ }
+ out.WriteString(html.EscapeString(in[i : i+j]))
+ i += j
+ continue
+ }
+ // find the end of the tag
+ end := strings.IndexByte(in[i:], '>')
+ if end < 0 {
+ out.WriteString(html.EscapeString(in[i:]))
+ break
+ }
+ raw := in[i+1 : i+end] // between < and >
+ i += end + 1
+
+ closing := strings.HasPrefix(raw, "/")
+ raw = strings.TrimPrefix(raw, "/")
+ raw = strings.TrimSuffix(strings.TrimSpace(raw), "/")
+ name, attrs := splitTag(raw)
+ name = strings.ToLower(name)
+
+ if !allowedTags[name] {
+ continue // drop the tag entirely, keep surrounding text
+ }
+ if closing {
+ // close up to the matching open tag, if any
+ for n := len(stack) - 1; n >= 0; n-- {
+ if stack[n] == name {
+ for len(stack) > n {
+ top := stack[len(stack)-1]
+ stack = stack[:len(stack)-1]
+ out.WriteString("" + top + ">")
+ }
+ break
+ }
+ }
+ continue
+ }
+ if voidTags[name] {
+ out.WriteString("<" + name + ">")
+ continue
+ }
+ if name == "a" {
+ href := safeHref(attrs)
+ if href == "" {
+ out.WriteString("")
+ } else {
+ out.WriteString(``)
+ }
+ } else {
+ out.WriteString("<" + name + ">")
+ }
+ stack = append(stack, name)
+ }
+ // balance anything left open
+ for n := len(stack) - 1; n >= 0; n-- {
+ out.WriteString("" + stack[n] + ">")
+ }
+ return out.String()
+}
+
+// splitTag separates the tag name from its raw attribute string.
+func splitTag(raw string) (string, string) {
+ for i := 0; i < len(raw); i++ {
+ switch raw[i] {
+ case ' ', '\t', '\n', '\r':
+ return raw[:i], raw[i+1:]
+ }
+ }
+ return raw, ""
+}
+
+// safeHref extracts href and accepts only http, https, or mailto schemes.
+func safeHref(attrs string) string {
+ lower := strings.ToLower(attrs)
+ idx := strings.Index(lower, "href")
+ if idx < 0 {
+ return ""
+ }
+ rest := strings.TrimSpace(attrs[idx+4:])
+ if !strings.HasPrefix(rest, "=") {
+ return ""
+ }
+ rest = strings.TrimSpace(rest[1:])
+ var val string
+ switch {
+ case strings.HasPrefix(rest, `"`):
+ if end := strings.IndexByte(rest[1:], '"'); end >= 0 {
+ val = rest[1 : 1+end]
+ }
+ case strings.HasPrefix(rest, "'"):
+ if end := strings.IndexByte(rest[1:], '\''); end >= 0 {
+ val = rest[1 : 1+end]
+ }
+ default:
+ val = rest
+ if sp := strings.IndexAny(val, " \t\n"); sp >= 0 {
+ val = val[:sp]
+ }
+ }
+ val = strings.TrimSpace(val)
+ low := strings.ToLower(val)
+ if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") ||
+ strings.HasPrefix(low, "mailto:") {
+ return val
+ }
+ return ""
+}
+
+// SanitizePlain strips ALL tags, returning escaped plain text (used where
+// rich text is not allowed).
+func SanitizePlain(in string) string {
+ var out strings.Builder
+ i := 0
+ for i < len(in) {
+ if in[i] != '<' {
+ j := strings.IndexByte(in[i:], '<')
+ if j < 0 {
+ out.WriteString(html.EscapeString(in[i:]))
+ break
+ }
+ out.WriteString(html.EscapeString(in[i : i+j]))
+ i += j
+ continue
+ }
+ end := strings.IndexByte(in[i:], '>')
+ if end < 0 {
+ out.WriteString(html.EscapeString(in[i:]))
+ break
+ }
+ i += end + 1
+ }
+ return out.String()
+}
diff --git a/internal/chat/sanitize_test.go b/internal/chat/sanitize_test.go
new file mode 100644
index 0000000..c4d157d
--- /dev/null
+++ b/internal/chat/sanitize_test.go
@@ -0,0 +1,79 @@
+package chat
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestSanitizeHTML(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want string
+ }{
+ {"plain text escaped", `hello & "friends"`, "hello & "friends""},
+ {"allowed formatting kept", "bold it u s", "bold it u s"},
+ {"paragraph and lists", "a
", "a
"},
+ {"code and pre", "x := 1
", "x := 1
"},
+ {"blockquote", "q
", "q
"},
+ {"br void", "a
b
c", "a
b
c"},
+ {"script stripped", `hi`, "alert(1)hi"},
+ {"img dropped", `
safe`, "safe"},
+ {"event handlers stripped from allowed tag", `x`, "x"},
+ {"style attr stripped", `x
`, "x
"},
+ {"https link kept with rel", `l`,
+ `l`},
+ {"http link kept", `l`,
+ `l`},
+ {"javascript href removed", `l`, "l"},
+ {"data href removed", `l`, "l"},
+ {"single-quoted js href removed", `l`, "l"},
+ {"unquoted href", `l`,
+ `l`},
+ {"unclosed tags balanced", "bold both", "bold both"},
+ {"stray close ignored", "text", "text"},
+ {"mismatched nesting closed in order", "x", "x"},
+ {"case insensitive tags", "x", "xy"},
+ {"nested quotes attack", `">x`,
+ `alert(1)">x`},
+ {"incomplete tag escaped", "a alert(1)`,
+ `
`,
+ `