feat(authkeys): labels on managed API keys applied to every request#467
Conversation
Managed API keys can now carry labels. Every request authenticated with a labelled key gets those labels merged (deduplicated) with any labels the header-based tagging rules already extracted, so they flow into usage analytics, the request log label filter, and audit log entries. - AuthKey/CreateInput gain a labels field; labels JSON/JSONB column on all three storage backends with idempotent migrations - auth middleware merges key labels into the request-label context; the audit middleware re-reads labels post-auth since the entry snapshots them before authentication runs - labels are editable without rotating the key via PUT /admin/auth-keys/:id/labels (empty list clears); the service patches its in-memory snapshot immediately so the serving instance applies changes to the next request - dashboard: labels input on Create API Key, label chips column, and an Edit Labels modal on the API Keys page - new docs page docs/features/labelling.mdx covering header tagging and key labels; registered in docs.json Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The label column encode/decode that usage duplicated per backend now lives in sqlutil (goccy-backed, added for auth key labels), replacing marshalLabels and the inline reader unmarshal blocks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… per click lucide.createIcons() replaces <i data-lucide> placeholders with new svg nodes, but Alpine's x-if cleanup only removes nodes it inserted itself, so each theme toggle orphaned the previous icon and added another. The toggle now uses inline SVGs (the copy-button pattern), which Alpine can insert and remove cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAdds label support for auth keys end to end: shared merge and JSON helpers, storage and admin API updates, dashboard editing, authentication/audit propagation, usage log serialization changes, and new documentation. ChangesAuth key labels end-to-end
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant AuthMiddleware
participant AuthKeysService
participant AuditMiddleware
participant RequestContext
Client->>AuthMiddleware: authenticated request
AuthMiddleware->>AuthKeysService: Authenticate(token)
AuthKeysService-->>AuthMiddleware: AuthenticationResult(Labels, UserPath)
AuthMiddleware->>RequestContext: applyAuthKeyResult()
AuthMiddleware->>RequestContext: merge request labels and key labels
AuthMiddleware->>AuditMiddleware: continue request
AuditMiddleware->>RequestContext: RequestLabelsFromContext()
AuditMiddleware->>AuditMiddleware: update entry.Data.Labels
sequenceDiagram
participant AdminUI
participant AdminHandler
participant AuthKeysService
participant Store
AdminUI->>AdminHandler: PUT /admin/auth-keys/:id/labels
AdminHandler->>AuthKeysService: UpdateLabels(id, labels)
AuthKeysService->>AuthKeysService: MergeLabels(labels)
AuthKeysService->>Store: UpdateLabels(id, labels, now)
Store-->>AuthKeysService: ok or ErrNotFound
AuthKeysService->>AuthKeysService: applyLabelsUpdate()
AuthKeysService-->>AdminHandler: updated View
AdminHandler-->>AdminUI: 200 JSON or 404
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/admin/dashboard/static/js/modules/auth-keys.js`:
- Around line 281-370: In submitAuthKeyLabelsEditor, the current ordering leaves
a redundant editor.submitting reset after closeAuthKeyLabelsEditor replaces
authKeyLabelsEditor with a new object. Simplify the submit/close flow by
clearing submitting in the active editor state only once, then closing the
editor, and avoid mutating the stale local editor reference in the finally
block. Keep the existing behavior in openAuthKeyLabelsEditor,
closeAuthKeyLabelsEditor, and submitAuthKeyLabelsEditor unchanged aside from
this cleanup.
In `@internal/authkeys/store_mongodb.go`:
- Around line 99-112: Update MongoDBStore.UpdateLabels so clearing labels
removes the field instead of storing a null value: when labels is empty or nil,
use an $unset update for labels alongside the updated_at $set, and keep the
existing UpdateOne/ErrNotFound handling. Otherwise, continue setting labels
normally, using the UpdateLabels method and mongoAuthKeyIDFilter to locate the
document.
In `@internal/core/labels.go`:
- Around line 8-30: `MergeLabels` currently only trims and deduplicates, but it
should also enforce safe bounds before labels reach audit logs and usage
queries. Update the normalization chokepoint in `internal/core/labels.go` so
`MergeLabels` rejects or truncates overly long labels, caps the number of labels
per key, and optionally constrains allowed characters; keep the existing
trim/dedupe behavior and ensure both `Create` and `UpdateLabels` inherit the new
limits through this function.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 2e0b9f26-3e9a-429e-8643-bd203608d3e3
📒 Files selected for processing (32)
CLAUDE.mddocs/docs.jsondocs/features/labelling.mdxinternal/admin/dashboard/static/css/dashboard.cssinternal/admin/dashboard/static/js/modules/auth-keys.jsinternal/admin/dashboard/static/js/modules/auth-keys.test.cjsinternal/admin/dashboard/templates/page-auth-keys.htmlinternal/admin/dashboard/templates/sidebar.htmlinternal/admin/handler_authkeys.gointernal/admin/handler_authkeys_test.gointernal/admin/routes.gointernal/admin/routes_test.gointernal/auditlog/middleware.gointernal/auditlog/middleware_test.gointernal/authkeys/service.gointernal/authkeys/service_test.gointernal/authkeys/store.gointernal/authkeys/store_mongodb.gointernal/authkeys/store_postgresql.gointernal/authkeys/store_sqlite.gointernal/authkeys/store_sqlite_test.gointernal/authkeys/types.gointernal/core/labels.gointernal/core/labels_test.gointernal/server/auth.gointernal/server/auth_test.gointernal/storage/sqlutil/sqlutil.gointernal/storage/sqlutil/sqlutil_test.gointernal/usage/reader_postgresql.gointernal/usage/reader_sqlite.gointernal/usage/store_postgresql.gointernal/usage/store_sqlite.go
| openAuthKeyLabelsEditor(key) { | ||
| if (!key || this.authKeyLabelsEditor.submitting) { | ||
| return; | ||
| } | ||
| this.authKeyLabelsEditor = { | ||
| open: true, | ||
| id: key.id, | ||
| name: key.name || '', | ||
| value: (key.labels || []).join(', '), | ||
| submitting: false, | ||
| error: '' | ||
| }; | ||
| }, | ||
|
|
||
| closeAuthKeyLabelsEditor() { | ||
| if (!this.authKeyLabelsEditor.open || this.authKeyLabelsEditor.submitting) { | ||
| return; | ||
| } | ||
| this.authKeyLabelsEditor = { | ||
| open: false, | ||
| id: '', | ||
| name: '', | ||
| value: '', | ||
| submitting: false, | ||
| error: '' | ||
| }; | ||
| }, | ||
|
|
||
| async submitAuthKeyLabelsEditor() { | ||
| const editor = this.authKeyLabelsEditor; | ||
| if (!editor.open || editor.submitting || !editor.id) { | ||
| return; | ||
| } | ||
| editor.submitting = true; | ||
| editor.error = ''; | ||
| this.authKeyNotice = ''; | ||
| const payload = { labels: this.parseAuthKeyLabels(editor.value) }; | ||
|
|
||
| try { | ||
| const request = typeof this.requestOptions === 'function' | ||
| ? this.requestOptions({ | ||
| method: 'PUT', | ||
| body: JSON.stringify(payload) | ||
| }) | ||
| : { | ||
| method: 'PUT', | ||
| headers: this.headers(), | ||
| body: JSON.stringify(payload) | ||
| }; | ||
| const res = await fetch('/admin/auth-keys/' + encodeURIComponent(editor.id) + '/labels', request); | ||
| if (res.status === 503) { | ||
| this.authKeysAvailable = false; | ||
| editor.error = 'Auth keys feature is unavailable.'; | ||
| return; | ||
| } | ||
| if (typeof this.handleFetchResponse === 'function') { | ||
| const handled = this.handleFetchResponse(res, 'update API key labels', request); | ||
| if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { | ||
| return; | ||
| } | ||
| if (!handled) { | ||
| if (res.status === 401) { | ||
| editor.error = 'Authentication required.'; | ||
| return; | ||
| } | ||
| editor.error = await this._authKeyResponseMessage(res, 'Failed to update labels.'); | ||
| console.error('Failed to update auth key labels:', res.status, res.statusText, editor.error); | ||
| return; | ||
| } | ||
| } else if (res.status === 401) { | ||
| this.authError = true; | ||
| this.needsAuth = true; | ||
| editor.error = 'Authentication required.'; | ||
| return; | ||
| } else if (res.status !== 200) { | ||
| editor.error = await this._authKeyResponseMessage(res, 'Failed to update labels.'); | ||
| console.error('Failed to update auth key labels:', res.status, res.statusText, editor.error); | ||
| return; | ||
| } | ||
| await this.fetchAuthKeys(); | ||
| this.authKeyNotice = 'Labels updated for key "' + editor.name + '".'; | ||
| editor.submitting = false; | ||
| this.closeAuthKeyLabelsEditor(); | ||
| } catch (e) { | ||
| console.error('Failed to update auth key labels:', e); | ||
| editor.error = 'Failed to update labels.'; | ||
| } finally { | ||
| editor.submitting = false; | ||
| } | ||
| }, |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
Redundant submitting reset after editor is closed.
Line 362 sets editor.submitting = false before closeAuthKeyLabelsEditor() reassigns this.authKeyLabelsEditor to a fresh object; the finally block at Line 368 then mutates the now-orphaned old editor reference. Harmless today, but worth simplifying so the intent (why submitting must be cleared before close) is clearer to future readers.
♻️ Simplify submit/close ordering
- await this.fetchAuthKeys();
- this.authKeyNotice = 'Labels updated for key "' + editor.name + '".';
- editor.submitting = false;
- this.closeAuthKeyLabelsEditor();
+ await this.fetchAuthKeys();
+ const notice = 'Labels updated for key "' + editor.name + '".';
+ editor.submitting = false;
+ this.closeAuthKeyLabelsEditor();
+ this.authKeyNotice = notice;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| openAuthKeyLabelsEditor(key) { | |
| if (!key || this.authKeyLabelsEditor.submitting) { | |
| return; | |
| } | |
| this.authKeyLabelsEditor = { | |
| open: true, | |
| id: key.id, | |
| name: key.name || '', | |
| value: (key.labels || []).join(', '), | |
| submitting: false, | |
| error: '' | |
| }; | |
| }, | |
| closeAuthKeyLabelsEditor() { | |
| if (!this.authKeyLabelsEditor.open || this.authKeyLabelsEditor.submitting) { | |
| return; | |
| } | |
| this.authKeyLabelsEditor = { | |
| open: false, | |
| id: '', | |
| name: '', | |
| value: '', | |
| submitting: false, | |
| error: '' | |
| }; | |
| }, | |
| async submitAuthKeyLabelsEditor() { | |
| const editor = this.authKeyLabelsEditor; | |
| if (!editor.open || editor.submitting || !editor.id) { | |
| return; | |
| } | |
| editor.submitting = true; | |
| editor.error = ''; | |
| this.authKeyNotice = ''; | |
| const payload = { labels: this.parseAuthKeyLabels(editor.value) }; | |
| try { | |
| const request = typeof this.requestOptions === 'function' | |
| ? this.requestOptions({ | |
| method: 'PUT', | |
| body: JSON.stringify(payload) | |
| }) | |
| : { | |
| method: 'PUT', | |
| headers: this.headers(), | |
| body: JSON.stringify(payload) | |
| }; | |
| const res = await fetch('/admin/auth-keys/' + encodeURIComponent(editor.id) + '/labels', request); | |
| if (res.status === 503) { | |
| this.authKeysAvailable = false; | |
| editor.error = 'Auth keys feature is unavailable.'; | |
| return; | |
| } | |
| if (typeof this.handleFetchResponse === 'function') { | |
| const handled = this.handleFetchResponse(res, 'update API key labels', request); | |
| if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { | |
| return; | |
| } | |
| if (!handled) { | |
| if (res.status === 401) { | |
| editor.error = 'Authentication required.'; | |
| return; | |
| } | |
| editor.error = await this._authKeyResponseMessage(res, 'Failed to update labels.'); | |
| console.error('Failed to update auth key labels:', res.status, res.statusText, editor.error); | |
| return; | |
| } | |
| } else if (res.status === 401) { | |
| this.authError = true; | |
| this.needsAuth = true; | |
| editor.error = 'Authentication required.'; | |
| return; | |
| } else if (res.status !== 200) { | |
| editor.error = await this._authKeyResponseMessage(res, 'Failed to update labels.'); | |
| console.error('Failed to update auth key labels:', res.status, res.statusText, editor.error); | |
| return; | |
| } | |
| await this.fetchAuthKeys(); | |
| this.authKeyNotice = 'Labels updated for key "' + editor.name + '".'; | |
| editor.submitting = false; | |
| this.closeAuthKeyLabelsEditor(); | |
| } catch (e) { | |
| console.error('Failed to update auth key labels:', e); | |
| editor.error = 'Failed to update labels.'; | |
| } finally { | |
| editor.submitting = false; | |
| } | |
| }, | |
| openAuthKeyLabelsEditor(key) { | |
| if (!key || this.authKeyLabelsEditor.submitting) { | |
| return; | |
| } | |
| this.authKeyLabelsEditor = { | |
| open: true, | |
| id: key.id, | |
| name: key.name || '', | |
| value: (key.labels || []).join(', '), | |
| submitting: false, | |
| error: '' | |
| }; | |
| }, | |
| closeAuthKeyLabelsEditor() { | |
| if (!this.authKeyLabelsEditor.open || this.authKeyLabelsEditor.submitting) { | |
| return; | |
| } | |
| this.authKeyLabelsEditor = { | |
| open: false, | |
| id: '', | |
| name: '', | |
| value: '', | |
| submitting: false, | |
| error: '' | |
| }; | |
| }, | |
| async submitAuthKeyLabelsEditor() { | |
| const editor = this.authKeyLabelsEditor; | |
| if (!editor.open || editor.submitting || !editor.id) { | |
| return; | |
| } | |
| editor.submitting = true; | |
| editor.error = ''; | |
| this.authKeyNotice = ''; | |
| const payload = { labels: this.parseAuthKeyLabels(editor.value) }; | |
| try { | |
| const request = typeof this.requestOptions === 'function' | |
| ? this.requestOptions({ | |
| method: 'PUT', | |
| body: JSON.stringify(payload) | |
| }) | |
| : { | |
| method: 'PUT', | |
| headers: this.headers(), | |
| body: JSON.stringify(payload) | |
| }; | |
| const res = await fetch('/admin/auth-keys/' + encodeURIComponent(editor.id) + '/labels', request); | |
| if (res.status === 503) { | |
| this.authKeysAvailable = false; | |
| editor.error = 'Auth keys feature is unavailable.'; | |
| return; | |
| } | |
| if (typeof this.handleFetchResponse === 'function') { | |
| const handled = this.handleFetchResponse(res, 'update API key labels', request); | |
| if (typeof this.isStaleAuthFetchResult === 'function' && this.isStaleAuthFetchResult(handled)) { | |
| return; | |
| } | |
| if (!handled) { | |
| if (res.status === 401) { | |
| editor.error = 'Authentication required.'; | |
| return; | |
| } | |
| editor.error = await this._authKeyResponseMessage(res, 'Failed to update labels.'); | |
| console.error('Failed to update auth key labels:', res.status, res.statusText, editor.error); | |
| return; | |
| } | |
| } else if (res.status === 401) { | |
| this.authError = true; | |
| this.needsAuth = true; | |
| editor.error = 'Authentication required.'; | |
| return; | |
| } else if (res.status !== 200) { | |
| editor.error = await this._authKeyResponseMessage(res, 'Failed to update labels.'); | |
| console.error('Failed to update auth key labels:', res.status, res.statusText, editor.error); | |
| return; | |
| } | |
| await this.fetchAuthKeys(); | |
| const notice = 'Labels updated for key "' + editor.name + '".'; | |
| editor.submitting = false; | |
| this.closeAuthKeyLabelsEditor(); | |
| this.authKeyNotice = notice; | |
| } catch (e) { | |
| console.error('Failed to update auth key labels:', e); | |
| editor.error = 'Failed to update labels.'; | |
| } finally { | |
| editor.submitting = false; | |
| } | |
| }, |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/admin/dashboard/static/js/modules/auth-keys.js` around lines 281 -
370, In submitAuthKeyLabelsEditor, the current ordering leaves a redundant
editor.submitting reset after closeAuthKeyLabelsEditor replaces
authKeyLabelsEditor with a new object. Simplify the submit/close flow by
clearing submitting in the active editor state only once, then closing the
editor, and avoid mutating the stale local editor reference in the finally
block. Keep the existing behavior in openAuthKeyLabelsEditor,
closeAuthKeyLabelsEditor, and submitAuthKeyLabelsEditor unchanged aside from
this cleanup.
| // MergeLabels combines label sets in order into one list, trimming whitespace | ||
| // and dropping empty values and duplicates. Returns nil when nothing remains. | ||
| func MergeLabels(sets ...[]string) []string { | ||
| var merged []string | ||
| var seen map[string]struct{} | ||
| for _, set := range sets { | ||
| for _, label := range set { | ||
| label = strings.TrimSpace(label) | ||
| if label == "" { | ||
| continue | ||
| } | ||
| if seen == nil { | ||
| seen = make(map[string]struct{}) | ||
| } | ||
| if _, dup := seen[label]; dup { | ||
| continue | ||
| } | ||
| seen[label] = struct{}{} | ||
| merged = append(merged, label) | ||
| } | ||
| } | ||
| return merged | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win
Consider bounding label length/count.
MergeLabels trims and dedups but places no limit on label length, count, or character set. Since labels now flow into audit log entries, request-log filters, and usage-by-label breakdowns (per PR objective), unbounded free-text labels create risk of PII leakage into logs, log/query bloat, or abuse via very large label sets on a single key. Consider enforcing a reasonable max length per label and a max count per key here (the single normalization chokepoint used by both Create and UpdateLabels).
🛡️ Example bound
+const (
+ maxLabelLength = 64
+ maxLabelCount = 20
+)
+
func MergeLabels(sets ...[]string) []string {
var merged []string
var seen map[string]struct{}
for _, set := range sets {
for _, label := range set {
label = strings.TrimSpace(label)
if label == "" {
continue
}
+ if len(label) > maxLabelLength {
+ label = label[:maxLabelLength]
+ }
if seen == nil {
seen = make(map[string]struct{})
}
if _, dup := seen[label]; dup {
continue
}
seen[label] = struct{}{}
merged = append(merged, label)
+ if len(merged) >= maxLabelCount {
+ return merged
+ }
}
}
return merged
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // MergeLabels combines label sets in order into one list, trimming whitespace | |
| // and dropping empty values and duplicates. Returns nil when nothing remains. | |
| func MergeLabels(sets ...[]string) []string { | |
| var merged []string | |
| var seen map[string]struct{} | |
| for _, set := range sets { | |
| for _, label := range set { | |
| label = strings.TrimSpace(label) | |
| if label == "" { | |
| continue | |
| } | |
| if seen == nil { | |
| seen = make(map[string]struct{}) | |
| } | |
| if _, dup := seen[label]; dup { | |
| continue | |
| } | |
| seen[label] = struct{}{} | |
| merged = append(merged, label) | |
| } | |
| } | |
| return merged | |
| } | |
| const ( | |
| maxLabelLength = 64 | |
| maxLabelCount = 20 | |
| ) | |
| // MergeLabels combines label sets in order into one list, trimming whitespace | |
| // and dropping empty values and duplicates. Returns nil when nothing remains. | |
| func MergeLabels(sets ...[]string) []string { | |
| var merged []string | |
| var seen map[string]struct{} | |
| for _, set := range sets { | |
| for _, label := range set { | |
| label = strings.TrimSpace(label) | |
| if label == "" { | |
| continue | |
| } | |
| if len(label) > maxLabelLength { | |
| label = label[:maxLabelLength] | |
| } | |
| if seen == nil { | |
| seen = make(map[string]struct{}) | |
| } | |
| if _, dup := seen[label]; dup { | |
| continue | |
| } | |
| seen[label] = struct{}{} | |
| merged = append(merged, label) | |
| if len(merged) >= maxLabelCount { | |
| return merged | |
| } | |
| } | |
| } | |
| return merged | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/core/labels.go` around lines 8 - 30, `MergeLabels` currently only
trims and deduplicates, but it should also enforce safe bounds before labels
reach audit logs and usage queries. Update the normalization chokepoint in
`internal/core/labels.go` so `MergeLabels` rejects or truncates overly long
labels, caps the number of labels per key, and optionally constrains allowed
characters; keep the existing trim/dedupe behavior and ensure both `Create` and
`UpdateLabels` inherit the new limits through this function.
Keeps cleared keys shaped like keys created without labels, whose insert path omits the field via omitempty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Managed API keys can now carry labels. Every request authenticated with a labelled key gets those labels attached automatically — merged and de-duplicated with any labels the header-based tagging rules already extracted — so per-key traffic shows up in the usage by-label breakdown, the request log label filter, and audit log entries (
data.labels) with zero client changes. Issue each team/service its own labelled key and attribution is done.User-visible changes
Admin API
POST /admin/auth-keysaccepts"labels": ["team-a", "batch"](normalized: trimmed, de-duplicated); list/create responses include them.PUT /admin/auth-keys/:id/labelsreplaces a key's labels without rotating the key;[]clears them; 404 on unknown id; returns the updated key view.Dashboard
Docs
docs/features/labelling.mdxcovering both header-based tagging and API key labels (config.yaml, env vars,do_not_pass, update semantics, where labels surface); registered in docs.json. CLAUDE.md tagging reference updated.Implementation notes
labelsJSON/JSONB column onauth_keysacross SQLite/PostgreSQL/MongoDB with idempotent migrations (same pattern asuser_path).core.MergeLabelshelper; all existing usage/audit recorders pick them up unchanged. The audit middleware re-reads labels post-auth (applyAuthentication) because the entry snapshots them before authentication runs.sqlutil.NullableJSONStrings/StringsFromJSON(goccy-backed).createIcons()replaces<i data-lucide>nodes, breaking Alpine'sx-ifcleanup when the placeholder is the template root. The toggle now uses inline SVGs.Test plan
go test ./...green; pre-commit race tests, lint, hot-path perf guard, and mint docs validation pass.TAGGING_HEADER_1rule, sent a chat request with an extra header label → audit entry recorded["from-header","team-a","batch"](cross-source dedup);PUT .../labelsthen an immediate request carried the new labels; clear → NULL column; unknown id → 404.🤖 Generated with Claude Code
Summary by CodeRabbit