feat(npm): streampub error taxonomy (S11 Step 13, part 1/8)
📦 What this MR does
First of an 8-MR stack that splits the streampub streaming publish-envelope library (S11 Step 13) into independently reviewable parts. When the top of the stack merges, the resulting tree reproduces the single-MR reference implementation in merge request !420, except for the intentional improvements listed under Deviations below.
This is Part 1/8, targeting main (the bottom of the stack). Parts 2–8 chain on top of it; GitLab retargets each child to main as its parent merges, so the stack merges bottom-up.
This part lands the error taxonomy in isolation:
error.go— the typed*ProcessError(Code/Error/Unwrap/newError) plus the two sentinels and spec codes referenced first (bad_request,publish_envelope_invalid).Error()returns only the spec code, never the wrapped cause, so attacker-controlled input (tar entry names, attachment keys) cannot leak through anything that formats the error.docs/plans/2026-05-11-npm-local.md— corrects the Step 8 depguard scope (theformat-isolationdeny rule prefix-matchesinternal/format/, so it needs a targeted allow-rule for thestreampubsubpackage) and the Naming Conventions row.
The remaining sentinels and spec codes land in later parts as their first real caller appears — the unused linter forbids landing an unexported sentinel before something references it.
🚩 Deviations from the reference (!420 (closed))
Review of this isolated part surfaced three error.go API improvements that the reference does not have. They are applied here (and propagated through Parts 2–8), so the final stack tree differs from !420 (closed) by exactly these:
- Error type renamed
Error→ProcessError— descriptiveXxxErrorconvention, fixed before the type propagates through the rest of the stack. CodeStrfield unexported →code— a single access path throughCode(); callers can no longer read or overwrite it directly.Wrappedfield unexported →wrapped— reachable only viaUnwrap()(errors.Is/errors.As), soError()'s suppression of attacker-controlled input is structural rather than opt-in: no caller can read the cause as a bare field and format it into a log line or response.
AbortOffset stays exported (the publish handler reads it; it carries an offset, not client input). Aside from these three changes and the Step 13 plan Status-table row (added by the top of the stack), the merged stack equals !420 (closed).
Review follow-up (98122db5) corrected three code comments left stale by the rename above — two in error.go and one in error_test.go that still named the pre-rename *Error / Wrapped, plus one that inaccurately said the wrapped cause is reachable "via %w" (Error() never %w-wraps it; only Unwrap reaches it). Comment-only; completes the rename and adds no new behavioral deviation.
🧪 Tests
error_test.go is throwaway scaffolding, deleted by Part 2/8. error.go's unexported helpers and sentinels have no in-package caller until the tokenizer lands, so this file drives them directly to satisfy the unused and err113 linters. Part 2/8 introduces the walker and a black-box suite that exercises *ProcessError through Process, and removes this file. The file header says so explicitly.
golangci-lint(repo-pinned v2.12) → 0 issuesgo test -race -count=1 ./internal/format/npm/streampub/...→ pass
🔗 References
- Reference MR (read-only): !420 (closed)
- Related work item: #131 (closed) (not closed by this MR)