merge: maestro/tsk_GXDoc3Cs07Rn [tsk_nI2T8qpcoier] apierr + idgen + CONVENTIONS.md

Apply changes from tsk_GXDoc3Cs07Rn (conflict rescue: original auto-merge
blocked by uncommitted changes on main; worktree was clean, no code conflicts).

Changes applied verbatim from b64c002:
- server/CONVENTIONS.md: new file — mandatory coding conventions
- server/internal/apierr: add New(), StatusFor(), ErrUnauthorized/Forbidden/
  NotFound/Conflict predefined errors, chi Middleware; add apierr_test.go (8 tests)
- server/internal/idgen: implement idgen.go (UUID v7 + Crockford Base32 moved
  from codes); add idgen_test.go (12 tests)
- server/internal/codes/generator.go: refactor to delegate to idgen.*
- server/go.mod: move google/uuid from indirect to direct dependency

All tests pass: go test ./internal/apierr/... ./internal/idgen/... ./internal/codes/...

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 14:45:46 +08:00
parent 787151245e
commit 25e0204471
9 changed files with 1064 additions and 178 deletions
+206
View File
@@ -0,0 +1,206 @@
// Package idgen generates application-level identifiers.
// For most entities it wraps github.com/google/uuid (v7 time-ordered UUIDs).
// For activation codes it produces 16-character Crockford Base32 strings
// with a check digit, suitable for display and human entry.
package idgen
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
// ─────────────────────────────────────────────────────────────────────────────
// UUID v7 — time-ordered identifiers for database entities
// ─────────────────────────────────────────────────────────────────────────────
// New returns a new UUID v7 (time-ordered, random node bits).
// UUID v7 is the recommended identifier format for database entities; its
// time-ordered structure improves B-tree index locality compared with UUID v4.
// Panics only if the system's crypto/rand source is unavailable (a fatal
// misconfiguration; panic is appropriate).
func New() uuid.UUID {
id, err := uuid.NewV7()
if err != nil {
panic("idgen: crypto/rand unavailable: " + err.Error())
}
return id
}
// NewString returns a new UUID v7 as a canonical lower-case string
// (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).
func NewString() string {
return New().String()
}
// ─────────────────────────────────────────────────────────────────────────────
// Crockford Base32 — human-friendly activation codes
// ─────────────────────────────────────────────────────────────────────────────
// crockfordAlphabet is the 32-symbol encoding alphabet (excludes I, L, O, U
// to prevent visual confusion with 1, 1, 0, and V respectively).
const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
// crockfordCheck is the extended 37-symbol check-character alphabet used for
// the Crockford mod-37 check symbol. Symbols 031 match crockfordAlphabet;
// symbols 3236 are *, ~, $, =, U.
const crockfordCheck = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U"
// crockfordDecode maps every printable ASCII character to its Crockford
// numeric value (031), or to -1 if the character is not valid.
// Normalisation rules per the Crockford spec are applied at init time:
//
// I, i, l, L → 1
// O, o → 0
var crockfordDecode [128]int8
func init() {
for i := range crockfordDecode {
crockfordDecode[i] = -1
}
for i, ch := range crockfordAlphabet {
crockfordDecode[ch] = int8(i)
// Accept lower-case equivalents.
if ch >= 'A' && ch <= 'Z' {
crockfordDecode[ch-'A'+'a'] = int8(i)
}
}
// Normalisation: I/i/l/L → 1; O/o → 0.
crockfordDecode['I'] = crockfordDecode['1']
crockfordDecode['i'] = crockfordDecode['1']
crockfordDecode['l'] = crockfordDecode['1']
crockfordDecode['L'] = crockfordDecode['1']
crockfordDecode['O'] = crockfordDecode['0']
crockfordDecode['o'] = crockfordDecode['0']
}
// computeCheckValue computes the Crockford mod-37 check value (Horner's method)
// of the first 15 characters of s (which must already be in canonical form).
// Returns -1 if any character is invalid.
func computeCheckValue(s string) int {
result := 0
for i := 0; i < 15; i++ {
ch := s[i]
if ch >= 128 {
return -1
}
v := int(crockfordDecode[ch])
if v < 0 {
return -1
}
result = (result*32 + v) % 37
}
return result
}
// validateCheckChar verifies that canonical[15] is the correct Crockford
// mod-37 check symbol for canonical[0:15].
func validateCheckChar(canonical string) error {
if len(canonical) != 16 {
return errors.New("idgen: invalid length for check validation")
}
expected := computeCheckValue(canonical)
if expected < 0 {
return errors.New("idgen: invalid data characters in code")
}
want := rune(crockfordCheck[expected])
got := rune(canonical[15])
if got != want {
return fmt.Errorf("idgen: check character mismatch: want %c, got %c", want, got)
}
return nil
}
// CanonicalizeCode converts an activation-code string into its canonical form:
// uppercase, with I/L→1 and O→0 substitutions applied, with the check
// character validated. Hyphens and spaces are stripped before validation
// (supports the common XXXX-XXXX-XXXX-XXXX display format).
//
// Returns an error if the string contains characters outside the normalised
// Crockford alphabet, if the length (after stripping) is not exactly 16, or
// if the check character is incorrect.
func CanonicalizeCode(code string) (string, error) {
code = strings.TrimSpace(code)
code = strings.ReplaceAll(code, "-", "")
code = strings.ReplaceAll(code, " ", "")
if len(code) != 16 {
return "", fmt.Errorf("idgen: code must be exactly 16 characters, got %d", len(code))
}
var buf [16]byte
for i := 0; i < 16; i++ {
ch := code[i]
if ch >= 128 {
return "", fmt.Errorf("idgen: non-ASCII character at position %d", i)
}
v := crockfordDecode[ch]
if v < 0 {
if i < 15 {
return "", fmt.Errorf("idgen: invalid character %q at position %d", ch, i)
}
// Position 15 is the check character; validate separately below.
buf[i] = []byte(strings.ToUpper(string(ch)))[0]
continue
}
buf[i] = crockfordAlphabet[v]
}
canonical := string(buf[:])
if err := validateCheckChar(canonical); err != nil {
return "", err
}
return canonical, nil
}
// HashCode returns the hex-encoded SHA-256 digest of the canonical plaintext
// activation code. This digest is the value stored in the database; the
// plaintext code itself must never be persisted or logged.
func HashCode(canonical string) string {
sum := sha256.Sum256([]byte(canonical))
return hex.EncodeToString(sum[:])
}
// GenerateCode generates a single random activation code in canonical
// Crockford Base32 form: 15 random data characters followed by 1 mod-37
// check character (16 characters total). Uses crypto/rand for
// cryptographically-secure randomness.
//
// To avoid modular bias, the function uses rejection sampling: random bytes
// in [0, 224) are accepted (224 = 7×32 ensures a uniform distribution over
// the 32-symbol alphabet), bytes ≥ 224 are discarded.
func GenerateCode() (string, error) {
const dataLen = 15
var buf [dataLen]byte
i := 0
for i < dataLen {
// Over-read to reduce crypto/rand syscall count.
var tmp [dataLen * 2]byte
if _, err := rand.Read(tmp[:]); err != nil {
return "", fmt.Errorf("idgen: crypto/rand: %w", err)
}
for _, b := range tmp {
if b < 224 { // accept range: 224 = 7×32
buf[i] = crockfordAlphabet[b%32]
i++
if i == dataLen {
break
}
}
}
}
data := string(buf[:])
// Compute check value over the 15 data chars (the dummy 16th char is ignored).
checkVal := computeCheckValue(data + "0")
if checkVal < 0 {
// Cannot happen: all buf[i] values come from crockfordAlphabet.
return "", errors.New("idgen: internal check computation error")
}
return data + string(crockfordCheck[checkVal]), nil
}